1pub mod autocomplete_glue;
2pub mod backend;
3pub mod find_bar;
4pub mod find_replace;
5pub mod markdown;
6pub mod markdown_edits;
7pub mod nvim_decode;
8pub mod nvim_host;
9pub mod nvim_rpc;
10pub mod parse_incremental;
11pub mod plain_keys;
12mod revisions;
13pub mod rope_buffer;
14pub mod typing_run;
15use revisions::Revisions;
16pub mod snapshot;
17pub mod text_coords;
18pub mod view;
19mod vim;
20mod vim_objects;
21pub mod widener_metrics;
22
23use self::rope_buffer::CursorMove;
24use ratatui::Frame;
25use ratatui::crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
26use ratatui::layout::Rect;
27use ratatui::style::{Modifier, Style};
28use std::num::NonZeroU64;
29
30fn snapshot_from_backend(backend: &BackendState, content_revision: NonZeroU64) -> EditorSnapshot {
37 match backend {
38 BackendState::Textarea(tb) => {
39 let cursor = tb.ta.cursor();
40 EditorSnapshot::of_buffer(tb.ta.text().clone(), cursor, content_revision)
41 }
42 BackendState::Nvim(nvim) => {
43 let snap = nvim.snapshot();
44 let lines_len = snap.lines.len();
45 let cursor_row = if lines_len == 0 {
46 0
47 } else {
48 snap.cursor.0.min(lines_len - 1)
49 };
50 let cursor = (cursor_row, snap.cursor.1);
51 let lines = snap.lines.clone();
52 let rev = Revisions::rev_from_gen(snap.content_gen);
53 drop(snap);
54 EditorSnapshot::owned(lines, cursor, rev)
55 }
56 }
57}
58
59fn preview_revision(base: NonZeroU64, lines: &[String]) -> NonZeroU64 {
68 use std::collections::hash_map::DefaultHasher;
69 use std::hash::{Hash, Hasher};
70 let mut h = DefaultHasher::new();
71 base.get().hash(&mut h);
72 lines.hash(&mut h);
73 NonZeroU64::new(h.finish()).unwrap_or(NonZeroU64::MIN)
74}
75
76fn has_trigger_before_cursor(line: &str, col: usize) -> bool {
87 let cursor_byte = line
88 .char_indices()
89 .nth(col)
90 .map(|(b, _)| b)
91 .unwrap_or(line.len());
92 line[..cursor_byte]
93 .chars()
94 .rev()
95 .any(|c| c == '[' || c == '#')
96}
97
98use self::backend::BackendState;
99#[cfg(test)]
100use self::find_bar::{BarFocus, SearchStatus};
101use self::markdown::ParsedBuffer;
102use self::nvim_host::NvimHost;
103use self::rope_buffer::RopeBuffer;
104use self::snapshot::EditorSnapshot;
105use self::view::MarkdownEditorView;
106use crate::util::single_slot_task::SingleSlotTask;
107
108pub(super) fn char_col_to_byte(line: &str, char_col: usize) -> usize {
111 line.char_indices()
112 .nth(char_col)
113 .map(|(b, _)| b)
114 .unwrap_or(line.len())
115}
116
117#[derive(Debug, Clone)]
121pub struct ClipboardImage {
122 pub width: usize,
123 pub height: usize,
124 pub rgba: Vec<u8>,
125}
126
127const LINKABLE_PASTE_SCHEMES: &[&str] = &["http", "https", "ftp", "ftps", "mailto"];
131
132const WHEEL_SCROLL_ROWS: isize = 1;
134
135fn linkable_url(s: &str) -> Option<&str> {
136 kimun_core::note::scan::url_with_allowed_scheme(s, LINKABLE_PASTE_SCHEMES)
137}
138
139fn try_build_markdown_link(clip: &str, selection: Option<&str>) -> Option<String> {
143 let url = linkable_url(clip)?;
144 let sel = selection.filter(|s| !s.is_empty())?;
145 let escaped = sel.replace('\\', r"\\").replace(']', r"\]");
146 Some(format!("[{escaped}]({url})"))
147}
148
149use std::sync::Arc;
150
151use kimun_core::NoteVault;
152
153use crate::components::Component;
154use crate::components::autocomplete::{
155 self, AutocompleteController, AutocompleteHost, AutocompleteMode, HandleKeyOutcome,
156};
157use crate::components::event_state::EventState;
158use crate::components::events::AppEvent;
159use crate::components::events::AppTx;
160use crate::components::events::InputEvent;
161use crate::components::events::redraw_callback;
162use crate::components::text_editor::autocomplete_glue::apply_accept_to_textarea;
163use crate::keys::KeyBindings;
164use crate::keys::action_shortcuts::TextAction;
165use crate::settings::AppSettings;
166use crate::settings::themes::Theme;
167
168#[derive(Debug, Clone, PartialEq)]
176pub enum FollowTarget {
177 Link(String),
179 Label(String),
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
191pub enum EditorClaim {
192 #[default]
193 None,
194 FindBar,
195 Autocomplete,
196}
197
198struct EditorHostSnapshot {
205 snap: EditorSnapshot,
206 cursor_screen: Option<(u16, u16)>,
207 cache_key: Option<NonZeroU64>,
208}
209
210impl AutocompleteHost for EditorHostSnapshot {
211 fn buffer_snapshot(&self) -> EditorSnapshot {
212 EditorSnapshot::of_buffer(
217 self.snap.text.clone(),
218 self.snap.cursor,
219 self.snap.content_revision,
220 )
221 }
222 fn cache_key(&self) -> Option<NonZeroU64> {
223 self.cache_key
224 }
225 fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
226 Some(self.cursor_screen.unwrap_or((0, 0)))
240 }
241}
242
243fn build_editor_host_snapshot(
249 backend: &BackendState,
250 content_revision: NonZeroU64,
251 cursor_screen: Option<(u16, u16)>,
252) -> Option<EditorHostSnapshot> {
253 if !backend.is_textarea() {
254 return None;
255 }
256 Some(EditorHostSnapshot {
257 snap: snapshot_from_backend(backend, content_revision),
258 cursor_screen,
259 cache_key: Some(content_revision),
260 })
261}
262
263pub struct TextEditorComponent {
267 backend: BackendState,
268 rect: Rect,
270 key_bindings: KeyBindings,
271 view: MarkdownEditorView,
272 revs: Revisions,
281 selection: Option<((usize, usize), (usize, usize))>,
284 nvim_host: NvimHost,
287 search: Option<find_bar::FindBar>,
289 autocomplete: Option<AutocompleteController>,
293 autocomplete_vault: Option<Arc<NoteVault>>,
297 autocomplete_redraw_bound: bool,
302 full_parse_task: SingleSlotTask<()>,
309 layout_task: SingleSlotTask<()>,
316 last_insert_session: bool,
320 pub wants_context_menu: bool,
323 search_needles: Vec<String>,
327 full_parse_tx: tokio::sync::mpsc::UnboundedSender<(u64, ParsedBuffer)>,
328 full_parse_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, ParsedBuffer)>,
329 layout_tx: tokio::sync::mpsc::UnboundedSender<(u64, crate::ropetext::Layout)>,
330 layout_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, crate::ropetext::Layout)>,
331 redraw_tx: Option<AppTx>,
335}
336
337impl TextEditorComponent {
338 pub fn new(key_bindings: KeyBindings, settings: &AppSettings) -> Self {
339 let (full_parse_tx, full_parse_rx) = tokio::sync::mpsc::unbounded_channel();
340 let (layout_tx, layout_rx) = tokio::sync::mpsc::unbounded_channel();
341 Self {
342 backend: BackendState::from_settings(
343 &settings.editor_backend,
344 settings.nvim_path.as_ref(),
345 ),
346 rect: Rect::default(),
347 key_bindings,
348 view: MarkdownEditorView::new(),
349 revs: Revisions::new(),
350 selection: None,
351 nvim_host: NvimHost::new(),
352 search: None,
353 autocomplete: None,
354 autocomplete_vault: None,
355 autocomplete_redraw_bound: false,
356 full_parse_task: SingleSlotTask::empty(),
357 layout_task: SingleSlotTask::empty(),
358 last_insert_session: false,
359 wants_context_menu: false,
360 search_needles: Vec::new(),
361 full_parse_tx,
362 full_parse_rx,
363 layout_tx,
364 layout_rx,
365 redraw_tx: None,
366 }
367 }
368
369 pub fn set_vault(&mut self, vault: Arc<NoteVault>) {
374 self.autocomplete_vault = Some(vault.clone());
375 if self.backend.is_textarea() {
376 self.autocomplete = Some(AutocompleteController::new(
377 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
378 AutocompleteMode::Both,
379 ));
380 }
381 }
382
383 fn ensure_autocomplete_for_textarea(&mut self) {
388 if self.autocomplete.is_some() {
389 return;
390 }
391 if !self.backend.is_textarea() {
392 return;
393 }
394 let Some(vault) = self.autocomplete_vault.clone() else {
395 return;
396 };
397 self.autocomplete = Some(AutocompleteController::new(
398 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
399 AutocompleteMode::Both,
400 ));
401 self.autocomplete_redraw_bound = false;
404 }
405
406 #[allow(dead_code)]
413 fn autocomplete_host_snapshot(&self) -> Option<EditorHostSnapshot> {
414 build_editor_host_snapshot(
415 &self.backend,
416 self.revs.current(),
417 self.view.last_cursor_screen,
418 )
419 }
420
421 fn poll_autocomplete(&mut self) {
424 if let Some(controller) = self.autocomplete.as_mut() {
425 controller.poll_results();
426 }
427 }
428
429 fn textarea_cursor(&self) -> Option<(usize, usize)> {
433 let ta = self.backend.as_textarea()?;
434 Some(ta.cursor())
435 }
436
437 fn refresh_autocomplete_if_open(&mut self) {
438 if !self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
440 return;
441 }
442 let Some(snapshot) = build_editor_host_snapshot(
446 &self.backend,
447 self.revs.current(),
448 self.view.last_cursor_screen,
449 ) else {
450 self.close_autocomplete();
451 return;
452 };
453 if let Some(controller) = self.autocomplete.as_mut() {
454 controller.refresh_if_open(&snapshot);
455 }
456 }
457
458 fn sync_autocomplete(&mut self) {
462 let Some(controller) = self.autocomplete.as_ref() else {
463 return; };
465
466 if !controller.is_open() {
479 let Some(ta) = self.backend.as_textarea() else {
480 return;
481 };
482 let (row, col) = ta.cursor();
483 let line = ta.row(row).unwrap_or_default();
484 if !has_trigger_before_cursor(&line, col) {
485 return;
486 }
487 }
488
489 let Some(snapshot) = build_editor_host_snapshot(
493 &self.backend,
494 self.revs.current(),
495 self.view.last_cursor_screen,
496 ) else {
497 if let Some(c) = self.autocomplete.as_mut() {
498 c.close();
499 }
500 return;
501 };
502 if let Some(controller) = self.autocomplete.as_mut() {
503 controller.sync(&snapshot);
504 }
505 }
506
507 pub fn text(&self) -> crate::ropetext::Text {
514 match &self.backend {
515 BackendState::Textarea(tb) => tb.ta.text().clone(),
516 BackendState::Nvim(_) => crate::ropetext::Text::new(),
517 }
518 }
519
520 pub fn view_snapshot(&self) -> EditorSnapshot {
539 snapshot_from_backend(&self.backend, self.revs.current())
540 }
541
542 pub fn cursor_pos(&self) -> (usize, usize) {
546 self.backend.cursor()
547 }
548
549 pub fn set_search_needles(&mut self, needles: Vec<String>) {
553 self.search_needles = needles
554 .into_iter()
555 .map(|n| n.to_lowercase())
556 .filter(|n| !n.is_empty())
557 .collect();
558 self.revs.arm_needles();
559 }
560
561 pub fn set_text(&mut self, text: String) {
562 if text == self.get_text() {
569 self.revs.mark_saved_current();
570 if let Some(nvim) = self.backend.as_nvim() {
571 nvim.mark_clean();
572 }
573 return;
574 }
575 match &mut self.backend {
576 BackendState::Textarea(tb) => {
577 tb.ta.replace(crate::ropetext::Text::from(text.as_str()));
578 }
579 BackendState::Nvim(nvim) => {
580 nvim.set_text(&text);
581 }
582 }
583 self.backend.reset_input_state();
584 self.bump_content();
585 let reconstructed = self.get_text();
586 self.mark_saved(reconstructed);
587 self.close_autocomplete();
590 self.search = None;
599 self.selection = None;
600 self.view.note_bulk_edit();
605 }
606
607 pub fn get_text(&self) -> String {
608 self.backend.text()
609 }
610
611 pub fn content_revision(&self) -> NonZeroU64 {
618 self.revs.current()
619 }
620
621 pub fn mark_saved_at_revision(&mut self, rev: NonZeroU64) {
631 if !self.revs.mark_saved_at(rev) {
632 return;
633 }
634 self.interrupt_typing();
638 if let Some(nvim) = self.backend.as_nvim() {
639 nvim.mark_clean();
640 }
641 }
642
643 pub fn mark_saved(&mut self, text: String) {
651 self.interrupt_typing();
652 let matches = text == self.get_text();
653 if matches {
654 if let Some(nvim) = self.backend.as_nvim() {
655 nvim.mark_clean();
656 }
657 self.revs.mark_saved_current();
658 } else {
659 self.revs.mark_diverged();
664 }
665 }
666
667 fn interrupt_typing(&mut self) {
684 self.view.clear_visual_goal();
685 if self.backend.modal_is_insert().unwrap_or(false) {
686 return;
687 }
688 if let Some((_, run)) = self.backend.as_textarea_parts_mut() {
689 run.end();
690 }
691 }
692
693 fn sync_insert_session(&mut self) {
700 let in_insert = self.backend.modal_is_insert().unwrap_or(false);
701 if self.last_insert_session == in_insert {
702 return;
703 }
704 self.last_insert_session = in_insert;
705 if let Some((_, run)) = self.backend.as_textarea_parts_mut() {
706 run.end();
707 }
708 }
709
710 pub fn is_dirty(&self) -> bool {
711 match &self.backend {
712 BackendState::Textarea(_) => self.revs.is_dirty(),
713 BackendState::Nvim(nvim) => nvim.snapshot().dirty,
714 }
715 }
716
717 pub fn space_leads(&self) -> bool {
726 self.backend.space_leads()
727 }
728
729 pub fn mouse_drives_cursor(&self) -> bool {
737 self.backend.is_textarea()
738 }
739
740 pub fn covers(&self, column: u16, row: u16) -> bool {
750 self.rect
751 .contains(ratatui::layout::Position::new(column, row))
752 }
753
754 pub fn claim(&self) -> EditorClaim {
759 if self.search.is_some() {
760 EditorClaim::FindBar
761 } else if self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
762 EditorClaim::Autocomplete
763 } else {
764 EditorClaim::None
765 }
766 }
767
768 pub fn follow_target_at_cursor(&self) -> Option<FollowTarget> {
771 let (_row, col, line) = match &self.backend {
772 BackendState::Textarea(tb) => {
773 let (row, col) = tb.ta.cursor();
774 let line = tb.ta.row(row)?.into_owned();
775 (row, col, line)
776 }
777 BackendState::Nvim(nvim) => {
778 let snap = nvim.snapshot();
779 let (row, col) = snap.cursor;
780 let line = snap.lines.get(row)?.to_string();
781 (row, col, line)
782 }
783 };
784
785 if let Some(span) = kimun_core::note::scan::link_char_spans(&line)
788 .into_iter()
789 .find(|s| s.start <= col && col < s.end)
790 {
791 return Some(FollowTarget::Link(span.target));
792 }
793
794 let parsed = self::markdown::ParsedLine::parse(&line);
796 parsed
797 .elements
798 .iter()
799 .find(|e| {
800 e.kind == self::markdown::ElementKind::Label
801 && col >= e.start_char
802 && col < e.end_char
803 })
804 .map(|e| {
805 let span: String = line
806 .chars()
807 .skip(e.start_char)
808 .take(e.end_char - e.start_char)
809 .collect();
810 let name = span.trim_start_matches('#').to_string();
811 FollowTarget::Label(name)
812 })
813 }
814
815 fn copy_selection_to_clipboard(&mut self, tx: &AppTx) {
821 let text = {
822 let selected = self
831 .inclusive_visual_range()
832 .zip(self.backend.as_textarea())
833 .and_then(|(range, ta)| ta.text_between(range.0, range.1));
834 match selected {
835 Some(t) if !t.is_empty() => t,
836 _ => {
837 tx.send(AppEvent::FlashMessage("nothing to copy".into()))
838 .ok();
839 return;
840 }
841 }
842 };
843 crate::components::yank(text, "copied", tx);
844 }
845
846 fn inclusive_visual_range(&self) -> Option<((usize, usize), (usize, usize))> {
852 let charwise = self.backend.selection_includes_cursor();
853 let ta = self.backend.as_textarea()?;
854 let (start, (er, ec)) = ta.selection_range()?;
855 let end = if charwise {
856 let len = ta.row(er).map(|l| l.chars().count()).unwrap_or(ec);
857 (er, (ec + 1).min(len))
858 } else {
859 (er, ec)
860 };
861 Some((start, end))
862 }
863
864 fn paste_from_clipboard(&mut self, tx: &AppTx) {
868 let text = match crate::components::with_clipboard(|c| c.get_text()) {
869 Ok(t) if !t.is_empty() => t,
870 Ok(_) => {
871 tx.send(AppEvent::FlashMessage("clipboard is empty".into()))
872 .ok();
873 return;
874 }
875 Err(e) => {
876 tx.send(AppEvent::FlashMessage(format!("clipboard: {e}")))
877 .ok();
878 return;
879 }
880 };
881 self.paste_text(&text, tx);
882 tx.send(AppEvent::FlashMessage("pasted".into())).ok();
886 }
887
888 fn extend_visual_selection_inclusive(&mut self) {
905 if !self.backend.selection_includes_cursor() {
906 return;
907 }
908 if let Some((start, end)) = self.inclusive_visual_range()
909 && let Some(ta) = self.backend.as_textarea_mut()
910 {
911 ta.set_selection(start, end);
912 }
913 }
914
915 pub fn paste_text(&mut self, text: &str, tx: &AppTx) {
916 if text.is_empty() {
917 return;
918 }
919 if self.search.is_some() {
926 if let (Some(bar), BackendState::Textarea(tb)) =
927 (self.search.as_mut(), &mut self.backend)
928 {
929 bar.paste(text, &mut tb.ta);
930 }
931 self.apply_edit_outcome();
932 return;
933 }
934 self.extend_visual_selection_inclusive();
935 match &mut self.backend {
936 BackendState::Textarea(tb) => {
937 let selection = linkable_url(text).and_then(|_| tb.ta.selection_text());
938 let wrapped = try_build_markdown_link(text, selection.as_deref());
939 let insert = wrapped.as_deref().unwrap_or(text).to_string();
940 tb.ta.edit(|ta| {
943 if ta.selection_range().is_some() {
944 ta.cut();
945 }
946 ta.insert_str(insert);
947 });
948 self.after_edit();
949 }
950 BackendState::Nvim(nvim) => {
951 nvim.paste(text, tx.clone());
952 self.bump_content();
953 }
954 }
955 self.bind_autocomplete_redraw(tx);
959 self.sync_autocomplete();
960 }
961
962 pub fn insert_at_cursor(&mut self, text: &str, tx: &AppTx) {
967 if matches!(self.backend, BackendState::Nvim(_)) {
968 self.paste_text(text, tx);
969 return;
970 }
971 self.take_selection_for_external_paste();
978 if let Some(ta) = self.backend.as_textarea_mut() {
979 ta.insert_str(text);
980 self.after_edit();
981 }
982 self.bind_autocomplete_redraw(tx);
985 self.sync_autocomplete();
986 }
987
988 pub fn take_clipboard_image(&mut self) -> Option<ClipboardImage> {
998 let img = crate::components::with_clipboard(|c| c.get_image()).ok()?;
999 Some(ClipboardImage {
1000 width: img.width,
1001 height: img.height,
1002 rgba: img.bytes.into_owned(),
1003 })
1004 }
1005
1006 pub fn take_selection_for_external_paste(&mut self) {
1015 self.extend_visual_selection_inclusive();
1016 let cut = if let Some(ta) = self.backend.as_textarea_mut() {
1017 let cut = ta.selection_range().is_some() && ta.cut();
1018 self.selection = ta.selection_range();
1019 cut
1020 } else {
1021 false
1022 };
1023 if cut {
1024 self.apply_edit_outcome();
1025 }
1026 self.backend.sync_mouse_selection(false);
1029 }
1030
1031 fn wrap_selection(&mut self, open: &str, close: &str) -> bool {
1036 self.extend_visual_selection_inclusive();
1037 let Some(ta) = self.backend.as_textarea_mut() else {
1038 return false;
1039 };
1040 if !markdown_edits::wrap_selection(ta, open, close) {
1041 return false;
1042 }
1043 self.after_edit();
1047 true
1048 }
1049
1050 pub fn apply_text_action(&mut self, action: TextAction) {
1054 let Some(marker) = markdown_edits::emphasis_marker(action) else {
1055 return;
1056 };
1057 if self.wrap_selection(marker, marker) {
1058 return;
1059 }
1060 let Some(ta) = self.backend.as_textarea_mut() else {
1061 return;
1062 };
1063 markdown_edits::insert_pair(ta, marker, marker);
1064 self.after_edit();
1065 }
1066
1067 pub fn smart_enter(&mut self) -> bool {
1071 let Some(ta) = self.backend.as_textarea_mut() else {
1072 return false;
1073 };
1074 if !markdown_edits::smart_enter(ta) {
1075 return false;
1076 }
1077 self.after_edit();
1078 true
1079 }
1080
1081 pub fn jump_to_heading(&mut self, heading: &str) {
1084 let Some(ta) = self.backend.as_textarea_mut() else {
1085 return;
1086 };
1087 if markdown_edits::jump_to_heading(ta, heading) {
1088 self.selection = self
1089 .backend
1090 .as_textarea()
1091 .and_then(|ta| ta.selection_range());
1092 }
1093 }
1094
1095 pub fn indent_lines(&mut self, dedent: bool) {
1100 let Some(ta) = self.backend.as_textarea_mut() else {
1101 return;
1102 };
1103 let rows = match ta.selection_range() {
1104 Some(((sr, _), (er, ec))) => {
1105 let last = if ec == 0 && er > sr { er - 1 } else { er };
1106 sr..=last
1107 }
1108 None => {
1109 let (row, _) = ta.cursor();
1110 row..=row
1111 }
1112 };
1113 if ta.indent_rows(rows, dedent) {
1114 self.after_edit();
1115 }
1116 }
1117}
1118
1119impl TextEditorComponent {
1120 #[inline]
1130 fn bump_content(&mut self) {
1131 self.revs.bump();
1132 }
1133
1134 fn maybe_recover_from_dead_nvim(&mut self) {
1136 if self.backend.recover_from_dead_nvim() {
1137 self.ensure_autocomplete_for_textarea();
1141 }
1142 }
1143
1144 fn handle_nvim_key(
1149 &mut self,
1150 key: &ratatui::crossterm::event::KeyEvent,
1151 tx: &AppTx,
1152 ) -> Option<EventState> {
1153 let nvim = self.backend.as_nvim()?;
1157 self.nvim_host.handle_key(nvim, key, tx);
1162 Some(EventState::Consumed)
1163 }
1164
1165 pub fn open_or_advance_search(&mut self) {
1169 if !self.backend.is_textarea() {
1170 return;
1171 }
1172 if self.search.is_some() {
1173 self.dispatch_bar(|bar, buf| {
1174 bar.advance(buf, false);
1175 find_bar::KeyOutcome::default()
1176 });
1177 return;
1178 }
1179 self.close_autocomplete();
1182 self.search = Some(find_bar::FindBar::new());
1183 }
1184
1185 pub fn open_replace(&mut self) {
1188 if !self.backend.is_textarea() {
1189 return;
1190 }
1191 if self.search.is_none() {
1192 self.close_autocomplete();
1193 self.search = Some(find_bar::FindBar::new());
1194 }
1195 if let Some(bar) = self.search.as_mut() {
1196 bar.reveal_replace();
1197 }
1198 }
1199
1200 fn search_repeat(&mut self, backward: bool) {
1203 let BackendState::Textarea(tb) = &mut self.backend else {
1204 return;
1205 };
1206 self.selection = if tb.ta.search_repeat(backward) {
1209 tb.ta.match_at_cursor()
1210 } else {
1211 None
1212 };
1213 }
1214
1215 fn dispatch_bar(
1218 &mut self,
1219 f: impl FnOnce(&mut find_bar::FindBar, &mut RopeBuffer) -> find_bar::KeyOutcome,
1220 ) -> bool {
1221 let BackendState::Textarea(tb) = &mut self.backend else {
1222 return false;
1223 };
1224 let Some(bar) = self.search.as_mut() else {
1225 return false;
1226 };
1227 let outcome = f(bar, &mut tb.ta);
1228 if outcome.close {
1229 self.search = None;
1230 tb.ta.cancel_selection();
1236 self.selection = None;
1237 }
1238 self.apply_edit_outcome();
1239 true
1240 }
1241
1242 fn dispatch_to_find_bar(&mut self, key: &ratatui::crossterm::event::KeyEvent) -> bool {
1244 self.dispatch_bar(|bar, buf| bar.handle_key(key, buf))
1245 }
1246
1247 #[cfg(test)]
1250 fn replace_preview(&self) -> Option<find_replace::Preview> {
1251 let bar = self.search.as_ref()?;
1252 let buf = self.backend.as_textarea()?;
1253 bar.preview(buf)
1254 }
1255
1256 pub fn close_autocomplete(&mut self) {
1260 if let Some(c) = self.autocomplete.as_mut() {
1261 c.close();
1262 }
1263 }
1264
1265 pub fn set_redraw_tx(&mut self, tx: &AppTx) {
1270 self.bind_autocomplete_redraw(tx);
1271 }
1272
1273 fn bind_autocomplete_redraw(&mut self, tx: &AppTx) {
1282 if self.redraw_tx.is_none() {
1283 self.redraw_tx = Some(tx.clone());
1284 }
1285 if self.autocomplete_redraw_bound {
1286 return;
1287 }
1288 if let Some(c) = self.autocomplete.as_mut() {
1289 c.set_redraw_callback(redraw_callback(tx.clone()));
1290 self.autocomplete_redraw_bound = true;
1291 }
1292 }
1293
1294 fn after_edit(&mut self) -> bool {
1300 self.interrupt_typing();
1301 self.selection = self
1302 .backend
1303 .as_textarea()
1304 .and_then(|ta| ta.selection_range());
1305 self.apply_edit_outcome()
1306 }
1307
1308 fn apply_edit_outcome(&mut self) -> bool {
1319 let Some(outcome) = self.backend.as_textarea_mut().map(|ta| ta.take_outcome()) else {
1320 return false;
1321 };
1322 if outcome.changed {
1323 self.bump_content();
1324 }
1325 if outcome.bulk {
1326 self.view.note_bulk_edit();
1327 }
1328 if let Some(rows) = outcome.damage {
1329 self.view.note_damage(rows, outcome.line_delta);
1330 }
1331 outcome.changed
1332 }
1333
1334 fn undo_grouped(&mut self) -> bool {
1337 let moved = self.backend.as_textarea_mut().is_some_and(|ta| ta.undo());
1338 if moved {
1339 self.selection = self
1340 .backend
1341 .as_textarea()
1342 .and_then(|ta| ta.selection_range());
1343 }
1344 moved
1345 }
1346
1347 fn redo_grouped(&mut self) -> bool {
1349 let moved = self.backend.as_textarea_mut().is_some_and(|ta| ta.redo());
1350 if moved {
1351 self.selection = self
1352 .backend
1353 .as_textarea()
1354 .and_then(|ta| ta.selection_range());
1355 }
1356 moved
1357 }
1358
1359 fn handle_textarea_key(
1361 &mut self,
1362 key: &ratatui::crossterm::event::KeyEvent,
1363 tx: &AppTx,
1364 ) -> EventState {
1365 let stroke = plain_keys::operation(*key).and_then(|op| match op {
1375 plain_keys::Operation::Insert(c) => Some(typing_run::Stroke::Insert(c)),
1376 plain_keys::Operation::InsertNewline => Some(typing_run::Stroke::Insert('\n')),
1377 plain_keys::Operation::DeleteBack | plain_keys::Operation::DeleteForward => {
1378 Some(typing_run::Stroke::Delete)
1379 }
1380 _ => None,
1381 });
1382 if stroke.is_none()
1383 && let Some((_, run)) = self.backend.as_textarea_parts_mut()
1384 {
1385 run.end();
1386 }
1387
1388 if key.modifiers == KeyModifiers::CONTROL {
1390 match key.code {
1391 KeyCode::Char('c') => {
1392 self.copy_selection_to_clipboard(tx);
1393 return EventState::Consumed;
1394 }
1395 KeyCode::Char('v') => {
1396 self.paste_from_clipboard(tx);
1397 return EventState::Consumed;
1398 }
1399 KeyCode::Char('x') => {
1400 self.copy_selection_to_clipboard(tx);
1401 let cut = if let Some(ta) = self.backend.as_textarea_mut() {
1402 let cut = ta.cut();
1408 self.selection = ta.selection_range();
1409 cut
1410 } else {
1411 false
1412 };
1413 if cut {
1414 self.apply_edit_outcome();
1415 }
1416 return EventState::Consumed;
1417 }
1418 _ => {}
1419 }
1420 }
1421
1422 if key.modifiers & !KeyModifiers::SHIFT == KeyModifiers::CONTROL {
1427 match key.code {
1428 KeyCode::Char('z') if !key.modifiers.contains(KeyModifiers::SHIFT) => {
1429 if self.undo_grouped() {
1430 self.apply_edit_outcome();
1431 }
1432 return EventState::Consumed;
1433 }
1434 KeyCode::Char('y') | KeyCode::Char('Z') => {
1435 if self.redo_grouped() {
1436 self.apply_edit_outcome();
1437 }
1438 return EventState::Consumed;
1439 }
1440 _ => {}
1441 }
1442 }
1443
1444 match (key.modifiers, key.code) {
1456 (m, KeyCode::Tab)
1457 if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
1458 {
1459 self.indent_lines(m.contains(KeyModifiers::SHIFT));
1460 return EventState::Consumed;
1461 }
1462 (_, KeyCode::BackTab) => {
1463 self.indent_lines(true);
1464 return EventState::Consumed;
1465 }
1466 _ => {}
1467 }
1468 if key.code == KeyCode::Enter && key.modifiers.is_empty() && self.smart_enter() {
1469 return EventState::Consumed;
1470 }
1471
1472 if let KeyCode::Char(c) = key.code
1479 && (key.modifiers & !KeyModifiers::SHIFT).is_empty()
1480 && let Some((open, close)) = markdown_edits::surround_pair(c)
1481 && self.wrap_selection(open, close)
1482 {
1483 return EventState::Consumed;
1484 }
1485
1486 self.sync_insert_session();
1492
1493 let in_insert_session = self.last_insert_session;
1495 let Some((ta, run)) = self.backend.as_textarea_parts_mut() else {
1496 unreachable!("handle_textarea_key called with non-Textarea backend")
1497 };
1498 if let Some(op) = plain_keys::operation(*key) {
1503 let vertical = match op {
1507 plain_keys::Operation::Move {
1508 to: CursorMove::Up,
1509 extend,
1510 } => Some((false, extend)),
1511 plain_keys::Operation::Move {
1512 to: CursorMove::Down,
1513 extend,
1514 } => Some((true, extend)),
1515 _ => None,
1516 };
1517 if vertical.is_none() {
1518 self.view.clear_visual_goal();
1519 }
1520 if let Some(stroke) = stroke {
1526 {
1527 let now = std::time::Instant::now();
1528 let carries_on = if in_insert_session {
1533 run.continues_session(stroke, now)
1534 } else {
1535 run.continues(stroke, now)
1536 };
1537 if carries_on {
1538 ta.continue_group();
1539 }
1540 }
1541 }
1542
1543 let changed = match vertical {
1544 Some((down, extend)) if self.view.move_cursor_visually(ta, down, extend) => false,
1547 _ => plain_keys::apply(op, ta),
1548 };
1549 self.selection = ta.selection_range();
1550 if changed {
1551 self.apply_edit_outcome();
1552 }
1553 }
1554 EventState::Consumed
1558 }
1559
1560 fn handle_mouse(
1562 &mut self,
1563 mouse: &ratatui::crossterm::event::MouseEvent,
1564 tx: &AppTx,
1565 ) -> EventState {
1566 if !self.covers(mouse.column, mouse.row) {
1567 return EventState::NotConsumed;
1568 }
1569 let r = self.rect;
1572 self.interrupt_typing();
1576 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right))
1580 && self.selection.is_none_or(|(start, end)| start == end)
1581 {
1582 self.wants_context_menu = true;
1583 return EventState::Consumed;
1584 }
1585 if !self.backend.is_textarea() {
1589 return EventState::NotConsumed;
1590 }
1591 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
1593 self.copy_selection_to_clipboard(tx);
1594 self.selection = if let Some(ta) = self.backend.as_textarea() {
1595 ta.selection_range()
1596 } else {
1597 None
1598 };
1599 return EventState::Consumed;
1600 }
1601 let Some(ta) = self.backend.as_textarea_mut() else {
1603 unreachable!()
1604 };
1605 match mouse.kind {
1606 MouseEventKind::Down(_) => {
1607 self.view.follow_cursor();
1608 ta.cancel_selection();
1609 let (lrow, lcol) = self
1610 .view
1611 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1612 ta.jump_to(lrow as usize, lcol as usize);
1613 ta.start_selection();
1614 }
1615 MouseEventKind::Drag(_) => {
1616 let (lrow, lcol) = self
1617 .view
1618 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1619 ta.jump_to(lrow as usize, lcol as usize);
1620 }
1621 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
1624 let delta = if mouse.kind == MouseEventKind::ScrollUp {
1625 -WHEEL_SCROLL_ROWS
1626 } else {
1627 WHEEL_SCROLL_ROWS
1628 };
1629 if self.view.scroll_by(delta)
1632 && let Some(c) = self.autocomplete.as_mut()
1633 {
1634 c.close();
1635 }
1636 }
1637 _ => {}
1640 }
1641 self.selection = ta.selection_range();
1642 EventState::Consumed
1645 }
1646}
1647
1648impl Component for TextEditorComponent {
1653 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
1654 self.maybe_recover_from_dead_nvim();
1655 self.bind_autocomplete_redraw(tx);
1656
1657 match event {
1658 InputEvent::Key(key) => {
1659 self.view.follow_cursor();
1663 let popup_open = self.autocomplete.as_ref().is_some_and(|c| c.is_open());
1671 if popup_open
1672 && let Some(host) = build_editor_host_snapshot(
1673 &self.backend,
1674 self.revs.current(),
1675 self.view.last_cursor_screen,
1676 )
1677 && let Some(controller) = self.autocomplete.as_mut()
1678 {
1679 match controller.handle_key(*key, &host) {
1680 HandleKeyOutcome::Accepted(action) => {
1681 self.interrupt_typing();
1682 if let Some(ta) = self.backend.as_textarea_mut() {
1683 ta.edit(|ta| apply_accept_to_textarea(ta, &action));
1684 }
1685 self.after_edit();
1686 return EventState::Consumed;
1687 }
1688 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
1689 return EventState::Consumed;
1690 }
1691 HandleKeyOutcome::NotHandled => {}
1692 }
1693 }
1694 if self.dispatch_to_find_bar(key) {
1699 self.interrupt_typing();
1703 return EventState::Consumed;
1704 }
1705 if let Some(outcome) = self.backend.vim_handle_key(key) {
1710 use self::vim::VimKeyOutcome;
1711 if !matches!(outcome, VimKeyOutcome::PassThrough) {
1716 self.interrupt_typing();
1717 }
1718 self.apply_edit_outcome();
1722 match outcome {
1723 VimKeyOutcome::TextMutated => {
1724 self.selection = None;
1727 return EventState::Consumed;
1728 }
1729 VimKeyOutcome::CursorOnly => {
1730 self.selection = self
1735 .backend
1736 .as_textarea()
1737 .and_then(|ta| ta.selection_range());
1738 if self.backend.selection_includes_cursor()
1741 && let Some(((sr, sc), (er, ec))) = self.selection
1742 {
1743 let len = self
1744 .backend
1745 .as_textarea()
1746 .and_then(|ta| ta.row(er))
1747 .map(|l| l.chars().count())
1748 .unwrap_or(ec);
1749 self.selection = Some(((sr, sc), (er, (ec + 1).min(len))));
1750 }
1751 if self.backend.is_visual_line()
1759 && let Some(((sr, _), (er, _))) = self.selection
1760 {
1761 self.selection = Some(((sr, 0), (er, usize::MAX)));
1762 }
1763 self.refresh_autocomplete_if_open();
1764 return EventState::Consumed;
1765 }
1766 VimKeyOutcome::NoOp => return EventState::Consumed,
1767 VimKeyOutcome::PassThrough => { }
1768 VimKeyOutcome::Host(action) => {
1769 use self::vim::VimHostAction;
1770 match action {
1771 VimHostAction::OpenPalette => {
1772 tx.send(AppEvent::ExecuteLeaderAction(
1774 crate::keys::leader::LeaderAction::Palette,
1775 ))
1776 .ok();
1777 }
1778 VimHostAction::OpenSearch { forward: _ } => {
1779 self.open_or_advance_search();
1783 }
1784 VimHostAction::SearchNext => self.search_repeat(false),
1785 VimHostAction::SearchPrev => self.search_repeat(true),
1786 VimHostAction::ClipboardCopy(text) => {
1790 self.selection = None;
1791 crate::components::yank(text, "copied", tx);
1792 }
1793 VimHostAction::ClipboardCut(text) => {
1794 self.selection = None;
1795 crate::components::yank(text, "cut", tx);
1796 }
1797 VimHostAction::ClipboardPaste => {
1803 self.selection = self
1804 .backend
1805 .as_textarea()
1806 .and_then(|ta| ta.selection_range());
1807 self.paste_from_clipboard(tx);
1808 }
1809 }
1810 return EventState::Consumed;
1811 }
1812 }
1813 }
1814 if let Some(state) = self.handle_nvim_key(key, tx) {
1815 return state;
1816 }
1817 let text_rev_before = self.revs.current();
1828 let cursor_before = self.textarea_cursor();
1829 let result = self.handle_textarea_key(key, tx);
1830 let cursor_after = self.textarea_cursor();
1831 if self.revs.current() != text_rev_before {
1832 self.sync_autocomplete();
1833 } else if cursor_before != cursor_after {
1834 self.refresh_autocomplete_if_open();
1835 }
1836 result
1837 }
1838 InputEvent::Mouse(mouse) => {
1839 let text_rev_before = self.revs.current();
1840 let cursor_before = self.textarea_cursor();
1841 let result = self.handle_mouse(mouse, tx);
1842 let cursor_after = self.textarea_cursor();
1843 if self.revs.current() != text_rev_before {
1846 self.sync_autocomplete();
1847 } else if cursor_before != cursor_after {
1848 self.refresh_autocomplete_if_open();
1849 }
1850 let has_sel = self
1868 .backend
1869 .as_textarea()
1870 .and_then(|ta| ta.selection_range())
1871 .is_some_and(|(s, e)| s != e);
1872 self.backend.sync_mouse_selection(has_sel);
1873 result
1874 }
1875 InputEvent::Paste(_) => EventState::NotConsumed,
1878 }
1879 }
1880
1881 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
1882 let bar_rows: u16 = self.search.as_ref().map_or(0, |bar| bar.rows());
1887 let bar_rows = bar_rows.min(rect.height);
1892 let (editor_rect, search_rect) = if bar_rows > 0 {
1893 (
1894 Rect {
1895 height: rect.height - bar_rows,
1896 ..rect
1897 },
1898 Some(Rect {
1899 y: rect.y + rect.height - bar_rows,
1900 height: bar_rows,
1901 ..rect
1902 }),
1903 )
1904 } else {
1905 (rect, None)
1906 };
1907 self.rect = editor_rect;
1910 let selection = match &self.backend {
1915 BackendState::Textarea(_) => match self.search.as_ref() {
1918 Some(bar) => bar.current_match(),
1919 None => self.selection,
1920 },
1921 BackendState::Nvim(nvim) => {
1922 self.nvim_host
1923 .frame_sync(nvim, editor_rect.width, editor_rect.height)
1924 }
1925 };
1926 while let Ok((generation, buf)) = self.full_parse_rx.try_recv() {
1932 self.view.install_full_parse(generation, buf);
1933 }
1934 while let Ok((generation, layout)) = self.layout_rx.try_recv() {
1938 self.view.install_full_layout(generation, layout);
1939 }
1940
1941 let overlay = match (self.search.as_ref(), self.backend.as_textarea()) {
1952 (Some(bar), Some(buf)) => bar.overlay(buf),
1953 _ => find_bar::BarOverlay::default(),
1954 };
1955 let preview = overlay.preview;
1956 let snap = snapshot_from_backend(&self.backend, self.revs.current());
1957 self.revs.adopt(snap.content_revision);
1963 let (view_lines, preview_spans) = match preview {
1967 None => (None, Vec::new()),
1968 Some(p) => (Some(p.lines), p.spans),
1969 };
1970 match &view_lines {
1971 None => self.view.update(&snap, editor_rect),
1972 Some(lines) => {
1973 let rev = preview_revision(snap.content_revision, lines);
1978 let view_snap = EditorSnapshot::borrowed(lines, snap.cursor, rev);
1979 self.view.update(&view_snap, editor_rect);
1980 }
1981 }
1982 if self.revs.needles_stale() {
1986 self.search_needles.clear();
1987 self.revs.disarm_needles();
1988 }
1989 self.view.set_needles(self.search_needles.clone());
1990
1991 let mut overlays: Vec<view::Overlay> = Vec::new();
1994 if let Some(((sr, sc), (er, ec))) = selection {
1995 for row in sr..=er {
1998 let start = if row == sr { sc } else { 0 };
1999 let end = if row == er { ec } else { usize::MAX };
2000 overlays.push(view::Overlay::new(
2001 row,
2002 start,
2003 end,
2004 view::OverlayKind::Selection,
2005 ));
2006 }
2007 }
2008 overlays.extend(preview_spans.iter().map(|p| {
2009 view::Overlay::new(
2010 p.row,
2011 p.start,
2012 p.end,
2013 if p.is_current {
2014 view::OverlayKind::PreviewCurrent
2015 } else {
2016 view::OverlayKind::Preview
2017 },
2018 )
2019 }));
2020 if view_lines.is_none() {
2023 overlays.extend(overlay.matches.iter().map(|&(row, start, end)| {
2024 view::Overlay::new(row, start, end, view::OverlayKind::Match)
2025 }));
2026 }
2027 self.view.set_overlays(overlays);
2028
2029 if let Some(generation) = self.view.take_pending_full_parse() {
2037 let text = match &view_lines {
2049 Some(lines) => crate::ropetext::Text::from(lines.join("\n").as_str()),
2050 None => snap.text.clone(),
2051 };
2052 let tx = self.full_parse_tx.clone();
2053 let redraw = self.redraw_tx.clone();
2054 self.full_parse_task.spawn(async move {
2055 let buf = ParsedBuffer::parse(&text);
2056 let _ = tx.send((generation, buf));
2057 if let Some(redraw) = redraw {
2060 let _ = redraw.send(AppEvent::Redraw);
2061 }
2062 });
2063 }
2064 if let Some(job) = self.view.take_pending_full_layout() {
2069 let tx = self.layout_tx.clone();
2070 let redraw = self.redraw_tx.clone();
2071 self.layout_task.spawn(async move {
2072 let hints = view::row_hints(&job.rendered_cache, &job.gutter_insets);
2073 let layout = crate::ropetext::Layout::compute(
2074 &job.text,
2075 job.width,
2076 crate::ropetext::Metrics::default(),
2077 &hints,
2078 );
2079 let _ = tx.send((job.generation, layout));
2080 if let Some(redraw) = redraw {
2081 let _ = redraw.send(AppEvent::Redraw);
2082 }
2083 });
2084 }
2085 let bar_focused = self.search.is_some() && focused;
2088 let editor_focused = focused && !bar_focused;
2089 use self::view::CursorShape;
2090 let cursor_shape = match self.backend.modal_is_insert() {
2091 None => None, Some(true) => Some(CursorShape::Bar),
2093 Some(false) => Some(CursorShape::Block),
2094 };
2095 self.view
2096 .render(f, editor_rect, theme, editor_focused, cursor_shape);
2097
2098 if self.revs.needles_stale() {
2102 self.search_needles.clear();
2103 self.revs.disarm_needles();
2104 }
2105
2106 if snap.text.len_bytes() == 0 && editor_rect.height > 0 {
2110 let leader = self
2111 .key_bindings
2112 .first_combo_for(&crate::keys::action_shortcuts::ActionShortcuts::Leader)
2113 .unwrap_or_else(|| "leader".to_string());
2114 f.render_widget(
2115 ratatui::widgets::Paragraph::new(format!(
2116 "Type to start · [[ to link · # to tag · {leader} for commands"
2117 ))
2118 .style(
2119 Style::default()
2120 .fg(theme.gray.to_ratatui())
2121 .add_modifier(Modifier::ITALIC),
2122 ),
2123 Rect {
2124 x: editor_rect.x.saturating_add(2),
2125 width: editor_rect.width.saturating_sub(2),
2126 height: 1,
2127 ..editor_rect
2128 },
2129 );
2130 }
2131 if let (Some(state), Some(bar_rect)) = (self.search.as_mut(), search_rect) {
2132 state.render(f, bar_rect, theme, bar_focused);
2133 }
2134
2135 self.poll_autocomplete();
2143 if let (Some(controller), Some(live_anchor)) =
2150 (self.autocomplete.as_mut(), self.view.last_cursor_screen)
2151 {
2152 if let Some(state) = controller.state_mut() {
2153 state.anchor = live_anchor;
2154 }
2155 if let Some(state) = controller.state() {
2156 autocomplete::render(f, state, editor_rect, theme);
2157 }
2158 }
2159 }
2160
2161 fn hint_shortcuts(&self) -> Vec<(String, String)> {
2162 use crate::keys::action_shortcuts::ActionShortcuts;
2163
2164 if let Some(mut label) = self.backend.mode_label() {
2169 if let Some(p) = self.backend.pending_input_hint() {
2170 label = format!("{label} {p}");
2171 }
2172 let mut hints = vec![(String::new(), label)];
2173 hints.extend(
2174 [
2175 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2176 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2177 (ActionShortcuts::FileOperations, "file ops"),
2178 ]
2179 .iter()
2180 .filter_map(|(action, label)| {
2181 self.key_bindings
2182 .first_combo_for(action)
2183 .map(|k| (k, label.to_string()))
2184 }),
2185 );
2186 return hints;
2187 }
2188
2189 let mut hints: Vec<(String, String)> = Vec::new();
2192 match self.follow_target_at_cursor() {
2193 Some(FollowTarget::Link(_)) => {
2194 if let Some(k) = self
2195 .key_bindings
2196 .first_combo_for(&ActionShortcuts::FollowLink)
2197 {
2198 hints.push((k, "follow link".to_string()));
2199 }
2200 }
2201 Some(FollowTarget::Label(_)) => {
2202 if let Some(k) = self
2203 .key_bindings
2204 .first_combo_for(&ActionShortcuts::FollowLink)
2205 {
2206 hints.push((k, "browse tag".to_string()));
2207 }
2208 }
2209 None => {}
2210 }
2211 hints.extend(crate::components::hints::hints_for(
2212 &self.key_bindings,
2213 &[
2214 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2215 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2216 (ActionShortcuts::FileOperations, "file ops"),
2217 (ActionShortcuts::FindInBuffer, "find"),
2218 ],
2219 ));
2220 hints
2221 }
2222}
2223
2224#[cfg(test)]
2225mod tests {
2226 use super::snapshot::EditorMode;
2227 use super::*;
2228 use crate::keys::KeyBindings;
2229
2230 fn make_editor() -> TextEditorComponent {
2231 TextEditorComponent::new(
2232 KeyBindings::empty(),
2233 &crate::settings::AppSettings::default(),
2234 )
2235 }
2236
2237 fn dummy_tx() -> AppTx {
2238 tokio::sync::mpsc::unbounded_channel().0
2239 }
2240
2241 fn get_ta(editor: &mut TextEditorComponent) -> &mut RopeBuffer {
2242 match &mut editor.backend {
2243 BackendState::Textarea(tb) => &mut tb.ta,
2244 _ => panic!("expected Textarea backend"),
2245 }
2246 }
2247
2248 #[test]
2249 fn has_trigger_before_cursor_finds_bracket() {
2250 assert!(has_trigger_before_cursor("hello [[foo", 11));
2251 assert!(has_trigger_before_cursor("[[a b c", 7));
2252 }
2253
2254 #[test]
2255 fn has_trigger_before_cursor_finds_hashtag() {
2256 assert!(has_trigger_before_cursor("text #tag", 9));
2257 }
2258
2259 #[test]
2260 fn has_trigger_before_cursor_no_trigger_bails() {
2261 assert!(!has_trigger_before_cursor("plain prose here", 16));
2262 assert!(!has_trigger_before_cursor("", 0));
2263 }
2264
2265 #[test]
2266 fn has_trigger_before_cursor_handles_multibyte_no_panic() {
2267 let line = "你好世界".to_string() + &"a".repeat(80);
2270 let col = line.chars().count();
2271 assert!(!has_trigger_before_cursor(&line, col));
2272
2273 let with_emoji = "🦀".repeat(20) + "[[note";
2274 let col = with_emoji.chars().count();
2275 assert!(has_trigger_before_cursor(&with_emoji, col));
2276
2277 let accented = "é".repeat(100);
2278 let col = accented.chars().count();
2279 assert!(!has_trigger_before_cursor(&accented, col));
2280 }
2281
2282 #[test]
2283 fn has_trigger_before_cursor_ignores_chars_after_cursor() {
2284 assert!(!has_trigger_before_cursor("foo [[bar", 3));
2286 }
2287
2288 #[test]
2289 fn has_trigger_before_cursor_wikilink_with_spaces() {
2290 assert!(has_trigger_before_cursor("[[my note title", 15));
2293 }
2294
2295 #[test]
2296 fn fresh_editor_is_not_dirty() {
2297 let editor = make_editor();
2298 assert!(!editor.is_dirty());
2299 }
2300
2301 #[test]
2302 fn after_set_text_not_dirty() {
2303 let mut editor = make_editor();
2304 editor.set_text("hello world".to_string());
2305 assert!(!editor.is_dirty());
2306 }
2307
2308 #[test]
2309 fn get_text_returns_loaded_content() {
2310 let mut editor = make_editor();
2311 editor.set_text("line one\nline two".to_string());
2312 assert_eq!(editor.get_text(), "line one\nline two");
2313 }
2314
2315 #[test]
2316 fn mark_saved_clears_dirty() {
2317 let mut editor = make_editor();
2318 editor.set_text("initial".to_string());
2319 let text = editor.get_text();
2320 editor.mark_saved(text.clone() + "x"); assert!(editor.is_dirty());
2322 editor.mark_saved(text); assert!(!editor.is_dirty());
2324 }
2325
2326 #[test]
2327 fn trailing_newline_does_not_cause_false_dirty() {
2328 let mut editor = make_editor();
2329 editor.set_text("content\n".to_string());
2330 assert!(
2331 !editor.is_dirty(),
2332 "trailing newline should not make editor dirty after load"
2333 );
2334 }
2335
2336 #[test]
2337 fn cursor_move_does_not_dirty_buffer() {
2338 let mut editor = make_editor();
2339 editor.set_text("hello world".to_string());
2340 assert!(!editor.is_dirty());
2341 let tx = dummy_tx();
2342 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
2345 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2346 assert!(
2347 !editor.is_dirty(),
2348 "cursor move must not mark the editor as dirty"
2349 );
2350 }
2351
2352 #[test]
2353 fn empty_stack_undo_redo_does_not_dirty_or_bump_revision() {
2354 let mut editor = make_editor();
2358 editor.set_text("foo".to_string());
2359 let rev_before = editor.content_revision();
2360 assert!(!editor.is_dirty());
2361 let tx = dummy_tx();
2362 for key_code in [KeyCode::Char('z'), KeyCode::Char('y')] {
2363 let key = ratatui::crossterm::event::KeyEvent::new(key_code, KeyModifiers::CONTROL);
2364 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2365 }
2366 assert!(
2367 !editor.is_dirty(),
2368 "empty-stack undo/redo must not flip is_dirty"
2369 );
2370 assert_eq!(
2371 editor.content_revision(),
2372 rev_before,
2373 "empty-stack undo/redo must not bump content_revision"
2374 );
2375 }
2376
2377 #[test]
2378 fn fresh_editor_content_revision_is_nonzero() {
2379 let editor = make_editor();
2386 assert!(editor.content_revision().get() >= 1);
2387 }
2388
2389 #[test]
2390 fn mouse_down_clears_selection() {
2391 let mut editor = make_editor();
2392 editor.set_text("hello world".to_string());
2393 let ta = get_ta(&mut editor);
2394 ta.start_selection();
2395 ta.move_cursor(CursorMove::WordForward);
2396 assert!(ta.selection_range().is_some());
2397 ta.cancel_selection();
2398 editor.selection = if let BackendState::Textarea(tb) = &editor.backend {
2399 tb.ta.selection_range()
2400 } else {
2401 None
2402 };
2403 assert!(editor.selection.is_none());
2404 }
2405
2406 #[test]
2407 fn ctrl_c_copies_selected_text() {
2408 let mut editor = make_editor();
2409 editor.set_text("hello world".to_string());
2410 let ta = get_ta(&mut editor);
2411 ta.move_cursor(CursorMove::Head);
2412 ta.start_selection();
2413 ta.move_cursor(CursorMove::WordForward);
2414 let range = ta.selection_range().unwrap();
2415 let ((sr, sc), (er, ec)) = range;
2416 let lines = ta.rows();
2417 let selected = if sr == er {
2418 lines[sr][sc..ec].to_string()
2419 } else {
2420 lines[sr][sc..].to_string()
2421 };
2422 assert_eq!(selected, "hello ");
2423 }
2424
2425 fn select_range(editor: &mut TextEditorComponent, start: (usize, usize), end: (usize, usize)) {
2427 let ta = get_ta(editor);
2428 ta.cancel_selection();
2429 ta.move_cursor(CursorMove::Jump(start.0, start.1));
2430 ta.start_selection();
2431 ta.move_cursor(CursorMove::Jump(end.0, end.1));
2432 assert!(ta.selection_range().is_some());
2433 }
2434
2435 fn send_char(editor: &mut TextEditorComponent, c: char) {
2436 let tx = dummy_tx();
2437 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
2438 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2439 }
2440
2441 #[test]
2442 fn typing_open_paren_with_selection_wraps_it() {
2443 let mut editor = make_editor();
2444 editor.set_text("hello world".to_string());
2445 select_range(&mut editor, (0, 0), (0, 5)); send_char(&mut editor, '(');
2447 assert_eq!(editor.get_text(), "(hello) world");
2448 assert!(editor.is_dirty(), "wrap must mark the buffer dirty");
2449 }
2450
2451 #[test]
2452 fn wrap_keeps_selection_on_inner_text() {
2453 let mut editor = make_editor();
2454 editor.set_text("hello world".to_string());
2455 select_range(&mut editor, (0, 0), (0, 5));
2456 send_char(&mut editor, '(');
2457 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2459 }
2460
2461 #[test]
2462 fn chained_brackets_build_a_wikilink() {
2463 let mut editor = make_editor();
2464 editor.set_text("my note".to_string());
2465 select_range(&mut editor, (0, 0), (0, 7));
2466 send_char(&mut editor, '[');
2467 send_char(&mut editor, '[');
2468 assert_eq!(editor.get_text(), "[[my note]]");
2469 assert_eq!(editor.selection, Some(((0, 2), (0, 9))));
2470 }
2471
2472 #[test]
2473 fn symmetric_chars_wrap_and_chain() {
2474 let mut editor = make_editor();
2475 editor.set_text("bold".to_string());
2476 select_range(&mut editor, (0, 0), (0, 4));
2477 send_char(&mut editor, '*');
2478 assert_eq!(editor.get_text(), "*bold*");
2479 send_char(&mut editor, '*');
2480 assert_eq!(editor.get_text(), "**bold**");
2481 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2482 }
2483
2484 #[test]
2485 fn closing_char_replaces_selection() {
2486 let mut editor = make_editor();
2487 editor.set_text("hello world".to_string());
2488 select_range(&mut editor, (0, 0), (0, 5));
2489 send_char(&mut editor, ')');
2490 assert_eq!(editor.get_text(), ") world");
2491 }
2492
2493 #[test]
2494 fn open_char_without_selection_inserts_normally() {
2495 let mut editor = make_editor();
2496 editor.set_text("hello".to_string());
2497 let ta = get_ta(&mut editor);
2498 ta.move_cursor(CursorMove::End);
2499 send_char(&mut editor, '(');
2500 assert_eq!(editor.get_text(), "hello(");
2501 }
2502
2503 #[test]
2504 fn text_action_keeps_selection_on_inner_text() {
2505 let mut editor = make_editor();
2508 editor.set_text("bold word".to_string());
2509 select_range(&mut editor, (0, 0), (0, 4));
2510 editor.apply_text_action(TextAction::Bold);
2511 assert_eq!(editor.get_text(), "**bold** word");
2512 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2513 }
2514
2515 #[test]
2516 fn bold_undo_is_one_step_back_to_original() {
2517 let mut editor = make_editor();
2522 editor.set_text("hello world".to_string());
2523 select_range(&mut editor, (0, 0), (0, 5));
2524 editor.apply_text_action(TextAction::Bold);
2525 assert_eq!(editor.get_text(), "**hello** world");
2526 assert!(get_ta(&mut editor).undo(), "the bold is one entry");
2527 assert_eq!(editor.get_text(), "hello world");
2528 assert!(
2529 !get_ta(&mut editor).undo(),
2530 "and has no second half left to take back"
2531 );
2532 }
2533
2534 #[test]
2535 fn linkable_url_accepts_supported_schemes() {
2536 assert_eq!(
2537 linkable_url("https://example.com"),
2538 Some("https://example.com")
2539 );
2540 assert_eq!(
2541 linkable_url("http://example.com/path?q=1#frag"),
2542 Some("http://example.com/path?q=1#frag"),
2543 );
2544 assert_eq!(
2545 linkable_url(" https://example.com "),
2546 Some("https://example.com")
2547 );
2548 assert_eq!(
2549 linkable_url("ftp://files.example.com/x"),
2550 Some("ftp://files.example.com/x"),
2551 );
2552 assert_eq!(
2553 linkable_url("ftps://files.example.com/x"),
2554 Some("ftps://files.example.com/x"),
2555 );
2556 assert_eq!(
2557 linkable_url("mailto:user@example.com"),
2558 Some("mailto:user@example.com"),
2559 );
2560 assert_eq!(
2561 linkable_url("mailto:user@example.com?subject=hi"),
2562 Some("mailto:user@example.com?subject=hi"),
2563 );
2564 }
2565
2566 #[test]
2567 fn linkable_url_rejects_other_schemes_and_plain_text() {
2568 assert_eq!(linkable_url("file:///etc/passwd"), None);
2569 assert_eq!(linkable_url("ssh://host"), None);
2570 assert_eq!(linkable_url("javascript:alert(1)"), None);
2571 assert_eq!(linkable_url("example.com"), None);
2572 assert_eq!(linkable_url("not a url"), None);
2573 assert_eq!(linkable_url(""), None);
2574 assert_eq!(linkable_url("https://example.com\nmore"), None);
2575 }
2576
2577 #[test]
2578 fn try_build_markdown_link_wraps_selection_when_clip_is_url() {
2579 assert_eq!(
2580 try_build_markdown_link("https://example.com", Some("click here")).as_deref(),
2581 Some("[click here](https://example.com)"),
2582 );
2583 }
2584
2585 #[test]
2586 fn try_build_markdown_link_trims_url_whitespace() {
2587 assert_eq!(
2588 try_build_markdown_link(" https://example.com\n", Some("link")).as_deref(),
2589 Some("[link](https://example.com)"),
2590 );
2591 }
2592
2593 #[test]
2594 fn try_build_markdown_link_returns_none_when_no_selection() {
2595 assert_eq!(try_build_markdown_link("https://example.com", None), None);
2596 }
2597
2598 #[test]
2599 fn try_build_markdown_link_returns_none_when_not_url() {
2600 assert_eq!(try_build_markdown_link("plain text", Some("sel")), None);
2601 }
2602
2603 #[test]
2604 fn try_build_markdown_link_returns_none_when_selection_empty() {
2605 assert_eq!(
2606 try_build_markdown_link("https://example.com", Some("")),
2607 None
2608 );
2609 }
2610
2611 #[test]
2612 fn try_build_markdown_link_escapes_close_bracket_in_selection() {
2613 assert_eq!(
2614 try_build_markdown_link("https://example.com", Some("a]b")).as_deref(),
2615 Some(r"[a\]b](https://example.com)"),
2616 );
2617 }
2618
2619 #[test]
2620 fn try_build_markdown_link_wraps_ftp_url() {
2621 assert_eq!(
2622 try_build_markdown_link("ftp://files.example.com/x", Some("download")).as_deref(),
2623 Some("[download](ftp://files.example.com/x)"),
2624 );
2625 }
2626
2627 fn key(code: KeyCode, mods: KeyModifiers) -> ratatui::crossterm::event::KeyEvent {
2628 ratatui::crossterm::event::KeyEvent::new(code, mods)
2629 }
2630
2631 #[test]
2633 fn search_needles_clear_on_edit() {
2634 let settings = crate::settings::AppSettings::default();
2635 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2636 ed.set_text("alpha beta".to_string());
2637 ed.set_search_needles(vec!["Alpha".to_string()]);
2638 assert_eq!(ed.search_needles, vec!["alpha"]);
2639 assert!(!ed.revs.needles_stale());
2640
2641 ed.set_text("alpha beta gamma".to_string());
2643 assert!(ed.revs.needles_stale());
2644 }
2645
2646 #[test]
2647 fn open_or_advance_search_opens_find_bar_with_empty_query() {
2648 let mut editor = make_editor();
2649 editor.set_text("hello world".to_string());
2650 editor.open_or_advance_search();
2651 let state = editor.search.as_ref().expect("find bar opened");
2652 assert!(state.input.is_empty());
2653 assert!(matches!(state.status, SearchStatus::Empty));
2654 }
2655
2656 #[test]
2657 fn open_or_advance_search_advances_when_already_open() {
2658 let mut editor = make_editor();
2659 editor.set_text("ab ab ab".to_string());
2660 let tx = dummy_tx();
2661 editor.open_or_advance_search();
2662 editor.handle_input(
2663 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
2664 &tx,
2665 );
2666 editor.handle_input(
2667 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
2668 &tx,
2669 );
2670 editor.open_or_advance_search();
2672 let (_, col) = get_ta(&mut editor).cursor();
2673 assert_eq!(col, 3, "second invocation advances to next match");
2674 }
2675
2676 #[test]
2677 fn typing_in_find_bar_jumps_cursor_to_first_match() {
2678 let mut editor = make_editor();
2679 editor.set_text("foo bar baz".to_string());
2680 let tx = dummy_tx();
2681 editor.open_or_advance_search();
2682 for ch in ['b', 'a', 'r'] {
2683 editor.handle_input(
2684 &InputEvent::Key(key(KeyCode::Char(ch), KeyModifiers::NONE)),
2685 &tx,
2686 );
2687 }
2688 let state = editor.search.as_ref().unwrap();
2689 assert_eq!(state.input.value(), "bar");
2690 assert!(matches!(state.status, SearchStatus::Match));
2691 let (_, col) = get_ta(&mut editor).cursor();
2692 assert_eq!(col, 4, "cursor jumped to start of 'bar'");
2693 }
2694
2695 #[test]
2696 fn enter_in_find_bar_advances_to_next_match() {
2697 let mut editor = make_editor();
2698 editor.set_text("ab ab ab".to_string());
2699 let tx = dummy_tx();
2700 editor.open_or_advance_search();
2701 editor.handle_input(
2702 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
2703 &tx,
2704 );
2705 editor.handle_input(
2706 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
2707 &tx,
2708 );
2709 editor.handle_input(
2711 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
2712 &tx,
2713 );
2714 let (_, col) = get_ta(&mut editor).cursor();
2715 assert_eq!(col, 3, "Enter advances to second match");
2716 }
2717
2718 #[test]
2719 fn match_is_highlighted_as_selection_after_search() {
2720 let mut editor = make_editor();
2721 editor.set_text("foo bar baz".to_string());
2722 let tx = dummy_tx();
2723 editor.open_or_advance_search();
2724 for ch in ['b', 'a', 'r'] {
2725 editor.handle_input(
2726 &InputEvent::Key(key(KeyCode::Char(ch), KeyModifiers::NONE)),
2727 &tx,
2728 );
2729 }
2730 assert_eq!(
2733 editor.search.as_ref().unwrap().current_match(),
2734 Some(((0, 4), (0, 7)))
2735 );
2736 }
2737
2738 #[test]
2739 fn no_match_clears_selection() {
2740 let mut editor = make_editor();
2741 editor.set_text("hello".to_string());
2742 let tx = dummy_tx();
2743 editor.open_or_advance_search();
2744 editor.handle_input(
2745 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::NONE)),
2746 &tx,
2747 );
2748 assert_eq!(editor.selection, None);
2749 }
2750
2751 #[test]
2752 fn esc_in_find_bar_clears_selection_highlight() {
2753 let mut editor = make_editor();
2754 editor.set_text("foo bar".to_string());
2755 let tx = dummy_tx();
2756 editor.open_or_advance_search();
2757 editor.handle_input(
2758 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
2759 &tx,
2760 );
2761 editor.handle_input(
2762 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
2763 &tx,
2764 );
2765 editor.handle_input(
2766 &InputEvent::Key(key(KeyCode::Char('r'), KeyModifiers::NONE)),
2767 &tx,
2768 );
2769 assert!(
2770 editor
2771 .search
2772 .as_ref()
2773 .is_some_and(|b| b.current_match().is_some())
2774 );
2775 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
2776 assert!(editor.search.is_none());
2778 assert!(editor.selection.is_none());
2779 }
2780
2781 #[test]
2782 fn esc_in_find_bar_closes_it() {
2783 let mut editor = make_editor();
2784 editor.set_text("hello".to_string());
2785 let tx = dummy_tx();
2786 editor.open_or_advance_search();
2787 assert!(editor.search.is_some());
2788 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
2789 assert!(editor.search.is_none());
2790 }
2791
2792 #[test]
2793 fn find_bar_consumes_typing_so_editor_text_is_unchanged() {
2794 let mut editor = make_editor();
2795 editor.set_text("hello".to_string());
2796 let tx = dummy_tx();
2797 editor.open_or_advance_search();
2798 editor.handle_input(
2799 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
2800 &tx,
2801 );
2802 assert_eq!(editor.get_text(), "hello");
2803 }
2804
2805 #[test]
2806 fn no_match_status_when_query_absent() {
2807 let mut editor = make_editor();
2808 editor.set_text("hello".to_string());
2809 let tx = dummy_tx();
2810 editor.open_or_advance_search();
2811 editor.handle_input(
2812 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::NONE)),
2813 &tx,
2814 );
2815 let state = editor.search.as_ref().unwrap();
2816 assert!(matches!(state.status, SearchStatus::NoMatch));
2817 }
2818
2819 #[test]
2820 fn try_build_markdown_link_wraps_mailto_url() {
2821 assert_eq!(
2822 try_build_markdown_link("mailto:user@example.com", Some("email me")).as_deref(),
2823 Some("[email me](mailto:user@example.com)"),
2824 );
2825 }
2826
2827 #[test]
2828 fn insert_at_cursor_appends_text() {
2829 let mut editor = make_editor();
2830 editor.set_text("hello".to_string());
2831 {
2832 let ta = get_ta(&mut editor);
2833 ta.move_cursor(CursorMove::End);
2834 }
2835 editor.insert_at_cursor(" world", &dummy_tx());
2836 assert_eq!(editor.get_text(), "hello world");
2837 }
2838
2839 #[test]
2840 fn insert_at_cursor_replaces_selection() {
2841 let mut editor = make_editor();
2842 editor.set_text("hello world".to_string());
2843 {
2844 let ta = get_ta(&mut editor);
2845 ta.move_cursor(CursorMove::Head);
2846 ta.start_selection();
2847 ta.move_cursor(CursorMove::WordForward);
2848 }
2849 editor.insert_at_cursor("HEY ", &dummy_tx());
2850 assert_eq!(editor.get_text(), "HEY world");
2851 }
2852
2853 #[test]
2854 fn paste_inserts_text_at_cursor() {
2855 let mut editor = make_editor();
2856 editor.set_text("hello".to_string());
2857 let ta = get_ta(&mut editor);
2858 ta.move_cursor(CursorMove::End);
2859 ta.insert_str(" world");
2860 assert_eq!(editor.get_text(), "hello world");
2861 }
2862
2863 #[test]
2864 fn a_selection_ending_at_column_zero_leaves_that_row_alone() {
2865 let mut editor = make_editor();
2866 editor.set_text("foo\nbar\nbaz".to_string());
2867 select_range(&mut editor, (0, 0), (2, 0));
2868 editor.indent_lines(false);
2869 assert_eq!(get_ta(&mut editor).rows(), &[" foo", " bar", "baz"]);
2870 assert_eq!(editor.selection, Some(((0, 4), (2, 0))));
2871 }
2872
2873 #[test]
2874 fn unsupported_text_action_is_noop() {
2875 let mut editor = make_editor();
2876 editor.set_text("hello".to_string());
2877 editor.apply_text_action(TextAction::Underline);
2878 assert_eq!(editor.get_text(), "hello");
2879 }
2880
2881 #[test]
2882 fn bold_action_with_no_selection_inserts_a_pair_through_the_component() {
2883 let mut editor = make_editor();
2886 editor.set_text("hello".to_string());
2887 {
2888 let ta = get_ta(&mut editor);
2889 ta.move_cursor(CursorMove::End);
2890 }
2891 editor.apply_text_action(TextAction::Bold);
2892 assert_eq!(editor.get_text(), "hello****");
2893 assert_eq!(get_ta(&mut editor).cursor(), (0, 7));
2894 assert!(editor.is_dirty(), "the insert is an edit");
2895 }
2896
2897 #[test]
2898 fn jump_to_heading_moves_the_view_cursor() {
2899 let mut ed = make_editor();
2901 ed.set_text("intro\n# Top\nbody\n## Sub One\nmore\n".to_string());
2902 ed.jump_to_heading("Sub One");
2903 assert_eq!(ed.view_snapshot().cursor.0, 3);
2904 ed.jump_to_heading("Top");
2905 assert_eq!(ed.view_snapshot().cursor.0, 1);
2906 select_range(&mut ed, (2, 1), (2, 1));
2909 ed.jump_to_heading("Top");
2910 assert_eq!(ed.selection, None);
2911 assert_eq!(ed.view_snapshot().cursor.0, 1);
2912 ed.jump_to_heading("Nope");
2913 assert_eq!(
2914 ed.view_snapshot().cursor.0,
2915 1,
2916 "an unknown heading leaves the cursor"
2917 );
2918 }
2919
2920 #[test]
2921 fn tab_indents_the_cursor_row_and_backtab_dedents_it() {
2922 let mut editor = make_editor();
2923 editor.set_text("foo".to_string());
2924 let tx = dummy_tx();
2925 let tab = key(KeyCode::Tab, KeyModifiers::NONE);
2926 let _ = editor.handle_input(&InputEvent::Key(tab), &tx);
2927 assert_eq!(editor.get_text(), " foo");
2928 assert!(editor.is_dirty(), "an indent is an edit");
2929 let back = key(KeyCode::BackTab, KeyModifiers::SHIFT);
2930 let _ = editor.handle_input(&InputEvent::Key(back), &tx);
2931 assert_eq!(editor.get_text(), "foo");
2932 }
2933
2934 #[test]
2935 fn enter_at_the_end_of_a_list_item_continues_it() {
2936 let mut editor = make_editor();
2937 editor.set_text("- foo".to_string());
2938 {
2939 let ta = get_ta(&mut editor);
2940 ta.move_cursor(CursorMove::End);
2941 }
2942 let tx = dummy_tx();
2943 let enter = key(KeyCode::Enter, KeyModifiers::NONE);
2944 let _ = editor.handle_input(&InputEvent::Key(enter), &tx);
2945 assert_eq!(editor.get_text(), "- foo\n- ");
2946 assert!(editor.is_dirty());
2947 }
2948
2949 #[test]
2950 fn a_letter_typed_after_smart_enter_starts_its_own_undo_group() {
2951 let mut editor = make_editor();
2952 editor.set_text(String::new());
2953 for c in "- foo".chars() {
2954 send_char(&mut editor, c);
2955 }
2956 let tx = dummy_tx();
2957 let enter = key(KeyCode::Enter, KeyModifiers::NONE);
2958 let _ = editor.handle_input(&InputEvent::Key(enter), &tx);
2959 send_char(&mut editor, 'b');
2960 assert_eq!(editor.get_text(), "- foo\n- b");
2961 assert!(get_ta(&mut editor).undo());
2962 assert_eq!(
2963 editor.get_text(),
2964 "- foo\n- ",
2965 "the letter after a list continuation is its own undo group"
2966 );
2967 }
2968
2969 #[test]
2970 fn textarea_hint_shortcuts_has_no_mode_indicator() {
2971 let editor = make_editor();
2972 let hints = editor.hint_shortcuts();
2973 assert!(
2975 !hints
2976 .iter()
2977 .any(|(_, label)| label == "NORMAL" || label == "INSERT")
2978 );
2979 }
2980
2981 fn place_cursor_at_col(editor: &mut TextEditorComponent, col: usize) {
2985 let ta = get_ta(editor);
2986 ta.move_cursor(CursorMove::Head);
2987 for _ in 0..col {
2988 ta.move_cursor(CursorMove::Forward);
2989 }
2990 }
2991
2992 #[test]
2993 fn follow_target_at_cursor_returns_label_when_cursor_on_hashtag() {
2994 let mut editor = make_editor();
2995 editor.set_text("see #rust now".to_string());
2996 place_cursor_at_col(&mut editor, 5);
2998 assert_eq!(
2999 editor.follow_target_at_cursor(),
3000 Some(FollowTarget::Label("rust".into())),
3001 );
3002 }
3003
3004 #[test]
3005 fn follow_target_at_cursor_returns_label_at_hash_char() {
3006 let mut editor = make_editor();
3007 editor.set_text("see #rust now".to_string());
3008 place_cursor_at_col(&mut editor, 4);
3010 assert_eq!(
3011 editor.follow_target_at_cursor(),
3012 Some(FollowTarget::Label("rust".into())),
3013 );
3014 }
3015
3016 #[test]
3017 fn follow_target_at_cursor_returns_none_outside_hashtag() {
3018 let mut editor = make_editor();
3019 editor.set_text("see #rust now".to_string());
3020 place_cursor_at_col(&mut editor, 0);
3022 assert_eq!(editor.follow_target_at_cursor(), None);
3023 }
3024
3025 #[test]
3026 fn follow_target_at_cursor_returns_link_for_wikilink() {
3027 let mut editor = make_editor();
3028 editor.set_text("open [[my note]] please".to_string());
3029 place_cursor_at_col(&mut editor, 7);
3031 let result = editor.follow_target_at_cursor();
3032 assert!(
3033 matches!(result, Some(FollowTarget::Link(_))),
3034 "expected Link variant, got {result:?}"
3035 );
3036 }
3037
3038 #[test]
3041 fn follow_target_at_cursor_returns_link_for_markdown_link_with_fragment() {
3042 let line = "[see docs](#section)";
3047 let mut editor = make_editor();
3048 editor.set_text(line.to_string());
3049 let cursor = "[see docs](#sec".chars().count(); place_cursor_at_col(&mut editor, cursor);
3052 let result = editor.follow_target_at_cursor();
3053 assert!(
3054 matches!(result, Some(FollowTarget::Link(_))),
3055 "expected Link variant for markdown link fragment, got {result:?}"
3056 );
3057 }
3058
3059 #[test]
3060 fn vim_normal_i_then_typing_inserts_text() {
3061 let mut settings = crate::settings::AppSettings::default();
3062 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
3063 let mut editor = TextEditorComponent::new(KeyBindings::empty(), &settings);
3064 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3065 editor.handle_input(
3067 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3068 &tx,
3069 );
3070 assert_eq!(editor.get_text(), "");
3071 editor.handle_input(
3073 &InputEvent::Key(key(KeyCode::Char('i'), KeyModifiers::NONE)),
3074 &tx,
3075 );
3076 editor.handle_input(
3077 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3078 &tx,
3079 );
3080 assert_eq!(editor.get_text(), "x");
3081 }
3082
3083 fn open_replace_bar(
3088 editor: &mut TextEditorComponent,
3089 tx: &AppTx,
3090 pattern: &str,
3091 replacement: &str,
3092 ) {
3093 editor.open_or_advance_search();
3094 for c in pattern.chars() {
3095 editor.handle_input(
3096 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3097 tx,
3098 );
3099 }
3100 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), tx);
3101 for c in replacement.chars() {
3102 editor.handle_input(
3103 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3104 tx,
3105 );
3106 }
3107 }
3108
3109 #[test]
3110 fn tab_reveals_the_replace_field_and_then_cycles_focus() {
3111 let mut editor = make_editor();
3112 let tx = dummy_tx();
3113 editor.set_text("todo".to_string());
3114 editor.open_or_advance_search();
3115 assert!(
3116 !editor.search.as_ref().unwrap().is_replacing(),
3117 "a find-only bar must not start with a replace field"
3118 );
3119
3120 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3121 let s = editor.search.as_ref().unwrap();
3122 assert!(s.is_replacing(), "Tab must reveal the replace field");
3123 assert_eq!(s.focus, BarFocus::Find);
3126
3127 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3128 assert_eq!(editor.search.as_ref().unwrap().focus, BarFocus::Replace);
3129 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3130 assert_eq!(editor.search.as_ref().unwrap().focus, BarFocus::Find);
3131 }
3132
3133 #[test]
3134 fn typing_in_the_replace_field_does_not_touch_the_buffer() {
3135 let mut editor = make_editor();
3136 let tx = dummy_tx();
3137 editor.set_text("todo and todo".to_string());
3138 open_replace_bar(&mut editor, &tx, "todo", "done");
3139 assert_eq!(
3140 editor.get_text(),
3141 "todo and todo",
3142 "the preview is a view of the note, never a write to it"
3143 );
3144 }
3145
3146 #[test]
3147 fn enter_replaces_the_current_match_and_advances() {
3148 let mut editor = make_editor();
3149 let tx = dummy_tx();
3150 editor.set_text("todo and todo".to_string());
3151 open_replace_bar(&mut editor, &tx, "todo", "done");
3152 editor.handle_input(
3153 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3154 &tx,
3155 );
3156 assert_eq!(editor.get_text(), "done and todo");
3157 }
3158
3159 #[test]
3160 fn replacing_a_match_that_ends_inside_a_cluster_is_refused_not_corrupted() {
3161 let mut editor = make_editor();
3167 let tx = dummy_tx();
3168 editor.set_text("e\u{301}f".to_string());
3169 open_replace_bar(&mut editor, &tx, "e", "x");
3170 editor.handle_input(
3171 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3172 &tx,
3173 );
3174 assert_eq!(
3175 editor.get_text(),
3176 "e\u{301}f",
3177 "the note is left alone rather than half-rewritten"
3178 );
3179 }
3180
3181 #[test]
3182 fn ctrl_a_replaces_every_match() {
3183 let mut editor = make_editor();
3184 let tx = dummy_tx();
3185 editor.set_text("todo and todo\nmore todo".to_string());
3186 open_replace_bar(&mut editor, &tx, "todo", "done");
3187 editor.handle_input(
3188 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3189 &tx,
3190 );
3191 assert_eq!(editor.get_text(), "done and done\nmore done");
3192 }
3193
3194 #[test]
3195 fn replace_all_keeps_the_reading_position() {
3196 let mut editor = make_editor();
3197 let tx = dummy_tx();
3198 editor.set_text("todo\nxx\ntodo\nyy".to_string());
3199 open_replace_bar(&mut editor, &tx, "todo", "done");
3200 if let Some(ta) = editor.backend.as_textarea_mut() {
3204 ta.move_cursor(CursorMove::Jump(3, 1));
3205 }
3206 editor.handle_input(
3207 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3208 &tx,
3209 );
3210 assert_eq!(editor.get_text(), "done\nxx\ndone\nyy");
3211 let (row, _) = editor.cursor_pos();
3212 assert_eq!(
3213 row, 3,
3214 "replace all must not throw the cursor to the end of the note"
3215 );
3216 }
3217
3218 #[test]
3219 fn an_empty_replacement_arms_before_it_deletes() {
3220 let mut editor = make_editor();
3221 let tx = dummy_tx();
3222 editor.set_text("todo and todo".to_string());
3223 open_replace_bar(&mut editor, &tx, "todo ", "");
3224
3225 let ctrl_a = InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
3226 editor.handle_input(&ctrl_a, &tx);
3227 assert_eq!(
3228 editor.get_text(),
3229 "todo and todo",
3230 "the first Ctrl+A on an empty replacement must arm, not delete"
3231 );
3232 assert!(editor.search.as_ref().unwrap().armed_empty);
3233
3234 editor.handle_input(&ctrl_a, &tx);
3235 assert_eq!(editor.get_text(), "and todo");
3236 }
3237
3238 #[test]
3239 fn esc_disarms_an_empty_replace_all_without_closing_the_bar() {
3240 let mut editor = make_editor();
3241 let tx = dummy_tx();
3242 editor.set_text("todo".to_string());
3243 open_replace_bar(&mut editor, &tx, "todo", "");
3244 editor.handle_input(
3245 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3246 &tx,
3247 );
3248 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3249 let s = editor
3250 .search
3251 .as_ref()
3252 .expect("Esc disarms before it closes");
3253 assert!(!s.armed_empty);
3254 assert_eq!(editor.get_text(), "todo");
3255 }
3256
3257 #[test]
3258 fn one_ctrl_z_undoes_a_whole_replace_all() {
3259 let mut editor = make_editor();
3260 let tx = dummy_tx();
3261 editor.set_text("todo and todo".to_string());
3262 open_replace_bar(&mut editor, &tx, "todo", "done");
3263 editor.handle_input(
3264 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3265 &tx,
3266 );
3267 assert_eq!(editor.get_text(), "done and done");
3268
3269 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3271 editor.handle_input(
3272 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3273 &tx,
3274 );
3275 assert_eq!(
3276 editor.get_text(),
3277 "todo and todo",
3278 "a replace is two history entries and must cost ONE undo — \
3279 popping half leaves the note with a hole in it"
3280 );
3281 }
3282
3283 #[test]
3284 fn one_ctrl_z_undoes_a_single_replace_step() {
3285 let mut editor = make_editor();
3286 let tx = dummy_tx();
3287 editor.set_text("todo and todo".to_string());
3288 open_replace_bar(&mut editor, &tx, "todo", "done");
3289 editor.handle_input(
3290 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3291 &tx,
3292 );
3293 assert_eq!(editor.get_text(), "done and todo");
3294 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3295 editor.handle_input(
3296 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3297 &tx,
3298 );
3299 assert_eq!(editor.get_text(), "todo and todo");
3300 }
3301
3302 #[test]
3303 fn redo_regroups_the_replace() {
3304 let mut editor = make_editor();
3305 let tx = dummy_tx();
3306 editor.set_text("todo".to_string());
3307 open_replace_bar(&mut editor, &tx, "todo", "done");
3308 editor.handle_input(
3309 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3310 &tx,
3311 );
3312 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3313 editor.handle_input(
3314 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3315 &tx,
3316 );
3317 assert_eq!(editor.get_text(), "todo");
3318 editor.handle_input(
3319 &InputEvent::Key(key(KeyCode::Char('y'), KeyModifiers::CONTROL)),
3320 &tx,
3321 );
3322 assert_eq!(
3323 editor.get_text(),
3324 "done",
3325 "one redo must restore the whole replace"
3326 );
3327 }
3328
3329 #[test]
3330 fn smartcase_drives_both_the_count_and_the_replace() {
3331 let mut editor = make_editor();
3332 let tx = dummy_tx();
3333 editor.set_text("todo Todo TODO".to_string());
3334 open_replace_bar(&mut editor, &tx, "todo", "x");
3335 assert_eq!(
3336 editor.search.as_ref().unwrap().match_count,
3337 3,
3338 "an all-lowercase pattern matches any case"
3339 );
3340 editor.handle_input(
3341 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3342 &tx,
3343 );
3344 assert_eq!(editor.get_text(), "x x x");
3345 }
3346
3347 #[test]
3348 fn an_uppercase_pattern_is_case_sensitive() {
3349 let mut editor = make_editor();
3350 let tx = dummy_tx();
3351 editor.set_text("todo Todo TODO".to_string());
3352 open_replace_bar(&mut editor, &tx, "Todo", "x");
3353 assert_eq!(editor.search.as_ref().unwrap().match_count, 1);
3354 editor.handle_input(
3355 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3356 &tx,
3357 );
3358 assert_eq!(editor.get_text(), "todo x TODO");
3359 }
3360
3361 #[test]
3362 fn the_preview_substitutes_lines_without_writing_them() {
3363 let mut editor = make_editor();
3364 let tx = dummy_tx();
3365 editor.set_text("todo and todo".to_string());
3366 open_replace_bar(&mut editor, &tx, "todo", "done");
3367 let preview = editor.replace_preview().expect("a preview must be built");
3368 assert_eq!(preview.lines, vec!["done and done".to_string()]);
3369 assert_eq!(preview.spans.len(), 2);
3370 assert!(
3371 preview.spans.iter().any(|s| s.is_current),
3372 "the match under the cursor must be flagged so Enter's target is visible"
3373 );
3374 assert_eq!(
3375 editor.get_text(),
3376 "todo and todo",
3377 "building a preview must never mutate the buffer"
3378 );
3379 }
3380
3381 #[test]
3386 fn a_deletion_preview_still_marks_the_current_match() {
3387 let mut editor = make_editor();
3388 let tx = dummy_tx();
3389 editor.set_text("todo and todo".to_string());
3390 open_replace_bar(&mut editor, &tx, "todo", "");
3391 let preview = editor.replace_preview().expect("a preview must be built");
3392 assert_eq!(preview.lines, vec![" and ".to_string()]);
3393 let current = preview
3394 .spans
3395 .iter()
3396 .find(|s| s.is_current)
3397 .expect("the current match must stay flagged when it previews as nothing");
3398 assert_eq!(
3399 current.start, current.end,
3400 "an empty replacement previews as a zero-width span — the renderer \
3401 widens it to a caret cell so the marker cannot vanish"
3402 );
3403 }
3404
3405 #[test]
3410 fn a_multi_row_selection_cannot_derail_an_interactive_replace() {
3411 let mut editor = make_editor();
3412 let tx = dummy_tx();
3413 editor.set_text("alpha beta\nxy".to_string());
3414 open_replace_bar(&mut editor, &tx, "beta", "Z");
3415 editor.selection = Some(((0, 6), (1, 1)));
3417 editor.handle_input(
3418 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3419 &tx,
3420 );
3421 assert_eq!(editor.get_text(), "alpha Z\nxy");
3422 }
3423
3424 #[test]
3429 fn deleting_a_match_marks_the_note_dirty() {
3430 let mut editor = make_editor();
3431 let tx = dummy_tx();
3432 editor.set_text("todo and todo".to_string());
3433 editor.mark_saved("todo and todo".to_string());
3434 assert!(!editor.is_dirty());
3435
3436 open_replace_bar(&mut editor, &tx, "todo ", "");
3437 editor.handle_input(
3438 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3439 &tx,
3440 );
3441 assert_eq!(editor.get_text(), "and todo");
3442 assert!(
3443 editor.is_dirty(),
3444 "a deletion is an edit — if the revision does not move, autosave \
3445 never writes it and the change is silently lost"
3446 );
3447 }
3448
3449 #[test]
3451 fn emptying_the_note_via_replace_all_marks_it_dirty_and_is_undoable() {
3452 let mut editor = make_editor();
3453 let tx = dummy_tx();
3454 editor.set_text("todo".to_string());
3455 editor.mark_saved("todo".to_string());
3456 open_replace_bar(&mut editor, &tx, "todo", "");
3457 let ctrl_a = InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
3458 editor.handle_input(&ctrl_a, &tx); editor.handle_input(&ctrl_a, &tx); assert_eq!(editor.get_text(), "");
3461 assert!(editor.is_dirty());
3462
3463 editor.handle_input(
3464 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3465 &tx,
3466 );
3467 assert_eq!(editor.get_text(), "todo");
3468 }
3469
3470 #[test]
3474 fn ctrl_z_works_without_closing_the_bar_first() {
3475 let mut editor = make_editor();
3476 let tx = dummy_tx();
3477 editor.set_text("todo and todo".to_string());
3478 open_replace_bar(&mut editor, &tx, "todo", "done");
3479 editor.handle_input(
3480 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3481 &tx,
3482 );
3483 assert_eq!(editor.get_text(), "done and done");
3484 editor.handle_input(
3485 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3486 &tx,
3487 );
3488 assert_eq!(editor.get_text(), "todo and todo");
3489 assert!(editor.search.is_some(), "undo must not close the bar");
3490 }
3491
3492 #[test]
3496 fn a_zero_width_match_does_not_over_claim_history_entries() {
3497 let mut editor = make_editor();
3498 let tx = dummy_tx();
3499 editor.set_text("ab".to_string());
3500 open_replace_bar(&mut editor, &tx, r"\b", "|");
3501 editor.handle_input(
3502 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3503 &tx,
3504 );
3505 assert_eq!(editor.get_text(), "|ab");
3506 editor.handle_input(
3507 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3508 &tx,
3509 );
3510 assert_eq!(
3511 editor.get_text(),
3512 "ab",
3513 "one undo must land exactly on the pre-replace text, not past it"
3514 );
3515 }
3516
3517 #[test]
3521 fn a_note_swap_resets_the_find_bar_and_its_undo_groups() {
3522 let mut editor = make_editor();
3523 let tx = dummy_tx();
3524 editor.set_text("todo".to_string());
3525 open_replace_bar(&mut editor, &tx, "todo", "");
3526 editor.handle_input(
3527 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3528 &tx,
3529 );
3530 assert!(editor.search.as_ref().unwrap().armed_empty);
3531
3532 editor.set_text("todo elsewhere".to_string());
3533 assert!(editor.search.is_none(), "the bar belonged to the old note");
3534 assert!(
3537 !editor.backend.as_textarea_mut().unwrap().undo(),
3538 "the new note's history has nothing to undo"
3539 );
3540 }
3541
3542 #[test]
3548 fn concealed_markdown_still_highlights_what_it_counts() {
3549 let mut editor = make_editor();
3550 let tx = dummy_tx();
3551 editor.set_text("# Heading\n[[note]]".to_string());
3552 editor.open_or_advance_search();
3553 for c in r"\[\[".chars() {
3554 editor.handle_input(
3555 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3556 &tx,
3557 );
3558 }
3559 let state = editor.search.as_ref().unwrap();
3560 assert_eq!(state.match_count, 1, "the `[[` sigil is a real match");
3561 let spans = state
3562 .pattern
3563 .as_ref()
3564 .unwrap()
3565 .match_spans(editor.backend.as_textarea().unwrap().text().lines());
3566 assert_eq!(
3567 spans,
3568 vec![(1, 0, 2)],
3569 "and it must be reported as a paintable span, not silently dropped \
3570 because the rendered row conceals it"
3571 );
3572 }
3573
3574 #[test]
3579 fn paste_goes_into_the_focused_bar_field() {
3580 let mut editor = make_editor();
3581 let tx = dummy_tx();
3582 editor.set_text("todo".to_string());
3583 open_replace_bar(&mut editor, &tx, "todo", "");
3584 editor.paste_text("done", &tx);
3585 assert_eq!(editor.get_text(), "todo", "the buffer is untouched");
3586 assert_eq!(editor.search.as_ref().unwrap().replacement(), "done");
3587 }
3588
3589 #[test]
3590 fn a_multiline_paste_collapses_to_its_first_line() {
3591 let mut editor = make_editor();
3592 let tx = dummy_tx();
3593 editor.set_text("x".to_string());
3594 editor.open_or_advance_search();
3595 editor.paste_text("first\nsecond", &tx);
3596 assert_eq!(editor.search.as_ref().unwrap().input.value(), "first");
3597 }
3598
3599 #[test]
3603 fn the_bar_is_never_an_invisible_modal() {
3604 use ratatui::Terminal;
3605 use ratatui::backend::TestBackend;
3606 let mut editor = make_editor();
3607 editor.set_text("todo".to_string());
3608 let theme = Theme::default();
3609 let mut term = Terminal::new(TestBackend::new(40, 1)).unwrap();
3610 let area = Rect::new(0, 0, 40, 1);
3611 editor.open_or_advance_search();
3612 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
3613 let row: String = (0..40)
3614 .filter_map(|x| {
3615 term.backend()
3616 .buffer()
3617 .cell(ratatui::layout::Position::new(x, 0))
3618 .map(|c| c.symbol().to_string())
3619 })
3620 .collect();
3621 assert!(
3622 row.contains("Find:"),
3623 "an open bar must be drawn even when it costs the whole pane, got {row:?}"
3624 );
3625 }
3626
3627 #[test]
3639 fn a_row_far_from_the_cursor_reparses_after_replace_all() {
3640 use ratatui::Terminal;
3641 use ratatui::backend::TestBackend;
3642 let mut editor = make_editor();
3643 let tx = dummy_tx();
3644 let mut lines: Vec<String> = (0..400).map(|i| format!("filler {i}")).collect();
3645 lines[0] = "todo".to_string();
3646 lines[398] = "todo".to_string();
3647 editor.set_text(lines.join("\n"));
3648 let theme = Theme::default();
3649 let mut term = Terminal::new(TestBackend::new(20, 8)).unwrap();
3650 let area = Rect::new(0, 0, 20, 8);
3651 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
3652
3653 open_replace_bar(&mut editor, &tx, "todo", "[[x]]");
3654 if let Some(ta) = editor.backend.as_textarea_mut() {
3657 ta.move_cursor(CursorMove::Jump(398, 0));
3658 }
3659 editor.handle_input(
3660 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3661 &tx,
3662 );
3663 if let Some(ta) = editor.backend.as_textarea_mut() {
3666 ta.move_cursor(CursorMove::Jump(1, 0));
3667 }
3668 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
3669 let row0: String = (0..20)
3670 .filter_map(|x| {
3671 term.backend()
3672 .buffer()
3673 .cell(ratatui::layout::Position::new(x, 0))
3674 .map(|c| c.symbol().to_string())
3675 })
3676 .collect::<String>()
3677 .trim_end()
3678 .to_string();
3679 assert_eq!(
3680 row0, "x",
3681 "row 0 must render as a parsed wikilink; `[[x]]` would mean it \
3682 kept the parse of the text that was there before the replace"
3683 );
3684 }
3685
3686 #[test]
3690 fn indenting_a_block_undoes_in_one_step() {
3691 let mut editor = make_editor();
3692 let tx = dummy_tx();
3693 editor.set_text("a\nb\nc".to_string());
3694 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
3695 get_ta(&mut editor).start_selection();
3696 get_ta(&mut editor).move_cursor(CursorMove::Jump(2, 1));
3697 editor.indent_lines(false);
3698 assert_eq!(editor.get_text(), " a\n b\n c");
3699
3700 editor.handle_input(
3701 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3702 &tx,
3703 );
3704 assert_eq!(
3705 editor.get_text(),
3706 "a\nb\nc",
3707 "one undo must revert the whole block, not just the last line"
3708 );
3709 }
3710
3711 #[test]
3713 fn pasting_over_a_selection_undoes_in_one_step() {
3714 let mut editor = make_editor();
3715 let tx = dummy_tx();
3716 editor.set_text("hello world".to_string());
3717 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
3718 get_ta(&mut editor).start_selection();
3719 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 5));
3720 editor.paste_text("goodbye", &tx);
3721 assert_eq!(editor.get_text(), "goodbye world");
3722
3723 editor.handle_input(
3724 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3725 &tx,
3726 );
3727 assert_eq!(editor.get_text(), "hello world");
3728 }
3729
3730 #[test]
3734 fn closing_the_bar_clears_a_stale_selection() {
3735 let mut editor = make_editor();
3736 let tx = dummy_tx();
3737 editor.set_text("alpha beta".to_string());
3738 editor.selection = Some(((0, 0), (0, 5)));
3739 editor.open_or_advance_search();
3740 for c in "beta".chars() {
3741 editor.handle_input(
3742 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3743 &tx,
3744 );
3745 }
3746 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3747 assert!(editor.search.is_none());
3748 assert_eq!(
3749 editor.selection, None,
3750 "a selection from before the search must not outlive the bar"
3751 );
3752 }
3753
3754 #[test]
3757 fn undo_inside_the_bar_rederives_the_current_match() {
3758 let mut editor = make_editor();
3759 let tx = dummy_tx();
3760 editor.set_text("foo foo".to_string());
3761 open_replace_bar(&mut editor, &tx, "foo", "xy");
3762 editor.handle_input(
3763 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3764 &tx,
3765 );
3766 assert_eq!(editor.get_text(), "xy foo");
3767 editor.handle_input(
3768 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3769 &tx,
3770 );
3771 assert_eq!(editor.get_text(), "foo foo");
3772 let current = editor.search.as_ref().unwrap().current_match();
3773 if let Some(((row, start), (_, end))) = current {
3774 let line = &editor.get_text()[..];
3775 let text: String = line
3776 .lines()
3777 .nth(row)
3778 .unwrap()
3779 .chars()
3780 .skip(start)
3781 .take(end - start)
3782 .collect();
3783 assert_eq!(
3784 text, "foo",
3785 "the highlight must sit on a real match, got {text:?}"
3786 );
3787 }
3788 }
3789
3790 #[test]
3794 fn vim_n_highlights_the_match_it_lands_on() {
3795 let mut editor = make_vim_editor();
3796 let tx = dummy_tx();
3797 editor.set_text("lo xx lo".to_string());
3798 editor.open_or_advance_search();
3799 for c in "lo".chars() {
3800 editor.handle_input(
3801 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3802 &tx,
3803 );
3804 }
3805 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3806 editor.handle_input(
3807 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
3808 &tx,
3809 );
3810 assert_eq!(
3811 editor.selection,
3812 Some(((0, 6), (0, 8))),
3813 "`n` must paint the match it jumped to"
3814 );
3815 }
3816
3817 #[test]
3822 fn closing_the_bar_cannot_leave_an_invisible_selection() {
3823 let mut editor = make_editor();
3824 let tx = dummy_tx();
3825 editor.set_text("foo bar baz".to_string());
3826 get_ta(&mut editor).select_all();
3828 editor.open_or_advance_search();
3829 for c in "bar".chars() {
3830 editor.handle_input(
3831 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3832 &tx,
3833 );
3834 }
3835 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3836 editor.handle_input(
3837 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3838 &tx,
3839 );
3840 assert!(
3841 editor.get_text().contains("foo"),
3842 "typing after the bar closed must not eat unhighlighted text, got {:?}",
3843 editor.get_text()
3844 );
3845 }
3846
3847 #[test]
3850 fn vim_visual_indent_undoes_in_one_step() {
3851 let mut editor = make_vim_editor();
3852 let tx = dummy_tx();
3853 editor.set_text("a\nb\nc".to_string());
3854 for c in ['V', 'j', 'j', '>'] {
3855 editor.handle_input(
3856 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3857 &tx,
3858 );
3859 }
3860 let indented = editor.get_text();
3861 assert_ne!(indented, "a\nb\nc", "`>` must indent the selection");
3862 editor.handle_input(
3863 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
3864 &tx,
3865 );
3866 assert_eq!(
3867 editor.get_text(),
3868 "a\nb\nc",
3869 "one `u` must revert the whole indent, not one row"
3870 );
3871 }
3872
3873 #[test]
3877 fn vim_n_cannot_leave_an_invisible_selection() {
3878 let mut editor = make_vim_editor();
3879 let tx = dummy_tx();
3880 editor.set_text("foo bar foo".to_string());
3881 editor.open_or_advance_search();
3882 for c in "foo".chars() {
3883 editor.handle_input(
3884 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3885 &tx,
3886 );
3887 }
3888 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3889 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
3891 get_ta(&mut editor).start_selection();
3892 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 3));
3893 editor.handle_input(
3894 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
3895 &tx,
3896 );
3897 for c in ['i', 'X'] {
3898 editor.handle_input(
3899 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3900 &tx,
3901 );
3902 }
3903 assert!(
3904 editor.get_text().contains("bar"),
3905 "typing after `n` must not eat unhighlighted text, got {:?}",
3906 editor.get_text()
3907 );
3908 }
3909
3910 #[test]
3915 fn overlays_paint_where_the_post_pass_used_to() {
3916 use ratatui::Terminal;
3917 use ratatui::backend::TestBackend;
3918 use ratatui::layout::Position;
3919 let mut editor = make_editor();
3920 editor.set_text("find the needle here\n- [x] done task\n- [ ] open task".to_string());
3921 editor.set_search_needles(vec!["needle".to_string()]);
3922 let theme = Theme::default();
3923 let mut term = Terminal::new(TestBackend::new(40, 6)).unwrap();
3924 let area = Rect::new(0, 0, 40, 6);
3925 term.draw(|f| editor.render(f, area, &theme, false))
3926 .unwrap();
3927 let buf = term.backend().buffer();
3928
3929 let row: String = (0..40)
3930 .filter_map(|x| {
3931 buf.cell(Position::new(x, 0))
3932 .map(|c| c.symbol().to_string())
3933 })
3934 .collect();
3935 let at = row.find("needle").expect("needle is on screen");
3936 let cell = buf.cell(Position::new(at as u16, 0)).unwrap();
3937 assert_eq!(
3938 cell.fg,
3939 theme.color_search_match.to_ratatui(),
3940 "the needle must still be emphasised"
3941 );
3942
3943 let struck = |y: u16| {
3945 (0..40).any(|x| {
3946 buf.cell(Position::new(x, y)).is_some_and(|c| {
3947 c.style()
3948 .add_modifier
3949 .contains(ratatui::style::Modifier::CROSSED_OUT)
3950 })
3951 })
3952 };
3953 assert!(struck(1), "a done task strikes its text");
3954 assert!(!struck(2), "an open task does not");
3955
3956 for y in [1u16, 2] {
3958 assert!(
3959 (0..40).any(|x| buf
3960 .cell(Position::new(x, y))
3961 .is_some_and(|c| c.fg == theme.accent.to_ratatui())),
3962 "row {y} must have an accent-coloured checkbox"
3963 );
3964 }
3965 }
3966
3967 #[test]
3968 fn no_preview_without_a_replace_field() {
3969 let mut editor = make_editor();
3970 let tx = dummy_tx();
3971 editor.set_text("todo".to_string());
3972 editor.open_or_advance_search();
3973 for c in "todo".chars() {
3974 editor.handle_input(
3975 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3976 &tx,
3977 );
3978 }
3979 assert!(
3980 editor.replace_preview().is_none(),
3981 "a find-only bar previews nothing"
3982 );
3983 }
3984
3985 #[test]
3986 fn capture_expansion_is_gated_on_the_pattern_capturing() {
3987 let mut editor = make_editor();
3988 let tx = dummy_tx();
3989 editor.set_text("cost".to_string());
3991 open_replace_bar(&mut editor, &tx, "cost", "$1");
3992 editor.handle_input(
3993 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3994 &tx,
3995 );
3996 assert_eq!(editor.get_text(), "$1");
3997 }
3998
3999 #[test]
4000 fn the_bar_reserves_two_rows_only_while_replacing() {
4001 use ratatui::Terminal;
4002 use ratatui::backend::TestBackend;
4003 let mut editor = make_editor();
4004 let tx = dummy_tx();
4005 editor.set_text("todo".to_string());
4006 let theme = Theme::default();
4007 let mut term = Terminal::new(TestBackend::new(40, 10)).unwrap();
4008 let area = Rect::new(0, 0, 40, 10);
4009
4010 editor.open_or_advance_search();
4011 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4012 assert_eq!(editor.rect.height, 9, "a find-only bar takes one row");
4013
4014 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
4015 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4016 assert_eq!(
4017 editor.rect.height, 8,
4018 "the replace field takes a second row"
4019 );
4020 }
4021
4022 #[test]
4027 fn guu_really_does_undo_in_one_step() {
4028 let mut editor = make_vim_editor();
4029 let tx = dummy_tx();
4030 editor.set_text("Mixed Case Line".to_string());
4031 for c in "guu".chars() {
4032 editor.handle_input(
4033 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4034 &tx,
4035 );
4036 }
4037 assert_eq!(editor.get_text(), "mixed case line");
4038 editor.handle_input(
4039 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4040 &tx,
4041 );
4042 assert_eq!(editor.get_text(), "Mixed Case Line");
4043 }
4044
4045 #[test]
4050 fn vim_u_undoes_a_whole_replace() {
4051 let mut editor = make_vim_editor();
4052 let tx = dummy_tx();
4053 editor.set_text("todo and todo".to_string());
4054 open_replace_bar(&mut editor, &tx, "todo", "done");
4055 editor.handle_input(
4056 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4057 &tx,
4058 );
4059 assert_eq!(editor.get_text(), "done and done");
4060
4061 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4063 editor.handle_input(
4064 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4065 &tx,
4066 );
4067 assert_eq!(editor.get_text(), "todo and todo");
4068 }
4069
4070 fn type_out(editor: &mut TextEditorComponent, tx: &AppTx, text: &str) {
4073 use ratatui::crossterm::event::KeyEvent;
4074 for c in text.chars() {
4075 let code = if c == '\n' {
4076 KeyCode::Enter
4077 } else {
4078 KeyCode::Char(c)
4079 };
4080 editor.handle_textarea_key(&KeyEvent::new(code, KeyModifiers::NONE), tx);
4081 }
4082 }
4083
4084 #[test]
4085 fn undo_takes_back_a_word_not_a_letter() {
4086 let mut editor = make_editor();
4089 let tx = dummy_tx();
4090 editor.set_text(String::new());
4091 type_out(&mut editor, &tx, "hello world");
4092 assert_eq!(editor.get_text(), "hello world");
4093
4094 assert!(get_ta(&mut editor).undo());
4095 assert_eq!(editor.get_text(), "hello ", "the last word goes whole");
4096 assert!(get_ta(&mut editor).undo());
4097 assert_eq!(editor.get_text(), "", "and so does the first");
4098 }
4099
4100 #[test]
4101 fn a_cursor_move_separates_two_runs() {
4102 let mut editor = make_editor();
4103 let tx = dummy_tx();
4104 editor.set_text(String::new());
4105 type_out(&mut editor, &tx, "ab");
4106 arrow(&mut editor, &tx, KeyCode::Home);
4107 type_out(&mut editor, &tx, "cd");
4108 assert_eq!(editor.get_text(), "cdab");
4109
4110 assert!(get_ta(&mut editor).undo());
4111 assert_eq!(
4112 editor.get_text(),
4113 "ab",
4114 "only what was typed after the move comes back off"
4115 );
4116 }
4117
4118 #[test]
4119 fn backspacing_to_fix_a_typo_is_its_own_action() {
4120 use ratatui::crossterm::event::KeyEvent;
4121 let mut editor = make_editor();
4122 let tx = dummy_tx();
4123 editor.set_text(String::new());
4124 type_out(&mut editor, &tx, "helllo");
4125 editor.handle_textarea_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
4126 assert_eq!(editor.get_text(), "helll");
4127
4128 assert!(get_ta(&mut editor).undo());
4129 assert_eq!(
4130 editor.get_text(),
4131 "helllo",
4132 "the delete undoes on its own, without taking the typing with it"
4133 );
4134 }
4135
4136 #[test]
4137 fn an_undo_between_two_runs_separates_them() {
4138 let mut editor = make_editor();
4141 let tx = dummy_tx();
4142 editor.set_text(String::new());
4143 type_out(&mut editor, &tx, "ab");
4144 assert!(get_ta(&mut editor).undo());
4145 assert_eq!(editor.get_text(), "");
4146 type_out(&mut editor, &tx, "cd");
4147 assert_eq!(editor.get_text(), "cd");
4148 assert!(get_ta(&mut editor).undo());
4149 assert_eq!(
4150 editor.get_text(),
4151 "",
4152 "the second run is its own group, not an extension of an undone one"
4153 );
4154 }
4155
4156 #[test]
4157 fn a_save_closes_the_open_group() {
4158 let mut editor = make_editor();
4162 let tx = dummy_tx();
4163 type_out(&mut editor, &tx, "abc");
4164 let saved = editor.get_text();
4165 editor.mark_saved(saved);
4166 type_out(&mut editor, &tx, "def");
4169
4170 assert!(get_ta(&mut editor).undo());
4171 assert_eq!(
4172 editor.get_text(),
4173 "abc",
4174 "one undo lands on what was saved, not before it"
4175 );
4176 }
4177
4178 #[test]
4179 fn a_stale_save_completion_does_not_close_the_group() {
4180 let mut editor = make_editor();
4184 let tx = dummy_tx();
4185 type_out(&mut editor, &tx, "abc");
4186 let stale = NonZeroU64::new(1).expect("nonzero");
4187 editor.mark_saved_at_revision(stale);
4188 type_out(&mut editor, &tx, "def");
4189
4190 assert!(get_ta(&mut editor).undo());
4191 assert_eq!(
4192 editor.get_text(),
4193 "",
4194 "the run carried on across a completion that marked nothing"
4195 );
4196 }
4197
4198 #[test]
4199 fn a_second_vim_insert_session_is_its_own_group() {
4200 use ratatui::crossterm::event::KeyEvent;
4204 let mut editor = make_vim_editor();
4205 let tx = dummy_tx();
4206 editor.set_text(String::new());
4207 let press = |editor: &mut TextEditorComponent, code| {
4208 let _ = editor.handle_input(
4209 &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
4210 &tx,
4211 );
4212 };
4213 press(&mut editor, KeyCode::Char('i'));
4214 for c in "one".chars() {
4215 press(&mut editor, KeyCode::Char(c));
4216 }
4217 press(&mut editor, KeyCode::Esc);
4218 press(&mut editor, KeyCode::Char('i'));
4219 for c in "two".chars() {
4220 press(&mut editor, KeyCode::Char(c));
4221 }
4222 press(&mut editor, KeyCode::Esc);
4223 assert_eq!(editor.get_text(), "ontwoe");
4226
4227 assert!(get_ta(&mut editor).undo());
4228 assert_eq!(
4229 editor.get_text(),
4230 "one",
4231 "`u` takes back the second session only"
4232 );
4233 }
4234
4235 #[test]
4236 fn a_vim_insert_session_undoes_whole() {
4237 use ratatui::crossterm::event::KeyEvent;
4238 let mut editor = make_vim_editor();
4239 let tx = dummy_tx();
4240 editor.set_text(String::new());
4241 let press = |editor: &mut TextEditorComponent, code| {
4243 let _ = editor.handle_input(
4244 &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
4245 &tx,
4246 );
4247 };
4248 press(&mut editor, KeyCode::Char('i'));
4249 for c in "hello world".chars() {
4250 press(&mut editor, KeyCode::Char(c));
4251 }
4252 press(&mut editor, KeyCode::Esc);
4253 assert_eq!(editor.get_text(), "hello world");
4254
4255 assert!(get_ta(&mut editor).undo());
4256 assert_eq!(
4257 editor.get_text(),
4258 "",
4259 "vim's `u` takes back the whole session, word boundaries included"
4260 );
4261 }
4262
4263 fn lay_out(editor: &mut TextEditorComponent, width: u16, height: u16) {
4267 use ratatui::Terminal;
4268 use ratatui::backend::TestBackend;
4269 let theme = Theme::default();
4270 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
4271 let area = Rect::new(0, 0, width, height);
4272 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4273 }
4274
4275 fn arrow(editor: &mut TextEditorComponent, tx: &AppTx, code: KeyCode) {
4276 use ratatui::crossterm::event::KeyEvent;
4277 editor.handle_textarea_key(&KeyEvent::new(code, KeyModifiers::NONE), tx);
4278 }
4279
4280 #[test]
4281 fn down_moves_one_drawn_line_not_one_row() {
4282 let mut editor = make_editor();
4285 let tx = dummy_tx();
4286 editor.set_text(
4287 "aaaa bbbb cccc dddd
4288second row"
4289 .to_string(),
4290 );
4291 lay_out(&mut editor, 6, 10);
4292
4293 get_ta(&mut editor).jump_to(0, 0);
4294 arrow(&mut editor, &tx, KeyCode::Down);
4295 assert_eq!(
4296 get_ta(&mut editor).cursor(),
4297 (0, 5),
4298 "still inside the first row, on its second drawn line"
4299 );
4300 arrow(&mut editor, &tx, KeyCode::Down);
4301 assert_eq!(get_ta(&mut editor).cursor(), (0, 10));
4302 arrow(&mut editor, &tx, KeyCode::Down);
4303 assert_eq!(get_ta(&mut editor).cursor(), (0, 15));
4304 arrow(&mut editor, &tx, KeyCode::Down);
4305 assert_eq!(
4306 get_ta(&mut editor).cursor().0,
4307 1,
4308 "and only the fourth press reaches the next row"
4309 );
4310 }
4311
4312 #[test]
4313 fn up_and_down_are_symmetric_across_a_wrap() {
4314 let mut editor = make_editor();
4315 let tx = dummy_tx();
4316 editor.set_text("aaaa bbbb cccc".to_string());
4317 lay_out(&mut editor, 6, 10);
4318
4319 get_ta(&mut editor).jump_to(0, 0);
4320 arrow(&mut editor, &tx, KeyCode::Down);
4321 let middle = get_ta(&mut editor).cursor();
4322 arrow(&mut editor, &tx, KeyCode::Up);
4323 assert_eq!(get_ta(&mut editor).cursor(), (0, 0));
4324 assert_eq!(middle, (0, 5));
4325 }
4326
4327 #[test]
4328 fn an_arrow_against_a_stale_layout_falls_back_instead_of_panicking() {
4329 let mut editor = make_editor();
4334 let tx = dummy_tx();
4335 editor.set_text("abcd\nefgh".to_string());
4336 lay_out(&mut editor, 20, 10);
4337
4338 get_ta(&mut editor).jump_to(0, 4);
4339 for _ in 0..3 {
4340 get_ta(&mut editor).delete_char();
4341 }
4342 assert_eq!(get_ta(&mut editor).rows(), &["a", "efgh"]);
4343
4344 arrow(&mut editor, &tx, KeyCode::Down);
4346 assert_eq!(get_ta(&mut editor).cursor().0, 1, "still moved down a row");
4347 }
4348
4349 #[test]
4350 fn an_action_between_arrows_forgets_the_goal_cell() {
4351 let mut editor = make_editor();
4357 let tx = dummy_tx();
4358 editor.set_text(
4359 "aaaaaaaa
4360bb
4361cccccccc"
4362 .to_string(),
4363 );
4364 lay_out(&mut editor, 20, 10);
4365
4366 get_ta(&mut editor).jump_to(0, 7);
4367 arrow(&mut editor, &tx, KeyCode::Down);
4368 assert_eq!(
4369 get_ta(&mut editor).cursor(),
4370 (1, 2),
4371 "clamped to the short row"
4372 );
4373
4374 let saved = editor.get_text();
4375 editor.mark_saved(saved);
4376
4377 arrow(&mut editor, &tx, KeyCode::Down);
4378 assert_eq!(
4379 get_ta(&mut editor).cursor(),
4380 (2, 2),
4381 "the goal was forgotten, so the third row keeps the clamped column"
4382 );
4383 }
4384
4385 #[test]
4386 fn a_run_of_arrows_keeps_its_goal_cell() {
4387 let mut editor = make_editor();
4390 let tx = dummy_tx();
4391 editor.set_text(
4392 "aaaaaaaa
4393bb
4394cccccccc"
4395 .to_string(),
4396 );
4397 lay_out(&mut editor, 20, 10);
4398
4399 get_ta(&mut editor).jump_to(0, 7);
4400 arrow(&mut editor, &tx, KeyCode::Down);
4401 assert_eq!(
4402 get_ta(&mut editor).cursor(),
4403 (1, 2),
4404 "clamped to the short row"
4405 );
4406 arrow(&mut editor, &tx, KeyCode::Down);
4407 assert_eq!(
4408 get_ta(&mut editor).cursor(),
4409 (2, 7),
4410 "and back out to the cell the run still wants"
4411 );
4412 }
4413
4414 #[test]
4415 fn another_key_ends_the_run() {
4416 let mut editor = make_editor();
4417 let tx = dummy_tx();
4418 editor.set_text(
4419 "aaaaaaaa
4420bb
4421cccccccc"
4422 .to_string(),
4423 );
4424 lay_out(&mut editor, 20, 10);
4425
4426 get_ta(&mut editor).jump_to(0, 7);
4427 arrow(&mut editor, &tx, KeyCode::Down);
4428 arrow(&mut editor, &tx, KeyCode::Home);
4429 arrow(&mut editor, &tx, KeyCode::Down);
4430 assert_eq!(
4431 get_ta(&mut editor).cursor(),
4432 (2, 0),
4433 "Home set a new goal; the old one is gone"
4434 );
4435 }
4436
4437 #[test]
4438 fn shift_down_extends_by_a_drawn_line() {
4439 let mut editor = make_editor();
4440 let tx = dummy_tx();
4441 editor.set_text("aaaa bbbb cccc".to_string());
4442 lay_out(&mut editor, 6, 10);
4443
4444 get_ta(&mut editor).jump_to(0, 0);
4445 editor.handle_textarea_key(
4446 &ratatui::crossterm::event::KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT),
4447 &tx,
4448 );
4449 assert_eq!(
4450 get_ta(&mut editor).selection_range(),
4451 Some(((0, 0), (0, 5)))
4452 );
4453 }
4454
4455 fn make_vim_editor() -> TextEditorComponent {
4457 let mut settings = crate::settings::AppSettings::default();
4458 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
4459 TextEditorComponent::new(KeyBindings::empty(), &settings)
4460 }
4461
4462 fn vim_mode(editor: &TextEditorComponent) -> EditorMode {
4465 match &editor.backend {
4466 BackendState::Textarea(tb) => match &tb.input {
4467 backend::InputInterpreter::Vim(e) => e.mode().clone(),
4468 _ => panic!("expected Vim input interpreter"),
4469 },
4470 _ => panic!("expected Textarea backend"),
4471 }
4472 }
4473
4474 #[test]
4480 fn vim_visual_paste_url_wraps_whole_selected_word() {
4481 let mut editor = make_vim_editor();
4482 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4483 editor.set_text("hello world".to_string());
4484 editor.handle_input(
4487 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
4488 &tx,
4489 );
4490 editor.handle_input(
4491 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
4492 &tx,
4493 );
4494 assert_eq!(vim_mode(&editor), EditorMode::Visual);
4495 editor.paste_text("https://example.com", &tx);
4496 assert_eq!(
4497 editor.get_text(),
4498 "[hello](https://example.com) world",
4499 "the whole selected word (including the char under the cursor) must be wrapped"
4500 );
4501 }
4502
4503 #[test]
4509 fn vim_visual_bold_wraps_whole_selected_word() {
4510 let mut editor = make_vim_editor();
4511 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4512 editor.set_text("hello world".to_string());
4513 editor.handle_input(
4514 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
4515 &tx,
4516 );
4517 editor.handle_input(
4518 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
4519 &tx,
4520 );
4521 assert_eq!(vim_mode(&editor), EditorMode::Visual);
4522 editor.apply_text_action(TextAction::Bold);
4523 assert_eq!(
4524 editor.get_text(),
4525 "**hello** world",
4526 "the whole selected word (including the char under the cursor) must be wrapped"
4527 );
4528 }
4529
4530 #[test]
4538 fn paste_reports_its_outcome() {
4539 let mut editor = make_editor();
4540 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4541 editor.set_text("x".to_string());
4542 editor.paste_from_clipboard(&tx);
4543 let reported = std::iter::from_fn(|| rx.try_recv().ok()).any(|e| {
4544 matches!(e, AppEvent::FlashMessage(m)
4545 if m == "pasted" || m == "clipboard is empty" || m.starts_with("clipboard: "))
4546 });
4547 assert!(reported, "a paste attempt must always report something");
4548 }
4549
4550 #[test]
4556 fn external_paste_drops_the_selection_and_leaves_visual() {
4557 let mut editor = make_vim_editor();
4558 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4559 editor.set_text("hello world".to_string());
4560 editor.handle_input(
4561 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
4562 &tx,
4563 );
4564 editor.handle_input(
4565 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
4566 &tx,
4567 );
4568 assert_eq!(vim_mode(&editor), EditorMode::Visual);
4569
4570 editor.take_selection_for_external_paste();
4571
4572 assert_eq!(
4573 vim_mode(&editor),
4574 EditorMode::Normal,
4575 "the engine must not keep believing it is in Visual"
4576 );
4577 assert_eq!(
4578 get_ta(&mut editor).selection_range(),
4579 None,
4580 "the selection the incoming content replaces must be gone"
4581 );
4582 assert_eq!(
4583 editor.get_text(),
4584 " world",
4585 "the inclusive visual range is what gets replaced"
4586 );
4587 }
4588
4589 #[test]
4592 fn external_paste_without_a_selection_leaves_the_buffer_alone() {
4593 let mut editor = make_vim_editor();
4594 editor.set_text("hello world".to_string());
4595 editor.take_selection_for_external_paste();
4596 assert_eq!(editor.get_text(), "hello world");
4597 assert_eq!(vim_mode(&editor), EditorMode::Normal);
4598 }
4599
4600 #[test]
4605 fn vim_visual_copy_is_read_only_and_does_not_grow_selection() {
4606 let mut editor = make_vim_editor();
4607 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4608 editor.set_text("hello world".to_string());
4609 editor.handle_input(
4610 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
4611 &tx,
4612 );
4613 editor.handle_input(
4614 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
4615 &tx,
4616 );
4617 let before = get_ta(&mut editor).selection_range();
4618 assert_eq!(before, Some(((0, 0), (0, 4))));
4619 assert_eq!(
4621 editor.inclusive_visual_range(),
4622 Some(((0, 0), (0, 5))),
4623 "copy must read the inclusive range including the cursor char"
4624 );
4625 editor.copy_selection_to_clipboard(&tx);
4627 editor.copy_selection_to_clipboard(&tx);
4628 assert_eq!(
4629 get_ta(&mut editor).selection_range(),
4630 before,
4631 "copy must not move the cursor or grow the live selection"
4632 );
4633 }
4634
4635 #[test]
4643 fn vim_visual_line_selection_stays_full_width_after_cursor_moves_back() {
4644 let mut editor = make_vim_editor();
4645 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4646 editor.set_text("hello world".to_string());
4647 editor.handle_input(
4648 &InputEvent::Key(key(KeyCode::Char('V'), KeyModifiers::NONE)),
4649 &tx,
4650 );
4651 assert_eq!(vim_mode(&editor), EditorMode::VisualLine);
4652 assert_eq!(
4653 editor.selection,
4654 Some(((0, 0), (0, usize::MAX))),
4655 "V must highlight the full line right away"
4656 );
4657 for _ in 0..5 {
4658 editor.handle_input(
4659 &InputEvent::Key(key(KeyCode::Left, KeyModifiers::NONE)),
4660 &tx,
4661 );
4662 }
4663 assert_eq!(
4664 editor.selection,
4665 Some(((0, 0), (0, usize::MAX))),
4666 "moving the cursor back must not shrink the linewise highlight"
4667 );
4668 }
4669
4670 #[test]
4677 fn vim_visual_outdent_with_nothing_to_remove_clears_the_highlight() {
4678 let mut editor = make_vim_editor();
4679 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4680 editor.set_text("alpha\nbeta".to_string());
4681 for c in ['V', 'j'] {
4682 editor.handle_input(
4683 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4684 &tx,
4685 );
4686 }
4687 assert_eq!(vim_mode(&editor), EditorMode::VisualLine);
4688 assert!(
4689 editor.selection.is_some(),
4690 "V over two rows must paint a highlight"
4691 );
4692
4693 editor.handle_input(
4694 &InputEvent::Key(key(KeyCode::Char('<'), KeyModifiers::NONE)),
4695 &tx,
4696 );
4697 assert_eq!(
4698 editor.get_text(),
4699 "alpha\nbeta",
4700 "there is no indentation to remove"
4701 );
4702 assert_eq!(vim_mode(&editor), EditorMode::Normal);
4703 assert_eq!(
4704 editor.selection, None,
4705 "leaving Visual mode must clear the highlight even when the outdent changed nothing"
4706 );
4707 }
4708
4709 #[test]
4719 fn vim_sync_collapsed_sel_stays_normal() {
4720 let mut editor = make_vim_editor();
4721 editor.set_text("hello world".to_string());
4722
4723 assert_eq!(vim_mode(&editor), EditorMode::Normal);
4725
4726 editor.backend.sync_mouse_selection(false);
4729 assert_eq!(
4730 vim_mode(&editor),
4731 EditorMode::Normal,
4732 "collapsed (bare click) selection must not enter Visual mode"
4733 );
4734 }
4735
4736 #[test]
4738 fn vim_sync_real_sel_enters_visual() {
4739 let mut editor = make_vim_editor();
4740 editor.set_text("hello world".to_string());
4741
4742 assert_eq!(vim_mode(&editor), EditorMode::Normal);
4744
4745 editor.backend.sync_mouse_selection(true);
4747 assert_eq!(
4748 vim_mode(&editor),
4749 EditorMode::Visual,
4750 "real drag selection must enter Visual mode"
4751 );
4752 }
4753
4754 #[test]
4758 fn vim_find_bar_captures_typing_not_cursor() {
4759 let mut editor = make_vim_editor();
4760 editor.set_text("hello world\nsecond line".to_string());
4761 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4762
4763 editor.open_or_advance_search();
4765 assert!(editor.search.is_some(), "find bar must be open");
4766
4767 editor.handle_input(
4769 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
4770 &tx,
4771 );
4772 editor.handle_input(
4773 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
4774 &tx,
4775 );
4776
4777 let q = editor
4781 .search
4782 .as_ref()
4783 .map(|s| s.input.value().to_string())
4784 .unwrap_or_default();
4785 assert_eq!(q, "lo", "find query must capture typed characters");
4786
4787 assert_eq!(
4790 editor.get_text(),
4791 "hello world\nsecond line",
4792 "buffer must not be modified while find bar is open"
4793 );
4794
4795 assert_eq!(
4801 editor.cursor_pos().1,
4802 3,
4803 "cursor must jump to the search match (col 3), not to a vim motion position"
4804 );
4805 }
4806
4807 #[test]
4812 fn vim_search_enter_steps_and_esc_keeps_the_pattern_for_n() {
4813 let mut editor = make_vim_editor();
4814 editor.set_text("lo xx lo yy lo".to_string());
4816 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4817
4818 editor.open_or_advance_search();
4820 assert!(editor.search.is_some(), "find bar must open");
4821
4822 editor.handle_input(
4824 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
4825 &tx,
4826 );
4827 editor.handle_input(
4828 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
4829 &tx,
4830 );
4831
4832 editor.handle_input(
4836 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
4837 &tx,
4838 );
4839 assert!(
4840 editor.search.is_some(),
4841 "find bar stays open on Enter — it steps, it does not confirm"
4842 );
4843 let (_, c1) = editor.cursor_pos();
4844 assert_eq!(c1, 6, "Enter must step to the 2nd 'lo' at col 6");
4845
4846 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4850 assert!(editor.search.is_none(), "Esc must close the find bar");
4851
4852 editor.handle_input(
4854 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
4855 &tx,
4856 );
4857 let (_, c2) = editor.cursor_pos();
4858 assert_eq!(c2, 12, "'n' must jump to the 3rd 'lo' at col 12");
4859
4860 assert_eq!(editor.get_text(), "lo xx lo yy lo");
4862 }
4863}