1pub mod autocomplete_glue;
2pub mod backend;
3pub mod find_bar;
4pub mod find_replace;
5pub mod markdown;
6pub mod nvim_decode;
7pub mod nvim_host;
8pub mod nvim_rpc;
9pub mod parse_incremental;
10pub mod plain_keys;
11mod revisions;
12pub mod rope_buffer;
13pub mod typing_run;
14use revisions::Revisions;
15pub mod snapshot;
16pub mod text_coords;
17pub mod view;
18mod vim;
19mod vim_objects;
20pub mod widener_metrics;
21
22use self::rope_buffer::CursorMove;
23use ratatui::Frame;
24use ratatui::crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
25use ratatui::layout::Rect;
26use ratatui::style::{Modifier, Style};
27use std::num::NonZeroU64;
28
29pub(crate) fn cursor_tuple(ta: &rope_buffer::RopeBuffer) -> (usize, usize) {
33 ta.cursor()
34}
35
36fn snapshot_from_backend(backend: &BackendState, content_revision: NonZeroU64) -> EditorSnapshot {
43 match backend {
44 BackendState::Textarea(tb) => {
45 let cursor = cursor_tuple(&tb.ta);
46 EditorSnapshot::of_buffer(tb.ta.text().clone(), cursor, content_revision)
47 }
48 BackendState::Nvim(nvim) => {
49 let snap = nvim.snapshot();
50 let lines_len = snap.lines.len();
51 let cursor_row = if lines_len == 0 {
52 0
53 } else {
54 snap.cursor.0.min(lines_len - 1)
55 };
56 let cursor = (cursor_row, snap.cursor.1);
57 let lines = snap.lines.clone();
58 let rev = Revisions::rev_from_gen(snap.content_gen);
59 drop(snap);
60 EditorSnapshot::owned(lines, cursor, rev)
61 }
62 }
63}
64
65fn preview_revision(base: NonZeroU64, lines: &[String]) -> NonZeroU64 {
74 use std::collections::hash_map::DefaultHasher;
75 use std::hash::{Hash, Hasher};
76 let mut h = DefaultHasher::new();
77 base.get().hash(&mut h);
78 lines.hash(&mut h);
79 NonZeroU64::new(h.finish()).unwrap_or(NonZeroU64::MIN)
80}
81
82fn has_trigger_before_cursor(line: &str, col: usize) -> bool {
93 let cursor_byte = line
94 .char_indices()
95 .nth(col)
96 .map(|(b, _)| b)
97 .unwrap_or(line.len());
98 line[..cursor_byte]
99 .chars()
100 .rev()
101 .any(|c| c == '[' || c == '#')
102}
103
104use self::backend::BackendState;
105#[cfg(test)]
106use self::find_bar::{BarFocus, SearchStatus};
107use self::markdown::ParsedBuffer;
108use self::nvim_host::NvimHost;
109use self::rope_buffer::RopeBuffer;
110use self::snapshot::EditorSnapshot;
111use self::view::MarkdownEditorView;
112use crate::util::single_slot_task::SingleSlotTask;
113
114fn increment_ordered_marker(marker: &str) -> Option<String> {
117 let trimmed = marker.trim_end_matches(' ');
118 let dot = trimmed.strip_suffix('.')?;
119 let n: u32 = dot.parse().ok()?;
120 Some(format!("{}. ", n + 1))
121}
122
123pub(super) fn char_col_to_byte(line: &str, char_col: usize) -> usize {
126 line.char_indices()
127 .nth(char_col)
128 .map(|(b, _)| b)
129 .unwrap_or(line.len())
130}
131
132fn selection_text(ta: &rope_buffer::RopeBuffer) -> Option<String> {
138 selection_text_in(ta, ta.selection_range()?)
139}
140
141fn selection_text_in(
145 ta: &rope_buffer::RopeBuffer,
146 range: ((usize, usize), (usize, usize)),
147) -> Option<String> {
148 let ((sr, sc), (er, ec)) = range;
149 if sr == er && sc == ec {
150 return None;
151 }
152 ta.span_between((sr, sc), (er, ec))
155 .and_then(|span| ta.text().slice(span))
156 .map(|text| text.into_owned())
157}
158
159fn surround_pair(c: char) -> Option<(&'static str, &'static str)> {
164 match c {
165 '(' => Some(("(", ")")),
166 '[' => Some(("[", "]")),
167 '{' => Some(("{", "}")),
168 '<' => Some(("<", ">")),
169 '"' => Some(("\"", "\"")),
170 '\'' => Some(("'", "'")),
171 '`' => Some(("`", "`")),
172 '*' => Some(("*", "*")),
173 '_' => Some(("_", "_")),
174 '~' => Some(("~", "~")),
175 _ => None,
176 }
177}
178
179fn set_selection(ta: &mut RopeBuffer, start: (usize, usize), end: (usize, usize)) -> bool {
186 let max = u16::MAX as usize;
187 if start.0 > max || start.1 > max || end.0 > max || end.1 > max {
188 return false;
189 }
190 ta.cancel_selection();
191 ta.jump_to(start.0, start.1);
192 ta.start_selection();
193 ta.jump_to(end.0, end.1);
194 true
195}
196
197#[derive(Debug, Clone)]
201pub struct ClipboardImage {
202 pub width: usize,
203 pub height: usize,
204 pub rgba: Vec<u8>,
205}
206
207const LINKABLE_PASTE_SCHEMES: &[&str] = &["http", "https", "ftp", "ftps", "mailto"];
211
212fn linkable_url(s: &str) -> Option<&str> {
213 kimun_core::note::scan::url_with_allowed_scheme(s, LINKABLE_PASTE_SCHEMES)
214}
215
216fn try_build_markdown_link(clip: &str, selection: Option<&str>) -> Option<String> {
220 let url = linkable_url(clip)?;
221 let sel = selection.filter(|s| !s.is_empty())?;
222 let escaped = sel.replace('\\', r"\\").replace(']', r"\]");
223 Some(format!("[{escaped}]({url})"))
224}
225
226use std::sync::Arc;
227
228use kimun_core::NoteVault;
229
230use crate::components::Component;
231use crate::components::autocomplete::{
232 self, AutocompleteController, AutocompleteHost, AutocompleteMode, HandleKeyOutcome,
233};
234use crate::components::event_state::EventState;
235use crate::components::events::AppEvent;
236use crate::components::events::AppTx;
237use crate::components::events::InputEvent;
238use crate::components::events::redraw_callback;
239use crate::components::text_editor::autocomplete_glue::apply_accept_to_textarea;
240use crate::keys::KeyBindings;
241use crate::keys::action_shortcuts::TextAction;
242use crate::settings::AppSettings;
243use crate::settings::themes::Theme;
244
245#[derive(Debug, Clone, PartialEq)]
253pub enum FollowTarget {
254 Link(String),
256 Label(String),
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
268pub enum EditorClaim {
269 #[default]
270 None,
271 FindBar,
272 Autocomplete,
273}
274
275struct EditorHostSnapshot {
282 snap: EditorSnapshot,
283 cursor_screen: Option<(u16, u16)>,
284 cache_key: Option<NonZeroU64>,
285}
286
287impl AutocompleteHost for EditorHostSnapshot {
288 fn buffer_snapshot(&self) -> EditorSnapshot {
289 EditorSnapshot::of_buffer(
294 self.snap.text.clone(),
295 self.snap.cursor,
296 self.snap.content_revision,
297 )
298 }
299 fn cache_key(&self) -> Option<NonZeroU64> {
300 self.cache_key
301 }
302 fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
303 Some(self.cursor_screen.unwrap_or((0, 0)))
317 }
318}
319
320fn build_editor_host_snapshot(
326 backend: &BackendState,
327 content_revision: NonZeroU64,
328 cursor_screen: Option<(u16, u16)>,
329) -> Option<EditorHostSnapshot> {
330 if !backend.is_textarea() {
331 return None;
332 }
333 Some(EditorHostSnapshot {
334 snap: snapshot_from_backend(backend, content_revision),
335 cursor_screen,
336 cache_key: Some(content_revision),
337 })
338}
339
340pub struct TextEditorComponent {
344 backend: BackendState,
345 rect: Rect,
347 key_bindings: KeyBindings,
348 view: MarkdownEditorView,
349 revs: Revisions,
358 selection: Option<((usize, usize), (usize, usize))>,
361 nvim_host: NvimHost,
364 search: Option<find_bar::FindBar>,
366 autocomplete: Option<AutocompleteController>,
370 autocomplete_vault: Option<Arc<NoteVault>>,
374 autocomplete_redraw_bound: bool,
379 full_parse_task: SingleSlotTask<()>,
386 layout_task: SingleSlotTask<()>,
393 last_insert_session: bool,
397 pub wants_context_menu: bool,
400 search_needles: Vec<String>,
404 full_parse_tx: tokio::sync::mpsc::UnboundedSender<(u64, ParsedBuffer)>,
405 full_parse_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, ParsedBuffer)>,
406 layout_tx: tokio::sync::mpsc::UnboundedSender<(u64, crate::ropetext::Layout)>,
407 layout_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, crate::ropetext::Layout)>,
408 redraw_tx: Option<AppTx>,
412}
413
414impl TextEditorComponent {
415 pub fn new(key_bindings: KeyBindings, settings: &AppSettings) -> Self {
416 let (full_parse_tx, full_parse_rx) = tokio::sync::mpsc::unbounded_channel();
417 let (layout_tx, layout_rx) = tokio::sync::mpsc::unbounded_channel();
418 Self {
419 backend: BackendState::from_settings(
420 &settings.editor_backend,
421 settings.nvim_path.as_ref(),
422 ),
423 rect: Rect::default(),
424 key_bindings,
425 view: MarkdownEditorView::new(),
426 revs: Revisions::new(),
427 selection: None,
428 nvim_host: NvimHost::new(),
429 search: None,
430 autocomplete: None,
431 autocomplete_vault: None,
432 autocomplete_redraw_bound: false,
433 full_parse_task: SingleSlotTask::empty(),
434 layout_task: SingleSlotTask::empty(),
435 last_insert_session: false,
436 wants_context_menu: false,
437 search_needles: Vec::new(),
438 full_parse_tx,
439 full_parse_rx,
440 layout_tx,
441 layout_rx,
442 redraw_tx: None,
443 }
444 }
445
446 pub fn set_vault(&mut self, vault: Arc<NoteVault>) {
451 self.autocomplete_vault = Some(vault.clone());
452 if self.backend.is_textarea() {
453 self.autocomplete = Some(AutocompleteController::new(
454 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
455 AutocompleteMode::Both,
456 ));
457 }
458 }
459
460 fn ensure_autocomplete_for_textarea(&mut self) {
465 if self.autocomplete.is_some() {
466 return;
467 }
468 if !self.backend.is_textarea() {
469 return;
470 }
471 let Some(vault) = self.autocomplete_vault.clone() else {
472 return;
473 };
474 self.autocomplete = Some(AutocompleteController::new(
475 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
476 AutocompleteMode::Both,
477 ));
478 self.autocomplete_redraw_bound = false;
481 }
482
483 #[allow(dead_code)]
490 fn autocomplete_host_snapshot(&self) -> Option<EditorHostSnapshot> {
491 build_editor_host_snapshot(
492 &self.backend,
493 self.revs.current(),
494 self.view.last_cursor_screen,
495 )
496 }
497
498 fn poll_autocomplete(&mut self) {
501 if let Some(controller) = self.autocomplete.as_mut() {
502 controller.poll_results();
503 }
504 }
505
506 fn textarea_cursor(&self) -> Option<(usize, usize)> {
510 let ta = self.backend.as_textarea()?;
511 Some(cursor_tuple(ta))
512 }
513
514 fn refresh_autocomplete_if_open(&mut self) {
515 if !self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
517 return;
518 }
519 let Some(snapshot) = build_editor_host_snapshot(
523 &self.backend,
524 self.revs.current(),
525 self.view.last_cursor_screen,
526 ) else {
527 self.close_autocomplete();
528 return;
529 };
530 if let Some(controller) = self.autocomplete.as_mut() {
531 controller.refresh_if_open(&snapshot);
532 }
533 }
534
535 fn sync_autocomplete(&mut self) {
539 let Some(controller) = self.autocomplete.as_ref() else {
540 return; };
542
543 if !controller.is_open() {
556 let Some(ta) = self.backend.as_textarea() else {
557 return;
558 };
559 let (row, col) = cursor_tuple(ta);
560 let line = ta.row(row).unwrap_or_default();
561 if !has_trigger_before_cursor(&line, col) {
562 return;
563 }
564 }
565
566 let Some(snapshot) = build_editor_host_snapshot(
570 &self.backend,
571 self.revs.current(),
572 self.view.last_cursor_screen,
573 ) else {
574 if let Some(c) = self.autocomplete.as_mut() {
575 c.close();
576 }
577 return;
578 };
579 if let Some(controller) = self.autocomplete.as_mut() {
580 controller.sync(&snapshot);
581 }
582 }
583
584 pub fn text(&self) -> crate::ropetext::Text {
591 match &self.backend {
592 BackendState::Textarea(tb) => tb.ta.text().clone(),
593 BackendState::Nvim(_) => crate::ropetext::Text::new(),
594 }
595 }
596
597 pub fn view_snapshot(&self) -> EditorSnapshot {
616 snapshot_from_backend(&self.backend, self.revs.current())
617 }
618
619 pub fn cursor_pos(&self) -> (usize, usize) {
623 self.backend.cursor()
624 }
625
626 pub fn set_search_needles(&mut self, needles: Vec<String>) {
630 self.search_needles = needles
631 .into_iter()
632 .map(|n| n.to_lowercase())
633 .filter(|n| !n.is_empty())
634 .collect();
635 self.revs.arm_needles();
636 }
637
638 pub fn set_text(&mut self, text: String) {
639 if text == self.get_text() {
646 self.revs.mark_saved_current();
647 if let Some(nvim) = self.backend.as_nvim() {
648 nvim.mark_clean();
649 }
650 return;
651 }
652 match &mut self.backend {
653 BackendState::Textarea(tb) => {
654 tb.ta.replace(crate::ropetext::Text::from(text.as_str()));
655 }
656 BackendState::Nvim(nvim) => {
657 nvim.set_text(&text);
658 }
659 }
660 self.backend.reset_input_state();
661 self.bump_content();
662 let reconstructed = self.get_text();
663 self.mark_saved(reconstructed);
664 self.close_autocomplete();
667 self.search = None;
676 self.selection = None;
677 self.view.note_bulk_edit();
682 }
683
684 pub fn get_text(&self) -> String {
685 self.backend.text()
686 }
687
688 pub fn content_revision(&self) -> NonZeroU64 {
695 self.revs.current()
696 }
697
698 pub fn mark_saved_at_revision(&mut self, rev: NonZeroU64) {
708 if !self.revs.mark_saved_at(rev) {
709 return;
710 }
711 self.interrupt_typing();
715 if let Some(nvim) = self.backend.as_nvim() {
716 nvim.mark_clean();
717 }
718 }
719
720 pub fn mark_saved(&mut self, text: String) {
728 self.interrupt_typing();
729 let matches = text == self.get_text();
730 if matches {
731 if let Some(nvim) = self.backend.as_nvim() {
732 nvim.mark_clean();
733 }
734 self.revs.mark_saved_current();
735 } else {
736 self.revs.mark_diverged();
741 }
742 }
743
744 fn interrupt_typing(&mut self) {
761 self.view.clear_visual_goal();
762 if self.backend.modal_is_insert().unwrap_or(false) {
763 return;
764 }
765 if let Some((_, run)) = self.backend.as_textarea_parts_mut() {
766 run.end();
767 }
768 }
769
770 fn sync_insert_session(&mut self) {
777 let in_insert = self.backend.modal_is_insert().unwrap_or(false);
778 if self.last_insert_session == in_insert {
779 return;
780 }
781 self.last_insert_session = in_insert;
782 if let Some((_, run)) = self.backend.as_textarea_parts_mut() {
783 run.end();
784 }
785 }
786
787 pub fn is_dirty(&self) -> bool {
788 match &self.backend {
789 BackendState::Textarea(_) => self.revs.is_dirty(),
790 BackendState::Nvim(nvim) => nvim.snapshot().dirty,
791 }
792 }
793
794 pub fn space_leads(&self) -> bool {
803 self.backend.space_leads()
804 }
805
806 pub fn mouse_drives_cursor(&self) -> bool {
814 self.backend.is_textarea()
815 }
816
817 pub fn covers(&self, column: u16, row: u16) -> bool {
827 self.rect
828 .contains(ratatui::layout::Position::new(column, row))
829 }
830
831 pub fn claim(&self) -> EditorClaim {
836 if self.search.is_some() {
837 EditorClaim::FindBar
838 } else if self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
839 EditorClaim::Autocomplete
840 } else {
841 EditorClaim::None
842 }
843 }
844
845 pub fn follow_target_at_cursor(&self) -> Option<FollowTarget> {
848 let (_row, col, line) = match &self.backend {
849 BackendState::Textarea(tb) => {
850 let (row, col) = cursor_tuple(&tb.ta);
851 let line = tb.ta.row(row)?.into_owned();
852 (row, col, line)
853 }
854 BackendState::Nvim(nvim) => {
855 let snap = nvim.snapshot();
856 let (row, col) = snap.cursor;
857 let line = snap.lines.get(row)?.to_string();
858 (row, col, line)
859 }
860 };
861
862 if let Some(span) = kimun_core::note::scan::link_char_spans(&line)
865 .into_iter()
866 .find(|s| s.start <= col && col < s.end)
867 {
868 return Some(FollowTarget::Link(span.target));
869 }
870
871 let parsed = self::markdown::ParsedLine::parse(&line);
873 parsed
874 .elements
875 .iter()
876 .find(|e| {
877 e.kind == self::markdown::ElementKind::Label
878 && col >= e.start_char
879 && col < e.end_char
880 })
881 .map(|e| {
882 let span: String = line
883 .chars()
884 .skip(e.start_char)
885 .take(e.end_char - e.start_char)
886 .collect();
887 let name = span.trim_start_matches('#').to_string();
888 FollowTarget::Label(name)
889 })
890 }
891
892 fn copy_selection_to_clipboard(&mut self, tx: &AppTx) {
898 let text = {
899 let selected = self
908 .inclusive_visual_range()
909 .zip(self.backend.as_textarea())
910 .and_then(|(range, ta)| selection_text_in(ta, range));
911 match selected {
912 Some(t) if !t.is_empty() => t,
913 _ => {
914 tx.send(AppEvent::FlashMessage("nothing to copy".into()))
915 .ok();
916 return;
917 }
918 }
919 };
920 crate::components::yank(text, "copied", tx);
921 }
922
923 fn inclusive_visual_range(&self) -> Option<((usize, usize), (usize, usize))> {
929 let charwise = self.backend.selection_includes_cursor();
930 let ta = self.backend.as_textarea()?;
931 let (start, (er, ec)) = ta.selection_range()?;
932 let end = if charwise {
933 let len = ta.row(er).map(|l| l.chars().count()).unwrap_or(ec);
934 (er, (ec + 1).min(len))
935 } else {
936 (er, ec)
937 };
938 Some((start, end))
939 }
940
941 fn paste_from_clipboard(&mut self, tx: &AppTx) {
945 let text = match crate::components::with_clipboard(|c| c.get_text()) {
946 Ok(t) if !t.is_empty() => t,
947 Ok(_) => {
948 tx.send(AppEvent::FlashMessage("clipboard is empty".into()))
949 .ok();
950 return;
951 }
952 Err(e) => {
953 tx.send(AppEvent::FlashMessage(format!("clipboard: {e}")))
954 .ok();
955 return;
956 }
957 };
958 self.paste_text(&text, tx);
959 tx.send(AppEvent::FlashMessage("pasted".into())).ok();
963 }
964
965 fn extend_visual_selection_inclusive(&mut self) {
982 if !self.backend.selection_includes_cursor() {
983 return;
984 }
985 if let Some((start, end)) = self.inclusive_visual_range()
986 && let Some(ta) = self.backend.as_textarea_mut()
987 {
988 set_selection(ta, start, end);
989 }
990 }
991
992 pub fn paste_text(&mut self, text: &str, tx: &AppTx) {
993 if text.is_empty() {
994 return;
995 }
996 if self.search.is_some() {
1003 if let (Some(bar), BackendState::Textarea(tb)) =
1004 (self.search.as_mut(), &mut self.backend)
1005 {
1006 bar.paste(text, &mut tb.ta);
1007 }
1008 self.apply_edit_outcome();
1009 return;
1010 }
1011 self.extend_visual_selection_inclusive();
1012 match &mut self.backend {
1013 BackendState::Textarea(tb) => {
1014 let selection = linkable_url(text).and_then(|_| selection_text(&tb.ta));
1015 let wrapped = try_build_markdown_link(text, selection.as_deref());
1016 let insert = wrapped.as_deref().unwrap_or(text).to_string();
1017 tb.ta.edit(|ta| {
1020 if ta.selection_range().is_some() {
1021 ta.cut();
1022 }
1023 ta.insert_str(insert);
1024 });
1025 self.selection = tb.ta.selection_range();
1026 self.apply_edit_outcome();
1027 }
1028 BackendState::Nvim(nvim) => {
1029 nvim.paste(text, tx.clone());
1030 self.bump_content();
1031 }
1032 }
1033 self.bind_autocomplete_redraw(tx);
1037 self.sync_autocomplete();
1038 }
1039
1040 pub fn insert_at_cursor(&mut self, text: &str, tx: &AppTx) {
1045 if matches!(self.backend, BackendState::Nvim(_)) {
1046 self.paste_text(text, tx);
1047 return;
1048 }
1049 self.take_selection_for_external_paste();
1056 if let Some(ta) = self.backend.as_textarea_mut() {
1057 ta.insert_str(text);
1058 self.selection = ta.selection_range();
1059 self.apply_edit_outcome();
1060 }
1061 self.bind_autocomplete_redraw(tx);
1064 self.sync_autocomplete();
1065 }
1066
1067 pub fn take_clipboard_image(&mut self) -> Option<ClipboardImage> {
1077 let img = crate::components::with_clipboard(|c| c.get_image()).ok()?;
1078 Some(ClipboardImage {
1079 width: img.width,
1080 height: img.height,
1081 rgba: img.bytes.into_owned(),
1082 })
1083 }
1084
1085 pub fn take_selection_for_external_paste(&mut self) {
1094 self.extend_visual_selection_inclusive();
1095 let cut = if let Some(ta) = self.backend.as_textarea_mut() {
1096 let cut = ta.selection_range().is_some() && ta.cut();
1097 self.selection = ta.selection_range();
1098 cut
1099 } else {
1100 false
1101 };
1102 if cut {
1103 self.apply_edit_outcome();
1104 }
1105 self.backend.sync_mouse_selection(false);
1108 }
1109
1110 fn wrap_selection(&mut self, open: &str, close: &str) -> bool {
1116 self.extend_visual_selection_inclusive();
1120 let Some(ta) = self.backend.as_textarea_mut() else {
1121 return false;
1122 };
1123 let Some(((sr, sc), (er, ec))) = ta.selection_range() else {
1124 return false;
1125 };
1126 let Some(text) = selection_text(ta) else {
1127 return false;
1128 };
1129 ta.insert_str(format!("{open}{text}{close}"));
1130 let shift = open.chars().count();
1134 let inner_end_col = if sr == er { ec + shift } else { ec };
1135 set_selection(ta, (sr, sc + shift), (er, inner_end_col));
1136 self.selection = ta.selection_range();
1137 self.interrupt_typing();
1141 self.apply_edit_outcome();
1142 true
1143 }
1144
1145 pub fn apply_text_action(&mut self, action: TextAction) {
1148 let marker = match action {
1149 TextAction::Bold => "**",
1150 TextAction::Italic => "*",
1151 TextAction::Strikethrough => "~~",
1152 _ => return,
1153 };
1154 if self.wrap_selection(marker, marker) {
1155 return;
1156 }
1157 self.interrupt_typing();
1158 let Some(ta) = self.backend.as_textarea_mut() else {
1159 return;
1160 };
1161 ta.insert_str(format!("{marker}{marker}"));
1162 for _ in 0..marker.len() {
1163 ta.move_cursor(CursorMove::Back);
1164 }
1165 self.selection = ta.selection_range();
1166 self.apply_edit_outcome();
1167 }
1168
1169 pub fn smart_enter(&mut self) -> bool {
1174 enum Action {
1175 ClearLine { chars: usize },
1176 InsertPrefix(String),
1177 Dedent,
1178 }
1179 let action = {
1180 let Some(ta) = self.backend.as_textarea() else {
1181 return false;
1182 };
1183 if ta
1186 .selection_range()
1187 .is_some_and(|(start, end)| start != end)
1188 {
1189 return false;
1190 }
1191 let (row, col) = cursor_tuple(ta);
1192 let Some(line) = ta.row(row) else {
1193 return false;
1194 };
1195 let total_chars = line.chars().count();
1196 if col != total_chars {
1197 return false;
1198 }
1199 let ws_end = markdown::leading_ws_byte_len(&line);
1201 let (ws, after_ws) = line.split_at(ws_end);
1202 if let Some(marker_len) = markdown::list_marker_len(after_ws) {
1203 if after_ws.len() == marker_len {
1204 if ws_end > 0 {
1207 Action::Dedent
1208 } else {
1209 Action::ClearLine { chars: total_chars }
1210 }
1211 } else {
1212 let marker_str = &after_ws[..marker_len];
1213 let next_marker = increment_ordered_marker(marker_str)
1214 .unwrap_or_else(|| marker_str.to_string());
1215 Action::InsertPrefix(format!("{ws}{next_marker}"))
1216 }
1217 } else if ws_end > 0 && total_chars == ws_end {
1218 Action::Dedent
1219 } else if ws_end > 0 {
1220 Action::InsertPrefix(ws.to_string())
1221 } else {
1222 return false;
1223 }
1224 };
1225
1226 match action {
1227 Action::Dedent => {
1228 self.indent_lines(true);
1229 return true;
1230 }
1231 Action::ClearLine { chars } => {
1232 let Some(ta) = self.backend.as_textarea_mut() else {
1233 unreachable!()
1234 };
1235 ta.move_cursor(CursorMove::Head);
1236 ta.delete_str(chars);
1237 }
1238 Action::InsertPrefix(prefix) => {
1239 let Some(ta) = self.backend.as_textarea_mut() else {
1240 unreachable!()
1241 };
1242 ta.edit(|ta| {
1245 ta.insert_newline();
1246 ta.insert_str(prefix);
1247 });
1248 }
1249 }
1250 let Some(ta) = self.backend.as_textarea() else {
1251 unreachable!()
1252 };
1253 self.selection = ta.selection_range();
1254 self.apply_edit_outcome();
1255 true
1256 }
1257
1258 pub fn jump_to_heading(&mut self, heading: &str) {
1263 let Some(ta) = self.backend.as_textarea_mut() else {
1264 return;
1265 };
1266 fn normalise(text: &str) -> String {
1271 text.trim()
1272 .trim_end_matches('#')
1273 .trim()
1274 .replace(['*', '_', '`'], "")
1275 }
1276 let wanted = normalise(heading);
1277 let row = (0..ta.row_count()).find(|&row| {
1278 let Some(line) = ta.row(row) else {
1279 return false;
1280 };
1281 let t = line.trim_start();
1282 let stripped = t.trim_start_matches('#');
1283 stripped.len() != t.len() && normalise(stripped) == wanted
1284 });
1285 if let Some(row) = row {
1286 ta.jump_to(row, 0);
1287 }
1288 }
1289
1290 pub fn indent_lines(&mut self, dedent: bool) {
1294 let Some(ta) = self.backend.as_textarea_mut() else {
1295 return;
1296 };
1297 let tab_len = ta.indent_width() as usize;
1298 let hard_tab = ta.hard_tab_indent();
1299 let indent: String = if hard_tab {
1300 "\t".to_string()
1301 } else {
1302 " ".repeat(tab_len)
1303 };
1304 if indent.is_empty() {
1305 return;
1306 }
1307 let indent_chars = indent.len();
1308
1309 let sel = ta.selection_range();
1310 let saved_cursor = if sel.is_none() {
1311 Some(cursor_tuple(ta))
1312 } else {
1313 None
1314 };
1315 let (start_row, end_row) = match sel {
1316 Some(((sr, _), (er, ec))) => {
1317 let last = if ec == 0 && er > sr { er - 1 } else { er };
1320 (sr, last)
1321 }
1322 None => {
1323 let (r, _) = saved_cursor.unwrap();
1324 (r, r)
1325 }
1326 };
1327
1328 let row_count = end_row.saturating_sub(start_row) + 1;
1329 let mut row_deltas: Vec<isize> = Vec::with_capacity(row_count);
1330 let mut any_change = false;
1331
1332 ta.cancel_selection();
1337
1338 ta.edit(|ta| {
1341 for row in start_row..=end_row {
1342 if dedent {
1343 let count = {
1344 let line = ta.row(row).unwrap_or_default();
1345 let max_remove = if hard_tab { 1 } else { tab_len };
1346 let mut count = 0usize;
1347 for (i, c) in line.chars().enumerate() {
1348 if i >= max_remove {
1349 break;
1350 }
1351 if c == '\t' {
1352 count += 1;
1353 break;
1354 } else if c == ' ' && !hard_tab {
1355 count += 1;
1356 } else {
1357 break;
1358 }
1359 }
1360 count
1361 };
1362 if count > 0 {
1363 ta.jump_to(row, 0);
1364 ta.delete_str(count);
1365 any_change = true;
1366 }
1367 row_deltas.push(-(count as isize));
1368 } else {
1369 ta.jump_to(row, 0);
1370 ta.insert_str(&indent);
1371 row_deltas.push(indent_chars as isize);
1372 any_change = true;
1373 }
1374 }
1375 });
1376
1377 let adj = |row: usize, col: usize| -> usize {
1378 if row >= start_row && row <= end_row {
1379 let d = row_deltas[row - start_row];
1380 if d >= 0 {
1381 col + d as usize
1382 } else {
1383 col.saturating_sub((-d) as usize)
1384 }
1385 } else {
1386 col
1387 }
1388 };
1389
1390 match sel {
1391 Some(((ssr, ssc), (ser, sec))) => {
1392 set_selection(ta, (ssr, adj(ssr, ssc)), (ser, adj(ser, sec)));
1393 }
1394 None => {
1395 let (cr, cc) = saved_cursor.expect("captured when sel is None");
1396 let new_col = adj(cr, cc);
1397 ta.jump_to(cr, new_col);
1398 }
1399 }
1400
1401 if any_change {
1402 self.selection = ta.selection_range();
1403 self.apply_edit_outcome();
1404 }
1405 }
1406}
1407
1408impl TextEditorComponent {
1409 #[inline]
1419 fn bump_content(&mut self) {
1420 self.revs.bump();
1421 }
1422
1423 fn maybe_recover_from_dead_nvim(&mut self) {
1425 if self.backend.recover_from_dead_nvim() {
1426 self.ensure_autocomplete_for_textarea();
1430 }
1431 }
1432
1433 fn handle_nvim_key(
1438 &mut self,
1439 key: &ratatui::crossterm::event::KeyEvent,
1440 tx: &AppTx,
1441 ) -> Option<EventState> {
1442 let nvim = self.backend.as_nvim()?;
1446 self.nvim_host.handle_key(nvim, key, tx);
1451 Some(EventState::Consumed)
1452 }
1453
1454 pub fn open_or_advance_search(&mut self) {
1458 if !self.backend.is_textarea() {
1459 return;
1460 }
1461 if self.search.is_some() {
1462 self.dispatch_bar(|bar, buf| {
1463 bar.advance(buf, false);
1464 find_bar::KeyOutcome::default()
1465 });
1466 return;
1467 }
1468 self.close_autocomplete();
1471 self.search = Some(find_bar::FindBar::new());
1472 }
1473
1474 pub fn open_replace(&mut self) {
1477 if !self.backend.is_textarea() {
1478 return;
1479 }
1480 if self.search.is_none() {
1481 self.close_autocomplete();
1482 self.search = Some(find_bar::FindBar::new());
1483 }
1484 if let Some(bar) = self.search.as_mut() {
1485 bar.reveal_replace();
1486 }
1487 }
1488
1489 fn search_repeat(&mut self, backward: bool) {
1492 let BackendState::Textarea(tb) = &mut self.backend else {
1493 return;
1494 };
1495 self.selection = if tb.ta.search_repeat(backward) {
1498 tb.ta.match_at_cursor()
1499 } else {
1500 None
1501 };
1502 }
1503
1504 fn dispatch_bar(
1507 &mut self,
1508 f: impl FnOnce(&mut find_bar::FindBar, &mut RopeBuffer) -> find_bar::KeyOutcome,
1509 ) -> bool {
1510 let BackendState::Textarea(tb) = &mut self.backend else {
1511 return false;
1512 };
1513 let Some(bar) = self.search.as_mut() else {
1514 return false;
1515 };
1516 let outcome = f(bar, &mut tb.ta);
1517 if outcome.close {
1518 self.search = None;
1519 tb.ta.cancel_selection();
1525 self.selection = None;
1526 }
1527 self.apply_edit_outcome();
1528 true
1529 }
1530
1531 fn dispatch_to_find_bar(&mut self, key: &ratatui::crossterm::event::KeyEvent) -> bool {
1533 self.dispatch_bar(|bar, buf| bar.handle_key(key, buf))
1534 }
1535
1536 #[cfg(test)]
1539 fn replace_preview(&self) -> Option<find_replace::Preview> {
1540 let bar = self.search.as_ref()?;
1541 let buf = self.backend.as_textarea()?;
1542 bar.preview(buf)
1543 }
1544
1545 pub fn close_autocomplete(&mut self) {
1549 if let Some(c) = self.autocomplete.as_mut() {
1550 c.close();
1551 }
1552 }
1553
1554 pub fn set_redraw_tx(&mut self, tx: &AppTx) {
1559 self.bind_autocomplete_redraw(tx);
1560 }
1561
1562 fn bind_autocomplete_redraw(&mut self, tx: &AppTx) {
1571 if self.redraw_tx.is_none() {
1572 self.redraw_tx = Some(tx.clone());
1573 }
1574 if self.autocomplete_redraw_bound {
1575 return;
1576 }
1577 if let Some(c) = self.autocomplete.as_mut() {
1578 c.set_redraw_callback(redraw_callback(tx.clone()));
1579 self.autocomplete_redraw_bound = true;
1580 }
1581 }
1582
1583 fn apply_edit_outcome(&mut self) -> bool {
1594 let Some(outcome) = self.backend.as_textarea_mut().map(|ta| ta.take_outcome()) else {
1595 return false;
1596 };
1597 if outcome.changed {
1598 self.bump_content();
1599 }
1600 if outcome.bulk {
1601 self.view.note_bulk_edit();
1602 }
1603 if let Some(rows) = outcome.damage {
1604 self.view.note_damage(rows, outcome.line_delta);
1605 }
1606 outcome.changed
1607 }
1608
1609 fn undo_grouped(&mut self) -> bool {
1612 let moved = self.backend.as_textarea_mut().is_some_and(|ta| ta.undo());
1613 if moved {
1614 self.selection = self
1615 .backend
1616 .as_textarea()
1617 .and_then(|ta| ta.selection_range());
1618 }
1619 moved
1620 }
1621
1622 fn redo_grouped(&mut self) -> bool {
1624 let moved = self.backend.as_textarea_mut().is_some_and(|ta| ta.redo());
1625 if moved {
1626 self.selection = self
1627 .backend
1628 .as_textarea()
1629 .and_then(|ta| ta.selection_range());
1630 }
1631 moved
1632 }
1633
1634 fn handle_textarea_key(
1636 &mut self,
1637 key: &ratatui::crossterm::event::KeyEvent,
1638 tx: &AppTx,
1639 ) -> EventState {
1640 let stroke = plain_keys::operation(*key).and_then(|op| match op {
1650 plain_keys::Operation::Insert(c) => Some(typing_run::Stroke::Insert(c)),
1651 plain_keys::Operation::InsertNewline => Some(typing_run::Stroke::Insert('\n')),
1652 plain_keys::Operation::DeleteBack | plain_keys::Operation::DeleteForward => {
1653 Some(typing_run::Stroke::Delete)
1654 }
1655 _ => None,
1656 });
1657 if stroke.is_none()
1658 && let Some((_, run)) = self.backend.as_textarea_parts_mut()
1659 {
1660 run.end();
1661 }
1662
1663 if key.modifiers == KeyModifiers::CONTROL {
1665 match key.code {
1666 KeyCode::Char('c') => {
1667 self.copy_selection_to_clipboard(tx);
1668 return EventState::Consumed;
1669 }
1670 KeyCode::Char('v') => {
1671 self.paste_from_clipboard(tx);
1672 return EventState::Consumed;
1673 }
1674 KeyCode::Char('x') => {
1675 self.copy_selection_to_clipboard(tx);
1676 let cut = if let Some(ta) = self.backend.as_textarea_mut() {
1677 let cut = ta.cut();
1683 self.selection = ta.selection_range();
1684 cut
1685 } else {
1686 false
1687 };
1688 if cut {
1689 self.apply_edit_outcome();
1690 }
1691 return EventState::Consumed;
1692 }
1693 _ => {}
1694 }
1695 }
1696
1697 if key.modifiers & !KeyModifiers::SHIFT == KeyModifiers::CONTROL {
1702 match key.code {
1703 KeyCode::Char('z') if !key.modifiers.contains(KeyModifiers::SHIFT) => {
1704 if self.undo_grouped() {
1705 self.apply_edit_outcome();
1706 }
1707 return EventState::Consumed;
1708 }
1709 KeyCode::Char('y') | KeyCode::Char('Z') => {
1710 if self.redo_grouped() {
1711 self.apply_edit_outcome();
1712 }
1713 return EventState::Consumed;
1714 }
1715 _ => {}
1716 }
1717 }
1718
1719 match (key.modifiers, key.code) {
1731 (m, KeyCode::Tab)
1732 if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
1733 {
1734 self.indent_lines(m.contains(KeyModifiers::SHIFT));
1735 return EventState::Consumed;
1736 }
1737 (_, KeyCode::BackTab) => {
1738 self.indent_lines(true);
1739 return EventState::Consumed;
1740 }
1741 _ => {}
1742 }
1743 if key.code == KeyCode::Enter && key.modifiers.is_empty() && self.smart_enter() {
1744 return EventState::Consumed;
1745 }
1746
1747 if let KeyCode::Char(c) = key.code
1754 && (key.modifiers & !KeyModifiers::SHIFT).is_empty()
1755 && let Some((open, close)) = surround_pair(c)
1756 && self.wrap_selection(open, close)
1757 {
1758 return EventState::Consumed;
1759 }
1760
1761 self.sync_insert_session();
1767
1768 let in_insert_session = self.last_insert_session;
1770 let Some((ta, run)) = self.backend.as_textarea_parts_mut() else {
1771 unreachable!("handle_textarea_key called with non-Textarea backend")
1772 };
1773 if let Some(op) = plain_keys::operation(*key) {
1778 let vertical = match op {
1782 plain_keys::Operation::Move {
1783 to: CursorMove::Up,
1784 extend,
1785 } => Some((false, extend)),
1786 plain_keys::Operation::Move {
1787 to: CursorMove::Down,
1788 extend,
1789 } => Some((true, extend)),
1790 _ => None,
1791 };
1792 if vertical.is_none() {
1793 self.view.clear_visual_goal();
1794 }
1795 if let Some(stroke) = stroke {
1801 {
1802 let now = std::time::Instant::now();
1803 let carries_on = if in_insert_session {
1808 run.continues_session(stroke, now)
1809 } else {
1810 run.continues(stroke, now)
1811 };
1812 if carries_on {
1813 ta.continue_group();
1814 }
1815 }
1816 }
1817
1818 let changed = match vertical {
1819 Some((down, extend)) if self.view.move_cursor_visually(ta, down, extend) => false,
1822 _ => plain_keys::apply(op, ta),
1823 };
1824 self.selection = ta.selection_range();
1825 if changed {
1826 self.apply_edit_outcome();
1827 }
1828 }
1829 EventState::Consumed
1833 }
1834
1835 fn handle_mouse(
1837 &mut self,
1838 mouse: &ratatui::crossterm::event::MouseEvent,
1839 tx: &AppTx,
1840 ) -> EventState {
1841 if !self.covers(mouse.column, mouse.row) {
1842 return EventState::NotConsumed;
1843 }
1844 let r = self.rect;
1847 self.interrupt_typing();
1851 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right))
1855 && self.selection.is_none_or(|(start, end)| start == end)
1856 {
1857 self.wants_context_menu = true;
1858 return EventState::Consumed;
1859 }
1860 if !self.backend.is_textarea() {
1864 return EventState::NotConsumed;
1865 }
1866 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
1868 self.copy_selection_to_clipboard(tx);
1869 self.selection = if let Some(ta) = self.backend.as_textarea() {
1870 ta.selection_range()
1871 } else {
1872 None
1873 };
1874 return EventState::Consumed;
1875 }
1876 let Some(ta) = self.backend.as_textarea_mut() else {
1878 unreachable!()
1879 };
1880 match mouse.kind {
1881 MouseEventKind::Down(_) => {
1882 ta.cancel_selection();
1883 let (lrow, lcol) = self
1884 .view
1885 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1886 ta.jump_to(lrow as usize, lcol as usize);
1887 ta.start_selection();
1888 }
1889 MouseEventKind::Drag(_) => {
1890 let (lrow, lcol) = self
1891 .view
1892 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1893 ta.jump_to(lrow as usize, lcol as usize);
1894 }
1895 _ => {}
1900 }
1901 self.selection = ta.selection_range();
1902 EventState::Consumed
1905 }
1906}
1907
1908impl Component for TextEditorComponent {
1913 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
1914 self.maybe_recover_from_dead_nvim();
1915 self.bind_autocomplete_redraw(tx);
1916
1917 match event {
1918 InputEvent::Key(key) => {
1919 let popup_open = self.autocomplete.as_ref().is_some_and(|c| c.is_open());
1927 if popup_open
1928 && let Some(host) = build_editor_host_snapshot(
1929 &self.backend,
1930 self.revs.current(),
1931 self.view.last_cursor_screen,
1932 )
1933 && let Some(controller) = self.autocomplete.as_mut()
1934 {
1935 match controller.handle_key(*key, &host) {
1936 HandleKeyOutcome::Accepted(action) => {
1937 self.interrupt_typing();
1938 if let Some(ta) = self.backend.as_textarea_mut() {
1939 ta.edit(|ta| apply_accept_to_textarea(ta, &action));
1940 self.selection = ta.selection_range();
1941 }
1942 self.apply_edit_outcome();
1943 return EventState::Consumed;
1944 }
1945 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
1946 return EventState::Consumed;
1947 }
1948 HandleKeyOutcome::NotHandled => {}
1949 }
1950 }
1951 if self.dispatch_to_find_bar(key) {
1956 self.interrupt_typing();
1960 return EventState::Consumed;
1961 }
1962 if let Some(outcome) = self.backend.vim_handle_key(key) {
1967 use self::vim::VimKeyOutcome;
1968 if !matches!(outcome, VimKeyOutcome::PassThrough) {
1973 self.interrupt_typing();
1974 }
1975 self.apply_edit_outcome();
1979 match outcome {
1980 VimKeyOutcome::TextMutated => {
1981 self.selection = None;
1984 return EventState::Consumed;
1985 }
1986 VimKeyOutcome::CursorOnly => {
1987 self.selection = self
1992 .backend
1993 .as_textarea()
1994 .and_then(|ta| ta.selection_range());
1995 if self.backend.selection_includes_cursor()
1998 && let Some(((sr, sc), (er, ec))) = self.selection
1999 {
2000 let len = self
2001 .backend
2002 .as_textarea()
2003 .and_then(|ta| ta.row(er))
2004 .map(|l| l.chars().count())
2005 .unwrap_or(ec);
2006 self.selection = Some(((sr, sc), (er, (ec + 1).min(len))));
2007 }
2008 if self.backend.is_visual_line()
2016 && let Some(((sr, _), (er, _))) = self.selection
2017 {
2018 self.selection = Some(((sr, 0), (er, usize::MAX)));
2019 }
2020 self.refresh_autocomplete_if_open();
2021 return EventState::Consumed;
2022 }
2023 VimKeyOutcome::NoOp => return EventState::Consumed,
2024 VimKeyOutcome::PassThrough => { }
2025 VimKeyOutcome::Host(action) => {
2026 use self::vim::VimHostAction;
2027 match action {
2028 VimHostAction::OpenPalette => {
2029 tx.send(AppEvent::ExecuteLeaderAction(
2031 crate::keys::leader::LeaderAction::Palette,
2032 ))
2033 .ok();
2034 }
2035 VimHostAction::OpenSearch { forward: _ } => {
2036 self.open_or_advance_search();
2040 }
2041 VimHostAction::SearchNext => self.search_repeat(false),
2042 VimHostAction::SearchPrev => self.search_repeat(true),
2043 VimHostAction::ClipboardCopy(text) => {
2047 self.selection = None;
2048 crate::components::yank(text, "copied", tx);
2049 }
2050 VimHostAction::ClipboardCut(text) => {
2051 self.selection = None;
2052 crate::components::yank(text, "cut", tx);
2053 }
2054 VimHostAction::ClipboardPaste => {
2060 self.selection = self
2061 .backend
2062 .as_textarea()
2063 .and_then(|ta| ta.selection_range());
2064 self.paste_from_clipboard(tx);
2065 }
2066 }
2067 return EventState::Consumed;
2068 }
2069 }
2070 }
2071 if let Some(state) = self.handle_nvim_key(key, tx) {
2072 return state;
2073 }
2074 let text_rev_before = self.revs.current();
2085 let cursor_before = self.textarea_cursor();
2086 let result = self.handle_textarea_key(key, tx);
2087 let cursor_after = self.textarea_cursor();
2088 if self.revs.current() != text_rev_before {
2089 self.sync_autocomplete();
2090 } else if cursor_before != cursor_after {
2091 self.refresh_autocomplete_if_open();
2092 }
2093 result
2094 }
2095 InputEvent::Mouse(mouse) => {
2096 let text_rev_before = self.revs.current();
2097 let cursor_before = self.textarea_cursor();
2098 let result = self.handle_mouse(mouse, tx);
2099 let cursor_after = self.textarea_cursor();
2100 if self.revs.current() != text_rev_before {
2103 self.sync_autocomplete();
2104 } else if cursor_before != cursor_after {
2105 self.refresh_autocomplete_if_open();
2106 }
2107 let has_sel = self
2125 .backend
2126 .as_textarea()
2127 .and_then(|ta| ta.selection_range())
2128 .is_some_and(|(s, e)| s != e);
2129 self.backend.sync_mouse_selection(has_sel);
2130 result
2131 }
2132 InputEvent::Paste(_) => EventState::NotConsumed,
2135 }
2136 }
2137
2138 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
2139 let bar_rows: u16 = self.search.as_ref().map_or(0, |bar| bar.rows());
2144 let bar_rows = bar_rows.min(rect.height);
2149 let (editor_rect, search_rect) = if bar_rows > 0 {
2150 (
2151 Rect {
2152 height: rect.height - bar_rows,
2153 ..rect
2154 },
2155 Some(Rect {
2156 y: rect.y + rect.height - bar_rows,
2157 height: bar_rows,
2158 ..rect
2159 }),
2160 )
2161 } else {
2162 (rect, None)
2163 };
2164 self.rect = editor_rect;
2167 let selection = match &self.backend {
2172 BackendState::Textarea(_) => match self.search.as_ref() {
2175 Some(bar) => bar.current_match(),
2176 None => self.selection,
2177 },
2178 BackendState::Nvim(nvim) => {
2179 self.nvim_host
2180 .frame_sync(nvim, editor_rect.width, editor_rect.height)
2181 }
2182 };
2183 while let Ok((generation, buf)) = self.full_parse_rx.try_recv() {
2189 self.view.install_full_parse(generation, buf);
2190 }
2191 while let Ok((generation, layout)) = self.layout_rx.try_recv() {
2195 self.view.install_full_layout(generation, layout);
2196 }
2197
2198 let overlay = match (self.search.as_ref(), self.backend.as_textarea()) {
2209 (Some(bar), Some(buf)) => bar.overlay(buf),
2210 _ => find_bar::BarOverlay::default(),
2211 };
2212 let preview = overlay.preview;
2213 let snap = snapshot_from_backend(&self.backend, self.revs.current());
2214 self.revs.adopt(snap.content_revision);
2220 let (view_lines, preview_spans) = match preview {
2224 None => (None, Vec::new()),
2225 Some(p) => (Some(p.lines), p.spans),
2226 };
2227 match &view_lines {
2228 None => self.view.update(&snap, editor_rect),
2229 Some(lines) => {
2230 let rev = preview_revision(snap.content_revision, lines);
2235 let view_snap = EditorSnapshot::borrowed(lines, snap.cursor, rev);
2236 self.view.update(&view_snap, editor_rect);
2237 }
2238 }
2239 if self.revs.needles_stale() {
2243 self.search_needles.clear();
2244 self.revs.disarm_needles();
2245 }
2246 self.view.set_needles(self.search_needles.clone());
2247
2248 let mut overlays: Vec<view::Overlay> = Vec::new();
2251 if let Some(((sr, sc), (er, ec))) = selection {
2252 for row in sr..=er {
2255 let start = if row == sr { sc } else { 0 };
2256 let end = if row == er { ec } else { usize::MAX };
2257 overlays.push(view::Overlay::new(
2258 row,
2259 start,
2260 end,
2261 view::OverlayKind::Selection,
2262 ));
2263 }
2264 }
2265 overlays.extend(preview_spans.iter().map(|p| {
2266 view::Overlay::new(
2267 p.row,
2268 p.start,
2269 p.end,
2270 if p.is_current {
2271 view::OverlayKind::PreviewCurrent
2272 } else {
2273 view::OverlayKind::Preview
2274 },
2275 )
2276 }));
2277 if view_lines.is_none() {
2280 overlays.extend(overlay.matches.iter().map(|&(row, start, end)| {
2281 view::Overlay::new(row, start, end, view::OverlayKind::Match)
2282 }));
2283 }
2284 self.view.set_overlays(overlays);
2285
2286 if let Some(generation) = self.view.take_pending_full_parse() {
2294 let text = match &view_lines {
2306 Some(lines) => crate::ropetext::Text::from(lines.join("\n").as_str()),
2307 None => snap.text.clone(),
2308 };
2309 let tx = self.full_parse_tx.clone();
2310 let redraw = self.redraw_tx.clone();
2311 self.full_parse_task.spawn(async move {
2312 let buf = ParsedBuffer::parse(&text);
2313 let _ = tx.send((generation, buf));
2314 if let Some(redraw) = redraw {
2317 let _ = redraw.send(AppEvent::Redraw);
2318 }
2319 });
2320 }
2321 if let Some(job) = self.view.take_pending_full_layout() {
2326 let tx = self.layout_tx.clone();
2327 let redraw = self.redraw_tx.clone();
2328 self.layout_task.spawn(async move {
2329 let hints = view::row_hints(&job.rendered_cache, &job.gutter_insets);
2330 let layout = crate::ropetext::Layout::compute(
2331 &job.text,
2332 job.width,
2333 crate::ropetext::Metrics::default(),
2334 &hints,
2335 );
2336 let _ = tx.send((job.generation, layout));
2337 if let Some(redraw) = redraw {
2338 let _ = redraw.send(AppEvent::Redraw);
2339 }
2340 });
2341 }
2342 let bar_focused = self.search.is_some() && focused;
2345 let editor_focused = focused && !bar_focused;
2346 use self::view::CursorShape;
2347 let cursor_shape = match self.backend.modal_is_insert() {
2348 None => None, Some(true) => Some(CursorShape::Bar),
2350 Some(false) => Some(CursorShape::Block),
2351 };
2352 self.view
2353 .render(f, editor_rect, theme, editor_focused, cursor_shape);
2354
2355 if self.revs.needles_stale() {
2359 self.search_needles.clear();
2360 self.revs.disarm_needles();
2361 }
2362
2363 if snap.text.len_bytes() == 0 && editor_rect.height > 0 {
2367 let leader = self
2368 .key_bindings
2369 .first_combo_for(&crate::keys::action_shortcuts::ActionShortcuts::Leader)
2370 .unwrap_or_else(|| "leader".to_string());
2371 f.render_widget(
2372 ratatui::widgets::Paragraph::new(format!(
2373 "Type to start · [[ to link · # to tag · {leader} for commands"
2374 ))
2375 .style(
2376 Style::default()
2377 .fg(theme.gray.to_ratatui())
2378 .add_modifier(Modifier::ITALIC),
2379 ),
2380 Rect {
2381 x: editor_rect.x.saturating_add(2),
2382 width: editor_rect.width.saturating_sub(2),
2383 height: 1,
2384 ..editor_rect
2385 },
2386 );
2387 }
2388 if let (Some(state), Some(bar_rect)) = (self.search.as_mut(), search_rect) {
2389 state.render(f, bar_rect, theme, bar_focused);
2390 }
2391
2392 self.poll_autocomplete();
2400 if let (Some(controller), Some(live_anchor)) =
2407 (self.autocomplete.as_mut(), self.view.last_cursor_screen)
2408 {
2409 if let Some(state) = controller.state_mut() {
2410 state.anchor = live_anchor;
2411 }
2412 if let Some(state) = controller.state() {
2413 autocomplete::render(f, state, editor_rect, theme);
2414 }
2415 }
2416 }
2417
2418 fn hint_shortcuts(&self) -> Vec<(String, String)> {
2419 use crate::keys::action_shortcuts::ActionShortcuts;
2420
2421 if let Some(mut label) = self.backend.mode_label() {
2426 if let Some(p) = self.backend.pending_input_hint() {
2427 label = format!("{label} {p}");
2428 }
2429 let mut hints = vec![(String::new(), label)];
2430 hints.extend(
2431 [
2432 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2433 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2434 (ActionShortcuts::FileOperations, "file ops"),
2435 ]
2436 .iter()
2437 .filter_map(|(action, label)| {
2438 self.key_bindings
2439 .first_combo_for(action)
2440 .map(|k| (k, label.to_string()))
2441 }),
2442 );
2443 return hints;
2444 }
2445
2446 let mut hints: Vec<(String, String)> = Vec::new();
2449 match self.follow_target_at_cursor() {
2450 Some(FollowTarget::Link(_)) => {
2451 if let Some(k) = self
2452 .key_bindings
2453 .first_combo_for(&ActionShortcuts::FollowLink)
2454 {
2455 hints.push((k, "follow link".to_string()));
2456 }
2457 }
2458 Some(FollowTarget::Label(_)) => {
2459 if let Some(k) = self
2460 .key_bindings
2461 .first_combo_for(&ActionShortcuts::FollowLink)
2462 {
2463 hints.push((k, "browse tag".to_string()));
2464 }
2465 }
2466 None => {}
2467 }
2468 hints.extend(crate::components::hints::hints_for(
2469 &self.key_bindings,
2470 &[
2471 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2472 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2473 (ActionShortcuts::FileOperations, "file ops"),
2474 (ActionShortcuts::FindInBuffer, "find"),
2475 ],
2476 ));
2477 hints
2478 }
2479}
2480
2481#[cfg(test)]
2482mod tests {
2483 use super::snapshot::EditorMode;
2484 use super::*;
2485 use crate::keys::KeyBindings;
2486
2487 fn make_editor() -> TextEditorComponent {
2488 TextEditorComponent::new(
2489 KeyBindings::empty(),
2490 &crate::settings::AppSettings::default(),
2491 )
2492 }
2493
2494 fn dummy_tx() -> AppTx {
2495 tokio::sync::mpsc::unbounded_channel().0
2496 }
2497
2498 fn get_ta(editor: &mut TextEditorComponent) -> &mut RopeBuffer {
2499 match &mut editor.backend {
2500 BackendState::Textarea(tb) => &mut tb.ta,
2501 _ => panic!("expected Textarea backend"),
2502 }
2503 }
2504
2505 #[test]
2506 fn has_trigger_before_cursor_finds_bracket() {
2507 assert!(has_trigger_before_cursor("hello [[foo", 11));
2508 assert!(has_trigger_before_cursor("[[a b c", 7));
2509 }
2510
2511 #[test]
2512 fn has_trigger_before_cursor_finds_hashtag() {
2513 assert!(has_trigger_before_cursor("text #tag", 9));
2514 }
2515
2516 #[test]
2517 fn has_trigger_before_cursor_no_trigger_bails() {
2518 assert!(!has_trigger_before_cursor("plain prose here", 16));
2519 assert!(!has_trigger_before_cursor("", 0));
2520 }
2521
2522 #[test]
2523 fn has_trigger_before_cursor_handles_multibyte_no_panic() {
2524 let line = "你好世界".to_string() + &"a".repeat(80);
2527 let col = line.chars().count();
2528 assert!(!has_trigger_before_cursor(&line, col));
2529
2530 let with_emoji = "🦀".repeat(20) + "[[note";
2531 let col = with_emoji.chars().count();
2532 assert!(has_trigger_before_cursor(&with_emoji, col));
2533
2534 let accented = "é".repeat(100);
2535 let col = accented.chars().count();
2536 assert!(!has_trigger_before_cursor(&accented, col));
2537 }
2538
2539 #[test]
2540 fn has_trigger_before_cursor_ignores_chars_after_cursor() {
2541 assert!(!has_trigger_before_cursor("foo [[bar", 3));
2543 }
2544
2545 #[test]
2546 fn has_trigger_before_cursor_wikilink_with_spaces() {
2547 assert!(has_trigger_before_cursor("[[my note title", 15));
2550 }
2551
2552 #[test]
2553 fn fresh_editor_is_not_dirty() {
2554 let editor = make_editor();
2555 assert!(!editor.is_dirty());
2556 }
2557
2558 #[test]
2559 fn after_set_text_not_dirty() {
2560 let mut editor = make_editor();
2561 editor.set_text("hello world".to_string());
2562 assert!(!editor.is_dirty());
2563 }
2564
2565 #[test]
2566 fn get_text_returns_loaded_content() {
2567 let mut editor = make_editor();
2568 editor.set_text("line one\nline two".to_string());
2569 assert_eq!(editor.get_text(), "line one\nline two");
2570 }
2571
2572 #[test]
2573 fn mark_saved_clears_dirty() {
2574 let mut editor = make_editor();
2575 editor.set_text("initial".to_string());
2576 let text = editor.get_text();
2577 editor.mark_saved(text.clone() + "x"); assert!(editor.is_dirty());
2579 editor.mark_saved(text); assert!(!editor.is_dirty());
2581 }
2582
2583 #[test]
2584 fn trailing_newline_does_not_cause_false_dirty() {
2585 let mut editor = make_editor();
2586 editor.set_text("content\n".to_string());
2587 assert!(
2588 !editor.is_dirty(),
2589 "trailing newline should not make editor dirty after load"
2590 );
2591 }
2592
2593 #[test]
2594 fn cursor_move_does_not_dirty_buffer() {
2595 let mut editor = make_editor();
2596 editor.set_text("hello world".to_string());
2597 assert!(!editor.is_dirty());
2598 let tx = dummy_tx();
2599 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
2602 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2603 assert!(
2604 !editor.is_dirty(),
2605 "cursor move must not mark the editor as dirty"
2606 );
2607 }
2608
2609 #[test]
2610 fn empty_stack_undo_redo_does_not_dirty_or_bump_revision() {
2611 let mut editor = make_editor();
2615 editor.set_text("foo".to_string());
2616 let rev_before = editor.content_revision();
2617 assert!(!editor.is_dirty());
2618 let tx = dummy_tx();
2619 for key_code in [KeyCode::Char('z'), KeyCode::Char('y')] {
2620 let key = ratatui::crossterm::event::KeyEvent::new(key_code, KeyModifiers::CONTROL);
2621 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2622 }
2623 assert!(
2624 !editor.is_dirty(),
2625 "empty-stack undo/redo must not flip is_dirty"
2626 );
2627 assert_eq!(
2628 editor.content_revision(),
2629 rev_before,
2630 "empty-stack undo/redo must not bump content_revision"
2631 );
2632 }
2633
2634 #[test]
2635 fn fresh_editor_content_revision_is_nonzero() {
2636 let editor = make_editor();
2643 assert!(editor.content_revision().get() >= 1);
2644 }
2645
2646 #[test]
2647 fn mouse_down_clears_selection() {
2648 let mut editor = make_editor();
2649 editor.set_text("hello world".to_string());
2650 let ta = get_ta(&mut editor);
2651 ta.start_selection();
2652 ta.move_cursor(CursorMove::WordForward);
2653 assert!(ta.selection_range().is_some());
2654 ta.cancel_selection();
2655 editor.selection = if let BackendState::Textarea(tb) = &editor.backend {
2656 tb.ta.selection_range()
2657 } else {
2658 None
2659 };
2660 assert!(editor.selection.is_none());
2661 }
2662
2663 #[test]
2664 fn ctrl_c_copies_selected_text() {
2665 let mut editor = make_editor();
2666 editor.set_text("hello world".to_string());
2667 let ta = get_ta(&mut editor);
2668 ta.move_cursor(CursorMove::Head);
2669 ta.start_selection();
2670 ta.move_cursor(CursorMove::WordForward);
2671 let range = ta.selection_range().unwrap();
2672 let ((sr, sc), (er, ec)) = range;
2673 let lines = ta.rows();
2674 let selected = if sr == er {
2675 lines[sr][sc..ec].to_string()
2676 } else {
2677 lines[sr][sc..].to_string()
2678 };
2679 assert_eq!(selected, "hello ");
2680 }
2681
2682 fn select_range(editor: &mut TextEditorComponent, start: (usize, usize), end: (usize, usize)) {
2684 let ta = get_ta(editor);
2685 ta.cancel_selection();
2686 ta.move_cursor(CursorMove::Jump(start.0, start.1));
2687 ta.start_selection();
2688 ta.move_cursor(CursorMove::Jump(end.0, end.1));
2689 assert!(ta.selection_range().is_some());
2690 }
2691
2692 fn send_char(editor: &mut TextEditorComponent, c: char) {
2693 let tx = dummy_tx();
2694 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
2695 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2696 }
2697
2698 #[test]
2699 fn surround_pair_maps_open_and_symmetric_chars() {
2700 assert_eq!(surround_pair('('), Some(("(", ")")));
2701 assert_eq!(surround_pair('['), Some(("[", "]")));
2702 assert_eq!(surround_pair('{'), Some(("{", "}")));
2703 assert_eq!(surround_pair('<'), Some(("<", ">")));
2704 assert_eq!(surround_pair('"'), Some(("\"", "\"")));
2705 assert_eq!(surround_pair('\''), Some(("'", "'")));
2706 assert_eq!(surround_pair('`'), Some(("`", "`")));
2707 assert_eq!(surround_pair('*'), Some(("*", "*")));
2708 assert_eq!(surround_pair('_'), Some(("_", "_")));
2709 assert_eq!(surround_pair('~'), Some(("~", "~")));
2710 assert_eq!(surround_pair(')'), None);
2712 assert_eq!(surround_pair(']'), None);
2713 assert_eq!(surround_pair('}'), None);
2714 assert_eq!(surround_pair('>'), None);
2715 assert_eq!(surround_pair('a'), None);
2716 }
2717
2718 #[test]
2719 fn typing_open_paren_with_selection_wraps_it() {
2720 let mut editor = make_editor();
2721 editor.set_text("hello world".to_string());
2722 select_range(&mut editor, (0, 0), (0, 5)); send_char(&mut editor, '(');
2724 assert_eq!(editor.get_text(), "(hello) world");
2725 assert!(editor.is_dirty(), "wrap must mark the buffer dirty");
2726 }
2727
2728 #[test]
2729 fn wrap_keeps_selection_on_inner_text() {
2730 let mut editor = make_editor();
2731 editor.set_text("hello world".to_string());
2732 select_range(&mut editor, (0, 0), (0, 5));
2733 send_char(&mut editor, '(');
2734 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2736 }
2737
2738 #[test]
2739 fn chained_brackets_build_a_wikilink() {
2740 let mut editor = make_editor();
2741 editor.set_text("my note".to_string());
2742 select_range(&mut editor, (0, 0), (0, 7));
2743 send_char(&mut editor, '[');
2744 send_char(&mut editor, '[');
2745 assert_eq!(editor.get_text(), "[[my note]]");
2746 assert_eq!(editor.selection, Some(((0, 2), (0, 9))));
2747 }
2748
2749 #[test]
2750 fn symmetric_chars_wrap_and_chain() {
2751 let mut editor = make_editor();
2752 editor.set_text("bold".to_string());
2753 select_range(&mut editor, (0, 0), (0, 4));
2754 send_char(&mut editor, '*');
2755 assert_eq!(editor.get_text(), "*bold*");
2756 send_char(&mut editor, '*');
2757 assert_eq!(editor.get_text(), "**bold**");
2758 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2759 }
2760
2761 #[test]
2762 fn closing_char_replaces_selection() {
2763 let mut editor = make_editor();
2764 editor.set_text("hello world".to_string());
2765 select_range(&mut editor, (0, 0), (0, 5));
2766 send_char(&mut editor, ')');
2767 assert_eq!(editor.get_text(), ") world");
2768 }
2769
2770 #[test]
2771 fn open_char_without_selection_inserts_normally() {
2772 let mut editor = make_editor();
2773 editor.set_text("hello".to_string());
2774 let ta = get_ta(&mut editor);
2775 ta.move_cursor(CursorMove::End);
2776 send_char(&mut editor, '(');
2777 assert_eq!(editor.get_text(), "hello(");
2778 }
2779
2780 #[test]
2781 fn wrap_spans_multiline_selection() {
2782 let mut editor = make_editor();
2783 editor.set_text("abc\ndef".to_string());
2784 select_range(&mut editor, (0, 0), (1, 3));
2785 send_char(&mut editor, '(');
2786 assert_eq!(editor.get_text(), "(abc\ndef)");
2787 assert_eq!(editor.selection, Some(((0, 1), (1, 3))));
2789 }
2790
2791 #[test]
2792 fn wrap_handles_multibyte_selection() {
2793 let mut editor = make_editor();
2794 editor.set_text("héllo🦀 x".to_string());
2795 select_range(&mut editor, (0, 0), (0, 6)); send_char(&mut editor, '`');
2797 assert_eq!(editor.get_text(), "`héllo🦀` x");
2798 assert_eq!(editor.selection, Some(((0, 1), (0, 7))));
2799 }
2800
2801 #[test]
2802 fn wrap_with_reversed_selection_direction() {
2803 let mut editor = make_editor();
2805 editor.set_text("hello world".to_string());
2806 select_range(&mut editor, (0, 5), (0, 0));
2807 send_char(&mut editor, '(');
2808 assert_eq!(editor.get_text(), "(hello) world");
2809 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2810 }
2811
2812 #[test]
2813 fn text_action_keeps_selection_on_inner_text() {
2814 let mut editor = make_editor();
2817 editor.set_text("bold word".to_string());
2818 select_range(&mut editor, (0, 0), (0, 4));
2819 editor.apply_text_action(TextAction::Bold);
2820 assert_eq!(editor.get_text(), "**bold** word");
2821 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2822 }
2823
2824 #[test]
2825 fn bold_undo_is_one_step_back_to_original() {
2826 let mut editor = make_editor();
2831 editor.set_text("hello world".to_string());
2832 select_range(&mut editor, (0, 0), (0, 5));
2833 editor.apply_text_action(TextAction::Bold);
2834 assert_eq!(editor.get_text(), "**hello** world");
2835 assert!(get_ta(&mut editor).undo(), "the bold is one entry");
2836 assert_eq!(editor.get_text(), "hello world");
2837 assert!(
2838 !get_ta(&mut editor).undo(),
2839 "and has no second half left to take back"
2840 );
2841 }
2842
2843 #[test]
2844 fn wrap_undo_is_one_step_back_to_original() {
2845 let mut editor = make_editor();
2852 editor.set_text("hello world".to_string());
2853 select_range(&mut editor, (0, 0), (0, 5));
2854 send_char(&mut editor, '(');
2855 assert_eq!(editor.get_text(), "(hello) world");
2856 assert!(get_ta(&mut editor).undo(), "the wrap is one entry");
2857 assert_eq!(editor.get_text(), "hello world");
2858 assert!(
2859 !get_ta(&mut editor).undo(),
2860 "and has no second half left to take back"
2861 );
2862 }
2863
2864 #[test]
2865 fn linkable_url_accepts_supported_schemes() {
2866 assert_eq!(
2867 linkable_url("https://example.com"),
2868 Some("https://example.com")
2869 );
2870 assert_eq!(
2871 linkable_url("http://example.com/path?q=1#frag"),
2872 Some("http://example.com/path?q=1#frag"),
2873 );
2874 assert_eq!(
2875 linkable_url(" https://example.com "),
2876 Some("https://example.com")
2877 );
2878 assert_eq!(
2879 linkable_url("ftp://files.example.com/x"),
2880 Some("ftp://files.example.com/x"),
2881 );
2882 assert_eq!(
2883 linkable_url("ftps://files.example.com/x"),
2884 Some("ftps://files.example.com/x"),
2885 );
2886 assert_eq!(
2887 linkable_url("mailto:user@example.com"),
2888 Some("mailto:user@example.com"),
2889 );
2890 assert_eq!(
2891 linkable_url("mailto:user@example.com?subject=hi"),
2892 Some("mailto:user@example.com?subject=hi"),
2893 );
2894 }
2895
2896 #[test]
2897 fn linkable_url_rejects_other_schemes_and_plain_text() {
2898 assert_eq!(linkable_url("file:///etc/passwd"), None);
2899 assert_eq!(linkable_url("ssh://host"), None);
2900 assert_eq!(linkable_url("javascript:alert(1)"), None);
2901 assert_eq!(linkable_url("example.com"), None);
2902 assert_eq!(linkable_url("not a url"), None);
2903 assert_eq!(linkable_url(""), None);
2904 assert_eq!(linkable_url("https://example.com\nmore"), None);
2905 }
2906
2907 #[test]
2908 fn try_build_markdown_link_wraps_selection_when_clip_is_url() {
2909 assert_eq!(
2910 try_build_markdown_link("https://example.com", Some("click here")).as_deref(),
2911 Some("[click here](https://example.com)"),
2912 );
2913 }
2914
2915 #[test]
2916 fn try_build_markdown_link_trims_url_whitespace() {
2917 assert_eq!(
2918 try_build_markdown_link(" https://example.com\n", Some("link")).as_deref(),
2919 Some("[link](https://example.com)"),
2920 );
2921 }
2922
2923 #[test]
2924 fn try_build_markdown_link_returns_none_when_no_selection() {
2925 assert_eq!(try_build_markdown_link("https://example.com", None), None);
2926 }
2927
2928 #[test]
2929 fn try_build_markdown_link_returns_none_when_not_url() {
2930 assert_eq!(try_build_markdown_link("plain text", Some("sel")), None);
2931 }
2932
2933 #[test]
2934 fn try_build_markdown_link_returns_none_when_selection_empty() {
2935 assert_eq!(
2936 try_build_markdown_link("https://example.com", Some("")),
2937 None
2938 );
2939 }
2940
2941 #[test]
2942 fn try_build_markdown_link_escapes_close_bracket_in_selection() {
2943 assert_eq!(
2944 try_build_markdown_link("https://example.com", Some("a]b")).as_deref(),
2945 Some(r"[a\]b](https://example.com)"),
2946 );
2947 }
2948
2949 #[test]
2950 fn try_build_markdown_link_wraps_ftp_url() {
2951 assert_eq!(
2952 try_build_markdown_link("ftp://files.example.com/x", Some("download")).as_deref(),
2953 Some("[download](ftp://files.example.com/x)"),
2954 );
2955 }
2956
2957 fn key(code: KeyCode, mods: KeyModifiers) -> ratatui::crossterm::event::KeyEvent {
2958 ratatui::crossterm::event::KeyEvent::new(code, mods)
2959 }
2960
2961 #[test]
2963 fn search_needles_clear_on_edit() {
2964 let settings = crate::settings::AppSettings::default();
2965 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2966 ed.set_text("alpha beta".to_string());
2967 ed.set_search_needles(vec!["Alpha".to_string()]);
2968 assert_eq!(ed.search_needles, vec!["alpha"]);
2969 assert!(!ed.revs.needles_stale());
2970
2971 ed.set_text("alpha beta gamma".to_string());
2973 assert!(ed.revs.needles_stale());
2974 }
2975
2976 #[test]
2977 fn jump_to_heading_moves_cursor_to_heading_line() {
2978 let settings = crate::settings::AppSettings::default();
2979 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2980 ed.set_text("intro\n# Top\nbody\n## Sub One\nmore\n".to_string());
2981
2982 ed.jump_to_heading("Sub One");
2983 assert_eq!(ed.view_snapshot().cursor.0, 3);
2984
2985 ed.jump_to_heading("Top");
2986 assert_eq!(ed.view_snapshot().cursor.0, 1);
2987
2988 ed.jump_to_heading("Nope");
2990 assert_eq!(ed.view_snapshot().cursor.0, 1);
2991 }
2992
2993 #[test]
2994 fn open_or_advance_search_opens_find_bar_with_empty_query() {
2995 let mut editor = make_editor();
2996 editor.set_text("hello world".to_string());
2997 editor.open_or_advance_search();
2998 let state = editor.search.as_ref().expect("find bar opened");
2999 assert!(state.input.is_empty());
3000 assert!(matches!(state.status, SearchStatus::Empty));
3001 }
3002
3003 #[test]
3004 fn open_or_advance_search_advances_when_already_open() {
3005 let mut editor = make_editor();
3006 editor.set_text("ab ab ab".to_string());
3007 let tx = dummy_tx();
3008 editor.open_or_advance_search();
3009 editor.handle_input(
3010 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
3011 &tx,
3012 );
3013 editor.handle_input(
3014 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
3015 &tx,
3016 );
3017 editor.open_or_advance_search();
3019 let (_, col) = get_ta(&mut editor).cursor();
3020 assert_eq!(col, 3, "second invocation advances to next match");
3021 }
3022
3023 #[test]
3024 fn typing_in_find_bar_jumps_cursor_to_first_match() {
3025 let mut editor = make_editor();
3026 editor.set_text("foo bar baz".to_string());
3027 let tx = dummy_tx();
3028 editor.open_or_advance_search();
3029 for ch in ['b', 'a', 'r'] {
3030 editor.handle_input(
3031 &InputEvent::Key(key(KeyCode::Char(ch), KeyModifiers::NONE)),
3032 &tx,
3033 );
3034 }
3035 let state = editor.search.as_ref().unwrap();
3036 assert_eq!(state.input.value(), "bar");
3037 assert!(matches!(state.status, SearchStatus::Match));
3038 let (_, col) = get_ta(&mut editor).cursor();
3039 assert_eq!(col, 4, "cursor jumped to start of 'bar'");
3040 }
3041
3042 #[test]
3043 fn enter_in_find_bar_advances_to_next_match() {
3044 let mut editor = make_editor();
3045 editor.set_text("ab ab ab".to_string());
3046 let tx = dummy_tx();
3047 editor.open_or_advance_search();
3048 editor.handle_input(
3049 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
3050 &tx,
3051 );
3052 editor.handle_input(
3053 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
3054 &tx,
3055 );
3056 editor.handle_input(
3058 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3059 &tx,
3060 );
3061 let (_, col) = get_ta(&mut editor).cursor();
3062 assert_eq!(col, 3, "Enter advances to second match");
3063 }
3064
3065 #[test]
3066 fn match_is_highlighted_as_selection_after_search() {
3067 let mut editor = make_editor();
3068 editor.set_text("foo bar baz".to_string());
3069 let tx = dummy_tx();
3070 editor.open_or_advance_search();
3071 for ch in ['b', 'a', 'r'] {
3072 editor.handle_input(
3073 &InputEvent::Key(key(KeyCode::Char(ch), KeyModifiers::NONE)),
3074 &tx,
3075 );
3076 }
3077 assert_eq!(
3080 editor.search.as_ref().unwrap().current_match(),
3081 Some(((0, 4), (0, 7)))
3082 );
3083 }
3084
3085 #[test]
3086 fn no_match_clears_selection() {
3087 let mut editor = make_editor();
3088 editor.set_text("hello".to_string());
3089 let tx = dummy_tx();
3090 editor.open_or_advance_search();
3091 editor.handle_input(
3092 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::NONE)),
3093 &tx,
3094 );
3095 assert_eq!(editor.selection, None);
3096 }
3097
3098 #[test]
3099 fn esc_in_find_bar_clears_selection_highlight() {
3100 let mut editor = make_editor();
3101 editor.set_text("foo bar".to_string());
3102 let tx = dummy_tx();
3103 editor.open_or_advance_search();
3104 editor.handle_input(
3105 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
3106 &tx,
3107 );
3108 editor.handle_input(
3109 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
3110 &tx,
3111 );
3112 editor.handle_input(
3113 &InputEvent::Key(key(KeyCode::Char('r'), KeyModifiers::NONE)),
3114 &tx,
3115 );
3116 assert!(
3117 editor
3118 .search
3119 .as_ref()
3120 .is_some_and(|b| b.current_match().is_some())
3121 );
3122 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3123 assert!(editor.search.is_none());
3125 assert!(editor.selection.is_none());
3126 }
3127
3128 #[test]
3129 fn esc_in_find_bar_closes_it() {
3130 let mut editor = make_editor();
3131 editor.set_text("hello".to_string());
3132 let tx = dummy_tx();
3133 editor.open_or_advance_search();
3134 assert!(editor.search.is_some());
3135 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3136 assert!(editor.search.is_none());
3137 }
3138
3139 #[test]
3140 fn find_bar_consumes_typing_so_editor_text_is_unchanged() {
3141 let mut editor = make_editor();
3142 editor.set_text("hello".to_string());
3143 let tx = dummy_tx();
3144 editor.open_or_advance_search();
3145 editor.handle_input(
3146 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3147 &tx,
3148 );
3149 assert_eq!(editor.get_text(), "hello");
3150 }
3151
3152 #[test]
3153 fn no_match_status_when_query_absent() {
3154 let mut editor = make_editor();
3155 editor.set_text("hello".to_string());
3156 let tx = dummy_tx();
3157 editor.open_or_advance_search();
3158 editor.handle_input(
3159 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::NONE)),
3160 &tx,
3161 );
3162 let state = editor.search.as_ref().unwrap();
3163 assert!(matches!(state.status, SearchStatus::NoMatch));
3164 }
3165
3166 #[test]
3167 fn try_build_markdown_link_wraps_mailto_url() {
3168 assert_eq!(
3169 try_build_markdown_link("mailto:user@example.com", Some("email me")).as_deref(),
3170 Some("[email me](mailto:user@example.com)"),
3171 );
3172 }
3173
3174 #[test]
3175 fn insert_at_cursor_appends_text() {
3176 let mut editor = make_editor();
3177 editor.set_text("hello".to_string());
3178 {
3179 let ta = get_ta(&mut editor);
3180 ta.move_cursor(CursorMove::End);
3181 }
3182 editor.insert_at_cursor(" world", &dummy_tx());
3183 assert_eq!(editor.get_text(), "hello world");
3184 }
3185
3186 #[test]
3187 fn insert_at_cursor_replaces_selection() {
3188 let mut editor = make_editor();
3189 editor.set_text("hello world".to_string());
3190 {
3191 let ta = get_ta(&mut editor);
3192 ta.move_cursor(CursorMove::Head);
3193 ta.start_selection();
3194 ta.move_cursor(CursorMove::WordForward);
3195 }
3196 editor.insert_at_cursor("HEY ", &dummy_tx());
3197 assert_eq!(editor.get_text(), "HEY world");
3198 }
3199
3200 #[test]
3201 fn paste_inserts_text_at_cursor() {
3202 let mut editor = make_editor();
3203 editor.set_text("hello".to_string());
3204 let ta = get_ta(&mut editor);
3205 ta.move_cursor(CursorMove::End);
3206 ta.insert_str(" world");
3207 assert_eq!(editor.get_text(), "hello world");
3208 }
3209
3210 #[test]
3211 fn bold_action_with_no_selection_inserts_pair_and_centers_cursor() {
3212 let mut editor = make_editor();
3213 editor.set_text("hello".to_string());
3214 {
3215 let ta = get_ta(&mut editor);
3216 ta.move_cursor(CursorMove::End);
3217 }
3218 editor.apply_text_action(TextAction::Bold);
3219 assert_eq!(editor.get_text(), "hello****");
3220 let ta = get_ta(&mut editor);
3221 assert_eq!(ta.cursor(), (0, 7));
3222 }
3223
3224 #[test]
3225 fn italic_action_with_no_selection_inserts_single_pair() {
3226 let mut editor = make_editor();
3227 editor.set_text(String::new());
3228 editor.apply_text_action(TextAction::Italic);
3229 assert_eq!(editor.get_text(), "**");
3230 let ta = get_ta(&mut editor);
3231 assert_eq!(ta.cursor(), (0, 1));
3232 }
3233
3234 #[test]
3235 fn strikethrough_action_with_selection_wraps_text() {
3236 let mut editor = make_editor();
3237 editor.set_text("hello world".to_string());
3238 {
3239 let ta = get_ta(&mut editor);
3240 ta.move_cursor(CursorMove::Head);
3241 ta.start_selection();
3242 ta.move_cursor(CursorMove::WordForward);
3243 }
3244 editor.apply_text_action(TextAction::Strikethrough);
3245 assert_eq!(editor.get_text(), "~~hello ~~world");
3246 }
3247
3248 #[test]
3249 fn bold_action_wraps_non_ascii_selection() {
3250 let mut editor = make_editor();
3251 editor.set_text("hello 你好 world".to_string());
3252 {
3253 let ta = get_ta(&mut editor);
3254 ta.move_cursor(CursorMove::Head);
3255 ta.move_cursor(CursorMove::WordForward);
3256 ta.start_selection();
3257 ta.move_cursor(CursorMove::WordForward);
3258 }
3259 editor.apply_text_action(TextAction::Bold);
3260 assert_eq!(editor.get_text(), "hello **你好 **world");
3261 }
3262
3263 #[test]
3264 fn bold_action_wraps_selected_text() {
3265 let mut editor = make_editor();
3266 editor.set_text("foo bar".to_string());
3267 {
3268 let ta = get_ta(&mut editor);
3269 ta.move_cursor(CursorMove::Head);
3270 ta.start_selection();
3271 ta.move_cursor(CursorMove::WordForward);
3272 }
3273 editor.apply_text_action(TextAction::Bold);
3274 assert_eq!(editor.get_text(), "**foo **bar");
3275 }
3276
3277 #[test]
3278 fn indent_no_selection_indents_current_line() {
3279 let mut editor = make_editor();
3280 editor.set_text("foo\nbar".to_string());
3281 {
3282 let ta = get_ta(&mut editor);
3283 ta.move_cursor(CursorMove::Bottom);
3284 }
3285 editor.indent_lines(false);
3286 let lines = get_ta(&mut editor).rows();
3287 assert_eq!(lines[0], "foo");
3288 assert!(lines[1].starts_with(' ') || lines[1].starts_with('\t'));
3289 assert!(lines[1].trim_start() == "bar");
3290 }
3291
3292 #[test]
3293 fn indent_midline_selection_keeps_text_before_and_selection() {
3294 let mut editor = make_editor();
3295 editor.set_text("hello world".to_string());
3296 {
3297 let ta = get_ta(&mut editor);
3298 ta.move_cursor(CursorMove::Jump(0, 6));
3299 ta.start_selection();
3300 ta.move_cursor(CursorMove::End);
3301 }
3302 editor.indent_lines(false);
3303 let ta = get_ta(&mut editor);
3304 assert_eq!(ta.rows()[0].trim_start(), "hello world");
3306 let indent = ta.rows()[0].len() - "hello world".len();
3308 assert_eq!(
3309 ta.selection_range(),
3310 Some(((0, 6 + indent), (0, 11 + indent)))
3311 );
3312 }
3313
3314 #[test]
3315 fn indent_with_selection_indents_all_touched_lines() {
3316 let mut editor = make_editor();
3317 editor.set_text("foo\nbar\nbaz".to_string());
3318 {
3319 let ta = get_ta(&mut editor);
3320 ta.move_cursor(CursorMove::Top);
3321 ta.start_selection();
3322 ta.move_cursor(CursorMove::Down);
3323 ta.move_cursor(CursorMove::End);
3324 }
3325 editor.indent_lines(false);
3326 let lines: Vec<String> = get_ta(&mut editor).rows().to_vec();
3327 assert_eq!(lines[0].trim_start(), "foo");
3328 assert_eq!(lines[1].trim_start(), "bar");
3329 assert_eq!(lines[2], "baz");
3330 assert!(lines[0].len() > 3);
3331 assert!(lines[1].len() > 3);
3332 }
3333
3334 #[test]
3335 fn dedent_removes_leading_indent() {
3336 let mut editor = make_editor();
3337 editor.set_text(" foo\n bar\nbaz".to_string());
3338 let tab_len = get_ta(&mut editor).indent_width() as usize;
3339 {
3340 let ta = get_ta(&mut editor);
3341 ta.move_cursor(CursorMove::Top);
3342 ta.start_selection();
3343 ta.move_cursor(CursorMove::Bottom);
3344 ta.move_cursor(CursorMove::End);
3345 }
3346 editor.indent_lines(true);
3347 let lines: Vec<String> = get_ta(&mut editor).rows().to_vec();
3348 assert_eq!(lines[0], format!("{}foo", " ".repeat(4 - tab_len.min(4))));
3350 assert_eq!(
3352 lines[1],
3353 format!("{}bar", " ".repeat(2usize.saturating_sub(tab_len)))
3354 );
3355 assert_eq!(lines[2], "baz");
3356 }
3357
3358 #[test]
3359 fn dedent_no_leading_whitespace_is_noop_for_that_line() {
3360 let mut editor = make_editor();
3361 editor.set_text("foo".to_string());
3362 editor.indent_lines(true);
3363 assert_eq!(editor.get_text(), "foo");
3364 }
3365
3366 #[test]
3367 fn smart_enter_continues_unordered_list() {
3368 let mut editor = make_editor();
3369 editor.set_text("- foo".to_string());
3370 {
3371 let ta = get_ta(&mut editor);
3372 ta.move_cursor(CursorMove::End);
3373 }
3374 assert!(editor.smart_enter());
3375 assert_eq!(editor.get_text(), "- foo\n- ");
3376 }
3377
3378 #[test]
3379 fn smart_enter_continues_ordered_list_increments() {
3380 let mut editor = make_editor();
3381 editor.set_text("1. foo".to_string());
3382 {
3383 let ta = get_ta(&mut editor);
3384 ta.move_cursor(CursorMove::End);
3385 }
3386 assert!(editor.smart_enter());
3387 assert_eq!(editor.get_text(), "1. foo\n2. ");
3388 }
3389
3390 #[test]
3391 fn smart_enter_on_empty_list_marker_clears_line() {
3392 let mut editor = make_editor();
3393 editor.set_text("- ".to_string());
3394 {
3395 let ta = get_ta(&mut editor);
3396 ta.move_cursor(CursorMove::End);
3397 }
3398 assert!(editor.smart_enter());
3399 assert_eq!(editor.get_text(), "");
3400 }
3401
3402 #[test]
3403 fn smart_enter_preserves_indent() {
3404 let mut editor = make_editor();
3405 editor.set_text(" body".to_string());
3406 {
3407 let ta = get_ta(&mut editor);
3408 ta.move_cursor(CursorMove::End);
3409 }
3410 assert!(editor.smart_enter());
3411 assert_eq!(editor.get_text(), " body\n ");
3412 }
3413
3414 #[test]
3415 fn smart_enter_on_empty_indent_dedents() {
3416 let mut editor = make_editor();
3417 editor.set_text(" ".to_string());
3418 {
3419 let ta = get_ta(&mut editor);
3420 ta.move_cursor(CursorMove::End);
3421 }
3422 let tab_len = get_ta(&mut editor).indent_width() as usize;
3423 assert!(editor.smart_enter());
3424 assert_eq!(
3425 editor.get_text(),
3426 " ".repeat(4usize.saturating_sub(tab_len))
3427 );
3428 }
3429
3430 #[test]
3431 fn smart_enter_no_indent_no_marker_returns_false() {
3432 let mut editor = make_editor();
3433 editor.set_text("plain".to_string());
3434 {
3435 let ta = get_ta(&mut editor);
3436 ta.move_cursor(CursorMove::End);
3437 }
3438 assert!(!editor.smart_enter());
3439 assert_eq!(editor.get_text(), "plain");
3440 }
3441
3442 #[test]
3443 fn smart_enter_mid_line_returns_false() {
3444 let mut editor = make_editor();
3445 editor.set_text("- foo".to_string());
3446 {
3447 let ta = get_ta(&mut editor);
3448 ta.move_cursor(CursorMove::Head);
3449 ta.move_cursor(CursorMove::Forward);
3450 ta.move_cursor(CursorMove::Forward);
3451 }
3452 assert!(!editor.smart_enter());
3453 }
3454
3455 #[test]
3456 fn smart_enter_on_empty_indented_list_marker_dedents_keeping_marker() {
3457 let mut editor = make_editor();
3458 let tab_len = get_ta(&mut editor).indent_width() as usize;
3459 let indent = " ".repeat(tab_len);
3460 editor.set_text(format!("{indent}- "));
3461 {
3462 let ta = get_ta(&mut editor);
3463 ta.move_cursor(CursorMove::End);
3464 }
3465 assert!(editor.smart_enter());
3466 assert_eq!(editor.get_text(), "- ");
3467 }
3468
3469 #[test]
3470 fn smart_enter_on_empty_list_marker_clears_line_after_full_dedent() {
3471 let mut editor = make_editor();
3472 let tab_len = get_ta(&mut editor).indent_width() as usize;
3473 let indent = " ".repeat(tab_len);
3474 editor.set_text(format!("{indent}- "));
3475 {
3476 let ta = get_ta(&mut editor);
3477 ta.move_cursor(CursorMove::End);
3478 }
3479 assert!(editor.smart_enter());
3481 assert_eq!(editor.get_text(), "- ");
3482 {
3485 let ta = get_ta(&mut editor);
3486 ta.move_cursor(CursorMove::End);
3487 }
3488 assert!(editor.smart_enter());
3489 assert_eq!(editor.get_text(), "");
3490 }
3491
3492 #[test]
3493 fn smart_enter_continues_list_with_non_ascii_content() {
3494 let mut editor = make_editor();
3495 editor.set_text("- 你好".to_string());
3496 {
3497 let ta = get_ta(&mut editor);
3498 ta.move_cursor(CursorMove::End);
3499 }
3500 assert!(editor.smart_enter());
3501 assert_eq!(editor.get_text(), "- 你好\n- ");
3502 }
3503
3504 #[test]
3505 fn smart_enter_preserves_tab_indent() {
3506 let mut editor = make_editor();
3507 editor.set_text("\tbody".to_string());
3508 {
3509 let ta = get_ta(&mut editor);
3510 ta.move_cursor(CursorMove::End);
3511 }
3512 assert!(editor.smart_enter());
3513 assert_eq!(editor.get_text(), "\tbody\n\t");
3514 }
3515
3516 #[test]
3517 fn smart_enter_on_tab_only_line_dedents() {
3518 let mut editor = make_editor();
3519 editor.set_text("\t\t".to_string());
3520 {
3521 let ta = get_ta(&mut editor);
3522 ta.move_cursor(CursorMove::End);
3523 }
3524 assert!(editor.smart_enter());
3525 assert_eq!(editor.get_text(), "\t");
3527 }
3528
3529 #[test]
3530 fn smart_enter_continues_indented_list() {
3531 let mut editor = make_editor();
3532 editor.set_text(" - foo".to_string());
3533 {
3534 let ta = get_ta(&mut editor);
3535 ta.move_cursor(CursorMove::End);
3536 }
3537 assert!(editor.smart_enter());
3538 assert_eq!(editor.get_text(), " - foo\n - ");
3539 }
3540
3541 #[test]
3542 fn unsupported_text_action_is_noop() {
3543 let mut editor = make_editor();
3544 editor.set_text("hello".to_string());
3545 editor.apply_text_action(TextAction::Underline);
3546 assert_eq!(editor.get_text(), "hello");
3547 }
3548
3549 #[test]
3550 fn textarea_hint_shortcuts_has_no_mode_indicator() {
3551 let editor = make_editor();
3552 let hints = editor.hint_shortcuts();
3553 assert!(
3555 !hints
3556 .iter()
3557 .any(|(_, label)| label == "NORMAL" || label == "INSERT")
3558 );
3559 }
3560
3561 fn place_cursor_at_col(editor: &mut TextEditorComponent, col: usize) {
3565 let ta = get_ta(editor);
3566 ta.move_cursor(CursorMove::Head);
3567 for _ in 0..col {
3568 ta.move_cursor(CursorMove::Forward);
3569 }
3570 }
3571
3572 #[test]
3573 fn follow_target_at_cursor_returns_label_when_cursor_on_hashtag() {
3574 let mut editor = make_editor();
3575 editor.set_text("see #rust now".to_string());
3576 place_cursor_at_col(&mut editor, 5);
3578 assert_eq!(
3579 editor.follow_target_at_cursor(),
3580 Some(FollowTarget::Label("rust".into())),
3581 );
3582 }
3583
3584 #[test]
3585 fn follow_target_at_cursor_returns_label_at_hash_char() {
3586 let mut editor = make_editor();
3587 editor.set_text("see #rust now".to_string());
3588 place_cursor_at_col(&mut editor, 4);
3590 assert_eq!(
3591 editor.follow_target_at_cursor(),
3592 Some(FollowTarget::Label("rust".into())),
3593 );
3594 }
3595
3596 #[test]
3597 fn follow_target_at_cursor_returns_none_outside_hashtag() {
3598 let mut editor = make_editor();
3599 editor.set_text("see #rust now".to_string());
3600 place_cursor_at_col(&mut editor, 0);
3602 assert_eq!(editor.follow_target_at_cursor(), None);
3603 }
3604
3605 #[test]
3606 fn follow_target_at_cursor_returns_link_for_wikilink() {
3607 let mut editor = make_editor();
3608 editor.set_text("open [[my note]] please".to_string());
3609 place_cursor_at_col(&mut editor, 7);
3611 let result = editor.follow_target_at_cursor();
3612 assert!(
3613 matches!(result, Some(FollowTarget::Link(_))),
3614 "expected Link variant, got {result:?}"
3615 );
3616 }
3617
3618 #[test]
3621 fn follow_target_at_cursor_returns_link_for_markdown_link_with_fragment() {
3622 let line = "[see docs](#section)";
3627 let mut editor = make_editor();
3628 editor.set_text(line.to_string());
3629 let cursor = "[see docs](#sec".chars().count(); place_cursor_at_col(&mut editor, cursor);
3632 let result = editor.follow_target_at_cursor();
3633 assert!(
3634 matches!(result, Some(FollowTarget::Link(_))),
3635 "expected Link variant for markdown link fragment, got {result:?}"
3636 );
3637 }
3638
3639 #[test]
3640 fn vim_normal_i_then_typing_inserts_text() {
3641 let mut settings = crate::settings::AppSettings::default();
3642 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
3643 let mut editor = TextEditorComponent::new(KeyBindings::empty(), &settings);
3644 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3645 editor.handle_input(
3647 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3648 &tx,
3649 );
3650 assert_eq!(editor.get_text(), "");
3651 editor.handle_input(
3653 &InputEvent::Key(key(KeyCode::Char('i'), KeyModifiers::NONE)),
3654 &tx,
3655 );
3656 editor.handle_input(
3657 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3658 &tx,
3659 );
3660 assert_eq!(editor.get_text(), "x");
3661 }
3662
3663 fn open_replace_bar(
3668 editor: &mut TextEditorComponent,
3669 tx: &AppTx,
3670 pattern: &str,
3671 replacement: &str,
3672 ) {
3673 editor.open_or_advance_search();
3674 for c in pattern.chars() {
3675 editor.handle_input(
3676 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3677 tx,
3678 );
3679 }
3680 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), tx);
3681 for c in replacement.chars() {
3682 editor.handle_input(
3683 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3684 tx,
3685 );
3686 }
3687 }
3688
3689 #[test]
3690 fn tab_reveals_the_replace_field_and_then_cycles_focus() {
3691 let mut editor = make_editor();
3692 let tx = dummy_tx();
3693 editor.set_text("todo".to_string());
3694 editor.open_or_advance_search();
3695 assert!(
3696 !editor.search.as_ref().unwrap().is_replacing(),
3697 "a find-only bar must not start with a replace field"
3698 );
3699
3700 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3701 let s = editor.search.as_ref().unwrap();
3702 assert!(s.is_replacing(), "Tab must reveal the replace field");
3703 assert_eq!(s.focus, BarFocus::Find);
3706
3707 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3708 assert_eq!(editor.search.as_ref().unwrap().focus, BarFocus::Replace);
3709 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3710 assert_eq!(editor.search.as_ref().unwrap().focus, BarFocus::Find);
3711 }
3712
3713 #[test]
3714 fn typing_in_the_replace_field_does_not_touch_the_buffer() {
3715 let mut editor = make_editor();
3716 let tx = dummy_tx();
3717 editor.set_text("todo and todo".to_string());
3718 open_replace_bar(&mut editor, &tx, "todo", "done");
3719 assert_eq!(
3720 editor.get_text(),
3721 "todo and todo",
3722 "the preview is a view of the note, never a write to it"
3723 );
3724 }
3725
3726 #[test]
3727 fn enter_replaces_the_current_match_and_advances() {
3728 let mut editor = make_editor();
3729 let tx = dummy_tx();
3730 editor.set_text("todo and todo".to_string());
3731 open_replace_bar(&mut editor, &tx, "todo", "done");
3732 editor.handle_input(
3733 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3734 &tx,
3735 );
3736 assert_eq!(editor.get_text(), "done and todo");
3737 }
3738
3739 #[test]
3740 fn replacing_a_match_that_ends_inside_a_cluster_is_refused_not_corrupted() {
3741 let mut editor = make_editor();
3747 let tx = dummy_tx();
3748 editor.set_text("e\u{301}f".to_string());
3749 open_replace_bar(&mut editor, &tx, "e", "x");
3750 editor.handle_input(
3751 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3752 &tx,
3753 );
3754 assert_eq!(
3755 editor.get_text(),
3756 "e\u{301}f",
3757 "the note is left alone rather than half-rewritten"
3758 );
3759 }
3760
3761 #[test]
3762 fn ctrl_a_replaces_every_match() {
3763 let mut editor = make_editor();
3764 let tx = dummy_tx();
3765 editor.set_text("todo and todo\nmore todo".to_string());
3766 open_replace_bar(&mut editor, &tx, "todo", "done");
3767 editor.handle_input(
3768 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3769 &tx,
3770 );
3771 assert_eq!(editor.get_text(), "done and done\nmore done");
3772 }
3773
3774 #[test]
3775 fn replace_all_keeps_the_reading_position() {
3776 let mut editor = make_editor();
3777 let tx = dummy_tx();
3778 editor.set_text("todo\nxx\ntodo\nyy".to_string());
3779 open_replace_bar(&mut editor, &tx, "todo", "done");
3780 if let Some(ta) = editor.backend.as_textarea_mut() {
3784 ta.move_cursor(CursorMove::Jump(3, 1));
3785 }
3786 editor.handle_input(
3787 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3788 &tx,
3789 );
3790 assert_eq!(editor.get_text(), "done\nxx\ndone\nyy");
3791 let (row, _) = editor.cursor_pos();
3792 assert_eq!(
3793 row, 3,
3794 "replace all must not throw the cursor to the end of the note"
3795 );
3796 }
3797
3798 #[test]
3799 fn an_empty_replacement_arms_before_it_deletes() {
3800 let mut editor = make_editor();
3801 let tx = dummy_tx();
3802 editor.set_text("todo and todo".to_string());
3803 open_replace_bar(&mut editor, &tx, "todo ", "");
3804
3805 let ctrl_a = InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
3806 editor.handle_input(&ctrl_a, &tx);
3807 assert_eq!(
3808 editor.get_text(),
3809 "todo and todo",
3810 "the first Ctrl+A on an empty replacement must arm, not delete"
3811 );
3812 assert!(editor.search.as_ref().unwrap().armed_empty);
3813
3814 editor.handle_input(&ctrl_a, &tx);
3815 assert_eq!(editor.get_text(), "and todo");
3816 }
3817
3818 #[test]
3819 fn esc_disarms_an_empty_replace_all_without_closing_the_bar() {
3820 let mut editor = make_editor();
3821 let tx = dummy_tx();
3822 editor.set_text("todo".to_string());
3823 open_replace_bar(&mut editor, &tx, "todo", "");
3824 editor.handle_input(
3825 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3826 &tx,
3827 );
3828 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3829 let s = editor
3830 .search
3831 .as_ref()
3832 .expect("Esc disarms before it closes");
3833 assert!(!s.armed_empty);
3834 assert_eq!(editor.get_text(), "todo");
3835 }
3836
3837 #[test]
3838 fn one_ctrl_z_undoes_a_whole_replace_all() {
3839 let mut editor = make_editor();
3840 let tx = dummy_tx();
3841 editor.set_text("todo and todo".to_string());
3842 open_replace_bar(&mut editor, &tx, "todo", "done");
3843 editor.handle_input(
3844 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3845 &tx,
3846 );
3847 assert_eq!(editor.get_text(), "done and done");
3848
3849 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3851 editor.handle_input(
3852 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3853 &tx,
3854 );
3855 assert_eq!(
3856 editor.get_text(),
3857 "todo and todo",
3858 "a replace is two history entries and must cost ONE undo — \
3859 popping half leaves the note with a hole in it"
3860 );
3861 }
3862
3863 #[test]
3864 fn one_ctrl_z_undoes_a_single_replace_step() {
3865 let mut editor = make_editor();
3866 let tx = dummy_tx();
3867 editor.set_text("todo and todo".to_string());
3868 open_replace_bar(&mut editor, &tx, "todo", "done");
3869 editor.handle_input(
3870 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3871 &tx,
3872 );
3873 assert_eq!(editor.get_text(), "done and todo");
3874 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3875 editor.handle_input(
3876 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3877 &tx,
3878 );
3879 assert_eq!(editor.get_text(), "todo and todo");
3880 }
3881
3882 #[test]
3883 fn redo_regroups_the_replace() {
3884 let mut editor = make_editor();
3885 let tx = dummy_tx();
3886 editor.set_text("todo".to_string());
3887 open_replace_bar(&mut editor, &tx, "todo", "done");
3888 editor.handle_input(
3889 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3890 &tx,
3891 );
3892 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3893 editor.handle_input(
3894 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3895 &tx,
3896 );
3897 assert_eq!(editor.get_text(), "todo");
3898 editor.handle_input(
3899 &InputEvent::Key(key(KeyCode::Char('y'), KeyModifiers::CONTROL)),
3900 &tx,
3901 );
3902 assert_eq!(
3903 editor.get_text(),
3904 "done",
3905 "one redo must restore the whole replace"
3906 );
3907 }
3908
3909 #[test]
3910 fn smartcase_drives_both_the_count_and_the_replace() {
3911 let mut editor = make_editor();
3912 let tx = dummy_tx();
3913 editor.set_text("todo Todo TODO".to_string());
3914 open_replace_bar(&mut editor, &tx, "todo", "x");
3915 assert_eq!(
3916 editor.search.as_ref().unwrap().match_count,
3917 3,
3918 "an all-lowercase pattern matches any case"
3919 );
3920 editor.handle_input(
3921 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3922 &tx,
3923 );
3924 assert_eq!(editor.get_text(), "x x x");
3925 }
3926
3927 #[test]
3928 fn an_uppercase_pattern_is_case_sensitive() {
3929 let mut editor = make_editor();
3930 let tx = dummy_tx();
3931 editor.set_text("todo Todo TODO".to_string());
3932 open_replace_bar(&mut editor, &tx, "Todo", "x");
3933 assert_eq!(editor.search.as_ref().unwrap().match_count, 1);
3934 editor.handle_input(
3935 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3936 &tx,
3937 );
3938 assert_eq!(editor.get_text(), "todo x TODO");
3939 }
3940
3941 #[test]
3942 fn the_preview_substitutes_lines_without_writing_them() {
3943 let mut editor = make_editor();
3944 let tx = dummy_tx();
3945 editor.set_text("todo and todo".to_string());
3946 open_replace_bar(&mut editor, &tx, "todo", "done");
3947 let preview = editor.replace_preview().expect("a preview must be built");
3948 assert_eq!(preview.lines, vec!["done and done".to_string()]);
3949 assert_eq!(preview.spans.len(), 2);
3950 assert!(
3951 preview.spans.iter().any(|s| s.is_current),
3952 "the match under the cursor must be flagged so Enter's target is visible"
3953 );
3954 assert_eq!(
3955 editor.get_text(),
3956 "todo and todo",
3957 "building a preview must never mutate the buffer"
3958 );
3959 }
3960
3961 #[test]
3966 fn a_deletion_preview_still_marks_the_current_match() {
3967 let mut editor = make_editor();
3968 let tx = dummy_tx();
3969 editor.set_text("todo and todo".to_string());
3970 open_replace_bar(&mut editor, &tx, "todo", "");
3971 let preview = editor.replace_preview().expect("a preview must be built");
3972 assert_eq!(preview.lines, vec![" and ".to_string()]);
3973 let current = preview
3974 .spans
3975 .iter()
3976 .find(|s| s.is_current)
3977 .expect("the current match must stay flagged when it previews as nothing");
3978 assert_eq!(
3979 current.start, current.end,
3980 "an empty replacement previews as a zero-width span — the renderer \
3981 widens it to a caret cell so the marker cannot vanish"
3982 );
3983 }
3984
3985 #[test]
3990 fn a_multi_row_selection_cannot_derail_an_interactive_replace() {
3991 let mut editor = make_editor();
3992 let tx = dummy_tx();
3993 editor.set_text("alpha beta\nxy".to_string());
3994 open_replace_bar(&mut editor, &tx, "beta", "Z");
3995 editor.selection = Some(((0, 6), (1, 1)));
3997 editor.handle_input(
3998 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3999 &tx,
4000 );
4001 assert_eq!(editor.get_text(), "alpha Z\nxy");
4002 }
4003
4004 #[test]
4009 fn deleting_a_match_marks_the_note_dirty() {
4010 let mut editor = make_editor();
4011 let tx = dummy_tx();
4012 editor.set_text("todo and todo".to_string());
4013 editor.mark_saved("todo and todo".to_string());
4014 assert!(!editor.is_dirty());
4015
4016 open_replace_bar(&mut editor, &tx, "todo ", "");
4017 editor.handle_input(
4018 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
4019 &tx,
4020 );
4021 assert_eq!(editor.get_text(), "and todo");
4022 assert!(
4023 editor.is_dirty(),
4024 "a deletion is an edit — if the revision does not move, autosave \
4025 never writes it and the change is silently lost"
4026 );
4027 }
4028
4029 #[test]
4031 fn emptying_the_note_via_replace_all_marks_it_dirty_and_is_undoable() {
4032 let mut editor = make_editor();
4033 let tx = dummy_tx();
4034 editor.set_text("todo".to_string());
4035 editor.mark_saved("todo".to_string());
4036 open_replace_bar(&mut editor, &tx, "todo", "");
4037 let ctrl_a = InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
4038 editor.handle_input(&ctrl_a, &tx); editor.handle_input(&ctrl_a, &tx); assert_eq!(editor.get_text(), "");
4041 assert!(editor.is_dirty());
4042
4043 editor.handle_input(
4044 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4045 &tx,
4046 );
4047 assert_eq!(editor.get_text(), "todo");
4048 }
4049
4050 #[test]
4054 fn ctrl_z_works_without_closing_the_bar_first() {
4055 let mut editor = make_editor();
4056 let tx = dummy_tx();
4057 editor.set_text("todo and todo".to_string());
4058 open_replace_bar(&mut editor, &tx, "todo", "done");
4059 editor.handle_input(
4060 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4061 &tx,
4062 );
4063 assert_eq!(editor.get_text(), "done and done");
4064 editor.handle_input(
4065 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4066 &tx,
4067 );
4068 assert_eq!(editor.get_text(), "todo and todo");
4069 assert!(editor.search.is_some(), "undo must not close the bar");
4070 }
4071
4072 #[test]
4076 fn a_zero_width_match_does_not_over_claim_history_entries() {
4077 let mut editor = make_editor();
4078 let tx = dummy_tx();
4079 editor.set_text("ab".to_string());
4080 open_replace_bar(&mut editor, &tx, r"\b", "|");
4081 editor.handle_input(
4082 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
4083 &tx,
4084 );
4085 assert_eq!(editor.get_text(), "|ab");
4086 editor.handle_input(
4087 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4088 &tx,
4089 );
4090 assert_eq!(
4091 editor.get_text(),
4092 "ab",
4093 "one undo must land exactly on the pre-replace text, not past it"
4094 );
4095 }
4096
4097 #[test]
4101 fn a_note_swap_resets_the_find_bar_and_its_undo_groups() {
4102 let mut editor = make_editor();
4103 let tx = dummy_tx();
4104 editor.set_text("todo".to_string());
4105 open_replace_bar(&mut editor, &tx, "todo", "");
4106 editor.handle_input(
4107 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4108 &tx,
4109 );
4110 assert!(editor.search.as_ref().unwrap().armed_empty);
4111
4112 editor.set_text("todo elsewhere".to_string());
4113 assert!(editor.search.is_none(), "the bar belonged to the old note");
4114 assert!(
4117 !editor.backend.as_textarea_mut().unwrap().undo(),
4118 "the new note's history has nothing to undo"
4119 );
4120 }
4121
4122 #[test]
4128 fn concealed_markdown_still_highlights_what_it_counts() {
4129 let mut editor = make_editor();
4130 let tx = dummy_tx();
4131 editor.set_text("# Heading\n[[note]]".to_string());
4132 editor.open_or_advance_search();
4133 for c in r"\[\[".chars() {
4134 editor.handle_input(
4135 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4136 &tx,
4137 );
4138 }
4139 let state = editor.search.as_ref().unwrap();
4140 assert_eq!(state.match_count, 1, "the `[[` sigil is a real match");
4141 let spans = state
4142 .pattern
4143 .as_ref()
4144 .unwrap()
4145 .match_spans(editor.backend.as_textarea().unwrap().text().lines());
4146 assert_eq!(
4147 spans,
4148 vec![(1, 0, 2)],
4149 "and it must be reported as a paintable span, not silently dropped \
4150 because the rendered row conceals it"
4151 );
4152 }
4153
4154 #[test]
4159 fn paste_goes_into_the_focused_bar_field() {
4160 let mut editor = make_editor();
4161 let tx = dummy_tx();
4162 editor.set_text("todo".to_string());
4163 open_replace_bar(&mut editor, &tx, "todo", "");
4164 editor.paste_text("done", &tx);
4165 assert_eq!(editor.get_text(), "todo", "the buffer is untouched");
4166 assert_eq!(editor.search.as_ref().unwrap().replacement(), "done");
4167 }
4168
4169 #[test]
4170 fn a_multiline_paste_collapses_to_its_first_line() {
4171 let mut editor = make_editor();
4172 let tx = dummy_tx();
4173 editor.set_text("x".to_string());
4174 editor.open_or_advance_search();
4175 editor.paste_text("first\nsecond", &tx);
4176 assert_eq!(editor.search.as_ref().unwrap().input.value(), "first");
4177 }
4178
4179 #[test]
4183 fn the_bar_is_never_an_invisible_modal() {
4184 use ratatui::Terminal;
4185 use ratatui::backend::TestBackend;
4186 let mut editor = make_editor();
4187 editor.set_text("todo".to_string());
4188 let theme = Theme::default();
4189 let mut term = Terminal::new(TestBackend::new(40, 1)).unwrap();
4190 let area = Rect::new(0, 0, 40, 1);
4191 editor.open_or_advance_search();
4192 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4193 let row: String = (0..40)
4194 .filter_map(|x| {
4195 term.backend()
4196 .buffer()
4197 .cell(ratatui::layout::Position::new(x, 0))
4198 .map(|c| c.symbol().to_string())
4199 })
4200 .collect();
4201 assert!(
4202 row.contains("Find:"),
4203 "an open bar must be drawn even when it costs the whole pane, got {row:?}"
4204 );
4205 }
4206
4207 #[test]
4219 fn a_row_far_from_the_cursor_reparses_after_replace_all() {
4220 use ratatui::Terminal;
4221 use ratatui::backend::TestBackend;
4222 let mut editor = make_editor();
4223 let tx = dummy_tx();
4224 let mut lines: Vec<String> = (0..400).map(|i| format!("filler {i}")).collect();
4225 lines[0] = "todo".to_string();
4226 lines[398] = "todo".to_string();
4227 editor.set_text(lines.join("\n"));
4228 let theme = Theme::default();
4229 let mut term = Terminal::new(TestBackend::new(20, 8)).unwrap();
4230 let area = Rect::new(0, 0, 20, 8);
4231 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4232
4233 open_replace_bar(&mut editor, &tx, "todo", "[[x]]");
4234 if let Some(ta) = editor.backend.as_textarea_mut() {
4237 ta.move_cursor(CursorMove::Jump(398, 0));
4238 }
4239 editor.handle_input(
4240 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4241 &tx,
4242 );
4243 if let Some(ta) = editor.backend.as_textarea_mut() {
4246 ta.move_cursor(CursorMove::Jump(1, 0));
4247 }
4248 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4249 let row0: String = (0..20)
4250 .filter_map(|x| {
4251 term.backend()
4252 .buffer()
4253 .cell(ratatui::layout::Position::new(x, 0))
4254 .map(|c| c.symbol().to_string())
4255 })
4256 .collect::<String>()
4257 .trim_end()
4258 .to_string();
4259 assert_eq!(
4260 row0, "x",
4261 "row 0 must render as a parsed wikilink; `[[x]]` would mean it \
4262 kept the parse of the text that was there before the replace"
4263 );
4264 }
4265
4266 #[test]
4270 fn indenting_a_block_undoes_in_one_step() {
4271 let mut editor = make_editor();
4272 let tx = dummy_tx();
4273 editor.set_text("a\nb\nc".to_string());
4274 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
4275 get_ta(&mut editor).start_selection();
4276 get_ta(&mut editor).move_cursor(CursorMove::Jump(2, 1));
4277 editor.indent_lines(false);
4278 assert_eq!(editor.get_text(), " a\n b\n c");
4279
4280 editor.handle_input(
4281 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4282 &tx,
4283 );
4284 assert_eq!(
4285 editor.get_text(),
4286 "a\nb\nc",
4287 "one undo must revert the whole block, not just the last line"
4288 );
4289 }
4290
4291 #[test]
4293 fn pasting_over_a_selection_undoes_in_one_step() {
4294 let mut editor = make_editor();
4295 let tx = dummy_tx();
4296 editor.set_text("hello world".to_string());
4297 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
4298 get_ta(&mut editor).start_selection();
4299 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 5));
4300 editor.paste_text("goodbye", &tx);
4301 assert_eq!(editor.get_text(), "goodbye world");
4302
4303 editor.handle_input(
4304 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4305 &tx,
4306 );
4307 assert_eq!(editor.get_text(), "hello world");
4308 }
4309
4310 #[test]
4314 fn closing_the_bar_clears_a_stale_selection() {
4315 let mut editor = make_editor();
4316 let tx = dummy_tx();
4317 editor.set_text("alpha beta".to_string());
4318 editor.selection = Some(((0, 0), (0, 5)));
4319 editor.open_or_advance_search();
4320 for c in "beta".chars() {
4321 editor.handle_input(
4322 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4323 &tx,
4324 );
4325 }
4326 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4327 assert!(editor.search.is_none());
4328 assert_eq!(
4329 editor.selection, None,
4330 "a selection from before the search must not outlive the bar"
4331 );
4332 }
4333
4334 #[test]
4337 fn undo_inside_the_bar_rederives_the_current_match() {
4338 let mut editor = make_editor();
4339 let tx = dummy_tx();
4340 editor.set_text("foo foo".to_string());
4341 open_replace_bar(&mut editor, &tx, "foo", "xy");
4342 editor.handle_input(
4343 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
4344 &tx,
4345 );
4346 assert_eq!(editor.get_text(), "xy foo");
4347 editor.handle_input(
4348 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4349 &tx,
4350 );
4351 assert_eq!(editor.get_text(), "foo foo");
4352 let current = editor.search.as_ref().unwrap().current_match();
4353 if let Some(((row, start), (_, end))) = current {
4354 let line = &editor.get_text()[..];
4355 let text: String = line
4356 .lines()
4357 .nth(row)
4358 .unwrap()
4359 .chars()
4360 .skip(start)
4361 .take(end - start)
4362 .collect();
4363 assert_eq!(
4364 text, "foo",
4365 "the highlight must sit on a real match, got {text:?}"
4366 );
4367 }
4368 }
4369
4370 #[test]
4374 fn vim_n_highlights_the_match_it_lands_on() {
4375 let mut editor = make_vim_editor();
4376 let tx = dummy_tx();
4377 editor.set_text("lo xx lo".to_string());
4378 editor.open_or_advance_search();
4379 for c in "lo".chars() {
4380 editor.handle_input(
4381 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4382 &tx,
4383 );
4384 }
4385 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4386 editor.handle_input(
4387 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
4388 &tx,
4389 );
4390 assert_eq!(
4391 editor.selection,
4392 Some(((0, 6), (0, 8))),
4393 "`n` must paint the match it jumped to"
4394 );
4395 }
4396
4397 #[test]
4402 fn closing_the_bar_cannot_leave_an_invisible_selection() {
4403 let mut editor = make_editor();
4404 let tx = dummy_tx();
4405 editor.set_text("foo bar baz".to_string());
4406 get_ta(&mut editor).select_all();
4408 editor.open_or_advance_search();
4409 for c in "bar".chars() {
4410 editor.handle_input(
4411 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4412 &tx,
4413 );
4414 }
4415 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4416 editor.handle_input(
4417 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
4418 &tx,
4419 );
4420 assert!(
4421 editor.get_text().contains("foo"),
4422 "typing after the bar closed must not eat unhighlighted text, got {:?}",
4423 editor.get_text()
4424 );
4425 }
4426
4427 #[test]
4430 fn vim_visual_indent_undoes_in_one_step() {
4431 let mut editor = make_vim_editor();
4432 let tx = dummy_tx();
4433 editor.set_text("a\nb\nc".to_string());
4434 for c in ['V', 'j', 'j', '>'] {
4435 editor.handle_input(
4436 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4437 &tx,
4438 );
4439 }
4440 let indented = editor.get_text();
4441 assert_ne!(indented, "a\nb\nc", "`>` must indent the selection");
4442 editor.handle_input(
4443 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4444 &tx,
4445 );
4446 assert_eq!(
4447 editor.get_text(),
4448 "a\nb\nc",
4449 "one `u` must revert the whole indent, not one row"
4450 );
4451 }
4452
4453 #[test]
4457 fn vim_n_cannot_leave_an_invisible_selection() {
4458 let mut editor = make_vim_editor();
4459 let tx = dummy_tx();
4460 editor.set_text("foo bar foo".to_string());
4461 editor.open_or_advance_search();
4462 for c in "foo".chars() {
4463 editor.handle_input(
4464 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4465 &tx,
4466 );
4467 }
4468 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4469 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
4471 get_ta(&mut editor).start_selection();
4472 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 3));
4473 editor.handle_input(
4474 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
4475 &tx,
4476 );
4477 for c in ['i', 'X'] {
4478 editor.handle_input(
4479 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4480 &tx,
4481 );
4482 }
4483 assert!(
4484 editor.get_text().contains("bar"),
4485 "typing after `n` must not eat unhighlighted text, got {:?}",
4486 editor.get_text()
4487 );
4488 }
4489
4490 #[test]
4495 fn overlays_paint_where_the_post_pass_used_to() {
4496 use ratatui::Terminal;
4497 use ratatui::backend::TestBackend;
4498 use ratatui::layout::Position;
4499 let mut editor = make_editor();
4500 editor.set_text("find the needle here\n- [x] done task\n- [ ] open task".to_string());
4501 editor.set_search_needles(vec!["needle".to_string()]);
4502 let theme = Theme::default();
4503 let mut term = Terminal::new(TestBackend::new(40, 6)).unwrap();
4504 let area = Rect::new(0, 0, 40, 6);
4505 term.draw(|f| editor.render(f, area, &theme, false))
4506 .unwrap();
4507 let buf = term.backend().buffer();
4508
4509 let row: String = (0..40)
4510 .filter_map(|x| {
4511 buf.cell(Position::new(x, 0))
4512 .map(|c| c.symbol().to_string())
4513 })
4514 .collect();
4515 let at = row.find("needle").expect("needle is on screen");
4516 let cell = buf.cell(Position::new(at as u16, 0)).unwrap();
4517 assert_eq!(
4518 cell.fg,
4519 theme.color_search_match.to_ratatui(),
4520 "the needle must still be emphasised"
4521 );
4522
4523 let struck = |y: u16| {
4525 (0..40).any(|x| {
4526 buf.cell(Position::new(x, y)).is_some_and(|c| {
4527 c.style()
4528 .add_modifier
4529 .contains(ratatui::style::Modifier::CROSSED_OUT)
4530 })
4531 })
4532 };
4533 assert!(struck(1), "a done task strikes its text");
4534 assert!(!struck(2), "an open task does not");
4535
4536 for y in [1u16, 2] {
4538 assert!(
4539 (0..40).any(|x| buf
4540 .cell(Position::new(x, y))
4541 .is_some_and(|c| c.fg == theme.accent.to_ratatui())),
4542 "row {y} must have an accent-coloured checkbox"
4543 );
4544 }
4545 }
4546
4547 #[test]
4548 fn no_preview_without_a_replace_field() {
4549 let mut editor = make_editor();
4550 let tx = dummy_tx();
4551 editor.set_text("todo".to_string());
4552 editor.open_or_advance_search();
4553 for c in "todo".chars() {
4554 editor.handle_input(
4555 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4556 &tx,
4557 );
4558 }
4559 assert!(
4560 editor.replace_preview().is_none(),
4561 "a find-only bar previews nothing"
4562 );
4563 }
4564
4565 #[test]
4566 fn capture_expansion_is_gated_on_the_pattern_capturing() {
4567 let mut editor = make_editor();
4568 let tx = dummy_tx();
4569 editor.set_text("cost".to_string());
4571 open_replace_bar(&mut editor, &tx, "cost", "$1");
4572 editor.handle_input(
4573 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4574 &tx,
4575 );
4576 assert_eq!(editor.get_text(), "$1");
4577 }
4578
4579 #[test]
4580 fn the_bar_reserves_two_rows_only_while_replacing() {
4581 use ratatui::Terminal;
4582 use ratatui::backend::TestBackend;
4583 let mut editor = make_editor();
4584 let tx = dummy_tx();
4585 editor.set_text("todo".to_string());
4586 let theme = Theme::default();
4587 let mut term = Terminal::new(TestBackend::new(40, 10)).unwrap();
4588 let area = Rect::new(0, 0, 40, 10);
4589
4590 editor.open_or_advance_search();
4591 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4592 assert_eq!(editor.rect.height, 9, "a find-only bar takes one row");
4593
4594 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
4595 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4596 assert_eq!(
4597 editor.rect.height, 8,
4598 "the replace field takes a second row"
4599 );
4600 }
4601
4602 #[test]
4607 fn guu_really_does_undo_in_one_step() {
4608 let mut editor = make_vim_editor();
4609 let tx = dummy_tx();
4610 editor.set_text("Mixed Case Line".to_string());
4611 for c in "guu".chars() {
4612 editor.handle_input(
4613 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4614 &tx,
4615 );
4616 }
4617 assert_eq!(editor.get_text(), "mixed case line");
4618 editor.handle_input(
4619 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4620 &tx,
4621 );
4622 assert_eq!(editor.get_text(), "Mixed Case Line");
4623 }
4624
4625 #[test]
4630 fn vim_u_undoes_a_whole_replace() {
4631 let mut editor = make_vim_editor();
4632 let tx = dummy_tx();
4633 editor.set_text("todo and todo".to_string());
4634 open_replace_bar(&mut editor, &tx, "todo", "done");
4635 editor.handle_input(
4636 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4637 &tx,
4638 );
4639 assert_eq!(editor.get_text(), "done and done");
4640
4641 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4643 editor.handle_input(
4644 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4645 &tx,
4646 );
4647 assert_eq!(editor.get_text(), "todo and todo");
4648 }
4649
4650 fn type_out(editor: &mut TextEditorComponent, tx: &AppTx, text: &str) {
4653 use ratatui::crossterm::event::KeyEvent;
4654 for c in text.chars() {
4655 let code = if c == '\n' {
4656 KeyCode::Enter
4657 } else {
4658 KeyCode::Char(c)
4659 };
4660 editor.handle_textarea_key(&KeyEvent::new(code, KeyModifiers::NONE), tx);
4661 }
4662 }
4663
4664 #[test]
4665 fn undo_takes_back_a_word_not_a_letter() {
4666 let mut editor = make_editor();
4669 let tx = dummy_tx();
4670 editor.set_text(String::new());
4671 type_out(&mut editor, &tx, "hello world");
4672 assert_eq!(editor.get_text(), "hello world");
4673
4674 assert!(get_ta(&mut editor).undo());
4675 assert_eq!(editor.get_text(), "hello ", "the last word goes whole");
4676 assert!(get_ta(&mut editor).undo());
4677 assert_eq!(editor.get_text(), "", "and so does the first");
4678 }
4679
4680 #[test]
4681 fn a_cursor_move_separates_two_runs() {
4682 let mut editor = make_editor();
4683 let tx = dummy_tx();
4684 editor.set_text(String::new());
4685 type_out(&mut editor, &tx, "ab");
4686 arrow(&mut editor, &tx, KeyCode::Home);
4687 type_out(&mut editor, &tx, "cd");
4688 assert_eq!(editor.get_text(), "cdab");
4689
4690 assert!(get_ta(&mut editor).undo());
4691 assert_eq!(
4692 editor.get_text(),
4693 "ab",
4694 "only what was typed after the move comes back off"
4695 );
4696 }
4697
4698 #[test]
4699 fn backspacing_to_fix_a_typo_is_its_own_action() {
4700 use ratatui::crossterm::event::KeyEvent;
4701 let mut editor = make_editor();
4702 let tx = dummy_tx();
4703 editor.set_text(String::new());
4704 type_out(&mut editor, &tx, "helllo");
4705 editor.handle_textarea_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
4706 assert_eq!(editor.get_text(), "helll");
4707
4708 assert!(get_ta(&mut editor).undo());
4709 assert_eq!(
4710 editor.get_text(),
4711 "helllo",
4712 "the delete undoes on its own, without taking the typing with it"
4713 );
4714 }
4715
4716 #[test]
4717 fn an_undo_between_two_runs_separates_them() {
4718 let mut editor = make_editor();
4721 let tx = dummy_tx();
4722 editor.set_text(String::new());
4723 type_out(&mut editor, &tx, "ab");
4724 assert!(get_ta(&mut editor).undo());
4725 assert_eq!(editor.get_text(), "");
4726 type_out(&mut editor, &tx, "cd");
4727 assert_eq!(editor.get_text(), "cd");
4728 assert!(get_ta(&mut editor).undo());
4729 assert_eq!(
4730 editor.get_text(),
4731 "",
4732 "the second run is its own group, not an extension of an undone one"
4733 );
4734 }
4735
4736 #[test]
4737 fn a_save_closes_the_open_group() {
4738 let mut editor = make_editor();
4742 let tx = dummy_tx();
4743 type_out(&mut editor, &tx, "abc");
4744 let saved = editor.get_text();
4745 editor.mark_saved(saved);
4746 type_out(&mut editor, &tx, "def");
4749
4750 assert!(get_ta(&mut editor).undo());
4751 assert_eq!(
4752 editor.get_text(),
4753 "abc",
4754 "one undo lands on what was saved, not before it"
4755 );
4756 }
4757
4758 #[test]
4759 fn a_stale_save_completion_does_not_close_the_group() {
4760 let mut editor = make_editor();
4764 let tx = dummy_tx();
4765 type_out(&mut editor, &tx, "abc");
4766 let stale = NonZeroU64::new(1).expect("nonzero");
4767 editor.mark_saved_at_revision(stale);
4768 type_out(&mut editor, &tx, "def");
4769
4770 assert!(get_ta(&mut editor).undo());
4771 assert_eq!(
4772 editor.get_text(),
4773 "",
4774 "the run carried on across a completion that marked nothing"
4775 );
4776 }
4777
4778 #[test]
4779 fn a_second_vim_insert_session_is_its_own_group() {
4780 use ratatui::crossterm::event::KeyEvent;
4784 let mut editor = make_vim_editor();
4785 let tx = dummy_tx();
4786 editor.set_text(String::new());
4787 let press = |editor: &mut TextEditorComponent, code| {
4788 let _ = editor.handle_input(
4789 &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
4790 &tx,
4791 );
4792 };
4793 press(&mut editor, KeyCode::Char('i'));
4794 for c in "one".chars() {
4795 press(&mut editor, KeyCode::Char(c));
4796 }
4797 press(&mut editor, KeyCode::Esc);
4798 press(&mut editor, KeyCode::Char('i'));
4799 for c in "two".chars() {
4800 press(&mut editor, KeyCode::Char(c));
4801 }
4802 press(&mut editor, KeyCode::Esc);
4803 assert_eq!(editor.get_text(), "ontwoe");
4806
4807 assert!(get_ta(&mut editor).undo());
4808 assert_eq!(
4809 editor.get_text(),
4810 "one",
4811 "`u` takes back the second session only"
4812 );
4813 }
4814
4815 #[test]
4816 fn a_vim_insert_session_undoes_whole() {
4817 use ratatui::crossterm::event::KeyEvent;
4818 let mut editor = make_vim_editor();
4819 let tx = dummy_tx();
4820 editor.set_text(String::new());
4821 let press = |editor: &mut TextEditorComponent, code| {
4823 let _ = editor.handle_input(
4824 &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
4825 &tx,
4826 );
4827 };
4828 press(&mut editor, KeyCode::Char('i'));
4829 for c in "hello world".chars() {
4830 press(&mut editor, KeyCode::Char(c));
4831 }
4832 press(&mut editor, KeyCode::Esc);
4833 assert_eq!(editor.get_text(), "hello world");
4834
4835 assert!(get_ta(&mut editor).undo());
4836 assert_eq!(
4837 editor.get_text(),
4838 "",
4839 "vim's `u` takes back the whole session, word boundaries included"
4840 );
4841 }
4842
4843 fn lay_out(editor: &mut TextEditorComponent, width: u16, height: u16) {
4847 use ratatui::Terminal;
4848 use ratatui::backend::TestBackend;
4849 let theme = Theme::default();
4850 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
4851 let area = Rect::new(0, 0, width, height);
4852 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4853 }
4854
4855 fn arrow(editor: &mut TextEditorComponent, tx: &AppTx, code: KeyCode) {
4856 use ratatui::crossterm::event::KeyEvent;
4857 editor.handle_textarea_key(&KeyEvent::new(code, KeyModifiers::NONE), tx);
4858 }
4859
4860 #[test]
4861 fn down_moves_one_drawn_line_not_one_row() {
4862 let mut editor = make_editor();
4865 let tx = dummy_tx();
4866 editor.set_text(
4867 "aaaa bbbb cccc dddd
4868second row"
4869 .to_string(),
4870 );
4871 lay_out(&mut editor, 6, 10);
4872
4873 get_ta(&mut editor).jump_to(0, 0);
4874 arrow(&mut editor, &tx, KeyCode::Down);
4875 assert_eq!(
4876 get_ta(&mut editor).cursor(),
4877 (0, 5),
4878 "still inside the first row, on its second drawn line"
4879 );
4880 arrow(&mut editor, &tx, KeyCode::Down);
4881 assert_eq!(get_ta(&mut editor).cursor(), (0, 10));
4882 arrow(&mut editor, &tx, KeyCode::Down);
4883 assert_eq!(get_ta(&mut editor).cursor(), (0, 15));
4884 arrow(&mut editor, &tx, KeyCode::Down);
4885 assert_eq!(
4886 get_ta(&mut editor).cursor().0,
4887 1,
4888 "and only the fourth press reaches the next row"
4889 );
4890 }
4891
4892 #[test]
4893 fn up_and_down_are_symmetric_across_a_wrap() {
4894 let mut editor = make_editor();
4895 let tx = dummy_tx();
4896 editor.set_text("aaaa bbbb cccc".to_string());
4897 lay_out(&mut editor, 6, 10);
4898
4899 get_ta(&mut editor).jump_to(0, 0);
4900 arrow(&mut editor, &tx, KeyCode::Down);
4901 let middle = get_ta(&mut editor).cursor();
4902 arrow(&mut editor, &tx, KeyCode::Up);
4903 assert_eq!(get_ta(&mut editor).cursor(), (0, 0));
4904 assert_eq!(middle, (0, 5));
4905 }
4906
4907 #[test]
4908 fn an_arrow_against_a_stale_layout_falls_back_instead_of_panicking() {
4909 let mut editor = make_editor();
4914 let tx = dummy_tx();
4915 editor.set_text("abcd\nefgh".to_string());
4916 lay_out(&mut editor, 20, 10);
4917
4918 get_ta(&mut editor).jump_to(0, 4);
4919 for _ in 0..3 {
4920 get_ta(&mut editor).delete_char();
4921 }
4922 assert_eq!(get_ta(&mut editor).rows(), &["a", "efgh"]);
4923
4924 arrow(&mut editor, &tx, KeyCode::Down);
4926 assert_eq!(get_ta(&mut editor).cursor().0, 1, "still moved down a row");
4927 }
4928
4929 #[test]
4930 fn an_action_between_arrows_forgets_the_goal_cell() {
4931 let mut editor = make_editor();
4937 let tx = dummy_tx();
4938 editor.set_text(
4939 "aaaaaaaa
4940bb
4941cccccccc"
4942 .to_string(),
4943 );
4944 lay_out(&mut editor, 20, 10);
4945
4946 get_ta(&mut editor).jump_to(0, 7);
4947 arrow(&mut editor, &tx, KeyCode::Down);
4948 assert_eq!(
4949 get_ta(&mut editor).cursor(),
4950 (1, 2),
4951 "clamped to the short row"
4952 );
4953
4954 let saved = editor.get_text();
4955 editor.mark_saved(saved);
4956
4957 arrow(&mut editor, &tx, KeyCode::Down);
4958 assert_eq!(
4959 get_ta(&mut editor).cursor(),
4960 (2, 2),
4961 "the goal was forgotten, so the third row keeps the clamped column"
4962 );
4963 }
4964
4965 #[test]
4966 fn a_run_of_arrows_keeps_its_goal_cell() {
4967 let mut editor = make_editor();
4970 let tx = dummy_tx();
4971 editor.set_text(
4972 "aaaaaaaa
4973bb
4974cccccccc"
4975 .to_string(),
4976 );
4977 lay_out(&mut editor, 20, 10);
4978
4979 get_ta(&mut editor).jump_to(0, 7);
4980 arrow(&mut editor, &tx, KeyCode::Down);
4981 assert_eq!(
4982 get_ta(&mut editor).cursor(),
4983 (1, 2),
4984 "clamped to the short row"
4985 );
4986 arrow(&mut editor, &tx, KeyCode::Down);
4987 assert_eq!(
4988 get_ta(&mut editor).cursor(),
4989 (2, 7),
4990 "and back out to the cell the run still wants"
4991 );
4992 }
4993
4994 #[test]
4995 fn another_key_ends_the_run() {
4996 let mut editor = make_editor();
4997 let tx = dummy_tx();
4998 editor.set_text(
4999 "aaaaaaaa
5000bb
5001cccccccc"
5002 .to_string(),
5003 );
5004 lay_out(&mut editor, 20, 10);
5005
5006 get_ta(&mut editor).jump_to(0, 7);
5007 arrow(&mut editor, &tx, KeyCode::Down);
5008 arrow(&mut editor, &tx, KeyCode::Home);
5009 arrow(&mut editor, &tx, KeyCode::Down);
5010 assert_eq!(
5011 get_ta(&mut editor).cursor(),
5012 (2, 0),
5013 "Home set a new goal; the old one is gone"
5014 );
5015 }
5016
5017 #[test]
5018 fn shift_down_extends_by_a_drawn_line() {
5019 let mut editor = make_editor();
5020 let tx = dummy_tx();
5021 editor.set_text("aaaa bbbb cccc".to_string());
5022 lay_out(&mut editor, 6, 10);
5023
5024 get_ta(&mut editor).jump_to(0, 0);
5025 editor.handle_textarea_key(
5026 &ratatui::crossterm::event::KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT),
5027 &tx,
5028 );
5029 assert_eq!(
5030 get_ta(&mut editor).selection_range(),
5031 Some(((0, 0), (0, 5)))
5032 );
5033 }
5034
5035 fn make_vim_editor() -> TextEditorComponent {
5037 let mut settings = crate::settings::AppSettings::default();
5038 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
5039 TextEditorComponent::new(KeyBindings::empty(), &settings)
5040 }
5041
5042 fn vim_mode(editor: &TextEditorComponent) -> EditorMode {
5045 match &editor.backend {
5046 BackendState::Textarea(tb) => match &tb.input {
5047 backend::InputInterpreter::Vim(e) => e.mode().clone(),
5048 _ => panic!("expected Vim input interpreter"),
5049 },
5050 _ => panic!("expected Textarea backend"),
5051 }
5052 }
5053
5054 #[test]
5060 fn vim_visual_paste_url_wraps_whole_selected_word() {
5061 let mut editor = make_vim_editor();
5062 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5063 editor.set_text("hello world".to_string());
5064 editor.handle_input(
5067 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
5068 &tx,
5069 );
5070 editor.handle_input(
5071 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
5072 &tx,
5073 );
5074 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5075 editor.paste_text("https://example.com", &tx);
5076 assert_eq!(
5077 editor.get_text(),
5078 "[hello](https://example.com) world",
5079 "the whole selected word (including the char under the cursor) must be wrapped"
5080 );
5081 }
5082
5083 #[test]
5089 fn vim_visual_bold_wraps_whole_selected_word() {
5090 let mut editor = make_vim_editor();
5091 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5092 editor.set_text("hello world".to_string());
5093 editor.handle_input(
5094 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
5095 &tx,
5096 );
5097 editor.handle_input(
5098 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
5099 &tx,
5100 );
5101 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5102 editor.apply_text_action(TextAction::Bold);
5103 assert_eq!(
5104 editor.get_text(),
5105 "**hello** world",
5106 "the whole selected word (including the char under the cursor) must be wrapped"
5107 );
5108 }
5109
5110 #[test]
5118 fn paste_reports_its_outcome() {
5119 let mut editor = make_editor();
5120 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5121 editor.set_text("x".to_string());
5122 editor.paste_from_clipboard(&tx);
5123 let reported = std::iter::from_fn(|| rx.try_recv().ok()).any(|e| {
5124 matches!(e, AppEvent::FlashMessage(m)
5125 if m == "pasted" || m == "clipboard is empty" || m.starts_with("clipboard: "))
5126 });
5127 assert!(reported, "a paste attempt must always report something");
5128 }
5129
5130 #[test]
5136 fn external_paste_drops_the_selection_and_leaves_visual() {
5137 let mut editor = make_vim_editor();
5138 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5139 editor.set_text("hello world".to_string());
5140 editor.handle_input(
5141 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
5142 &tx,
5143 );
5144 editor.handle_input(
5145 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
5146 &tx,
5147 );
5148 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5149
5150 editor.take_selection_for_external_paste();
5151
5152 assert_eq!(
5153 vim_mode(&editor),
5154 EditorMode::Normal,
5155 "the engine must not keep believing it is in Visual"
5156 );
5157 assert_eq!(
5158 get_ta(&mut editor).selection_range(),
5159 None,
5160 "the selection the incoming content replaces must be gone"
5161 );
5162 assert_eq!(
5163 editor.get_text(),
5164 " world",
5165 "the inclusive visual range is what gets replaced"
5166 );
5167 }
5168
5169 #[test]
5172 fn external_paste_without_a_selection_leaves_the_buffer_alone() {
5173 let mut editor = make_vim_editor();
5174 editor.set_text("hello world".to_string());
5175 editor.take_selection_for_external_paste();
5176 assert_eq!(editor.get_text(), "hello world");
5177 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5178 }
5179
5180 #[test]
5185 fn vim_visual_copy_is_read_only_and_does_not_grow_selection() {
5186 let mut editor = make_vim_editor();
5187 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5188 editor.set_text("hello world".to_string());
5189 editor.handle_input(
5190 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
5191 &tx,
5192 );
5193 editor.handle_input(
5194 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
5195 &tx,
5196 );
5197 let before = get_ta(&mut editor).selection_range();
5198 assert_eq!(before, Some(((0, 0), (0, 4))));
5199 assert_eq!(
5201 editor.inclusive_visual_range(),
5202 Some(((0, 0), (0, 5))),
5203 "copy must read the inclusive range including the cursor char"
5204 );
5205 editor.copy_selection_to_clipboard(&tx);
5207 editor.copy_selection_to_clipboard(&tx);
5208 assert_eq!(
5209 get_ta(&mut editor).selection_range(),
5210 before,
5211 "copy must not move the cursor or grow the live selection"
5212 );
5213 }
5214
5215 #[test]
5223 fn vim_visual_line_selection_stays_full_width_after_cursor_moves_back() {
5224 let mut editor = make_vim_editor();
5225 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5226 editor.set_text("hello world".to_string());
5227 editor.handle_input(
5228 &InputEvent::Key(key(KeyCode::Char('V'), KeyModifiers::NONE)),
5229 &tx,
5230 );
5231 assert_eq!(vim_mode(&editor), EditorMode::VisualLine);
5232 assert_eq!(
5233 editor.selection,
5234 Some(((0, 0), (0, usize::MAX))),
5235 "V must highlight the full line right away"
5236 );
5237 for _ in 0..5 {
5238 editor.handle_input(
5239 &InputEvent::Key(key(KeyCode::Left, KeyModifiers::NONE)),
5240 &tx,
5241 );
5242 }
5243 assert_eq!(
5244 editor.selection,
5245 Some(((0, 0), (0, usize::MAX))),
5246 "moving the cursor back must not shrink the linewise highlight"
5247 );
5248 }
5249
5250 #[test]
5260 fn vim_sync_collapsed_sel_stays_normal() {
5261 let mut editor = make_vim_editor();
5262 editor.set_text("hello world".to_string());
5263
5264 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5266
5267 editor.backend.sync_mouse_selection(false);
5270 assert_eq!(
5271 vim_mode(&editor),
5272 EditorMode::Normal,
5273 "collapsed (bare click) selection must not enter Visual mode"
5274 );
5275 }
5276
5277 #[test]
5279 fn vim_sync_real_sel_enters_visual() {
5280 let mut editor = make_vim_editor();
5281 editor.set_text("hello world".to_string());
5282
5283 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5285
5286 editor.backend.sync_mouse_selection(true);
5288 assert_eq!(
5289 vim_mode(&editor),
5290 EditorMode::Visual,
5291 "real drag selection must enter Visual mode"
5292 );
5293 }
5294
5295 #[test]
5299 fn vim_find_bar_captures_typing_not_cursor() {
5300 let mut editor = make_vim_editor();
5301 editor.set_text("hello world\nsecond line".to_string());
5302 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5303
5304 editor.open_or_advance_search();
5306 assert!(editor.search.is_some(), "find bar must be open");
5307
5308 editor.handle_input(
5310 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
5311 &tx,
5312 );
5313 editor.handle_input(
5314 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
5315 &tx,
5316 );
5317
5318 let q = editor
5322 .search
5323 .as_ref()
5324 .map(|s| s.input.value().to_string())
5325 .unwrap_or_default();
5326 assert_eq!(q, "lo", "find query must capture typed characters");
5327
5328 assert_eq!(
5331 editor.get_text(),
5332 "hello world\nsecond line",
5333 "buffer must not be modified while find bar is open"
5334 );
5335
5336 assert_eq!(
5342 editor.cursor_pos().1,
5343 3,
5344 "cursor must jump to the search match (col 3), not to a vim motion position"
5345 );
5346 }
5347
5348 #[test]
5353 fn vim_search_enter_steps_and_esc_keeps_the_pattern_for_n() {
5354 let mut editor = make_vim_editor();
5355 editor.set_text("lo xx lo yy lo".to_string());
5357 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5358
5359 editor.open_or_advance_search();
5361 assert!(editor.search.is_some(), "find bar must open");
5362
5363 editor.handle_input(
5365 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
5366 &tx,
5367 );
5368 editor.handle_input(
5369 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
5370 &tx,
5371 );
5372
5373 editor.handle_input(
5377 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
5378 &tx,
5379 );
5380 assert!(
5381 editor.search.is_some(),
5382 "find bar stays open on Enter — it steps, it does not confirm"
5383 );
5384 let (_, c1) = editor.cursor_pos();
5385 assert_eq!(c1, 6, "Enter must step to the 2nd 'lo' at col 6");
5386
5387 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
5391 assert!(editor.search.is_none(), "Esc must close the find bar");
5392
5393 editor.handle_input(
5395 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
5396 &tx,
5397 );
5398 let (_, c2) = editor.cursor_pos();
5399 assert_eq!(c2, 12, "'n' must jump to the 3rd 'lo' at col 12");
5400
5401 assert_eq!(editor.get_text(), "lo xx lo yy lo");
5403 }
5404}