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_heading(&mut self, heading: &str) {
1147 let Some(ta) = self.backend.as_textarea_mut() else {
1148 return;
1149 };
1150 if markdown_edits::jump_to_heading(ta, heading) {
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 handle_mouse(
1613 &mut self,
1614 mouse: &ratatui::crossterm::event::MouseEvent,
1615 tx: &AppTx,
1616 ) -> EventState {
1617 if !self.covers(mouse.column, mouse.row) {
1618 return EventState::NotConsumed;
1619 }
1620 let r = self.rect;
1623 self.interrupt_typing();
1627 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right))
1631 && self.selection.is_none_or(|(start, end)| start == end)
1632 {
1633 self.wants_context_menu = true;
1634 return EventState::Consumed;
1635 }
1636 if !self.backend.is_textarea() {
1640 return EventState::NotConsumed;
1641 }
1642 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
1644 self.copy_selection_to_clipboard(tx);
1645 self.sync_highlight();
1646 return EventState::Consumed;
1647 }
1648 if matches!(mouse.kind, MouseEventKind::Drag(_)) {
1652 let (lrow, lcol) = self
1653 .view
1654 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1655 match self.drag_origin {
1656 Some(origin)
1657 if self
1658 .backend
1659 .select_dragged(origin, (lrow as usize, lcol as usize)) =>
1660 {
1661 self.sync_highlight();
1662 return EventState::Consumed;
1663 }
1664 None if self.backend.is_vim() => return EventState::Consumed,
1665 _ => {}
1666 }
1667 }
1668 if matches!(mouse.kind, MouseEventKind::Up(_)) {
1670 self.drag_origin = None;
1671 }
1672 let Some(ta) = self.backend.as_textarea_mut() else {
1674 unreachable!()
1675 };
1676 match mouse.kind {
1677 MouseEventKind::Down(_) => {
1678 self.view.follow_cursor();
1679 ta.cancel_selection();
1680 let (lrow, lcol) = self
1681 .view
1682 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1683 ta.jump_to(lrow as usize, lcol as usize);
1684 ta.start_selection();
1685 self.drag_origin = Some(ta.cursor());
1686 }
1687 MouseEventKind::Drag(_) => {
1688 let (lrow, lcol) = self
1689 .view
1690 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1691 ta.jump_to(lrow as usize, lcol as usize);
1692 }
1693 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
1696 let delta = if mouse.kind == MouseEventKind::ScrollUp {
1697 -WHEEL_SCROLL_ROWS
1698 } else {
1699 WHEEL_SCROLL_ROWS
1700 };
1701 if self.view.scroll_by(delta)
1704 && let Some(c) = self.autocomplete.as_mut()
1705 {
1706 c.close();
1707 }
1708 }
1709 _ => {}
1712 }
1713 self.selection = ta.selection_range();
1714 EventState::Consumed
1717 }
1718}
1719
1720impl Component for TextEditorComponent {
1725 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
1726 self.maybe_recover_from_dead_nvim();
1727 self.bind_autocomplete_redraw(tx);
1728
1729 match event {
1730 InputEvent::Key(key) => {
1731 self.view.follow_cursor();
1735 let popup_open = self.autocomplete.as_ref().is_some_and(|c| c.is_open());
1743 if popup_open
1744 && let Some(host) = build_editor_host_snapshot(
1745 &self.backend,
1746 self.revs.current(),
1747 self.view.last_cursor_screen,
1748 )
1749 && let Some(controller) = self.autocomplete.as_mut()
1750 {
1751 match controller.handle_key(*key, &host) {
1752 HandleKeyOutcome::Accepted(action) => {
1753 self.interrupt_typing();
1754 if let Some(ta) = self.backend.as_textarea_mut() {
1755 ta.edit(|ta| apply_accept_to_textarea(ta, &action));
1756 }
1757 self.after_edit();
1758 return EventState::Consumed;
1759 }
1760 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
1761 return EventState::Consumed;
1762 }
1763 HandleKeyOutcome::NotHandled => {}
1764 }
1765 }
1766 if self.dispatch_to_find_bar(key) {
1771 self.interrupt_typing();
1775 return EventState::Consumed;
1776 }
1777 if let Some(outcome) = self.backend.vim_handle_key(key) {
1782 use self::vim::VimKeyOutcome;
1783 if !matches!(outcome, VimKeyOutcome::PassThrough) {
1788 self.interrupt_typing();
1789 }
1790 self.apply_edit_outcome();
1794 match outcome {
1795 VimKeyOutcome::TextMutated => {
1796 self.selection = None;
1799 return EventState::Consumed;
1800 }
1801 VimKeyOutcome::CursorOnly => {
1802 self.sync_highlight();
1807 self.refresh_autocomplete_if_open();
1808 return EventState::Consumed;
1809 }
1810 VimKeyOutcome::NoOp => return EventState::Consumed,
1811 VimKeyOutcome::PassThrough => { }
1812 VimKeyOutcome::Host(action) => {
1813 use self::vim::VimHostAction;
1814 match action {
1815 VimHostAction::OpenPalette => {
1816 tx.send(AppEvent::ExecuteLeaderAction(
1818 crate::keys::leader::LeaderAction::Palette,
1819 ))
1820 .ok();
1821 }
1822 VimHostAction::OpenSearch { forward: _ } => {
1823 self.open_or_advance_search();
1827 }
1828 VimHostAction::SearchNext => self.search_repeat(false),
1829 VimHostAction::SearchPrev => self.search_repeat(true),
1830 VimHostAction::ClipboardCopy(text) => {
1834 self.selection = None;
1835 crate::components::yank(text, "copied", tx);
1836 }
1837 VimHostAction::ClipboardCut(text) => {
1838 self.selection = None;
1839 crate::components::yank(text, "cut", tx);
1840 }
1841 VimHostAction::ClipboardPaste => {
1847 self.selection = self
1848 .backend
1849 .as_textarea()
1850 .and_then(|ta| ta.selection_range());
1851 self.paste_from_clipboard(tx);
1852 }
1853 }
1854 return EventState::Consumed;
1855 }
1856 }
1857 }
1858 if let Some(state) = self.handle_nvim_key(key, tx) {
1859 return state;
1860 }
1861 let text_rev_before = self.revs.current();
1872 let cursor_before = self.textarea_cursor();
1873 let result = self.handle_textarea_key(key, tx);
1874 let cursor_after = self.textarea_cursor();
1875 if self.revs.current() != text_rev_before {
1876 self.sync_autocomplete();
1877 } else if cursor_before != cursor_after {
1878 self.refresh_autocomplete_if_open();
1879 }
1880 result
1881 }
1882 InputEvent::Mouse(mouse) => {
1883 let text_rev_before = self.revs.current();
1884 let cursor_before = self.textarea_cursor();
1885 let result = self.handle_mouse(mouse, tx);
1886 let cursor_after = self.textarea_cursor();
1887 if self.revs.current() != text_rev_before {
1890 self.sync_autocomplete();
1891 } else if cursor_before != cursor_after {
1892 self.refresh_autocomplete_if_open();
1893 }
1894 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
1907 self.backend.sync_mouse_selection(false);
1908 }
1909 self.sync_highlight();
1910 result
1911 }
1912 InputEvent::Paste(_) => EventState::NotConsumed,
1915 }
1916 }
1917
1918 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
1919 let bar_rows: u16 = self.search.as_ref().map_or(0, |bar| bar.rows());
1924 let bar_rows = bar_rows.min(rect.height);
1929 let (editor_rect, search_rect) = if bar_rows > 0 {
1930 (
1931 Rect {
1932 height: rect.height - bar_rows,
1933 ..rect
1934 },
1935 Some(Rect {
1936 y: rect.y + rect.height - bar_rows,
1937 height: bar_rows,
1938 ..rect
1939 }),
1940 )
1941 } else {
1942 (rect, None)
1943 };
1944 self.rect = editor_rect;
1947 let selection = match &self.backend {
1952 BackendState::Textarea(_) => match self.search.as_ref() {
1955 Some(bar) => bar.current_match(),
1956 None => self.selection,
1957 },
1958 BackendState::Nvim(nvim) => {
1959 self.nvim_host
1960 .frame_sync(nvim, editor_rect.width, editor_rect.height)
1961 }
1962 };
1963 while let Ok((generation, buf)) = self.full_parse_rx.try_recv() {
1969 self.view.install_full_parse(generation, buf);
1970 }
1971 while let Ok((generation, layout)) = self.layout_rx.try_recv() {
1975 self.view.install_full_layout(generation, layout);
1976 }
1977
1978 let overlay = match (self.search.as_ref(), self.backend.as_textarea()) {
1989 (Some(bar), Some(buf)) => bar.overlay(buf),
1990 _ => find_bar::BarOverlay::default(),
1991 };
1992 let preview = overlay.preview;
1993 let snap = snapshot_from_backend(&self.backend, self.revs.current());
1994 self.revs.adopt(snap.content_revision);
2000 let (view_lines, preview_spans) = match preview {
2004 None => (None, Vec::new()),
2005 Some(p) => (Some(p.lines), p.spans),
2006 };
2007 match &view_lines {
2008 None => self.view.update(&snap, editor_rect),
2009 Some(lines) => {
2010 let rev = preview_revision(snap.content_revision, lines);
2015 let view_snap = EditorSnapshot::borrowed(lines, snap.cursor, rev);
2016 self.view.update(&view_snap, editor_rect);
2017 }
2018 }
2019 if self.revs.needles_stale() {
2023 self.search_needles.clear();
2024 self.revs.disarm_needles();
2025 }
2026 self.view.set_needles(self.search_needles.clone());
2027
2028 let mut overlays: Vec<view::Overlay> = Vec::new();
2031 if let Some(((sr, sc), (er, ec))) = selection {
2032 for row in sr..=er {
2035 let start = if row == sr { sc } else { 0 };
2036 let end = if row == er { ec } else { usize::MAX };
2037 overlays.push(view::Overlay::new(
2038 row,
2039 start,
2040 end,
2041 view::OverlayKind::Selection,
2042 ));
2043 }
2044 }
2045 overlays.extend(preview_spans.iter().map(|p| {
2046 view::Overlay::new(
2047 p.row,
2048 p.start,
2049 p.end,
2050 if p.is_current {
2051 view::OverlayKind::PreviewCurrent
2052 } else {
2053 view::OverlayKind::Preview
2054 },
2055 )
2056 }));
2057 if view_lines.is_none() {
2060 overlays.extend(overlay.matches.iter().map(|&(row, start, end)| {
2061 view::Overlay::new(row, start, end, view::OverlayKind::Match)
2062 }));
2063 }
2064 self.view.set_overlays(overlays);
2065
2066 if let Some(generation) = self.view.take_pending_full_parse() {
2074 let text = match &view_lines {
2086 Some(lines) => crate::ropetext::Text::from(lines.join("\n").as_str()),
2087 None => snap.text.clone(),
2088 };
2089 let tx = self.full_parse_tx.clone();
2090 let redraw = self.redraw_tx.clone();
2091 self.full_parse_task.spawn(async move {
2092 let buf = ParsedBuffer::parse(&text);
2093 let _ = tx.send((generation, buf));
2094 if let Some(redraw) = redraw {
2097 let _ = redraw.send(AppEvent::Redraw);
2098 }
2099 });
2100 }
2101 if let Some(job) = self.view.take_pending_full_layout() {
2106 let tx = self.layout_tx.clone();
2107 let redraw = self.redraw_tx.clone();
2108 self.layout_task.spawn(async move {
2109 let hints = view::row_hints(&job.rendered_cache, &job.gutter_insets);
2110 let layout = crate::ropetext::Layout::compute(
2111 &job.text,
2112 job.width,
2113 crate::ropetext::Metrics::default(),
2114 &hints,
2115 );
2116 let _ = tx.send((job.generation, layout));
2117 if let Some(redraw) = redraw {
2118 let _ = redraw.send(AppEvent::Redraw);
2119 }
2120 });
2121 }
2122 let bar_focused = self.search.is_some() && focused;
2125 let editor_focused = focused && !bar_focused;
2126 use self::view::CursorShape;
2127 let cursor_shape = match self.backend.modal_is_insert() {
2128 None => None, Some(true) => Some(CursorShape::Bar),
2130 Some(false) => Some(CursorShape::Block),
2131 };
2132 self.view
2133 .render(f, editor_rect, theme, editor_focused, cursor_shape);
2134
2135 if self.revs.needles_stale() {
2139 self.search_needles.clear();
2140 self.revs.disarm_needles();
2141 }
2142
2143 if snap.text.len_bytes() == 0 && editor_rect.height > 0 {
2147 let leader = self
2148 .key_bindings
2149 .first_combo_for(&crate::keys::action_shortcuts::ActionShortcuts::Leader)
2150 .unwrap_or_else(|| "leader".to_string());
2151 f.render_widget(
2152 ratatui::widgets::Paragraph::new(format!(
2153 "Type to start · [[ to link · # to tag · {leader} for commands"
2154 ))
2155 .style(
2156 Style::default()
2157 .fg(theme.gray.to_ratatui())
2158 .add_modifier(Modifier::ITALIC),
2159 ),
2160 Rect {
2161 x: editor_rect.x.saturating_add(2),
2162 width: editor_rect.width.saturating_sub(2),
2163 height: 1,
2164 ..editor_rect
2165 },
2166 );
2167 }
2168 if let (Some(state), Some(bar_rect)) = (self.search.as_mut(), search_rect) {
2169 state.render(f, bar_rect, theme, bar_focused);
2170 }
2171
2172 self.poll_autocomplete();
2180 if let (Some(controller), Some(live_anchor)) =
2187 (self.autocomplete.as_mut(), self.view.last_cursor_screen)
2188 {
2189 if let Some(state) = controller.state_mut() {
2190 state.anchor = live_anchor;
2191 }
2192 if let Some(state) = controller.state() {
2193 autocomplete::render(f, state, editor_rect, theme);
2194 }
2195 }
2196 }
2197
2198 fn hint_shortcuts(&self) -> Vec<(String, String)> {
2199 use crate::keys::action_shortcuts::ActionShortcuts;
2200
2201 if let Some(mut label) = self.backend.mode_label() {
2206 if let Some(p) = self.backend.pending_input_hint() {
2207 label = format!("{label} {p}");
2208 }
2209 let mut hints = vec![(String::new(), label)];
2210 hints.extend(
2211 [
2212 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2213 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2214 (ActionShortcuts::FileOperations, "file ops"),
2215 ]
2216 .iter()
2217 .filter_map(|(action, label)| {
2218 self.key_bindings
2219 .first_combo_for(action)
2220 .map(|k| (k, label.to_string()))
2221 }),
2222 );
2223 return hints;
2224 }
2225
2226 let mut hints: Vec<(String, String)> = Vec::new();
2229 match self.follow_target_at_cursor() {
2230 Some(FollowTarget::Link(_)) => {
2231 if let Some(k) = self
2232 .key_bindings
2233 .first_combo_for(&ActionShortcuts::FollowLink)
2234 {
2235 hints.push((k, "follow link".to_string()));
2236 }
2237 }
2238 Some(FollowTarget::Label(_)) => {
2239 if let Some(k) = self
2240 .key_bindings
2241 .first_combo_for(&ActionShortcuts::FollowLink)
2242 {
2243 hints.push((k, "browse tag".to_string()));
2244 }
2245 }
2246 None => {}
2247 }
2248 hints.extend(crate::components::hints::hints_for(
2249 &self.key_bindings,
2250 &[
2251 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2252 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2253 (ActionShortcuts::FileOperations, "file ops"),
2254 (ActionShortcuts::FindInBuffer, "find"),
2255 ],
2256 ));
2257 hints
2258 }
2259}
2260
2261#[cfg(test)]
2262mod tests {
2263 use super::snapshot::EditorMode;
2264 use super::*;
2265 use crate::keys::KeyBindings;
2266
2267 fn make_editor() -> TextEditorComponent {
2268 TextEditorComponent::new(
2269 KeyBindings::empty(),
2270 &crate::settings::AppSettings::default(),
2271 )
2272 }
2273
2274 fn dummy_tx() -> AppTx {
2275 tokio::sync::mpsc::unbounded_channel().0
2276 }
2277
2278 fn get_ta(editor: &mut TextEditorComponent) -> &mut RopeBuffer {
2279 match &mut editor.backend {
2280 BackendState::Textarea(tb) => &mut tb.ta,
2281 _ => panic!("expected Textarea backend"),
2282 }
2283 }
2284
2285 #[test]
2286 fn has_trigger_before_cursor_finds_bracket() {
2287 assert!(has_trigger_before_cursor("hello [[foo", 11));
2288 assert!(has_trigger_before_cursor("[[a b c", 7));
2289 }
2290
2291 #[test]
2292 fn has_trigger_before_cursor_finds_hashtag() {
2293 assert!(has_trigger_before_cursor("text #tag", 9));
2294 }
2295
2296 #[test]
2297 fn has_trigger_before_cursor_no_trigger_bails() {
2298 assert!(!has_trigger_before_cursor("plain prose here", 16));
2299 assert!(!has_trigger_before_cursor("", 0));
2300 }
2301
2302 #[test]
2303 fn has_trigger_before_cursor_handles_multibyte_no_panic() {
2304 let line = "你好世界".to_string() + &"a".repeat(80);
2307 let col = line.chars().count();
2308 assert!(!has_trigger_before_cursor(&line, col));
2309
2310 let with_emoji = "🦀".repeat(20) + "[[note";
2311 let col = with_emoji.chars().count();
2312 assert!(has_trigger_before_cursor(&with_emoji, col));
2313
2314 let accented = "é".repeat(100);
2315 let col = accented.chars().count();
2316 assert!(!has_trigger_before_cursor(&accented, col));
2317 }
2318
2319 #[test]
2320 fn has_trigger_before_cursor_ignores_chars_after_cursor() {
2321 assert!(!has_trigger_before_cursor("foo [[bar", 3));
2323 }
2324
2325 #[test]
2326 fn has_trigger_before_cursor_wikilink_with_spaces() {
2327 assert!(has_trigger_before_cursor("[[my note title", 15));
2330 }
2331
2332 #[test]
2333 fn fresh_editor_is_not_dirty() {
2334 let editor = make_editor();
2335 assert!(!editor.is_dirty());
2336 }
2337
2338 #[test]
2339 fn after_set_text_not_dirty() {
2340 let mut editor = make_editor();
2341 editor.set_text("hello world".to_string());
2342 assert!(!editor.is_dirty());
2343 }
2344
2345 #[test]
2346 fn get_text_returns_loaded_content() {
2347 let mut editor = make_editor();
2348 editor.set_text("line one\nline two".to_string());
2349 assert_eq!(editor.get_text(), "line one\nline two");
2350 }
2351
2352 #[test]
2353 fn mark_saved_clears_dirty() {
2354 let mut editor = make_editor();
2355 editor.set_text("initial".to_string());
2356 let text = editor.get_text();
2357 editor.mark_saved(text.clone() + "x"); assert!(editor.is_dirty());
2359 editor.mark_saved(text); assert!(!editor.is_dirty());
2361 }
2362
2363 #[test]
2364 fn trailing_newline_does_not_cause_false_dirty() {
2365 let mut editor = make_editor();
2366 editor.set_text("content\n".to_string());
2367 assert!(
2368 !editor.is_dirty(),
2369 "trailing newline should not make editor dirty after load"
2370 );
2371 }
2372
2373 #[test]
2374 fn cursor_move_does_not_dirty_buffer() {
2375 let mut editor = make_editor();
2376 editor.set_text("hello world".to_string());
2377 assert!(!editor.is_dirty());
2378 let tx = dummy_tx();
2379 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
2382 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2383 assert!(
2384 !editor.is_dirty(),
2385 "cursor move must not mark the editor as dirty"
2386 );
2387 }
2388
2389 #[test]
2390 fn empty_stack_undo_redo_does_not_dirty_or_bump_revision() {
2391 let mut editor = make_editor();
2395 editor.set_text("foo".to_string());
2396 let rev_before = editor.content_revision();
2397 assert!(!editor.is_dirty());
2398 let tx = dummy_tx();
2399 for key_code in [KeyCode::Char('z'), KeyCode::Char('y')] {
2400 let key = ratatui::crossterm::event::KeyEvent::new(key_code, KeyModifiers::CONTROL);
2401 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2402 }
2403 assert!(
2404 !editor.is_dirty(),
2405 "empty-stack undo/redo must not flip is_dirty"
2406 );
2407 assert_eq!(
2408 editor.content_revision(),
2409 rev_before,
2410 "empty-stack undo/redo must not bump content_revision"
2411 );
2412 }
2413
2414 #[test]
2415 fn fresh_editor_content_revision_is_nonzero() {
2416 let editor = make_editor();
2423 assert!(editor.content_revision().get() >= 1);
2424 }
2425
2426 #[test]
2427 fn mouse_down_clears_selection() {
2428 let mut editor = make_editor();
2429 editor.set_text("hello world".to_string());
2430 let ta = get_ta(&mut editor);
2431 ta.start_selection();
2432 ta.move_cursor(CursorMove::WordForward);
2433 assert!(ta.selection_range().is_some());
2434 ta.cancel_selection();
2435 editor.selection = if let BackendState::Textarea(tb) = &editor.backend {
2436 tb.ta.selection_range()
2437 } else {
2438 None
2439 };
2440 assert!(editor.selection.is_none());
2441 }
2442
2443 #[test]
2444 fn ctrl_c_copies_selected_text() {
2445 let mut editor = make_editor();
2446 editor.set_text("hello world".to_string());
2447 let ta = get_ta(&mut editor);
2448 ta.move_cursor(CursorMove::Head);
2449 ta.start_selection();
2450 ta.move_cursor(CursorMove::WordForward);
2451 let range = ta.selection_range().unwrap();
2452 let ((sr, sc), (er, ec)) = range;
2453 let lines = ta.rows();
2454 let selected = if sr == er {
2455 lines[sr][sc..ec].to_string()
2456 } else {
2457 lines[sr][sc..].to_string()
2458 };
2459 assert_eq!(selected, "hello ");
2460 }
2461
2462 fn select_range(editor: &mut TextEditorComponent, start: (usize, usize), end: (usize, usize)) {
2464 let ta = get_ta(editor);
2465 ta.cancel_selection();
2466 ta.move_cursor(CursorMove::Jump(start.0, start.1));
2467 ta.start_selection();
2468 ta.move_cursor(CursorMove::Jump(end.0, end.1));
2469 assert!(ta.selection_range().is_some());
2470 }
2471
2472 fn send_char(editor: &mut TextEditorComponent, c: char) {
2473 let tx = dummy_tx();
2474 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
2475 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2476 }
2477
2478 #[test]
2479 fn typing_open_paren_with_selection_wraps_it() {
2480 let mut editor = make_editor();
2481 editor.set_text("hello world".to_string());
2482 select_range(&mut editor, (0, 0), (0, 5)); send_char(&mut editor, '(');
2484 assert_eq!(editor.get_text(), "(hello) world");
2485 assert!(editor.is_dirty(), "wrap must mark the buffer dirty");
2486 }
2487
2488 #[test]
2489 fn wrap_keeps_selection_on_inner_text() {
2490 let mut editor = make_editor();
2491 editor.set_text("hello world".to_string());
2492 select_range(&mut editor, (0, 0), (0, 5));
2493 send_char(&mut editor, '(');
2494 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2496 }
2497
2498 #[test]
2499 fn chained_brackets_build_a_wikilink() {
2500 let mut editor = make_editor();
2501 editor.set_text("my note".to_string());
2502 select_range(&mut editor, (0, 0), (0, 7));
2503 send_char(&mut editor, '[');
2504 send_char(&mut editor, '[');
2505 assert_eq!(editor.get_text(), "[[my note]]");
2506 assert_eq!(editor.selection, Some(((0, 2), (0, 9))));
2507 }
2508
2509 #[test]
2510 fn symmetric_chars_wrap_and_chain() {
2511 let mut editor = make_editor();
2512 editor.set_text("bold".to_string());
2513 select_range(&mut editor, (0, 0), (0, 4));
2514 send_char(&mut editor, '*');
2515 assert_eq!(editor.get_text(), "*bold*");
2516 send_char(&mut editor, '*');
2517 assert_eq!(editor.get_text(), "**bold**");
2518 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2519 }
2520
2521 #[test]
2522 fn closing_char_replaces_selection() {
2523 let mut editor = make_editor();
2524 editor.set_text("hello world".to_string());
2525 select_range(&mut editor, (0, 0), (0, 5));
2526 send_char(&mut editor, ')');
2527 assert_eq!(editor.get_text(), ") world");
2528 }
2529
2530 #[test]
2531 fn open_char_without_selection_inserts_normally() {
2532 let mut editor = make_editor();
2533 editor.set_text("hello".to_string());
2534 let ta = get_ta(&mut editor);
2535 ta.move_cursor(CursorMove::End);
2536 send_char(&mut editor, '(');
2537 assert_eq!(editor.get_text(), "hello(");
2538 }
2539
2540 #[test]
2541 fn text_action_keeps_selection_on_inner_text() {
2542 let mut editor = make_editor();
2545 editor.set_text("bold word".to_string());
2546 select_range(&mut editor, (0, 0), (0, 4));
2547 editor.apply_text_action(TextAction::Bold);
2548 assert_eq!(editor.get_text(), "**bold** word");
2549 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2550 }
2551
2552 #[test]
2553 fn bold_undo_is_one_step_back_to_original() {
2554 let mut editor = make_editor();
2559 editor.set_text("hello world".to_string());
2560 select_range(&mut editor, (0, 0), (0, 5));
2561 editor.apply_text_action(TextAction::Bold);
2562 assert_eq!(editor.get_text(), "**hello** world");
2563 assert!(get_ta(&mut editor).undo(), "the bold is one entry");
2564 assert_eq!(editor.get_text(), "hello world");
2565 assert!(
2566 !get_ta(&mut editor).undo(),
2567 "and has no second half left to take back"
2568 );
2569 }
2570
2571 #[test]
2572 fn linkable_url_accepts_supported_schemes() {
2573 assert_eq!(
2574 linkable_url("https://example.com"),
2575 Some("https://example.com")
2576 );
2577 assert_eq!(
2578 linkable_url("http://example.com/path?q=1#frag"),
2579 Some("http://example.com/path?q=1#frag"),
2580 );
2581 assert_eq!(
2582 linkable_url(" https://example.com "),
2583 Some("https://example.com")
2584 );
2585 assert_eq!(
2586 linkable_url("ftp://files.example.com/x"),
2587 Some("ftp://files.example.com/x"),
2588 );
2589 assert_eq!(
2590 linkable_url("ftps://files.example.com/x"),
2591 Some("ftps://files.example.com/x"),
2592 );
2593 assert_eq!(
2594 linkable_url("mailto:user@example.com"),
2595 Some("mailto:user@example.com"),
2596 );
2597 assert_eq!(
2598 linkable_url("mailto:user@example.com?subject=hi"),
2599 Some("mailto:user@example.com?subject=hi"),
2600 );
2601 }
2602
2603 #[test]
2604 fn linkable_url_rejects_other_schemes_and_plain_text() {
2605 assert_eq!(linkable_url("file:///etc/passwd"), None);
2606 assert_eq!(linkable_url("ssh://host"), None);
2607 assert_eq!(linkable_url("javascript:alert(1)"), None);
2608 assert_eq!(linkable_url("example.com"), None);
2609 assert_eq!(linkable_url("not a url"), None);
2610 assert_eq!(linkable_url(""), None);
2611 assert_eq!(linkable_url("https://example.com\nmore"), None);
2612 }
2613
2614 #[test]
2615 fn try_build_markdown_link_wraps_selection_when_clip_is_url() {
2616 assert_eq!(
2617 try_build_markdown_link("https://example.com", Some("click here")).as_deref(),
2618 Some("[click here](https://example.com)"),
2619 );
2620 }
2621
2622 #[test]
2623 fn try_build_markdown_link_trims_url_whitespace() {
2624 assert_eq!(
2625 try_build_markdown_link(" https://example.com\n", Some("link")).as_deref(),
2626 Some("[link](https://example.com)"),
2627 );
2628 }
2629
2630 #[test]
2631 fn try_build_markdown_link_returns_none_when_no_selection() {
2632 assert_eq!(try_build_markdown_link("https://example.com", None), None);
2633 }
2634
2635 #[test]
2636 fn try_build_markdown_link_returns_none_when_not_url() {
2637 assert_eq!(try_build_markdown_link("plain text", Some("sel")), None);
2638 }
2639
2640 #[test]
2641 fn try_build_markdown_link_returns_none_when_selection_empty() {
2642 assert_eq!(
2643 try_build_markdown_link("https://example.com", Some("")),
2644 None
2645 );
2646 }
2647
2648 #[test]
2649 fn try_build_markdown_link_escapes_close_bracket_in_selection() {
2650 assert_eq!(
2651 try_build_markdown_link("https://example.com", Some("a]b")).as_deref(),
2652 Some(r"[a\]b](https://example.com)"),
2653 );
2654 }
2655
2656 #[test]
2657 fn try_build_markdown_link_wraps_ftp_url() {
2658 assert_eq!(
2659 try_build_markdown_link("ftp://files.example.com/x", Some("download")).as_deref(),
2660 Some("[download](ftp://files.example.com/x)"),
2661 );
2662 }
2663
2664 fn key(code: KeyCode, mods: KeyModifiers) -> ratatui::crossterm::event::KeyEvent {
2665 ratatui::crossterm::event::KeyEvent::new(code, mods)
2666 }
2667
2668 #[test]
2670 fn search_needles_clear_on_edit() {
2671 let settings = crate::settings::AppSettings::default();
2672 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2673 ed.set_text("alpha beta".to_string());
2674 ed.set_search_needles(vec!["Alpha".to_string()]);
2675 assert_eq!(ed.search_needles, vec!["alpha"]);
2676 assert!(!ed.revs.needles_stale());
2677
2678 ed.set_text("alpha beta gamma".to_string());
2680 assert!(ed.revs.needles_stale());
2681 }
2682
2683 #[test]
2684 fn open_or_advance_search_opens_find_bar_with_empty_query() {
2685 let mut editor = make_editor();
2686 editor.set_text("hello world".to_string());
2687 editor.open_or_advance_search();
2688 let state = editor.search.as_ref().expect("find bar opened");
2689 assert!(state.input.is_empty());
2690 assert!(matches!(state.status, SearchStatus::Empty));
2691 }
2692
2693 #[test]
2694 fn open_or_advance_search_advances_when_already_open() {
2695 let mut editor = make_editor();
2696 editor.set_text("ab ab ab".to_string());
2697 let tx = dummy_tx();
2698 editor.open_or_advance_search();
2699 editor.handle_input(
2700 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
2701 &tx,
2702 );
2703 editor.handle_input(
2704 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
2705 &tx,
2706 );
2707 editor.open_or_advance_search();
2709 let (_, col) = get_ta(&mut editor).cursor();
2710 assert_eq!(col, 3, "second invocation advances to next match");
2711 }
2712
2713 #[test]
2714 fn typing_in_find_bar_jumps_cursor_to_first_match() {
2715 let mut editor = make_editor();
2716 editor.set_text("foo bar baz".to_string());
2717 let tx = dummy_tx();
2718 editor.open_or_advance_search();
2719 for ch in ['b', 'a', 'r'] {
2720 editor.handle_input(
2721 &InputEvent::Key(key(KeyCode::Char(ch), KeyModifiers::NONE)),
2722 &tx,
2723 );
2724 }
2725 let state = editor.search.as_ref().unwrap();
2726 assert_eq!(state.input.value(), "bar");
2727 assert!(matches!(state.status, SearchStatus::Match));
2728 let (_, col) = get_ta(&mut editor).cursor();
2729 assert_eq!(col, 4, "cursor jumped to start of 'bar'");
2730 }
2731
2732 #[test]
2733 fn enter_in_find_bar_advances_to_next_match() {
2734 let mut editor = make_editor();
2735 editor.set_text("ab ab ab".to_string());
2736 let tx = dummy_tx();
2737 editor.open_or_advance_search();
2738 editor.handle_input(
2739 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
2740 &tx,
2741 );
2742 editor.handle_input(
2743 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
2744 &tx,
2745 );
2746 editor.handle_input(
2748 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
2749 &tx,
2750 );
2751 let (_, col) = get_ta(&mut editor).cursor();
2752 assert_eq!(col, 3, "Enter advances to second match");
2753 }
2754
2755 #[test]
2756 fn match_is_highlighted_as_selection_after_search() {
2757 let mut editor = make_editor();
2758 editor.set_text("foo bar baz".to_string());
2759 let tx = dummy_tx();
2760 editor.open_or_advance_search();
2761 for ch in ['b', 'a', 'r'] {
2762 editor.handle_input(
2763 &InputEvent::Key(key(KeyCode::Char(ch), KeyModifiers::NONE)),
2764 &tx,
2765 );
2766 }
2767 assert_eq!(
2770 editor.search.as_ref().unwrap().current_match(),
2771 Some(((0, 4), (0, 7)))
2772 );
2773 }
2774
2775 #[test]
2776 fn no_match_clears_selection() {
2777 let mut editor = make_editor();
2778 editor.set_text("hello".to_string());
2779 let tx = dummy_tx();
2780 editor.open_or_advance_search();
2781 editor.handle_input(
2782 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::NONE)),
2783 &tx,
2784 );
2785 assert_eq!(editor.selection, None);
2786 }
2787
2788 #[test]
2789 fn esc_in_find_bar_clears_selection_highlight() {
2790 let mut editor = make_editor();
2791 editor.set_text("foo bar".to_string());
2792 let tx = dummy_tx();
2793 editor.open_or_advance_search();
2794 editor.handle_input(
2795 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
2796 &tx,
2797 );
2798 editor.handle_input(
2799 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
2800 &tx,
2801 );
2802 editor.handle_input(
2803 &InputEvent::Key(key(KeyCode::Char('r'), KeyModifiers::NONE)),
2804 &tx,
2805 );
2806 assert!(
2807 editor
2808 .search
2809 .as_ref()
2810 .is_some_and(|b| b.current_match().is_some())
2811 );
2812 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
2813 assert!(editor.search.is_none());
2815 assert!(editor.selection.is_none());
2816 }
2817
2818 #[test]
2819 fn esc_in_find_bar_closes_it() {
2820 let mut editor = make_editor();
2821 editor.set_text("hello".to_string());
2822 let tx = dummy_tx();
2823 editor.open_or_advance_search();
2824 assert!(editor.search.is_some());
2825 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
2826 assert!(editor.search.is_none());
2827 }
2828
2829 #[test]
2830 fn find_bar_consumes_typing_so_editor_text_is_unchanged() {
2831 let mut editor = make_editor();
2832 editor.set_text("hello".to_string());
2833 let tx = dummy_tx();
2834 editor.open_or_advance_search();
2835 editor.handle_input(
2836 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
2837 &tx,
2838 );
2839 assert_eq!(editor.get_text(), "hello");
2840 }
2841
2842 #[test]
2843 fn no_match_status_when_query_absent() {
2844 let mut editor = make_editor();
2845 editor.set_text("hello".to_string());
2846 let tx = dummy_tx();
2847 editor.open_or_advance_search();
2848 editor.handle_input(
2849 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::NONE)),
2850 &tx,
2851 );
2852 let state = editor.search.as_ref().unwrap();
2853 assert!(matches!(state.status, SearchStatus::NoMatch));
2854 }
2855
2856 #[test]
2857 fn try_build_markdown_link_wraps_mailto_url() {
2858 assert_eq!(
2859 try_build_markdown_link("mailto:user@example.com", Some("email me")).as_deref(),
2860 Some("[email me](mailto:user@example.com)"),
2861 );
2862 }
2863
2864 #[test]
2865 fn insert_at_cursor_appends_text() {
2866 let mut editor = make_editor();
2867 editor.set_text("hello".to_string());
2868 {
2869 let ta = get_ta(&mut editor);
2870 ta.move_cursor(CursorMove::End);
2871 }
2872 editor.insert_at_cursor(" world", &dummy_tx());
2873 assert_eq!(editor.get_text(), "hello world");
2874 }
2875
2876 #[test]
2877 fn insert_at_cursor_replaces_selection() {
2878 let mut editor = make_editor();
2879 editor.set_text("hello world".to_string());
2880 {
2881 let ta = get_ta(&mut editor);
2882 ta.move_cursor(CursorMove::Head);
2883 ta.start_selection();
2884 ta.move_cursor(CursorMove::WordForward);
2885 }
2886 editor.insert_at_cursor("HEY ", &dummy_tx());
2887 assert_eq!(editor.get_text(), "HEY world");
2888 }
2889
2890 #[test]
2891 fn paste_inserts_text_at_cursor() {
2892 let mut editor = make_editor();
2893 editor.set_text("hello".to_string());
2894 let ta = get_ta(&mut editor);
2895 ta.move_cursor(CursorMove::End);
2896 ta.insert_str(" world");
2897 assert_eq!(editor.get_text(), "hello world");
2898 }
2899
2900 #[test]
2901 fn a_selection_ending_at_column_zero_leaves_that_row_alone() {
2902 let mut editor = make_editor();
2903 editor.set_text("foo\nbar\nbaz".to_string());
2904 select_range(&mut editor, (0, 0), (2, 0));
2905 editor.indent_lines(false);
2906 assert_eq!(get_ta(&mut editor).rows(), &[" foo", " bar", "baz"]);
2907 assert_eq!(editor.selection, Some(((0, 4), (2, 0))));
2908 }
2909
2910 #[test]
2911 fn unsupported_text_action_is_noop() {
2912 let mut editor = make_editor();
2913 editor.set_text("hello".to_string());
2914 editor.apply_text_action(TextAction::Underline);
2915 assert_eq!(editor.get_text(), "hello");
2916 }
2917
2918 #[test]
2919 fn bold_action_with_no_selection_inserts_a_pair_through_the_component() {
2920 let mut editor = make_editor();
2923 editor.set_text("hello".to_string());
2924 {
2925 let ta = get_ta(&mut editor);
2926 ta.move_cursor(CursorMove::End);
2927 }
2928 editor.apply_text_action(TextAction::Bold);
2929 assert_eq!(editor.get_text(), "hello****");
2930 assert_eq!(get_ta(&mut editor).cursor(), (0, 7));
2931 assert!(editor.is_dirty(), "the insert is an edit");
2932 }
2933
2934 #[test]
2935 fn jump_to_heading_moves_the_view_cursor() {
2936 let mut ed = make_editor();
2938 ed.set_text("intro\n# Top\nbody\n## Sub One\nmore\n".to_string());
2939 ed.jump_to_heading("Sub One");
2940 assert_eq!(ed.view_snapshot().cursor.0, 3);
2941 ed.jump_to_heading("Top");
2942 assert_eq!(ed.view_snapshot().cursor.0, 1);
2943 select_range(&mut ed, (2, 1), (2, 1));
2946 ed.jump_to_heading("Top");
2947 assert_eq!(ed.selection, None);
2948 assert_eq!(ed.view_snapshot().cursor.0, 1);
2949 ed.jump_to_heading("Nope");
2950 assert_eq!(
2951 ed.view_snapshot().cursor.0,
2952 1,
2953 "an unknown heading leaves the cursor"
2954 );
2955 }
2956
2957 #[test]
2958 fn tab_indents_the_cursor_row_and_backtab_dedents_it() {
2959 let mut editor = make_editor();
2960 editor.set_text("foo".to_string());
2961 let tx = dummy_tx();
2962 let tab = key(KeyCode::Tab, KeyModifiers::NONE);
2963 let _ = editor.handle_input(&InputEvent::Key(tab), &tx);
2964 assert_eq!(editor.get_text(), " foo");
2965 assert!(editor.is_dirty(), "an indent is an edit");
2966 let back = key(KeyCode::BackTab, KeyModifiers::SHIFT);
2967 let _ = editor.handle_input(&InputEvent::Key(back), &tx);
2968 assert_eq!(editor.get_text(), "foo");
2969 }
2970
2971 #[test]
2972 fn enter_at_the_end_of_a_list_item_continues_it() {
2973 let mut editor = make_editor();
2974 editor.set_text("- foo".to_string());
2975 {
2976 let ta = get_ta(&mut editor);
2977 ta.move_cursor(CursorMove::End);
2978 }
2979 let tx = dummy_tx();
2980 let enter = key(KeyCode::Enter, KeyModifiers::NONE);
2981 let _ = editor.handle_input(&InputEvent::Key(enter), &tx);
2982 assert_eq!(editor.get_text(), "- foo\n- ");
2983 assert!(editor.is_dirty());
2984 }
2985
2986 #[test]
2987 fn a_letter_typed_after_smart_enter_starts_its_own_undo_group() {
2988 let mut editor = make_editor();
2989 editor.set_text(String::new());
2990 for c in "- foo".chars() {
2991 send_char(&mut editor, c);
2992 }
2993 let tx = dummy_tx();
2994 let enter = key(KeyCode::Enter, KeyModifiers::NONE);
2995 let _ = editor.handle_input(&InputEvent::Key(enter), &tx);
2996 send_char(&mut editor, 'b');
2997 assert_eq!(editor.get_text(), "- foo\n- b");
2998 assert!(get_ta(&mut editor).undo());
2999 assert_eq!(
3000 editor.get_text(),
3001 "- foo\n- ",
3002 "the letter after a list continuation is its own undo group"
3003 );
3004 }
3005
3006 #[test]
3007 fn textarea_hint_shortcuts_has_no_mode_indicator() {
3008 let editor = make_editor();
3009 let hints = editor.hint_shortcuts();
3010 assert!(
3012 !hints
3013 .iter()
3014 .any(|(_, label)| label == "NORMAL" || label == "INSERT")
3015 );
3016 }
3017
3018 fn place_cursor_at_col(editor: &mut TextEditorComponent, col: usize) {
3022 let ta = get_ta(editor);
3023 ta.move_cursor(CursorMove::Head);
3024 for _ in 0..col {
3025 ta.move_cursor(CursorMove::Forward);
3026 }
3027 }
3028
3029 #[test]
3030 fn follow_target_at_cursor_returns_label_when_cursor_on_hashtag() {
3031 let mut editor = make_editor();
3032 editor.set_text("see #rust now".to_string());
3033 place_cursor_at_col(&mut editor, 5);
3035 assert_eq!(
3036 editor.follow_target_at_cursor(),
3037 Some(FollowTarget::Label("rust".into())),
3038 );
3039 }
3040
3041 #[test]
3042 fn follow_target_at_cursor_returns_label_at_hash_char() {
3043 let mut editor = make_editor();
3044 editor.set_text("see #rust now".to_string());
3045 place_cursor_at_col(&mut editor, 4);
3047 assert_eq!(
3048 editor.follow_target_at_cursor(),
3049 Some(FollowTarget::Label("rust".into())),
3050 );
3051 }
3052
3053 #[test]
3054 fn follow_target_at_cursor_returns_none_outside_hashtag() {
3055 let mut editor = make_editor();
3056 editor.set_text("see #rust now".to_string());
3057 place_cursor_at_col(&mut editor, 0);
3059 assert_eq!(editor.follow_target_at_cursor(), None);
3060 }
3061
3062 #[test]
3063 fn follow_target_at_cursor_returns_link_for_wikilink() {
3064 let mut editor = make_editor();
3065 editor.set_text("open [[my note]] please".to_string());
3066 place_cursor_at_col(&mut editor, 7);
3068 let result = editor.follow_target_at_cursor();
3069 assert!(
3070 matches!(result, Some(FollowTarget::Link(_))),
3071 "expected Link variant, got {result:?}"
3072 );
3073 }
3074
3075 #[test]
3078 fn follow_target_at_cursor_returns_link_for_markdown_link_with_fragment() {
3079 let line = "[see docs](#section)";
3084 let mut editor = make_editor();
3085 editor.set_text(line.to_string());
3086 let cursor = "[see docs](#sec".chars().count(); place_cursor_at_col(&mut editor, cursor);
3089 let result = editor.follow_target_at_cursor();
3090 assert!(
3091 matches!(result, Some(FollowTarget::Link(_))),
3092 "expected Link variant for markdown link fragment, got {result:?}"
3093 );
3094 }
3095
3096 #[test]
3097 fn vim_normal_i_then_typing_inserts_text() {
3098 let mut settings = crate::settings::AppSettings::default();
3099 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
3100 let mut editor = TextEditorComponent::new(KeyBindings::empty(), &settings);
3101 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3102 editor.handle_input(
3104 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3105 &tx,
3106 );
3107 assert_eq!(editor.get_text(), "");
3108 editor.handle_input(
3110 &InputEvent::Key(key(KeyCode::Char('i'), KeyModifiers::NONE)),
3111 &tx,
3112 );
3113 editor.handle_input(
3114 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3115 &tx,
3116 );
3117 assert_eq!(editor.get_text(), "x");
3118 }
3119
3120 fn open_replace_bar(
3125 editor: &mut TextEditorComponent,
3126 tx: &AppTx,
3127 pattern: &str,
3128 replacement: &str,
3129 ) {
3130 editor.open_or_advance_search();
3131 for c in pattern.chars() {
3132 editor.handle_input(
3133 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3134 tx,
3135 );
3136 }
3137 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), tx);
3138 for c in replacement.chars() {
3139 editor.handle_input(
3140 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3141 tx,
3142 );
3143 }
3144 }
3145
3146 #[test]
3147 fn tab_reveals_the_replace_field_and_then_cycles_focus() {
3148 let mut editor = make_editor();
3149 let tx = dummy_tx();
3150 editor.set_text("todo".to_string());
3151 editor.open_or_advance_search();
3152 assert!(
3153 !editor.search.as_ref().unwrap().is_replacing(),
3154 "a find-only bar must not start with a replace field"
3155 );
3156
3157 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3158 let s = editor.search.as_ref().unwrap();
3159 assert!(s.is_replacing(), "Tab must reveal the replace field");
3160 assert_eq!(s.focus, BarFocus::Find);
3163
3164 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3165 assert_eq!(editor.search.as_ref().unwrap().focus, BarFocus::Replace);
3166 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3167 assert_eq!(editor.search.as_ref().unwrap().focus, BarFocus::Find);
3168 }
3169
3170 #[test]
3171 fn typing_in_the_replace_field_does_not_touch_the_buffer() {
3172 let mut editor = make_editor();
3173 let tx = dummy_tx();
3174 editor.set_text("todo and todo".to_string());
3175 open_replace_bar(&mut editor, &tx, "todo", "done");
3176 assert_eq!(
3177 editor.get_text(),
3178 "todo and todo",
3179 "the preview is a view of the note, never a write to it"
3180 );
3181 }
3182
3183 #[test]
3184 fn enter_replaces_the_current_match_and_advances() {
3185 let mut editor = make_editor();
3186 let tx = dummy_tx();
3187 editor.set_text("todo and todo".to_string());
3188 open_replace_bar(&mut editor, &tx, "todo", "done");
3189 editor.handle_input(
3190 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3191 &tx,
3192 );
3193 assert_eq!(editor.get_text(), "done and todo");
3194 }
3195
3196 #[test]
3197 fn replacing_a_match_that_ends_inside_a_cluster_is_refused_not_corrupted() {
3198 let mut editor = make_editor();
3204 let tx = dummy_tx();
3205 editor.set_text("e\u{301}f".to_string());
3206 open_replace_bar(&mut editor, &tx, "e", "x");
3207 editor.handle_input(
3208 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3209 &tx,
3210 );
3211 assert_eq!(
3212 editor.get_text(),
3213 "e\u{301}f",
3214 "the note is left alone rather than half-rewritten"
3215 );
3216 }
3217
3218 #[test]
3219 fn ctrl_a_replaces_every_match() {
3220 let mut editor = make_editor();
3221 let tx = dummy_tx();
3222 editor.set_text("todo and todo\nmore todo".to_string());
3223 open_replace_bar(&mut editor, &tx, "todo", "done");
3224 editor.handle_input(
3225 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3226 &tx,
3227 );
3228 assert_eq!(editor.get_text(), "done and done\nmore done");
3229 }
3230
3231 #[test]
3232 fn replace_all_keeps_the_reading_position() {
3233 let mut editor = make_editor();
3234 let tx = dummy_tx();
3235 editor.set_text("todo\nxx\ntodo\nyy".to_string());
3236 open_replace_bar(&mut editor, &tx, "todo", "done");
3237 if let Some(ta) = editor.backend.as_textarea_mut() {
3241 ta.move_cursor(CursorMove::Jump(3, 1));
3242 }
3243 editor.handle_input(
3244 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3245 &tx,
3246 );
3247 assert_eq!(editor.get_text(), "done\nxx\ndone\nyy");
3248 let (row, _) = editor.cursor_pos();
3249 assert_eq!(
3250 row, 3,
3251 "replace all must not throw the cursor to the end of the note"
3252 );
3253 }
3254
3255 #[test]
3256 fn an_empty_replacement_arms_before_it_deletes() {
3257 let mut editor = make_editor();
3258 let tx = dummy_tx();
3259 editor.set_text("todo and todo".to_string());
3260 open_replace_bar(&mut editor, &tx, "todo ", "");
3261
3262 let ctrl_a = InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
3263 editor.handle_input(&ctrl_a, &tx);
3264 assert_eq!(
3265 editor.get_text(),
3266 "todo and todo",
3267 "the first Ctrl+A on an empty replacement must arm, not delete"
3268 );
3269 assert!(editor.search.as_ref().unwrap().armed_empty);
3270
3271 editor.handle_input(&ctrl_a, &tx);
3272 assert_eq!(editor.get_text(), "and todo");
3273 }
3274
3275 #[test]
3276 fn esc_disarms_an_empty_replace_all_without_closing_the_bar() {
3277 let mut editor = make_editor();
3278 let tx = dummy_tx();
3279 editor.set_text("todo".to_string());
3280 open_replace_bar(&mut editor, &tx, "todo", "");
3281 editor.handle_input(
3282 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3283 &tx,
3284 );
3285 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3286 let s = editor
3287 .search
3288 .as_ref()
3289 .expect("Esc disarms before it closes");
3290 assert!(!s.armed_empty);
3291 assert_eq!(editor.get_text(), "todo");
3292 }
3293
3294 #[test]
3295 fn one_ctrl_z_undoes_a_whole_replace_all() {
3296 let mut editor = make_editor();
3297 let tx = dummy_tx();
3298 editor.set_text("todo and todo".to_string());
3299 open_replace_bar(&mut editor, &tx, "todo", "done");
3300 editor.handle_input(
3301 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3302 &tx,
3303 );
3304 assert_eq!(editor.get_text(), "done and done");
3305
3306 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3308 editor.handle_input(
3309 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3310 &tx,
3311 );
3312 assert_eq!(
3313 editor.get_text(),
3314 "todo and todo",
3315 "a replace is two history entries and must cost ONE undo — \
3316 popping half leaves the note with a hole in it"
3317 );
3318 }
3319
3320 #[test]
3321 fn one_ctrl_z_undoes_a_single_replace_step() {
3322 let mut editor = make_editor();
3323 let tx = dummy_tx();
3324 editor.set_text("todo and todo".to_string());
3325 open_replace_bar(&mut editor, &tx, "todo", "done");
3326 editor.handle_input(
3327 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3328 &tx,
3329 );
3330 assert_eq!(editor.get_text(), "done and todo");
3331 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3332 editor.handle_input(
3333 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3334 &tx,
3335 );
3336 assert_eq!(editor.get_text(), "todo and todo");
3337 }
3338
3339 #[test]
3340 fn redo_regroups_the_replace() {
3341 let mut editor = make_editor();
3342 let tx = dummy_tx();
3343 editor.set_text("todo".to_string());
3344 open_replace_bar(&mut editor, &tx, "todo", "done");
3345 editor.handle_input(
3346 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3347 &tx,
3348 );
3349 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3350 editor.handle_input(
3351 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3352 &tx,
3353 );
3354 assert_eq!(editor.get_text(), "todo");
3355 editor.handle_input(
3356 &InputEvent::Key(key(KeyCode::Char('y'), KeyModifiers::CONTROL)),
3357 &tx,
3358 );
3359 assert_eq!(
3360 editor.get_text(),
3361 "done",
3362 "one redo must restore the whole replace"
3363 );
3364 }
3365
3366 #[test]
3367 fn smartcase_drives_both_the_count_and_the_replace() {
3368 let mut editor = make_editor();
3369 let tx = dummy_tx();
3370 editor.set_text("todo Todo TODO".to_string());
3371 open_replace_bar(&mut editor, &tx, "todo", "x");
3372 assert_eq!(
3373 editor.search.as_ref().unwrap().match_count,
3374 3,
3375 "an all-lowercase pattern matches any case"
3376 );
3377 editor.handle_input(
3378 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3379 &tx,
3380 );
3381 assert_eq!(editor.get_text(), "x x x");
3382 }
3383
3384 #[test]
3385 fn an_uppercase_pattern_is_case_sensitive() {
3386 let mut editor = make_editor();
3387 let tx = dummy_tx();
3388 editor.set_text("todo Todo TODO".to_string());
3389 open_replace_bar(&mut editor, &tx, "Todo", "x");
3390 assert_eq!(editor.search.as_ref().unwrap().match_count, 1);
3391 editor.handle_input(
3392 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3393 &tx,
3394 );
3395 assert_eq!(editor.get_text(), "todo x TODO");
3396 }
3397
3398 #[test]
3399 fn the_preview_substitutes_lines_without_writing_them() {
3400 let mut editor = make_editor();
3401 let tx = dummy_tx();
3402 editor.set_text("todo and todo".to_string());
3403 open_replace_bar(&mut editor, &tx, "todo", "done");
3404 let preview = editor.replace_preview().expect("a preview must be built");
3405 assert_eq!(preview.lines, vec!["done and done".to_string()]);
3406 assert_eq!(preview.spans.len(), 2);
3407 assert!(
3408 preview.spans.iter().any(|s| s.is_current),
3409 "the match under the cursor must be flagged so Enter's target is visible"
3410 );
3411 assert_eq!(
3412 editor.get_text(),
3413 "todo and todo",
3414 "building a preview must never mutate the buffer"
3415 );
3416 }
3417
3418 #[test]
3423 fn a_deletion_preview_still_marks_the_current_match() {
3424 let mut editor = make_editor();
3425 let tx = dummy_tx();
3426 editor.set_text("todo and todo".to_string());
3427 open_replace_bar(&mut editor, &tx, "todo", "");
3428 let preview = editor.replace_preview().expect("a preview must be built");
3429 assert_eq!(preview.lines, vec![" and ".to_string()]);
3430 let current = preview
3431 .spans
3432 .iter()
3433 .find(|s| s.is_current)
3434 .expect("the current match must stay flagged when it previews as nothing");
3435 assert_eq!(
3436 current.start, current.end,
3437 "an empty replacement previews as a zero-width span — the renderer \
3438 widens it to a caret cell so the marker cannot vanish"
3439 );
3440 }
3441
3442 #[test]
3447 fn a_multi_row_selection_cannot_derail_an_interactive_replace() {
3448 let mut editor = make_editor();
3449 let tx = dummy_tx();
3450 editor.set_text("alpha beta\nxy".to_string());
3451 open_replace_bar(&mut editor, &tx, "beta", "Z");
3452 editor.selection = Some(((0, 6), (1, 1)));
3454 editor.handle_input(
3455 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3456 &tx,
3457 );
3458 assert_eq!(editor.get_text(), "alpha Z\nxy");
3459 }
3460
3461 #[test]
3466 fn deleting_a_match_marks_the_note_dirty() {
3467 let mut editor = make_editor();
3468 let tx = dummy_tx();
3469 editor.set_text("todo and todo".to_string());
3470 editor.mark_saved("todo and todo".to_string());
3471 assert!(!editor.is_dirty());
3472
3473 open_replace_bar(&mut editor, &tx, "todo ", "");
3474 editor.handle_input(
3475 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3476 &tx,
3477 );
3478 assert_eq!(editor.get_text(), "and todo");
3479 assert!(
3480 editor.is_dirty(),
3481 "a deletion is an edit — if the revision does not move, autosave \
3482 never writes it and the change is silently lost"
3483 );
3484 }
3485
3486 #[test]
3488 fn emptying_the_note_via_replace_all_marks_it_dirty_and_is_undoable() {
3489 let mut editor = make_editor();
3490 let tx = dummy_tx();
3491 editor.set_text("todo".to_string());
3492 editor.mark_saved("todo".to_string());
3493 open_replace_bar(&mut editor, &tx, "todo", "");
3494 let ctrl_a = InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
3495 editor.handle_input(&ctrl_a, &tx); editor.handle_input(&ctrl_a, &tx); assert_eq!(editor.get_text(), "");
3498 assert!(editor.is_dirty());
3499
3500 editor.handle_input(
3501 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3502 &tx,
3503 );
3504 assert_eq!(editor.get_text(), "todo");
3505 }
3506
3507 #[test]
3511 fn ctrl_z_works_without_closing_the_bar_first() {
3512 let mut editor = make_editor();
3513 let tx = dummy_tx();
3514 editor.set_text("todo and todo".to_string());
3515 open_replace_bar(&mut editor, &tx, "todo", "done");
3516 editor.handle_input(
3517 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3518 &tx,
3519 );
3520 assert_eq!(editor.get_text(), "done and done");
3521 editor.handle_input(
3522 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3523 &tx,
3524 );
3525 assert_eq!(editor.get_text(), "todo and todo");
3526 assert!(editor.search.is_some(), "undo must not close the bar");
3527 }
3528
3529 #[test]
3533 fn a_zero_width_match_does_not_over_claim_history_entries() {
3534 let mut editor = make_editor();
3535 let tx = dummy_tx();
3536 editor.set_text("ab".to_string());
3537 open_replace_bar(&mut editor, &tx, r"\b", "|");
3538 editor.handle_input(
3539 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3540 &tx,
3541 );
3542 assert_eq!(editor.get_text(), "|ab");
3543 editor.handle_input(
3544 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3545 &tx,
3546 );
3547 assert_eq!(
3548 editor.get_text(),
3549 "ab",
3550 "one undo must land exactly on the pre-replace text, not past it"
3551 );
3552 }
3553
3554 #[test]
3558 fn a_note_swap_resets_the_find_bar_and_its_undo_groups() {
3559 let mut editor = make_editor();
3560 let tx = dummy_tx();
3561 editor.set_text("todo".to_string());
3562 open_replace_bar(&mut editor, &tx, "todo", "");
3563 editor.handle_input(
3564 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3565 &tx,
3566 );
3567 assert!(editor.search.as_ref().unwrap().armed_empty);
3568
3569 editor.set_text("todo elsewhere".to_string());
3570 assert!(editor.search.is_none(), "the bar belonged to the old note");
3571 assert!(
3574 !editor.backend.as_textarea_mut().unwrap().undo(),
3575 "the new note's history has nothing to undo"
3576 );
3577 }
3578
3579 #[test]
3585 fn concealed_markdown_still_highlights_what_it_counts() {
3586 let mut editor = make_editor();
3587 let tx = dummy_tx();
3588 editor.set_text("# Heading\n[[note]]".to_string());
3589 editor.open_or_advance_search();
3590 for c in r"\[\[".chars() {
3591 editor.handle_input(
3592 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3593 &tx,
3594 );
3595 }
3596 let state = editor.search.as_ref().unwrap();
3597 assert_eq!(state.match_count, 1, "the `[[` sigil is a real match");
3598 let spans = state
3599 .pattern
3600 .as_ref()
3601 .unwrap()
3602 .match_spans(editor.backend.as_textarea().unwrap().text().lines());
3603 assert_eq!(
3604 spans,
3605 vec![(1, 0, 2)],
3606 "and it must be reported as a paintable span, not silently dropped \
3607 because the rendered row conceals it"
3608 );
3609 }
3610
3611 #[test]
3616 fn paste_goes_into_the_focused_bar_field() {
3617 let mut editor = make_editor();
3618 let tx = dummy_tx();
3619 editor.set_text("todo".to_string());
3620 open_replace_bar(&mut editor, &tx, "todo", "");
3621 editor.paste_text("done", &tx);
3622 assert_eq!(editor.get_text(), "todo", "the buffer is untouched");
3623 assert_eq!(editor.search.as_ref().unwrap().replacement(), "done");
3624 }
3625
3626 #[test]
3627 fn a_multiline_paste_collapses_to_its_first_line() {
3628 let mut editor = make_editor();
3629 let tx = dummy_tx();
3630 editor.set_text("x".to_string());
3631 editor.open_or_advance_search();
3632 editor.paste_text("first\nsecond", &tx);
3633 assert_eq!(editor.search.as_ref().unwrap().input.value(), "first");
3634 }
3635
3636 #[test]
3640 fn the_bar_is_never_an_invisible_modal() {
3641 use ratatui::Terminal;
3642 use ratatui::backend::TestBackend;
3643 let mut editor = make_editor();
3644 editor.set_text("todo".to_string());
3645 let theme = Theme::default();
3646 let mut term = Terminal::new(TestBackend::new(40, 1)).unwrap();
3647 let area = Rect::new(0, 0, 40, 1);
3648 editor.open_or_advance_search();
3649 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
3650 let row: String = (0..40)
3651 .filter_map(|x| {
3652 term.backend()
3653 .buffer()
3654 .cell(ratatui::layout::Position::new(x, 0))
3655 .map(|c| c.symbol().to_string())
3656 })
3657 .collect();
3658 assert!(
3659 row.contains("Find:"),
3660 "an open bar must be drawn even when it costs the whole pane, got {row:?}"
3661 );
3662 }
3663
3664 #[test]
3676 fn a_row_far_from_the_cursor_reparses_after_replace_all() {
3677 use ratatui::Terminal;
3678 use ratatui::backend::TestBackend;
3679 let mut editor = make_editor();
3680 let tx = dummy_tx();
3681 let mut lines: Vec<String> = (0..400).map(|i| format!("filler {i}")).collect();
3682 lines[0] = "todo".to_string();
3683 lines[398] = "todo".to_string();
3684 editor.set_text(lines.join("\n"));
3685 let theme = Theme::default();
3686 let mut term = Terminal::new(TestBackend::new(20, 8)).unwrap();
3687 let area = Rect::new(0, 0, 20, 8);
3688 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
3689
3690 open_replace_bar(&mut editor, &tx, "todo", "[[x]]");
3691 if let Some(ta) = editor.backend.as_textarea_mut() {
3694 ta.move_cursor(CursorMove::Jump(398, 0));
3695 }
3696 editor.handle_input(
3697 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3698 &tx,
3699 );
3700 if let Some(ta) = editor.backend.as_textarea_mut() {
3703 ta.move_cursor(CursorMove::Jump(1, 0));
3704 }
3705 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
3706 let row0: String = (0..20)
3707 .filter_map(|x| {
3708 term.backend()
3709 .buffer()
3710 .cell(ratatui::layout::Position::new(x, 0))
3711 .map(|c| c.symbol().to_string())
3712 })
3713 .collect::<String>()
3714 .trim_end()
3715 .to_string();
3716 assert_eq!(
3717 row0, "x",
3718 "row 0 must render as a parsed wikilink; `[[x]]` would mean it \
3719 kept the parse of the text that was there before the replace"
3720 );
3721 }
3722
3723 #[test]
3727 fn indenting_a_block_undoes_in_one_step() {
3728 let mut editor = make_editor();
3729 let tx = dummy_tx();
3730 editor.set_text("a\nb\nc".to_string());
3731 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
3732 get_ta(&mut editor).start_selection();
3733 get_ta(&mut editor).move_cursor(CursorMove::Jump(2, 1));
3734 editor.indent_lines(false);
3735 assert_eq!(editor.get_text(), " a\n b\n c");
3736
3737 editor.handle_input(
3738 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3739 &tx,
3740 );
3741 assert_eq!(
3742 editor.get_text(),
3743 "a\nb\nc",
3744 "one undo must revert the whole block, not just the last line"
3745 );
3746 }
3747
3748 #[test]
3750 fn pasting_over_a_selection_undoes_in_one_step() {
3751 let mut editor = make_editor();
3752 let tx = dummy_tx();
3753 editor.set_text("hello world".to_string());
3754 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
3755 get_ta(&mut editor).start_selection();
3756 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 5));
3757 editor.paste_text("goodbye", &tx);
3758 assert_eq!(editor.get_text(), "goodbye world");
3759
3760 editor.handle_input(
3761 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3762 &tx,
3763 );
3764 assert_eq!(editor.get_text(), "hello world");
3765 }
3766
3767 #[test]
3771 fn closing_the_bar_clears_a_stale_selection() {
3772 let mut editor = make_editor();
3773 let tx = dummy_tx();
3774 editor.set_text("alpha beta".to_string());
3775 editor.selection = Some(((0, 0), (0, 5)));
3776 editor.open_or_advance_search();
3777 for c in "beta".chars() {
3778 editor.handle_input(
3779 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3780 &tx,
3781 );
3782 }
3783 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3784 assert!(editor.search.is_none());
3785 assert_eq!(
3786 editor.selection, None,
3787 "a selection from before the search must not outlive the bar"
3788 );
3789 }
3790
3791 #[test]
3794 fn undo_inside_the_bar_rederives_the_current_match() {
3795 let mut editor = make_editor();
3796 let tx = dummy_tx();
3797 editor.set_text("foo foo".to_string());
3798 open_replace_bar(&mut editor, &tx, "foo", "xy");
3799 editor.handle_input(
3800 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3801 &tx,
3802 );
3803 assert_eq!(editor.get_text(), "xy foo");
3804 editor.handle_input(
3805 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3806 &tx,
3807 );
3808 assert_eq!(editor.get_text(), "foo foo");
3809 let current = editor.search.as_ref().unwrap().current_match();
3810 if let Some(((row, start), (_, end))) = current {
3811 let line = &editor.get_text()[..];
3812 let text: String = line
3813 .lines()
3814 .nth(row)
3815 .unwrap()
3816 .chars()
3817 .skip(start)
3818 .take(end - start)
3819 .collect();
3820 assert_eq!(
3821 text, "foo",
3822 "the highlight must sit on a real match, got {text:?}"
3823 );
3824 }
3825 }
3826
3827 #[test]
3831 fn vim_n_highlights_the_match_it_lands_on() {
3832 let mut editor = make_vim_editor();
3833 let tx = dummy_tx();
3834 editor.set_text("lo xx lo".to_string());
3835 editor.open_or_advance_search();
3836 for c in "lo".chars() {
3837 editor.handle_input(
3838 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3839 &tx,
3840 );
3841 }
3842 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3843 editor.handle_input(
3844 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
3845 &tx,
3846 );
3847 assert_eq!(
3848 editor.selection,
3849 Some(((0, 6), (0, 8))),
3850 "`n` must paint the match it jumped to"
3851 );
3852 }
3853
3854 #[test]
3859 fn closing_the_bar_cannot_leave_an_invisible_selection() {
3860 let mut editor = make_editor();
3861 let tx = dummy_tx();
3862 editor.set_text("foo bar baz".to_string());
3863 get_ta(&mut editor).select_all();
3865 editor.open_or_advance_search();
3866 for c in "bar".chars() {
3867 editor.handle_input(
3868 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3869 &tx,
3870 );
3871 }
3872 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3873 editor.handle_input(
3874 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3875 &tx,
3876 );
3877 assert!(
3878 editor.get_text().contains("foo"),
3879 "typing after the bar closed must not eat unhighlighted text, got {:?}",
3880 editor.get_text()
3881 );
3882 }
3883
3884 #[test]
3887 fn vim_visual_indent_undoes_in_one_step() {
3888 let mut editor = make_vim_editor();
3889 let tx = dummy_tx();
3890 editor.set_text("a\nb\nc".to_string());
3891 for c in ['V', 'j', 'j', '>'] {
3892 editor.handle_input(
3893 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3894 &tx,
3895 );
3896 }
3897 let indented = editor.get_text();
3898 assert_ne!(indented, "a\nb\nc", "`>` must indent the selection");
3899 editor.handle_input(
3900 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
3901 &tx,
3902 );
3903 assert_eq!(
3904 editor.get_text(),
3905 "a\nb\nc",
3906 "one `u` must revert the whole indent, not one row"
3907 );
3908 }
3909
3910 #[test]
3913 fn vim_charwise_visual_indent_shifts_the_touched_rows() {
3914 let mut editor = make_vim_editor();
3915 let tx = dummy_tx();
3916 editor.set_text("one\ntwo\nthree".to_string());
3917 for c in ['l', 'v', 'j'] {
3918 editor.handle_input(
3919 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3920 &tx,
3921 );
3922 }
3923 editor.handle_input(
3925 &InputEvent::Key(key(KeyCode::Char('>'), KeyModifiers::SHIFT)),
3926 &tx,
3927 );
3928 assert_eq!(editor.get_text(), " one\n two\nthree");
3929 assert_eq!(vim_mode(&editor), EditorMode::Normal);
3930 assert_eq!(editor.selection, None);
3931 }
3932
3933 #[test]
3936 fn vim_gv_does_not_cross_notes() {
3937 let mut editor = make_vim_editor();
3938 let tx = dummy_tx();
3939 editor.set_text("a\nb\nc".to_string());
3940 for c in ['V', 'j'] {
3941 editor.handle_input(
3942 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3943 &tx,
3944 );
3945 }
3946 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3947 editor.set_text("x\ny\nz".to_string());
3948 for c in ['g', 'v'] {
3949 editor.handle_input(
3950 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3951 &tx,
3952 );
3953 }
3954 assert_eq!(vim_mode(&editor), EditorMode::Normal);
3955 assert_eq!(editor.selection, None);
3956 }
3957
3958 fn press(editor: &mut TextEditorComponent, keys: &str) {
3959 let tx = dummy_tx();
3960 for c in keys.chars() {
3961 editor.handle_input(
3962 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3963 &tx,
3964 );
3965 }
3966 }
3967
3968 fn note(name: &str) -> kimun_core::nfs::VaultPath {
3969 kimun_core::nfs::VaultPath::note_path_from(name)
3970 }
3971
3972 #[test]
3977 fn a_note_with_the_same_text_does_not_inherit_undo_history() {
3978 let mut editor = make_vim_editor();
3979 editor.open_note(note("alpha"), "one".to_string());
3980 press(&mut editor, "x");
3981 assert_eq!(editor.get_text(), "ne");
3982 editor.open_note(note("beta"), "ne".to_string());
3983 press(&mut editor, "u");
3984 assert_eq!(editor.get_text(), "ne", "beta has nothing to undo");
3985 }
3986
3987 #[test]
3990 fn empty_notes_do_not_share_history() {
3991 let mut editor = make_vim_editor();
3992 let tx = dummy_tx();
3993 editor.open_note(note("alpha"), String::new());
3994 press(&mut editor, "ix");
3995 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3996 press(&mut editor, "u");
3997 assert_eq!(editor.get_text(), "");
3998 editor.open_note(note("beta"), String::new());
3999 editor.handle_input(
4000 &InputEvent::Key(key(KeyCode::Char('r'), KeyModifiers::CONTROL)),
4001 &tx,
4002 );
4003 assert_eq!(editor.get_text(), "", "beta has nothing to redo");
4004 }
4005
4006 #[test]
4008 fn a_note_with_the_same_text_does_not_inherit_the_selection() {
4009 let mut editor = make_vim_editor();
4010 let tx = dummy_tx();
4011 editor.open_note(note("alpha"), "a\nb\nc".to_string());
4012 press(&mut editor, "Vj");
4013 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4014 press(&mut editor, "Vj");
4015 editor.open_note(note("beta"), "a\nb\nc".to_string());
4016 assert_eq!(vim_mode(&editor), EditorMode::Normal);
4017 assert_eq!(editor.selection, None);
4018 press(&mut editor, "gv");
4019 assert_eq!(
4020 vim_mode(&editor),
4021 EditorMode::Normal,
4022 "no selection to reselect"
4023 );
4024 }
4025
4026 fn top_row(term: &ratatui::Terminal<ratatui::backend::TestBackend>, width: u16) -> String {
4028 (0..width)
4029 .filter_map(|x| term.backend().buffer().cell((x, 0)).map(|c| c.symbol()))
4030 .collect::<String>()
4031 .trim_end()
4032 .to_string()
4033 }
4034
4035 #[test]
4038 fn another_note_opens_scrolled_to_the_top() {
4039 use ratatui::Terminal;
4040 use ratatui::backend::TestBackend;
4041 let mut editor = make_vim_editor();
4042 let theme = Theme::default();
4043 let mut term = Terminal::new(TestBackend::new(20, 8)).unwrap();
4044 let area = Rect::new(0, 0, 20, 8);
4045 let body: String = (0..400).map(|i| format!("line {i}\n")).collect();
4046 editor.open_note(note("alpha"), body.clone());
4047 press(&mut editor, "300G");
4048 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4049 assert_ne!(top_row(&term, 20), "line 0", "alpha is scrolled down");
4050
4051 editor.open_note(note("beta"), body);
4052 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4053 assert_eq!(top_row(&term, 20), "line 0");
4054 }
4055
4056 #[test]
4059 fn reopening_the_same_note_keeps_its_history() {
4060 let mut editor = make_vim_editor();
4061 editor.open_note(note("alpha"), "one".to_string());
4062 press(&mut editor, "x");
4063 editor.open_note(note("alpha"), "ne".to_string());
4064 press(&mut editor, "u");
4065 assert_eq!(editor.get_text(), "one");
4066 }
4067
4068 #[test]
4071 fn renaming_the_open_note_keeps_its_history() {
4072 let mut editor = make_vim_editor();
4073 editor.open_note(note("alpha"), "one".to_string());
4074 press(&mut editor, "x");
4075 editor.renamed_to(note("gamma"));
4076 editor.set_text("ne".to_string());
4077 editor.open_note(note("gamma"), "ne".to_string());
4078 press(&mut editor, "u");
4079 assert_eq!(editor.get_text(), "one");
4080 }
4081
4082 #[test]
4085 fn vim_gv_after_external_paste_lands_where_the_selection_was() {
4086 let mut editor = make_vim_editor();
4087 let tx = dummy_tx();
4088 editor.set_text("a\nb\nc".to_string());
4089 for c in ['j', 'V', 'j'] {
4090 editor.handle_input(
4091 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4092 &tx,
4093 );
4094 }
4095 editor.take_selection_for_external_paste();
4096 for c in ['g', 'v'] {
4097 editor.handle_input(
4098 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4099 &tx,
4100 );
4101 }
4102 assert_eq!(vim_mode(&editor), EditorMode::VisualLine);
4103 let ta = get_ta(&mut editor);
4104 assert_eq!(ta.cursor().0, 1);
4105 }
4106
4107 #[test]
4110 fn vim_gv_repaints_the_last_selection() {
4111 let mut editor = make_vim_editor();
4112 let tx = dummy_tx();
4113 editor.set_text("a\nb\nc".to_string());
4114 for c in ['V', 'j', '>', 'g', 'v'] {
4115 editor.handle_input(
4116 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4117 &tx,
4118 );
4119 }
4120 assert_eq!(vim_mode(&editor), EditorMode::VisualLine);
4121 assert!(editor.selection.is_some(), "gv must paint the highlight");
4122 }
4123
4124 #[test]
4128 fn vim_n_cannot_leave_an_invisible_selection() {
4129 let mut editor = make_vim_editor();
4130 let tx = dummy_tx();
4131 editor.set_text("foo bar foo".to_string());
4132 editor.open_or_advance_search();
4133 for c in "foo".chars() {
4134 editor.handle_input(
4135 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4136 &tx,
4137 );
4138 }
4139 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4140 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
4142 get_ta(&mut editor).start_selection();
4143 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 3));
4144 editor.handle_input(
4145 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
4146 &tx,
4147 );
4148 for c in ['i', 'X'] {
4149 editor.handle_input(
4150 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4151 &tx,
4152 );
4153 }
4154 assert!(
4155 editor.get_text().contains("bar"),
4156 "typing after `n` must not eat unhighlighted text, got {:?}",
4157 editor.get_text()
4158 );
4159 }
4160
4161 #[test]
4166 fn overlays_paint_where_the_post_pass_used_to() {
4167 use ratatui::Terminal;
4168 use ratatui::backend::TestBackend;
4169 use ratatui::layout::Position;
4170 let mut editor = make_editor();
4171 editor.set_text("find the needle here\n- [x] done task\n- [ ] open task".to_string());
4172 editor.set_search_needles(vec!["needle".to_string()]);
4173 let theme = Theme::default();
4174 let mut term = Terminal::new(TestBackend::new(40, 6)).unwrap();
4175 let area = Rect::new(0, 0, 40, 6);
4176 term.draw(|f| editor.render(f, area, &theme, false))
4177 .unwrap();
4178 let buf = term.backend().buffer();
4179
4180 let row: String = (0..40)
4181 .filter_map(|x| {
4182 buf.cell(Position::new(x, 0))
4183 .map(|c| c.symbol().to_string())
4184 })
4185 .collect();
4186 let at = row.find("needle").expect("needle is on screen");
4187 let cell = buf.cell(Position::new(at as u16, 0)).unwrap();
4188 assert_eq!(
4189 cell.fg,
4190 theme.color_search_match.to_ratatui(),
4191 "the needle must still be emphasised"
4192 );
4193
4194 let struck = |y: u16| {
4196 (0..40).any(|x| {
4197 buf.cell(Position::new(x, y)).is_some_and(|c| {
4198 c.style()
4199 .add_modifier
4200 .contains(ratatui::style::Modifier::CROSSED_OUT)
4201 })
4202 })
4203 };
4204 assert!(struck(1), "a done task strikes its text");
4205 assert!(!struck(2), "an open task does not");
4206
4207 for y in [1u16, 2] {
4209 assert!(
4210 (0..40).any(|x| buf
4211 .cell(Position::new(x, y))
4212 .is_some_and(|c| c.fg == theme.accent.to_ratatui())),
4213 "row {y} must have an accent-coloured checkbox"
4214 );
4215 }
4216 }
4217
4218 #[test]
4219 fn no_preview_without_a_replace_field() {
4220 let mut editor = make_editor();
4221 let tx = dummy_tx();
4222 editor.set_text("todo".to_string());
4223 editor.open_or_advance_search();
4224 for c in "todo".chars() {
4225 editor.handle_input(
4226 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4227 &tx,
4228 );
4229 }
4230 assert!(
4231 editor.replace_preview().is_none(),
4232 "a find-only bar previews nothing"
4233 );
4234 }
4235
4236 #[test]
4237 fn capture_expansion_is_gated_on_the_pattern_capturing() {
4238 let mut editor = make_editor();
4239 let tx = dummy_tx();
4240 editor.set_text("cost".to_string());
4242 open_replace_bar(&mut editor, &tx, "cost", "$1");
4243 editor.handle_input(
4244 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4245 &tx,
4246 );
4247 assert_eq!(editor.get_text(), "$1");
4248 }
4249
4250 #[test]
4251 fn the_bar_reserves_two_rows_only_while_replacing() {
4252 use ratatui::Terminal;
4253 use ratatui::backend::TestBackend;
4254 let mut editor = make_editor();
4255 let tx = dummy_tx();
4256 editor.set_text("todo".to_string());
4257 let theme = Theme::default();
4258 let mut term = Terminal::new(TestBackend::new(40, 10)).unwrap();
4259 let area = Rect::new(0, 0, 40, 10);
4260
4261 editor.open_or_advance_search();
4262 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4263 assert_eq!(editor.rect.height, 9, "a find-only bar takes one row");
4264
4265 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
4266 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4267 assert_eq!(
4268 editor.rect.height, 8,
4269 "the replace field takes a second row"
4270 );
4271 }
4272
4273 #[test]
4278 fn guu_really_does_undo_in_one_step() {
4279 let mut editor = make_vim_editor();
4280 let tx = dummy_tx();
4281 editor.set_text("Mixed Case Line".to_string());
4282 for c in "guu".chars() {
4283 editor.handle_input(
4284 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4285 &tx,
4286 );
4287 }
4288 assert_eq!(editor.get_text(), "mixed case line");
4289 editor.handle_input(
4290 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4291 &tx,
4292 );
4293 assert_eq!(editor.get_text(), "Mixed Case Line");
4294 }
4295
4296 #[test]
4301 fn vim_u_undoes_a_whole_replace() {
4302 let mut editor = make_vim_editor();
4303 let tx = dummy_tx();
4304 editor.set_text("todo and todo".to_string());
4305 open_replace_bar(&mut editor, &tx, "todo", "done");
4306 editor.handle_input(
4307 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4308 &tx,
4309 );
4310 assert_eq!(editor.get_text(), "done and done");
4311
4312 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4314 editor.handle_input(
4315 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4316 &tx,
4317 );
4318 assert_eq!(editor.get_text(), "todo and todo");
4319 }
4320
4321 fn type_out(editor: &mut TextEditorComponent, tx: &AppTx, text: &str) {
4324 use ratatui::crossterm::event::KeyEvent;
4325 for c in text.chars() {
4326 let code = if c == '\n' {
4327 KeyCode::Enter
4328 } else {
4329 KeyCode::Char(c)
4330 };
4331 editor.handle_textarea_key(&KeyEvent::new(code, KeyModifiers::NONE), tx);
4332 }
4333 }
4334
4335 #[test]
4336 fn undo_takes_back_a_word_not_a_letter() {
4337 let mut editor = make_editor();
4340 let tx = dummy_tx();
4341 editor.set_text(String::new());
4342 type_out(&mut editor, &tx, "hello world");
4343 assert_eq!(editor.get_text(), "hello world");
4344
4345 assert!(get_ta(&mut editor).undo());
4346 assert_eq!(editor.get_text(), "hello ", "the last word goes whole");
4347 assert!(get_ta(&mut editor).undo());
4348 assert_eq!(editor.get_text(), "", "and so does the first");
4349 }
4350
4351 #[test]
4352 fn a_cursor_move_separates_two_runs() {
4353 let mut editor = make_editor();
4354 let tx = dummy_tx();
4355 editor.set_text(String::new());
4356 type_out(&mut editor, &tx, "ab");
4357 arrow(&mut editor, &tx, KeyCode::Home);
4358 type_out(&mut editor, &tx, "cd");
4359 assert_eq!(editor.get_text(), "cdab");
4360
4361 assert!(get_ta(&mut editor).undo());
4362 assert_eq!(
4363 editor.get_text(),
4364 "ab",
4365 "only what was typed after the move comes back off"
4366 );
4367 }
4368
4369 #[test]
4370 fn backspacing_to_fix_a_typo_is_its_own_action() {
4371 use ratatui::crossterm::event::KeyEvent;
4372 let mut editor = make_editor();
4373 let tx = dummy_tx();
4374 editor.set_text(String::new());
4375 type_out(&mut editor, &tx, "helllo");
4376 editor.handle_textarea_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
4377 assert_eq!(editor.get_text(), "helll");
4378
4379 assert!(get_ta(&mut editor).undo());
4380 assert_eq!(
4381 editor.get_text(),
4382 "helllo",
4383 "the delete undoes on its own, without taking the typing with it"
4384 );
4385 }
4386
4387 #[test]
4388 fn an_undo_between_two_runs_separates_them() {
4389 let mut editor = make_editor();
4392 let tx = dummy_tx();
4393 editor.set_text(String::new());
4394 type_out(&mut editor, &tx, "ab");
4395 assert!(get_ta(&mut editor).undo());
4396 assert_eq!(editor.get_text(), "");
4397 type_out(&mut editor, &tx, "cd");
4398 assert_eq!(editor.get_text(), "cd");
4399 assert!(get_ta(&mut editor).undo());
4400 assert_eq!(
4401 editor.get_text(),
4402 "",
4403 "the second run is its own group, not an extension of an undone one"
4404 );
4405 }
4406
4407 #[test]
4408 fn a_save_closes_the_open_group() {
4409 let mut editor = make_editor();
4413 let tx = dummy_tx();
4414 type_out(&mut editor, &tx, "abc");
4415 let saved = editor.get_text();
4416 editor.mark_saved(saved);
4417 type_out(&mut editor, &tx, "def");
4420
4421 assert!(get_ta(&mut editor).undo());
4422 assert_eq!(
4423 editor.get_text(),
4424 "abc",
4425 "one undo lands on what was saved, not before it"
4426 );
4427 }
4428
4429 #[test]
4430 fn a_stale_save_completion_does_not_close_the_group() {
4431 let mut editor = make_editor();
4435 let tx = dummy_tx();
4436 type_out(&mut editor, &tx, "abc");
4437 let stale = NonZeroU64::new(1).expect("nonzero");
4438 editor.mark_saved_at_revision(stale);
4439 type_out(&mut editor, &tx, "def");
4440
4441 assert!(get_ta(&mut editor).undo());
4442 assert_eq!(
4443 editor.get_text(),
4444 "",
4445 "the run carried on across a completion that marked nothing"
4446 );
4447 }
4448
4449 #[test]
4450 fn a_second_vim_insert_session_is_its_own_group() {
4451 use ratatui::crossterm::event::KeyEvent;
4455 let mut editor = make_vim_editor();
4456 let tx = dummy_tx();
4457 editor.set_text(String::new());
4458 let press = |editor: &mut TextEditorComponent, code| {
4459 let _ = editor.handle_input(
4460 &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
4461 &tx,
4462 );
4463 };
4464 press(&mut editor, KeyCode::Char('i'));
4465 for c in "one".chars() {
4466 press(&mut editor, KeyCode::Char(c));
4467 }
4468 press(&mut editor, KeyCode::Esc);
4469 press(&mut editor, KeyCode::Char('i'));
4470 for c in "two".chars() {
4471 press(&mut editor, KeyCode::Char(c));
4472 }
4473 press(&mut editor, KeyCode::Esc);
4474 assert_eq!(editor.get_text(), "ontwoe");
4477
4478 assert!(get_ta(&mut editor).undo());
4479 assert_eq!(
4480 editor.get_text(),
4481 "one",
4482 "`u` takes back the second session only"
4483 );
4484 }
4485
4486 #[test]
4487 fn a_vim_insert_session_undoes_whole() {
4488 use ratatui::crossterm::event::KeyEvent;
4489 let mut editor = make_vim_editor();
4490 let tx = dummy_tx();
4491 editor.set_text(String::new());
4492 let press = |editor: &mut TextEditorComponent, code| {
4494 let _ = editor.handle_input(
4495 &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
4496 &tx,
4497 );
4498 };
4499 press(&mut editor, KeyCode::Char('i'));
4500 for c in "hello world".chars() {
4501 press(&mut editor, KeyCode::Char(c));
4502 }
4503 press(&mut editor, KeyCode::Esc);
4504 assert_eq!(editor.get_text(), "hello world");
4505
4506 assert!(get_ta(&mut editor).undo());
4507 assert_eq!(
4508 editor.get_text(),
4509 "",
4510 "vim's `u` takes back the whole session, word boundaries included"
4511 );
4512 }
4513
4514 fn lay_out(editor: &mut TextEditorComponent, width: u16, height: u16) {
4518 use ratatui::Terminal;
4519 use ratatui::backend::TestBackend;
4520 let theme = Theme::default();
4521 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
4522 let area = Rect::new(0, 0, width, height);
4523 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4524 }
4525
4526 fn arrow(editor: &mut TextEditorComponent, tx: &AppTx, code: KeyCode) {
4527 use ratatui::crossterm::event::KeyEvent;
4528 editor.handle_textarea_key(&KeyEvent::new(code, KeyModifiers::NONE), tx);
4529 }
4530
4531 #[test]
4532 fn down_moves_one_drawn_line_not_one_row() {
4533 let mut editor = make_editor();
4536 let tx = dummy_tx();
4537 editor.set_text(
4538 "aaaa bbbb cccc dddd
4539second row"
4540 .to_string(),
4541 );
4542 lay_out(&mut editor, 6, 10);
4543
4544 get_ta(&mut editor).jump_to(0, 0);
4545 arrow(&mut editor, &tx, KeyCode::Down);
4546 assert_eq!(
4547 get_ta(&mut editor).cursor(),
4548 (0, 5),
4549 "still inside the first row, on its second drawn line"
4550 );
4551 arrow(&mut editor, &tx, KeyCode::Down);
4552 assert_eq!(get_ta(&mut editor).cursor(), (0, 10));
4553 arrow(&mut editor, &tx, KeyCode::Down);
4554 assert_eq!(get_ta(&mut editor).cursor(), (0, 15));
4555 arrow(&mut editor, &tx, KeyCode::Down);
4556 assert_eq!(
4557 get_ta(&mut editor).cursor().0,
4558 1,
4559 "and only the fourth press reaches the next row"
4560 );
4561 }
4562
4563 #[test]
4564 fn up_and_down_are_symmetric_across_a_wrap() {
4565 let mut editor = make_editor();
4566 let tx = dummy_tx();
4567 editor.set_text("aaaa bbbb cccc".to_string());
4568 lay_out(&mut editor, 6, 10);
4569
4570 get_ta(&mut editor).jump_to(0, 0);
4571 arrow(&mut editor, &tx, KeyCode::Down);
4572 let middle = get_ta(&mut editor).cursor();
4573 arrow(&mut editor, &tx, KeyCode::Up);
4574 assert_eq!(get_ta(&mut editor).cursor(), (0, 0));
4575 assert_eq!(middle, (0, 5));
4576 }
4577
4578 #[test]
4579 fn an_arrow_against_a_stale_layout_falls_back_instead_of_panicking() {
4580 let mut editor = make_editor();
4585 let tx = dummy_tx();
4586 editor.set_text("abcd\nefgh".to_string());
4587 lay_out(&mut editor, 20, 10);
4588
4589 get_ta(&mut editor).jump_to(0, 4);
4590 for _ in 0..3 {
4591 get_ta(&mut editor).delete_char();
4592 }
4593 assert_eq!(get_ta(&mut editor).rows(), &["a", "efgh"]);
4594
4595 arrow(&mut editor, &tx, KeyCode::Down);
4597 assert_eq!(get_ta(&mut editor).cursor().0, 1, "still moved down a row");
4598 }
4599
4600 #[test]
4601 fn an_action_between_arrows_forgets_the_goal_cell() {
4602 let mut editor = make_editor();
4608 let tx = dummy_tx();
4609 editor.set_text(
4610 "aaaaaaaa
4611bb
4612cccccccc"
4613 .to_string(),
4614 );
4615 lay_out(&mut editor, 20, 10);
4616
4617 get_ta(&mut editor).jump_to(0, 7);
4618 arrow(&mut editor, &tx, KeyCode::Down);
4619 assert_eq!(
4620 get_ta(&mut editor).cursor(),
4621 (1, 2),
4622 "clamped to the short row"
4623 );
4624
4625 let saved = editor.get_text();
4626 editor.mark_saved(saved);
4627
4628 arrow(&mut editor, &tx, KeyCode::Down);
4629 assert_eq!(
4630 get_ta(&mut editor).cursor(),
4631 (2, 2),
4632 "the goal was forgotten, so the third row keeps the clamped column"
4633 );
4634 }
4635
4636 #[test]
4637 fn a_run_of_arrows_keeps_its_goal_cell() {
4638 let mut editor = make_editor();
4641 let tx = dummy_tx();
4642 editor.set_text(
4643 "aaaaaaaa
4644bb
4645cccccccc"
4646 .to_string(),
4647 );
4648 lay_out(&mut editor, 20, 10);
4649
4650 get_ta(&mut editor).jump_to(0, 7);
4651 arrow(&mut editor, &tx, KeyCode::Down);
4652 assert_eq!(
4653 get_ta(&mut editor).cursor(),
4654 (1, 2),
4655 "clamped to the short row"
4656 );
4657 arrow(&mut editor, &tx, KeyCode::Down);
4658 assert_eq!(
4659 get_ta(&mut editor).cursor(),
4660 (2, 7),
4661 "and back out to the cell the run still wants"
4662 );
4663 }
4664
4665 #[test]
4666 fn another_key_ends_the_run() {
4667 let mut editor = make_editor();
4668 let tx = dummy_tx();
4669 editor.set_text(
4670 "aaaaaaaa
4671bb
4672cccccccc"
4673 .to_string(),
4674 );
4675 lay_out(&mut editor, 20, 10);
4676
4677 get_ta(&mut editor).jump_to(0, 7);
4678 arrow(&mut editor, &tx, KeyCode::Down);
4679 arrow(&mut editor, &tx, KeyCode::Home);
4680 arrow(&mut editor, &tx, KeyCode::Down);
4681 assert_eq!(
4682 get_ta(&mut editor).cursor(),
4683 (2, 0),
4684 "Home set a new goal; the old one is gone"
4685 );
4686 }
4687
4688 #[test]
4689 fn shift_down_extends_by_a_drawn_line() {
4690 let mut editor = make_editor();
4691 let tx = dummy_tx();
4692 editor.set_text("aaaa bbbb cccc".to_string());
4693 lay_out(&mut editor, 6, 10);
4694
4695 get_ta(&mut editor).jump_to(0, 0);
4696 editor.handle_textarea_key(
4697 &ratatui::crossterm::event::KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT),
4698 &tx,
4699 );
4700 assert_eq!(
4701 get_ta(&mut editor).selection_range(),
4702 Some(((0, 0), (0, 5)))
4703 );
4704 }
4705
4706 fn make_vim_editor() -> TextEditorComponent {
4708 let mut settings = crate::settings::AppSettings::default();
4709 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
4710 TextEditorComponent::new(KeyBindings::empty(), &settings)
4711 }
4712
4713 fn vim_mode(editor: &TextEditorComponent) -> EditorMode {
4716 match &editor.backend {
4717 BackendState::Textarea(tb) => match &tb.input {
4718 backend::InputInterpreter::Vim(e) => e.mode().clone(),
4719 _ => panic!("expected Vim input interpreter"),
4720 },
4721 _ => panic!("expected Textarea backend"),
4722 }
4723 }
4724
4725 #[test]
4731 fn vim_visual_paste_url_wraps_whole_selected_word() {
4732 let mut editor = make_vim_editor();
4733 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4734 editor.set_text("hello world".to_string());
4735 editor.handle_input(
4738 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
4739 &tx,
4740 );
4741 editor.handle_input(
4742 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
4743 &tx,
4744 );
4745 assert_eq!(vim_mode(&editor), EditorMode::Visual);
4746 editor.paste_text("https://example.com", &tx);
4747 assert_eq!(
4748 editor.get_text(),
4749 "[hello](https://example.com) world",
4750 "the whole selected word (including the char under the cursor) must be wrapped"
4751 );
4752 }
4753
4754 #[test]
4760 fn vim_visual_bold_wraps_whole_selected_word() {
4761 let mut editor = make_vim_editor();
4762 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4763 editor.set_text("hello world".to_string());
4764 editor.handle_input(
4765 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
4766 &tx,
4767 );
4768 editor.handle_input(
4769 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
4770 &tx,
4771 );
4772 assert_eq!(vim_mode(&editor), EditorMode::Visual);
4773 editor.apply_text_action(TextAction::Bold);
4774 assert_eq!(
4775 editor.get_text(),
4776 "**hello** world",
4777 "the whole selected word (including the char under the cursor) must be wrapped"
4778 );
4779 }
4780
4781 #[test]
4789 fn paste_reports_its_outcome() {
4790 let mut editor = make_editor();
4791 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4792 editor.set_text("x".to_string());
4793 editor.paste_from_clipboard(&tx);
4794 let reported = std::iter::from_fn(|| rx.try_recv().ok()).any(|e| {
4795 matches!(e, AppEvent::FlashMessage(m)
4796 if m == "pasted" || m == "clipboard is empty" || m.starts_with("clipboard: "))
4797 });
4798 assert!(reported, "a paste attempt must always report something");
4799 }
4800
4801 #[test]
4807 fn external_paste_drops_the_selection_and_leaves_visual() {
4808 let mut editor = make_vim_editor();
4809 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4810 editor.set_text("hello world".to_string());
4811 editor.handle_input(
4812 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
4813 &tx,
4814 );
4815 editor.handle_input(
4816 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
4817 &tx,
4818 );
4819 assert_eq!(vim_mode(&editor), EditorMode::Visual);
4820
4821 editor.take_selection_for_external_paste();
4822
4823 assert_eq!(
4824 vim_mode(&editor),
4825 EditorMode::Normal,
4826 "the engine must not keep believing it is in Visual"
4827 );
4828 assert_eq!(
4829 get_ta(&mut editor).selection_range(),
4830 None,
4831 "the selection the incoming content replaces must be gone"
4832 );
4833 assert_eq!(
4834 editor.get_text(),
4835 " world",
4836 "the inclusive visual range is what gets replaced"
4837 );
4838 }
4839
4840 #[test]
4844 fn external_paste_over_visual_line_replaces_the_whole_rows() {
4845 let mut editor = make_vim_editor();
4846 let tx = dummy_tx();
4847 editor.set_text("a\nbb\ncc\nd".to_string());
4848 for c in ['j', 'l', 'V', 'j'] {
4849 editor.handle_input(
4850 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4851 &tx,
4852 );
4853 }
4854 editor.take_selection_for_external_paste();
4855 assert_eq!(editor.get_text(), "a\n\nd");
4856 assert_eq!(vim_mode(&editor), EditorMode::Normal);
4857 }
4858
4859 #[test]
4861 fn bracketed_paste_over_visual_line_replaces_the_whole_rows() {
4862 let mut editor = make_vim_editor();
4863 let tx = dummy_tx();
4864 editor.set_text("a\nbb\ncc\nd".to_string());
4865 for c in ['j', 'l', 'V', 'j'] {
4866 editor.handle_input(
4867 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4868 &tx,
4869 );
4870 }
4871 editor.paste_text("X", &tx);
4872 assert_eq!(editor.get_text(), "a\nX\nd");
4873 }
4874
4875 #[test]
4878 fn surround_over_charwise_visual_includes_the_cursor_char() {
4879 let mut editor = make_vim_editor();
4880 let tx = dummy_tx();
4881 editor.set_text("hello world".to_string());
4882 for c in ['v', 'e', '*'] {
4883 editor.handle_input(
4884 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4885 &tx,
4886 );
4887 }
4888 assert_eq!(editor.get_text(), "*hello* world");
4889 }
4890
4891 #[test]
4894 fn surround_over_an_emoji_wraps_all_of_it() {
4895 let mut editor = make_vim_editor();
4896 let tx = dummy_tx();
4897 editor.set_text("a❤️b".to_string());
4898 for c in ['l', 'v', '*'] {
4899 editor.handle_input(
4900 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4901 &tx,
4902 );
4903 }
4904 assert_eq!(editor.get_text(), "a*❤️*b");
4905 }
4906
4907 #[test]
4909 fn visual_highlight_covers_the_whole_emoji() {
4910 let mut editor = make_vim_editor();
4911 let tx = dummy_tx();
4912 editor.set_text("a👍🏽b".to_string());
4913 for c in ['l', 'v'] {
4914 editor.handle_input(
4915 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4916 &tx,
4917 );
4918 }
4919 assert_eq!(editor.selection, Some(((0, 1), (0, 3))));
4920 }
4921
4922 #[test]
4926 fn bracketed_paste_over_visual_returns_to_normal() {
4927 let mut editor = make_vim_editor();
4928 let tx = dummy_tx();
4929 editor.set_text("keep\nabc".to_string());
4930 press(&mut editor, "jV");
4931 editor.paste_text("xyz", &tx);
4932 assert_eq!(vim_mode(&editor), EditorMode::Normal);
4933 assert_eq!(editor.get_text(), "keep\nxyz");
4934 press(&mut editor, "0x");
4935 assert_eq!(editor.get_text(), "keep\nyz");
4936 }
4937
4938 #[test]
4941 fn two_emphases_over_a_visual_selection_nest_cleanly() {
4942 let mut editor = make_vim_editor();
4943 editor.set_text("hello world".to_string());
4944 press(&mut editor, "ve");
4945 editor.apply_text_action(TextAction::Bold);
4946 assert_eq!(vim_mode(&editor), EditorMode::Visual);
4947 editor.apply_text_action(TextAction::Strikethrough);
4948 assert_eq!(editor.get_text(), "**~~hello~~** world");
4949 }
4950
4951 #[test]
4955 fn surround_chains_into_a_wikilink_from_visual() {
4956 let mut editor = make_vim_editor();
4957 editor.set_text("hello world".to_string());
4958 press(&mut editor, "ve[");
4959 assert_eq!(vim_mode(&editor), EditorMode::Visual);
4960 press(&mut editor, "[");
4961 assert!(
4962 editor.get_text().starts_with("[[hello]]"),
4963 "{:?}",
4964 editor.get_text()
4965 );
4966 }
4967
4968 #[test]
4971 fn a_motion_after_a_surround_extends_the_visual_selection() {
4972 let mut editor = make_vim_editor();
4973 editor.set_text("hello world".to_string());
4974 press(&mut editor, "ve*");
4975 assert_eq!(editor.get_text(), "*hello* world");
4976 assert_eq!(vim_mode(&editor), EditorMode::Visual);
4977 assert_eq!(editor.selection, Some(((0, 1), (0, 6))), "the inner text");
4978 }
4979
4980 #[test]
4983 fn a_surround_typed_in_insert_stays_in_insert() {
4984 let mut editor = make_vim_editor();
4985 editor.set_text("hello world".to_string());
4986 press(&mut editor, "i");
4987 {
4988 let ta = get_ta(&mut editor);
4989 ta.move_cursor(CursorMove::Jump(0, 0));
4990 ta.start_selection();
4991 ta.move_cursor(CursorMove::Jump(0, 5));
4992 }
4993 press(&mut editor, "(");
4994 assert_eq!(editor.get_text(), "(hello) world");
4995 assert_eq!(vim_mode(&editor), EditorMode::Insert);
4996 }
4997
4998 fn mouse(kind: ratatui::crossterm::event::MouseEventKind, column: u16) -> InputEvent {
4999 InputEvent::Mouse(ratatui::crossterm::event::MouseEvent {
5000 kind,
5001 column,
5002 row: 0,
5003 modifiers: KeyModifiers::NONE,
5004 })
5005 }
5006
5007 fn rendered_vim_editor(text: &str) -> TextEditorComponent {
5008 use ratatui::Terminal;
5009 use ratatui::backend::TestBackend;
5010 let mut editor = make_vim_editor();
5011 editor.set_text(text.to_string());
5012 let mut term = Terminal::new(TestBackend::new(30, 4)).unwrap();
5013 term.draw(|f| editor.render(f, Rect::new(0, 0, 30, 4), &Theme::default(), true))
5014 .unwrap();
5015 editor
5016 }
5017
5018 #[test]
5021 fn right_click_in_visual_copies_like_ctrl_c() {
5022 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5023 let tx = dummy_tx();
5024 for (text, keys) in [("hello world", "ve"), ("hello", "V"), ("\nx", "V")] {
5025 let mut editor = rendered_vim_editor(text);
5026 press(&mut editor, keys);
5027 editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Right), 1), &tx);
5028 assert_eq!(
5029 vim_mode(&editor),
5030 EditorMode::Normal,
5031 "after `{keys}` on {text:?}"
5032 );
5033 assert_eq!(editor.selection, None);
5034 }
5035 }
5036
5037 #[test]
5040 fn a_drag_selects_exactly_what_was_dragged() {
5041 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5042 let tx = dummy_tx();
5043 for (from, to) in [(0, 5), (5, 0)] {
5044 let mut editor = rendered_vim_editor("hello world");
5045 editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), from), &tx);
5046 editor.handle_input(&mouse(MouseEventKind::Drag(MouseButton::Left), to), &tx);
5047 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5048 assert_eq!(editor.selection, Some(((0, 0), (0, 5))), "drag {from}→{to}");
5049 press(&mut editor, "d");
5050 assert_eq!(editor.get_text(), " world", "drag {from}→{to}");
5051 }
5052 }
5053
5054 #[test]
5057 fn a_click_leaves_visual() {
5058 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5059 let tx = dummy_tx();
5060 for keys in ["vll", "V"] {
5061 let mut editor = rendered_vim_editor("hello world");
5062 press(&mut editor, keys);
5063 editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), 8), &tx);
5064 assert_eq!(vim_mode(&editor), EditorMode::Normal, "after `{keys}`");
5065 press(&mut editor, "x");
5066 assert_eq!(
5067 editor.get_text(),
5068 "hello wold",
5069 "after `{keys}`: a plain `x`"
5070 );
5071 }
5072 }
5073
5074 #[test]
5076 fn gv_after_a_click_reselects_what_the_click_ended() {
5077 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5078 let tx = dummy_tx();
5079 let mut editor = rendered_vim_editor("hello world");
5080 press(&mut editor, "vll");
5081 editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), 8), &tx);
5082 press(&mut editor, "gv");
5083 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5084 assert_eq!(editor.selection, Some(((0, 0), (0, 3))));
5085 }
5086
5087 #[test]
5090 fn scrolling_keeps_visual() {
5091 use ratatui::crossterm::event::MouseEventKind;
5092 let tx = dummy_tx();
5093 for keys in ["jV", "jv", "ve"] {
5094 let mut editor = rendered_vim_editor("a\n\nb");
5095 press(&mut editor, keys);
5096 let mode = vim_mode(&editor);
5097 editor.handle_input(&mouse(MouseEventKind::ScrollDown, 1), &tx);
5098 assert_eq!(vim_mode(&editor), mode, "after `{keys}`");
5099 }
5100 }
5101
5102 #[test]
5105 fn a_drag_without_its_own_press_selects_nothing() {
5106 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5107 let tx = dummy_tx();
5108 let mut editor = rendered_vim_editor("hello world");
5109 editor.open_note(note("alpha"), "hello world".to_string());
5110 editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), 2), &tx);
5111 editor.open_note(note("beta"), "other text".to_string());
5112 editor.handle_input(&mouse(MouseEventKind::Drag(MouseButton::Left), 8), &tx);
5113 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5114 assert!(
5115 get_ta(&mut editor)
5116 .selection_range()
5117 .is_none_or(|(s, e)| s == e)
5118 );
5119
5120 let mut editor = rendered_vim_editor("hello world");
5121 editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), 2), &tx);
5122 editor.handle_input(&mouse(MouseEventKind::Up(MouseButton::Left), 2), &tx);
5123 get_ta(&mut editor).cancel_selection();
5124 editor.handle_input(&mouse(MouseEventKind::Drag(MouseButton::Left), 8), &tx);
5125 assert_eq!(
5126 vim_mode(&editor),
5127 EditorMode::Normal,
5128 "a drag after the release"
5129 );
5130 }
5131
5132 #[test]
5134 fn a_one_character_drag_selects_it() {
5135 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5136 let tx = dummy_tx();
5137 let mut editor = rendered_vim_editor("hello");
5138 editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), 1), &tx);
5139 editor.handle_input(&mouse(MouseEventKind::Drag(MouseButton::Left), 2), &tx);
5140 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5141 assert_eq!(editor.selection, Some(((0, 1), (0, 2))));
5142 press(&mut editor, "d");
5143 assert_eq!(editor.get_text(), "hllo");
5144 }
5145
5146 #[test]
5149 fn a_drag_that_turns_back_selects_from_its_origin() {
5150 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
5151 let tx = dummy_tx();
5152 let mut editor = rendered_vim_editor("hello world");
5153 editor.handle_input(&mouse(MouseEventKind::Down(MouseButton::Left), 6), &tx);
5154 for col in [3, 1, 0, 9, 11] {
5155 editor.handle_input(&mouse(MouseEventKind::Drag(MouseButton::Left), col), &tx);
5156 }
5157 assert_eq!(editor.selection, Some(((0, 6), (0, 11))));
5158 press(&mut editor, "d");
5159 assert_eq!(editor.get_text(), "hello ");
5160 }
5161
5162 #[test]
5165 fn a_pair_char_over_an_empty_visual_row_does_nothing() {
5166 for keys in ["jV*", "jv*"] {
5167 let mut editor = make_vim_editor();
5168 editor.set_text("a\n\nb".to_string());
5169 press(&mut editor, keys);
5170 assert_eq!(editor.get_text(), "a\n\nb", "{keys}");
5171 assert_ne!(
5172 vim_mode(&editor),
5173 EditorMode::Normal,
5174 "{keys}: still selecting"
5175 );
5176 }
5177 }
5178
5179 #[test]
5180 fn emphasis_over_an_empty_visual_row_does_nothing() {
5181 let mut editor = make_vim_editor();
5182 editor.set_text("a\n\nb".to_string());
5183 press(&mut editor, "jv");
5184 editor.apply_text_action(TextAction::Bold);
5185 assert_eq!(editor.get_text(), "a\n\nb");
5186 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5187 }
5188
5189 #[test]
5193 fn a_wrap_ending_in_a_line_break_does_not_chain() {
5194 let mut editor = make_vim_editor();
5195 editor.set_text("a\n\nz".to_string());
5196 press(&mut editor, "vj*");
5197 assert_eq!(editor.get_text(), "*a\n*\nz");
5198 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5199 press(&mut editor, "*");
5200 assert_eq!(editor.get_text(), "*a\n*\nz");
5201 }
5202
5203 #[test]
5205 fn emphasis_from_visual_line_keeps_the_row_text_selected() {
5206 let mut editor = make_vim_editor();
5207 editor.set_text("hello\nx".to_string());
5208 press(&mut editor, "V");
5209 editor.apply_text_action(TextAction::Bold);
5210 assert_eq!(editor.get_text(), "**hello**\nx");
5211 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5212 assert_eq!(editor.selection, Some(((0, 2), (0, 7))));
5213 }
5214
5215 #[test]
5217 fn surround_over_visual_line_wraps_the_whole_row() {
5218 let mut editor = make_vim_editor();
5219 let tx = dummy_tx();
5220 editor.set_text("a\nbold\nc".to_string());
5221 for c in ['j', 'l', 'V', '*'] {
5222 editor.handle_input(
5223 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
5224 &tx,
5225 );
5226 }
5227 assert_eq!(editor.get_text(), "a\n*bold*\nc");
5228 }
5229
5230 #[test]
5233 fn external_paste_without_a_selection_leaves_the_buffer_alone() {
5234 let mut editor = make_vim_editor();
5235 editor.set_text("hello world".to_string());
5236 editor.take_selection_for_external_paste();
5237 assert_eq!(editor.get_text(), "hello world");
5238 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5239 }
5240
5241 #[test]
5248 fn vim_visual_copy_leaves_visual_and_nothing_to_grow() {
5249 let mut editor = make_vim_editor();
5250 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5251 editor.set_text("hello world".to_string());
5252 press(&mut editor, "ve");
5253 assert_eq!(
5254 editor.backend.selection_as_shown(),
5255 Some(((0, 0), (0, 5))),
5256 "the copy reads the inclusive range including the cursor char"
5257 );
5258 editor.copy_selection_to_clipboard(&tx);
5259 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5260 assert_eq!(get_ta(&mut editor).selection_range(), None);
5261 editor.copy_selection_to_clipboard(&tx);
5262 assert_eq!(get_ta(&mut editor).selection_range(), None);
5263 assert_eq!(
5264 get_ta(&mut editor).cursor(),
5265 (0, 0),
5266 "at the copied range's start"
5267 );
5268 }
5269
5270 #[test]
5278 fn vim_visual_line_selection_stays_full_width_after_cursor_moves_back() {
5279 let mut editor = make_vim_editor();
5280 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5281 editor.set_text("hello world".to_string());
5282 editor.handle_input(
5283 &InputEvent::Key(key(KeyCode::Char('V'), KeyModifiers::NONE)),
5284 &tx,
5285 );
5286 assert_eq!(vim_mode(&editor), EditorMode::VisualLine);
5287 assert_eq!(
5288 editor.selection,
5289 Some(((0, 0), (0, usize::MAX))),
5290 "V must highlight the full line right away"
5291 );
5292 for _ in 0..5 {
5293 editor.handle_input(
5294 &InputEvent::Key(key(KeyCode::Left, KeyModifiers::NONE)),
5295 &tx,
5296 );
5297 }
5298 assert_eq!(
5299 editor.selection,
5300 Some(((0, 0), (0, usize::MAX))),
5301 "moving the cursor back must not shrink the linewise highlight"
5302 );
5303 }
5304
5305 #[test]
5312 fn vim_visual_outdent_with_nothing_to_remove_clears_the_highlight() {
5313 let mut editor = make_vim_editor();
5314 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5315 editor.set_text("alpha\nbeta".to_string());
5316 for c in ['V', 'j'] {
5317 editor.handle_input(
5318 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
5319 &tx,
5320 );
5321 }
5322 assert_eq!(vim_mode(&editor), EditorMode::VisualLine);
5323 assert!(
5324 editor.selection.is_some(),
5325 "V over two rows must paint a highlight"
5326 );
5327
5328 editor.handle_input(
5329 &InputEvent::Key(key(KeyCode::Char('<'), KeyModifiers::NONE)),
5330 &tx,
5331 );
5332 assert_eq!(
5333 editor.get_text(),
5334 "alpha\nbeta",
5335 "there is no indentation to remove"
5336 );
5337 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5338 assert_eq!(
5339 editor.selection, None,
5340 "leaving Visual mode must clear the highlight even when the outdent changed nothing"
5341 );
5342 }
5343
5344 #[test]
5354 fn vim_sync_collapsed_sel_stays_normal() {
5355 let mut editor = make_vim_editor();
5356 editor.set_text("hello world".to_string());
5357
5358 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5360
5361 editor.backend.sync_mouse_selection(false);
5364 assert_eq!(
5365 vim_mode(&editor),
5366 EditorMode::Normal,
5367 "collapsed (bare click) selection must not enter Visual mode"
5368 );
5369 }
5370
5371 #[test]
5373 fn vim_sync_real_sel_enters_visual() {
5374 let mut editor = make_vim_editor();
5375 editor.set_text("hello world".to_string());
5376
5377 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5379
5380 editor.backend.sync_mouse_selection(true);
5382 assert_eq!(
5383 vim_mode(&editor),
5384 EditorMode::Visual,
5385 "real drag selection must enter Visual mode"
5386 );
5387 }
5388
5389 #[test]
5393 fn vim_find_bar_captures_typing_not_cursor() {
5394 let mut editor = make_vim_editor();
5395 editor.set_text("hello world\nsecond line".to_string());
5396 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5397
5398 editor.open_or_advance_search();
5400 assert!(editor.search.is_some(), "find bar must be open");
5401
5402 editor.handle_input(
5404 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
5405 &tx,
5406 );
5407 editor.handle_input(
5408 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
5409 &tx,
5410 );
5411
5412 let q = editor
5416 .search
5417 .as_ref()
5418 .map(|s| s.input.value().to_string())
5419 .unwrap_or_default();
5420 assert_eq!(q, "lo", "find query must capture typed characters");
5421
5422 assert_eq!(
5425 editor.get_text(),
5426 "hello world\nsecond line",
5427 "buffer must not be modified while find bar is open"
5428 );
5429
5430 assert_eq!(
5436 editor.cursor_pos().1,
5437 3,
5438 "cursor must jump to the search match (col 3), not to a vim motion position"
5439 );
5440 }
5441
5442 #[test]
5447 fn vim_search_enter_steps_and_esc_keeps_the_pattern_for_n() {
5448 let mut editor = make_vim_editor();
5449 editor.set_text("lo xx lo yy lo".to_string());
5451 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5452
5453 editor.open_or_advance_search();
5455 assert!(editor.search.is_some(), "find bar must open");
5456
5457 editor.handle_input(
5459 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
5460 &tx,
5461 );
5462 editor.handle_input(
5463 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
5464 &tx,
5465 );
5466
5467 editor.handle_input(
5471 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
5472 &tx,
5473 );
5474 assert!(
5475 editor.search.is_some(),
5476 "find bar stays open on Enter — it steps, it does not confirm"
5477 );
5478 let (_, c1) = editor.cursor_pos();
5479 assert_eq!(c1, 6, "Enter must step to the 2nd 'lo' at col 6");
5480
5481 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
5485 assert!(editor.search.is_none(), "Esc must close the find bar");
5486
5487 editor.handle_input(
5489 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
5490 &tx,
5491 );
5492 let (_, c2) = editor.cursor_pos();
5493 assert_eq!(c2, 12, "'n' must jump to the 3rd 'lo' at col 12");
5494
5495 assert_eq!(editor.get_text(), "lo xx lo yy lo");
5497 }
5498}