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
132fn linkable_url(s: &str) -> Option<&str> {
133 kimun_core::note::scan::url_with_allowed_scheme(s, LINKABLE_PASTE_SCHEMES)
134}
135
136fn try_build_markdown_link(clip: &str, selection: Option<&str>) -> Option<String> {
140 let url = linkable_url(clip)?;
141 let sel = selection.filter(|s| !s.is_empty())?;
142 let escaped = sel.replace('\\', r"\\").replace(']', r"\]");
143 Some(format!("[{escaped}]({url})"))
144}
145
146use std::sync::Arc;
147
148use kimun_core::NoteVault;
149
150use crate::components::Component;
151use crate::components::autocomplete::{
152 self, AutocompleteController, AutocompleteHost, AutocompleteMode, HandleKeyOutcome,
153};
154use crate::components::event_state::EventState;
155use crate::components::events::AppEvent;
156use crate::components::events::AppTx;
157use crate::components::events::InputEvent;
158use crate::components::events::redraw_callback;
159use crate::components::text_editor::autocomplete_glue::apply_accept_to_textarea;
160use crate::keys::KeyBindings;
161use crate::keys::action_shortcuts::TextAction;
162use crate::settings::AppSettings;
163use crate::settings::themes::Theme;
164
165#[derive(Debug, Clone, PartialEq)]
173pub enum FollowTarget {
174 Link(String),
176 Label(String),
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
188pub enum EditorClaim {
189 #[default]
190 None,
191 FindBar,
192 Autocomplete,
193}
194
195struct EditorHostSnapshot {
202 snap: EditorSnapshot,
203 cursor_screen: Option<(u16, u16)>,
204 cache_key: Option<NonZeroU64>,
205}
206
207impl AutocompleteHost for EditorHostSnapshot {
208 fn buffer_snapshot(&self) -> EditorSnapshot {
209 EditorSnapshot::of_buffer(
214 self.snap.text.clone(),
215 self.snap.cursor,
216 self.snap.content_revision,
217 )
218 }
219 fn cache_key(&self) -> Option<NonZeroU64> {
220 self.cache_key
221 }
222 fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
223 Some(self.cursor_screen.unwrap_or((0, 0)))
237 }
238}
239
240fn build_editor_host_snapshot(
246 backend: &BackendState,
247 content_revision: NonZeroU64,
248 cursor_screen: Option<(u16, u16)>,
249) -> Option<EditorHostSnapshot> {
250 if !backend.is_textarea() {
251 return None;
252 }
253 Some(EditorHostSnapshot {
254 snap: snapshot_from_backend(backend, content_revision),
255 cursor_screen,
256 cache_key: Some(content_revision),
257 })
258}
259
260pub struct TextEditorComponent {
264 backend: BackendState,
265 rect: Rect,
267 key_bindings: KeyBindings,
268 view: MarkdownEditorView,
269 revs: Revisions,
278 selection: Option<((usize, usize), (usize, usize))>,
281 nvim_host: NvimHost,
284 search: Option<find_bar::FindBar>,
286 autocomplete: Option<AutocompleteController>,
290 autocomplete_vault: Option<Arc<NoteVault>>,
294 autocomplete_redraw_bound: bool,
299 full_parse_task: SingleSlotTask<()>,
306 layout_task: SingleSlotTask<()>,
313 last_insert_session: bool,
317 pub wants_context_menu: bool,
320 search_needles: Vec<String>,
324 full_parse_tx: tokio::sync::mpsc::UnboundedSender<(u64, ParsedBuffer)>,
325 full_parse_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, ParsedBuffer)>,
326 layout_tx: tokio::sync::mpsc::UnboundedSender<(u64, crate::ropetext::Layout)>,
327 layout_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, crate::ropetext::Layout)>,
328 redraw_tx: Option<AppTx>,
332}
333
334impl TextEditorComponent {
335 pub fn new(key_bindings: KeyBindings, settings: &AppSettings) -> Self {
336 let (full_parse_tx, full_parse_rx) = tokio::sync::mpsc::unbounded_channel();
337 let (layout_tx, layout_rx) = tokio::sync::mpsc::unbounded_channel();
338 Self {
339 backend: BackendState::from_settings(
340 &settings.editor_backend,
341 settings.nvim_path.as_ref(),
342 ),
343 rect: Rect::default(),
344 key_bindings,
345 view: MarkdownEditorView::new(),
346 revs: Revisions::new(),
347 selection: None,
348 nvim_host: NvimHost::new(),
349 search: None,
350 autocomplete: None,
351 autocomplete_vault: None,
352 autocomplete_redraw_bound: false,
353 full_parse_task: SingleSlotTask::empty(),
354 layout_task: SingleSlotTask::empty(),
355 last_insert_session: false,
356 wants_context_menu: false,
357 search_needles: Vec::new(),
358 full_parse_tx,
359 full_parse_rx,
360 layout_tx,
361 layout_rx,
362 redraw_tx: None,
363 }
364 }
365
366 pub fn set_vault(&mut self, vault: Arc<NoteVault>) {
371 self.autocomplete_vault = Some(vault.clone());
372 if self.backend.is_textarea() {
373 self.autocomplete = Some(AutocompleteController::new(
374 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
375 AutocompleteMode::Both,
376 ));
377 }
378 }
379
380 fn ensure_autocomplete_for_textarea(&mut self) {
385 if self.autocomplete.is_some() {
386 return;
387 }
388 if !self.backend.is_textarea() {
389 return;
390 }
391 let Some(vault) = self.autocomplete_vault.clone() else {
392 return;
393 };
394 self.autocomplete = Some(AutocompleteController::new(
395 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
396 AutocompleteMode::Both,
397 ));
398 self.autocomplete_redraw_bound = false;
401 }
402
403 #[allow(dead_code)]
410 fn autocomplete_host_snapshot(&self) -> Option<EditorHostSnapshot> {
411 build_editor_host_snapshot(
412 &self.backend,
413 self.revs.current(),
414 self.view.last_cursor_screen,
415 )
416 }
417
418 fn poll_autocomplete(&mut self) {
421 if let Some(controller) = self.autocomplete.as_mut() {
422 controller.poll_results();
423 }
424 }
425
426 fn textarea_cursor(&self) -> Option<(usize, usize)> {
430 let ta = self.backend.as_textarea()?;
431 Some(ta.cursor())
432 }
433
434 fn refresh_autocomplete_if_open(&mut self) {
435 if !self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
437 return;
438 }
439 let Some(snapshot) = build_editor_host_snapshot(
443 &self.backend,
444 self.revs.current(),
445 self.view.last_cursor_screen,
446 ) else {
447 self.close_autocomplete();
448 return;
449 };
450 if let Some(controller) = self.autocomplete.as_mut() {
451 controller.refresh_if_open(&snapshot);
452 }
453 }
454
455 fn sync_autocomplete(&mut self) {
459 let Some(controller) = self.autocomplete.as_ref() else {
460 return; };
462
463 if !controller.is_open() {
476 let Some(ta) = self.backend.as_textarea() else {
477 return;
478 };
479 let (row, col) = ta.cursor();
480 let line = ta.row(row).unwrap_or_default();
481 if !has_trigger_before_cursor(&line, col) {
482 return;
483 }
484 }
485
486 let Some(snapshot) = build_editor_host_snapshot(
490 &self.backend,
491 self.revs.current(),
492 self.view.last_cursor_screen,
493 ) else {
494 if let Some(c) = self.autocomplete.as_mut() {
495 c.close();
496 }
497 return;
498 };
499 if let Some(controller) = self.autocomplete.as_mut() {
500 controller.sync(&snapshot);
501 }
502 }
503
504 pub fn text(&self) -> crate::ropetext::Text {
511 match &self.backend {
512 BackendState::Textarea(tb) => tb.ta.text().clone(),
513 BackendState::Nvim(_) => crate::ropetext::Text::new(),
514 }
515 }
516
517 pub fn view_snapshot(&self) -> EditorSnapshot {
536 snapshot_from_backend(&self.backend, self.revs.current())
537 }
538
539 pub fn cursor_pos(&self) -> (usize, usize) {
543 self.backend.cursor()
544 }
545
546 pub fn set_search_needles(&mut self, needles: Vec<String>) {
550 self.search_needles = needles
551 .into_iter()
552 .map(|n| n.to_lowercase())
553 .filter(|n| !n.is_empty())
554 .collect();
555 self.revs.arm_needles();
556 }
557
558 pub fn set_text(&mut self, text: String) {
559 if text == self.get_text() {
566 self.revs.mark_saved_current();
567 if let Some(nvim) = self.backend.as_nvim() {
568 nvim.mark_clean();
569 }
570 return;
571 }
572 match &mut self.backend {
573 BackendState::Textarea(tb) => {
574 tb.ta.replace(crate::ropetext::Text::from(text.as_str()));
575 }
576 BackendState::Nvim(nvim) => {
577 nvim.set_text(&text);
578 }
579 }
580 self.backend.reset_input_state();
581 self.bump_content();
582 let reconstructed = self.get_text();
583 self.mark_saved(reconstructed);
584 self.close_autocomplete();
587 self.search = None;
596 self.selection = None;
597 self.view.note_bulk_edit();
602 }
603
604 pub fn get_text(&self) -> String {
605 self.backend.text()
606 }
607
608 pub fn content_revision(&self) -> NonZeroU64 {
615 self.revs.current()
616 }
617
618 pub fn mark_saved_at_revision(&mut self, rev: NonZeroU64) {
628 if !self.revs.mark_saved_at(rev) {
629 return;
630 }
631 self.interrupt_typing();
635 if let Some(nvim) = self.backend.as_nvim() {
636 nvim.mark_clean();
637 }
638 }
639
640 pub fn mark_saved(&mut self, text: String) {
648 self.interrupt_typing();
649 let matches = text == self.get_text();
650 if matches {
651 if let Some(nvim) = self.backend.as_nvim() {
652 nvim.mark_clean();
653 }
654 self.revs.mark_saved_current();
655 } else {
656 self.revs.mark_diverged();
661 }
662 }
663
664 fn interrupt_typing(&mut self) {
681 self.view.clear_visual_goal();
682 if self.backend.modal_is_insert().unwrap_or(false) {
683 return;
684 }
685 if let Some((_, run)) = self.backend.as_textarea_parts_mut() {
686 run.end();
687 }
688 }
689
690 fn sync_insert_session(&mut self) {
697 let in_insert = self.backend.modal_is_insert().unwrap_or(false);
698 if self.last_insert_session == in_insert {
699 return;
700 }
701 self.last_insert_session = in_insert;
702 if let Some((_, run)) = self.backend.as_textarea_parts_mut() {
703 run.end();
704 }
705 }
706
707 pub fn is_dirty(&self) -> bool {
708 match &self.backend {
709 BackendState::Textarea(_) => self.revs.is_dirty(),
710 BackendState::Nvim(nvim) => nvim.snapshot().dirty,
711 }
712 }
713
714 pub fn space_leads(&self) -> bool {
723 self.backend.space_leads()
724 }
725
726 pub fn mouse_drives_cursor(&self) -> bool {
734 self.backend.is_textarea()
735 }
736
737 pub fn covers(&self, column: u16, row: u16) -> bool {
747 self.rect
748 .contains(ratatui::layout::Position::new(column, row))
749 }
750
751 pub fn claim(&self) -> EditorClaim {
756 if self.search.is_some() {
757 EditorClaim::FindBar
758 } else if self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
759 EditorClaim::Autocomplete
760 } else {
761 EditorClaim::None
762 }
763 }
764
765 pub fn follow_target_at_cursor(&self) -> Option<FollowTarget> {
768 let (_row, col, line) = match &self.backend {
769 BackendState::Textarea(tb) => {
770 let (row, col) = tb.ta.cursor();
771 let line = tb.ta.row(row)?.into_owned();
772 (row, col, line)
773 }
774 BackendState::Nvim(nvim) => {
775 let snap = nvim.snapshot();
776 let (row, col) = snap.cursor;
777 let line = snap.lines.get(row)?.to_string();
778 (row, col, line)
779 }
780 };
781
782 if let Some(span) = kimun_core::note::scan::link_char_spans(&line)
785 .into_iter()
786 .find(|s| s.start <= col && col < s.end)
787 {
788 return Some(FollowTarget::Link(span.target));
789 }
790
791 let parsed = self::markdown::ParsedLine::parse(&line);
793 parsed
794 .elements
795 .iter()
796 .find(|e| {
797 e.kind == self::markdown::ElementKind::Label
798 && col >= e.start_char
799 && col < e.end_char
800 })
801 .map(|e| {
802 let span: String = line
803 .chars()
804 .skip(e.start_char)
805 .take(e.end_char - e.start_char)
806 .collect();
807 let name = span.trim_start_matches('#').to_string();
808 FollowTarget::Label(name)
809 })
810 }
811
812 fn copy_selection_to_clipboard(&mut self, tx: &AppTx) {
818 let text = {
819 let selected = self
828 .inclusive_visual_range()
829 .zip(self.backend.as_textarea())
830 .and_then(|(range, ta)| ta.text_between(range.0, range.1));
831 match selected {
832 Some(t) if !t.is_empty() => t,
833 _ => {
834 tx.send(AppEvent::FlashMessage("nothing to copy".into()))
835 .ok();
836 return;
837 }
838 }
839 };
840 crate::components::yank(text, "copied", tx);
841 }
842
843 fn inclusive_visual_range(&self) -> Option<((usize, usize), (usize, usize))> {
849 let charwise = self.backend.selection_includes_cursor();
850 let ta = self.backend.as_textarea()?;
851 let (start, (er, ec)) = ta.selection_range()?;
852 let end = if charwise {
853 let len = ta.row(er).map(|l| l.chars().count()).unwrap_or(ec);
854 (er, (ec + 1).min(len))
855 } else {
856 (er, ec)
857 };
858 Some((start, end))
859 }
860
861 fn paste_from_clipboard(&mut self, tx: &AppTx) {
865 let text = match crate::components::with_clipboard(|c| c.get_text()) {
866 Ok(t) if !t.is_empty() => t,
867 Ok(_) => {
868 tx.send(AppEvent::FlashMessage("clipboard is empty".into()))
869 .ok();
870 return;
871 }
872 Err(e) => {
873 tx.send(AppEvent::FlashMessage(format!("clipboard: {e}")))
874 .ok();
875 return;
876 }
877 };
878 self.paste_text(&text, tx);
879 tx.send(AppEvent::FlashMessage("pasted".into())).ok();
883 }
884
885 fn extend_visual_selection_inclusive(&mut self) {
902 if !self.backend.selection_includes_cursor() {
903 return;
904 }
905 if let Some((start, end)) = self.inclusive_visual_range()
906 && let Some(ta) = self.backend.as_textarea_mut()
907 {
908 ta.set_selection(start, end);
909 }
910 }
911
912 pub fn paste_text(&mut self, text: &str, tx: &AppTx) {
913 if text.is_empty() {
914 return;
915 }
916 if self.search.is_some() {
923 if let (Some(bar), BackendState::Textarea(tb)) =
924 (self.search.as_mut(), &mut self.backend)
925 {
926 bar.paste(text, &mut tb.ta);
927 }
928 self.apply_edit_outcome();
929 return;
930 }
931 self.extend_visual_selection_inclusive();
932 match &mut self.backend {
933 BackendState::Textarea(tb) => {
934 let selection = linkable_url(text).and_then(|_| tb.ta.selection_text());
935 let wrapped = try_build_markdown_link(text, selection.as_deref());
936 let insert = wrapped.as_deref().unwrap_or(text).to_string();
937 tb.ta.edit(|ta| {
940 if ta.selection_range().is_some() {
941 ta.cut();
942 }
943 ta.insert_str(insert);
944 });
945 self.after_edit();
946 }
947 BackendState::Nvim(nvim) => {
948 nvim.paste(text, tx.clone());
949 self.bump_content();
950 }
951 }
952 self.bind_autocomplete_redraw(tx);
956 self.sync_autocomplete();
957 }
958
959 pub fn insert_at_cursor(&mut self, text: &str, tx: &AppTx) {
964 if matches!(self.backend, BackendState::Nvim(_)) {
965 self.paste_text(text, tx);
966 return;
967 }
968 self.take_selection_for_external_paste();
975 if let Some(ta) = self.backend.as_textarea_mut() {
976 ta.insert_str(text);
977 self.after_edit();
978 }
979 self.bind_autocomplete_redraw(tx);
982 self.sync_autocomplete();
983 }
984
985 pub fn take_clipboard_image(&mut self) -> Option<ClipboardImage> {
995 let img = crate::components::with_clipboard(|c| c.get_image()).ok()?;
996 Some(ClipboardImage {
997 width: img.width,
998 height: img.height,
999 rgba: img.bytes.into_owned(),
1000 })
1001 }
1002
1003 pub fn take_selection_for_external_paste(&mut self) {
1012 self.extend_visual_selection_inclusive();
1013 let cut = if let Some(ta) = self.backend.as_textarea_mut() {
1014 let cut = ta.selection_range().is_some() && ta.cut();
1015 self.selection = ta.selection_range();
1016 cut
1017 } else {
1018 false
1019 };
1020 if cut {
1021 self.apply_edit_outcome();
1022 }
1023 self.backend.sync_mouse_selection(false);
1026 }
1027
1028 fn wrap_selection(&mut self, open: &str, close: &str) -> bool {
1033 self.extend_visual_selection_inclusive();
1034 let Some(ta) = self.backend.as_textarea_mut() else {
1035 return false;
1036 };
1037 if !markdown_edits::wrap_selection(ta, open, close) {
1038 return false;
1039 }
1040 self.after_edit();
1044 true
1045 }
1046
1047 pub fn apply_text_action(&mut self, action: TextAction) {
1051 let Some(marker) = markdown_edits::emphasis_marker(action) else {
1052 return;
1053 };
1054 if self.wrap_selection(marker, marker) {
1055 return;
1056 }
1057 let Some(ta) = self.backend.as_textarea_mut() else {
1058 return;
1059 };
1060 markdown_edits::insert_pair(ta, marker, marker);
1061 self.after_edit();
1062 }
1063
1064 pub fn smart_enter(&mut self) -> bool {
1068 let Some(ta) = self.backend.as_textarea_mut() else {
1069 return false;
1070 };
1071 if !markdown_edits::smart_enter(ta) {
1072 return false;
1073 }
1074 self.after_edit();
1075 true
1076 }
1077
1078 pub fn jump_to_heading(&mut self, heading: &str) {
1081 let Some(ta) = self.backend.as_textarea_mut() else {
1082 return;
1083 };
1084 if markdown_edits::jump_to_heading(ta, heading) {
1085 self.selection = self
1086 .backend
1087 .as_textarea()
1088 .and_then(|ta| ta.selection_range());
1089 }
1090 }
1091
1092 pub fn indent_lines(&mut self, dedent: bool) {
1097 let Some(ta) = self.backend.as_textarea_mut() else {
1098 return;
1099 };
1100 let rows = match ta.selection_range() {
1101 Some(((sr, _), (er, ec))) => {
1102 let last = if ec == 0 && er > sr { er - 1 } else { er };
1103 sr..=last
1104 }
1105 None => {
1106 let (row, _) = ta.cursor();
1107 row..=row
1108 }
1109 };
1110 if ta.indent_rows(rows, dedent) {
1111 self.after_edit();
1112 }
1113 }
1114}
1115
1116impl TextEditorComponent {
1117 #[inline]
1127 fn bump_content(&mut self) {
1128 self.revs.bump();
1129 }
1130
1131 fn maybe_recover_from_dead_nvim(&mut self) {
1133 if self.backend.recover_from_dead_nvim() {
1134 self.ensure_autocomplete_for_textarea();
1138 }
1139 }
1140
1141 fn handle_nvim_key(
1146 &mut self,
1147 key: &ratatui::crossterm::event::KeyEvent,
1148 tx: &AppTx,
1149 ) -> Option<EventState> {
1150 let nvim = self.backend.as_nvim()?;
1154 self.nvim_host.handle_key(nvim, key, tx);
1159 Some(EventState::Consumed)
1160 }
1161
1162 pub fn open_or_advance_search(&mut self) {
1166 if !self.backend.is_textarea() {
1167 return;
1168 }
1169 if self.search.is_some() {
1170 self.dispatch_bar(|bar, buf| {
1171 bar.advance(buf, false);
1172 find_bar::KeyOutcome::default()
1173 });
1174 return;
1175 }
1176 self.close_autocomplete();
1179 self.search = Some(find_bar::FindBar::new());
1180 }
1181
1182 pub fn open_replace(&mut self) {
1185 if !self.backend.is_textarea() {
1186 return;
1187 }
1188 if self.search.is_none() {
1189 self.close_autocomplete();
1190 self.search = Some(find_bar::FindBar::new());
1191 }
1192 if let Some(bar) = self.search.as_mut() {
1193 bar.reveal_replace();
1194 }
1195 }
1196
1197 fn search_repeat(&mut self, backward: bool) {
1200 let BackendState::Textarea(tb) = &mut self.backend else {
1201 return;
1202 };
1203 self.selection = if tb.ta.search_repeat(backward) {
1206 tb.ta.match_at_cursor()
1207 } else {
1208 None
1209 };
1210 }
1211
1212 fn dispatch_bar(
1215 &mut self,
1216 f: impl FnOnce(&mut find_bar::FindBar, &mut RopeBuffer) -> find_bar::KeyOutcome,
1217 ) -> bool {
1218 let BackendState::Textarea(tb) = &mut self.backend else {
1219 return false;
1220 };
1221 let Some(bar) = self.search.as_mut() else {
1222 return false;
1223 };
1224 let outcome = f(bar, &mut tb.ta);
1225 if outcome.close {
1226 self.search = None;
1227 tb.ta.cancel_selection();
1233 self.selection = None;
1234 }
1235 self.apply_edit_outcome();
1236 true
1237 }
1238
1239 fn dispatch_to_find_bar(&mut self, key: &ratatui::crossterm::event::KeyEvent) -> bool {
1241 self.dispatch_bar(|bar, buf| bar.handle_key(key, buf))
1242 }
1243
1244 #[cfg(test)]
1247 fn replace_preview(&self) -> Option<find_replace::Preview> {
1248 let bar = self.search.as_ref()?;
1249 let buf = self.backend.as_textarea()?;
1250 bar.preview(buf)
1251 }
1252
1253 pub fn close_autocomplete(&mut self) {
1257 if let Some(c) = self.autocomplete.as_mut() {
1258 c.close();
1259 }
1260 }
1261
1262 pub fn set_redraw_tx(&mut self, tx: &AppTx) {
1267 self.bind_autocomplete_redraw(tx);
1268 }
1269
1270 fn bind_autocomplete_redraw(&mut self, tx: &AppTx) {
1279 if self.redraw_tx.is_none() {
1280 self.redraw_tx = Some(tx.clone());
1281 }
1282 if self.autocomplete_redraw_bound {
1283 return;
1284 }
1285 if let Some(c) = self.autocomplete.as_mut() {
1286 c.set_redraw_callback(redraw_callback(tx.clone()));
1287 self.autocomplete_redraw_bound = true;
1288 }
1289 }
1290
1291 fn after_edit(&mut self) -> bool {
1297 self.interrupt_typing();
1298 self.selection = self
1299 .backend
1300 .as_textarea()
1301 .and_then(|ta| ta.selection_range());
1302 self.apply_edit_outcome()
1303 }
1304
1305 fn apply_edit_outcome(&mut self) -> bool {
1316 let Some(outcome) = self.backend.as_textarea_mut().map(|ta| ta.take_outcome()) else {
1317 return false;
1318 };
1319 if outcome.changed {
1320 self.bump_content();
1321 }
1322 if outcome.bulk {
1323 self.view.note_bulk_edit();
1324 }
1325 if let Some(rows) = outcome.damage {
1326 self.view.note_damage(rows, outcome.line_delta);
1327 }
1328 outcome.changed
1329 }
1330
1331 fn undo_grouped(&mut self) -> bool {
1334 let moved = self.backend.as_textarea_mut().is_some_and(|ta| ta.undo());
1335 if moved {
1336 self.selection = self
1337 .backend
1338 .as_textarea()
1339 .and_then(|ta| ta.selection_range());
1340 }
1341 moved
1342 }
1343
1344 fn redo_grouped(&mut self) -> bool {
1346 let moved = self.backend.as_textarea_mut().is_some_and(|ta| ta.redo());
1347 if moved {
1348 self.selection = self
1349 .backend
1350 .as_textarea()
1351 .and_then(|ta| ta.selection_range());
1352 }
1353 moved
1354 }
1355
1356 fn handle_textarea_key(
1358 &mut self,
1359 key: &ratatui::crossterm::event::KeyEvent,
1360 tx: &AppTx,
1361 ) -> EventState {
1362 let stroke = plain_keys::operation(*key).and_then(|op| match op {
1372 plain_keys::Operation::Insert(c) => Some(typing_run::Stroke::Insert(c)),
1373 plain_keys::Operation::InsertNewline => Some(typing_run::Stroke::Insert('\n')),
1374 plain_keys::Operation::DeleteBack | plain_keys::Operation::DeleteForward => {
1375 Some(typing_run::Stroke::Delete)
1376 }
1377 _ => None,
1378 });
1379 if stroke.is_none()
1380 && let Some((_, run)) = self.backend.as_textarea_parts_mut()
1381 {
1382 run.end();
1383 }
1384
1385 if key.modifiers == KeyModifiers::CONTROL {
1387 match key.code {
1388 KeyCode::Char('c') => {
1389 self.copy_selection_to_clipboard(tx);
1390 return EventState::Consumed;
1391 }
1392 KeyCode::Char('v') => {
1393 self.paste_from_clipboard(tx);
1394 return EventState::Consumed;
1395 }
1396 KeyCode::Char('x') => {
1397 self.copy_selection_to_clipboard(tx);
1398 let cut = if let Some(ta) = self.backend.as_textarea_mut() {
1399 let cut = ta.cut();
1405 self.selection = ta.selection_range();
1406 cut
1407 } else {
1408 false
1409 };
1410 if cut {
1411 self.apply_edit_outcome();
1412 }
1413 return EventState::Consumed;
1414 }
1415 _ => {}
1416 }
1417 }
1418
1419 if key.modifiers & !KeyModifiers::SHIFT == KeyModifiers::CONTROL {
1424 match key.code {
1425 KeyCode::Char('z') if !key.modifiers.contains(KeyModifiers::SHIFT) => {
1426 if self.undo_grouped() {
1427 self.apply_edit_outcome();
1428 }
1429 return EventState::Consumed;
1430 }
1431 KeyCode::Char('y') | KeyCode::Char('Z') => {
1432 if self.redo_grouped() {
1433 self.apply_edit_outcome();
1434 }
1435 return EventState::Consumed;
1436 }
1437 _ => {}
1438 }
1439 }
1440
1441 match (key.modifiers, key.code) {
1453 (m, KeyCode::Tab)
1454 if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
1455 {
1456 self.indent_lines(m.contains(KeyModifiers::SHIFT));
1457 return EventState::Consumed;
1458 }
1459 (_, KeyCode::BackTab) => {
1460 self.indent_lines(true);
1461 return EventState::Consumed;
1462 }
1463 _ => {}
1464 }
1465 if key.code == KeyCode::Enter && key.modifiers.is_empty() && self.smart_enter() {
1466 return EventState::Consumed;
1467 }
1468
1469 if let KeyCode::Char(c) = key.code
1476 && (key.modifiers & !KeyModifiers::SHIFT).is_empty()
1477 && let Some((open, close)) = markdown_edits::surround_pair(c)
1478 && self.wrap_selection(open, close)
1479 {
1480 return EventState::Consumed;
1481 }
1482
1483 self.sync_insert_session();
1489
1490 let in_insert_session = self.last_insert_session;
1492 let Some((ta, run)) = self.backend.as_textarea_parts_mut() else {
1493 unreachable!("handle_textarea_key called with non-Textarea backend")
1494 };
1495 if let Some(op) = plain_keys::operation(*key) {
1500 let vertical = match op {
1504 plain_keys::Operation::Move {
1505 to: CursorMove::Up,
1506 extend,
1507 } => Some((false, extend)),
1508 plain_keys::Operation::Move {
1509 to: CursorMove::Down,
1510 extend,
1511 } => Some((true, extend)),
1512 _ => None,
1513 };
1514 if vertical.is_none() {
1515 self.view.clear_visual_goal();
1516 }
1517 if let Some(stroke) = stroke {
1523 {
1524 let now = std::time::Instant::now();
1525 let carries_on = if in_insert_session {
1530 run.continues_session(stroke, now)
1531 } else {
1532 run.continues(stroke, now)
1533 };
1534 if carries_on {
1535 ta.continue_group();
1536 }
1537 }
1538 }
1539
1540 let changed = match vertical {
1541 Some((down, extend)) if self.view.move_cursor_visually(ta, down, extend) => false,
1544 _ => plain_keys::apply(op, ta),
1545 };
1546 self.selection = ta.selection_range();
1547 if changed {
1548 self.apply_edit_outcome();
1549 }
1550 }
1551 EventState::Consumed
1555 }
1556
1557 fn handle_mouse(
1559 &mut self,
1560 mouse: &ratatui::crossterm::event::MouseEvent,
1561 tx: &AppTx,
1562 ) -> EventState {
1563 if !self.covers(mouse.column, mouse.row) {
1564 return EventState::NotConsumed;
1565 }
1566 let r = self.rect;
1569 self.interrupt_typing();
1573 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right))
1577 && self.selection.is_none_or(|(start, end)| start == end)
1578 {
1579 self.wants_context_menu = true;
1580 return EventState::Consumed;
1581 }
1582 if !self.backend.is_textarea() {
1586 return EventState::NotConsumed;
1587 }
1588 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
1590 self.copy_selection_to_clipboard(tx);
1591 self.selection = if let Some(ta) = self.backend.as_textarea() {
1592 ta.selection_range()
1593 } else {
1594 None
1595 };
1596 return EventState::Consumed;
1597 }
1598 let Some(ta) = self.backend.as_textarea_mut() else {
1600 unreachable!()
1601 };
1602 match mouse.kind {
1603 MouseEventKind::Down(_) => {
1604 ta.cancel_selection();
1605 let (lrow, lcol) = self
1606 .view
1607 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1608 ta.jump_to(lrow as usize, lcol as usize);
1609 ta.start_selection();
1610 }
1611 MouseEventKind::Drag(_) => {
1612 let (lrow, lcol) = self
1613 .view
1614 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1615 ta.jump_to(lrow as usize, lcol as usize);
1616 }
1617 _ => {}
1622 }
1623 self.selection = ta.selection_range();
1624 EventState::Consumed
1627 }
1628}
1629
1630impl Component for TextEditorComponent {
1635 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
1636 self.maybe_recover_from_dead_nvim();
1637 self.bind_autocomplete_redraw(tx);
1638
1639 match event {
1640 InputEvent::Key(key) => {
1641 let popup_open = self.autocomplete.as_ref().is_some_and(|c| c.is_open());
1649 if popup_open
1650 && let Some(host) = build_editor_host_snapshot(
1651 &self.backend,
1652 self.revs.current(),
1653 self.view.last_cursor_screen,
1654 )
1655 && let Some(controller) = self.autocomplete.as_mut()
1656 {
1657 match controller.handle_key(*key, &host) {
1658 HandleKeyOutcome::Accepted(action) => {
1659 self.interrupt_typing();
1660 if let Some(ta) = self.backend.as_textarea_mut() {
1661 ta.edit(|ta| apply_accept_to_textarea(ta, &action));
1662 }
1663 self.after_edit();
1664 return EventState::Consumed;
1665 }
1666 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
1667 return EventState::Consumed;
1668 }
1669 HandleKeyOutcome::NotHandled => {}
1670 }
1671 }
1672 if self.dispatch_to_find_bar(key) {
1677 self.interrupt_typing();
1681 return EventState::Consumed;
1682 }
1683 if let Some(outcome) = self.backend.vim_handle_key(key) {
1688 use self::vim::VimKeyOutcome;
1689 if !matches!(outcome, VimKeyOutcome::PassThrough) {
1694 self.interrupt_typing();
1695 }
1696 self.apply_edit_outcome();
1700 match outcome {
1701 VimKeyOutcome::TextMutated => {
1702 self.selection = None;
1705 return EventState::Consumed;
1706 }
1707 VimKeyOutcome::CursorOnly => {
1708 self.selection = self
1713 .backend
1714 .as_textarea()
1715 .and_then(|ta| ta.selection_range());
1716 if self.backend.selection_includes_cursor()
1719 && let Some(((sr, sc), (er, ec))) = self.selection
1720 {
1721 let len = self
1722 .backend
1723 .as_textarea()
1724 .and_then(|ta| ta.row(er))
1725 .map(|l| l.chars().count())
1726 .unwrap_or(ec);
1727 self.selection = Some(((sr, sc), (er, (ec + 1).min(len))));
1728 }
1729 if self.backend.is_visual_line()
1737 && let Some(((sr, _), (er, _))) = self.selection
1738 {
1739 self.selection = Some(((sr, 0), (er, usize::MAX)));
1740 }
1741 self.refresh_autocomplete_if_open();
1742 return EventState::Consumed;
1743 }
1744 VimKeyOutcome::NoOp => return EventState::Consumed,
1745 VimKeyOutcome::PassThrough => { }
1746 VimKeyOutcome::Host(action) => {
1747 use self::vim::VimHostAction;
1748 match action {
1749 VimHostAction::OpenPalette => {
1750 tx.send(AppEvent::ExecuteLeaderAction(
1752 crate::keys::leader::LeaderAction::Palette,
1753 ))
1754 .ok();
1755 }
1756 VimHostAction::OpenSearch { forward: _ } => {
1757 self.open_or_advance_search();
1761 }
1762 VimHostAction::SearchNext => self.search_repeat(false),
1763 VimHostAction::SearchPrev => self.search_repeat(true),
1764 VimHostAction::ClipboardCopy(text) => {
1768 self.selection = None;
1769 crate::components::yank(text, "copied", tx);
1770 }
1771 VimHostAction::ClipboardCut(text) => {
1772 self.selection = None;
1773 crate::components::yank(text, "cut", tx);
1774 }
1775 VimHostAction::ClipboardPaste => {
1781 self.selection = self
1782 .backend
1783 .as_textarea()
1784 .and_then(|ta| ta.selection_range());
1785 self.paste_from_clipboard(tx);
1786 }
1787 }
1788 return EventState::Consumed;
1789 }
1790 }
1791 }
1792 if let Some(state) = self.handle_nvim_key(key, tx) {
1793 return state;
1794 }
1795 let text_rev_before = self.revs.current();
1806 let cursor_before = self.textarea_cursor();
1807 let result = self.handle_textarea_key(key, tx);
1808 let cursor_after = self.textarea_cursor();
1809 if self.revs.current() != text_rev_before {
1810 self.sync_autocomplete();
1811 } else if cursor_before != cursor_after {
1812 self.refresh_autocomplete_if_open();
1813 }
1814 result
1815 }
1816 InputEvent::Mouse(mouse) => {
1817 let text_rev_before = self.revs.current();
1818 let cursor_before = self.textarea_cursor();
1819 let result = self.handle_mouse(mouse, tx);
1820 let cursor_after = self.textarea_cursor();
1821 if self.revs.current() != text_rev_before {
1824 self.sync_autocomplete();
1825 } else if cursor_before != cursor_after {
1826 self.refresh_autocomplete_if_open();
1827 }
1828 let has_sel = self
1846 .backend
1847 .as_textarea()
1848 .and_then(|ta| ta.selection_range())
1849 .is_some_and(|(s, e)| s != e);
1850 self.backend.sync_mouse_selection(has_sel);
1851 result
1852 }
1853 InputEvent::Paste(_) => EventState::NotConsumed,
1856 }
1857 }
1858
1859 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
1860 let bar_rows: u16 = self.search.as_ref().map_or(0, |bar| bar.rows());
1865 let bar_rows = bar_rows.min(rect.height);
1870 let (editor_rect, search_rect) = if bar_rows > 0 {
1871 (
1872 Rect {
1873 height: rect.height - bar_rows,
1874 ..rect
1875 },
1876 Some(Rect {
1877 y: rect.y + rect.height - bar_rows,
1878 height: bar_rows,
1879 ..rect
1880 }),
1881 )
1882 } else {
1883 (rect, None)
1884 };
1885 self.rect = editor_rect;
1888 let selection = match &self.backend {
1893 BackendState::Textarea(_) => match self.search.as_ref() {
1896 Some(bar) => bar.current_match(),
1897 None => self.selection,
1898 },
1899 BackendState::Nvim(nvim) => {
1900 self.nvim_host
1901 .frame_sync(nvim, editor_rect.width, editor_rect.height)
1902 }
1903 };
1904 while let Ok((generation, buf)) = self.full_parse_rx.try_recv() {
1910 self.view.install_full_parse(generation, buf);
1911 }
1912 while let Ok((generation, layout)) = self.layout_rx.try_recv() {
1916 self.view.install_full_layout(generation, layout);
1917 }
1918
1919 let overlay = match (self.search.as_ref(), self.backend.as_textarea()) {
1930 (Some(bar), Some(buf)) => bar.overlay(buf),
1931 _ => find_bar::BarOverlay::default(),
1932 };
1933 let preview = overlay.preview;
1934 let snap = snapshot_from_backend(&self.backend, self.revs.current());
1935 self.revs.adopt(snap.content_revision);
1941 let (view_lines, preview_spans) = match preview {
1945 None => (None, Vec::new()),
1946 Some(p) => (Some(p.lines), p.spans),
1947 };
1948 match &view_lines {
1949 None => self.view.update(&snap, editor_rect),
1950 Some(lines) => {
1951 let rev = preview_revision(snap.content_revision, lines);
1956 let view_snap = EditorSnapshot::borrowed(lines, snap.cursor, rev);
1957 self.view.update(&view_snap, editor_rect);
1958 }
1959 }
1960 if self.revs.needles_stale() {
1964 self.search_needles.clear();
1965 self.revs.disarm_needles();
1966 }
1967 self.view.set_needles(self.search_needles.clone());
1968
1969 let mut overlays: Vec<view::Overlay> = Vec::new();
1972 if let Some(((sr, sc), (er, ec))) = selection {
1973 for row in sr..=er {
1976 let start = if row == sr { sc } else { 0 };
1977 let end = if row == er { ec } else { usize::MAX };
1978 overlays.push(view::Overlay::new(
1979 row,
1980 start,
1981 end,
1982 view::OverlayKind::Selection,
1983 ));
1984 }
1985 }
1986 overlays.extend(preview_spans.iter().map(|p| {
1987 view::Overlay::new(
1988 p.row,
1989 p.start,
1990 p.end,
1991 if p.is_current {
1992 view::OverlayKind::PreviewCurrent
1993 } else {
1994 view::OverlayKind::Preview
1995 },
1996 )
1997 }));
1998 if view_lines.is_none() {
2001 overlays.extend(overlay.matches.iter().map(|&(row, start, end)| {
2002 view::Overlay::new(row, start, end, view::OverlayKind::Match)
2003 }));
2004 }
2005 self.view.set_overlays(overlays);
2006
2007 if let Some(generation) = self.view.take_pending_full_parse() {
2015 let text = match &view_lines {
2027 Some(lines) => crate::ropetext::Text::from(lines.join("\n").as_str()),
2028 None => snap.text.clone(),
2029 };
2030 let tx = self.full_parse_tx.clone();
2031 let redraw = self.redraw_tx.clone();
2032 self.full_parse_task.spawn(async move {
2033 let buf = ParsedBuffer::parse(&text);
2034 let _ = tx.send((generation, buf));
2035 if let Some(redraw) = redraw {
2038 let _ = redraw.send(AppEvent::Redraw);
2039 }
2040 });
2041 }
2042 if let Some(job) = self.view.take_pending_full_layout() {
2047 let tx = self.layout_tx.clone();
2048 let redraw = self.redraw_tx.clone();
2049 self.layout_task.spawn(async move {
2050 let hints = view::row_hints(&job.rendered_cache, &job.gutter_insets);
2051 let layout = crate::ropetext::Layout::compute(
2052 &job.text,
2053 job.width,
2054 crate::ropetext::Metrics::default(),
2055 &hints,
2056 );
2057 let _ = tx.send((job.generation, layout));
2058 if let Some(redraw) = redraw {
2059 let _ = redraw.send(AppEvent::Redraw);
2060 }
2061 });
2062 }
2063 let bar_focused = self.search.is_some() && focused;
2066 let editor_focused = focused && !bar_focused;
2067 use self::view::CursorShape;
2068 let cursor_shape = match self.backend.modal_is_insert() {
2069 None => None, Some(true) => Some(CursorShape::Bar),
2071 Some(false) => Some(CursorShape::Block),
2072 };
2073 self.view
2074 .render(f, editor_rect, theme, editor_focused, cursor_shape);
2075
2076 if self.revs.needles_stale() {
2080 self.search_needles.clear();
2081 self.revs.disarm_needles();
2082 }
2083
2084 if snap.text.len_bytes() == 0 && editor_rect.height > 0 {
2088 let leader = self
2089 .key_bindings
2090 .first_combo_for(&crate::keys::action_shortcuts::ActionShortcuts::Leader)
2091 .unwrap_or_else(|| "leader".to_string());
2092 f.render_widget(
2093 ratatui::widgets::Paragraph::new(format!(
2094 "Type to start · [[ to link · # to tag · {leader} for commands"
2095 ))
2096 .style(
2097 Style::default()
2098 .fg(theme.gray.to_ratatui())
2099 .add_modifier(Modifier::ITALIC),
2100 ),
2101 Rect {
2102 x: editor_rect.x.saturating_add(2),
2103 width: editor_rect.width.saturating_sub(2),
2104 height: 1,
2105 ..editor_rect
2106 },
2107 );
2108 }
2109 if let (Some(state), Some(bar_rect)) = (self.search.as_mut(), search_rect) {
2110 state.render(f, bar_rect, theme, bar_focused);
2111 }
2112
2113 self.poll_autocomplete();
2121 if let (Some(controller), Some(live_anchor)) =
2128 (self.autocomplete.as_mut(), self.view.last_cursor_screen)
2129 {
2130 if let Some(state) = controller.state_mut() {
2131 state.anchor = live_anchor;
2132 }
2133 if let Some(state) = controller.state() {
2134 autocomplete::render(f, state, editor_rect, theme);
2135 }
2136 }
2137 }
2138
2139 fn hint_shortcuts(&self) -> Vec<(String, String)> {
2140 use crate::keys::action_shortcuts::ActionShortcuts;
2141
2142 if let Some(mut label) = self.backend.mode_label() {
2147 if let Some(p) = self.backend.pending_input_hint() {
2148 label = format!("{label} {p}");
2149 }
2150 let mut hints = vec![(String::new(), label)];
2151 hints.extend(
2152 [
2153 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2154 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2155 (ActionShortcuts::FileOperations, "file ops"),
2156 ]
2157 .iter()
2158 .filter_map(|(action, label)| {
2159 self.key_bindings
2160 .first_combo_for(action)
2161 .map(|k| (k, label.to_string()))
2162 }),
2163 );
2164 return hints;
2165 }
2166
2167 let mut hints: Vec<(String, String)> = Vec::new();
2170 match self.follow_target_at_cursor() {
2171 Some(FollowTarget::Link(_)) => {
2172 if let Some(k) = self
2173 .key_bindings
2174 .first_combo_for(&ActionShortcuts::FollowLink)
2175 {
2176 hints.push((k, "follow link".to_string()));
2177 }
2178 }
2179 Some(FollowTarget::Label(_)) => {
2180 if let Some(k) = self
2181 .key_bindings
2182 .first_combo_for(&ActionShortcuts::FollowLink)
2183 {
2184 hints.push((k, "browse tag".to_string()));
2185 }
2186 }
2187 None => {}
2188 }
2189 hints.extend(crate::components::hints::hints_for(
2190 &self.key_bindings,
2191 &[
2192 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2193 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2194 (ActionShortcuts::FileOperations, "file ops"),
2195 (ActionShortcuts::FindInBuffer, "find"),
2196 ],
2197 ));
2198 hints
2199 }
2200}
2201
2202#[cfg(test)]
2203mod tests {
2204 use super::snapshot::EditorMode;
2205 use super::*;
2206 use crate::keys::KeyBindings;
2207
2208 fn make_editor() -> TextEditorComponent {
2209 TextEditorComponent::new(
2210 KeyBindings::empty(),
2211 &crate::settings::AppSettings::default(),
2212 )
2213 }
2214
2215 fn dummy_tx() -> AppTx {
2216 tokio::sync::mpsc::unbounded_channel().0
2217 }
2218
2219 fn get_ta(editor: &mut TextEditorComponent) -> &mut RopeBuffer {
2220 match &mut editor.backend {
2221 BackendState::Textarea(tb) => &mut tb.ta,
2222 _ => panic!("expected Textarea backend"),
2223 }
2224 }
2225
2226 #[test]
2227 fn has_trigger_before_cursor_finds_bracket() {
2228 assert!(has_trigger_before_cursor("hello [[foo", 11));
2229 assert!(has_trigger_before_cursor("[[a b c", 7));
2230 }
2231
2232 #[test]
2233 fn has_trigger_before_cursor_finds_hashtag() {
2234 assert!(has_trigger_before_cursor("text #tag", 9));
2235 }
2236
2237 #[test]
2238 fn has_trigger_before_cursor_no_trigger_bails() {
2239 assert!(!has_trigger_before_cursor("plain prose here", 16));
2240 assert!(!has_trigger_before_cursor("", 0));
2241 }
2242
2243 #[test]
2244 fn has_trigger_before_cursor_handles_multibyte_no_panic() {
2245 let line = "你好世界".to_string() + &"a".repeat(80);
2248 let col = line.chars().count();
2249 assert!(!has_trigger_before_cursor(&line, col));
2250
2251 let with_emoji = "🦀".repeat(20) + "[[note";
2252 let col = with_emoji.chars().count();
2253 assert!(has_trigger_before_cursor(&with_emoji, col));
2254
2255 let accented = "é".repeat(100);
2256 let col = accented.chars().count();
2257 assert!(!has_trigger_before_cursor(&accented, col));
2258 }
2259
2260 #[test]
2261 fn has_trigger_before_cursor_ignores_chars_after_cursor() {
2262 assert!(!has_trigger_before_cursor("foo [[bar", 3));
2264 }
2265
2266 #[test]
2267 fn has_trigger_before_cursor_wikilink_with_spaces() {
2268 assert!(has_trigger_before_cursor("[[my note title", 15));
2271 }
2272
2273 #[test]
2274 fn fresh_editor_is_not_dirty() {
2275 let editor = make_editor();
2276 assert!(!editor.is_dirty());
2277 }
2278
2279 #[test]
2280 fn after_set_text_not_dirty() {
2281 let mut editor = make_editor();
2282 editor.set_text("hello world".to_string());
2283 assert!(!editor.is_dirty());
2284 }
2285
2286 #[test]
2287 fn get_text_returns_loaded_content() {
2288 let mut editor = make_editor();
2289 editor.set_text("line one\nline two".to_string());
2290 assert_eq!(editor.get_text(), "line one\nline two");
2291 }
2292
2293 #[test]
2294 fn mark_saved_clears_dirty() {
2295 let mut editor = make_editor();
2296 editor.set_text("initial".to_string());
2297 let text = editor.get_text();
2298 editor.mark_saved(text.clone() + "x"); assert!(editor.is_dirty());
2300 editor.mark_saved(text); assert!(!editor.is_dirty());
2302 }
2303
2304 #[test]
2305 fn trailing_newline_does_not_cause_false_dirty() {
2306 let mut editor = make_editor();
2307 editor.set_text("content\n".to_string());
2308 assert!(
2309 !editor.is_dirty(),
2310 "trailing newline should not make editor dirty after load"
2311 );
2312 }
2313
2314 #[test]
2315 fn cursor_move_does_not_dirty_buffer() {
2316 let mut editor = make_editor();
2317 editor.set_text("hello world".to_string());
2318 assert!(!editor.is_dirty());
2319 let tx = dummy_tx();
2320 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
2323 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2324 assert!(
2325 !editor.is_dirty(),
2326 "cursor move must not mark the editor as dirty"
2327 );
2328 }
2329
2330 #[test]
2331 fn empty_stack_undo_redo_does_not_dirty_or_bump_revision() {
2332 let mut editor = make_editor();
2336 editor.set_text("foo".to_string());
2337 let rev_before = editor.content_revision();
2338 assert!(!editor.is_dirty());
2339 let tx = dummy_tx();
2340 for key_code in [KeyCode::Char('z'), KeyCode::Char('y')] {
2341 let key = ratatui::crossterm::event::KeyEvent::new(key_code, KeyModifiers::CONTROL);
2342 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2343 }
2344 assert!(
2345 !editor.is_dirty(),
2346 "empty-stack undo/redo must not flip is_dirty"
2347 );
2348 assert_eq!(
2349 editor.content_revision(),
2350 rev_before,
2351 "empty-stack undo/redo must not bump content_revision"
2352 );
2353 }
2354
2355 #[test]
2356 fn fresh_editor_content_revision_is_nonzero() {
2357 let editor = make_editor();
2364 assert!(editor.content_revision().get() >= 1);
2365 }
2366
2367 #[test]
2368 fn mouse_down_clears_selection() {
2369 let mut editor = make_editor();
2370 editor.set_text("hello world".to_string());
2371 let ta = get_ta(&mut editor);
2372 ta.start_selection();
2373 ta.move_cursor(CursorMove::WordForward);
2374 assert!(ta.selection_range().is_some());
2375 ta.cancel_selection();
2376 editor.selection = if let BackendState::Textarea(tb) = &editor.backend {
2377 tb.ta.selection_range()
2378 } else {
2379 None
2380 };
2381 assert!(editor.selection.is_none());
2382 }
2383
2384 #[test]
2385 fn ctrl_c_copies_selected_text() {
2386 let mut editor = make_editor();
2387 editor.set_text("hello world".to_string());
2388 let ta = get_ta(&mut editor);
2389 ta.move_cursor(CursorMove::Head);
2390 ta.start_selection();
2391 ta.move_cursor(CursorMove::WordForward);
2392 let range = ta.selection_range().unwrap();
2393 let ((sr, sc), (er, ec)) = range;
2394 let lines = ta.rows();
2395 let selected = if sr == er {
2396 lines[sr][sc..ec].to_string()
2397 } else {
2398 lines[sr][sc..].to_string()
2399 };
2400 assert_eq!(selected, "hello ");
2401 }
2402
2403 fn select_range(editor: &mut TextEditorComponent, start: (usize, usize), end: (usize, usize)) {
2405 let ta = get_ta(editor);
2406 ta.cancel_selection();
2407 ta.move_cursor(CursorMove::Jump(start.0, start.1));
2408 ta.start_selection();
2409 ta.move_cursor(CursorMove::Jump(end.0, end.1));
2410 assert!(ta.selection_range().is_some());
2411 }
2412
2413 fn send_char(editor: &mut TextEditorComponent, c: char) {
2414 let tx = dummy_tx();
2415 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
2416 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2417 }
2418
2419 #[test]
2420 fn typing_open_paren_with_selection_wraps_it() {
2421 let mut editor = make_editor();
2422 editor.set_text("hello world".to_string());
2423 select_range(&mut editor, (0, 0), (0, 5)); send_char(&mut editor, '(');
2425 assert_eq!(editor.get_text(), "(hello) world");
2426 assert!(editor.is_dirty(), "wrap must mark the buffer dirty");
2427 }
2428
2429 #[test]
2430 fn wrap_keeps_selection_on_inner_text() {
2431 let mut editor = make_editor();
2432 editor.set_text("hello world".to_string());
2433 select_range(&mut editor, (0, 0), (0, 5));
2434 send_char(&mut editor, '(');
2435 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2437 }
2438
2439 #[test]
2440 fn chained_brackets_build_a_wikilink() {
2441 let mut editor = make_editor();
2442 editor.set_text("my note".to_string());
2443 select_range(&mut editor, (0, 0), (0, 7));
2444 send_char(&mut editor, '[');
2445 send_char(&mut editor, '[');
2446 assert_eq!(editor.get_text(), "[[my note]]");
2447 assert_eq!(editor.selection, Some(((0, 2), (0, 9))));
2448 }
2449
2450 #[test]
2451 fn symmetric_chars_wrap_and_chain() {
2452 let mut editor = make_editor();
2453 editor.set_text("bold".to_string());
2454 select_range(&mut editor, (0, 0), (0, 4));
2455 send_char(&mut editor, '*');
2456 assert_eq!(editor.get_text(), "*bold*");
2457 send_char(&mut editor, '*');
2458 assert_eq!(editor.get_text(), "**bold**");
2459 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2460 }
2461
2462 #[test]
2463 fn closing_char_replaces_selection() {
2464 let mut editor = make_editor();
2465 editor.set_text("hello world".to_string());
2466 select_range(&mut editor, (0, 0), (0, 5));
2467 send_char(&mut editor, ')');
2468 assert_eq!(editor.get_text(), ") world");
2469 }
2470
2471 #[test]
2472 fn open_char_without_selection_inserts_normally() {
2473 let mut editor = make_editor();
2474 editor.set_text("hello".to_string());
2475 let ta = get_ta(&mut editor);
2476 ta.move_cursor(CursorMove::End);
2477 send_char(&mut editor, '(');
2478 assert_eq!(editor.get_text(), "hello(");
2479 }
2480
2481 #[test]
2482 fn text_action_keeps_selection_on_inner_text() {
2483 let mut editor = make_editor();
2486 editor.set_text("bold word".to_string());
2487 select_range(&mut editor, (0, 0), (0, 4));
2488 editor.apply_text_action(TextAction::Bold);
2489 assert_eq!(editor.get_text(), "**bold** word");
2490 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2491 }
2492
2493 #[test]
2494 fn bold_undo_is_one_step_back_to_original() {
2495 let mut editor = make_editor();
2500 editor.set_text("hello world".to_string());
2501 select_range(&mut editor, (0, 0), (0, 5));
2502 editor.apply_text_action(TextAction::Bold);
2503 assert_eq!(editor.get_text(), "**hello** world");
2504 assert!(get_ta(&mut editor).undo(), "the bold is one entry");
2505 assert_eq!(editor.get_text(), "hello world");
2506 assert!(
2507 !get_ta(&mut editor).undo(),
2508 "and has no second half left to take back"
2509 );
2510 }
2511
2512 #[test]
2513 fn linkable_url_accepts_supported_schemes() {
2514 assert_eq!(
2515 linkable_url("https://example.com"),
2516 Some("https://example.com")
2517 );
2518 assert_eq!(
2519 linkable_url("http://example.com/path?q=1#frag"),
2520 Some("http://example.com/path?q=1#frag"),
2521 );
2522 assert_eq!(
2523 linkable_url(" https://example.com "),
2524 Some("https://example.com")
2525 );
2526 assert_eq!(
2527 linkable_url("ftp://files.example.com/x"),
2528 Some("ftp://files.example.com/x"),
2529 );
2530 assert_eq!(
2531 linkable_url("ftps://files.example.com/x"),
2532 Some("ftps://files.example.com/x"),
2533 );
2534 assert_eq!(
2535 linkable_url("mailto:user@example.com"),
2536 Some("mailto:user@example.com"),
2537 );
2538 assert_eq!(
2539 linkable_url("mailto:user@example.com?subject=hi"),
2540 Some("mailto:user@example.com?subject=hi"),
2541 );
2542 }
2543
2544 #[test]
2545 fn linkable_url_rejects_other_schemes_and_plain_text() {
2546 assert_eq!(linkable_url("file:///etc/passwd"), None);
2547 assert_eq!(linkable_url("ssh://host"), None);
2548 assert_eq!(linkable_url("javascript:alert(1)"), None);
2549 assert_eq!(linkable_url("example.com"), None);
2550 assert_eq!(linkable_url("not a url"), None);
2551 assert_eq!(linkable_url(""), None);
2552 assert_eq!(linkable_url("https://example.com\nmore"), None);
2553 }
2554
2555 #[test]
2556 fn try_build_markdown_link_wraps_selection_when_clip_is_url() {
2557 assert_eq!(
2558 try_build_markdown_link("https://example.com", Some("click here")).as_deref(),
2559 Some("[click here](https://example.com)"),
2560 );
2561 }
2562
2563 #[test]
2564 fn try_build_markdown_link_trims_url_whitespace() {
2565 assert_eq!(
2566 try_build_markdown_link(" https://example.com\n", Some("link")).as_deref(),
2567 Some("[link](https://example.com)"),
2568 );
2569 }
2570
2571 #[test]
2572 fn try_build_markdown_link_returns_none_when_no_selection() {
2573 assert_eq!(try_build_markdown_link("https://example.com", None), None);
2574 }
2575
2576 #[test]
2577 fn try_build_markdown_link_returns_none_when_not_url() {
2578 assert_eq!(try_build_markdown_link("plain text", Some("sel")), None);
2579 }
2580
2581 #[test]
2582 fn try_build_markdown_link_returns_none_when_selection_empty() {
2583 assert_eq!(
2584 try_build_markdown_link("https://example.com", Some("")),
2585 None
2586 );
2587 }
2588
2589 #[test]
2590 fn try_build_markdown_link_escapes_close_bracket_in_selection() {
2591 assert_eq!(
2592 try_build_markdown_link("https://example.com", Some("a]b")).as_deref(),
2593 Some(r"[a\]b](https://example.com)"),
2594 );
2595 }
2596
2597 #[test]
2598 fn try_build_markdown_link_wraps_ftp_url() {
2599 assert_eq!(
2600 try_build_markdown_link("ftp://files.example.com/x", Some("download")).as_deref(),
2601 Some("[download](ftp://files.example.com/x)"),
2602 );
2603 }
2604
2605 fn key(code: KeyCode, mods: KeyModifiers) -> ratatui::crossterm::event::KeyEvent {
2606 ratatui::crossterm::event::KeyEvent::new(code, mods)
2607 }
2608
2609 #[test]
2611 fn search_needles_clear_on_edit() {
2612 let settings = crate::settings::AppSettings::default();
2613 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2614 ed.set_text("alpha beta".to_string());
2615 ed.set_search_needles(vec!["Alpha".to_string()]);
2616 assert_eq!(ed.search_needles, vec!["alpha"]);
2617 assert!(!ed.revs.needles_stale());
2618
2619 ed.set_text("alpha beta gamma".to_string());
2621 assert!(ed.revs.needles_stale());
2622 }
2623
2624 #[test]
2625 fn open_or_advance_search_opens_find_bar_with_empty_query() {
2626 let mut editor = make_editor();
2627 editor.set_text("hello world".to_string());
2628 editor.open_or_advance_search();
2629 let state = editor.search.as_ref().expect("find bar opened");
2630 assert!(state.input.is_empty());
2631 assert!(matches!(state.status, SearchStatus::Empty));
2632 }
2633
2634 #[test]
2635 fn open_or_advance_search_advances_when_already_open() {
2636 let mut editor = make_editor();
2637 editor.set_text("ab ab ab".to_string());
2638 let tx = dummy_tx();
2639 editor.open_or_advance_search();
2640 editor.handle_input(
2641 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
2642 &tx,
2643 );
2644 editor.handle_input(
2645 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
2646 &tx,
2647 );
2648 editor.open_or_advance_search();
2650 let (_, col) = get_ta(&mut editor).cursor();
2651 assert_eq!(col, 3, "second invocation advances to next match");
2652 }
2653
2654 #[test]
2655 fn typing_in_find_bar_jumps_cursor_to_first_match() {
2656 let mut editor = make_editor();
2657 editor.set_text("foo bar baz".to_string());
2658 let tx = dummy_tx();
2659 editor.open_or_advance_search();
2660 for ch in ['b', 'a', 'r'] {
2661 editor.handle_input(
2662 &InputEvent::Key(key(KeyCode::Char(ch), KeyModifiers::NONE)),
2663 &tx,
2664 );
2665 }
2666 let state = editor.search.as_ref().unwrap();
2667 assert_eq!(state.input.value(), "bar");
2668 assert!(matches!(state.status, SearchStatus::Match));
2669 let (_, col) = get_ta(&mut editor).cursor();
2670 assert_eq!(col, 4, "cursor jumped to start of 'bar'");
2671 }
2672
2673 #[test]
2674 fn enter_in_find_bar_advances_to_next_match() {
2675 let mut editor = make_editor();
2676 editor.set_text("ab ab ab".to_string());
2677 let tx = dummy_tx();
2678 editor.open_or_advance_search();
2679 editor.handle_input(
2680 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
2681 &tx,
2682 );
2683 editor.handle_input(
2684 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
2685 &tx,
2686 );
2687 editor.handle_input(
2689 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
2690 &tx,
2691 );
2692 let (_, col) = get_ta(&mut editor).cursor();
2693 assert_eq!(col, 3, "Enter advances to second match");
2694 }
2695
2696 #[test]
2697 fn match_is_highlighted_as_selection_after_search() {
2698 let mut editor = make_editor();
2699 editor.set_text("foo bar baz".to_string());
2700 let tx = dummy_tx();
2701 editor.open_or_advance_search();
2702 for ch in ['b', 'a', 'r'] {
2703 editor.handle_input(
2704 &InputEvent::Key(key(KeyCode::Char(ch), KeyModifiers::NONE)),
2705 &tx,
2706 );
2707 }
2708 assert_eq!(
2711 editor.search.as_ref().unwrap().current_match(),
2712 Some(((0, 4), (0, 7)))
2713 );
2714 }
2715
2716 #[test]
2717 fn no_match_clears_selection() {
2718 let mut editor = make_editor();
2719 editor.set_text("hello".to_string());
2720 let tx = dummy_tx();
2721 editor.open_or_advance_search();
2722 editor.handle_input(
2723 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::NONE)),
2724 &tx,
2725 );
2726 assert_eq!(editor.selection, None);
2727 }
2728
2729 #[test]
2730 fn esc_in_find_bar_clears_selection_highlight() {
2731 let mut editor = make_editor();
2732 editor.set_text("foo bar".to_string());
2733 let tx = dummy_tx();
2734 editor.open_or_advance_search();
2735 editor.handle_input(
2736 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
2737 &tx,
2738 );
2739 editor.handle_input(
2740 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
2741 &tx,
2742 );
2743 editor.handle_input(
2744 &InputEvent::Key(key(KeyCode::Char('r'), KeyModifiers::NONE)),
2745 &tx,
2746 );
2747 assert!(
2748 editor
2749 .search
2750 .as_ref()
2751 .is_some_and(|b| b.current_match().is_some())
2752 );
2753 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
2754 assert!(editor.search.is_none());
2756 assert!(editor.selection.is_none());
2757 }
2758
2759 #[test]
2760 fn esc_in_find_bar_closes_it() {
2761 let mut editor = make_editor();
2762 editor.set_text("hello".to_string());
2763 let tx = dummy_tx();
2764 editor.open_or_advance_search();
2765 assert!(editor.search.is_some());
2766 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
2767 assert!(editor.search.is_none());
2768 }
2769
2770 #[test]
2771 fn find_bar_consumes_typing_so_editor_text_is_unchanged() {
2772 let mut editor = make_editor();
2773 editor.set_text("hello".to_string());
2774 let tx = dummy_tx();
2775 editor.open_or_advance_search();
2776 editor.handle_input(
2777 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
2778 &tx,
2779 );
2780 assert_eq!(editor.get_text(), "hello");
2781 }
2782
2783 #[test]
2784 fn no_match_status_when_query_absent() {
2785 let mut editor = make_editor();
2786 editor.set_text("hello".to_string());
2787 let tx = dummy_tx();
2788 editor.open_or_advance_search();
2789 editor.handle_input(
2790 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::NONE)),
2791 &tx,
2792 );
2793 let state = editor.search.as_ref().unwrap();
2794 assert!(matches!(state.status, SearchStatus::NoMatch));
2795 }
2796
2797 #[test]
2798 fn try_build_markdown_link_wraps_mailto_url() {
2799 assert_eq!(
2800 try_build_markdown_link("mailto:user@example.com", Some("email me")).as_deref(),
2801 Some("[email me](mailto:user@example.com)"),
2802 );
2803 }
2804
2805 #[test]
2806 fn insert_at_cursor_appends_text() {
2807 let mut editor = make_editor();
2808 editor.set_text("hello".to_string());
2809 {
2810 let ta = get_ta(&mut editor);
2811 ta.move_cursor(CursorMove::End);
2812 }
2813 editor.insert_at_cursor(" world", &dummy_tx());
2814 assert_eq!(editor.get_text(), "hello world");
2815 }
2816
2817 #[test]
2818 fn insert_at_cursor_replaces_selection() {
2819 let mut editor = make_editor();
2820 editor.set_text("hello world".to_string());
2821 {
2822 let ta = get_ta(&mut editor);
2823 ta.move_cursor(CursorMove::Head);
2824 ta.start_selection();
2825 ta.move_cursor(CursorMove::WordForward);
2826 }
2827 editor.insert_at_cursor("HEY ", &dummy_tx());
2828 assert_eq!(editor.get_text(), "HEY world");
2829 }
2830
2831 #[test]
2832 fn paste_inserts_text_at_cursor() {
2833 let mut editor = make_editor();
2834 editor.set_text("hello".to_string());
2835 let ta = get_ta(&mut editor);
2836 ta.move_cursor(CursorMove::End);
2837 ta.insert_str(" world");
2838 assert_eq!(editor.get_text(), "hello world");
2839 }
2840
2841 #[test]
2842 fn a_selection_ending_at_column_zero_leaves_that_row_alone() {
2843 let mut editor = make_editor();
2844 editor.set_text("foo\nbar\nbaz".to_string());
2845 select_range(&mut editor, (0, 0), (2, 0));
2846 editor.indent_lines(false);
2847 assert_eq!(get_ta(&mut editor).rows(), &[" foo", " bar", "baz"]);
2848 assert_eq!(editor.selection, Some(((0, 4), (2, 0))));
2849 }
2850
2851 #[test]
2852 fn unsupported_text_action_is_noop() {
2853 let mut editor = make_editor();
2854 editor.set_text("hello".to_string());
2855 editor.apply_text_action(TextAction::Underline);
2856 assert_eq!(editor.get_text(), "hello");
2857 }
2858
2859 #[test]
2860 fn bold_action_with_no_selection_inserts_a_pair_through_the_component() {
2861 let mut editor = make_editor();
2864 editor.set_text("hello".to_string());
2865 {
2866 let ta = get_ta(&mut editor);
2867 ta.move_cursor(CursorMove::End);
2868 }
2869 editor.apply_text_action(TextAction::Bold);
2870 assert_eq!(editor.get_text(), "hello****");
2871 assert_eq!(get_ta(&mut editor).cursor(), (0, 7));
2872 assert!(editor.is_dirty(), "the insert is an edit");
2873 }
2874
2875 #[test]
2876 fn jump_to_heading_moves_the_view_cursor() {
2877 let mut ed = make_editor();
2879 ed.set_text("intro\n# Top\nbody\n## Sub One\nmore\n".to_string());
2880 ed.jump_to_heading("Sub One");
2881 assert_eq!(ed.view_snapshot().cursor.0, 3);
2882 ed.jump_to_heading("Top");
2883 assert_eq!(ed.view_snapshot().cursor.0, 1);
2884 select_range(&mut ed, (2, 1), (2, 1));
2887 ed.jump_to_heading("Top");
2888 assert_eq!(ed.selection, None);
2889 assert_eq!(ed.view_snapshot().cursor.0, 1);
2890 ed.jump_to_heading("Nope");
2891 assert_eq!(
2892 ed.view_snapshot().cursor.0,
2893 1,
2894 "an unknown heading leaves the cursor"
2895 );
2896 }
2897
2898 #[test]
2899 fn tab_indents_the_cursor_row_and_backtab_dedents_it() {
2900 let mut editor = make_editor();
2901 editor.set_text("foo".to_string());
2902 let tx = dummy_tx();
2903 let tab = key(KeyCode::Tab, KeyModifiers::NONE);
2904 let _ = editor.handle_input(&InputEvent::Key(tab), &tx);
2905 assert_eq!(editor.get_text(), " foo");
2906 assert!(editor.is_dirty(), "an indent is an edit");
2907 let back = key(KeyCode::BackTab, KeyModifiers::SHIFT);
2908 let _ = editor.handle_input(&InputEvent::Key(back), &tx);
2909 assert_eq!(editor.get_text(), "foo");
2910 }
2911
2912 #[test]
2913 fn enter_at_the_end_of_a_list_item_continues_it() {
2914 let mut editor = make_editor();
2915 editor.set_text("- foo".to_string());
2916 {
2917 let ta = get_ta(&mut editor);
2918 ta.move_cursor(CursorMove::End);
2919 }
2920 let tx = dummy_tx();
2921 let enter = key(KeyCode::Enter, KeyModifiers::NONE);
2922 let _ = editor.handle_input(&InputEvent::Key(enter), &tx);
2923 assert_eq!(editor.get_text(), "- foo\n- ");
2924 assert!(editor.is_dirty());
2925 }
2926
2927 #[test]
2928 fn a_letter_typed_after_smart_enter_starts_its_own_undo_group() {
2929 let mut editor = make_editor();
2930 editor.set_text(String::new());
2931 for c in "- foo".chars() {
2932 send_char(&mut editor, c);
2933 }
2934 let tx = dummy_tx();
2935 let enter = key(KeyCode::Enter, KeyModifiers::NONE);
2936 let _ = editor.handle_input(&InputEvent::Key(enter), &tx);
2937 send_char(&mut editor, 'b');
2938 assert_eq!(editor.get_text(), "- foo\n- b");
2939 assert!(get_ta(&mut editor).undo());
2940 assert_eq!(
2941 editor.get_text(),
2942 "- foo\n- ",
2943 "the letter after a list continuation is its own undo group"
2944 );
2945 }
2946
2947 #[test]
2948 fn textarea_hint_shortcuts_has_no_mode_indicator() {
2949 let editor = make_editor();
2950 let hints = editor.hint_shortcuts();
2951 assert!(
2953 !hints
2954 .iter()
2955 .any(|(_, label)| label == "NORMAL" || label == "INSERT")
2956 );
2957 }
2958
2959 fn place_cursor_at_col(editor: &mut TextEditorComponent, col: usize) {
2963 let ta = get_ta(editor);
2964 ta.move_cursor(CursorMove::Head);
2965 for _ in 0..col {
2966 ta.move_cursor(CursorMove::Forward);
2967 }
2968 }
2969
2970 #[test]
2971 fn follow_target_at_cursor_returns_label_when_cursor_on_hashtag() {
2972 let mut editor = make_editor();
2973 editor.set_text("see #rust now".to_string());
2974 place_cursor_at_col(&mut editor, 5);
2976 assert_eq!(
2977 editor.follow_target_at_cursor(),
2978 Some(FollowTarget::Label("rust".into())),
2979 );
2980 }
2981
2982 #[test]
2983 fn follow_target_at_cursor_returns_label_at_hash_char() {
2984 let mut editor = make_editor();
2985 editor.set_text("see #rust now".to_string());
2986 place_cursor_at_col(&mut editor, 4);
2988 assert_eq!(
2989 editor.follow_target_at_cursor(),
2990 Some(FollowTarget::Label("rust".into())),
2991 );
2992 }
2993
2994 #[test]
2995 fn follow_target_at_cursor_returns_none_outside_hashtag() {
2996 let mut editor = make_editor();
2997 editor.set_text("see #rust now".to_string());
2998 place_cursor_at_col(&mut editor, 0);
3000 assert_eq!(editor.follow_target_at_cursor(), None);
3001 }
3002
3003 #[test]
3004 fn follow_target_at_cursor_returns_link_for_wikilink() {
3005 let mut editor = make_editor();
3006 editor.set_text("open [[my note]] please".to_string());
3007 place_cursor_at_col(&mut editor, 7);
3009 let result = editor.follow_target_at_cursor();
3010 assert!(
3011 matches!(result, Some(FollowTarget::Link(_))),
3012 "expected Link variant, got {result:?}"
3013 );
3014 }
3015
3016 #[test]
3019 fn follow_target_at_cursor_returns_link_for_markdown_link_with_fragment() {
3020 let line = "[see docs](#section)";
3025 let mut editor = make_editor();
3026 editor.set_text(line.to_string());
3027 let cursor = "[see docs](#sec".chars().count(); place_cursor_at_col(&mut editor, cursor);
3030 let result = editor.follow_target_at_cursor();
3031 assert!(
3032 matches!(result, Some(FollowTarget::Link(_))),
3033 "expected Link variant for markdown link fragment, got {result:?}"
3034 );
3035 }
3036
3037 #[test]
3038 fn vim_normal_i_then_typing_inserts_text() {
3039 let mut settings = crate::settings::AppSettings::default();
3040 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
3041 let mut editor = TextEditorComponent::new(KeyBindings::empty(), &settings);
3042 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3043 editor.handle_input(
3045 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3046 &tx,
3047 );
3048 assert_eq!(editor.get_text(), "");
3049 editor.handle_input(
3051 &InputEvent::Key(key(KeyCode::Char('i'), KeyModifiers::NONE)),
3052 &tx,
3053 );
3054 editor.handle_input(
3055 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3056 &tx,
3057 );
3058 assert_eq!(editor.get_text(), "x");
3059 }
3060
3061 fn open_replace_bar(
3066 editor: &mut TextEditorComponent,
3067 tx: &AppTx,
3068 pattern: &str,
3069 replacement: &str,
3070 ) {
3071 editor.open_or_advance_search();
3072 for c in pattern.chars() {
3073 editor.handle_input(
3074 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3075 tx,
3076 );
3077 }
3078 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), tx);
3079 for c in replacement.chars() {
3080 editor.handle_input(
3081 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3082 tx,
3083 );
3084 }
3085 }
3086
3087 #[test]
3088 fn tab_reveals_the_replace_field_and_then_cycles_focus() {
3089 let mut editor = make_editor();
3090 let tx = dummy_tx();
3091 editor.set_text("todo".to_string());
3092 editor.open_or_advance_search();
3093 assert!(
3094 !editor.search.as_ref().unwrap().is_replacing(),
3095 "a find-only bar must not start with a replace field"
3096 );
3097
3098 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3099 let s = editor.search.as_ref().unwrap();
3100 assert!(s.is_replacing(), "Tab must reveal the replace field");
3101 assert_eq!(s.focus, BarFocus::Find);
3104
3105 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3106 assert_eq!(editor.search.as_ref().unwrap().focus, BarFocus::Replace);
3107 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3108 assert_eq!(editor.search.as_ref().unwrap().focus, BarFocus::Find);
3109 }
3110
3111 #[test]
3112 fn typing_in_the_replace_field_does_not_touch_the_buffer() {
3113 let mut editor = make_editor();
3114 let tx = dummy_tx();
3115 editor.set_text("todo and todo".to_string());
3116 open_replace_bar(&mut editor, &tx, "todo", "done");
3117 assert_eq!(
3118 editor.get_text(),
3119 "todo and todo",
3120 "the preview is a view of the note, never a write to it"
3121 );
3122 }
3123
3124 #[test]
3125 fn enter_replaces_the_current_match_and_advances() {
3126 let mut editor = make_editor();
3127 let tx = dummy_tx();
3128 editor.set_text("todo and todo".to_string());
3129 open_replace_bar(&mut editor, &tx, "todo", "done");
3130 editor.handle_input(
3131 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3132 &tx,
3133 );
3134 assert_eq!(editor.get_text(), "done and todo");
3135 }
3136
3137 #[test]
3138 fn replacing_a_match_that_ends_inside_a_cluster_is_refused_not_corrupted() {
3139 let mut editor = make_editor();
3145 let tx = dummy_tx();
3146 editor.set_text("e\u{301}f".to_string());
3147 open_replace_bar(&mut editor, &tx, "e", "x");
3148 editor.handle_input(
3149 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3150 &tx,
3151 );
3152 assert_eq!(
3153 editor.get_text(),
3154 "e\u{301}f",
3155 "the note is left alone rather than half-rewritten"
3156 );
3157 }
3158
3159 #[test]
3160 fn ctrl_a_replaces_every_match() {
3161 let mut editor = make_editor();
3162 let tx = dummy_tx();
3163 editor.set_text("todo and todo\nmore todo".to_string());
3164 open_replace_bar(&mut editor, &tx, "todo", "done");
3165 editor.handle_input(
3166 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3167 &tx,
3168 );
3169 assert_eq!(editor.get_text(), "done and done\nmore done");
3170 }
3171
3172 #[test]
3173 fn replace_all_keeps_the_reading_position() {
3174 let mut editor = make_editor();
3175 let tx = dummy_tx();
3176 editor.set_text("todo\nxx\ntodo\nyy".to_string());
3177 open_replace_bar(&mut editor, &tx, "todo", "done");
3178 if let Some(ta) = editor.backend.as_textarea_mut() {
3182 ta.move_cursor(CursorMove::Jump(3, 1));
3183 }
3184 editor.handle_input(
3185 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3186 &tx,
3187 );
3188 assert_eq!(editor.get_text(), "done\nxx\ndone\nyy");
3189 let (row, _) = editor.cursor_pos();
3190 assert_eq!(
3191 row, 3,
3192 "replace all must not throw the cursor to the end of the note"
3193 );
3194 }
3195
3196 #[test]
3197 fn an_empty_replacement_arms_before_it_deletes() {
3198 let mut editor = make_editor();
3199 let tx = dummy_tx();
3200 editor.set_text("todo and todo".to_string());
3201 open_replace_bar(&mut editor, &tx, "todo ", "");
3202
3203 let ctrl_a = InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
3204 editor.handle_input(&ctrl_a, &tx);
3205 assert_eq!(
3206 editor.get_text(),
3207 "todo and todo",
3208 "the first Ctrl+A on an empty replacement must arm, not delete"
3209 );
3210 assert!(editor.search.as_ref().unwrap().armed_empty);
3211
3212 editor.handle_input(&ctrl_a, &tx);
3213 assert_eq!(editor.get_text(), "and todo");
3214 }
3215
3216 #[test]
3217 fn esc_disarms_an_empty_replace_all_without_closing_the_bar() {
3218 let mut editor = make_editor();
3219 let tx = dummy_tx();
3220 editor.set_text("todo".to_string());
3221 open_replace_bar(&mut editor, &tx, "todo", "");
3222 editor.handle_input(
3223 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3224 &tx,
3225 );
3226 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3227 let s = editor
3228 .search
3229 .as_ref()
3230 .expect("Esc disarms before it closes");
3231 assert!(!s.armed_empty);
3232 assert_eq!(editor.get_text(), "todo");
3233 }
3234
3235 #[test]
3236 fn one_ctrl_z_undoes_a_whole_replace_all() {
3237 let mut editor = make_editor();
3238 let tx = dummy_tx();
3239 editor.set_text("todo and todo".to_string());
3240 open_replace_bar(&mut editor, &tx, "todo", "done");
3241 editor.handle_input(
3242 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3243 &tx,
3244 );
3245 assert_eq!(editor.get_text(), "done and done");
3246
3247 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3249 editor.handle_input(
3250 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3251 &tx,
3252 );
3253 assert_eq!(
3254 editor.get_text(),
3255 "todo and todo",
3256 "a replace is two history entries and must cost ONE undo — \
3257 popping half leaves the note with a hole in it"
3258 );
3259 }
3260
3261 #[test]
3262 fn one_ctrl_z_undoes_a_single_replace_step() {
3263 let mut editor = make_editor();
3264 let tx = dummy_tx();
3265 editor.set_text("todo and todo".to_string());
3266 open_replace_bar(&mut editor, &tx, "todo", "done");
3267 editor.handle_input(
3268 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3269 &tx,
3270 );
3271 assert_eq!(editor.get_text(), "done and todo");
3272 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3273 editor.handle_input(
3274 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3275 &tx,
3276 );
3277 assert_eq!(editor.get_text(), "todo and todo");
3278 }
3279
3280 #[test]
3281 fn redo_regroups_the_replace() {
3282 let mut editor = make_editor();
3283 let tx = dummy_tx();
3284 editor.set_text("todo".to_string());
3285 open_replace_bar(&mut editor, &tx, "todo", "done");
3286 editor.handle_input(
3287 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3288 &tx,
3289 );
3290 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3291 editor.handle_input(
3292 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3293 &tx,
3294 );
3295 assert_eq!(editor.get_text(), "todo");
3296 editor.handle_input(
3297 &InputEvent::Key(key(KeyCode::Char('y'), KeyModifiers::CONTROL)),
3298 &tx,
3299 );
3300 assert_eq!(
3301 editor.get_text(),
3302 "done",
3303 "one redo must restore the whole replace"
3304 );
3305 }
3306
3307 #[test]
3308 fn smartcase_drives_both_the_count_and_the_replace() {
3309 let mut editor = make_editor();
3310 let tx = dummy_tx();
3311 editor.set_text("todo Todo TODO".to_string());
3312 open_replace_bar(&mut editor, &tx, "todo", "x");
3313 assert_eq!(
3314 editor.search.as_ref().unwrap().match_count,
3315 3,
3316 "an all-lowercase pattern matches any case"
3317 );
3318 editor.handle_input(
3319 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3320 &tx,
3321 );
3322 assert_eq!(editor.get_text(), "x x x");
3323 }
3324
3325 #[test]
3326 fn an_uppercase_pattern_is_case_sensitive() {
3327 let mut editor = make_editor();
3328 let tx = dummy_tx();
3329 editor.set_text("todo Todo TODO".to_string());
3330 open_replace_bar(&mut editor, &tx, "Todo", "x");
3331 assert_eq!(editor.search.as_ref().unwrap().match_count, 1);
3332 editor.handle_input(
3333 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3334 &tx,
3335 );
3336 assert_eq!(editor.get_text(), "todo x TODO");
3337 }
3338
3339 #[test]
3340 fn the_preview_substitutes_lines_without_writing_them() {
3341 let mut editor = make_editor();
3342 let tx = dummy_tx();
3343 editor.set_text("todo and todo".to_string());
3344 open_replace_bar(&mut editor, &tx, "todo", "done");
3345 let preview = editor.replace_preview().expect("a preview must be built");
3346 assert_eq!(preview.lines, vec!["done and done".to_string()]);
3347 assert_eq!(preview.spans.len(), 2);
3348 assert!(
3349 preview.spans.iter().any(|s| s.is_current),
3350 "the match under the cursor must be flagged so Enter's target is visible"
3351 );
3352 assert_eq!(
3353 editor.get_text(),
3354 "todo and todo",
3355 "building a preview must never mutate the buffer"
3356 );
3357 }
3358
3359 #[test]
3364 fn a_deletion_preview_still_marks_the_current_match() {
3365 let mut editor = make_editor();
3366 let tx = dummy_tx();
3367 editor.set_text("todo and todo".to_string());
3368 open_replace_bar(&mut editor, &tx, "todo", "");
3369 let preview = editor.replace_preview().expect("a preview must be built");
3370 assert_eq!(preview.lines, vec![" and ".to_string()]);
3371 let current = preview
3372 .spans
3373 .iter()
3374 .find(|s| s.is_current)
3375 .expect("the current match must stay flagged when it previews as nothing");
3376 assert_eq!(
3377 current.start, current.end,
3378 "an empty replacement previews as a zero-width span — the renderer \
3379 widens it to a caret cell so the marker cannot vanish"
3380 );
3381 }
3382
3383 #[test]
3388 fn a_multi_row_selection_cannot_derail_an_interactive_replace() {
3389 let mut editor = make_editor();
3390 let tx = dummy_tx();
3391 editor.set_text("alpha beta\nxy".to_string());
3392 open_replace_bar(&mut editor, &tx, "beta", "Z");
3393 editor.selection = Some(((0, 6), (1, 1)));
3395 editor.handle_input(
3396 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3397 &tx,
3398 );
3399 assert_eq!(editor.get_text(), "alpha Z\nxy");
3400 }
3401
3402 #[test]
3407 fn deleting_a_match_marks_the_note_dirty() {
3408 let mut editor = make_editor();
3409 let tx = dummy_tx();
3410 editor.set_text("todo and todo".to_string());
3411 editor.mark_saved("todo and todo".to_string());
3412 assert!(!editor.is_dirty());
3413
3414 open_replace_bar(&mut editor, &tx, "todo ", "");
3415 editor.handle_input(
3416 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3417 &tx,
3418 );
3419 assert_eq!(editor.get_text(), "and todo");
3420 assert!(
3421 editor.is_dirty(),
3422 "a deletion is an edit — if the revision does not move, autosave \
3423 never writes it and the change is silently lost"
3424 );
3425 }
3426
3427 #[test]
3429 fn emptying_the_note_via_replace_all_marks_it_dirty_and_is_undoable() {
3430 let mut editor = make_editor();
3431 let tx = dummy_tx();
3432 editor.set_text("todo".to_string());
3433 editor.mark_saved("todo".to_string());
3434 open_replace_bar(&mut editor, &tx, "todo", "");
3435 let ctrl_a = InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
3436 editor.handle_input(&ctrl_a, &tx); editor.handle_input(&ctrl_a, &tx); assert_eq!(editor.get_text(), "");
3439 assert!(editor.is_dirty());
3440
3441 editor.handle_input(
3442 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3443 &tx,
3444 );
3445 assert_eq!(editor.get_text(), "todo");
3446 }
3447
3448 #[test]
3452 fn ctrl_z_works_without_closing_the_bar_first() {
3453 let mut editor = make_editor();
3454 let tx = dummy_tx();
3455 editor.set_text("todo and todo".to_string());
3456 open_replace_bar(&mut editor, &tx, "todo", "done");
3457 editor.handle_input(
3458 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3459 &tx,
3460 );
3461 assert_eq!(editor.get_text(), "done and done");
3462 editor.handle_input(
3463 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3464 &tx,
3465 );
3466 assert_eq!(editor.get_text(), "todo and todo");
3467 assert!(editor.search.is_some(), "undo must not close the bar");
3468 }
3469
3470 #[test]
3474 fn a_zero_width_match_does_not_over_claim_history_entries() {
3475 let mut editor = make_editor();
3476 let tx = dummy_tx();
3477 editor.set_text("ab".to_string());
3478 open_replace_bar(&mut editor, &tx, r"\b", "|");
3479 editor.handle_input(
3480 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3481 &tx,
3482 );
3483 assert_eq!(editor.get_text(), "|ab");
3484 editor.handle_input(
3485 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3486 &tx,
3487 );
3488 assert_eq!(
3489 editor.get_text(),
3490 "ab",
3491 "one undo must land exactly on the pre-replace text, not past it"
3492 );
3493 }
3494
3495 #[test]
3499 fn a_note_swap_resets_the_find_bar_and_its_undo_groups() {
3500 let mut editor = make_editor();
3501 let tx = dummy_tx();
3502 editor.set_text("todo".to_string());
3503 open_replace_bar(&mut editor, &tx, "todo", "");
3504 editor.handle_input(
3505 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3506 &tx,
3507 );
3508 assert!(editor.search.as_ref().unwrap().armed_empty);
3509
3510 editor.set_text("todo elsewhere".to_string());
3511 assert!(editor.search.is_none(), "the bar belonged to the old note");
3512 assert!(
3515 !editor.backend.as_textarea_mut().unwrap().undo(),
3516 "the new note's history has nothing to undo"
3517 );
3518 }
3519
3520 #[test]
3526 fn concealed_markdown_still_highlights_what_it_counts() {
3527 let mut editor = make_editor();
3528 let tx = dummy_tx();
3529 editor.set_text("# Heading\n[[note]]".to_string());
3530 editor.open_or_advance_search();
3531 for c in r"\[\[".chars() {
3532 editor.handle_input(
3533 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3534 &tx,
3535 );
3536 }
3537 let state = editor.search.as_ref().unwrap();
3538 assert_eq!(state.match_count, 1, "the `[[` sigil is a real match");
3539 let spans = state
3540 .pattern
3541 .as_ref()
3542 .unwrap()
3543 .match_spans(editor.backend.as_textarea().unwrap().text().lines());
3544 assert_eq!(
3545 spans,
3546 vec![(1, 0, 2)],
3547 "and it must be reported as a paintable span, not silently dropped \
3548 because the rendered row conceals it"
3549 );
3550 }
3551
3552 #[test]
3557 fn paste_goes_into_the_focused_bar_field() {
3558 let mut editor = make_editor();
3559 let tx = dummy_tx();
3560 editor.set_text("todo".to_string());
3561 open_replace_bar(&mut editor, &tx, "todo", "");
3562 editor.paste_text("done", &tx);
3563 assert_eq!(editor.get_text(), "todo", "the buffer is untouched");
3564 assert_eq!(editor.search.as_ref().unwrap().replacement(), "done");
3565 }
3566
3567 #[test]
3568 fn a_multiline_paste_collapses_to_its_first_line() {
3569 let mut editor = make_editor();
3570 let tx = dummy_tx();
3571 editor.set_text("x".to_string());
3572 editor.open_or_advance_search();
3573 editor.paste_text("first\nsecond", &tx);
3574 assert_eq!(editor.search.as_ref().unwrap().input.value(), "first");
3575 }
3576
3577 #[test]
3581 fn the_bar_is_never_an_invisible_modal() {
3582 use ratatui::Terminal;
3583 use ratatui::backend::TestBackend;
3584 let mut editor = make_editor();
3585 editor.set_text("todo".to_string());
3586 let theme = Theme::default();
3587 let mut term = Terminal::new(TestBackend::new(40, 1)).unwrap();
3588 let area = Rect::new(0, 0, 40, 1);
3589 editor.open_or_advance_search();
3590 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
3591 let row: String = (0..40)
3592 .filter_map(|x| {
3593 term.backend()
3594 .buffer()
3595 .cell(ratatui::layout::Position::new(x, 0))
3596 .map(|c| c.symbol().to_string())
3597 })
3598 .collect();
3599 assert!(
3600 row.contains("Find:"),
3601 "an open bar must be drawn even when it costs the whole pane, got {row:?}"
3602 );
3603 }
3604
3605 #[test]
3617 fn a_row_far_from_the_cursor_reparses_after_replace_all() {
3618 use ratatui::Terminal;
3619 use ratatui::backend::TestBackend;
3620 let mut editor = make_editor();
3621 let tx = dummy_tx();
3622 let mut lines: Vec<String> = (0..400).map(|i| format!("filler {i}")).collect();
3623 lines[0] = "todo".to_string();
3624 lines[398] = "todo".to_string();
3625 editor.set_text(lines.join("\n"));
3626 let theme = Theme::default();
3627 let mut term = Terminal::new(TestBackend::new(20, 8)).unwrap();
3628 let area = Rect::new(0, 0, 20, 8);
3629 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
3630
3631 open_replace_bar(&mut editor, &tx, "todo", "[[x]]");
3632 if let Some(ta) = editor.backend.as_textarea_mut() {
3635 ta.move_cursor(CursorMove::Jump(398, 0));
3636 }
3637 editor.handle_input(
3638 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3639 &tx,
3640 );
3641 if let Some(ta) = editor.backend.as_textarea_mut() {
3644 ta.move_cursor(CursorMove::Jump(1, 0));
3645 }
3646 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
3647 let row0: String = (0..20)
3648 .filter_map(|x| {
3649 term.backend()
3650 .buffer()
3651 .cell(ratatui::layout::Position::new(x, 0))
3652 .map(|c| c.symbol().to_string())
3653 })
3654 .collect::<String>()
3655 .trim_end()
3656 .to_string();
3657 assert_eq!(
3658 row0, "x",
3659 "row 0 must render as a parsed wikilink; `[[x]]` would mean it \
3660 kept the parse of the text that was there before the replace"
3661 );
3662 }
3663
3664 #[test]
3668 fn indenting_a_block_undoes_in_one_step() {
3669 let mut editor = make_editor();
3670 let tx = dummy_tx();
3671 editor.set_text("a\nb\nc".to_string());
3672 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
3673 get_ta(&mut editor).start_selection();
3674 get_ta(&mut editor).move_cursor(CursorMove::Jump(2, 1));
3675 editor.indent_lines(false);
3676 assert_eq!(editor.get_text(), " a\n b\n c");
3677
3678 editor.handle_input(
3679 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3680 &tx,
3681 );
3682 assert_eq!(
3683 editor.get_text(),
3684 "a\nb\nc",
3685 "one undo must revert the whole block, not just the last line"
3686 );
3687 }
3688
3689 #[test]
3691 fn pasting_over_a_selection_undoes_in_one_step() {
3692 let mut editor = make_editor();
3693 let tx = dummy_tx();
3694 editor.set_text("hello world".to_string());
3695 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
3696 get_ta(&mut editor).start_selection();
3697 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 5));
3698 editor.paste_text("goodbye", &tx);
3699 assert_eq!(editor.get_text(), "goodbye world");
3700
3701 editor.handle_input(
3702 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3703 &tx,
3704 );
3705 assert_eq!(editor.get_text(), "hello world");
3706 }
3707
3708 #[test]
3712 fn closing_the_bar_clears_a_stale_selection() {
3713 let mut editor = make_editor();
3714 let tx = dummy_tx();
3715 editor.set_text("alpha beta".to_string());
3716 editor.selection = Some(((0, 0), (0, 5)));
3717 editor.open_or_advance_search();
3718 for c in "beta".chars() {
3719 editor.handle_input(
3720 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3721 &tx,
3722 );
3723 }
3724 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3725 assert!(editor.search.is_none());
3726 assert_eq!(
3727 editor.selection, None,
3728 "a selection from before the search must not outlive the bar"
3729 );
3730 }
3731
3732 #[test]
3735 fn undo_inside_the_bar_rederives_the_current_match() {
3736 let mut editor = make_editor();
3737 let tx = dummy_tx();
3738 editor.set_text("foo foo".to_string());
3739 open_replace_bar(&mut editor, &tx, "foo", "xy");
3740 editor.handle_input(
3741 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3742 &tx,
3743 );
3744 assert_eq!(editor.get_text(), "xy foo");
3745 editor.handle_input(
3746 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3747 &tx,
3748 );
3749 assert_eq!(editor.get_text(), "foo foo");
3750 let current = editor.search.as_ref().unwrap().current_match();
3751 if let Some(((row, start), (_, end))) = current {
3752 let line = &editor.get_text()[..];
3753 let text: String = line
3754 .lines()
3755 .nth(row)
3756 .unwrap()
3757 .chars()
3758 .skip(start)
3759 .take(end - start)
3760 .collect();
3761 assert_eq!(
3762 text, "foo",
3763 "the highlight must sit on a real match, got {text:?}"
3764 );
3765 }
3766 }
3767
3768 #[test]
3772 fn vim_n_highlights_the_match_it_lands_on() {
3773 let mut editor = make_vim_editor();
3774 let tx = dummy_tx();
3775 editor.set_text("lo xx lo".to_string());
3776 editor.open_or_advance_search();
3777 for c in "lo".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 editor.handle_input(
3785 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
3786 &tx,
3787 );
3788 assert_eq!(
3789 editor.selection,
3790 Some(((0, 6), (0, 8))),
3791 "`n` must paint the match it jumped to"
3792 );
3793 }
3794
3795 #[test]
3800 fn closing_the_bar_cannot_leave_an_invisible_selection() {
3801 let mut editor = make_editor();
3802 let tx = dummy_tx();
3803 editor.set_text("foo bar baz".to_string());
3804 get_ta(&mut editor).select_all();
3806 editor.open_or_advance_search();
3807 for c in "bar".chars() {
3808 editor.handle_input(
3809 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3810 &tx,
3811 );
3812 }
3813 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3814 editor.handle_input(
3815 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3816 &tx,
3817 );
3818 assert!(
3819 editor.get_text().contains("foo"),
3820 "typing after the bar closed must not eat unhighlighted text, got {:?}",
3821 editor.get_text()
3822 );
3823 }
3824
3825 #[test]
3828 fn vim_visual_indent_undoes_in_one_step() {
3829 let mut editor = make_vim_editor();
3830 let tx = dummy_tx();
3831 editor.set_text("a\nb\nc".to_string());
3832 for c in ['V', 'j', 'j', '>'] {
3833 editor.handle_input(
3834 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3835 &tx,
3836 );
3837 }
3838 let indented = editor.get_text();
3839 assert_ne!(indented, "a\nb\nc", "`>` must indent the selection");
3840 editor.handle_input(
3841 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
3842 &tx,
3843 );
3844 assert_eq!(
3845 editor.get_text(),
3846 "a\nb\nc",
3847 "one `u` must revert the whole indent, not one row"
3848 );
3849 }
3850
3851 #[test]
3855 fn vim_n_cannot_leave_an_invisible_selection() {
3856 let mut editor = make_vim_editor();
3857 let tx = dummy_tx();
3858 editor.set_text("foo bar foo".to_string());
3859 editor.open_or_advance_search();
3860 for c in "foo".chars() {
3861 editor.handle_input(
3862 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3863 &tx,
3864 );
3865 }
3866 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3867 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
3869 get_ta(&mut editor).start_selection();
3870 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 3));
3871 editor.handle_input(
3872 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
3873 &tx,
3874 );
3875 for c in ['i', 'X'] {
3876 editor.handle_input(
3877 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3878 &tx,
3879 );
3880 }
3881 assert!(
3882 editor.get_text().contains("bar"),
3883 "typing after `n` must not eat unhighlighted text, got {:?}",
3884 editor.get_text()
3885 );
3886 }
3887
3888 #[test]
3893 fn overlays_paint_where_the_post_pass_used_to() {
3894 use ratatui::Terminal;
3895 use ratatui::backend::TestBackend;
3896 use ratatui::layout::Position;
3897 let mut editor = make_editor();
3898 editor.set_text("find the needle here\n- [x] done task\n- [ ] open task".to_string());
3899 editor.set_search_needles(vec!["needle".to_string()]);
3900 let theme = Theme::default();
3901 let mut term = Terminal::new(TestBackend::new(40, 6)).unwrap();
3902 let area = Rect::new(0, 0, 40, 6);
3903 term.draw(|f| editor.render(f, area, &theme, false))
3904 .unwrap();
3905 let buf = term.backend().buffer();
3906
3907 let row: String = (0..40)
3908 .filter_map(|x| {
3909 buf.cell(Position::new(x, 0))
3910 .map(|c| c.symbol().to_string())
3911 })
3912 .collect();
3913 let at = row.find("needle").expect("needle is on screen");
3914 let cell = buf.cell(Position::new(at as u16, 0)).unwrap();
3915 assert_eq!(
3916 cell.fg,
3917 theme.color_search_match.to_ratatui(),
3918 "the needle must still be emphasised"
3919 );
3920
3921 let struck = |y: u16| {
3923 (0..40).any(|x| {
3924 buf.cell(Position::new(x, y)).is_some_and(|c| {
3925 c.style()
3926 .add_modifier
3927 .contains(ratatui::style::Modifier::CROSSED_OUT)
3928 })
3929 })
3930 };
3931 assert!(struck(1), "a done task strikes its text");
3932 assert!(!struck(2), "an open task does not");
3933
3934 for y in [1u16, 2] {
3936 assert!(
3937 (0..40).any(|x| buf
3938 .cell(Position::new(x, y))
3939 .is_some_and(|c| c.fg == theme.accent.to_ratatui())),
3940 "row {y} must have an accent-coloured checkbox"
3941 );
3942 }
3943 }
3944
3945 #[test]
3946 fn no_preview_without_a_replace_field() {
3947 let mut editor = make_editor();
3948 let tx = dummy_tx();
3949 editor.set_text("todo".to_string());
3950 editor.open_or_advance_search();
3951 for c in "todo".chars() {
3952 editor.handle_input(
3953 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3954 &tx,
3955 );
3956 }
3957 assert!(
3958 editor.replace_preview().is_none(),
3959 "a find-only bar previews nothing"
3960 );
3961 }
3962
3963 #[test]
3964 fn capture_expansion_is_gated_on_the_pattern_capturing() {
3965 let mut editor = make_editor();
3966 let tx = dummy_tx();
3967 editor.set_text("cost".to_string());
3969 open_replace_bar(&mut editor, &tx, "cost", "$1");
3970 editor.handle_input(
3971 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3972 &tx,
3973 );
3974 assert_eq!(editor.get_text(), "$1");
3975 }
3976
3977 #[test]
3978 fn the_bar_reserves_two_rows_only_while_replacing() {
3979 use ratatui::Terminal;
3980 use ratatui::backend::TestBackend;
3981 let mut editor = make_editor();
3982 let tx = dummy_tx();
3983 editor.set_text("todo".to_string());
3984 let theme = Theme::default();
3985 let mut term = Terminal::new(TestBackend::new(40, 10)).unwrap();
3986 let area = Rect::new(0, 0, 40, 10);
3987
3988 editor.open_or_advance_search();
3989 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
3990 assert_eq!(editor.rect.height, 9, "a find-only bar takes one row");
3991
3992 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3993 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
3994 assert_eq!(
3995 editor.rect.height, 8,
3996 "the replace field takes a second row"
3997 );
3998 }
3999
4000 #[test]
4005 fn guu_really_does_undo_in_one_step() {
4006 let mut editor = make_vim_editor();
4007 let tx = dummy_tx();
4008 editor.set_text("Mixed Case Line".to_string());
4009 for c in "guu".chars() {
4010 editor.handle_input(
4011 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4012 &tx,
4013 );
4014 }
4015 assert_eq!(editor.get_text(), "mixed case line");
4016 editor.handle_input(
4017 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4018 &tx,
4019 );
4020 assert_eq!(editor.get_text(), "Mixed Case Line");
4021 }
4022
4023 #[test]
4028 fn vim_u_undoes_a_whole_replace() {
4029 let mut editor = make_vim_editor();
4030 let tx = dummy_tx();
4031 editor.set_text("todo and todo".to_string());
4032 open_replace_bar(&mut editor, &tx, "todo", "done");
4033 editor.handle_input(
4034 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4035 &tx,
4036 );
4037 assert_eq!(editor.get_text(), "done and done");
4038
4039 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4041 editor.handle_input(
4042 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4043 &tx,
4044 );
4045 assert_eq!(editor.get_text(), "todo and todo");
4046 }
4047
4048 fn type_out(editor: &mut TextEditorComponent, tx: &AppTx, text: &str) {
4051 use ratatui::crossterm::event::KeyEvent;
4052 for c in text.chars() {
4053 let code = if c == '\n' {
4054 KeyCode::Enter
4055 } else {
4056 KeyCode::Char(c)
4057 };
4058 editor.handle_textarea_key(&KeyEvent::new(code, KeyModifiers::NONE), tx);
4059 }
4060 }
4061
4062 #[test]
4063 fn undo_takes_back_a_word_not_a_letter() {
4064 let mut editor = make_editor();
4067 let tx = dummy_tx();
4068 editor.set_text(String::new());
4069 type_out(&mut editor, &tx, "hello world");
4070 assert_eq!(editor.get_text(), "hello world");
4071
4072 assert!(get_ta(&mut editor).undo());
4073 assert_eq!(editor.get_text(), "hello ", "the last word goes whole");
4074 assert!(get_ta(&mut editor).undo());
4075 assert_eq!(editor.get_text(), "", "and so does the first");
4076 }
4077
4078 #[test]
4079 fn a_cursor_move_separates_two_runs() {
4080 let mut editor = make_editor();
4081 let tx = dummy_tx();
4082 editor.set_text(String::new());
4083 type_out(&mut editor, &tx, "ab");
4084 arrow(&mut editor, &tx, KeyCode::Home);
4085 type_out(&mut editor, &tx, "cd");
4086 assert_eq!(editor.get_text(), "cdab");
4087
4088 assert!(get_ta(&mut editor).undo());
4089 assert_eq!(
4090 editor.get_text(),
4091 "ab",
4092 "only what was typed after the move comes back off"
4093 );
4094 }
4095
4096 #[test]
4097 fn backspacing_to_fix_a_typo_is_its_own_action() {
4098 use ratatui::crossterm::event::KeyEvent;
4099 let mut editor = make_editor();
4100 let tx = dummy_tx();
4101 editor.set_text(String::new());
4102 type_out(&mut editor, &tx, "helllo");
4103 editor.handle_textarea_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
4104 assert_eq!(editor.get_text(), "helll");
4105
4106 assert!(get_ta(&mut editor).undo());
4107 assert_eq!(
4108 editor.get_text(),
4109 "helllo",
4110 "the delete undoes on its own, without taking the typing with it"
4111 );
4112 }
4113
4114 #[test]
4115 fn an_undo_between_two_runs_separates_them() {
4116 let mut editor = make_editor();
4119 let tx = dummy_tx();
4120 editor.set_text(String::new());
4121 type_out(&mut editor, &tx, "ab");
4122 assert!(get_ta(&mut editor).undo());
4123 assert_eq!(editor.get_text(), "");
4124 type_out(&mut editor, &tx, "cd");
4125 assert_eq!(editor.get_text(), "cd");
4126 assert!(get_ta(&mut editor).undo());
4127 assert_eq!(
4128 editor.get_text(),
4129 "",
4130 "the second run is its own group, not an extension of an undone one"
4131 );
4132 }
4133
4134 #[test]
4135 fn a_save_closes_the_open_group() {
4136 let mut editor = make_editor();
4140 let tx = dummy_tx();
4141 type_out(&mut editor, &tx, "abc");
4142 let saved = editor.get_text();
4143 editor.mark_saved(saved);
4144 type_out(&mut editor, &tx, "def");
4147
4148 assert!(get_ta(&mut editor).undo());
4149 assert_eq!(
4150 editor.get_text(),
4151 "abc",
4152 "one undo lands on what was saved, not before it"
4153 );
4154 }
4155
4156 #[test]
4157 fn a_stale_save_completion_does_not_close_the_group() {
4158 let mut editor = make_editor();
4162 let tx = dummy_tx();
4163 type_out(&mut editor, &tx, "abc");
4164 let stale = NonZeroU64::new(1).expect("nonzero");
4165 editor.mark_saved_at_revision(stale);
4166 type_out(&mut editor, &tx, "def");
4167
4168 assert!(get_ta(&mut editor).undo());
4169 assert_eq!(
4170 editor.get_text(),
4171 "",
4172 "the run carried on across a completion that marked nothing"
4173 );
4174 }
4175
4176 #[test]
4177 fn a_second_vim_insert_session_is_its_own_group() {
4178 use ratatui::crossterm::event::KeyEvent;
4182 let mut editor = make_vim_editor();
4183 let tx = dummy_tx();
4184 editor.set_text(String::new());
4185 let press = |editor: &mut TextEditorComponent, code| {
4186 let _ = editor.handle_input(
4187 &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
4188 &tx,
4189 );
4190 };
4191 press(&mut editor, KeyCode::Char('i'));
4192 for c in "one".chars() {
4193 press(&mut editor, KeyCode::Char(c));
4194 }
4195 press(&mut editor, KeyCode::Esc);
4196 press(&mut editor, KeyCode::Char('i'));
4197 for c in "two".chars() {
4198 press(&mut editor, KeyCode::Char(c));
4199 }
4200 press(&mut editor, KeyCode::Esc);
4201 assert_eq!(editor.get_text(), "ontwoe");
4204
4205 assert!(get_ta(&mut editor).undo());
4206 assert_eq!(
4207 editor.get_text(),
4208 "one",
4209 "`u` takes back the second session only"
4210 );
4211 }
4212
4213 #[test]
4214 fn a_vim_insert_session_undoes_whole() {
4215 use ratatui::crossterm::event::KeyEvent;
4216 let mut editor = make_vim_editor();
4217 let tx = dummy_tx();
4218 editor.set_text(String::new());
4219 let press = |editor: &mut TextEditorComponent, code| {
4221 let _ = editor.handle_input(
4222 &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
4223 &tx,
4224 );
4225 };
4226 press(&mut editor, KeyCode::Char('i'));
4227 for c in "hello world".chars() {
4228 press(&mut editor, KeyCode::Char(c));
4229 }
4230 press(&mut editor, KeyCode::Esc);
4231 assert_eq!(editor.get_text(), "hello world");
4232
4233 assert!(get_ta(&mut editor).undo());
4234 assert_eq!(
4235 editor.get_text(),
4236 "",
4237 "vim's `u` takes back the whole session, word boundaries included"
4238 );
4239 }
4240
4241 fn lay_out(editor: &mut TextEditorComponent, width: u16, height: u16) {
4245 use ratatui::Terminal;
4246 use ratatui::backend::TestBackend;
4247 let theme = Theme::default();
4248 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
4249 let area = Rect::new(0, 0, width, height);
4250 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4251 }
4252
4253 fn arrow(editor: &mut TextEditorComponent, tx: &AppTx, code: KeyCode) {
4254 use ratatui::crossterm::event::KeyEvent;
4255 editor.handle_textarea_key(&KeyEvent::new(code, KeyModifiers::NONE), tx);
4256 }
4257
4258 #[test]
4259 fn down_moves_one_drawn_line_not_one_row() {
4260 let mut editor = make_editor();
4263 let tx = dummy_tx();
4264 editor.set_text(
4265 "aaaa bbbb cccc dddd
4266second row"
4267 .to_string(),
4268 );
4269 lay_out(&mut editor, 6, 10);
4270
4271 get_ta(&mut editor).jump_to(0, 0);
4272 arrow(&mut editor, &tx, KeyCode::Down);
4273 assert_eq!(
4274 get_ta(&mut editor).cursor(),
4275 (0, 5),
4276 "still inside the first row, on its second drawn line"
4277 );
4278 arrow(&mut editor, &tx, KeyCode::Down);
4279 assert_eq!(get_ta(&mut editor).cursor(), (0, 10));
4280 arrow(&mut editor, &tx, KeyCode::Down);
4281 assert_eq!(get_ta(&mut editor).cursor(), (0, 15));
4282 arrow(&mut editor, &tx, KeyCode::Down);
4283 assert_eq!(
4284 get_ta(&mut editor).cursor().0,
4285 1,
4286 "and only the fourth press reaches the next row"
4287 );
4288 }
4289
4290 #[test]
4291 fn up_and_down_are_symmetric_across_a_wrap() {
4292 let mut editor = make_editor();
4293 let tx = dummy_tx();
4294 editor.set_text("aaaa bbbb cccc".to_string());
4295 lay_out(&mut editor, 6, 10);
4296
4297 get_ta(&mut editor).jump_to(0, 0);
4298 arrow(&mut editor, &tx, KeyCode::Down);
4299 let middle = get_ta(&mut editor).cursor();
4300 arrow(&mut editor, &tx, KeyCode::Up);
4301 assert_eq!(get_ta(&mut editor).cursor(), (0, 0));
4302 assert_eq!(middle, (0, 5));
4303 }
4304
4305 #[test]
4306 fn an_arrow_against_a_stale_layout_falls_back_instead_of_panicking() {
4307 let mut editor = make_editor();
4312 let tx = dummy_tx();
4313 editor.set_text("abcd\nefgh".to_string());
4314 lay_out(&mut editor, 20, 10);
4315
4316 get_ta(&mut editor).jump_to(0, 4);
4317 for _ in 0..3 {
4318 get_ta(&mut editor).delete_char();
4319 }
4320 assert_eq!(get_ta(&mut editor).rows(), &["a", "efgh"]);
4321
4322 arrow(&mut editor, &tx, KeyCode::Down);
4324 assert_eq!(get_ta(&mut editor).cursor().0, 1, "still moved down a row");
4325 }
4326
4327 #[test]
4328 fn an_action_between_arrows_forgets_the_goal_cell() {
4329 let mut editor = make_editor();
4335 let tx = dummy_tx();
4336 editor.set_text(
4337 "aaaaaaaa
4338bb
4339cccccccc"
4340 .to_string(),
4341 );
4342 lay_out(&mut editor, 20, 10);
4343
4344 get_ta(&mut editor).jump_to(0, 7);
4345 arrow(&mut editor, &tx, KeyCode::Down);
4346 assert_eq!(
4347 get_ta(&mut editor).cursor(),
4348 (1, 2),
4349 "clamped to the short row"
4350 );
4351
4352 let saved = editor.get_text();
4353 editor.mark_saved(saved);
4354
4355 arrow(&mut editor, &tx, KeyCode::Down);
4356 assert_eq!(
4357 get_ta(&mut editor).cursor(),
4358 (2, 2),
4359 "the goal was forgotten, so the third row keeps the clamped column"
4360 );
4361 }
4362
4363 #[test]
4364 fn a_run_of_arrows_keeps_its_goal_cell() {
4365 let mut editor = make_editor();
4368 let tx = dummy_tx();
4369 editor.set_text(
4370 "aaaaaaaa
4371bb
4372cccccccc"
4373 .to_string(),
4374 );
4375 lay_out(&mut editor, 20, 10);
4376
4377 get_ta(&mut editor).jump_to(0, 7);
4378 arrow(&mut editor, &tx, KeyCode::Down);
4379 assert_eq!(
4380 get_ta(&mut editor).cursor(),
4381 (1, 2),
4382 "clamped to the short row"
4383 );
4384 arrow(&mut editor, &tx, KeyCode::Down);
4385 assert_eq!(
4386 get_ta(&mut editor).cursor(),
4387 (2, 7),
4388 "and back out to the cell the run still wants"
4389 );
4390 }
4391
4392 #[test]
4393 fn another_key_ends_the_run() {
4394 let mut editor = make_editor();
4395 let tx = dummy_tx();
4396 editor.set_text(
4397 "aaaaaaaa
4398bb
4399cccccccc"
4400 .to_string(),
4401 );
4402 lay_out(&mut editor, 20, 10);
4403
4404 get_ta(&mut editor).jump_to(0, 7);
4405 arrow(&mut editor, &tx, KeyCode::Down);
4406 arrow(&mut editor, &tx, KeyCode::Home);
4407 arrow(&mut editor, &tx, KeyCode::Down);
4408 assert_eq!(
4409 get_ta(&mut editor).cursor(),
4410 (2, 0),
4411 "Home set a new goal; the old one is gone"
4412 );
4413 }
4414
4415 #[test]
4416 fn shift_down_extends_by_a_drawn_line() {
4417 let mut editor = make_editor();
4418 let tx = dummy_tx();
4419 editor.set_text("aaaa bbbb cccc".to_string());
4420 lay_out(&mut editor, 6, 10);
4421
4422 get_ta(&mut editor).jump_to(0, 0);
4423 editor.handle_textarea_key(
4424 &ratatui::crossterm::event::KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT),
4425 &tx,
4426 );
4427 assert_eq!(
4428 get_ta(&mut editor).selection_range(),
4429 Some(((0, 0), (0, 5)))
4430 );
4431 }
4432
4433 fn make_vim_editor() -> TextEditorComponent {
4435 let mut settings = crate::settings::AppSettings::default();
4436 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
4437 TextEditorComponent::new(KeyBindings::empty(), &settings)
4438 }
4439
4440 fn vim_mode(editor: &TextEditorComponent) -> EditorMode {
4443 match &editor.backend {
4444 BackendState::Textarea(tb) => match &tb.input {
4445 backend::InputInterpreter::Vim(e) => e.mode().clone(),
4446 _ => panic!("expected Vim input interpreter"),
4447 },
4448 _ => panic!("expected Textarea backend"),
4449 }
4450 }
4451
4452 #[test]
4458 fn vim_visual_paste_url_wraps_whole_selected_word() {
4459 let mut editor = make_vim_editor();
4460 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4461 editor.set_text("hello world".to_string());
4462 editor.handle_input(
4465 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
4466 &tx,
4467 );
4468 editor.handle_input(
4469 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
4470 &tx,
4471 );
4472 assert_eq!(vim_mode(&editor), EditorMode::Visual);
4473 editor.paste_text("https://example.com", &tx);
4474 assert_eq!(
4475 editor.get_text(),
4476 "[hello](https://example.com) world",
4477 "the whole selected word (including the char under the cursor) must be wrapped"
4478 );
4479 }
4480
4481 #[test]
4487 fn vim_visual_bold_wraps_whole_selected_word() {
4488 let mut editor = make_vim_editor();
4489 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4490 editor.set_text("hello world".to_string());
4491 editor.handle_input(
4492 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
4493 &tx,
4494 );
4495 editor.handle_input(
4496 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
4497 &tx,
4498 );
4499 assert_eq!(vim_mode(&editor), EditorMode::Visual);
4500 editor.apply_text_action(TextAction::Bold);
4501 assert_eq!(
4502 editor.get_text(),
4503 "**hello** world",
4504 "the whole selected word (including the char under the cursor) must be wrapped"
4505 );
4506 }
4507
4508 #[test]
4516 fn paste_reports_its_outcome() {
4517 let mut editor = make_editor();
4518 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4519 editor.set_text("x".to_string());
4520 editor.paste_from_clipboard(&tx);
4521 let reported = std::iter::from_fn(|| rx.try_recv().ok()).any(|e| {
4522 matches!(e, AppEvent::FlashMessage(m)
4523 if m == "pasted" || m == "clipboard is empty" || m.starts_with("clipboard: "))
4524 });
4525 assert!(reported, "a paste attempt must always report something");
4526 }
4527
4528 #[test]
4534 fn external_paste_drops_the_selection_and_leaves_visual() {
4535 let mut editor = make_vim_editor();
4536 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4537 editor.set_text("hello world".to_string());
4538 editor.handle_input(
4539 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
4540 &tx,
4541 );
4542 editor.handle_input(
4543 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
4544 &tx,
4545 );
4546 assert_eq!(vim_mode(&editor), EditorMode::Visual);
4547
4548 editor.take_selection_for_external_paste();
4549
4550 assert_eq!(
4551 vim_mode(&editor),
4552 EditorMode::Normal,
4553 "the engine must not keep believing it is in Visual"
4554 );
4555 assert_eq!(
4556 get_ta(&mut editor).selection_range(),
4557 None,
4558 "the selection the incoming content replaces must be gone"
4559 );
4560 assert_eq!(
4561 editor.get_text(),
4562 " world",
4563 "the inclusive visual range is what gets replaced"
4564 );
4565 }
4566
4567 #[test]
4570 fn external_paste_without_a_selection_leaves_the_buffer_alone() {
4571 let mut editor = make_vim_editor();
4572 editor.set_text("hello world".to_string());
4573 editor.take_selection_for_external_paste();
4574 assert_eq!(editor.get_text(), "hello world");
4575 assert_eq!(vim_mode(&editor), EditorMode::Normal);
4576 }
4577
4578 #[test]
4583 fn vim_visual_copy_is_read_only_and_does_not_grow_selection() {
4584 let mut editor = make_vim_editor();
4585 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4586 editor.set_text("hello world".to_string());
4587 editor.handle_input(
4588 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
4589 &tx,
4590 );
4591 editor.handle_input(
4592 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
4593 &tx,
4594 );
4595 let before = get_ta(&mut editor).selection_range();
4596 assert_eq!(before, Some(((0, 0), (0, 4))));
4597 assert_eq!(
4599 editor.inclusive_visual_range(),
4600 Some(((0, 0), (0, 5))),
4601 "copy must read the inclusive range including the cursor char"
4602 );
4603 editor.copy_selection_to_clipboard(&tx);
4605 editor.copy_selection_to_clipboard(&tx);
4606 assert_eq!(
4607 get_ta(&mut editor).selection_range(),
4608 before,
4609 "copy must not move the cursor or grow the live selection"
4610 );
4611 }
4612
4613 #[test]
4621 fn vim_visual_line_selection_stays_full_width_after_cursor_moves_back() {
4622 let mut editor = make_vim_editor();
4623 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4624 editor.set_text("hello world".to_string());
4625 editor.handle_input(
4626 &InputEvent::Key(key(KeyCode::Char('V'), KeyModifiers::NONE)),
4627 &tx,
4628 );
4629 assert_eq!(vim_mode(&editor), EditorMode::VisualLine);
4630 assert_eq!(
4631 editor.selection,
4632 Some(((0, 0), (0, usize::MAX))),
4633 "V must highlight the full line right away"
4634 );
4635 for _ in 0..5 {
4636 editor.handle_input(
4637 &InputEvent::Key(key(KeyCode::Left, KeyModifiers::NONE)),
4638 &tx,
4639 );
4640 }
4641 assert_eq!(
4642 editor.selection,
4643 Some(((0, 0), (0, usize::MAX))),
4644 "moving the cursor back must not shrink the linewise highlight"
4645 );
4646 }
4647
4648 #[test]
4655 fn vim_visual_outdent_with_nothing_to_remove_clears_the_highlight() {
4656 let mut editor = make_vim_editor();
4657 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4658 editor.set_text("alpha\nbeta".to_string());
4659 for c in ['V', 'j'] {
4660 editor.handle_input(
4661 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4662 &tx,
4663 );
4664 }
4665 assert_eq!(vim_mode(&editor), EditorMode::VisualLine);
4666 assert!(
4667 editor.selection.is_some(),
4668 "V over two rows must paint a highlight"
4669 );
4670
4671 editor.handle_input(
4672 &InputEvent::Key(key(KeyCode::Char('<'), KeyModifiers::NONE)),
4673 &tx,
4674 );
4675 assert_eq!(
4676 editor.get_text(),
4677 "alpha\nbeta",
4678 "there is no indentation to remove"
4679 );
4680 assert_eq!(vim_mode(&editor), EditorMode::Normal);
4681 assert_eq!(
4682 editor.selection, None,
4683 "leaving Visual mode must clear the highlight even when the outdent changed nothing"
4684 );
4685 }
4686
4687 #[test]
4697 fn vim_sync_collapsed_sel_stays_normal() {
4698 let mut editor = make_vim_editor();
4699 editor.set_text("hello world".to_string());
4700
4701 assert_eq!(vim_mode(&editor), EditorMode::Normal);
4703
4704 editor.backend.sync_mouse_selection(false);
4707 assert_eq!(
4708 vim_mode(&editor),
4709 EditorMode::Normal,
4710 "collapsed (bare click) selection must not enter Visual mode"
4711 );
4712 }
4713
4714 #[test]
4716 fn vim_sync_real_sel_enters_visual() {
4717 let mut editor = make_vim_editor();
4718 editor.set_text("hello world".to_string());
4719
4720 assert_eq!(vim_mode(&editor), EditorMode::Normal);
4722
4723 editor.backend.sync_mouse_selection(true);
4725 assert_eq!(
4726 vim_mode(&editor),
4727 EditorMode::Visual,
4728 "real drag selection must enter Visual mode"
4729 );
4730 }
4731
4732 #[test]
4736 fn vim_find_bar_captures_typing_not_cursor() {
4737 let mut editor = make_vim_editor();
4738 editor.set_text("hello world\nsecond line".to_string());
4739 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4740
4741 editor.open_or_advance_search();
4743 assert!(editor.search.is_some(), "find bar must be open");
4744
4745 editor.handle_input(
4747 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
4748 &tx,
4749 );
4750 editor.handle_input(
4751 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
4752 &tx,
4753 );
4754
4755 let q = editor
4759 .search
4760 .as_ref()
4761 .map(|s| s.input.value().to_string())
4762 .unwrap_or_default();
4763 assert_eq!(q, "lo", "find query must capture typed characters");
4764
4765 assert_eq!(
4768 editor.get_text(),
4769 "hello world\nsecond line",
4770 "buffer must not be modified while find bar is open"
4771 );
4772
4773 assert_eq!(
4779 editor.cursor_pos().1,
4780 3,
4781 "cursor must jump to the search match (col 3), not to a vim motion position"
4782 );
4783 }
4784
4785 #[test]
4790 fn vim_search_enter_steps_and_esc_keeps_the_pattern_for_n() {
4791 let mut editor = make_vim_editor();
4792 editor.set_text("lo xx lo yy lo".to_string());
4794 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4795
4796 editor.open_or_advance_search();
4798 assert!(editor.search.is_some(), "find bar must open");
4799
4800 editor.handle_input(
4802 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
4803 &tx,
4804 );
4805 editor.handle_input(
4806 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
4807 &tx,
4808 );
4809
4810 editor.handle_input(
4814 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
4815 &tx,
4816 );
4817 assert!(
4818 editor.search.is_some(),
4819 "find bar stays open on Enter — it steps, it does not confirm"
4820 );
4821 let (_, c1) = editor.cursor_pos();
4822 assert_eq!(c1, 6, "Enter must step to the 2nd 'lo' at col 6");
4823
4824 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4828 assert!(editor.search.is_none(), "Esc must close the find bar");
4829
4830 editor.handle_input(
4832 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
4833 &tx,
4834 );
4835 let (_, c2) = editor.cursor_pos();
4836 assert_eq!(c2, 12, "'n' must jump to the 3rd 'lo' at col 12");
4837
4838 assert_eq!(editor.get_text(), "lo xx lo yy lo");
4840 }
4841}