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()
2000 && let Some(((sr, sc), (er, ec))) = self.selection
2001 {
2002 let len = self
2003 .backend
2004 .as_textarea()
2005 .and_then(|ta| ta.row(er))
2006 .map(|l| l.chars().count())
2007 .unwrap_or(ec);
2008 self.selection = Some(((sr, sc), (er, (ec + 1).min(len))));
2009 }
2010 self.refresh_autocomplete_if_open();
2011 return EventState::Consumed;
2012 }
2013 VimKeyOutcome::NoOp => return EventState::Consumed,
2014 VimKeyOutcome::PassThrough => { }
2015 VimKeyOutcome::Host(action) => {
2016 use self::vim::VimHostAction;
2017 match action {
2018 VimHostAction::OpenPalette => {
2019 tx.send(AppEvent::ExecuteLeaderAction(
2021 crate::keys::leader::LeaderAction::Palette,
2022 ))
2023 .ok();
2024 }
2025 VimHostAction::OpenSearch { forward: _ } => {
2026 self.open_or_advance_search();
2030 }
2031 VimHostAction::SearchNext => self.search_repeat(false),
2032 VimHostAction::SearchPrev => self.search_repeat(true),
2033 VimHostAction::ClipboardCopy(text) => {
2037 self.selection = None;
2038 crate::components::yank(text, "copied", tx);
2039 }
2040 VimHostAction::ClipboardCut(text) => {
2041 self.selection = None;
2042 crate::components::yank(text, "cut", tx);
2043 }
2044 VimHostAction::ClipboardPaste => {
2050 self.selection = self
2051 .backend
2052 .as_textarea()
2053 .and_then(|ta| ta.selection_range());
2054 self.paste_from_clipboard(tx);
2055 }
2056 }
2057 return EventState::Consumed;
2058 }
2059 }
2060 }
2061 if let Some(state) = self.handle_nvim_key(key, tx) {
2062 return state;
2063 }
2064 let text_rev_before = self.revs.current();
2075 let cursor_before = self.textarea_cursor();
2076 let result = self.handle_textarea_key(key, tx);
2077 let cursor_after = self.textarea_cursor();
2078 if self.revs.current() != text_rev_before {
2079 self.sync_autocomplete();
2080 } else if cursor_before != cursor_after {
2081 self.refresh_autocomplete_if_open();
2082 }
2083 result
2084 }
2085 InputEvent::Mouse(mouse) => {
2086 let text_rev_before = self.revs.current();
2087 let cursor_before = self.textarea_cursor();
2088 let result = self.handle_mouse(mouse, tx);
2089 let cursor_after = self.textarea_cursor();
2090 if self.revs.current() != text_rev_before {
2093 self.sync_autocomplete();
2094 } else if cursor_before != cursor_after {
2095 self.refresh_autocomplete_if_open();
2096 }
2097 let has_sel = self
2115 .backend
2116 .as_textarea()
2117 .and_then(|ta| ta.selection_range())
2118 .is_some_and(|(s, e)| s != e);
2119 self.backend.sync_mouse_selection(has_sel);
2120 result
2121 }
2122 InputEvent::Paste(_) => EventState::NotConsumed,
2125 }
2126 }
2127
2128 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
2129 let bar_rows: u16 = self.search.as_ref().map_or(0, |bar| bar.rows());
2134 let bar_rows = bar_rows.min(rect.height);
2139 let (editor_rect, search_rect) = if bar_rows > 0 {
2140 (
2141 Rect {
2142 height: rect.height - bar_rows,
2143 ..rect
2144 },
2145 Some(Rect {
2146 y: rect.y + rect.height - bar_rows,
2147 height: bar_rows,
2148 ..rect
2149 }),
2150 )
2151 } else {
2152 (rect, None)
2153 };
2154 self.rect = editor_rect;
2157 let selection = match &self.backend {
2162 BackendState::Textarea(_) => match self.search.as_ref() {
2165 Some(bar) => bar.current_match(),
2166 None => self.selection,
2167 },
2168 BackendState::Nvim(nvim) => {
2169 self.nvim_host
2170 .frame_sync(nvim, editor_rect.width, editor_rect.height)
2171 }
2172 };
2173 while let Ok((generation, buf)) = self.full_parse_rx.try_recv() {
2179 self.view.install_full_parse(generation, buf);
2180 }
2181 while let Ok((generation, layout)) = self.layout_rx.try_recv() {
2185 self.view.install_full_layout(generation, layout);
2186 }
2187
2188 let overlay = match (self.search.as_ref(), self.backend.as_textarea()) {
2199 (Some(bar), Some(buf)) => bar.overlay(buf),
2200 _ => find_bar::BarOverlay::default(),
2201 };
2202 let preview = overlay.preview;
2203 let snap = snapshot_from_backend(&self.backend, self.revs.current());
2204 self.revs.adopt(snap.content_revision);
2210 let (view_lines, preview_spans) = match preview {
2214 None => (None, Vec::new()),
2215 Some(p) => (Some(p.lines), p.spans),
2216 };
2217 match &view_lines {
2218 None => self.view.update(&snap, editor_rect),
2219 Some(lines) => {
2220 let rev = preview_revision(snap.content_revision, lines);
2225 let view_snap = EditorSnapshot::borrowed(lines, snap.cursor, rev);
2226 self.view.update(&view_snap, editor_rect);
2227 }
2228 }
2229 if self.revs.needles_stale() {
2233 self.search_needles.clear();
2234 self.revs.disarm_needles();
2235 }
2236 self.view.set_needles(self.search_needles.clone());
2237
2238 let mut overlays: Vec<view::Overlay> = Vec::new();
2241 if let Some(((sr, sc), (er, ec))) = selection {
2242 for row in sr..=er {
2245 let start = if row == sr { sc } else { 0 };
2246 let end = if row == er { ec } else { usize::MAX };
2247 overlays.push(view::Overlay::new(
2248 row,
2249 start,
2250 end,
2251 view::OverlayKind::Selection,
2252 ));
2253 }
2254 }
2255 overlays.extend(preview_spans.iter().map(|p| {
2256 view::Overlay::new(
2257 p.row,
2258 p.start,
2259 p.end,
2260 if p.is_current {
2261 view::OverlayKind::PreviewCurrent
2262 } else {
2263 view::OverlayKind::Preview
2264 },
2265 )
2266 }));
2267 if view_lines.is_none() {
2270 overlays.extend(overlay.matches.iter().map(|&(row, start, end)| {
2271 view::Overlay::new(row, start, end, view::OverlayKind::Match)
2272 }));
2273 }
2274 self.view.set_overlays(overlays);
2275
2276 if let Some(generation) = self.view.take_pending_full_parse() {
2284 let text = match &view_lines {
2296 Some(lines) => crate::ropetext::Text::from(lines.join("\n").as_str()),
2297 None => snap.text.clone(),
2298 };
2299 let tx = self.full_parse_tx.clone();
2300 let redraw = self.redraw_tx.clone();
2301 self.full_parse_task.spawn(async move {
2302 let buf = ParsedBuffer::parse(&text);
2303 let _ = tx.send((generation, buf));
2304 if let Some(redraw) = redraw {
2307 let _ = redraw.send(AppEvent::Redraw);
2308 }
2309 });
2310 }
2311 if let Some(job) = self.view.take_pending_full_layout() {
2316 let tx = self.layout_tx.clone();
2317 let redraw = self.redraw_tx.clone();
2318 self.layout_task.spawn(async move {
2319 let hints = view::row_hints(&job.rendered_cache, &job.gutter_insets);
2320 let layout = crate::ropetext::Layout::compute(
2321 &job.text,
2322 job.width,
2323 crate::ropetext::Metrics::default(),
2324 &hints,
2325 );
2326 let _ = tx.send((job.generation, layout));
2327 if let Some(redraw) = redraw {
2328 let _ = redraw.send(AppEvent::Redraw);
2329 }
2330 });
2331 }
2332 let bar_focused = self.search.is_some() && focused;
2335 let editor_focused = focused && !bar_focused;
2336 use self::view::CursorShape;
2337 let cursor_shape = match self.backend.modal_is_insert() {
2338 None => None, Some(true) => Some(CursorShape::Bar),
2340 Some(false) => Some(CursorShape::Block),
2341 };
2342 self.view
2343 .render(f, editor_rect, theme, editor_focused, cursor_shape);
2344
2345 if self.revs.needles_stale() {
2349 self.search_needles.clear();
2350 self.revs.disarm_needles();
2351 }
2352
2353 if snap.text.len_bytes() == 0 && editor_rect.height > 0 {
2357 let leader = self
2358 .key_bindings
2359 .first_combo_for(&crate::keys::action_shortcuts::ActionShortcuts::Leader)
2360 .unwrap_or_else(|| "leader".to_string());
2361 f.render_widget(
2362 ratatui::widgets::Paragraph::new(format!(
2363 "Type to start · [[ to link · # to tag · {leader} for commands"
2364 ))
2365 .style(
2366 Style::default()
2367 .fg(theme.gray.to_ratatui())
2368 .add_modifier(Modifier::ITALIC),
2369 ),
2370 Rect {
2371 x: editor_rect.x.saturating_add(2),
2372 width: editor_rect.width.saturating_sub(2),
2373 height: 1,
2374 ..editor_rect
2375 },
2376 );
2377 }
2378 if let (Some(state), Some(bar_rect)) = (self.search.as_mut(), search_rect) {
2379 state.render(f, bar_rect, theme, bar_focused);
2380 }
2381
2382 self.poll_autocomplete();
2390 if let (Some(controller), Some(live_anchor)) =
2397 (self.autocomplete.as_mut(), self.view.last_cursor_screen)
2398 {
2399 if let Some(state) = controller.state_mut() {
2400 state.anchor = live_anchor;
2401 }
2402 if let Some(state) = controller.state() {
2403 autocomplete::render(f, state, editor_rect, theme);
2404 }
2405 }
2406 }
2407
2408 fn hint_shortcuts(&self) -> Vec<(String, String)> {
2409 use crate::keys::action_shortcuts::ActionShortcuts;
2410
2411 if let Some(mut label) = self.backend.mode_label() {
2416 if let Some(p) = self.backend.pending_input_hint() {
2417 label = format!("{label} {p}");
2418 }
2419 let mut hints = vec![(String::new(), label)];
2420 hints.extend(
2421 [
2422 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2423 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2424 (ActionShortcuts::FileOperations, "file ops"),
2425 ]
2426 .iter()
2427 .filter_map(|(action, label)| {
2428 self.key_bindings
2429 .first_combo_for(action)
2430 .map(|k| (k, label.to_string()))
2431 }),
2432 );
2433 return hints;
2434 }
2435
2436 let mut hints: Vec<(String, String)> = Vec::new();
2439 match self.follow_target_at_cursor() {
2440 Some(FollowTarget::Link(_)) => {
2441 if let Some(k) = self
2442 .key_bindings
2443 .first_combo_for(&ActionShortcuts::FollowLink)
2444 {
2445 hints.push((k, "follow link".to_string()));
2446 }
2447 }
2448 Some(FollowTarget::Label(_)) => {
2449 if let Some(k) = self
2450 .key_bindings
2451 .first_combo_for(&ActionShortcuts::FollowLink)
2452 {
2453 hints.push((k, "browse tag".to_string()));
2454 }
2455 }
2456 None => {}
2457 }
2458 hints.extend(crate::components::hints::hints_for(
2459 &self.key_bindings,
2460 &[
2461 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2462 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2463 (ActionShortcuts::FileOperations, "file ops"),
2464 (ActionShortcuts::FindInBuffer, "find"),
2465 ],
2466 ));
2467 hints
2468 }
2469}
2470
2471#[cfg(test)]
2472mod tests {
2473 use super::snapshot::EditorMode;
2474 use super::*;
2475 use crate::keys::KeyBindings;
2476
2477 fn make_editor() -> TextEditorComponent {
2478 TextEditorComponent::new(
2479 KeyBindings::empty(),
2480 &crate::settings::AppSettings::default(),
2481 )
2482 }
2483
2484 fn dummy_tx() -> AppTx {
2485 tokio::sync::mpsc::unbounded_channel().0
2486 }
2487
2488 fn get_ta(editor: &mut TextEditorComponent) -> &mut RopeBuffer {
2489 match &mut editor.backend {
2490 BackendState::Textarea(tb) => &mut tb.ta,
2491 _ => panic!("expected Textarea backend"),
2492 }
2493 }
2494
2495 #[test]
2496 fn has_trigger_before_cursor_finds_bracket() {
2497 assert!(has_trigger_before_cursor("hello [[foo", 11));
2498 assert!(has_trigger_before_cursor("[[a b c", 7));
2499 }
2500
2501 #[test]
2502 fn has_trigger_before_cursor_finds_hashtag() {
2503 assert!(has_trigger_before_cursor("text #tag", 9));
2504 }
2505
2506 #[test]
2507 fn has_trigger_before_cursor_no_trigger_bails() {
2508 assert!(!has_trigger_before_cursor("plain prose here", 16));
2509 assert!(!has_trigger_before_cursor("", 0));
2510 }
2511
2512 #[test]
2513 fn has_trigger_before_cursor_handles_multibyte_no_panic() {
2514 let line = "你好世界".to_string() + &"a".repeat(80);
2517 let col = line.chars().count();
2518 assert!(!has_trigger_before_cursor(&line, col));
2519
2520 let with_emoji = "🦀".repeat(20) + "[[note";
2521 let col = with_emoji.chars().count();
2522 assert!(has_trigger_before_cursor(&with_emoji, col));
2523
2524 let accented = "é".repeat(100);
2525 let col = accented.chars().count();
2526 assert!(!has_trigger_before_cursor(&accented, col));
2527 }
2528
2529 #[test]
2530 fn has_trigger_before_cursor_ignores_chars_after_cursor() {
2531 assert!(!has_trigger_before_cursor("foo [[bar", 3));
2533 }
2534
2535 #[test]
2536 fn has_trigger_before_cursor_wikilink_with_spaces() {
2537 assert!(has_trigger_before_cursor("[[my note title", 15));
2540 }
2541
2542 #[test]
2543 fn fresh_editor_is_not_dirty() {
2544 let editor = make_editor();
2545 assert!(!editor.is_dirty());
2546 }
2547
2548 #[test]
2549 fn after_set_text_not_dirty() {
2550 let mut editor = make_editor();
2551 editor.set_text("hello world".to_string());
2552 assert!(!editor.is_dirty());
2553 }
2554
2555 #[test]
2556 fn get_text_returns_loaded_content() {
2557 let mut editor = make_editor();
2558 editor.set_text("line one\nline two".to_string());
2559 assert_eq!(editor.get_text(), "line one\nline two");
2560 }
2561
2562 #[test]
2563 fn mark_saved_clears_dirty() {
2564 let mut editor = make_editor();
2565 editor.set_text("initial".to_string());
2566 let text = editor.get_text();
2567 editor.mark_saved(text.clone() + "x"); assert!(editor.is_dirty());
2569 editor.mark_saved(text); assert!(!editor.is_dirty());
2571 }
2572
2573 #[test]
2574 fn trailing_newline_does_not_cause_false_dirty() {
2575 let mut editor = make_editor();
2576 editor.set_text("content\n".to_string());
2577 assert!(
2578 !editor.is_dirty(),
2579 "trailing newline should not make editor dirty after load"
2580 );
2581 }
2582
2583 #[test]
2584 fn cursor_move_does_not_dirty_buffer() {
2585 let mut editor = make_editor();
2586 editor.set_text("hello world".to_string());
2587 assert!(!editor.is_dirty());
2588 let tx = dummy_tx();
2589 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
2592 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2593 assert!(
2594 !editor.is_dirty(),
2595 "cursor move must not mark the editor as dirty"
2596 );
2597 }
2598
2599 #[test]
2600 fn empty_stack_undo_redo_does_not_dirty_or_bump_revision() {
2601 let mut editor = make_editor();
2605 editor.set_text("foo".to_string());
2606 let rev_before = editor.content_revision();
2607 assert!(!editor.is_dirty());
2608 let tx = dummy_tx();
2609 for key_code in [KeyCode::Char('z'), KeyCode::Char('y')] {
2610 let key = ratatui::crossterm::event::KeyEvent::new(key_code, KeyModifiers::CONTROL);
2611 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2612 }
2613 assert!(
2614 !editor.is_dirty(),
2615 "empty-stack undo/redo must not flip is_dirty"
2616 );
2617 assert_eq!(
2618 editor.content_revision(),
2619 rev_before,
2620 "empty-stack undo/redo must not bump content_revision"
2621 );
2622 }
2623
2624 #[test]
2625 fn fresh_editor_content_revision_is_nonzero() {
2626 let editor = make_editor();
2633 assert!(editor.content_revision().get() >= 1);
2634 }
2635
2636 #[test]
2637 fn mouse_down_clears_selection() {
2638 let mut editor = make_editor();
2639 editor.set_text("hello world".to_string());
2640 let ta = get_ta(&mut editor);
2641 ta.start_selection();
2642 ta.move_cursor(CursorMove::WordForward);
2643 assert!(ta.selection_range().is_some());
2644 ta.cancel_selection();
2645 editor.selection = if let BackendState::Textarea(tb) = &editor.backend {
2646 tb.ta.selection_range()
2647 } else {
2648 None
2649 };
2650 assert!(editor.selection.is_none());
2651 }
2652
2653 #[test]
2654 fn ctrl_c_copies_selected_text() {
2655 let mut editor = make_editor();
2656 editor.set_text("hello world".to_string());
2657 let ta = get_ta(&mut editor);
2658 ta.move_cursor(CursorMove::Head);
2659 ta.start_selection();
2660 ta.move_cursor(CursorMove::WordForward);
2661 let range = ta.selection_range().unwrap();
2662 let ((sr, sc), (er, ec)) = range;
2663 let lines = ta.rows();
2664 let selected = if sr == er {
2665 lines[sr][sc..ec].to_string()
2666 } else {
2667 lines[sr][sc..].to_string()
2668 };
2669 assert_eq!(selected, "hello ");
2670 }
2671
2672 fn select_range(editor: &mut TextEditorComponent, start: (usize, usize), end: (usize, usize)) {
2674 let ta = get_ta(editor);
2675 ta.cancel_selection();
2676 ta.move_cursor(CursorMove::Jump(start.0, start.1));
2677 ta.start_selection();
2678 ta.move_cursor(CursorMove::Jump(end.0, end.1));
2679 assert!(ta.selection_range().is_some());
2680 }
2681
2682 fn send_char(editor: &mut TextEditorComponent, c: char) {
2683 let tx = dummy_tx();
2684 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
2685 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2686 }
2687
2688 #[test]
2689 fn surround_pair_maps_open_and_symmetric_chars() {
2690 assert_eq!(surround_pair('('), Some(("(", ")")));
2691 assert_eq!(surround_pair('['), Some(("[", "]")));
2692 assert_eq!(surround_pair('{'), Some(("{", "}")));
2693 assert_eq!(surround_pair('<'), Some(("<", ">")));
2694 assert_eq!(surround_pair('"'), Some(("\"", "\"")));
2695 assert_eq!(surround_pair('\''), Some(("'", "'")));
2696 assert_eq!(surround_pair('`'), Some(("`", "`")));
2697 assert_eq!(surround_pair('*'), Some(("*", "*")));
2698 assert_eq!(surround_pair('_'), Some(("_", "_")));
2699 assert_eq!(surround_pair('~'), Some(("~", "~")));
2700 assert_eq!(surround_pair(')'), None);
2702 assert_eq!(surround_pair(']'), None);
2703 assert_eq!(surround_pair('}'), None);
2704 assert_eq!(surround_pair('>'), None);
2705 assert_eq!(surround_pair('a'), None);
2706 }
2707
2708 #[test]
2709 fn typing_open_paren_with_selection_wraps_it() {
2710 let mut editor = make_editor();
2711 editor.set_text("hello world".to_string());
2712 select_range(&mut editor, (0, 0), (0, 5)); send_char(&mut editor, '(');
2714 assert_eq!(editor.get_text(), "(hello) world");
2715 assert!(editor.is_dirty(), "wrap must mark the buffer dirty");
2716 }
2717
2718 #[test]
2719 fn wrap_keeps_selection_on_inner_text() {
2720 let mut editor = make_editor();
2721 editor.set_text("hello world".to_string());
2722 select_range(&mut editor, (0, 0), (0, 5));
2723 send_char(&mut editor, '(');
2724 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2726 }
2727
2728 #[test]
2729 fn chained_brackets_build_a_wikilink() {
2730 let mut editor = make_editor();
2731 editor.set_text("my note".to_string());
2732 select_range(&mut editor, (0, 0), (0, 7));
2733 send_char(&mut editor, '[');
2734 send_char(&mut editor, '[');
2735 assert_eq!(editor.get_text(), "[[my note]]");
2736 assert_eq!(editor.selection, Some(((0, 2), (0, 9))));
2737 }
2738
2739 #[test]
2740 fn symmetric_chars_wrap_and_chain() {
2741 let mut editor = make_editor();
2742 editor.set_text("bold".to_string());
2743 select_range(&mut editor, (0, 0), (0, 4));
2744 send_char(&mut editor, '*');
2745 assert_eq!(editor.get_text(), "*bold*");
2746 send_char(&mut editor, '*');
2747 assert_eq!(editor.get_text(), "**bold**");
2748 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2749 }
2750
2751 #[test]
2752 fn closing_char_replaces_selection() {
2753 let mut editor = make_editor();
2754 editor.set_text("hello world".to_string());
2755 select_range(&mut editor, (0, 0), (0, 5));
2756 send_char(&mut editor, ')');
2757 assert_eq!(editor.get_text(), ") world");
2758 }
2759
2760 #[test]
2761 fn open_char_without_selection_inserts_normally() {
2762 let mut editor = make_editor();
2763 editor.set_text("hello".to_string());
2764 let ta = get_ta(&mut editor);
2765 ta.move_cursor(CursorMove::End);
2766 send_char(&mut editor, '(');
2767 assert_eq!(editor.get_text(), "hello(");
2768 }
2769
2770 #[test]
2771 fn wrap_spans_multiline_selection() {
2772 let mut editor = make_editor();
2773 editor.set_text("abc\ndef".to_string());
2774 select_range(&mut editor, (0, 0), (1, 3));
2775 send_char(&mut editor, '(');
2776 assert_eq!(editor.get_text(), "(abc\ndef)");
2777 assert_eq!(editor.selection, Some(((0, 1), (1, 3))));
2779 }
2780
2781 #[test]
2782 fn wrap_handles_multibyte_selection() {
2783 let mut editor = make_editor();
2784 editor.set_text("héllo🦀 x".to_string());
2785 select_range(&mut editor, (0, 0), (0, 6)); send_char(&mut editor, '`');
2787 assert_eq!(editor.get_text(), "`héllo🦀` x");
2788 assert_eq!(editor.selection, Some(((0, 1), (0, 7))));
2789 }
2790
2791 #[test]
2792 fn wrap_with_reversed_selection_direction() {
2793 let mut editor = make_editor();
2795 editor.set_text("hello world".to_string());
2796 select_range(&mut editor, (0, 5), (0, 0));
2797 send_char(&mut editor, '(');
2798 assert_eq!(editor.get_text(), "(hello) world");
2799 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2800 }
2801
2802 #[test]
2803 fn text_action_keeps_selection_on_inner_text() {
2804 let mut editor = make_editor();
2807 editor.set_text("bold word".to_string());
2808 select_range(&mut editor, (0, 0), (0, 4));
2809 editor.apply_text_action(TextAction::Bold);
2810 assert_eq!(editor.get_text(), "**bold** word");
2811 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2812 }
2813
2814 #[test]
2815 fn bold_undo_is_one_step_back_to_original() {
2816 let mut editor = make_editor();
2821 editor.set_text("hello world".to_string());
2822 select_range(&mut editor, (0, 0), (0, 5));
2823 editor.apply_text_action(TextAction::Bold);
2824 assert_eq!(editor.get_text(), "**hello** world");
2825 assert!(get_ta(&mut editor).undo(), "the bold is one entry");
2826 assert_eq!(editor.get_text(), "hello world");
2827 assert!(
2828 !get_ta(&mut editor).undo(),
2829 "and has no second half left to take back"
2830 );
2831 }
2832
2833 #[test]
2834 fn wrap_undo_is_one_step_back_to_original() {
2835 let mut editor = make_editor();
2842 editor.set_text("hello world".to_string());
2843 select_range(&mut editor, (0, 0), (0, 5));
2844 send_char(&mut editor, '(');
2845 assert_eq!(editor.get_text(), "(hello) world");
2846 assert!(get_ta(&mut editor).undo(), "the wrap is one entry");
2847 assert_eq!(editor.get_text(), "hello world");
2848 assert!(
2849 !get_ta(&mut editor).undo(),
2850 "and has no second half left to take back"
2851 );
2852 }
2853
2854 #[test]
2855 fn linkable_url_accepts_supported_schemes() {
2856 assert_eq!(
2857 linkable_url("https://example.com"),
2858 Some("https://example.com")
2859 );
2860 assert_eq!(
2861 linkable_url("http://example.com/path?q=1#frag"),
2862 Some("http://example.com/path?q=1#frag"),
2863 );
2864 assert_eq!(
2865 linkable_url(" https://example.com "),
2866 Some("https://example.com")
2867 );
2868 assert_eq!(
2869 linkable_url("ftp://files.example.com/x"),
2870 Some("ftp://files.example.com/x"),
2871 );
2872 assert_eq!(
2873 linkable_url("ftps://files.example.com/x"),
2874 Some("ftps://files.example.com/x"),
2875 );
2876 assert_eq!(
2877 linkable_url("mailto:user@example.com"),
2878 Some("mailto:user@example.com"),
2879 );
2880 assert_eq!(
2881 linkable_url("mailto:user@example.com?subject=hi"),
2882 Some("mailto:user@example.com?subject=hi"),
2883 );
2884 }
2885
2886 #[test]
2887 fn linkable_url_rejects_other_schemes_and_plain_text() {
2888 assert_eq!(linkable_url("file:///etc/passwd"), None);
2889 assert_eq!(linkable_url("ssh://host"), None);
2890 assert_eq!(linkable_url("javascript:alert(1)"), None);
2891 assert_eq!(linkable_url("example.com"), None);
2892 assert_eq!(linkable_url("not a url"), None);
2893 assert_eq!(linkable_url(""), None);
2894 assert_eq!(linkable_url("https://example.com\nmore"), None);
2895 }
2896
2897 #[test]
2898 fn try_build_markdown_link_wraps_selection_when_clip_is_url() {
2899 assert_eq!(
2900 try_build_markdown_link("https://example.com", Some("click here")).as_deref(),
2901 Some("[click here](https://example.com)"),
2902 );
2903 }
2904
2905 #[test]
2906 fn try_build_markdown_link_trims_url_whitespace() {
2907 assert_eq!(
2908 try_build_markdown_link(" https://example.com\n", Some("link")).as_deref(),
2909 Some("[link](https://example.com)"),
2910 );
2911 }
2912
2913 #[test]
2914 fn try_build_markdown_link_returns_none_when_no_selection() {
2915 assert_eq!(try_build_markdown_link("https://example.com", None), None);
2916 }
2917
2918 #[test]
2919 fn try_build_markdown_link_returns_none_when_not_url() {
2920 assert_eq!(try_build_markdown_link("plain text", Some("sel")), None);
2921 }
2922
2923 #[test]
2924 fn try_build_markdown_link_returns_none_when_selection_empty() {
2925 assert_eq!(
2926 try_build_markdown_link("https://example.com", Some("")),
2927 None
2928 );
2929 }
2930
2931 #[test]
2932 fn try_build_markdown_link_escapes_close_bracket_in_selection() {
2933 assert_eq!(
2934 try_build_markdown_link("https://example.com", Some("a]b")).as_deref(),
2935 Some(r"[a\]b](https://example.com)"),
2936 );
2937 }
2938
2939 #[test]
2940 fn try_build_markdown_link_wraps_ftp_url() {
2941 assert_eq!(
2942 try_build_markdown_link("ftp://files.example.com/x", Some("download")).as_deref(),
2943 Some("[download](ftp://files.example.com/x)"),
2944 );
2945 }
2946
2947 fn key(code: KeyCode, mods: KeyModifiers) -> ratatui::crossterm::event::KeyEvent {
2948 ratatui::crossterm::event::KeyEvent::new(code, mods)
2949 }
2950
2951 #[test]
2953 fn search_needles_clear_on_edit() {
2954 let settings = crate::settings::AppSettings::default();
2955 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2956 ed.set_text("alpha beta".to_string());
2957 ed.set_search_needles(vec!["Alpha".to_string()]);
2958 assert_eq!(ed.search_needles, vec!["alpha"]);
2959 assert!(!ed.revs.needles_stale());
2960
2961 ed.set_text("alpha beta gamma".to_string());
2963 assert!(ed.revs.needles_stale());
2964 }
2965
2966 #[test]
2967 fn jump_to_heading_moves_cursor_to_heading_line() {
2968 let settings = crate::settings::AppSettings::default();
2969 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2970 ed.set_text("intro\n# Top\nbody\n## Sub One\nmore\n".to_string());
2971
2972 ed.jump_to_heading("Sub One");
2973 assert_eq!(ed.view_snapshot().cursor.0, 3);
2974
2975 ed.jump_to_heading("Top");
2976 assert_eq!(ed.view_snapshot().cursor.0, 1);
2977
2978 ed.jump_to_heading("Nope");
2980 assert_eq!(ed.view_snapshot().cursor.0, 1);
2981 }
2982
2983 #[test]
2984 fn open_or_advance_search_opens_find_bar_with_empty_query() {
2985 let mut editor = make_editor();
2986 editor.set_text("hello world".to_string());
2987 editor.open_or_advance_search();
2988 let state = editor.search.as_ref().expect("find bar opened");
2989 assert!(state.input.is_empty());
2990 assert!(matches!(state.status, SearchStatus::Empty));
2991 }
2992
2993 #[test]
2994 fn open_or_advance_search_advances_when_already_open() {
2995 let mut editor = make_editor();
2996 editor.set_text("ab ab ab".to_string());
2997 let tx = dummy_tx();
2998 editor.open_or_advance_search();
2999 editor.handle_input(
3000 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
3001 &tx,
3002 );
3003 editor.handle_input(
3004 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
3005 &tx,
3006 );
3007 editor.open_or_advance_search();
3009 let (_, col) = get_ta(&mut editor).cursor();
3010 assert_eq!(col, 3, "second invocation advances to next match");
3011 }
3012
3013 #[test]
3014 fn typing_in_find_bar_jumps_cursor_to_first_match() {
3015 let mut editor = make_editor();
3016 editor.set_text("foo bar baz".to_string());
3017 let tx = dummy_tx();
3018 editor.open_or_advance_search();
3019 for ch in ['b', 'a', 'r'] {
3020 editor.handle_input(
3021 &InputEvent::Key(key(KeyCode::Char(ch), KeyModifiers::NONE)),
3022 &tx,
3023 );
3024 }
3025 let state = editor.search.as_ref().unwrap();
3026 assert_eq!(state.input.value(), "bar");
3027 assert!(matches!(state.status, SearchStatus::Match));
3028 let (_, col) = get_ta(&mut editor).cursor();
3029 assert_eq!(col, 4, "cursor jumped to start of 'bar'");
3030 }
3031
3032 #[test]
3033 fn enter_in_find_bar_advances_to_next_match() {
3034 let mut editor = make_editor();
3035 editor.set_text("ab ab ab".to_string());
3036 let tx = dummy_tx();
3037 editor.open_or_advance_search();
3038 editor.handle_input(
3039 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
3040 &tx,
3041 );
3042 editor.handle_input(
3043 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
3044 &tx,
3045 );
3046 editor.handle_input(
3048 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3049 &tx,
3050 );
3051 let (_, col) = get_ta(&mut editor).cursor();
3052 assert_eq!(col, 3, "Enter advances to second match");
3053 }
3054
3055 #[test]
3056 fn match_is_highlighted_as_selection_after_search() {
3057 let mut editor = make_editor();
3058 editor.set_text("foo bar baz".to_string());
3059 let tx = dummy_tx();
3060 editor.open_or_advance_search();
3061 for ch in ['b', 'a', 'r'] {
3062 editor.handle_input(
3063 &InputEvent::Key(key(KeyCode::Char(ch), KeyModifiers::NONE)),
3064 &tx,
3065 );
3066 }
3067 assert_eq!(
3070 editor.search.as_ref().unwrap().current_match(),
3071 Some(((0, 4), (0, 7)))
3072 );
3073 }
3074
3075 #[test]
3076 fn no_match_clears_selection() {
3077 let mut editor = make_editor();
3078 editor.set_text("hello".to_string());
3079 let tx = dummy_tx();
3080 editor.open_or_advance_search();
3081 editor.handle_input(
3082 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::NONE)),
3083 &tx,
3084 );
3085 assert_eq!(editor.selection, None);
3086 }
3087
3088 #[test]
3089 fn esc_in_find_bar_clears_selection_highlight() {
3090 let mut editor = make_editor();
3091 editor.set_text("foo bar".to_string());
3092 let tx = dummy_tx();
3093 editor.open_or_advance_search();
3094 editor.handle_input(
3095 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
3096 &tx,
3097 );
3098 editor.handle_input(
3099 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
3100 &tx,
3101 );
3102 editor.handle_input(
3103 &InputEvent::Key(key(KeyCode::Char('r'), KeyModifiers::NONE)),
3104 &tx,
3105 );
3106 assert!(
3107 editor
3108 .search
3109 .as_ref()
3110 .is_some_and(|b| b.current_match().is_some())
3111 );
3112 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3113 assert!(editor.search.is_none());
3115 assert!(editor.selection.is_none());
3116 }
3117
3118 #[test]
3119 fn esc_in_find_bar_closes_it() {
3120 let mut editor = make_editor();
3121 editor.set_text("hello".to_string());
3122 let tx = dummy_tx();
3123 editor.open_or_advance_search();
3124 assert!(editor.search.is_some());
3125 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3126 assert!(editor.search.is_none());
3127 }
3128
3129 #[test]
3130 fn find_bar_consumes_typing_so_editor_text_is_unchanged() {
3131 let mut editor = make_editor();
3132 editor.set_text("hello".to_string());
3133 let tx = dummy_tx();
3134 editor.open_or_advance_search();
3135 editor.handle_input(
3136 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3137 &tx,
3138 );
3139 assert_eq!(editor.get_text(), "hello");
3140 }
3141
3142 #[test]
3143 fn no_match_status_when_query_absent() {
3144 let mut editor = make_editor();
3145 editor.set_text("hello".to_string());
3146 let tx = dummy_tx();
3147 editor.open_or_advance_search();
3148 editor.handle_input(
3149 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::NONE)),
3150 &tx,
3151 );
3152 let state = editor.search.as_ref().unwrap();
3153 assert!(matches!(state.status, SearchStatus::NoMatch));
3154 }
3155
3156 #[test]
3157 fn try_build_markdown_link_wraps_mailto_url() {
3158 assert_eq!(
3159 try_build_markdown_link("mailto:user@example.com", Some("email me")).as_deref(),
3160 Some("[email me](mailto:user@example.com)"),
3161 );
3162 }
3163
3164 #[test]
3165 fn insert_at_cursor_appends_text() {
3166 let mut editor = make_editor();
3167 editor.set_text("hello".to_string());
3168 {
3169 let ta = get_ta(&mut editor);
3170 ta.move_cursor(CursorMove::End);
3171 }
3172 editor.insert_at_cursor(" world", &dummy_tx());
3173 assert_eq!(editor.get_text(), "hello world");
3174 }
3175
3176 #[test]
3177 fn insert_at_cursor_replaces_selection() {
3178 let mut editor = make_editor();
3179 editor.set_text("hello world".to_string());
3180 {
3181 let ta = get_ta(&mut editor);
3182 ta.move_cursor(CursorMove::Head);
3183 ta.start_selection();
3184 ta.move_cursor(CursorMove::WordForward);
3185 }
3186 editor.insert_at_cursor("HEY ", &dummy_tx());
3187 assert_eq!(editor.get_text(), "HEY world");
3188 }
3189
3190 #[test]
3191 fn paste_inserts_text_at_cursor() {
3192 let mut editor = make_editor();
3193 editor.set_text("hello".to_string());
3194 let ta = get_ta(&mut editor);
3195 ta.move_cursor(CursorMove::End);
3196 ta.insert_str(" world");
3197 assert_eq!(editor.get_text(), "hello world");
3198 }
3199
3200 #[test]
3201 fn bold_action_with_no_selection_inserts_pair_and_centers_cursor() {
3202 let mut editor = make_editor();
3203 editor.set_text("hello".to_string());
3204 {
3205 let ta = get_ta(&mut editor);
3206 ta.move_cursor(CursorMove::End);
3207 }
3208 editor.apply_text_action(TextAction::Bold);
3209 assert_eq!(editor.get_text(), "hello****");
3210 let ta = get_ta(&mut editor);
3211 assert_eq!(ta.cursor(), (0, 7));
3212 }
3213
3214 #[test]
3215 fn italic_action_with_no_selection_inserts_single_pair() {
3216 let mut editor = make_editor();
3217 editor.set_text(String::new());
3218 editor.apply_text_action(TextAction::Italic);
3219 assert_eq!(editor.get_text(), "**");
3220 let ta = get_ta(&mut editor);
3221 assert_eq!(ta.cursor(), (0, 1));
3222 }
3223
3224 #[test]
3225 fn strikethrough_action_with_selection_wraps_text() {
3226 let mut editor = make_editor();
3227 editor.set_text("hello world".to_string());
3228 {
3229 let ta = get_ta(&mut editor);
3230 ta.move_cursor(CursorMove::Head);
3231 ta.start_selection();
3232 ta.move_cursor(CursorMove::WordForward);
3233 }
3234 editor.apply_text_action(TextAction::Strikethrough);
3235 assert_eq!(editor.get_text(), "~~hello ~~world");
3236 }
3237
3238 #[test]
3239 fn bold_action_wraps_non_ascii_selection() {
3240 let mut editor = make_editor();
3241 editor.set_text("hello 你好 world".to_string());
3242 {
3243 let ta = get_ta(&mut editor);
3244 ta.move_cursor(CursorMove::Head);
3245 ta.move_cursor(CursorMove::WordForward);
3246 ta.start_selection();
3247 ta.move_cursor(CursorMove::WordForward);
3248 }
3249 editor.apply_text_action(TextAction::Bold);
3250 assert_eq!(editor.get_text(), "hello **你好 **world");
3251 }
3252
3253 #[test]
3254 fn bold_action_wraps_selected_text() {
3255 let mut editor = make_editor();
3256 editor.set_text("foo bar".to_string());
3257 {
3258 let ta = get_ta(&mut editor);
3259 ta.move_cursor(CursorMove::Head);
3260 ta.start_selection();
3261 ta.move_cursor(CursorMove::WordForward);
3262 }
3263 editor.apply_text_action(TextAction::Bold);
3264 assert_eq!(editor.get_text(), "**foo **bar");
3265 }
3266
3267 #[test]
3268 fn indent_no_selection_indents_current_line() {
3269 let mut editor = make_editor();
3270 editor.set_text("foo\nbar".to_string());
3271 {
3272 let ta = get_ta(&mut editor);
3273 ta.move_cursor(CursorMove::Bottom);
3274 }
3275 editor.indent_lines(false);
3276 let lines = get_ta(&mut editor).rows();
3277 assert_eq!(lines[0], "foo");
3278 assert!(lines[1].starts_with(' ') || lines[1].starts_with('\t'));
3279 assert!(lines[1].trim_start() == "bar");
3280 }
3281
3282 #[test]
3283 fn indent_midline_selection_keeps_text_before_and_selection() {
3284 let mut editor = make_editor();
3285 editor.set_text("hello world".to_string());
3286 {
3287 let ta = get_ta(&mut editor);
3288 ta.move_cursor(CursorMove::Jump(0, 6));
3289 ta.start_selection();
3290 ta.move_cursor(CursorMove::End);
3291 }
3292 editor.indent_lines(false);
3293 let ta = get_ta(&mut editor);
3294 assert_eq!(ta.rows()[0].trim_start(), "hello world");
3296 let indent = ta.rows()[0].len() - "hello world".len();
3298 assert_eq!(
3299 ta.selection_range(),
3300 Some(((0, 6 + indent), (0, 11 + indent)))
3301 );
3302 }
3303
3304 #[test]
3305 fn indent_with_selection_indents_all_touched_lines() {
3306 let mut editor = make_editor();
3307 editor.set_text("foo\nbar\nbaz".to_string());
3308 {
3309 let ta = get_ta(&mut editor);
3310 ta.move_cursor(CursorMove::Top);
3311 ta.start_selection();
3312 ta.move_cursor(CursorMove::Down);
3313 ta.move_cursor(CursorMove::End);
3314 }
3315 editor.indent_lines(false);
3316 let lines: Vec<String> = get_ta(&mut editor).rows().to_vec();
3317 assert_eq!(lines[0].trim_start(), "foo");
3318 assert_eq!(lines[1].trim_start(), "bar");
3319 assert_eq!(lines[2], "baz");
3320 assert!(lines[0].len() > 3);
3321 assert!(lines[1].len() > 3);
3322 }
3323
3324 #[test]
3325 fn dedent_removes_leading_indent() {
3326 let mut editor = make_editor();
3327 editor.set_text(" foo\n bar\nbaz".to_string());
3328 let tab_len = get_ta(&mut editor).indent_width() as usize;
3329 {
3330 let ta = get_ta(&mut editor);
3331 ta.move_cursor(CursorMove::Top);
3332 ta.start_selection();
3333 ta.move_cursor(CursorMove::Bottom);
3334 ta.move_cursor(CursorMove::End);
3335 }
3336 editor.indent_lines(true);
3337 let lines: Vec<String> = get_ta(&mut editor).rows().to_vec();
3338 assert_eq!(lines[0], format!("{}foo", " ".repeat(4 - tab_len.min(4))));
3340 assert_eq!(
3342 lines[1],
3343 format!("{}bar", " ".repeat(2usize.saturating_sub(tab_len)))
3344 );
3345 assert_eq!(lines[2], "baz");
3346 }
3347
3348 #[test]
3349 fn dedent_no_leading_whitespace_is_noop_for_that_line() {
3350 let mut editor = make_editor();
3351 editor.set_text("foo".to_string());
3352 editor.indent_lines(true);
3353 assert_eq!(editor.get_text(), "foo");
3354 }
3355
3356 #[test]
3357 fn smart_enter_continues_unordered_list() {
3358 let mut editor = make_editor();
3359 editor.set_text("- foo".to_string());
3360 {
3361 let ta = get_ta(&mut editor);
3362 ta.move_cursor(CursorMove::End);
3363 }
3364 assert!(editor.smart_enter());
3365 assert_eq!(editor.get_text(), "- foo\n- ");
3366 }
3367
3368 #[test]
3369 fn smart_enter_continues_ordered_list_increments() {
3370 let mut editor = make_editor();
3371 editor.set_text("1. foo".to_string());
3372 {
3373 let ta = get_ta(&mut editor);
3374 ta.move_cursor(CursorMove::End);
3375 }
3376 assert!(editor.smart_enter());
3377 assert_eq!(editor.get_text(), "1. foo\n2. ");
3378 }
3379
3380 #[test]
3381 fn smart_enter_on_empty_list_marker_clears_line() {
3382 let mut editor = make_editor();
3383 editor.set_text("- ".to_string());
3384 {
3385 let ta = get_ta(&mut editor);
3386 ta.move_cursor(CursorMove::End);
3387 }
3388 assert!(editor.smart_enter());
3389 assert_eq!(editor.get_text(), "");
3390 }
3391
3392 #[test]
3393 fn smart_enter_preserves_indent() {
3394 let mut editor = make_editor();
3395 editor.set_text(" body".to_string());
3396 {
3397 let ta = get_ta(&mut editor);
3398 ta.move_cursor(CursorMove::End);
3399 }
3400 assert!(editor.smart_enter());
3401 assert_eq!(editor.get_text(), " body\n ");
3402 }
3403
3404 #[test]
3405 fn smart_enter_on_empty_indent_dedents() {
3406 let mut editor = make_editor();
3407 editor.set_text(" ".to_string());
3408 {
3409 let ta = get_ta(&mut editor);
3410 ta.move_cursor(CursorMove::End);
3411 }
3412 let tab_len = get_ta(&mut editor).indent_width() as usize;
3413 assert!(editor.smart_enter());
3414 assert_eq!(
3415 editor.get_text(),
3416 " ".repeat(4usize.saturating_sub(tab_len))
3417 );
3418 }
3419
3420 #[test]
3421 fn smart_enter_no_indent_no_marker_returns_false() {
3422 let mut editor = make_editor();
3423 editor.set_text("plain".to_string());
3424 {
3425 let ta = get_ta(&mut editor);
3426 ta.move_cursor(CursorMove::End);
3427 }
3428 assert!(!editor.smart_enter());
3429 assert_eq!(editor.get_text(), "plain");
3430 }
3431
3432 #[test]
3433 fn smart_enter_mid_line_returns_false() {
3434 let mut editor = make_editor();
3435 editor.set_text("- foo".to_string());
3436 {
3437 let ta = get_ta(&mut editor);
3438 ta.move_cursor(CursorMove::Head);
3439 ta.move_cursor(CursorMove::Forward);
3440 ta.move_cursor(CursorMove::Forward);
3441 }
3442 assert!(!editor.smart_enter());
3443 }
3444
3445 #[test]
3446 fn smart_enter_on_empty_indented_list_marker_dedents_keeping_marker() {
3447 let mut editor = make_editor();
3448 let tab_len = get_ta(&mut editor).indent_width() as usize;
3449 let indent = " ".repeat(tab_len);
3450 editor.set_text(format!("{indent}- "));
3451 {
3452 let ta = get_ta(&mut editor);
3453 ta.move_cursor(CursorMove::End);
3454 }
3455 assert!(editor.smart_enter());
3456 assert_eq!(editor.get_text(), "- ");
3457 }
3458
3459 #[test]
3460 fn smart_enter_on_empty_list_marker_clears_line_after_full_dedent() {
3461 let mut editor = make_editor();
3462 let tab_len = get_ta(&mut editor).indent_width() as usize;
3463 let indent = " ".repeat(tab_len);
3464 editor.set_text(format!("{indent}- "));
3465 {
3466 let ta = get_ta(&mut editor);
3467 ta.move_cursor(CursorMove::End);
3468 }
3469 assert!(editor.smart_enter());
3471 assert_eq!(editor.get_text(), "- ");
3472 {
3475 let ta = get_ta(&mut editor);
3476 ta.move_cursor(CursorMove::End);
3477 }
3478 assert!(editor.smart_enter());
3479 assert_eq!(editor.get_text(), "");
3480 }
3481
3482 #[test]
3483 fn smart_enter_continues_list_with_non_ascii_content() {
3484 let mut editor = make_editor();
3485 editor.set_text("- 你好".to_string());
3486 {
3487 let ta = get_ta(&mut editor);
3488 ta.move_cursor(CursorMove::End);
3489 }
3490 assert!(editor.smart_enter());
3491 assert_eq!(editor.get_text(), "- 你好\n- ");
3492 }
3493
3494 #[test]
3495 fn smart_enter_preserves_tab_indent() {
3496 let mut editor = make_editor();
3497 editor.set_text("\tbody".to_string());
3498 {
3499 let ta = get_ta(&mut editor);
3500 ta.move_cursor(CursorMove::End);
3501 }
3502 assert!(editor.smart_enter());
3503 assert_eq!(editor.get_text(), "\tbody\n\t");
3504 }
3505
3506 #[test]
3507 fn smart_enter_on_tab_only_line_dedents() {
3508 let mut editor = make_editor();
3509 editor.set_text("\t\t".to_string());
3510 {
3511 let ta = get_ta(&mut editor);
3512 ta.move_cursor(CursorMove::End);
3513 }
3514 assert!(editor.smart_enter());
3515 assert_eq!(editor.get_text(), "\t");
3517 }
3518
3519 #[test]
3520 fn smart_enter_continues_indented_list() {
3521 let mut editor = make_editor();
3522 editor.set_text(" - foo".to_string());
3523 {
3524 let ta = get_ta(&mut editor);
3525 ta.move_cursor(CursorMove::End);
3526 }
3527 assert!(editor.smart_enter());
3528 assert_eq!(editor.get_text(), " - foo\n - ");
3529 }
3530
3531 #[test]
3532 fn unsupported_text_action_is_noop() {
3533 let mut editor = make_editor();
3534 editor.set_text("hello".to_string());
3535 editor.apply_text_action(TextAction::Underline);
3536 assert_eq!(editor.get_text(), "hello");
3537 }
3538
3539 #[test]
3540 fn textarea_hint_shortcuts_has_no_mode_indicator() {
3541 let editor = make_editor();
3542 let hints = editor.hint_shortcuts();
3543 assert!(
3545 !hints
3546 .iter()
3547 .any(|(_, label)| label == "NORMAL" || label == "INSERT")
3548 );
3549 }
3550
3551 fn place_cursor_at_col(editor: &mut TextEditorComponent, col: usize) {
3555 let ta = get_ta(editor);
3556 ta.move_cursor(CursorMove::Head);
3557 for _ in 0..col {
3558 ta.move_cursor(CursorMove::Forward);
3559 }
3560 }
3561
3562 #[test]
3563 fn follow_target_at_cursor_returns_label_when_cursor_on_hashtag() {
3564 let mut editor = make_editor();
3565 editor.set_text("see #rust now".to_string());
3566 place_cursor_at_col(&mut editor, 5);
3568 assert_eq!(
3569 editor.follow_target_at_cursor(),
3570 Some(FollowTarget::Label("rust".into())),
3571 );
3572 }
3573
3574 #[test]
3575 fn follow_target_at_cursor_returns_label_at_hash_char() {
3576 let mut editor = make_editor();
3577 editor.set_text("see #rust now".to_string());
3578 place_cursor_at_col(&mut editor, 4);
3580 assert_eq!(
3581 editor.follow_target_at_cursor(),
3582 Some(FollowTarget::Label("rust".into())),
3583 );
3584 }
3585
3586 #[test]
3587 fn follow_target_at_cursor_returns_none_outside_hashtag() {
3588 let mut editor = make_editor();
3589 editor.set_text("see #rust now".to_string());
3590 place_cursor_at_col(&mut editor, 0);
3592 assert_eq!(editor.follow_target_at_cursor(), None);
3593 }
3594
3595 #[test]
3596 fn follow_target_at_cursor_returns_link_for_wikilink() {
3597 let mut editor = make_editor();
3598 editor.set_text("open [[my note]] please".to_string());
3599 place_cursor_at_col(&mut editor, 7);
3601 let result = editor.follow_target_at_cursor();
3602 assert!(
3603 matches!(result, Some(FollowTarget::Link(_))),
3604 "expected Link variant, got {result:?}"
3605 );
3606 }
3607
3608 #[test]
3611 fn follow_target_at_cursor_returns_link_for_markdown_link_with_fragment() {
3612 let line = "[see docs](#section)";
3617 let mut editor = make_editor();
3618 editor.set_text(line.to_string());
3619 let cursor = "[see docs](#sec".chars().count(); place_cursor_at_col(&mut editor, cursor);
3622 let result = editor.follow_target_at_cursor();
3623 assert!(
3624 matches!(result, Some(FollowTarget::Link(_))),
3625 "expected Link variant for markdown link fragment, got {result:?}"
3626 );
3627 }
3628
3629 #[test]
3630 fn vim_normal_i_then_typing_inserts_text() {
3631 let mut settings = crate::settings::AppSettings::default();
3632 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
3633 let mut editor = TextEditorComponent::new(KeyBindings::empty(), &settings);
3634 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3635 editor.handle_input(
3637 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3638 &tx,
3639 );
3640 assert_eq!(editor.get_text(), "");
3641 editor.handle_input(
3643 &InputEvent::Key(key(KeyCode::Char('i'), KeyModifiers::NONE)),
3644 &tx,
3645 );
3646 editor.handle_input(
3647 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3648 &tx,
3649 );
3650 assert_eq!(editor.get_text(), "x");
3651 }
3652
3653 fn open_replace_bar(
3658 editor: &mut TextEditorComponent,
3659 tx: &AppTx,
3660 pattern: &str,
3661 replacement: &str,
3662 ) {
3663 editor.open_or_advance_search();
3664 for c in pattern.chars() {
3665 editor.handle_input(
3666 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3667 tx,
3668 );
3669 }
3670 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), tx);
3671 for c in replacement.chars() {
3672 editor.handle_input(
3673 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3674 tx,
3675 );
3676 }
3677 }
3678
3679 #[test]
3680 fn tab_reveals_the_replace_field_and_then_cycles_focus() {
3681 let mut editor = make_editor();
3682 let tx = dummy_tx();
3683 editor.set_text("todo".to_string());
3684 editor.open_or_advance_search();
3685 assert!(
3686 !editor.search.as_ref().unwrap().is_replacing(),
3687 "a find-only bar must not start with a replace field"
3688 );
3689
3690 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3691 let s = editor.search.as_ref().unwrap();
3692 assert!(s.is_replacing(), "Tab must reveal the replace field");
3693 assert_eq!(s.focus, BarFocus::Find);
3696
3697 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3698 assert_eq!(editor.search.as_ref().unwrap().focus, BarFocus::Replace);
3699 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3700 assert_eq!(editor.search.as_ref().unwrap().focus, BarFocus::Find);
3701 }
3702
3703 #[test]
3704 fn typing_in_the_replace_field_does_not_touch_the_buffer() {
3705 let mut editor = make_editor();
3706 let tx = dummy_tx();
3707 editor.set_text("todo and todo".to_string());
3708 open_replace_bar(&mut editor, &tx, "todo", "done");
3709 assert_eq!(
3710 editor.get_text(),
3711 "todo and todo",
3712 "the preview is a view of the note, never a write to it"
3713 );
3714 }
3715
3716 #[test]
3717 fn enter_replaces_the_current_match_and_advances() {
3718 let mut editor = make_editor();
3719 let tx = dummy_tx();
3720 editor.set_text("todo and todo".to_string());
3721 open_replace_bar(&mut editor, &tx, "todo", "done");
3722 editor.handle_input(
3723 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3724 &tx,
3725 );
3726 assert_eq!(editor.get_text(), "done and todo");
3727 }
3728
3729 #[test]
3730 fn replacing_a_match_that_ends_inside_a_cluster_is_refused_not_corrupted() {
3731 let mut editor = make_editor();
3737 let tx = dummy_tx();
3738 editor.set_text("e\u{301}f".to_string());
3739 open_replace_bar(&mut editor, &tx, "e", "x");
3740 editor.handle_input(
3741 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3742 &tx,
3743 );
3744 assert_eq!(
3745 editor.get_text(),
3746 "e\u{301}f",
3747 "the note is left alone rather than half-rewritten"
3748 );
3749 }
3750
3751 #[test]
3752 fn ctrl_a_replaces_every_match() {
3753 let mut editor = make_editor();
3754 let tx = dummy_tx();
3755 editor.set_text("todo and todo\nmore todo".to_string());
3756 open_replace_bar(&mut editor, &tx, "todo", "done");
3757 editor.handle_input(
3758 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3759 &tx,
3760 );
3761 assert_eq!(editor.get_text(), "done and done\nmore done");
3762 }
3763
3764 #[test]
3765 fn replace_all_keeps_the_reading_position() {
3766 let mut editor = make_editor();
3767 let tx = dummy_tx();
3768 editor.set_text("todo\nxx\ntodo\nyy".to_string());
3769 open_replace_bar(&mut editor, &tx, "todo", "done");
3770 if let Some(ta) = editor.backend.as_textarea_mut() {
3774 ta.move_cursor(CursorMove::Jump(3, 1));
3775 }
3776 editor.handle_input(
3777 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3778 &tx,
3779 );
3780 assert_eq!(editor.get_text(), "done\nxx\ndone\nyy");
3781 let (row, _) = editor.cursor_pos();
3782 assert_eq!(
3783 row, 3,
3784 "replace all must not throw the cursor to the end of the note"
3785 );
3786 }
3787
3788 #[test]
3789 fn an_empty_replacement_arms_before_it_deletes() {
3790 let mut editor = make_editor();
3791 let tx = dummy_tx();
3792 editor.set_text("todo and todo".to_string());
3793 open_replace_bar(&mut editor, &tx, "todo ", "");
3794
3795 let ctrl_a = InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
3796 editor.handle_input(&ctrl_a, &tx);
3797 assert_eq!(
3798 editor.get_text(),
3799 "todo and todo",
3800 "the first Ctrl+A on an empty replacement must arm, not delete"
3801 );
3802 assert!(editor.search.as_ref().unwrap().armed_empty);
3803
3804 editor.handle_input(&ctrl_a, &tx);
3805 assert_eq!(editor.get_text(), "and todo");
3806 }
3807
3808 #[test]
3809 fn esc_disarms_an_empty_replace_all_without_closing_the_bar() {
3810 let mut editor = make_editor();
3811 let tx = dummy_tx();
3812 editor.set_text("todo".to_string());
3813 open_replace_bar(&mut editor, &tx, "todo", "");
3814 editor.handle_input(
3815 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3816 &tx,
3817 );
3818 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3819 let s = editor
3820 .search
3821 .as_ref()
3822 .expect("Esc disarms before it closes");
3823 assert!(!s.armed_empty);
3824 assert_eq!(editor.get_text(), "todo");
3825 }
3826
3827 #[test]
3828 fn one_ctrl_z_undoes_a_whole_replace_all() {
3829 let mut editor = make_editor();
3830 let tx = dummy_tx();
3831 editor.set_text("todo and todo".to_string());
3832 open_replace_bar(&mut editor, &tx, "todo", "done");
3833 editor.handle_input(
3834 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3835 &tx,
3836 );
3837 assert_eq!(editor.get_text(), "done and done");
3838
3839 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3841 editor.handle_input(
3842 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3843 &tx,
3844 );
3845 assert_eq!(
3846 editor.get_text(),
3847 "todo and todo",
3848 "a replace is two history entries and must cost ONE undo — \
3849 popping half leaves the note with a hole in it"
3850 );
3851 }
3852
3853 #[test]
3854 fn one_ctrl_z_undoes_a_single_replace_step() {
3855 let mut editor = make_editor();
3856 let tx = dummy_tx();
3857 editor.set_text("todo and todo".to_string());
3858 open_replace_bar(&mut editor, &tx, "todo", "done");
3859 editor.handle_input(
3860 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3861 &tx,
3862 );
3863 assert_eq!(editor.get_text(), "done and todo");
3864 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3865 editor.handle_input(
3866 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3867 &tx,
3868 );
3869 assert_eq!(editor.get_text(), "todo and todo");
3870 }
3871
3872 #[test]
3873 fn redo_regroups_the_replace() {
3874 let mut editor = make_editor();
3875 let tx = dummy_tx();
3876 editor.set_text("todo".to_string());
3877 open_replace_bar(&mut editor, &tx, "todo", "done");
3878 editor.handle_input(
3879 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3880 &tx,
3881 );
3882 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3883 editor.handle_input(
3884 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3885 &tx,
3886 );
3887 assert_eq!(editor.get_text(), "todo");
3888 editor.handle_input(
3889 &InputEvent::Key(key(KeyCode::Char('y'), KeyModifiers::CONTROL)),
3890 &tx,
3891 );
3892 assert_eq!(
3893 editor.get_text(),
3894 "done",
3895 "one redo must restore the whole replace"
3896 );
3897 }
3898
3899 #[test]
3900 fn smartcase_drives_both_the_count_and_the_replace() {
3901 let mut editor = make_editor();
3902 let tx = dummy_tx();
3903 editor.set_text("todo Todo TODO".to_string());
3904 open_replace_bar(&mut editor, &tx, "todo", "x");
3905 assert_eq!(
3906 editor.search.as_ref().unwrap().match_count,
3907 3,
3908 "an all-lowercase pattern matches any case"
3909 );
3910 editor.handle_input(
3911 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3912 &tx,
3913 );
3914 assert_eq!(editor.get_text(), "x x x");
3915 }
3916
3917 #[test]
3918 fn an_uppercase_pattern_is_case_sensitive() {
3919 let mut editor = make_editor();
3920 let tx = dummy_tx();
3921 editor.set_text("todo Todo TODO".to_string());
3922 open_replace_bar(&mut editor, &tx, "Todo", "x");
3923 assert_eq!(editor.search.as_ref().unwrap().match_count, 1);
3924 editor.handle_input(
3925 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3926 &tx,
3927 );
3928 assert_eq!(editor.get_text(), "todo x TODO");
3929 }
3930
3931 #[test]
3932 fn the_preview_substitutes_lines_without_writing_them() {
3933 let mut editor = make_editor();
3934 let tx = dummy_tx();
3935 editor.set_text("todo and todo".to_string());
3936 open_replace_bar(&mut editor, &tx, "todo", "done");
3937 let preview = editor.replace_preview().expect("a preview must be built");
3938 assert_eq!(preview.lines, vec!["done and done".to_string()]);
3939 assert_eq!(preview.spans.len(), 2);
3940 assert!(
3941 preview.spans.iter().any(|s| s.is_current),
3942 "the match under the cursor must be flagged so Enter's target is visible"
3943 );
3944 assert_eq!(
3945 editor.get_text(),
3946 "todo and todo",
3947 "building a preview must never mutate the buffer"
3948 );
3949 }
3950
3951 #[test]
3956 fn a_deletion_preview_still_marks_the_current_match() {
3957 let mut editor = make_editor();
3958 let tx = dummy_tx();
3959 editor.set_text("todo and todo".to_string());
3960 open_replace_bar(&mut editor, &tx, "todo", "");
3961 let preview = editor.replace_preview().expect("a preview must be built");
3962 assert_eq!(preview.lines, vec![" and ".to_string()]);
3963 let current = preview
3964 .spans
3965 .iter()
3966 .find(|s| s.is_current)
3967 .expect("the current match must stay flagged when it previews as nothing");
3968 assert_eq!(
3969 current.start, current.end,
3970 "an empty replacement previews as a zero-width span — the renderer \
3971 widens it to a caret cell so the marker cannot vanish"
3972 );
3973 }
3974
3975 #[test]
3980 fn a_multi_row_selection_cannot_derail_an_interactive_replace() {
3981 let mut editor = make_editor();
3982 let tx = dummy_tx();
3983 editor.set_text("alpha beta\nxy".to_string());
3984 open_replace_bar(&mut editor, &tx, "beta", "Z");
3985 editor.selection = Some(((0, 6), (1, 1)));
3987 editor.handle_input(
3988 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3989 &tx,
3990 );
3991 assert_eq!(editor.get_text(), "alpha Z\nxy");
3992 }
3993
3994 #[test]
3999 fn deleting_a_match_marks_the_note_dirty() {
4000 let mut editor = make_editor();
4001 let tx = dummy_tx();
4002 editor.set_text("todo and todo".to_string());
4003 editor.mark_saved("todo and todo".to_string());
4004 assert!(!editor.is_dirty());
4005
4006 open_replace_bar(&mut editor, &tx, "todo ", "");
4007 editor.handle_input(
4008 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
4009 &tx,
4010 );
4011 assert_eq!(editor.get_text(), "and todo");
4012 assert!(
4013 editor.is_dirty(),
4014 "a deletion is an edit — if the revision does not move, autosave \
4015 never writes it and the change is silently lost"
4016 );
4017 }
4018
4019 #[test]
4021 fn emptying_the_note_via_replace_all_marks_it_dirty_and_is_undoable() {
4022 let mut editor = make_editor();
4023 let tx = dummy_tx();
4024 editor.set_text("todo".to_string());
4025 editor.mark_saved("todo".to_string());
4026 open_replace_bar(&mut editor, &tx, "todo", "");
4027 let ctrl_a = InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
4028 editor.handle_input(&ctrl_a, &tx); editor.handle_input(&ctrl_a, &tx); assert_eq!(editor.get_text(), "");
4031 assert!(editor.is_dirty());
4032
4033 editor.handle_input(
4034 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4035 &tx,
4036 );
4037 assert_eq!(editor.get_text(), "todo");
4038 }
4039
4040 #[test]
4044 fn ctrl_z_works_without_closing_the_bar_first() {
4045 let mut editor = make_editor();
4046 let tx = dummy_tx();
4047 editor.set_text("todo and todo".to_string());
4048 open_replace_bar(&mut editor, &tx, "todo", "done");
4049 editor.handle_input(
4050 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4051 &tx,
4052 );
4053 assert_eq!(editor.get_text(), "done and done");
4054 editor.handle_input(
4055 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4056 &tx,
4057 );
4058 assert_eq!(editor.get_text(), "todo and todo");
4059 assert!(editor.search.is_some(), "undo must not close the bar");
4060 }
4061
4062 #[test]
4066 fn a_zero_width_match_does_not_over_claim_history_entries() {
4067 let mut editor = make_editor();
4068 let tx = dummy_tx();
4069 editor.set_text("ab".to_string());
4070 open_replace_bar(&mut editor, &tx, r"\b", "|");
4071 editor.handle_input(
4072 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
4073 &tx,
4074 );
4075 assert_eq!(editor.get_text(), "|ab");
4076 editor.handle_input(
4077 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4078 &tx,
4079 );
4080 assert_eq!(
4081 editor.get_text(),
4082 "ab",
4083 "one undo must land exactly on the pre-replace text, not past it"
4084 );
4085 }
4086
4087 #[test]
4091 fn a_note_swap_resets_the_find_bar_and_its_undo_groups() {
4092 let mut editor = make_editor();
4093 let tx = dummy_tx();
4094 editor.set_text("todo".to_string());
4095 open_replace_bar(&mut editor, &tx, "todo", "");
4096 editor.handle_input(
4097 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4098 &tx,
4099 );
4100 assert!(editor.search.as_ref().unwrap().armed_empty);
4101
4102 editor.set_text("todo elsewhere".to_string());
4103 assert!(editor.search.is_none(), "the bar belonged to the old note");
4104 assert!(
4107 !editor.backend.as_textarea_mut().unwrap().undo(),
4108 "the new note's history has nothing to undo"
4109 );
4110 }
4111
4112 #[test]
4118 fn concealed_markdown_still_highlights_what_it_counts() {
4119 let mut editor = make_editor();
4120 let tx = dummy_tx();
4121 editor.set_text("# Heading\n[[note]]".to_string());
4122 editor.open_or_advance_search();
4123 for c in r"\[\[".chars() {
4124 editor.handle_input(
4125 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4126 &tx,
4127 );
4128 }
4129 let state = editor.search.as_ref().unwrap();
4130 assert_eq!(state.match_count, 1, "the `[[` sigil is a real match");
4131 let spans = state
4132 .pattern
4133 .as_ref()
4134 .unwrap()
4135 .match_spans(editor.backend.as_textarea().unwrap().text().lines());
4136 assert_eq!(
4137 spans,
4138 vec![(1, 0, 2)],
4139 "and it must be reported as a paintable span, not silently dropped \
4140 because the rendered row conceals it"
4141 );
4142 }
4143
4144 #[test]
4149 fn paste_goes_into_the_focused_bar_field() {
4150 let mut editor = make_editor();
4151 let tx = dummy_tx();
4152 editor.set_text("todo".to_string());
4153 open_replace_bar(&mut editor, &tx, "todo", "");
4154 editor.paste_text("done", &tx);
4155 assert_eq!(editor.get_text(), "todo", "the buffer is untouched");
4156 assert_eq!(editor.search.as_ref().unwrap().replacement(), "done");
4157 }
4158
4159 #[test]
4160 fn a_multiline_paste_collapses_to_its_first_line() {
4161 let mut editor = make_editor();
4162 let tx = dummy_tx();
4163 editor.set_text("x".to_string());
4164 editor.open_or_advance_search();
4165 editor.paste_text("first\nsecond", &tx);
4166 assert_eq!(editor.search.as_ref().unwrap().input.value(), "first");
4167 }
4168
4169 #[test]
4173 fn the_bar_is_never_an_invisible_modal() {
4174 use ratatui::Terminal;
4175 use ratatui::backend::TestBackend;
4176 let mut editor = make_editor();
4177 editor.set_text("todo".to_string());
4178 let theme = Theme::default();
4179 let mut term = Terminal::new(TestBackend::new(40, 1)).unwrap();
4180 let area = Rect::new(0, 0, 40, 1);
4181 editor.open_or_advance_search();
4182 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4183 let row: String = (0..40)
4184 .filter_map(|x| {
4185 term.backend()
4186 .buffer()
4187 .cell(ratatui::layout::Position::new(x, 0))
4188 .map(|c| c.symbol().to_string())
4189 })
4190 .collect();
4191 assert!(
4192 row.contains("Find:"),
4193 "an open bar must be drawn even when it costs the whole pane, got {row:?}"
4194 );
4195 }
4196
4197 #[test]
4209 fn a_row_far_from_the_cursor_reparses_after_replace_all() {
4210 use ratatui::Terminal;
4211 use ratatui::backend::TestBackend;
4212 let mut editor = make_editor();
4213 let tx = dummy_tx();
4214 let mut lines: Vec<String> = (0..400).map(|i| format!("filler {i}")).collect();
4215 lines[0] = "todo".to_string();
4216 lines[398] = "todo".to_string();
4217 editor.set_text(lines.join("\n"));
4218 let theme = Theme::default();
4219 let mut term = Terminal::new(TestBackend::new(20, 8)).unwrap();
4220 let area = Rect::new(0, 0, 20, 8);
4221 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4222
4223 open_replace_bar(&mut editor, &tx, "todo", "[[x]]");
4224 if let Some(ta) = editor.backend.as_textarea_mut() {
4227 ta.move_cursor(CursorMove::Jump(398, 0));
4228 }
4229 editor.handle_input(
4230 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4231 &tx,
4232 );
4233 if let Some(ta) = editor.backend.as_textarea_mut() {
4236 ta.move_cursor(CursorMove::Jump(1, 0));
4237 }
4238 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4239 let row0: String = (0..20)
4240 .filter_map(|x| {
4241 term.backend()
4242 .buffer()
4243 .cell(ratatui::layout::Position::new(x, 0))
4244 .map(|c| c.symbol().to_string())
4245 })
4246 .collect::<String>()
4247 .trim_end()
4248 .to_string();
4249 assert_eq!(
4250 row0, "x",
4251 "row 0 must render as a parsed wikilink; `[[x]]` would mean it \
4252 kept the parse of the text that was there before the replace"
4253 );
4254 }
4255
4256 #[test]
4260 fn indenting_a_block_undoes_in_one_step() {
4261 let mut editor = make_editor();
4262 let tx = dummy_tx();
4263 editor.set_text("a\nb\nc".to_string());
4264 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
4265 get_ta(&mut editor).start_selection();
4266 get_ta(&mut editor).move_cursor(CursorMove::Jump(2, 1));
4267 editor.indent_lines(false);
4268 assert_eq!(editor.get_text(), " a\n b\n c");
4269
4270 editor.handle_input(
4271 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4272 &tx,
4273 );
4274 assert_eq!(
4275 editor.get_text(),
4276 "a\nb\nc",
4277 "one undo must revert the whole block, not just the last line"
4278 );
4279 }
4280
4281 #[test]
4283 fn pasting_over_a_selection_undoes_in_one_step() {
4284 let mut editor = make_editor();
4285 let tx = dummy_tx();
4286 editor.set_text("hello world".to_string());
4287 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
4288 get_ta(&mut editor).start_selection();
4289 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 5));
4290 editor.paste_text("goodbye", &tx);
4291 assert_eq!(editor.get_text(), "goodbye world");
4292
4293 editor.handle_input(
4294 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4295 &tx,
4296 );
4297 assert_eq!(editor.get_text(), "hello world");
4298 }
4299
4300 #[test]
4304 fn closing_the_bar_clears_a_stale_selection() {
4305 let mut editor = make_editor();
4306 let tx = dummy_tx();
4307 editor.set_text("alpha beta".to_string());
4308 editor.selection = Some(((0, 0), (0, 5)));
4309 editor.open_or_advance_search();
4310 for c in "beta".chars() {
4311 editor.handle_input(
4312 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4313 &tx,
4314 );
4315 }
4316 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4317 assert!(editor.search.is_none());
4318 assert_eq!(
4319 editor.selection, None,
4320 "a selection from before the search must not outlive the bar"
4321 );
4322 }
4323
4324 #[test]
4327 fn undo_inside_the_bar_rederives_the_current_match() {
4328 let mut editor = make_editor();
4329 let tx = dummy_tx();
4330 editor.set_text("foo foo".to_string());
4331 open_replace_bar(&mut editor, &tx, "foo", "xy");
4332 editor.handle_input(
4333 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
4334 &tx,
4335 );
4336 assert_eq!(editor.get_text(), "xy foo");
4337 editor.handle_input(
4338 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4339 &tx,
4340 );
4341 assert_eq!(editor.get_text(), "foo foo");
4342 let current = editor.search.as_ref().unwrap().current_match();
4343 if let Some(((row, start), (_, end))) = current {
4344 let line = &editor.get_text()[..];
4345 let text: String = line
4346 .lines()
4347 .nth(row)
4348 .unwrap()
4349 .chars()
4350 .skip(start)
4351 .take(end - start)
4352 .collect();
4353 assert_eq!(
4354 text, "foo",
4355 "the highlight must sit on a real match, got {text:?}"
4356 );
4357 }
4358 }
4359
4360 #[test]
4364 fn vim_n_highlights_the_match_it_lands_on() {
4365 let mut editor = make_vim_editor();
4366 let tx = dummy_tx();
4367 editor.set_text("lo xx lo".to_string());
4368 editor.open_or_advance_search();
4369 for c in "lo".chars() {
4370 editor.handle_input(
4371 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4372 &tx,
4373 );
4374 }
4375 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4376 editor.handle_input(
4377 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
4378 &tx,
4379 );
4380 assert_eq!(
4381 editor.selection,
4382 Some(((0, 6), (0, 8))),
4383 "`n` must paint the match it jumped to"
4384 );
4385 }
4386
4387 #[test]
4392 fn closing_the_bar_cannot_leave_an_invisible_selection() {
4393 let mut editor = make_editor();
4394 let tx = dummy_tx();
4395 editor.set_text("foo bar baz".to_string());
4396 get_ta(&mut editor).select_all();
4398 editor.open_or_advance_search();
4399 for c in "bar".chars() {
4400 editor.handle_input(
4401 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4402 &tx,
4403 );
4404 }
4405 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4406 editor.handle_input(
4407 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
4408 &tx,
4409 );
4410 assert!(
4411 editor.get_text().contains("foo"),
4412 "typing after the bar closed must not eat unhighlighted text, got {:?}",
4413 editor.get_text()
4414 );
4415 }
4416
4417 #[test]
4420 fn vim_visual_indent_undoes_in_one_step() {
4421 let mut editor = make_vim_editor();
4422 let tx = dummy_tx();
4423 editor.set_text("a\nb\nc".to_string());
4424 for c in ['V', 'j', 'j', '>'] {
4425 editor.handle_input(
4426 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4427 &tx,
4428 );
4429 }
4430 let indented = editor.get_text();
4431 assert_ne!(indented, "a\nb\nc", "`>` must indent the selection");
4432 editor.handle_input(
4433 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4434 &tx,
4435 );
4436 assert_eq!(
4437 editor.get_text(),
4438 "a\nb\nc",
4439 "one `u` must revert the whole indent, not one row"
4440 );
4441 }
4442
4443 #[test]
4447 fn vim_n_cannot_leave_an_invisible_selection() {
4448 let mut editor = make_vim_editor();
4449 let tx = dummy_tx();
4450 editor.set_text("foo bar foo".to_string());
4451 editor.open_or_advance_search();
4452 for c in "foo".chars() {
4453 editor.handle_input(
4454 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4455 &tx,
4456 );
4457 }
4458 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4459 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
4461 get_ta(&mut editor).start_selection();
4462 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 3));
4463 editor.handle_input(
4464 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
4465 &tx,
4466 );
4467 for c in ['i', 'X'] {
4468 editor.handle_input(
4469 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4470 &tx,
4471 );
4472 }
4473 assert!(
4474 editor.get_text().contains("bar"),
4475 "typing after `n` must not eat unhighlighted text, got {:?}",
4476 editor.get_text()
4477 );
4478 }
4479
4480 #[test]
4485 fn overlays_paint_where_the_post_pass_used_to() {
4486 use ratatui::Terminal;
4487 use ratatui::backend::TestBackend;
4488 use ratatui::layout::Position;
4489 let mut editor = make_editor();
4490 editor.set_text("find the needle here\n- [x] done task\n- [ ] open task".to_string());
4491 editor.set_search_needles(vec!["needle".to_string()]);
4492 let theme = Theme::default();
4493 let mut term = Terminal::new(TestBackend::new(40, 6)).unwrap();
4494 let area = Rect::new(0, 0, 40, 6);
4495 term.draw(|f| editor.render(f, area, &theme, false))
4496 .unwrap();
4497 let buf = term.backend().buffer();
4498
4499 let row: String = (0..40)
4500 .filter_map(|x| {
4501 buf.cell(Position::new(x, 0))
4502 .map(|c| c.symbol().to_string())
4503 })
4504 .collect();
4505 let at = row.find("needle").expect("needle is on screen");
4506 let cell = buf.cell(Position::new(at as u16, 0)).unwrap();
4507 assert_eq!(
4508 cell.fg,
4509 theme.color_search_match.to_ratatui(),
4510 "the needle must still be emphasised"
4511 );
4512
4513 let struck = |y: u16| {
4515 (0..40).any(|x| {
4516 buf.cell(Position::new(x, y)).is_some_and(|c| {
4517 c.style()
4518 .add_modifier
4519 .contains(ratatui::style::Modifier::CROSSED_OUT)
4520 })
4521 })
4522 };
4523 assert!(struck(1), "a done task strikes its text");
4524 assert!(!struck(2), "an open task does not");
4525
4526 for y in [1u16, 2] {
4528 assert!(
4529 (0..40).any(|x| buf
4530 .cell(Position::new(x, y))
4531 .is_some_and(|c| c.fg == theme.accent.to_ratatui())),
4532 "row {y} must have an accent-coloured checkbox"
4533 );
4534 }
4535 }
4536
4537 #[test]
4538 fn no_preview_without_a_replace_field() {
4539 let mut editor = make_editor();
4540 let tx = dummy_tx();
4541 editor.set_text("todo".to_string());
4542 editor.open_or_advance_search();
4543 for c in "todo".chars() {
4544 editor.handle_input(
4545 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4546 &tx,
4547 );
4548 }
4549 assert!(
4550 editor.replace_preview().is_none(),
4551 "a find-only bar previews nothing"
4552 );
4553 }
4554
4555 #[test]
4556 fn capture_expansion_is_gated_on_the_pattern_capturing() {
4557 let mut editor = make_editor();
4558 let tx = dummy_tx();
4559 editor.set_text("cost".to_string());
4561 open_replace_bar(&mut editor, &tx, "cost", "$1");
4562 editor.handle_input(
4563 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4564 &tx,
4565 );
4566 assert_eq!(editor.get_text(), "$1");
4567 }
4568
4569 #[test]
4570 fn the_bar_reserves_two_rows_only_while_replacing() {
4571 use ratatui::Terminal;
4572 use ratatui::backend::TestBackend;
4573 let mut editor = make_editor();
4574 let tx = dummy_tx();
4575 editor.set_text("todo".to_string());
4576 let theme = Theme::default();
4577 let mut term = Terminal::new(TestBackend::new(40, 10)).unwrap();
4578 let area = Rect::new(0, 0, 40, 10);
4579
4580 editor.open_or_advance_search();
4581 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4582 assert_eq!(editor.rect.height, 9, "a find-only bar takes one row");
4583
4584 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
4585 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4586 assert_eq!(
4587 editor.rect.height, 8,
4588 "the replace field takes a second row"
4589 );
4590 }
4591
4592 #[test]
4597 fn guu_really_does_undo_in_one_step() {
4598 let mut editor = make_vim_editor();
4599 let tx = dummy_tx();
4600 editor.set_text("Mixed Case Line".to_string());
4601 for c in "guu".chars() {
4602 editor.handle_input(
4603 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4604 &tx,
4605 );
4606 }
4607 assert_eq!(editor.get_text(), "mixed case line");
4608 editor.handle_input(
4609 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4610 &tx,
4611 );
4612 assert_eq!(editor.get_text(), "Mixed Case Line");
4613 }
4614
4615 #[test]
4620 fn vim_u_undoes_a_whole_replace() {
4621 let mut editor = make_vim_editor();
4622 let tx = dummy_tx();
4623 editor.set_text("todo and todo".to_string());
4624 open_replace_bar(&mut editor, &tx, "todo", "done");
4625 editor.handle_input(
4626 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4627 &tx,
4628 );
4629 assert_eq!(editor.get_text(), "done and done");
4630
4631 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4633 editor.handle_input(
4634 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4635 &tx,
4636 );
4637 assert_eq!(editor.get_text(), "todo and todo");
4638 }
4639
4640 fn type_out(editor: &mut TextEditorComponent, tx: &AppTx, text: &str) {
4643 use ratatui::crossterm::event::KeyEvent;
4644 for c in text.chars() {
4645 let code = if c == '\n' {
4646 KeyCode::Enter
4647 } else {
4648 KeyCode::Char(c)
4649 };
4650 editor.handle_textarea_key(&KeyEvent::new(code, KeyModifiers::NONE), tx);
4651 }
4652 }
4653
4654 #[test]
4655 fn undo_takes_back_a_word_not_a_letter() {
4656 let mut editor = make_editor();
4659 let tx = dummy_tx();
4660 editor.set_text(String::new());
4661 type_out(&mut editor, &tx, "hello world");
4662 assert_eq!(editor.get_text(), "hello world");
4663
4664 assert!(get_ta(&mut editor).undo());
4665 assert_eq!(editor.get_text(), "hello ", "the last word goes whole");
4666 assert!(get_ta(&mut editor).undo());
4667 assert_eq!(editor.get_text(), "", "and so does the first");
4668 }
4669
4670 #[test]
4671 fn a_cursor_move_separates_two_runs() {
4672 let mut editor = make_editor();
4673 let tx = dummy_tx();
4674 editor.set_text(String::new());
4675 type_out(&mut editor, &tx, "ab");
4676 arrow(&mut editor, &tx, KeyCode::Home);
4677 type_out(&mut editor, &tx, "cd");
4678 assert_eq!(editor.get_text(), "cdab");
4679
4680 assert!(get_ta(&mut editor).undo());
4681 assert_eq!(
4682 editor.get_text(),
4683 "ab",
4684 "only what was typed after the move comes back off"
4685 );
4686 }
4687
4688 #[test]
4689 fn backspacing_to_fix_a_typo_is_its_own_action() {
4690 use ratatui::crossterm::event::KeyEvent;
4691 let mut editor = make_editor();
4692 let tx = dummy_tx();
4693 editor.set_text(String::new());
4694 type_out(&mut editor, &tx, "helllo");
4695 editor.handle_textarea_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
4696 assert_eq!(editor.get_text(), "helll");
4697
4698 assert!(get_ta(&mut editor).undo());
4699 assert_eq!(
4700 editor.get_text(),
4701 "helllo",
4702 "the delete undoes on its own, without taking the typing with it"
4703 );
4704 }
4705
4706 #[test]
4707 fn an_undo_between_two_runs_separates_them() {
4708 let mut editor = make_editor();
4711 let tx = dummy_tx();
4712 editor.set_text(String::new());
4713 type_out(&mut editor, &tx, "ab");
4714 assert!(get_ta(&mut editor).undo());
4715 assert_eq!(editor.get_text(), "");
4716 type_out(&mut editor, &tx, "cd");
4717 assert_eq!(editor.get_text(), "cd");
4718 assert!(get_ta(&mut editor).undo());
4719 assert_eq!(
4720 editor.get_text(),
4721 "",
4722 "the second run is its own group, not an extension of an undone one"
4723 );
4724 }
4725
4726 #[test]
4727 fn a_save_closes_the_open_group() {
4728 let mut editor = make_editor();
4732 let tx = dummy_tx();
4733 type_out(&mut editor, &tx, "abc");
4734 let saved = editor.get_text();
4735 editor.mark_saved(saved);
4736 type_out(&mut editor, &tx, "def");
4739
4740 assert!(get_ta(&mut editor).undo());
4741 assert_eq!(
4742 editor.get_text(),
4743 "abc",
4744 "one undo lands on what was saved, not before it"
4745 );
4746 }
4747
4748 #[test]
4749 fn a_stale_save_completion_does_not_close_the_group() {
4750 let mut editor = make_editor();
4754 let tx = dummy_tx();
4755 type_out(&mut editor, &tx, "abc");
4756 let stale = NonZeroU64::new(1).expect("nonzero");
4757 editor.mark_saved_at_revision(stale);
4758 type_out(&mut editor, &tx, "def");
4759
4760 assert!(get_ta(&mut editor).undo());
4761 assert_eq!(
4762 editor.get_text(),
4763 "",
4764 "the run carried on across a completion that marked nothing"
4765 );
4766 }
4767
4768 #[test]
4769 fn a_second_vim_insert_session_is_its_own_group() {
4770 use ratatui::crossterm::event::KeyEvent;
4774 let mut editor = make_vim_editor();
4775 let tx = dummy_tx();
4776 editor.set_text(String::new());
4777 let press = |editor: &mut TextEditorComponent, code| {
4778 let _ = editor.handle_input(
4779 &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
4780 &tx,
4781 );
4782 };
4783 press(&mut editor, KeyCode::Char('i'));
4784 for c in "one".chars() {
4785 press(&mut editor, KeyCode::Char(c));
4786 }
4787 press(&mut editor, KeyCode::Esc);
4788 press(&mut editor, KeyCode::Char('i'));
4789 for c in "two".chars() {
4790 press(&mut editor, KeyCode::Char(c));
4791 }
4792 press(&mut editor, KeyCode::Esc);
4793 assert_eq!(editor.get_text(), "ontwoe");
4796
4797 assert!(get_ta(&mut editor).undo());
4798 assert_eq!(
4799 editor.get_text(),
4800 "one",
4801 "`u` takes back the second session only"
4802 );
4803 }
4804
4805 #[test]
4806 fn a_vim_insert_session_undoes_whole() {
4807 use ratatui::crossterm::event::KeyEvent;
4808 let mut editor = make_vim_editor();
4809 let tx = dummy_tx();
4810 editor.set_text(String::new());
4811 let press = |editor: &mut TextEditorComponent, code| {
4813 let _ = editor.handle_input(
4814 &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
4815 &tx,
4816 );
4817 };
4818 press(&mut editor, KeyCode::Char('i'));
4819 for c in "hello world".chars() {
4820 press(&mut editor, KeyCode::Char(c));
4821 }
4822 press(&mut editor, KeyCode::Esc);
4823 assert_eq!(editor.get_text(), "hello world");
4824
4825 assert!(get_ta(&mut editor).undo());
4826 assert_eq!(
4827 editor.get_text(),
4828 "",
4829 "vim's `u` takes back the whole session, word boundaries included"
4830 );
4831 }
4832
4833 fn lay_out(editor: &mut TextEditorComponent, width: u16, height: u16) {
4837 use ratatui::Terminal;
4838 use ratatui::backend::TestBackend;
4839 let theme = Theme::default();
4840 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
4841 let area = Rect::new(0, 0, width, height);
4842 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4843 }
4844
4845 fn arrow(editor: &mut TextEditorComponent, tx: &AppTx, code: KeyCode) {
4846 use ratatui::crossterm::event::KeyEvent;
4847 editor.handle_textarea_key(&KeyEvent::new(code, KeyModifiers::NONE), tx);
4848 }
4849
4850 #[test]
4851 fn down_moves_one_drawn_line_not_one_row() {
4852 let mut editor = make_editor();
4855 let tx = dummy_tx();
4856 editor.set_text(
4857 "aaaa bbbb cccc dddd
4858second row"
4859 .to_string(),
4860 );
4861 lay_out(&mut editor, 6, 10);
4862
4863 get_ta(&mut editor).jump_to(0, 0);
4864 arrow(&mut editor, &tx, KeyCode::Down);
4865 assert_eq!(
4866 get_ta(&mut editor).cursor(),
4867 (0, 5),
4868 "still inside the first row, on its second drawn line"
4869 );
4870 arrow(&mut editor, &tx, KeyCode::Down);
4871 assert_eq!(get_ta(&mut editor).cursor(), (0, 10));
4872 arrow(&mut editor, &tx, KeyCode::Down);
4873 assert_eq!(get_ta(&mut editor).cursor(), (0, 15));
4874 arrow(&mut editor, &tx, KeyCode::Down);
4875 assert_eq!(
4876 get_ta(&mut editor).cursor().0,
4877 1,
4878 "and only the fourth press reaches the next row"
4879 );
4880 }
4881
4882 #[test]
4883 fn up_and_down_are_symmetric_across_a_wrap() {
4884 let mut editor = make_editor();
4885 let tx = dummy_tx();
4886 editor.set_text("aaaa bbbb cccc".to_string());
4887 lay_out(&mut editor, 6, 10);
4888
4889 get_ta(&mut editor).jump_to(0, 0);
4890 arrow(&mut editor, &tx, KeyCode::Down);
4891 let middle = get_ta(&mut editor).cursor();
4892 arrow(&mut editor, &tx, KeyCode::Up);
4893 assert_eq!(get_ta(&mut editor).cursor(), (0, 0));
4894 assert_eq!(middle, (0, 5));
4895 }
4896
4897 #[test]
4898 fn an_arrow_against_a_stale_layout_falls_back_instead_of_panicking() {
4899 let mut editor = make_editor();
4904 let tx = dummy_tx();
4905 editor.set_text("abcd\nefgh".to_string());
4906 lay_out(&mut editor, 20, 10);
4907
4908 get_ta(&mut editor).jump_to(0, 4);
4909 for _ in 0..3 {
4910 get_ta(&mut editor).delete_char();
4911 }
4912 assert_eq!(get_ta(&mut editor).rows(), &["a", "efgh"]);
4913
4914 arrow(&mut editor, &tx, KeyCode::Down);
4916 assert_eq!(get_ta(&mut editor).cursor().0, 1, "still moved down a row");
4917 }
4918
4919 #[test]
4920 fn an_action_between_arrows_forgets_the_goal_cell() {
4921 let mut editor = make_editor();
4927 let tx = dummy_tx();
4928 editor.set_text(
4929 "aaaaaaaa
4930bb
4931cccccccc"
4932 .to_string(),
4933 );
4934 lay_out(&mut editor, 20, 10);
4935
4936 get_ta(&mut editor).jump_to(0, 7);
4937 arrow(&mut editor, &tx, KeyCode::Down);
4938 assert_eq!(
4939 get_ta(&mut editor).cursor(),
4940 (1, 2),
4941 "clamped to the short row"
4942 );
4943
4944 let saved = editor.get_text();
4945 editor.mark_saved(saved);
4946
4947 arrow(&mut editor, &tx, KeyCode::Down);
4948 assert_eq!(
4949 get_ta(&mut editor).cursor(),
4950 (2, 2),
4951 "the goal was forgotten, so the third row keeps the clamped column"
4952 );
4953 }
4954
4955 #[test]
4956 fn a_run_of_arrows_keeps_its_goal_cell() {
4957 let mut editor = make_editor();
4960 let tx = dummy_tx();
4961 editor.set_text(
4962 "aaaaaaaa
4963bb
4964cccccccc"
4965 .to_string(),
4966 );
4967 lay_out(&mut editor, 20, 10);
4968
4969 get_ta(&mut editor).jump_to(0, 7);
4970 arrow(&mut editor, &tx, KeyCode::Down);
4971 assert_eq!(
4972 get_ta(&mut editor).cursor(),
4973 (1, 2),
4974 "clamped to the short row"
4975 );
4976 arrow(&mut editor, &tx, KeyCode::Down);
4977 assert_eq!(
4978 get_ta(&mut editor).cursor(),
4979 (2, 7),
4980 "and back out to the cell the run still wants"
4981 );
4982 }
4983
4984 #[test]
4985 fn another_key_ends_the_run() {
4986 let mut editor = make_editor();
4987 let tx = dummy_tx();
4988 editor.set_text(
4989 "aaaaaaaa
4990bb
4991cccccccc"
4992 .to_string(),
4993 );
4994 lay_out(&mut editor, 20, 10);
4995
4996 get_ta(&mut editor).jump_to(0, 7);
4997 arrow(&mut editor, &tx, KeyCode::Down);
4998 arrow(&mut editor, &tx, KeyCode::Home);
4999 arrow(&mut editor, &tx, KeyCode::Down);
5000 assert_eq!(
5001 get_ta(&mut editor).cursor(),
5002 (2, 0),
5003 "Home set a new goal; the old one is gone"
5004 );
5005 }
5006
5007 #[test]
5008 fn shift_down_extends_by_a_drawn_line() {
5009 let mut editor = make_editor();
5010 let tx = dummy_tx();
5011 editor.set_text("aaaa bbbb cccc".to_string());
5012 lay_out(&mut editor, 6, 10);
5013
5014 get_ta(&mut editor).jump_to(0, 0);
5015 editor.handle_textarea_key(
5016 &ratatui::crossterm::event::KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT),
5017 &tx,
5018 );
5019 assert_eq!(
5020 get_ta(&mut editor).selection_range(),
5021 Some(((0, 0), (0, 5)))
5022 );
5023 }
5024
5025 fn make_vim_editor() -> TextEditorComponent {
5027 let mut settings = crate::settings::AppSettings::default();
5028 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
5029 TextEditorComponent::new(KeyBindings::empty(), &settings)
5030 }
5031
5032 fn vim_mode(editor: &TextEditorComponent) -> EditorMode {
5035 match &editor.backend {
5036 BackendState::Textarea(tb) => match &tb.input {
5037 backend::InputInterpreter::Vim(e) => e.mode().clone(),
5038 _ => panic!("expected Vim input interpreter"),
5039 },
5040 _ => panic!("expected Textarea backend"),
5041 }
5042 }
5043
5044 #[test]
5050 fn vim_visual_paste_url_wraps_whole_selected_word() {
5051 let mut editor = make_vim_editor();
5052 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5053 editor.set_text("hello world".to_string());
5054 editor.handle_input(
5057 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
5058 &tx,
5059 );
5060 editor.handle_input(
5061 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
5062 &tx,
5063 );
5064 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5065 editor.paste_text("https://example.com", &tx);
5066 assert_eq!(
5067 editor.get_text(),
5068 "[hello](https://example.com) world",
5069 "the whole selected word (including the char under the cursor) must be wrapped"
5070 );
5071 }
5072
5073 #[test]
5079 fn vim_visual_bold_wraps_whole_selected_word() {
5080 let mut editor = make_vim_editor();
5081 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5082 editor.set_text("hello world".to_string());
5083 editor.handle_input(
5084 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
5085 &tx,
5086 );
5087 editor.handle_input(
5088 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
5089 &tx,
5090 );
5091 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5092 editor.apply_text_action(TextAction::Bold);
5093 assert_eq!(
5094 editor.get_text(),
5095 "**hello** world",
5096 "the whole selected word (including the char under the cursor) must be wrapped"
5097 );
5098 }
5099
5100 #[test]
5108 fn paste_reports_its_outcome() {
5109 let mut editor = make_editor();
5110 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5111 editor.set_text("x".to_string());
5112 editor.paste_from_clipboard(&tx);
5113 let reported = std::iter::from_fn(|| rx.try_recv().ok()).any(|e| {
5114 matches!(e, AppEvent::FlashMessage(m)
5115 if m == "pasted" || m == "clipboard is empty" || m.starts_with("clipboard: "))
5116 });
5117 assert!(reported, "a paste attempt must always report something");
5118 }
5119
5120 #[test]
5126 fn external_paste_drops_the_selection_and_leaves_visual() {
5127 let mut editor = make_vim_editor();
5128 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5129 editor.set_text("hello world".to_string());
5130 editor.handle_input(
5131 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
5132 &tx,
5133 );
5134 editor.handle_input(
5135 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
5136 &tx,
5137 );
5138 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5139
5140 editor.take_selection_for_external_paste();
5141
5142 assert_eq!(
5143 vim_mode(&editor),
5144 EditorMode::Normal,
5145 "the engine must not keep believing it is in Visual"
5146 );
5147 assert_eq!(
5148 get_ta(&mut editor).selection_range(),
5149 None,
5150 "the selection the incoming content replaces must be gone"
5151 );
5152 assert_eq!(
5153 editor.get_text(),
5154 " world",
5155 "the inclusive visual range is what gets replaced"
5156 );
5157 }
5158
5159 #[test]
5162 fn external_paste_without_a_selection_leaves_the_buffer_alone() {
5163 let mut editor = make_vim_editor();
5164 editor.set_text("hello world".to_string());
5165 editor.take_selection_for_external_paste();
5166 assert_eq!(editor.get_text(), "hello world");
5167 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5168 }
5169
5170 #[test]
5175 fn vim_visual_copy_is_read_only_and_does_not_grow_selection() {
5176 let mut editor = make_vim_editor();
5177 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5178 editor.set_text("hello world".to_string());
5179 editor.handle_input(
5180 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
5181 &tx,
5182 );
5183 editor.handle_input(
5184 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
5185 &tx,
5186 );
5187 let before = get_ta(&mut editor).selection_range();
5188 assert_eq!(before, Some(((0, 0), (0, 4))));
5189 assert_eq!(
5191 editor.inclusive_visual_range(),
5192 Some(((0, 0), (0, 5))),
5193 "copy must read the inclusive range including the cursor char"
5194 );
5195 editor.copy_selection_to_clipboard(&tx);
5197 editor.copy_selection_to_clipboard(&tx);
5198 assert_eq!(
5199 get_ta(&mut editor).selection_range(),
5200 before,
5201 "copy must not move the cursor or grow the live selection"
5202 );
5203 }
5204
5205 #[test]
5215 fn vim_sync_collapsed_sel_stays_normal() {
5216 let mut editor = make_vim_editor();
5217 editor.set_text("hello world".to_string());
5218
5219 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5221
5222 editor.backend.sync_mouse_selection(false);
5225 assert_eq!(
5226 vim_mode(&editor),
5227 EditorMode::Normal,
5228 "collapsed (bare click) selection must not enter Visual mode"
5229 );
5230 }
5231
5232 #[test]
5234 fn vim_sync_real_sel_enters_visual() {
5235 let mut editor = make_vim_editor();
5236 editor.set_text("hello world".to_string());
5237
5238 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5240
5241 editor.backend.sync_mouse_selection(true);
5243 assert_eq!(
5244 vim_mode(&editor),
5245 EditorMode::Visual,
5246 "real drag selection must enter Visual mode"
5247 );
5248 }
5249
5250 #[test]
5254 fn vim_find_bar_captures_typing_not_cursor() {
5255 let mut editor = make_vim_editor();
5256 editor.set_text("hello world\nsecond line".to_string());
5257 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5258
5259 editor.open_or_advance_search();
5261 assert!(editor.search.is_some(), "find bar must be open");
5262
5263 editor.handle_input(
5265 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
5266 &tx,
5267 );
5268 editor.handle_input(
5269 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
5270 &tx,
5271 );
5272
5273 let q = editor
5277 .search
5278 .as_ref()
5279 .map(|s| s.input.value().to_string())
5280 .unwrap_or_default();
5281 assert_eq!(q, "lo", "find query must capture typed characters");
5282
5283 assert_eq!(
5286 editor.get_text(),
5287 "hello world\nsecond line",
5288 "buffer must not be modified while find bar is open"
5289 );
5290
5291 assert_eq!(
5297 editor.cursor_pos().1,
5298 3,
5299 "cursor must jump to the search match (col 3), not to a vim motion position"
5300 );
5301 }
5302
5303 #[test]
5308 fn vim_search_enter_steps_and_esc_keeps_the_pattern_for_n() {
5309 let mut editor = make_vim_editor();
5310 editor.set_text("lo xx lo yy lo".to_string());
5312 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5313
5314 editor.open_or_advance_search();
5316 assert!(editor.search.is_some(), "find bar must open");
5317
5318 editor.handle_input(
5320 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
5321 &tx,
5322 );
5323 editor.handle_input(
5324 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
5325 &tx,
5326 );
5327
5328 editor.handle_input(
5332 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
5333 &tx,
5334 );
5335 assert!(
5336 editor.search.is_some(),
5337 "find bar stays open on Enter — it steps, it does not confirm"
5338 );
5339 let (_, c1) = editor.cursor_pos();
5340 assert_eq!(c1, 6, "Enter must step to the 2nd 'lo' at col 6");
5341
5342 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
5346 assert!(editor.search.is_none(), "Esc must close the find bar");
5347
5348 editor.handle_input(
5350 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
5351 &tx,
5352 );
5353 let (_, c2) = editor.cursor_pos();
5354 assert_eq!(c2, 12, "'n' must jump to the 3rd 'lo' at col 12");
5355
5356 assert_eq!(editor.get_text(), "lo xx lo yy lo");
5358 }
5359}