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)]
247pub enum LinkTarget {
248 Note(String),
250 Label(String),
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
262pub enum EditorClaim {
263 #[default]
264 None,
265 FindBar,
266 Autocomplete,
267}
268
269struct EditorHostSnapshot {
276 snap: EditorSnapshot,
277 cursor_screen: Option<(u16, u16)>,
278 cache_key: Option<NonZeroU64>,
279}
280
281impl AutocompleteHost for EditorHostSnapshot {
282 fn buffer_snapshot(&self) -> EditorSnapshot {
283 EditorSnapshot::of_buffer(
288 self.snap.text.clone(),
289 self.snap.cursor,
290 self.snap.content_revision,
291 )
292 }
293 fn cache_key(&self) -> Option<NonZeroU64> {
294 self.cache_key
295 }
296 fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
297 Some(self.cursor_screen.unwrap_or((0, 0)))
311 }
312}
313
314fn build_editor_host_snapshot(
320 backend: &BackendState,
321 content_revision: NonZeroU64,
322 cursor_screen: Option<(u16, u16)>,
323) -> Option<EditorHostSnapshot> {
324 if !backend.is_textarea() {
325 return None;
326 }
327 Some(EditorHostSnapshot {
328 snap: snapshot_from_backend(backend, content_revision),
329 cursor_screen,
330 cache_key: Some(content_revision),
331 })
332}
333
334pub struct TextEditorComponent {
338 backend: BackendState,
339 rect: Rect,
341 key_bindings: KeyBindings,
342 view: MarkdownEditorView,
343 revs: Revisions,
352 selection: Option<((usize, usize), (usize, usize))>,
355 nvim_host: NvimHost,
358 search: Option<find_bar::FindBar>,
360 autocomplete: Option<AutocompleteController>,
364 autocomplete_vault: Option<Arc<NoteVault>>,
368 autocomplete_redraw_bound: bool,
373 full_parse_task: SingleSlotTask<()>,
380 layout_task: SingleSlotTask<()>,
387 last_insert_session: bool,
391 pub wants_context_menu: bool,
394 search_needles: Vec<String>,
398 full_parse_tx: tokio::sync::mpsc::UnboundedSender<(u64, ParsedBuffer)>,
399 full_parse_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, ParsedBuffer)>,
400 layout_tx: tokio::sync::mpsc::UnboundedSender<(u64, crate::ropetext::Layout)>,
401 layout_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, crate::ropetext::Layout)>,
402 redraw_tx: Option<AppTx>,
406}
407
408impl TextEditorComponent {
409 pub fn new(key_bindings: KeyBindings, settings: &AppSettings) -> Self {
410 let (full_parse_tx, full_parse_rx) = tokio::sync::mpsc::unbounded_channel();
411 let (layout_tx, layout_rx) = tokio::sync::mpsc::unbounded_channel();
412 Self {
413 backend: BackendState::from_settings(
414 &settings.editor_backend,
415 settings.nvim_path.as_ref(),
416 ),
417 rect: Rect::default(),
418 key_bindings,
419 view: MarkdownEditorView::new(),
420 revs: Revisions::new(),
421 selection: None,
422 nvim_host: NvimHost::new(),
423 search: None,
424 autocomplete: None,
425 autocomplete_vault: None,
426 autocomplete_redraw_bound: false,
427 full_parse_task: SingleSlotTask::empty(),
428 layout_task: SingleSlotTask::empty(),
429 last_insert_session: false,
430 wants_context_menu: false,
431 search_needles: Vec::new(),
432 full_parse_tx,
433 full_parse_rx,
434 layout_tx,
435 layout_rx,
436 redraw_tx: None,
437 }
438 }
439
440 pub fn set_vault(&mut self, vault: Arc<NoteVault>) {
445 self.autocomplete_vault = Some(vault.clone());
446 if self.backend.is_textarea() {
447 self.autocomplete = Some(AutocompleteController::new(
448 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
449 AutocompleteMode::Both,
450 ));
451 }
452 }
453
454 fn ensure_autocomplete_for_textarea(&mut self) {
459 if self.autocomplete.is_some() {
460 return;
461 }
462 if !self.backend.is_textarea() {
463 return;
464 }
465 let Some(vault) = self.autocomplete_vault.clone() else {
466 return;
467 };
468 self.autocomplete = Some(AutocompleteController::new(
469 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
470 AutocompleteMode::Both,
471 ));
472 self.autocomplete_redraw_bound = false;
475 }
476
477 #[allow(dead_code)]
484 fn autocomplete_host_snapshot(&self) -> Option<EditorHostSnapshot> {
485 build_editor_host_snapshot(
486 &self.backend,
487 self.revs.current(),
488 self.view.last_cursor_screen,
489 )
490 }
491
492 fn poll_autocomplete(&mut self) {
495 if let Some(controller) = self.autocomplete.as_mut() {
496 controller.poll_results();
497 }
498 }
499
500 fn textarea_cursor(&self) -> Option<(usize, usize)> {
504 let ta = self.backend.as_textarea()?;
505 Some(cursor_tuple(ta))
506 }
507
508 fn refresh_autocomplete_if_open(&mut self) {
509 if !self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
511 return;
512 }
513 let Some(snapshot) = build_editor_host_snapshot(
517 &self.backend,
518 self.revs.current(),
519 self.view.last_cursor_screen,
520 ) else {
521 self.close_autocomplete();
522 return;
523 };
524 if let Some(controller) = self.autocomplete.as_mut() {
525 controller.refresh_if_open(&snapshot);
526 }
527 }
528
529 fn sync_autocomplete(&mut self) {
533 let Some(controller) = self.autocomplete.as_ref() else {
534 return; };
536
537 if !controller.is_open() {
550 let Some(ta) = self.backend.as_textarea() else {
551 return;
552 };
553 let (row, col) = cursor_tuple(ta);
554 let line = ta.row(row).unwrap_or_default();
555 if !has_trigger_before_cursor(&line, col) {
556 return;
557 }
558 }
559
560 let Some(snapshot) = build_editor_host_snapshot(
564 &self.backend,
565 self.revs.current(),
566 self.view.last_cursor_screen,
567 ) else {
568 if let Some(c) = self.autocomplete.as_mut() {
569 c.close();
570 }
571 return;
572 };
573 if let Some(controller) = self.autocomplete.as_mut() {
574 controller.sync(&snapshot);
575 }
576 }
577
578 pub fn text(&self) -> crate::ropetext::Text {
585 match &self.backend {
586 BackendState::Textarea(tb) => tb.ta.text().clone(),
587 BackendState::Nvim(_) => crate::ropetext::Text::new(),
588 }
589 }
590
591 pub fn view_snapshot(&self) -> EditorSnapshot {
610 snapshot_from_backend(&self.backend, self.revs.current())
611 }
612
613 pub fn cursor_pos(&self) -> (usize, usize) {
617 self.backend.cursor()
618 }
619
620 pub fn set_search_needles(&mut self, needles: Vec<String>) {
624 self.search_needles = needles
625 .into_iter()
626 .map(|n| n.to_lowercase())
627 .filter(|n| !n.is_empty())
628 .collect();
629 self.revs.arm_needles();
630 }
631
632 pub fn set_text(&mut self, text: String) {
633 if text == self.get_text() {
640 self.revs.mark_saved_current();
641 if let Some(nvim) = self.backend.as_nvim() {
642 nvim.mark_clean();
643 }
644 return;
645 }
646 match &mut self.backend {
647 BackendState::Textarea(tb) => {
648 tb.ta.replace(crate::ropetext::Text::from(text.as_str()));
649 }
650 BackendState::Nvim(nvim) => {
651 nvim.set_text(&text);
652 }
653 }
654 self.backend.reset_input_state();
655 self.bump_content();
656 let reconstructed = self.get_text();
657 self.mark_saved(reconstructed);
658 self.close_autocomplete();
661 self.search = None;
670 self.selection = None;
671 self.view.note_bulk_edit();
676 }
677
678 pub fn get_text(&self) -> String {
679 self.backend.text()
680 }
681
682 pub fn content_revision(&self) -> NonZeroU64 {
689 self.revs.current()
690 }
691
692 pub fn mark_saved_at_revision(&mut self, rev: NonZeroU64) {
702 if !self.revs.mark_saved_at(rev) {
703 return;
704 }
705 self.interrupt_typing();
709 if let Some(nvim) = self.backend.as_nvim() {
710 nvim.mark_clean();
711 }
712 }
713
714 pub fn mark_saved(&mut self, text: String) {
722 self.interrupt_typing();
723 let matches = text == self.get_text();
724 if matches {
725 if let Some(nvim) = self.backend.as_nvim() {
726 nvim.mark_clean();
727 }
728 self.revs.mark_saved_current();
729 } else {
730 self.revs.mark_diverged();
735 }
736 }
737
738 fn interrupt_typing(&mut self) {
755 self.view.clear_visual_goal();
756 if self.backend.modal_is_insert().unwrap_or(false) {
757 return;
758 }
759 if let Some((_, run)) = self.backend.as_textarea_parts_mut() {
760 run.end();
761 }
762 }
763
764 fn sync_insert_session(&mut self) {
771 let in_insert = self.backend.modal_is_insert().unwrap_or(false);
772 if self.last_insert_session == in_insert {
773 return;
774 }
775 self.last_insert_session = in_insert;
776 if let Some((_, run)) = self.backend.as_textarea_parts_mut() {
777 run.end();
778 }
779 }
780
781 pub fn is_dirty(&self) -> bool {
782 match &self.backend {
783 BackendState::Textarea(_) => self.revs.is_dirty(),
784 BackendState::Nvim(nvim) => nvim.snapshot().dirty,
785 }
786 }
787
788 pub fn space_leads(&self) -> bool {
797 self.backend.space_leads()
798 }
799
800 pub fn claim(&self) -> EditorClaim {
805 if self.search.is_some() {
806 EditorClaim::FindBar
807 } else if self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
808 EditorClaim::Autocomplete
809 } else {
810 EditorClaim::None
811 }
812 }
813
814 pub fn link_at_cursor(&self) -> Option<LinkTarget> {
817 let (_row, col, line) = match &self.backend {
818 BackendState::Textarea(tb) => {
819 let (row, col) = cursor_tuple(&tb.ta);
820 let line = tb.ta.row(row)?.into_owned();
821 (row, col, line)
822 }
823 BackendState::Nvim(nvim) => {
824 let snap = nvim.snapshot();
825 let (row, col) = snap.cursor;
826 let line = snap.lines.get(row)?.to_string();
827 (row, col, line)
828 }
829 };
830
831 if let Some(span) = kimun_core::note::scan::link_char_spans(&line)
834 .into_iter()
835 .find(|s| s.start <= col && col < s.end)
836 {
837 return Some(LinkTarget::Note(span.target));
838 }
839
840 let parsed = self::markdown::ParsedLine::parse(&line);
842 parsed
843 .elements
844 .iter()
845 .find(|e| {
846 e.kind == self::markdown::ElementKind::Label
847 && col >= e.start_char
848 && col < e.end_char
849 })
850 .map(|e| {
851 let span: String = line
852 .chars()
853 .skip(e.start_char)
854 .take(e.end_char - e.start_char)
855 .collect();
856 let name = span.trim_start_matches('#').to_string();
857 LinkTarget::Label(name)
858 })
859 }
860
861 fn copy_selection_to_clipboard(&mut self, tx: &AppTx) {
867 let text = {
868 let selected = self
877 .inclusive_visual_range()
878 .zip(self.backend.as_textarea())
879 .and_then(|(range, ta)| selection_text_in(ta, range));
880 match selected {
881 Some(t) if !t.is_empty() => t,
882 _ => {
883 tx.send(AppEvent::FlashMessage("nothing to copy".into()))
884 .ok();
885 return;
886 }
887 }
888 };
889 crate::components::yank(text, "copied", tx);
890 }
891
892 fn inclusive_visual_range(&self) -> Option<((usize, usize), (usize, usize))> {
898 let charwise = self.backend.selection_includes_cursor();
899 let ta = self.backend.as_textarea()?;
900 let (start, (er, ec)) = ta.selection_range()?;
901 let end = if charwise {
902 let len = ta.row(er).map(|l| l.chars().count()).unwrap_or(ec);
903 (er, (ec + 1).min(len))
904 } else {
905 (er, ec)
906 };
907 Some((start, end))
908 }
909
910 fn paste_from_clipboard(&mut self, tx: &AppTx) {
914 let text = match crate::components::with_clipboard(|c| c.get_text()) {
915 Ok(t) if !t.is_empty() => t,
916 Ok(_) => {
917 tx.send(AppEvent::FlashMessage("clipboard is empty".into()))
918 .ok();
919 return;
920 }
921 Err(e) => {
922 tx.send(AppEvent::FlashMessage(format!("clipboard: {e}")))
923 .ok();
924 return;
925 }
926 };
927 self.paste_text(&text, tx);
928 tx.send(AppEvent::FlashMessage("pasted".into())).ok();
932 }
933
934 fn extend_visual_selection_inclusive(&mut self) {
951 if !self.backend.selection_includes_cursor() {
952 return;
953 }
954 if let Some((start, end)) = self.inclusive_visual_range()
955 && let Some(ta) = self.backend.as_textarea_mut()
956 {
957 set_selection(ta, start, end);
958 }
959 }
960
961 pub fn paste_text(&mut self, text: &str, tx: &AppTx) {
962 if text.is_empty() {
963 return;
964 }
965 if self.search.is_some() {
972 if let (Some(bar), BackendState::Textarea(tb)) =
973 (self.search.as_mut(), &mut self.backend)
974 {
975 bar.paste(text, &mut tb.ta);
976 }
977 self.apply_edit_outcome();
978 return;
979 }
980 self.extend_visual_selection_inclusive();
981 match &mut self.backend {
982 BackendState::Textarea(tb) => {
983 let selection = linkable_url(text).and_then(|_| selection_text(&tb.ta));
984 let wrapped = try_build_markdown_link(text, selection.as_deref());
985 let insert = wrapped.as_deref().unwrap_or(text).to_string();
986 tb.ta.edit(|ta| {
989 if ta.selection_range().is_some() {
990 ta.cut();
991 }
992 ta.insert_str(insert);
993 });
994 self.selection = tb.ta.selection_range();
995 self.apply_edit_outcome();
996 }
997 BackendState::Nvim(nvim) => {
998 nvim.paste(text, tx.clone());
999 self.bump_content();
1000 }
1001 }
1002 self.bind_autocomplete_redraw(tx);
1006 self.sync_autocomplete();
1007 }
1008
1009 pub fn insert_at_cursor(&mut self, text: &str, tx: &AppTx) {
1014 if matches!(self.backend, BackendState::Nvim(_)) {
1015 self.paste_text(text, tx);
1016 return;
1017 }
1018 self.take_selection_for_external_paste();
1025 if let Some(ta) = self.backend.as_textarea_mut() {
1026 ta.insert_str(text);
1027 self.selection = ta.selection_range();
1028 self.apply_edit_outcome();
1029 }
1030 self.bind_autocomplete_redraw(tx);
1033 self.sync_autocomplete();
1034 }
1035
1036 pub fn take_clipboard_image(&mut self) -> Option<ClipboardImage> {
1046 let img = crate::components::with_clipboard(|c| c.get_image()).ok()?;
1047 Some(ClipboardImage {
1048 width: img.width,
1049 height: img.height,
1050 rgba: img.bytes.into_owned(),
1051 })
1052 }
1053
1054 pub fn take_selection_for_external_paste(&mut self) {
1063 self.extend_visual_selection_inclusive();
1064 let cut = if let Some(ta) = self.backend.as_textarea_mut() {
1065 let cut = ta.selection_range().is_some() && ta.cut();
1066 self.selection = ta.selection_range();
1067 cut
1068 } else {
1069 false
1070 };
1071 if cut {
1072 self.apply_edit_outcome();
1073 }
1074 self.backend.sync_mouse_selection(false);
1077 }
1078
1079 fn wrap_selection(&mut self, open: &str, close: &str) -> bool {
1085 self.extend_visual_selection_inclusive();
1089 let Some(ta) = self.backend.as_textarea_mut() else {
1090 return false;
1091 };
1092 let Some(((sr, sc), (er, ec))) = ta.selection_range() else {
1093 return false;
1094 };
1095 let Some(text) = selection_text(ta) else {
1096 return false;
1097 };
1098 ta.insert_str(format!("{open}{text}{close}"));
1099 let shift = open.chars().count();
1103 let inner_end_col = if sr == er { ec + shift } else { ec };
1104 set_selection(ta, (sr, sc + shift), (er, inner_end_col));
1105 self.selection = ta.selection_range();
1106 self.interrupt_typing();
1110 self.apply_edit_outcome();
1111 true
1112 }
1113
1114 pub fn apply_text_action(&mut self, action: TextAction) {
1117 let marker = match action {
1118 TextAction::Bold => "**",
1119 TextAction::Italic => "*",
1120 TextAction::Strikethrough => "~~",
1121 _ => return,
1122 };
1123 if self.wrap_selection(marker, marker) {
1124 return;
1125 }
1126 self.interrupt_typing();
1127 let Some(ta) = self.backend.as_textarea_mut() else {
1128 return;
1129 };
1130 ta.insert_str(format!("{marker}{marker}"));
1131 for _ in 0..marker.len() {
1132 ta.move_cursor(CursorMove::Back);
1133 }
1134 self.selection = ta.selection_range();
1135 self.apply_edit_outcome();
1136 }
1137
1138 pub fn smart_enter(&mut self) -> bool {
1143 enum Action {
1144 ClearLine { chars: usize },
1145 InsertPrefix(String),
1146 Dedent,
1147 }
1148 let action = {
1149 let Some(ta) = self.backend.as_textarea() else {
1150 return false;
1151 };
1152 if ta
1155 .selection_range()
1156 .is_some_and(|(start, end)| start != end)
1157 {
1158 return false;
1159 }
1160 let (row, col) = cursor_tuple(ta);
1161 let Some(line) = ta.row(row) else {
1162 return false;
1163 };
1164 let total_chars = line.chars().count();
1165 if col != total_chars {
1166 return false;
1167 }
1168 let ws_end = markdown::leading_ws_byte_len(&line);
1170 let (ws, after_ws) = line.split_at(ws_end);
1171 if let Some(marker_len) = markdown::list_marker_len(after_ws) {
1172 if after_ws.len() == marker_len {
1173 if ws_end > 0 {
1176 Action::Dedent
1177 } else {
1178 Action::ClearLine { chars: total_chars }
1179 }
1180 } else {
1181 let marker_str = &after_ws[..marker_len];
1182 let next_marker = increment_ordered_marker(marker_str)
1183 .unwrap_or_else(|| marker_str.to_string());
1184 Action::InsertPrefix(format!("{ws}{next_marker}"))
1185 }
1186 } else if ws_end > 0 && total_chars == ws_end {
1187 Action::Dedent
1188 } else if ws_end > 0 {
1189 Action::InsertPrefix(ws.to_string())
1190 } else {
1191 return false;
1192 }
1193 };
1194
1195 match action {
1196 Action::Dedent => {
1197 self.indent_lines(true);
1198 return true;
1199 }
1200 Action::ClearLine { chars } => {
1201 let Some(ta) = self.backend.as_textarea_mut() else {
1202 unreachable!()
1203 };
1204 ta.move_cursor(CursorMove::Head);
1205 ta.delete_str(chars);
1206 }
1207 Action::InsertPrefix(prefix) => {
1208 let Some(ta) = self.backend.as_textarea_mut() else {
1209 unreachable!()
1210 };
1211 ta.edit(|ta| {
1214 ta.insert_newline();
1215 ta.insert_str(prefix);
1216 });
1217 }
1218 }
1219 let Some(ta) = self.backend.as_textarea() else {
1220 unreachable!()
1221 };
1222 self.selection = ta.selection_range();
1223 self.apply_edit_outcome();
1224 true
1225 }
1226
1227 pub fn jump_to_heading(&mut self, heading: &str) {
1232 let Some(ta) = self.backend.as_textarea_mut() else {
1233 return;
1234 };
1235 fn normalise(text: &str) -> String {
1240 text.trim()
1241 .trim_end_matches('#')
1242 .trim()
1243 .replace(['*', '_', '`'], "")
1244 }
1245 let wanted = normalise(heading);
1246 let row = (0..ta.row_count()).find(|&row| {
1247 let Some(line) = ta.row(row) else {
1248 return false;
1249 };
1250 let t = line.trim_start();
1251 let stripped = t.trim_start_matches('#');
1252 stripped.len() != t.len() && normalise(stripped) == wanted
1253 });
1254 if let Some(row) = row {
1255 ta.jump_to(row, 0);
1256 }
1257 }
1258
1259 pub fn indent_lines(&mut self, dedent: bool) {
1263 let Some(ta) = self.backend.as_textarea_mut() else {
1264 return;
1265 };
1266 let tab_len = ta.indent_width() as usize;
1267 let hard_tab = ta.hard_tab_indent();
1268 let indent: String = if hard_tab {
1269 "\t".to_string()
1270 } else {
1271 " ".repeat(tab_len)
1272 };
1273 if indent.is_empty() {
1274 return;
1275 }
1276 let indent_chars = indent.len();
1277
1278 let sel = ta.selection_range();
1279 let saved_cursor = if sel.is_none() {
1280 Some(cursor_tuple(ta))
1281 } else {
1282 None
1283 };
1284 let (start_row, end_row) = match sel {
1285 Some(((sr, _), (er, ec))) => {
1286 let last = if ec == 0 && er > sr { er - 1 } else { er };
1289 (sr, last)
1290 }
1291 None => {
1292 let (r, _) = saved_cursor.unwrap();
1293 (r, r)
1294 }
1295 };
1296
1297 let row_count = end_row.saturating_sub(start_row) + 1;
1298 let mut row_deltas: Vec<isize> = Vec::with_capacity(row_count);
1299 let mut any_change = false;
1300
1301 ta.cancel_selection();
1306
1307 ta.edit(|ta| {
1310 for row in start_row..=end_row {
1311 if dedent {
1312 let count = {
1313 let line = ta.row(row).unwrap_or_default();
1314 let max_remove = if hard_tab { 1 } else { tab_len };
1315 let mut count = 0usize;
1316 for (i, c) in line.chars().enumerate() {
1317 if i >= max_remove {
1318 break;
1319 }
1320 if c == '\t' {
1321 count += 1;
1322 break;
1323 } else if c == ' ' && !hard_tab {
1324 count += 1;
1325 } else {
1326 break;
1327 }
1328 }
1329 count
1330 };
1331 if count > 0 {
1332 ta.jump_to(row, 0);
1333 ta.delete_str(count);
1334 any_change = true;
1335 }
1336 row_deltas.push(-(count as isize));
1337 } else {
1338 ta.jump_to(row, 0);
1339 ta.insert_str(&indent);
1340 row_deltas.push(indent_chars as isize);
1341 any_change = true;
1342 }
1343 }
1344 });
1345
1346 let adj = |row: usize, col: usize| -> usize {
1347 if row >= start_row && row <= end_row {
1348 let d = row_deltas[row - start_row];
1349 if d >= 0 {
1350 col + d as usize
1351 } else {
1352 col.saturating_sub((-d) as usize)
1353 }
1354 } else {
1355 col
1356 }
1357 };
1358
1359 match sel {
1360 Some(((ssr, ssc), (ser, sec))) => {
1361 set_selection(ta, (ssr, adj(ssr, ssc)), (ser, adj(ser, sec)));
1362 }
1363 None => {
1364 let (cr, cc) = saved_cursor.expect("captured when sel is None");
1365 let new_col = adj(cr, cc);
1366 ta.jump_to(cr, new_col);
1367 }
1368 }
1369
1370 if any_change {
1371 self.selection = ta.selection_range();
1372 self.apply_edit_outcome();
1373 }
1374 }
1375}
1376
1377impl TextEditorComponent {
1378 #[inline]
1388 fn bump_content(&mut self) {
1389 self.revs.bump();
1390 }
1391
1392 fn maybe_recover_from_dead_nvim(&mut self) {
1394 if self.backend.recover_from_dead_nvim() {
1395 self.ensure_autocomplete_for_textarea();
1399 }
1400 }
1401
1402 fn handle_nvim_key(
1407 &mut self,
1408 key: &ratatui::crossterm::event::KeyEvent,
1409 tx: &AppTx,
1410 ) -> Option<EventState> {
1411 let nvim = self.backend.as_nvim()?;
1415 self.nvim_host.handle_key(nvim, key, tx);
1420 Some(EventState::Consumed)
1421 }
1422
1423 pub fn open_or_advance_search(&mut self) {
1427 if !self.backend.is_textarea() {
1428 return;
1429 }
1430 if self.search.is_some() {
1431 self.dispatch_bar(|bar, buf| {
1432 bar.advance(buf, false);
1433 find_bar::KeyOutcome::default()
1434 });
1435 return;
1436 }
1437 self.close_autocomplete();
1440 self.search = Some(find_bar::FindBar::new());
1441 }
1442
1443 pub fn open_replace(&mut self) {
1446 if !self.backend.is_textarea() {
1447 return;
1448 }
1449 if self.search.is_none() {
1450 self.close_autocomplete();
1451 self.search = Some(find_bar::FindBar::new());
1452 }
1453 if let Some(bar) = self.search.as_mut() {
1454 bar.reveal_replace();
1455 }
1456 }
1457
1458 fn search_repeat(&mut self, backward: bool) {
1461 let BackendState::Textarea(tb) = &mut self.backend else {
1462 return;
1463 };
1464 self.selection = if tb.ta.search_repeat(backward) {
1467 tb.ta.match_at_cursor()
1468 } else {
1469 None
1470 };
1471 }
1472
1473 fn dispatch_bar(
1476 &mut self,
1477 f: impl FnOnce(&mut find_bar::FindBar, &mut RopeBuffer) -> find_bar::KeyOutcome,
1478 ) -> bool {
1479 let BackendState::Textarea(tb) = &mut self.backend else {
1480 return false;
1481 };
1482 let Some(bar) = self.search.as_mut() else {
1483 return false;
1484 };
1485 let outcome = f(bar, &mut tb.ta);
1486 if outcome.close {
1487 self.search = None;
1488 tb.ta.cancel_selection();
1494 self.selection = None;
1495 }
1496 self.apply_edit_outcome();
1497 true
1498 }
1499
1500 fn dispatch_to_find_bar(&mut self, key: &ratatui::crossterm::event::KeyEvent) -> bool {
1502 self.dispatch_bar(|bar, buf| bar.handle_key(key, buf))
1503 }
1504
1505 #[cfg(test)]
1508 fn replace_preview(&self) -> Option<find_replace::Preview> {
1509 let bar = self.search.as_ref()?;
1510 let buf = self.backend.as_textarea()?;
1511 bar.preview(buf)
1512 }
1513
1514 pub fn close_autocomplete(&mut self) {
1518 if let Some(c) = self.autocomplete.as_mut() {
1519 c.close();
1520 }
1521 }
1522
1523 pub fn set_redraw_tx(&mut self, tx: &AppTx) {
1528 self.bind_autocomplete_redraw(tx);
1529 }
1530
1531 fn bind_autocomplete_redraw(&mut self, tx: &AppTx) {
1540 if self.redraw_tx.is_none() {
1541 self.redraw_tx = Some(tx.clone());
1542 }
1543 if self.autocomplete_redraw_bound {
1544 return;
1545 }
1546 if let Some(c) = self.autocomplete.as_mut() {
1547 c.set_redraw_callback(redraw_callback(tx.clone()));
1548 self.autocomplete_redraw_bound = true;
1549 }
1550 }
1551
1552 fn apply_edit_outcome(&mut self) -> bool {
1563 let Some(outcome) = self.backend.as_textarea_mut().map(|ta| ta.take_outcome()) else {
1564 return false;
1565 };
1566 if outcome.changed {
1567 self.bump_content();
1568 }
1569 if outcome.bulk {
1570 self.view.note_bulk_edit();
1571 }
1572 if let Some(rows) = outcome.damage {
1573 self.view.note_damage(rows, outcome.line_delta);
1574 }
1575 outcome.changed
1576 }
1577
1578 fn undo_grouped(&mut self) -> bool {
1581 let moved = self.backend.as_textarea_mut().is_some_and(|ta| ta.undo());
1582 if moved {
1583 self.selection = self
1584 .backend
1585 .as_textarea()
1586 .and_then(|ta| ta.selection_range());
1587 }
1588 moved
1589 }
1590
1591 fn redo_grouped(&mut self) -> bool {
1593 let moved = self.backend.as_textarea_mut().is_some_and(|ta| ta.redo());
1594 if moved {
1595 self.selection = self
1596 .backend
1597 .as_textarea()
1598 .and_then(|ta| ta.selection_range());
1599 }
1600 moved
1601 }
1602
1603 fn handle_textarea_key(
1605 &mut self,
1606 key: &ratatui::crossterm::event::KeyEvent,
1607 tx: &AppTx,
1608 ) -> EventState {
1609 let stroke = plain_keys::operation(*key).and_then(|op| match op {
1619 plain_keys::Operation::Insert(c) => Some(typing_run::Stroke::Insert(c)),
1620 plain_keys::Operation::InsertNewline => Some(typing_run::Stroke::Insert('\n')),
1621 plain_keys::Operation::DeleteBack | plain_keys::Operation::DeleteForward => {
1622 Some(typing_run::Stroke::Delete)
1623 }
1624 _ => None,
1625 });
1626 if stroke.is_none()
1627 && let Some((_, run)) = self.backend.as_textarea_parts_mut()
1628 {
1629 run.end();
1630 }
1631
1632 if key.modifiers == KeyModifiers::CONTROL {
1634 match key.code {
1635 KeyCode::Char('c') => {
1636 self.copy_selection_to_clipboard(tx);
1637 return EventState::Consumed;
1638 }
1639 KeyCode::Char('v') => {
1640 self.paste_from_clipboard(tx);
1641 return EventState::Consumed;
1642 }
1643 KeyCode::Char('x') => {
1644 self.copy_selection_to_clipboard(tx);
1645 let cut = if let Some(ta) = self.backend.as_textarea_mut() {
1646 let cut = ta.cut();
1652 self.selection = ta.selection_range();
1653 cut
1654 } else {
1655 false
1656 };
1657 if cut {
1658 self.apply_edit_outcome();
1659 }
1660 return EventState::Consumed;
1661 }
1662 _ => {}
1663 }
1664 }
1665
1666 if key.modifiers & !KeyModifiers::SHIFT == KeyModifiers::CONTROL {
1671 match key.code {
1672 KeyCode::Char('z') if !key.modifiers.contains(KeyModifiers::SHIFT) => {
1673 if self.undo_grouped() {
1674 self.apply_edit_outcome();
1675 }
1676 return EventState::Consumed;
1677 }
1678 KeyCode::Char('y') | KeyCode::Char('Z') => {
1679 if self.redo_grouped() {
1680 self.apply_edit_outcome();
1681 }
1682 return EventState::Consumed;
1683 }
1684 _ => {}
1685 }
1686 }
1687
1688 match (key.modifiers, key.code) {
1700 (m, KeyCode::Tab)
1701 if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
1702 {
1703 self.indent_lines(m.contains(KeyModifiers::SHIFT));
1704 return EventState::Consumed;
1705 }
1706 (_, KeyCode::BackTab) => {
1707 self.indent_lines(true);
1708 return EventState::Consumed;
1709 }
1710 _ => {}
1711 }
1712 if key.code == KeyCode::Enter && key.modifiers.is_empty() && self.smart_enter() {
1713 return EventState::Consumed;
1714 }
1715
1716 if let KeyCode::Char(c) = key.code
1723 && (key.modifiers & !KeyModifiers::SHIFT).is_empty()
1724 && let Some((open, close)) = surround_pair(c)
1725 && self.wrap_selection(open, close)
1726 {
1727 return EventState::Consumed;
1728 }
1729
1730 self.sync_insert_session();
1736
1737 let in_insert_session = self.last_insert_session;
1739 let Some((ta, run)) = self.backend.as_textarea_parts_mut() else {
1740 unreachable!("handle_textarea_key called with non-Textarea backend")
1741 };
1742 if let Some(op) = plain_keys::operation(*key) {
1747 let vertical = match op {
1751 plain_keys::Operation::Move {
1752 to: CursorMove::Up,
1753 extend,
1754 } => Some((false, extend)),
1755 plain_keys::Operation::Move {
1756 to: CursorMove::Down,
1757 extend,
1758 } => Some((true, extend)),
1759 _ => None,
1760 };
1761 if vertical.is_none() {
1762 self.view.clear_visual_goal();
1763 }
1764 if let Some(stroke) = stroke {
1770 {
1771 let now = std::time::Instant::now();
1772 let carries_on = if in_insert_session {
1777 run.continues_session(stroke, now)
1778 } else {
1779 run.continues(stroke, now)
1780 };
1781 if carries_on {
1782 ta.continue_group();
1783 }
1784 }
1785 }
1786
1787 let changed = match vertical {
1788 Some((down, extend)) if self.view.move_cursor_visually(ta, down, extend) => false,
1791 _ => plain_keys::apply(op, ta),
1792 };
1793 self.selection = ta.selection_range();
1794 if changed {
1795 self.apply_edit_outcome();
1796 }
1797 }
1798 EventState::Consumed
1802 }
1803
1804 fn handle_mouse(
1806 &mut self,
1807 mouse: &ratatui::crossterm::event::MouseEvent,
1808 tx: &AppTx,
1809 ) -> EventState {
1810 let r = self.rect;
1811 let in_bounds = mouse.column >= r.x
1812 && mouse.column < r.x + r.width
1813 && mouse.row >= r.y
1814 && mouse.row < r.y + r.height;
1815 if !in_bounds {
1816 return EventState::NotConsumed;
1817 }
1818 self.interrupt_typing();
1822 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right))
1826 && self.selection.is_none_or(|(start, end)| start == end)
1827 {
1828 self.wants_context_menu = true;
1829 return EventState::Consumed;
1830 }
1831 if !self.backend.is_textarea() {
1835 return EventState::NotConsumed;
1836 }
1837 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
1839 self.copy_selection_to_clipboard(tx);
1840 self.selection = if let Some(ta) = self.backend.as_textarea() {
1841 ta.selection_range()
1842 } else {
1843 None
1844 };
1845 return EventState::Consumed;
1846 }
1847 let Some(ta) = self.backend.as_textarea_mut() else {
1849 unreachable!()
1850 };
1851 match mouse.kind {
1852 MouseEventKind::Down(_) => {
1853 ta.cancel_selection();
1854 let (lrow, lcol) = self
1855 .view
1856 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1857 ta.jump_to(lrow as usize, lcol as usize);
1858 ta.start_selection();
1859 }
1860 MouseEventKind::Drag(_) => {
1861 let (lrow, lcol) = self
1862 .view
1863 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1864 ta.jump_to(lrow as usize, lcol as usize);
1865 }
1866 _ => {}
1871 }
1872 self.selection = ta.selection_range();
1873 EventState::Consumed
1876 }
1877}
1878
1879impl Component for TextEditorComponent {
1884 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
1885 self.maybe_recover_from_dead_nvim();
1886 self.bind_autocomplete_redraw(tx);
1887
1888 match event {
1889 InputEvent::Key(key) => {
1890 let popup_open = self.autocomplete.as_ref().is_some_and(|c| c.is_open());
1898 if popup_open
1899 && let Some(host) = build_editor_host_snapshot(
1900 &self.backend,
1901 self.revs.current(),
1902 self.view.last_cursor_screen,
1903 )
1904 && let Some(controller) = self.autocomplete.as_mut()
1905 {
1906 match controller.handle_key(*key, &host) {
1907 HandleKeyOutcome::Accepted(action) => {
1908 self.interrupt_typing();
1909 if let Some(ta) = self.backend.as_textarea_mut() {
1910 ta.edit(|ta| apply_accept_to_textarea(ta, &action));
1911 self.selection = ta.selection_range();
1912 }
1913 self.apply_edit_outcome();
1914 return EventState::Consumed;
1915 }
1916 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
1917 return EventState::Consumed;
1918 }
1919 HandleKeyOutcome::NotHandled => {}
1920 }
1921 }
1922 if self.dispatch_to_find_bar(key) {
1927 self.interrupt_typing();
1931 return EventState::Consumed;
1932 }
1933 if let Some(outcome) = self.backend.vim_handle_key(key) {
1938 use self::vim::VimKeyOutcome;
1939 if !matches!(outcome, VimKeyOutcome::PassThrough) {
1944 self.interrupt_typing();
1945 }
1946 self.apply_edit_outcome();
1950 match outcome {
1951 VimKeyOutcome::TextMutated => {
1952 self.selection = None;
1955 return EventState::Consumed;
1956 }
1957 VimKeyOutcome::CursorOnly => {
1958 self.selection = self
1963 .backend
1964 .as_textarea()
1965 .and_then(|ta| ta.selection_range());
1966 if self.backend.selection_includes_cursor()
1971 && let Some(((sr, sc), (er, ec))) = self.selection
1972 {
1973 let len = self
1974 .backend
1975 .as_textarea()
1976 .and_then(|ta| ta.row(er))
1977 .map(|l| l.chars().count())
1978 .unwrap_or(ec);
1979 self.selection = Some(((sr, sc), (er, (ec + 1).min(len))));
1980 }
1981 self.refresh_autocomplete_if_open();
1982 return EventState::Consumed;
1983 }
1984 VimKeyOutcome::NoOp => return EventState::Consumed,
1985 VimKeyOutcome::PassThrough => { }
1986 VimKeyOutcome::Host(action) => {
1987 use self::vim::VimHostAction;
1988 match action {
1989 VimHostAction::OpenPalette => {
1990 tx.send(AppEvent::ExecuteLeaderAction(
1992 crate::keys::leader::LeaderAction::Palette,
1993 ))
1994 .ok();
1995 }
1996 VimHostAction::OpenSearch { forward: _ } => {
1997 self.open_or_advance_search();
2001 }
2002 VimHostAction::SearchNext => self.search_repeat(false),
2003 VimHostAction::SearchPrev => self.search_repeat(true),
2004 VimHostAction::ClipboardCopy(text) => {
2008 self.selection = None;
2009 crate::components::yank(text, "copied", tx);
2010 }
2011 VimHostAction::ClipboardCut(text) => {
2012 self.selection = None;
2013 crate::components::yank(text, "cut", tx);
2014 }
2015 VimHostAction::ClipboardPaste => {
2021 self.selection = self
2022 .backend
2023 .as_textarea()
2024 .and_then(|ta| ta.selection_range());
2025 self.paste_from_clipboard(tx);
2026 }
2027 }
2028 return EventState::Consumed;
2029 }
2030 }
2031 }
2032 if let Some(state) = self.handle_nvim_key(key, tx) {
2033 return state;
2034 }
2035 let text_rev_before = self.revs.current();
2046 let cursor_before = self.textarea_cursor();
2047 let result = self.handle_textarea_key(key, tx);
2048 let cursor_after = self.textarea_cursor();
2049 if self.revs.current() != text_rev_before {
2050 self.sync_autocomplete();
2051 } else if cursor_before != cursor_after {
2052 self.refresh_autocomplete_if_open();
2053 }
2054 result
2055 }
2056 InputEvent::Mouse(mouse) => {
2057 let text_rev_before = self.revs.current();
2058 let cursor_before = self.textarea_cursor();
2059 let result = self.handle_mouse(mouse, tx);
2060 let cursor_after = self.textarea_cursor();
2061 if self.revs.current() != text_rev_before {
2064 self.sync_autocomplete();
2065 } else if cursor_before != cursor_after {
2066 self.refresh_autocomplete_if_open();
2067 }
2068 if result == EventState::Consumed
2073 && matches!(
2074 mouse.kind,
2075 ratatui::crossterm::event::MouseEventKind::Down(
2076 ratatui::crossterm::event::MouseButton::Left
2077 )
2078 )
2079 {
2080 match self.link_at_cursor() {
2081 Some(LinkTarget::Note(target)) => {
2082 tx.send(AppEvent::FollowLink(target)).ok();
2083 }
2084 Some(LinkTarget::Label(name)) => {
2085 tx.send(AppEvent::FollowLabel(name)).ok();
2086 }
2087 None => {}
2088 }
2089 }
2090 let has_sel = self
2101 .backend
2102 .as_textarea()
2103 .and_then(|ta| ta.selection_range())
2104 .is_some_and(|(s, e)| s != e);
2105 self.backend.sync_mouse_selection(has_sel);
2106 result
2107 }
2108 InputEvent::Paste(_) => EventState::NotConsumed,
2111 }
2112 }
2113
2114 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
2115 let bar_rows: u16 = self.search.as_ref().map_or(0, |bar| bar.rows());
2120 let bar_rows = bar_rows.min(rect.height);
2125 let (editor_rect, search_rect) = if bar_rows > 0 {
2126 (
2127 Rect {
2128 height: rect.height - bar_rows,
2129 ..rect
2130 },
2131 Some(Rect {
2132 y: rect.y + rect.height - bar_rows,
2133 height: bar_rows,
2134 ..rect
2135 }),
2136 )
2137 } else {
2138 (rect, None)
2139 };
2140 self.rect = editor_rect;
2143 let selection = match &self.backend {
2148 BackendState::Textarea(_) => match self.search.as_ref() {
2151 Some(bar) => bar.current_match(),
2152 None => self.selection,
2153 },
2154 BackendState::Nvim(nvim) => {
2155 self.nvim_host
2156 .frame_sync(nvim, editor_rect.width, editor_rect.height)
2157 }
2158 };
2159 while let Ok((generation, buf)) = self.full_parse_rx.try_recv() {
2165 self.view.install_full_parse(generation, buf);
2166 }
2167 while let Ok((generation, layout)) = self.layout_rx.try_recv() {
2171 self.view.install_full_layout(generation, layout);
2172 }
2173
2174 let overlay = match (self.search.as_ref(), self.backend.as_textarea()) {
2185 (Some(bar), Some(buf)) => bar.overlay(buf),
2186 _ => find_bar::BarOverlay::default(),
2187 };
2188 let preview = overlay.preview;
2189 let snap = snapshot_from_backend(&self.backend, self.revs.current());
2190 self.revs.adopt(snap.content_revision);
2196 let (view_lines, preview_spans) = match preview {
2200 None => (None, Vec::new()),
2201 Some(p) => (Some(p.lines), p.spans),
2202 };
2203 match &view_lines {
2204 None => self.view.update(&snap, editor_rect),
2205 Some(lines) => {
2206 let rev = preview_revision(snap.content_revision, lines);
2211 let view_snap = EditorSnapshot::borrowed(lines, snap.cursor, rev);
2212 self.view.update(&view_snap, editor_rect);
2213 }
2214 }
2215 if self.revs.needles_stale() {
2219 self.search_needles.clear();
2220 self.revs.disarm_needles();
2221 }
2222 self.view.set_needles(self.search_needles.clone());
2223
2224 let mut overlays: Vec<view::Overlay> = Vec::new();
2227 if let Some(((sr, sc), (er, ec))) = selection {
2228 for row in sr..=er {
2231 let start = if row == sr { sc } else { 0 };
2232 let end = if row == er { ec } else { usize::MAX };
2233 overlays.push(view::Overlay::new(
2234 row,
2235 start,
2236 end,
2237 view::OverlayKind::Selection,
2238 ));
2239 }
2240 }
2241 overlays.extend(preview_spans.iter().map(|p| {
2242 view::Overlay::new(
2243 p.row,
2244 p.start,
2245 p.end,
2246 if p.is_current {
2247 view::OverlayKind::PreviewCurrent
2248 } else {
2249 view::OverlayKind::Preview
2250 },
2251 )
2252 }));
2253 if view_lines.is_none() {
2256 overlays.extend(overlay.matches.iter().map(|&(row, start, end)| {
2257 view::Overlay::new(row, start, end, view::OverlayKind::Match)
2258 }));
2259 }
2260 self.view.set_overlays(overlays);
2261
2262 if let Some(generation) = self.view.take_pending_full_parse() {
2270 let text = match &view_lines {
2282 Some(lines) => crate::ropetext::Text::from(lines.join("\n").as_str()),
2283 None => snap.text.clone(),
2284 };
2285 let tx = self.full_parse_tx.clone();
2286 let redraw = self.redraw_tx.clone();
2287 self.full_parse_task.spawn(async move {
2288 let buf = ParsedBuffer::parse(&text);
2289 let _ = tx.send((generation, buf));
2290 if let Some(redraw) = redraw {
2293 let _ = redraw.send(AppEvent::Redraw);
2294 }
2295 });
2296 }
2297 if let Some(job) = self.view.take_pending_full_layout() {
2302 let tx = self.layout_tx.clone();
2303 let redraw = self.redraw_tx.clone();
2304 self.layout_task.spawn(async move {
2305 let hints = view::row_hints(&job.rendered_cache, &job.gutter_insets);
2306 let layout = crate::ropetext::Layout::compute(
2307 &job.text,
2308 job.width,
2309 crate::ropetext::Metrics::default(),
2310 &hints,
2311 );
2312 let _ = tx.send((job.generation, layout));
2313 if let Some(redraw) = redraw {
2314 let _ = redraw.send(AppEvent::Redraw);
2315 }
2316 });
2317 }
2318 let bar_focused = self.search.is_some() && focused;
2321 let editor_focused = focused && !bar_focused;
2322 use self::view::CursorShape;
2323 let cursor_shape = match self.backend.modal_is_insert() {
2324 None => None, Some(true) => Some(CursorShape::Bar),
2326 Some(false) => Some(CursorShape::Block),
2327 };
2328 self.view
2329 .render(f, editor_rect, theme, editor_focused, cursor_shape);
2330
2331 if self.revs.needles_stale() {
2335 self.search_needles.clear();
2336 self.revs.disarm_needles();
2337 }
2338
2339 if snap.text.len_bytes() == 0 && editor_rect.height > 0 {
2343 let leader = self
2344 .key_bindings
2345 .first_combo_for(&crate::keys::action_shortcuts::ActionShortcuts::Leader)
2346 .unwrap_or_else(|| "leader".to_string());
2347 f.render_widget(
2348 ratatui::widgets::Paragraph::new(format!(
2349 "Type to start · [[ to link · # to tag · {leader} for commands"
2350 ))
2351 .style(
2352 Style::default()
2353 .fg(theme.gray.to_ratatui())
2354 .add_modifier(Modifier::ITALIC),
2355 ),
2356 Rect {
2357 x: editor_rect.x.saturating_add(2),
2358 width: editor_rect.width.saturating_sub(2),
2359 height: 1,
2360 ..editor_rect
2361 },
2362 );
2363 }
2364 if let (Some(state), Some(bar_rect)) = (self.search.as_mut(), search_rect) {
2365 state.render(f, bar_rect, theme, bar_focused);
2366 }
2367
2368 self.poll_autocomplete();
2376 if let (Some(controller), Some(live_anchor)) =
2383 (self.autocomplete.as_mut(), self.view.last_cursor_screen)
2384 {
2385 if let Some(state) = controller.state_mut() {
2386 state.anchor = live_anchor;
2387 }
2388 if let Some(state) = controller.state() {
2389 autocomplete::render(f, state, editor_rect, theme);
2390 }
2391 }
2392 }
2393
2394 fn hint_shortcuts(&self) -> Vec<(String, String)> {
2395 use crate::keys::action_shortcuts::ActionShortcuts;
2396
2397 if let Some(mut label) = self.backend.mode_label() {
2402 if let Some(p) = self.backend.pending_input_hint() {
2403 label = format!("{label} {p}");
2404 }
2405 let mut hints = vec![(String::new(), label)];
2406 hints.extend(
2407 [
2408 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2409 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2410 (ActionShortcuts::FileOperations, "file ops"),
2411 ]
2412 .iter()
2413 .filter_map(|(action, label)| {
2414 self.key_bindings
2415 .first_combo_for(action)
2416 .map(|k| (k, label.to_string()))
2417 }),
2418 );
2419 return hints;
2420 }
2421
2422 let mut hints: Vec<(String, String)> = Vec::new();
2425 match self.link_at_cursor() {
2426 Some(LinkTarget::Note(_)) => {
2427 if let Some(k) = self
2428 .key_bindings
2429 .first_combo_for(&ActionShortcuts::FollowLink)
2430 {
2431 hints.push((k, "follow link".to_string()));
2432 }
2433 }
2434 Some(LinkTarget::Label(_)) => {
2435 if let Some(k) = self
2436 .key_bindings
2437 .first_combo_for(&ActionShortcuts::FollowLink)
2438 {
2439 hints.push((k, "browse tag".to_string()));
2440 }
2441 }
2442 None => {}
2443 }
2444 hints.extend(crate::components::hints::hints_for(
2445 &self.key_bindings,
2446 &[
2447 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2448 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2449 (ActionShortcuts::FileOperations, "file ops"),
2450 (ActionShortcuts::FindInBuffer, "find"),
2451 ],
2452 ));
2453 hints
2454 }
2455}
2456
2457#[cfg(test)]
2458mod tests {
2459 use super::snapshot::EditorMode;
2460 use super::*;
2461 use crate::keys::KeyBindings;
2462
2463 fn make_editor() -> TextEditorComponent {
2464 TextEditorComponent::new(
2465 KeyBindings::empty(),
2466 &crate::settings::AppSettings::default(),
2467 )
2468 }
2469
2470 fn dummy_tx() -> AppTx {
2471 tokio::sync::mpsc::unbounded_channel().0
2472 }
2473
2474 fn get_ta(editor: &mut TextEditorComponent) -> &mut RopeBuffer {
2475 match &mut editor.backend {
2476 BackendState::Textarea(tb) => &mut tb.ta,
2477 _ => panic!("expected Textarea backend"),
2478 }
2479 }
2480
2481 #[test]
2482 fn has_trigger_before_cursor_finds_bracket() {
2483 assert!(has_trigger_before_cursor("hello [[foo", 11));
2484 assert!(has_trigger_before_cursor("[[a b c", 7));
2485 }
2486
2487 #[test]
2488 fn has_trigger_before_cursor_finds_hashtag() {
2489 assert!(has_trigger_before_cursor("text #tag", 9));
2490 }
2491
2492 #[test]
2493 fn has_trigger_before_cursor_no_trigger_bails() {
2494 assert!(!has_trigger_before_cursor("plain prose here", 16));
2495 assert!(!has_trigger_before_cursor("", 0));
2496 }
2497
2498 #[test]
2499 fn has_trigger_before_cursor_handles_multibyte_no_panic() {
2500 let line = "你好世界".to_string() + &"a".repeat(80);
2503 let col = line.chars().count();
2504 assert!(!has_trigger_before_cursor(&line, col));
2505
2506 let with_emoji = "🦀".repeat(20) + "[[note";
2507 let col = with_emoji.chars().count();
2508 assert!(has_trigger_before_cursor(&with_emoji, col));
2509
2510 let accented = "é".repeat(100);
2511 let col = accented.chars().count();
2512 assert!(!has_trigger_before_cursor(&accented, col));
2513 }
2514
2515 #[test]
2516 fn has_trigger_before_cursor_ignores_chars_after_cursor() {
2517 assert!(!has_trigger_before_cursor("foo [[bar", 3));
2519 }
2520
2521 #[test]
2522 fn has_trigger_before_cursor_wikilink_with_spaces() {
2523 assert!(has_trigger_before_cursor("[[my note title", 15));
2526 }
2527
2528 #[test]
2529 fn fresh_editor_is_not_dirty() {
2530 let editor = make_editor();
2531 assert!(!editor.is_dirty());
2532 }
2533
2534 #[test]
2535 fn after_set_text_not_dirty() {
2536 let mut editor = make_editor();
2537 editor.set_text("hello world".to_string());
2538 assert!(!editor.is_dirty());
2539 }
2540
2541 #[test]
2542 fn get_text_returns_loaded_content() {
2543 let mut editor = make_editor();
2544 editor.set_text("line one\nline two".to_string());
2545 assert_eq!(editor.get_text(), "line one\nline two");
2546 }
2547
2548 #[test]
2549 fn mark_saved_clears_dirty() {
2550 let mut editor = make_editor();
2551 editor.set_text("initial".to_string());
2552 let text = editor.get_text();
2553 editor.mark_saved(text.clone() + "x"); assert!(editor.is_dirty());
2555 editor.mark_saved(text); assert!(!editor.is_dirty());
2557 }
2558
2559 #[test]
2560 fn trailing_newline_does_not_cause_false_dirty() {
2561 let mut editor = make_editor();
2562 editor.set_text("content\n".to_string());
2563 assert!(
2564 !editor.is_dirty(),
2565 "trailing newline should not make editor dirty after load"
2566 );
2567 }
2568
2569 #[test]
2570 fn cursor_move_does_not_dirty_buffer() {
2571 let mut editor = make_editor();
2572 editor.set_text("hello world".to_string());
2573 assert!(!editor.is_dirty());
2574 let tx = dummy_tx();
2575 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
2578 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2579 assert!(
2580 !editor.is_dirty(),
2581 "cursor move must not mark the editor as dirty"
2582 );
2583 }
2584
2585 #[test]
2586 fn empty_stack_undo_redo_does_not_dirty_or_bump_revision() {
2587 let mut editor = make_editor();
2591 editor.set_text("foo".to_string());
2592 let rev_before = editor.content_revision();
2593 assert!(!editor.is_dirty());
2594 let tx = dummy_tx();
2595 for key_code in [KeyCode::Char('z'), KeyCode::Char('y')] {
2596 let key = ratatui::crossterm::event::KeyEvent::new(key_code, KeyModifiers::CONTROL);
2597 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2598 }
2599 assert!(
2600 !editor.is_dirty(),
2601 "empty-stack undo/redo must not flip is_dirty"
2602 );
2603 assert_eq!(
2604 editor.content_revision(),
2605 rev_before,
2606 "empty-stack undo/redo must not bump content_revision"
2607 );
2608 }
2609
2610 #[test]
2611 fn fresh_editor_content_revision_is_nonzero() {
2612 let editor = make_editor();
2619 assert!(editor.content_revision().get() >= 1);
2620 }
2621
2622 #[test]
2623 fn mouse_down_clears_selection() {
2624 let mut editor = make_editor();
2625 editor.set_text("hello world".to_string());
2626 let ta = get_ta(&mut editor);
2627 ta.start_selection();
2628 ta.move_cursor(CursorMove::WordForward);
2629 assert!(ta.selection_range().is_some());
2630 ta.cancel_selection();
2631 editor.selection = if let BackendState::Textarea(tb) = &editor.backend {
2632 tb.ta.selection_range()
2633 } else {
2634 None
2635 };
2636 assert!(editor.selection.is_none());
2637 }
2638
2639 #[test]
2640 fn ctrl_c_copies_selected_text() {
2641 let mut editor = make_editor();
2642 editor.set_text("hello world".to_string());
2643 let ta = get_ta(&mut editor);
2644 ta.move_cursor(CursorMove::Head);
2645 ta.start_selection();
2646 ta.move_cursor(CursorMove::WordForward);
2647 let range = ta.selection_range().unwrap();
2648 let ((sr, sc), (er, ec)) = range;
2649 let lines = ta.rows();
2650 let selected = if sr == er {
2651 lines[sr][sc..ec].to_string()
2652 } else {
2653 lines[sr][sc..].to_string()
2654 };
2655 assert_eq!(selected, "hello ");
2656 }
2657
2658 fn select_range(editor: &mut TextEditorComponent, start: (usize, usize), end: (usize, usize)) {
2660 let ta = get_ta(editor);
2661 ta.cancel_selection();
2662 ta.move_cursor(CursorMove::Jump(start.0, start.1));
2663 ta.start_selection();
2664 ta.move_cursor(CursorMove::Jump(end.0, end.1));
2665 assert!(ta.selection_range().is_some());
2666 }
2667
2668 fn send_char(editor: &mut TextEditorComponent, c: char) {
2669 let tx = dummy_tx();
2670 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
2671 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2672 }
2673
2674 #[test]
2675 fn surround_pair_maps_open_and_symmetric_chars() {
2676 assert_eq!(surround_pair('('), Some(("(", ")")));
2677 assert_eq!(surround_pair('['), Some(("[", "]")));
2678 assert_eq!(surround_pair('{'), Some(("{", "}")));
2679 assert_eq!(surround_pair('<'), Some(("<", ">")));
2680 assert_eq!(surround_pair('"'), Some(("\"", "\"")));
2681 assert_eq!(surround_pair('\''), Some(("'", "'")));
2682 assert_eq!(surround_pair('`'), Some(("`", "`")));
2683 assert_eq!(surround_pair('*'), Some(("*", "*")));
2684 assert_eq!(surround_pair('_'), Some(("_", "_")));
2685 assert_eq!(surround_pair('~'), Some(("~", "~")));
2686 assert_eq!(surround_pair(')'), None);
2688 assert_eq!(surround_pair(']'), None);
2689 assert_eq!(surround_pair('}'), None);
2690 assert_eq!(surround_pair('>'), None);
2691 assert_eq!(surround_pair('a'), None);
2692 }
2693
2694 #[test]
2695 fn typing_open_paren_with_selection_wraps_it() {
2696 let mut editor = make_editor();
2697 editor.set_text("hello world".to_string());
2698 select_range(&mut editor, (0, 0), (0, 5)); send_char(&mut editor, '(');
2700 assert_eq!(editor.get_text(), "(hello) world");
2701 assert!(editor.is_dirty(), "wrap must mark the buffer dirty");
2702 }
2703
2704 #[test]
2705 fn wrap_keeps_selection_on_inner_text() {
2706 let mut editor = make_editor();
2707 editor.set_text("hello world".to_string());
2708 select_range(&mut editor, (0, 0), (0, 5));
2709 send_char(&mut editor, '(');
2710 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2712 }
2713
2714 #[test]
2715 fn chained_brackets_build_a_wikilink() {
2716 let mut editor = make_editor();
2717 editor.set_text("my note".to_string());
2718 select_range(&mut editor, (0, 0), (0, 7));
2719 send_char(&mut editor, '[');
2720 send_char(&mut editor, '[');
2721 assert_eq!(editor.get_text(), "[[my note]]");
2722 assert_eq!(editor.selection, Some(((0, 2), (0, 9))));
2723 }
2724
2725 #[test]
2726 fn symmetric_chars_wrap_and_chain() {
2727 let mut editor = make_editor();
2728 editor.set_text("bold".to_string());
2729 select_range(&mut editor, (0, 0), (0, 4));
2730 send_char(&mut editor, '*');
2731 assert_eq!(editor.get_text(), "*bold*");
2732 send_char(&mut editor, '*');
2733 assert_eq!(editor.get_text(), "**bold**");
2734 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2735 }
2736
2737 #[test]
2738 fn closing_char_replaces_selection() {
2739 let mut editor = make_editor();
2740 editor.set_text("hello world".to_string());
2741 select_range(&mut editor, (0, 0), (0, 5));
2742 send_char(&mut editor, ')');
2743 assert_eq!(editor.get_text(), ") world");
2744 }
2745
2746 #[test]
2747 fn open_char_without_selection_inserts_normally() {
2748 let mut editor = make_editor();
2749 editor.set_text("hello".to_string());
2750 let ta = get_ta(&mut editor);
2751 ta.move_cursor(CursorMove::End);
2752 send_char(&mut editor, '(');
2753 assert_eq!(editor.get_text(), "hello(");
2754 }
2755
2756 #[test]
2757 fn wrap_spans_multiline_selection() {
2758 let mut editor = make_editor();
2759 editor.set_text("abc\ndef".to_string());
2760 select_range(&mut editor, (0, 0), (1, 3));
2761 send_char(&mut editor, '(');
2762 assert_eq!(editor.get_text(), "(abc\ndef)");
2763 assert_eq!(editor.selection, Some(((0, 1), (1, 3))));
2765 }
2766
2767 #[test]
2768 fn wrap_handles_multibyte_selection() {
2769 let mut editor = make_editor();
2770 editor.set_text("héllo🦀 x".to_string());
2771 select_range(&mut editor, (0, 0), (0, 6)); send_char(&mut editor, '`');
2773 assert_eq!(editor.get_text(), "`héllo🦀` x");
2774 assert_eq!(editor.selection, Some(((0, 1), (0, 7))));
2775 }
2776
2777 #[test]
2778 fn wrap_with_reversed_selection_direction() {
2779 let mut editor = make_editor();
2781 editor.set_text("hello world".to_string());
2782 select_range(&mut editor, (0, 5), (0, 0));
2783 send_char(&mut editor, '(');
2784 assert_eq!(editor.get_text(), "(hello) world");
2785 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2786 }
2787
2788 #[test]
2789 fn text_action_keeps_selection_on_inner_text() {
2790 let mut editor = make_editor();
2793 editor.set_text("bold word".to_string());
2794 select_range(&mut editor, (0, 0), (0, 4));
2795 editor.apply_text_action(TextAction::Bold);
2796 assert_eq!(editor.get_text(), "**bold** word");
2797 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2798 }
2799
2800 #[test]
2801 fn bold_undo_is_one_step_back_to_original() {
2802 let mut editor = make_editor();
2807 editor.set_text("hello world".to_string());
2808 select_range(&mut editor, (0, 0), (0, 5));
2809 editor.apply_text_action(TextAction::Bold);
2810 assert_eq!(editor.get_text(), "**hello** world");
2811 assert!(get_ta(&mut editor).undo(), "the bold is one entry");
2812 assert_eq!(editor.get_text(), "hello world");
2813 assert!(
2814 !get_ta(&mut editor).undo(),
2815 "and has no second half left to take back"
2816 );
2817 }
2818
2819 #[test]
2820 fn wrap_undo_is_one_step_back_to_original() {
2821 let mut editor = make_editor();
2828 editor.set_text("hello world".to_string());
2829 select_range(&mut editor, (0, 0), (0, 5));
2830 send_char(&mut editor, '(');
2831 assert_eq!(editor.get_text(), "(hello) world");
2832 assert!(get_ta(&mut editor).undo(), "the wrap is one entry");
2833 assert_eq!(editor.get_text(), "hello world");
2834 assert!(
2835 !get_ta(&mut editor).undo(),
2836 "and has no second half left to take back"
2837 );
2838 }
2839
2840 #[test]
2841 fn linkable_url_accepts_supported_schemes() {
2842 assert_eq!(
2843 linkable_url("https://example.com"),
2844 Some("https://example.com")
2845 );
2846 assert_eq!(
2847 linkable_url("http://example.com/path?q=1#frag"),
2848 Some("http://example.com/path?q=1#frag"),
2849 );
2850 assert_eq!(
2851 linkable_url(" https://example.com "),
2852 Some("https://example.com")
2853 );
2854 assert_eq!(
2855 linkable_url("ftp://files.example.com/x"),
2856 Some("ftp://files.example.com/x"),
2857 );
2858 assert_eq!(
2859 linkable_url("ftps://files.example.com/x"),
2860 Some("ftps://files.example.com/x"),
2861 );
2862 assert_eq!(
2863 linkable_url("mailto:user@example.com"),
2864 Some("mailto:user@example.com"),
2865 );
2866 assert_eq!(
2867 linkable_url("mailto:user@example.com?subject=hi"),
2868 Some("mailto:user@example.com?subject=hi"),
2869 );
2870 }
2871
2872 #[test]
2873 fn linkable_url_rejects_other_schemes_and_plain_text() {
2874 assert_eq!(linkable_url("file:///etc/passwd"), None);
2875 assert_eq!(linkable_url("ssh://host"), None);
2876 assert_eq!(linkable_url("javascript:alert(1)"), None);
2877 assert_eq!(linkable_url("example.com"), None);
2878 assert_eq!(linkable_url("not a url"), None);
2879 assert_eq!(linkable_url(""), None);
2880 assert_eq!(linkable_url("https://example.com\nmore"), None);
2881 }
2882
2883 #[test]
2884 fn try_build_markdown_link_wraps_selection_when_clip_is_url() {
2885 assert_eq!(
2886 try_build_markdown_link("https://example.com", Some("click here")).as_deref(),
2887 Some("[click here](https://example.com)"),
2888 );
2889 }
2890
2891 #[test]
2892 fn try_build_markdown_link_trims_url_whitespace() {
2893 assert_eq!(
2894 try_build_markdown_link(" https://example.com\n", Some("link")).as_deref(),
2895 Some("[link](https://example.com)"),
2896 );
2897 }
2898
2899 #[test]
2900 fn try_build_markdown_link_returns_none_when_no_selection() {
2901 assert_eq!(try_build_markdown_link("https://example.com", None), None);
2902 }
2903
2904 #[test]
2905 fn try_build_markdown_link_returns_none_when_not_url() {
2906 assert_eq!(try_build_markdown_link("plain text", Some("sel")), None);
2907 }
2908
2909 #[test]
2910 fn try_build_markdown_link_returns_none_when_selection_empty() {
2911 assert_eq!(
2912 try_build_markdown_link("https://example.com", Some("")),
2913 None
2914 );
2915 }
2916
2917 #[test]
2918 fn try_build_markdown_link_escapes_close_bracket_in_selection() {
2919 assert_eq!(
2920 try_build_markdown_link("https://example.com", Some("a]b")).as_deref(),
2921 Some(r"[a\]b](https://example.com)"),
2922 );
2923 }
2924
2925 #[test]
2926 fn try_build_markdown_link_wraps_ftp_url() {
2927 assert_eq!(
2928 try_build_markdown_link("ftp://files.example.com/x", Some("download")).as_deref(),
2929 Some("[download](ftp://files.example.com/x)"),
2930 );
2931 }
2932
2933 fn key(code: KeyCode, mods: KeyModifiers) -> ratatui::crossterm::event::KeyEvent {
2934 ratatui::crossterm::event::KeyEvent::new(code, mods)
2935 }
2936
2937 #[test]
2939 fn search_needles_clear_on_edit() {
2940 let settings = crate::settings::AppSettings::default();
2941 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2942 ed.set_text("alpha beta".to_string());
2943 ed.set_search_needles(vec!["Alpha".to_string()]);
2944 assert_eq!(ed.search_needles, vec!["alpha"]);
2945 assert!(!ed.revs.needles_stale());
2946
2947 ed.set_text("alpha beta gamma".to_string());
2949 assert!(ed.revs.needles_stale());
2950 }
2951
2952 #[test]
2953 fn jump_to_heading_moves_cursor_to_heading_line() {
2954 let settings = crate::settings::AppSettings::default();
2955 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2956 ed.set_text("intro\n# Top\nbody\n## Sub One\nmore\n".to_string());
2957
2958 ed.jump_to_heading("Sub One");
2959 assert_eq!(ed.view_snapshot().cursor.0, 3);
2960
2961 ed.jump_to_heading("Top");
2962 assert_eq!(ed.view_snapshot().cursor.0, 1);
2963
2964 ed.jump_to_heading("Nope");
2966 assert_eq!(ed.view_snapshot().cursor.0, 1);
2967 }
2968
2969 #[test]
2970 fn open_or_advance_search_opens_find_bar_with_empty_query() {
2971 let mut editor = make_editor();
2972 editor.set_text("hello world".to_string());
2973 editor.open_or_advance_search();
2974 let state = editor.search.as_ref().expect("find bar opened");
2975 assert!(state.input.is_empty());
2976 assert!(matches!(state.status, SearchStatus::Empty));
2977 }
2978
2979 #[test]
2980 fn open_or_advance_search_advances_when_already_open() {
2981 let mut editor = make_editor();
2982 editor.set_text("ab ab ab".to_string());
2983 let tx = dummy_tx();
2984 editor.open_or_advance_search();
2985 editor.handle_input(
2986 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
2987 &tx,
2988 );
2989 editor.handle_input(
2990 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
2991 &tx,
2992 );
2993 editor.open_or_advance_search();
2995 let (_, col) = get_ta(&mut editor).cursor();
2996 assert_eq!(col, 3, "second invocation advances to next match");
2997 }
2998
2999 #[test]
3000 fn typing_in_find_bar_jumps_cursor_to_first_match() {
3001 let mut editor = make_editor();
3002 editor.set_text("foo bar baz".to_string());
3003 let tx = dummy_tx();
3004 editor.open_or_advance_search();
3005 for ch in ['b', 'a', 'r'] {
3006 editor.handle_input(
3007 &InputEvent::Key(key(KeyCode::Char(ch), KeyModifiers::NONE)),
3008 &tx,
3009 );
3010 }
3011 let state = editor.search.as_ref().unwrap();
3012 assert_eq!(state.input.value(), "bar");
3013 assert!(matches!(state.status, SearchStatus::Match));
3014 let (_, col) = get_ta(&mut editor).cursor();
3015 assert_eq!(col, 4, "cursor jumped to start of 'bar'");
3016 }
3017
3018 #[test]
3019 fn enter_in_find_bar_advances_to_next_match() {
3020 let mut editor = make_editor();
3021 editor.set_text("ab ab ab".to_string());
3022 let tx = dummy_tx();
3023 editor.open_or_advance_search();
3024 editor.handle_input(
3025 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
3026 &tx,
3027 );
3028 editor.handle_input(
3029 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
3030 &tx,
3031 );
3032 editor.handle_input(
3034 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3035 &tx,
3036 );
3037 let (_, col) = get_ta(&mut editor).cursor();
3038 assert_eq!(col, 3, "Enter advances to second match");
3039 }
3040
3041 #[test]
3042 fn match_is_highlighted_as_selection_after_search() {
3043 let mut editor = make_editor();
3044 editor.set_text("foo bar baz".to_string());
3045 let tx = dummy_tx();
3046 editor.open_or_advance_search();
3047 for ch in ['b', 'a', 'r'] {
3048 editor.handle_input(
3049 &InputEvent::Key(key(KeyCode::Char(ch), KeyModifiers::NONE)),
3050 &tx,
3051 );
3052 }
3053 assert_eq!(
3056 editor.search.as_ref().unwrap().current_match(),
3057 Some(((0, 4), (0, 7)))
3058 );
3059 }
3060
3061 #[test]
3062 fn no_match_clears_selection() {
3063 let mut editor = make_editor();
3064 editor.set_text("hello".to_string());
3065 let tx = dummy_tx();
3066 editor.open_or_advance_search();
3067 editor.handle_input(
3068 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::NONE)),
3069 &tx,
3070 );
3071 assert_eq!(editor.selection, None);
3072 }
3073
3074 #[test]
3075 fn esc_in_find_bar_clears_selection_highlight() {
3076 let mut editor = make_editor();
3077 editor.set_text("foo bar".to_string());
3078 let tx = dummy_tx();
3079 editor.open_or_advance_search();
3080 editor.handle_input(
3081 &InputEvent::Key(key(KeyCode::Char('b'), KeyModifiers::NONE)),
3082 &tx,
3083 );
3084 editor.handle_input(
3085 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::NONE)),
3086 &tx,
3087 );
3088 editor.handle_input(
3089 &InputEvent::Key(key(KeyCode::Char('r'), KeyModifiers::NONE)),
3090 &tx,
3091 );
3092 assert!(
3093 editor
3094 .search
3095 .as_ref()
3096 .is_some_and(|b| b.current_match().is_some())
3097 );
3098 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3099 assert!(editor.search.is_none());
3101 assert!(editor.selection.is_none());
3102 }
3103
3104 #[test]
3105 fn esc_in_find_bar_closes_it() {
3106 let mut editor = make_editor();
3107 editor.set_text("hello".to_string());
3108 let tx = dummy_tx();
3109 editor.open_or_advance_search();
3110 assert!(editor.search.is_some());
3111 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3112 assert!(editor.search.is_none());
3113 }
3114
3115 #[test]
3116 fn find_bar_consumes_typing_so_editor_text_is_unchanged() {
3117 let mut editor = make_editor();
3118 editor.set_text("hello".to_string());
3119 let tx = dummy_tx();
3120 editor.open_or_advance_search();
3121 editor.handle_input(
3122 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3123 &tx,
3124 );
3125 assert_eq!(editor.get_text(), "hello");
3126 }
3127
3128 #[test]
3129 fn no_match_status_when_query_absent() {
3130 let mut editor = make_editor();
3131 editor.set_text("hello".to_string());
3132 let tx = dummy_tx();
3133 editor.open_or_advance_search();
3134 editor.handle_input(
3135 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::NONE)),
3136 &tx,
3137 );
3138 let state = editor.search.as_ref().unwrap();
3139 assert!(matches!(state.status, SearchStatus::NoMatch));
3140 }
3141
3142 #[test]
3143 fn try_build_markdown_link_wraps_mailto_url() {
3144 assert_eq!(
3145 try_build_markdown_link("mailto:user@example.com", Some("email me")).as_deref(),
3146 Some("[email me](mailto:user@example.com)"),
3147 );
3148 }
3149
3150 #[test]
3151 fn insert_at_cursor_appends_text() {
3152 let mut editor = make_editor();
3153 editor.set_text("hello".to_string());
3154 {
3155 let ta = get_ta(&mut editor);
3156 ta.move_cursor(CursorMove::End);
3157 }
3158 editor.insert_at_cursor(" world", &dummy_tx());
3159 assert_eq!(editor.get_text(), "hello world");
3160 }
3161
3162 #[test]
3163 fn insert_at_cursor_replaces_selection() {
3164 let mut editor = make_editor();
3165 editor.set_text("hello world".to_string());
3166 {
3167 let ta = get_ta(&mut editor);
3168 ta.move_cursor(CursorMove::Head);
3169 ta.start_selection();
3170 ta.move_cursor(CursorMove::WordForward);
3171 }
3172 editor.insert_at_cursor("HEY ", &dummy_tx());
3173 assert_eq!(editor.get_text(), "HEY world");
3174 }
3175
3176 #[test]
3177 fn paste_inserts_text_at_cursor() {
3178 let mut editor = make_editor();
3179 editor.set_text("hello".to_string());
3180 let ta = get_ta(&mut editor);
3181 ta.move_cursor(CursorMove::End);
3182 ta.insert_str(" world");
3183 assert_eq!(editor.get_text(), "hello world");
3184 }
3185
3186 #[test]
3187 fn bold_action_with_no_selection_inserts_pair_and_centers_cursor() {
3188 let mut editor = make_editor();
3189 editor.set_text("hello".to_string());
3190 {
3191 let ta = get_ta(&mut editor);
3192 ta.move_cursor(CursorMove::End);
3193 }
3194 editor.apply_text_action(TextAction::Bold);
3195 assert_eq!(editor.get_text(), "hello****");
3196 let ta = get_ta(&mut editor);
3197 assert_eq!(ta.cursor(), (0, 7));
3198 }
3199
3200 #[test]
3201 fn italic_action_with_no_selection_inserts_single_pair() {
3202 let mut editor = make_editor();
3203 editor.set_text(String::new());
3204 editor.apply_text_action(TextAction::Italic);
3205 assert_eq!(editor.get_text(), "**");
3206 let ta = get_ta(&mut editor);
3207 assert_eq!(ta.cursor(), (0, 1));
3208 }
3209
3210 #[test]
3211 fn strikethrough_action_with_selection_wraps_text() {
3212 let mut editor = make_editor();
3213 editor.set_text("hello world".to_string());
3214 {
3215 let ta = get_ta(&mut editor);
3216 ta.move_cursor(CursorMove::Head);
3217 ta.start_selection();
3218 ta.move_cursor(CursorMove::WordForward);
3219 }
3220 editor.apply_text_action(TextAction::Strikethrough);
3221 assert_eq!(editor.get_text(), "~~hello ~~world");
3222 }
3223
3224 #[test]
3225 fn bold_action_wraps_non_ascii_selection() {
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.move_cursor(CursorMove::WordForward);
3232 ta.start_selection();
3233 ta.move_cursor(CursorMove::WordForward);
3234 }
3235 editor.apply_text_action(TextAction::Bold);
3236 assert_eq!(editor.get_text(), "hello **你好 **world");
3237 }
3238
3239 #[test]
3240 fn bold_action_wraps_selected_text() {
3241 let mut editor = make_editor();
3242 editor.set_text("foo bar".to_string());
3243 {
3244 let ta = get_ta(&mut editor);
3245 ta.move_cursor(CursorMove::Head);
3246 ta.start_selection();
3247 ta.move_cursor(CursorMove::WordForward);
3248 }
3249 editor.apply_text_action(TextAction::Bold);
3250 assert_eq!(editor.get_text(), "**foo **bar");
3251 }
3252
3253 #[test]
3254 fn indent_no_selection_indents_current_line() {
3255 let mut editor = make_editor();
3256 editor.set_text("foo\nbar".to_string());
3257 {
3258 let ta = get_ta(&mut editor);
3259 ta.move_cursor(CursorMove::Bottom);
3260 }
3261 editor.indent_lines(false);
3262 let lines = get_ta(&mut editor).rows();
3263 assert_eq!(lines[0], "foo");
3264 assert!(lines[1].starts_with(' ') || lines[1].starts_with('\t'));
3265 assert!(lines[1].trim_start() == "bar");
3266 }
3267
3268 #[test]
3269 fn indent_midline_selection_keeps_text_before_and_selection() {
3270 let mut editor = make_editor();
3271 editor.set_text("hello world".to_string());
3272 {
3273 let ta = get_ta(&mut editor);
3274 ta.move_cursor(CursorMove::Jump(0, 6));
3275 ta.start_selection();
3276 ta.move_cursor(CursorMove::End);
3277 }
3278 editor.indent_lines(false);
3279 let ta = get_ta(&mut editor);
3280 assert_eq!(ta.rows()[0].trim_start(), "hello world");
3282 let indent = ta.rows()[0].len() - "hello world".len();
3284 assert_eq!(
3285 ta.selection_range(),
3286 Some(((0, 6 + indent), (0, 11 + indent)))
3287 );
3288 }
3289
3290 #[test]
3291 fn indent_with_selection_indents_all_touched_lines() {
3292 let mut editor = make_editor();
3293 editor.set_text("foo\nbar\nbaz".to_string());
3294 {
3295 let ta = get_ta(&mut editor);
3296 ta.move_cursor(CursorMove::Top);
3297 ta.start_selection();
3298 ta.move_cursor(CursorMove::Down);
3299 ta.move_cursor(CursorMove::End);
3300 }
3301 editor.indent_lines(false);
3302 let lines: Vec<String> = get_ta(&mut editor).rows().to_vec();
3303 assert_eq!(lines[0].trim_start(), "foo");
3304 assert_eq!(lines[1].trim_start(), "bar");
3305 assert_eq!(lines[2], "baz");
3306 assert!(lines[0].len() > 3);
3307 assert!(lines[1].len() > 3);
3308 }
3309
3310 #[test]
3311 fn dedent_removes_leading_indent() {
3312 let mut editor = make_editor();
3313 editor.set_text(" foo\n bar\nbaz".to_string());
3314 let tab_len = get_ta(&mut editor).indent_width() as usize;
3315 {
3316 let ta = get_ta(&mut editor);
3317 ta.move_cursor(CursorMove::Top);
3318 ta.start_selection();
3319 ta.move_cursor(CursorMove::Bottom);
3320 ta.move_cursor(CursorMove::End);
3321 }
3322 editor.indent_lines(true);
3323 let lines: Vec<String> = get_ta(&mut editor).rows().to_vec();
3324 assert_eq!(lines[0], format!("{}foo", " ".repeat(4 - tab_len.min(4))));
3326 assert_eq!(
3328 lines[1],
3329 format!("{}bar", " ".repeat(2usize.saturating_sub(tab_len)))
3330 );
3331 assert_eq!(lines[2], "baz");
3332 }
3333
3334 #[test]
3335 fn dedent_no_leading_whitespace_is_noop_for_that_line() {
3336 let mut editor = make_editor();
3337 editor.set_text("foo".to_string());
3338 editor.indent_lines(true);
3339 assert_eq!(editor.get_text(), "foo");
3340 }
3341
3342 #[test]
3343 fn smart_enter_continues_unordered_list() {
3344 let mut editor = make_editor();
3345 editor.set_text("- foo".to_string());
3346 {
3347 let ta = get_ta(&mut editor);
3348 ta.move_cursor(CursorMove::End);
3349 }
3350 assert!(editor.smart_enter());
3351 assert_eq!(editor.get_text(), "- foo\n- ");
3352 }
3353
3354 #[test]
3355 fn smart_enter_continues_ordered_list_increments() {
3356 let mut editor = make_editor();
3357 editor.set_text("1. foo".to_string());
3358 {
3359 let ta = get_ta(&mut editor);
3360 ta.move_cursor(CursorMove::End);
3361 }
3362 assert!(editor.smart_enter());
3363 assert_eq!(editor.get_text(), "1. foo\n2. ");
3364 }
3365
3366 #[test]
3367 fn smart_enter_on_empty_list_marker_clears_line() {
3368 let mut editor = make_editor();
3369 editor.set_text("- ".to_string());
3370 {
3371 let ta = get_ta(&mut editor);
3372 ta.move_cursor(CursorMove::End);
3373 }
3374 assert!(editor.smart_enter());
3375 assert_eq!(editor.get_text(), "");
3376 }
3377
3378 #[test]
3379 fn smart_enter_preserves_indent() {
3380 let mut editor = make_editor();
3381 editor.set_text(" body".to_string());
3382 {
3383 let ta = get_ta(&mut editor);
3384 ta.move_cursor(CursorMove::End);
3385 }
3386 assert!(editor.smart_enter());
3387 assert_eq!(editor.get_text(), " body\n ");
3388 }
3389
3390 #[test]
3391 fn smart_enter_on_empty_indent_dedents() {
3392 let mut editor = make_editor();
3393 editor.set_text(" ".to_string());
3394 {
3395 let ta = get_ta(&mut editor);
3396 ta.move_cursor(CursorMove::End);
3397 }
3398 let tab_len = get_ta(&mut editor).indent_width() as usize;
3399 assert!(editor.smart_enter());
3400 assert_eq!(
3401 editor.get_text(),
3402 " ".repeat(4usize.saturating_sub(tab_len))
3403 );
3404 }
3405
3406 #[test]
3407 fn smart_enter_no_indent_no_marker_returns_false() {
3408 let mut editor = make_editor();
3409 editor.set_text("plain".to_string());
3410 {
3411 let ta = get_ta(&mut editor);
3412 ta.move_cursor(CursorMove::End);
3413 }
3414 assert!(!editor.smart_enter());
3415 assert_eq!(editor.get_text(), "plain");
3416 }
3417
3418 #[test]
3419 fn smart_enter_mid_line_returns_false() {
3420 let mut editor = make_editor();
3421 editor.set_text("- foo".to_string());
3422 {
3423 let ta = get_ta(&mut editor);
3424 ta.move_cursor(CursorMove::Head);
3425 ta.move_cursor(CursorMove::Forward);
3426 ta.move_cursor(CursorMove::Forward);
3427 }
3428 assert!(!editor.smart_enter());
3429 }
3430
3431 #[test]
3432 fn smart_enter_on_empty_indented_list_marker_dedents_keeping_marker() {
3433 let mut editor = make_editor();
3434 let tab_len = get_ta(&mut editor).indent_width() as usize;
3435 let indent = " ".repeat(tab_len);
3436 editor.set_text(format!("{indent}- "));
3437 {
3438 let ta = get_ta(&mut editor);
3439 ta.move_cursor(CursorMove::End);
3440 }
3441 assert!(editor.smart_enter());
3442 assert_eq!(editor.get_text(), "- ");
3443 }
3444
3445 #[test]
3446 fn smart_enter_on_empty_list_marker_clears_line_after_full_dedent() {
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());
3457 assert_eq!(editor.get_text(), "- ");
3458 {
3461 let ta = get_ta(&mut editor);
3462 ta.move_cursor(CursorMove::End);
3463 }
3464 assert!(editor.smart_enter());
3465 assert_eq!(editor.get_text(), "");
3466 }
3467
3468 #[test]
3469 fn smart_enter_continues_list_with_non_ascii_content() {
3470 let mut editor = make_editor();
3471 editor.set_text("- 你好".to_string());
3472 {
3473 let ta = get_ta(&mut editor);
3474 ta.move_cursor(CursorMove::End);
3475 }
3476 assert!(editor.smart_enter());
3477 assert_eq!(editor.get_text(), "- 你好\n- ");
3478 }
3479
3480 #[test]
3481 fn smart_enter_preserves_tab_indent() {
3482 let mut editor = make_editor();
3483 editor.set_text("\tbody".to_string());
3484 {
3485 let ta = get_ta(&mut editor);
3486 ta.move_cursor(CursorMove::End);
3487 }
3488 assert!(editor.smart_enter());
3489 assert_eq!(editor.get_text(), "\tbody\n\t");
3490 }
3491
3492 #[test]
3493 fn smart_enter_on_tab_only_line_dedents() {
3494 let mut editor = make_editor();
3495 editor.set_text("\t\t".to_string());
3496 {
3497 let ta = get_ta(&mut editor);
3498 ta.move_cursor(CursorMove::End);
3499 }
3500 assert!(editor.smart_enter());
3501 assert_eq!(editor.get_text(), "\t");
3503 }
3504
3505 #[test]
3506 fn smart_enter_continues_indented_list() {
3507 let mut editor = make_editor();
3508 editor.set_text(" - foo".to_string());
3509 {
3510 let ta = get_ta(&mut editor);
3511 ta.move_cursor(CursorMove::End);
3512 }
3513 assert!(editor.smart_enter());
3514 assert_eq!(editor.get_text(), " - foo\n - ");
3515 }
3516
3517 #[test]
3518 fn unsupported_text_action_is_noop() {
3519 let mut editor = make_editor();
3520 editor.set_text("hello".to_string());
3521 editor.apply_text_action(TextAction::Underline);
3522 assert_eq!(editor.get_text(), "hello");
3523 }
3524
3525 #[test]
3526 fn textarea_hint_shortcuts_has_no_mode_indicator() {
3527 let editor = make_editor();
3528 let hints = editor.hint_shortcuts();
3529 assert!(
3531 !hints
3532 .iter()
3533 .any(|(_, label)| label == "NORMAL" || label == "INSERT")
3534 );
3535 }
3536
3537 fn place_cursor_at_col(editor: &mut TextEditorComponent, col: usize) {
3541 let ta = get_ta(editor);
3542 ta.move_cursor(CursorMove::Head);
3543 for _ in 0..col {
3544 ta.move_cursor(CursorMove::Forward);
3545 }
3546 }
3547
3548 #[test]
3549 fn link_at_cursor_returns_label_when_cursor_on_hashtag() {
3550 let mut editor = make_editor();
3551 editor.set_text("see #rust now".to_string());
3552 place_cursor_at_col(&mut editor, 5);
3554 assert_eq!(
3555 editor.link_at_cursor(),
3556 Some(LinkTarget::Label("rust".into())),
3557 );
3558 }
3559
3560 #[test]
3561 fn link_at_cursor_returns_label_at_hash_char() {
3562 let mut editor = make_editor();
3563 editor.set_text("see #rust now".to_string());
3564 place_cursor_at_col(&mut editor, 4);
3566 assert_eq!(
3567 editor.link_at_cursor(),
3568 Some(LinkTarget::Label("rust".into())),
3569 );
3570 }
3571
3572 #[test]
3573 fn link_at_cursor_returns_none_outside_hashtag() {
3574 let mut editor = make_editor();
3575 editor.set_text("see #rust now".to_string());
3576 place_cursor_at_col(&mut editor, 0);
3578 assert_eq!(editor.link_at_cursor(), None);
3579 }
3580
3581 #[test]
3582 fn link_at_cursor_returns_note_for_wikilink() {
3583 let mut editor = make_editor();
3584 editor.set_text("open [[my note]] please".to_string());
3585 place_cursor_at_col(&mut editor, 7);
3587 let result = editor.link_at_cursor();
3588 assert!(
3589 matches!(result, Some(LinkTarget::Note(_))),
3590 "expected Note variant, got {result:?}"
3591 );
3592 }
3593
3594 #[test]
3597 fn link_at_cursor_returns_note_for_markdown_link_with_fragment() {
3598 let line = "[see docs](#section)";
3603 let mut editor = make_editor();
3604 editor.set_text(line.to_string());
3605 let cursor = "[see docs](#sec".chars().count(); place_cursor_at_col(&mut editor, cursor);
3608 let result = editor.link_at_cursor();
3609 assert!(
3610 matches!(result, Some(LinkTarget::Note(_))),
3611 "expected Note variant for markdown link fragment, got {result:?}"
3612 );
3613 }
3614
3615 #[test]
3616 fn vim_normal_i_then_typing_inserts_text() {
3617 let mut settings = crate::settings::AppSettings::default();
3618 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
3619 let mut editor = TextEditorComponent::new(KeyBindings::empty(), &settings);
3620 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3621 editor.handle_input(
3623 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3624 &tx,
3625 );
3626 assert_eq!(editor.get_text(), "");
3627 editor.handle_input(
3629 &InputEvent::Key(key(KeyCode::Char('i'), KeyModifiers::NONE)),
3630 &tx,
3631 );
3632 editor.handle_input(
3633 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3634 &tx,
3635 );
3636 assert_eq!(editor.get_text(), "x");
3637 }
3638
3639 fn open_replace_bar(
3644 editor: &mut TextEditorComponent,
3645 tx: &AppTx,
3646 pattern: &str,
3647 replacement: &str,
3648 ) {
3649 editor.open_or_advance_search();
3650 for c in pattern.chars() {
3651 editor.handle_input(
3652 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3653 tx,
3654 );
3655 }
3656 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), tx);
3657 for c in replacement.chars() {
3658 editor.handle_input(
3659 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
3660 tx,
3661 );
3662 }
3663 }
3664
3665 #[test]
3666 fn tab_reveals_the_replace_field_and_then_cycles_focus() {
3667 let mut editor = make_editor();
3668 let tx = dummy_tx();
3669 editor.set_text("todo".to_string());
3670 editor.open_or_advance_search();
3671 assert!(
3672 !editor.search.as_ref().unwrap().is_replacing(),
3673 "a find-only bar must not start with a replace field"
3674 );
3675
3676 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3677 let s = editor.search.as_ref().unwrap();
3678 assert!(s.is_replacing(), "Tab must reveal the replace field");
3679 assert_eq!(s.focus, BarFocus::Find);
3682
3683 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3684 assert_eq!(editor.search.as_ref().unwrap().focus, BarFocus::Replace);
3685 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
3686 assert_eq!(editor.search.as_ref().unwrap().focus, BarFocus::Find);
3687 }
3688
3689 #[test]
3690 fn typing_in_the_replace_field_does_not_touch_the_buffer() {
3691 let mut editor = make_editor();
3692 let tx = dummy_tx();
3693 editor.set_text("todo and todo".to_string());
3694 open_replace_bar(&mut editor, &tx, "todo", "done");
3695 assert_eq!(
3696 editor.get_text(),
3697 "todo and todo",
3698 "the preview is a view of the note, never a write to it"
3699 );
3700 }
3701
3702 #[test]
3703 fn enter_replaces_the_current_match_and_advances() {
3704 let mut editor = make_editor();
3705 let tx = dummy_tx();
3706 editor.set_text("todo and todo".to_string());
3707 open_replace_bar(&mut editor, &tx, "todo", "done");
3708 editor.handle_input(
3709 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3710 &tx,
3711 );
3712 assert_eq!(editor.get_text(), "done and todo");
3713 }
3714
3715 #[test]
3716 fn replacing_a_match_that_ends_inside_a_cluster_is_refused_not_corrupted() {
3717 let mut editor = make_editor();
3723 let tx = dummy_tx();
3724 editor.set_text("e\u{301}f".to_string());
3725 open_replace_bar(&mut editor, &tx, "e", "x");
3726 editor.handle_input(
3727 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3728 &tx,
3729 );
3730 assert_eq!(
3731 editor.get_text(),
3732 "e\u{301}f",
3733 "the note is left alone rather than half-rewritten"
3734 );
3735 }
3736
3737 #[test]
3738 fn ctrl_a_replaces_every_match() {
3739 let mut editor = make_editor();
3740 let tx = dummy_tx();
3741 editor.set_text("todo and todo\nmore todo".to_string());
3742 open_replace_bar(&mut editor, &tx, "todo", "done");
3743 editor.handle_input(
3744 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3745 &tx,
3746 );
3747 assert_eq!(editor.get_text(), "done and done\nmore done");
3748 }
3749
3750 #[test]
3751 fn replace_all_keeps_the_reading_position() {
3752 let mut editor = make_editor();
3753 let tx = dummy_tx();
3754 editor.set_text("todo\nxx\ntodo\nyy".to_string());
3755 open_replace_bar(&mut editor, &tx, "todo", "done");
3756 if let Some(ta) = editor.backend.as_textarea_mut() {
3760 ta.move_cursor(CursorMove::Jump(3, 1));
3761 }
3762 editor.handle_input(
3763 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3764 &tx,
3765 );
3766 assert_eq!(editor.get_text(), "done\nxx\ndone\nyy");
3767 let (row, _) = editor.cursor_pos();
3768 assert_eq!(
3769 row, 3,
3770 "replace all must not throw the cursor to the end of the note"
3771 );
3772 }
3773
3774 #[test]
3775 fn an_empty_replacement_arms_before_it_deletes() {
3776 let mut editor = make_editor();
3777 let tx = dummy_tx();
3778 editor.set_text("todo and todo".to_string());
3779 open_replace_bar(&mut editor, &tx, "todo ", "");
3780
3781 let ctrl_a = InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
3782 editor.handle_input(&ctrl_a, &tx);
3783 assert_eq!(
3784 editor.get_text(),
3785 "todo and todo",
3786 "the first Ctrl+A on an empty replacement must arm, not delete"
3787 );
3788 assert!(editor.search.as_ref().unwrap().armed_empty);
3789
3790 editor.handle_input(&ctrl_a, &tx);
3791 assert_eq!(editor.get_text(), "and todo");
3792 }
3793
3794 #[test]
3795 fn esc_disarms_an_empty_replace_all_without_closing_the_bar() {
3796 let mut editor = make_editor();
3797 let tx = dummy_tx();
3798 editor.set_text("todo".to_string());
3799 open_replace_bar(&mut editor, &tx, "todo", "");
3800 editor.handle_input(
3801 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3802 &tx,
3803 );
3804 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3805 let s = editor
3806 .search
3807 .as_ref()
3808 .expect("Esc disarms before it closes");
3809 assert!(!s.armed_empty);
3810 assert_eq!(editor.get_text(), "todo");
3811 }
3812
3813 #[test]
3814 fn one_ctrl_z_undoes_a_whole_replace_all() {
3815 let mut editor = make_editor();
3816 let tx = dummy_tx();
3817 editor.set_text("todo and todo".to_string());
3818 open_replace_bar(&mut editor, &tx, "todo", "done");
3819 editor.handle_input(
3820 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3821 &tx,
3822 );
3823 assert_eq!(editor.get_text(), "done and done");
3824
3825 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3827 editor.handle_input(
3828 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3829 &tx,
3830 );
3831 assert_eq!(
3832 editor.get_text(),
3833 "todo and todo",
3834 "a replace is two history entries and must cost ONE undo — \
3835 popping half leaves the note with a hole in it"
3836 );
3837 }
3838
3839 #[test]
3840 fn one_ctrl_z_undoes_a_single_replace_step() {
3841 let mut editor = make_editor();
3842 let tx = dummy_tx();
3843 editor.set_text("todo and todo".to_string());
3844 open_replace_bar(&mut editor, &tx, "todo", "done");
3845 editor.handle_input(
3846 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3847 &tx,
3848 );
3849 assert_eq!(editor.get_text(), "done and todo");
3850 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3851 editor.handle_input(
3852 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3853 &tx,
3854 );
3855 assert_eq!(editor.get_text(), "todo and todo");
3856 }
3857
3858 #[test]
3859 fn redo_regroups_the_replace() {
3860 let mut editor = make_editor();
3861 let tx = dummy_tx();
3862 editor.set_text("todo".to_string());
3863 open_replace_bar(&mut editor, &tx, "todo", "done");
3864 editor.handle_input(
3865 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3866 &tx,
3867 );
3868 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
3869 editor.handle_input(
3870 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
3871 &tx,
3872 );
3873 assert_eq!(editor.get_text(), "todo");
3874 editor.handle_input(
3875 &InputEvent::Key(key(KeyCode::Char('y'), KeyModifiers::CONTROL)),
3876 &tx,
3877 );
3878 assert_eq!(
3879 editor.get_text(),
3880 "done",
3881 "one redo must restore the whole replace"
3882 );
3883 }
3884
3885 #[test]
3886 fn smartcase_drives_both_the_count_and_the_replace() {
3887 let mut editor = make_editor();
3888 let tx = dummy_tx();
3889 editor.set_text("todo Todo TODO".to_string());
3890 open_replace_bar(&mut editor, &tx, "todo", "x");
3891 assert_eq!(
3892 editor.search.as_ref().unwrap().match_count,
3893 3,
3894 "an all-lowercase pattern matches any case"
3895 );
3896 editor.handle_input(
3897 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3898 &tx,
3899 );
3900 assert_eq!(editor.get_text(), "x x x");
3901 }
3902
3903 #[test]
3904 fn an_uppercase_pattern_is_case_sensitive() {
3905 let mut editor = make_editor();
3906 let tx = dummy_tx();
3907 editor.set_text("todo Todo TODO".to_string());
3908 open_replace_bar(&mut editor, &tx, "Todo", "x");
3909 assert_eq!(editor.search.as_ref().unwrap().match_count, 1);
3910 editor.handle_input(
3911 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
3912 &tx,
3913 );
3914 assert_eq!(editor.get_text(), "todo x TODO");
3915 }
3916
3917 #[test]
3918 fn the_preview_substitutes_lines_without_writing_them() {
3919 let mut editor = make_editor();
3920 let tx = dummy_tx();
3921 editor.set_text("todo and todo".to_string());
3922 open_replace_bar(&mut editor, &tx, "todo", "done");
3923 let preview = editor.replace_preview().expect("a preview must be built");
3924 assert_eq!(preview.lines, vec!["done and done".to_string()]);
3925 assert_eq!(preview.spans.len(), 2);
3926 assert!(
3927 preview.spans.iter().any(|s| s.is_current),
3928 "the match under the cursor must be flagged so Enter's target is visible"
3929 );
3930 assert_eq!(
3931 editor.get_text(),
3932 "todo and todo",
3933 "building a preview must never mutate the buffer"
3934 );
3935 }
3936
3937 #[test]
3942 fn a_deletion_preview_still_marks_the_current_match() {
3943 let mut editor = make_editor();
3944 let tx = dummy_tx();
3945 editor.set_text("todo and todo".to_string());
3946 open_replace_bar(&mut editor, &tx, "todo", "");
3947 let preview = editor.replace_preview().expect("a preview must be built");
3948 assert_eq!(preview.lines, vec![" and ".to_string()]);
3949 let current = preview
3950 .spans
3951 .iter()
3952 .find(|s| s.is_current)
3953 .expect("the current match must stay flagged when it previews as nothing");
3954 assert_eq!(
3955 current.start, current.end,
3956 "an empty replacement previews as a zero-width span — the renderer \
3957 widens it to a caret cell so the marker cannot vanish"
3958 );
3959 }
3960
3961 #[test]
3966 fn a_multi_row_selection_cannot_derail_an_interactive_replace() {
3967 let mut editor = make_editor();
3968 let tx = dummy_tx();
3969 editor.set_text("alpha beta\nxy".to_string());
3970 open_replace_bar(&mut editor, &tx, "beta", "Z");
3971 editor.selection = Some(((0, 6), (1, 1)));
3973 editor.handle_input(
3974 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3975 &tx,
3976 );
3977 assert_eq!(editor.get_text(), "alpha Z\nxy");
3978 }
3979
3980 #[test]
3985 fn deleting_a_match_marks_the_note_dirty() {
3986 let mut editor = make_editor();
3987 let tx = dummy_tx();
3988 editor.set_text("todo and todo".to_string());
3989 editor.mark_saved("todo and todo".to_string());
3990 assert!(!editor.is_dirty());
3991
3992 open_replace_bar(&mut editor, &tx, "todo ", "");
3993 editor.handle_input(
3994 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3995 &tx,
3996 );
3997 assert_eq!(editor.get_text(), "and todo");
3998 assert!(
3999 editor.is_dirty(),
4000 "a deletion is an edit — if the revision does not move, autosave \
4001 never writes it and the change is silently lost"
4002 );
4003 }
4004
4005 #[test]
4007 fn emptying_the_note_via_replace_all_marks_it_dirty_and_is_undoable() {
4008 let mut editor = make_editor();
4009 let tx = dummy_tx();
4010 editor.set_text("todo".to_string());
4011 editor.mark_saved("todo".to_string());
4012 open_replace_bar(&mut editor, &tx, "todo", "");
4013 let ctrl_a = InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL));
4014 editor.handle_input(&ctrl_a, &tx); editor.handle_input(&ctrl_a, &tx); assert_eq!(editor.get_text(), "");
4017 assert!(editor.is_dirty());
4018
4019 editor.handle_input(
4020 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4021 &tx,
4022 );
4023 assert_eq!(editor.get_text(), "todo");
4024 }
4025
4026 #[test]
4030 fn ctrl_z_works_without_closing_the_bar_first() {
4031 let mut editor = make_editor();
4032 let tx = dummy_tx();
4033 editor.set_text("todo and todo".to_string());
4034 open_replace_bar(&mut editor, &tx, "todo", "done");
4035 editor.handle_input(
4036 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4037 &tx,
4038 );
4039 assert_eq!(editor.get_text(), "done and done");
4040 editor.handle_input(
4041 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4042 &tx,
4043 );
4044 assert_eq!(editor.get_text(), "todo and todo");
4045 assert!(editor.search.is_some(), "undo must not close the bar");
4046 }
4047
4048 #[test]
4052 fn a_zero_width_match_does_not_over_claim_history_entries() {
4053 let mut editor = make_editor();
4054 let tx = dummy_tx();
4055 editor.set_text("ab".to_string());
4056 open_replace_bar(&mut editor, &tx, r"\b", "|");
4057 editor.handle_input(
4058 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
4059 &tx,
4060 );
4061 assert_eq!(editor.get_text(), "|ab");
4062 editor.handle_input(
4063 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4064 &tx,
4065 );
4066 assert_eq!(
4067 editor.get_text(),
4068 "ab",
4069 "one undo must land exactly on the pre-replace text, not past it"
4070 );
4071 }
4072
4073 #[test]
4077 fn a_note_swap_resets_the_find_bar_and_its_undo_groups() {
4078 let mut editor = make_editor();
4079 let tx = dummy_tx();
4080 editor.set_text("todo".to_string());
4081 open_replace_bar(&mut editor, &tx, "todo", "");
4082 editor.handle_input(
4083 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4084 &tx,
4085 );
4086 assert!(editor.search.as_ref().unwrap().armed_empty);
4087
4088 editor.set_text("todo elsewhere".to_string());
4089 assert!(editor.search.is_none(), "the bar belonged to the old note");
4090 assert!(
4093 !editor.backend.as_textarea_mut().unwrap().undo(),
4094 "the new note's history has nothing to undo"
4095 );
4096 }
4097
4098 #[test]
4104 fn concealed_markdown_still_highlights_what_it_counts() {
4105 let mut editor = make_editor();
4106 let tx = dummy_tx();
4107 editor.set_text("# Heading\n[[note]]".to_string());
4108 editor.open_or_advance_search();
4109 for c in r"\[\[".chars() {
4110 editor.handle_input(
4111 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4112 &tx,
4113 );
4114 }
4115 let state = editor.search.as_ref().unwrap();
4116 assert_eq!(state.match_count, 1, "the `[[` sigil is a real match");
4117 let spans = state
4118 .pattern
4119 .as_ref()
4120 .unwrap()
4121 .match_spans(editor.backend.as_textarea().unwrap().text().lines());
4122 assert_eq!(
4123 spans,
4124 vec![(1, 0, 2)],
4125 "and it must be reported as a paintable span, not silently dropped \
4126 because the rendered row conceals it"
4127 );
4128 }
4129
4130 #[test]
4135 fn paste_goes_into_the_focused_bar_field() {
4136 let mut editor = make_editor();
4137 let tx = dummy_tx();
4138 editor.set_text("todo".to_string());
4139 open_replace_bar(&mut editor, &tx, "todo", "");
4140 editor.paste_text("done", &tx);
4141 assert_eq!(editor.get_text(), "todo", "the buffer is untouched");
4142 assert_eq!(editor.search.as_ref().unwrap().replacement(), "done");
4143 }
4144
4145 #[test]
4146 fn a_multiline_paste_collapses_to_its_first_line() {
4147 let mut editor = make_editor();
4148 let tx = dummy_tx();
4149 editor.set_text("x".to_string());
4150 editor.open_or_advance_search();
4151 editor.paste_text("first\nsecond", &tx);
4152 assert_eq!(editor.search.as_ref().unwrap().input.value(), "first");
4153 }
4154
4155 #[test]
4159 fn the_bar_is_never_an_invisible_modal() {
4160 use ratatui::Terminal;
4161 use ratatui::backend::TestBackend;
4162 let mut editor = make_editor();
4163 editor.set_text("todo".to_string());
4164 let theme = Theme::default();
4165 let mut term = Terminal::new(TestBackend::new(40, 1)).unwrap();
4166 let area = Rect::new(0, 0, 40, 1);
4167 editor.open_or_advance_search();
4168 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4169 let row: String = (0..40)
4170 .filter_map(|x| {
4171 term.backend()
4172 .buffer()
4173 .cell(ratatui::layout::Position::new(x, 0))
4174 .map(|c| c.symbol().to_string())
4175 })
4176 .collect();
4177 assert!(
4178 row.contains("Find:"),
4179 "an open bar must be drawn even when it costs the whole pane, got {row:?}"
4180 );
4181 }
4182
4183 #[test]
4195 fn a_row_far_from_the_cursor_reparses_after_replace_all() {
4196 use ratatui::Terminal;
4197 use ratatui::backend::TestBackend;
4198 let mut editor = make_editor();
4199 let tx = dummy_tx();
4200 let mut lines: Vec<String> = (0..400).map(|i| format!("filler {i}")).collect();
4201 lines[0] = "todo".to_string();
4202 lines[398] = "todo".to_string();
4203 editor.set_text(lines.join("\n"));
4204 let theme = Theme::default();
4205 let mut term = Terminal::new(TestBackend::new(20, 8)).unwrap();
4206 let area = Rect::new(0, 0, 20, 8);
4207 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4208
4209 open_replace_bar(&mut editor, &tx, "todo", "[[x]]");
4210 if let Some(ta) = editor.backend.as_textarea_mut() {
4213 ta.move_cursor(CursorMove::Jump(398, 0));
4214 }
4215 editor.handle_input(
4216 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4217 &tx,
4218 );
4219 if let Some(ta) = editor.backend.as_textarea_mut() {
4222 ta.move_cursor(CursorMove::Jump(1, 0));
4223 }
4224 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4225 let row0: String = (0..20)
4226 .filter_map(|x| {
4227 term.backend()
4228 .buffer()
4229 .cell(ratatui::layout::Position::new(x, 0))
4230 .map(|c| c.symbol().to_string())
4231 })
4232 .collect::<String>()
4233 .trim_end()
4234 .to_string();
4235 assert_eq!(
4236 row0, "x",
4237 "row 0 must render as a parsed wikilink; `[[x]]` would mean it \
4238 kept the parse of the text that was there before the replace"
4239 );
4240 }
4241
4242 #[test]
4246 fn indenting_a_block_undoes_in_one_step() {
4247 let mut editor = make_editor();
4248 let tx = dummy_tx();
4249 editor.set_text("a\nb\nc".to_string());
4250 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
4251 get_ta(&mut editor).start_selection();
4252 get_ta(&mut editor).move_cursor(CursorMove::Jump(2, 1));
4253 editor.indent_lines(false);
4254 assert_eq!(editor.get_text(), " a\n b\n c");
4255
4256 editor.handle_input(
4257 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4258 &tx,
4259 );
4260 assert_eq!(
4261 editor.get_text(),
4262 "a\nb\nc",
4263 "one undo must revert the whole block, not just the last line"
4264 );
4265 }
4266
4267 #[test]
4269 fn pasting_over_a_selection_undoes_in_one_step() {
4270 let mut editor = make_editor();
4271 let tx = dummy_tx();
4272 editor.set_text("hello world".to_string());
4273 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
4274 get_ta(&mut editor).start_selection();
4275 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 5));
4276 editor.paste_text("goodbye", &tx);
4277 assert_eq!(editor.get_text(), "goodbye world");
4278
4279 editor.handle_input(
4280 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4281 &tx,
4282 );
4283 assert_eq!(editor.get_text(), "hello world");
4284 }
4285
4286 #[test]
4290 fn closing_the_bar_clears_a_stale_selection() {
4291 let mut editor = make_editor();
4292 let tx = dummy_tx();
4293 editor.set_text("alpha beta".to_string());
4294 editor.selection = Some(((0, 0), (0, 5)));
4295 editor.open_or_advance_search();
4296 for c in "beta".chars() {
4297 editor.handle_input(
4298 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4299 &tx,
4300 );
4301 }
4302 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4303 assert!(editor.search.is_none());
4304 assert_eq!(
4305 editor.selection, None,
4306 "a selection from before the search must not outlive the bar"
4307 );
4308 }
4309
4310 #[test]
4313 fn undo_inside_the_bar_rederives_the_current_match() {
4314 let mut editor = make_editor();
4315 let tx = dummy_tx();
4316 editor.set_text("foo foo".to_string());
4317 open_replace_bar(&mut editor, &tx, "foo", "xy");
4318 editor.handle_input(
4319 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
4320 &tx,
4321 );
4322 assert_eq!(editor.get_text(), "xy foo");
4323 editor.handle_input(
4324 &InputEvent::Key(key(KeyCode::Char('z'), KeyModifiers::CONTROL)),
4325 &tx,
4326 );
4327 assert_eq!(editor.get_text(), "foo foo");
4328 let current = editor.search.as_ref().unwrap().current_match();
4329 if let Some(((row, start), (_, end))) = current {
4330 let line = &editor.get_text()[..];
4331 let text: String = line
4332 .lines()
4333 .nth(row)
4334 .unwrap()
4335 .chars()
4336 .skip(start)
4337 .take(end - start)
4338 .collect();
4339 assert_eq!(
4340 text, "foo",
4341 "the highlight must sit on a real match, got {text:?}"
4342 );
4343 }
4344 }
4345
4346 #[test]
4350 fn vim_n_highlights_the_match_it_lands_on() {
4351 let mut editor = make_vim_editor();
4352 let tx = dummy_tx();
4353 editor.set_text("lo xx lo".to_string());
4354 editor.open_or_advance_search();
4355 for c in "lo".chars() {
4356 editor.handle_input(
4357 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4358 &tx,
4359 );
4360 }
4361 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4362 editor.handle_input(
4363 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
4364 &tx,
4365 );
4366 assert_eq!(
4367 editor.selection,
4368 Some(((0, 6), (0, 8))),
4369 "`n` must paint the match it jumped to"
4370 );
4371 }
4372
4373 #[test]
4378 fn closing_the_bar_cannot_leave_an_invisible_selection() {
4379 let mut editor = make_editor();
4380 let tx = dummy_tx();
4381 editor.set_text("foo bar baz".to_string());
4382 get_ta(&mut editor).select_all();
4384 editor.open_or_advance_search();
4385 for c in "bar".chars() {
4386 editor.handle_input(
4387 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4388 &tx,
4389 );
4390 }
4391 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4392 editor.handle_input(
4393 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
4394 &tx,
4395 );
4396 assert!(
4397 editor.get_text().contains("foo"),
4398 "typing after the bar closed must not eat unhighlighted text, got {:?}",
4399 editor.get_text()
4400 );
4401 }
4402
4403 #[test]
4406 fn vim_visual_indent_undoes_in_one_step() {
4407 let mut editor = make_vim_editor();
4408 let tx = dummy_tx();
4409 editor.set_text("a\nb\nc".to_string());
4410 for c in ['V', 'j', 'j', '>'] {
4411 editor.handle_input(
4412 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4413 &tx,
4414 );
4415 }
4416 let indented = editor.get_text();
4417 assert_ne!(indented, "a\nb\nc", "`>` must indent the selection");
4418 editor.handle_input(
4419 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4420 &tx,
4421 );
4422 assert_eq!(
4423 editor.get_text(),
4424 "a\nb\nc",
4425 "one `u` must revert the whole indent, not one row"
4426 );
4427 }
4428
4429 #[test]
4433 fn vim_n_cannot_leave_an_invisible_selection() {
4434 let mut editor = make_vim_editor();
4435 let tx = dummy_tx();
4436 editor.set_text("foo bar foo".to_string());
4437 editor.open_or_advance_search();
4438 for c in "foo".chars() {
4439 editor.handle_input(
4440 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4441 &tx,
4442 );
4443 }
4444 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4445 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 0));
4447 get_ta(&mut editor).start_selection();
4448 get_ta(&mut editor).move_cursor(CursorMove::Jump(0, 3));
4449 editor.handle_input(
4450 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
4451 &tx,
4452 );
4453 for c in ['i', 'X'] {
4454 editor.handle_input(
4455 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4456 &tx,
4457 );
4458 }
4459 assert!(
4460 editor.get_text().contains("bar"),
4461 "typing after `n` must not eat unhighlighted text, got {:?}",
4462 editor.get_text()
4463 );
4464 }
4465
4466 #[test]
4471 fn overlays_paint_where_the_post_pass_used_to() {
4472 use ratatui::Terminal;
4473 use ratatui::backend::TestBackend;
4474 use ratatui::layout::Position;
4475 let mut editor = make_editor();
4476 editor.set_text("find the needle here\n- [x] done task\n- [ ] open task".to_string());
4477 editor.set_search_needles(vec!["needle".to_string()]);
4478 let theme = Theme::default();
4479 let mut term = Terminal::new(TestBackend::new(40, 6)).unwrap();
4480 let area = Rect::new(0, 0, 40, 6);
4481 term.draw(|f| editor.render(f, area, &theme, false))
4482 .unwrap();
4483 let buf = term.backend().buffer();
4484
4485 let row: String = (0..40)
4486 .filter_map(|x| {
4487 buf.cell(Position::new(x, 0))
4488 .map(|c| c.symbol().to_string())
4489 })
4490 .collect();
4491 let at = row.find("needle").expect("needle is on screen");
4492 let cell = buf.cell(Position::new(at as u16, 0)).unwrap();
4493 assert_eq!(
4494 cell.fg,
4495 theme.color_search_match.to_ratatui(),
4496 "the needle must still be emphasised"
4497 );
4498
4499 let struck = |y: u16| {
4501 (0..40).any(|x| {
4502 buf.cell(Position::new(x, y)).is_some_and(|c| {
4503 c.style()
4504 .add_modifier
4505 .contains(ratatui::style::Modifier::CROSSED_OUT)
4506 })
4507 })
4508 };
4509 assert!(struck(1), "a done task strikes its text");
4510 assert!(!struck(2), "an open task does not");
4511
4512 for y in [1u16, 2] {
4514 assert!(
4515 (0..40).any(|x| buf
4516 .cell(Position::new(x, y))
4517 .is_some_and(|c| c.fg == theme.accent.to_ratatui())),
4518 "row {y} must have an accent-coloured checkbox"
4519 );
4520 }
4521 }
4522
4523 #[test]
4524 fn no_preview_without_a_replace_field() {
4525 let mut editor = make_editor();
4526 let tx = dummy_tx();
4527 editor.set_text("todo".to_string());
4528 editor.open_or_advance_search();
4529 for c in "todo".chars() {
4530 editor.handle_input(
4531 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4532 &tx,
4533 );
4534 }
4535 assert!(
4536 editor.replace_preview().is_none(),
4537 "a find-only bar previews nothing"
4538 );
4539 }
4540
4541 #[test]
4542 fn capture_expansion_is_gated_on_the_pattern_capturing() {
4543 let mut editor = make_editor();
4544 let tx = dummy_tx();
4545 editor.set_text("cost".to_string());
4547 open_replace_bar(&mut editor, &tx, "cost", "$1");
4548 editor.handle_input(
4549 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4550 &tx,
4551 );
4552 assert_eq!(editor.get_text(), "$1");
4553 }
4554
4555 #[test]
4556 fn the_bar_reserves_two_rows_only_while_replacing() {
4557 use ratatui::Terminal;
4558 use ratatui::backend::TestBackend;
4559 let mut editor = make_editor();
4560 let tx = dummy_tx();
4561 editor.set_text("todo".to_string());
4562 let theme = Theme::default();
4563 let mut term = Terminal::new(TestBackend::new(40, 10)).unwrap();
4564 let area = Rect::new(0, 0, 40, 10);
4565
4566 editor.open_or_advance_search();
4567 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4568 assert_eq!(editor.rect.height, 9, "a find-only bar takes one row");
4569
4570 editor.handle_input(&InputEvent::Key(key(KeyCode::Tab, KeyModifiers::NONE)), &tx);
4571 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4572 assert_eq!(
4573 editor.rect.height, 8,
4574 "the replace field takes a second row"
4575 );
4576 }
4577
4578 #[test]
4583 fn guu_really_does_undo_in_one_step() {
4584 let mut editor = make_vim_editor();
4585 let tx = dummy_tx();
4586 editor.set_text("Mixed Case Line".to_string());
4587 for c in "guu".chars() {
4588 editor.handle_input(
4589 &InputEvent::Key(key(KeyCode::Char(c), KeyModifiers::NONE)),
4590 &tx,
4591 );
4592 }
4593 assert_eq!(editor.get_text(), "mixed case line");
4594 editor.handle_input(
4595 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4596 &tx,
4597 );
4598 assert_eq!(editor.get_text(), "Mixed Case Line");
4599 }
4600
4601 #[test]
4606 fn vim_u_undoes_a_whole_replace() {
4607 let mut editor = make_vim_editor();
4608 let tx = dummy_tx();
4609 editor.set_text("todo and todo".to_string());
4610 open_replace_bar(&mut editor, &tx, "todo", "done");
4611 editor.handle_input(
4612 &InputEvent::Key(key(KeyCode::Char('a'), KeyModifiers::CONTROL)),
4613 &tx,
4614 );
4615 assert_eq!(editor.get_text(), "done and done");
4616
4617 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
4619 editor.handle_input(
4620 &InputEvent::Key(key(KeyCode::Char('u'), KeyModifiers::NONE)),
4621 &tx,
4622 );
4623 assert_eq!(editor.get_text(), "todo and todo");
4624 }
4625
4626 fn type_out(editor: &mut TextEditorComponent, tx: &AppTx, text: &str) {
4629 use ratatui::crossterm::event::KeyEvent;
4630 for c in text.chars() {
4631 let code = if c == '\n' {
4632 KeyCode::Enter
4633 } else {
4634 KeyCode::Char(c)
4635 };
4636 editor.handle_textarea_key(&KeyEvent::new(code, KeyModifiers::NONE), tx);
4637 }
4638 }
4639
4640 #[test]
4641 fn undo_takes_back_a_word_not_a_letter() {
4642 let mut editor = make_editor();
4645 let tx = dummy_tx();
4646 editor.set_text(String::new());
4647 type_out(&mut editor, &tx, "hello world");
4648 assert_eq!(editor.get_text(), "hello world");
4649
4650 assert!(get_ta(&mut editor).undo());
4651 assert_eq!(editor.get_text(), "hello ", "the last word goes whole");
4652 assert!(get_ta(&mut editor).undo());
4653 assert_eq!(editor.get_text(), "", "and so does the first");
4654 }
4655
4656 #[test]
4657 fn a_cursor_move_separates_two_runs() {
4658 let mut editor = make_editor();
4659 let tx = dummy_tx();
4660 editor.set_text(String::new());
4661 type_out(&mut editor, &tx, "ab");
4662 arrow(&mut editor, &tx, KeyCode::Home);
4663 type_out(&mut editor, &tx, "cd");
4664 assert_eq!(editor.get_text(), "cdab");
4665
4666 assert!(get_ta(&mut editor).undo());
4667 assert_eq!(
4668 editor.get_text(),
4669 "ab",
4670 "only what was typed after the move comes back off"
4671 );
4672 }
4673
4674 #[test]
4675 fn backspacing_to_fix_a_typo_is_its_own_action() {
4676 use ratatui::crossterm::event::KeyEvent;
4677 let mut editor = make_editor();
4678 let tx = dummy_tx();
4679 editor.set_text(String::new());
4680 type_out(&mut editor, &tx, "helllo");
4681 editor.handle_textarea_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
4682 assert_eq!(editor.get_text(), "helll");
4683
4684 assert!(get_ta(&mut editor).undo());
4685 assert_eq!(
4686 editor.get_text(),
4687 "helllo",
4688 "the delete undoes on its own, without taking the typing with it"
4689 );
4690 }
4691
4692 #[test]
4693 fn an_undo_between_two_runs_separates_them() {
4694 let mut editor = make_editor();
4697 let tx = dummy_tx();
4698 editor.set_text(String::new());
4699 type_out(&mut editor, &tx, "ab");
4700 assert!(get_ta(&mut editor).undo());
4701 assert_eq!(editor.get_text(), "");
4702 type_out(&mut editor, &tx, "cd");
4703 assert_eq!(editor.get_text(), "cd");
4704 assert!(get_ta(&mut editor).undo());
4705 assert_eq!(
4706 editor.get_text(),
4707 "",
4708 "the second run is its own group, not an extension of an undone one"
4709 );
4710 }
4711
4712 #[test]
4713 fn a_save_closes_the_open_group() {
4714 let mut editor = make_editor();
4718 let tx = dummy_tx();
4719 type_out(&mut editor, &tx, "abc");
4720 let saved = editor.get_text();
4721 editor.mark_saved(saved);
4722 type_out(&mut editor, &tx, "def");
4725
4726 assert!(get_ta(&mut editor).undo());
4727 assert_eq!(
4728 editor.get_text(),
4729 "abc",
4730 "one undo lands on what was saved, not before it"
4731 );
4732 }
4733
4734 #[test]
4735 fn a_stale_save_completion_does_not_close_the_group() {
4736 let mut editor = make_editor();
4740 let tx = dummy_tx();
4741 type_out(&mut editor, &tx, "abc");
4742 let stale = NonZeroU64::new(1).expect("nonzero");
4743 editor.mark_saved_at_revision(stale);
4744 type_out(&mut editor, &tx, "def");
4745
4746 assert!(get_ta(&mut editor).undo());
4747 assert_eq!(
4748 editor.get_text(),
4749 "",
4750 "the run carried on across a completion that marked nothing"
4751 );
4752 }
4753
4754 #[test]
4755 fn a_second_vim_insert_session_is_its_own_group() {
4756 use ratatui::crossterm::event::KeyEvent;
4760 let mut editor = make_vim_editor();
4761 let tx = dummy_tx();
4762 editor.set_text(String::new());
4763 let press = |editor: &mut TextEditorComponent, code| {
4764 let _ = editor.handle_input(
4765 &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
4766 &tx,
4767 );
4768 };
4769 press(&mut editor, KeyCode::Char('i'));
4770 for c in "one".chars() {
4771 press(&mut editor, KeyCode::Char(c));
4772 }
4773 press(&mut editor, KeyCode::Esc);
4774 press(&mut editor, KeyCode::Char('i'));
4775 for c in "two".chars() {
4776 press(&mut editor, KeyCode::Char(c));
4777 }
4778 press(&mut editor, KeyCode::Esc);
4779 assert_eq!(editor.get_text(), "ontwoe");
4782
4783 assert!(get_ta(&mut editor).undo());
4784 assert_eq!(
4785 editor.get_text(),
4786 "one",
4787 "`u` takes back the second session only"
4788 );
4789 }
4790
4791 #[test]
4792 fn a_vim_insert_session_undoes_whole() {
4793 use ratatui::crossterm::event::KeyEvent;
4794 let mut editor = make_vim_editor();
4795 let tx = dummy_tx();
4796 editor.set_text(String::new());
4797 let press = |editor: &mut TextEditorComponent, code| {
4799 let _ = editor.handle_input(
4800 &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
4801 &tx,
4802 );
4803 };
4804 press(&mut editor, KeyCode::Char('i'));
4805 for c in "hello world".chars() {
4806 press(&mut editor, KeyCode::Char(c));
4807 }
4808 press(&mut editor, KeyCode::Esc);
4809 assert_eq!(editor.get_text(), "hello world");
4810
4811 assert!(get_ta(&mut editor).undo());
4812 assert_eq!(
4813 editor.get_text(),
4814 "",
4815 "vim's `u` takes back the whole session, word boundaries included"
4816 );
4817 }
4818
4819 fn lay_out(editor: &mut TextEditorComponent, width: u16, height: u16) {
4823 use ratatui::Terminal;
4824 use ratatui::backend::TestBackend;
4825 let theme = Theme::default();
4826 let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
4827 let area = Rect::new(0, 0, width, height);
4828 term.draw(|f| editor.render(f, area, &theme, true)).unwrap();
4829 }
4830
4831 fn arrow(editor: &mut TextEditorComponent, tx: &AppTx, code: KeyCode) {
4832 use ratatui::crossterm::event::KeyEvent;
4833 editor.handle_textarea_key(&KeyEvent::new(code, KeyModifiers::NONE), tx);
4834 }
4835
4836 #[test]
4837 fn down_moves_one_drawn_line_not_one_row() {
4838 let mut editor = make_editor();
4841 let tx = dummy_tx();
4842 editor.set_text(
4843 "aaaa bbbb cccc dddd
4844second row"
4845 .to_string(),
4846 );
4847 lay_out(&mut editor, 6, 10);
4848
4849 get_ta(&mut editor).jump_to(0, 0);
4850 arrow(&mut editor, &tx, KeyCode::Down);
4851 assert_eq!(
4852 get_ta(&mut editor).cursor(),
4853 (0, 5),
4854 "still inside the first row, on its second drawn line"
4855 );
4856 arrow(&mut editor, &tx, KeyCode::Down);
4857 assert_eq!(get_ta(&mut editor).cursor(), (0, 10));
4858 arrow(&mut editor, &tx, KeyCode::Down);
4859 assert_eq!(get_ta(&mut editor).cursor(), (0, 15));
4860 arrow(&mut editor, &tx, KeyCode::Down);
4861 assert_eq!(
4862 get_ta(&mut editor).cursor().0,
4863 1,
4864 "and only the fourth press reaches the next row"
4865 );
4866 }
4867
4868 #[test]
4869 fn up_and_down_are_symmetric_across_a_wrap() {
4870 let mut editor = make_editor();
4871 let tx = dummy_tx();
4872 editor.set_text("aaaa bbbb cccc".to_string());
4873 lay_out(&mut editor, 6, 10);
4874
4875 get_ta(&mut editor).jump_to(0, 0);
4876 arrow(&mut editor, &tx, KeyCode::Down);
4877 let middle = get_ta(&mut editor).cursor();
4878 arrow(&mut editor, &tx, KeyCode::Up);
4879 assert_eq!(get_ta(&mut editor).cursor(), (0, 0));
4880 assert_eq!(middle, (0, 5));
4881 }
4882
4883 #[test]
4884 fn an_arrow_against_a_stale_layout_falls_back_instead_of_panicking() {
4885 let mut editor = make_editor();
4890 let tx = dummy_tx();
4891 editor.set_text("abcd\nefgh".to_string());
4892 lay_out(&mut editor, 20, 10);
4893
4894 get_ta(&mut editor).jump_to(0, 4);
4895 for _ in 0..3 {
4896 get_ta(&mut editor).delete_char();
4897 }
4898 assert_eq!(get_ta(&mut editor).rows(), &["a", "efgh"]);
4899
4900 arrow(&mut editor, &tx, KeyCode::Down);
4902 assert_eq!(get_ta(&mut editor).cursor().0, 1, "still moved down a row");
4903 }
4904
4905 #[test]
4906 fn an_action_between_arrows_forgets_the_goal_cell() {
4907 let mut editor = make_editor();
4913 let tx = dummy_tx();
4914 editor.set_text(
4915 "aaaaaaaa
4916bb
4917cccccccc"
4918 .to_string(),
4919 );
4920 lay_out(&mut editor, 20, 10);
4921
4922 get_ta(&mut editor).jump_to(0, 7);
4923 arrow(&mut editor, &tx, KeyCode::Down);
4924 assert_eq!(
4925 get_ta(&mut editor).cursor(),
4926 (1, 2),
4927 "clamped to the short row"
4928 );
4929
4930 let saved = editor.get_text();
4931 editor.mark_saved(saved);
4932
4933 arrow(&mut editor, &tx, KeyCode::Down);
4934 assert_eq!(
4935 get_ta(&mut editor).cursor(),
4936 (2, 2),
4937 "the goal was forgotten, so the third row keeps the clamped column"
4938 );
4939 }
4940
4941 #[test]
4942 fn a_run_of_arrows_keeps_its_goal_cell() {
4943 let mut editor = make_editor();
4946 let tx = dummy_tx();
4947 editor.set_text(
4948 "aaaaaaaa
4949bb
4950cccccccc"
4951 .to_string(),
4952 );
4953 lay_out(&mut editor, 20, 10);
4954
4955 get_ta(&mut editor).jump_to(0, 7);
4956 arrow(&mut editor, &tx, KeyCode::Down);
4957 assert_eq!(
4958 get_ta(&mut editor).cursor(),
4959 (1, 2),
4960 "clamped to the short row"
4961 );
4962 arrow(&mut editor, &tx, KeyCode::Down);
4963 assert_eq!(
4964 get_ta(&mut editor).cursor(),
4965 (2, 7),
4966 "and back out to the cell the run still wants"
4967 );
4968 }
4969
4970 #[test]
4971 fn another_key_ends_the_run() {
4972 let mut editor = make_editor();
4973 let tx = dummy_tx();
4974 editor.set_text(
4975 "aaaaaaaa
4976bb
4977cccccccc"
4978 .to_string(),
4979 );
4980 lay_out(&mut editor, 20, 10);
4981
4982 get_ta(&mut editor).jump_to(0, 7);
4983 arrow(&mut editor, &tx, KeyCode::Down);
4984 arrow(&mut editor, &tx, KeyCode::Home);
4985 arrow(&mut editor, &tx, KeyCode::Down);
4986 assert_eq!(
4987 get_ta(&mut editor).cursor(),
4988 (2, 0),
4989 "Home set a new goal; the old one is gone"
4990 );
4991 }
4992
4993 #[test]
4994 fn shift_down_extends_by_a_drawn_line() {
4995 let mut editor = make_editor();
4996 let tx = dummy_tx();
4997 editor.set_text("aaaa bbbb cccc".to_string());
4998 lay_out(&mut editor, 6, 10);
4999
5000 get_ta(&mut editor).jump_to(0, 0);
5001 editor.handle_textarea_key(
5002 &ratatui::crossterm::event::KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT),
5003 &tx,
5004 );
5005 assert_eq!(
5006 get_ta(&mut editor).selection_range(),
5007 Some(((0, 0), (0, 5)))
5008 );
5009 }
5010
5011 fn make_vim_editor() -> TextEditorComponent {
5013 let mut settings = crate::settings::AppSettings::default();
5014 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
5015 TextEditorComponent::new(KeyBindings::empty(), &settings)
5016 }
5017
5018 fn vim_mode(editor: &TextEditorComponent) -> EditorMode {
5021 match &editor.backend {
5022 BackendState::Textarea(tb) => match &tb.input {
5023 backend::InputInterpreter::Vim(e) => e.mode().clone(),
5024 _ => panic!("expected Vim input interpreter"),
5025 },
5026 _ => panic!("expected Textarea backend"),
5027 }
5028 }
5029
5030 #[test]
5036 fn vim_visual_paste_url_wraps_whole_selected_word() {
5037 let mut editor = make_vim_editor();
5038 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5039 editor.set_text("hello world".to_string());
5040 editor.handle_input(
5043 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
5044 &tx,
5045 );
5046 editor.handle_input(
5047 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
5048 &tx,
5049 );
5050 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5051 editor.paste_text("https://example.com", &tx);
5052 assert_eq!(
5053 editor.get_text(),
5054 "[hello](https://example.com) world",
5055 "the whole selected word (including the char under the cursor) must be wrapped"
5056 );
5057 }
5058
5059 #[test]
5065 fn vim_visual_bold_wraps_whole_selected_word() {
5066 let mut editor = make_vim_editor();
5067 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5068 editor.set_text("hello world".to_string());
5069 editor.handle_input(
5070 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
5071 &tx,
5072 );
5073 editor.handle_input(
5074 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
5075 &tx,
5076 );
5077 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5078 editor.apply_text_action(TextAction::Bold);
5079 assert_eq!(
5080 editor.get_text(),
5081 "**hello** world",
5082 "the whole selected word (including the char under the cursor) must be wrapped"
5083 );
5084 }
5085
5086 #[test]
5094 fn paste_reports_its_outcome() {
5095 let mut editor = make_editor();
5096 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5097 editor.set_text("x".to_string());
5098 editor.paste_from_clipboard(&tx);
5099 let reported = std::iter::from_fn(|| rx.try_recv().ok()).any(|e| {
5100 matches!(e, AppEvent::FlashMessage(m)
5101 if m == "pasted" || m == "clipboard is empty" || m.starts_with("clipboard: "))
5102 });
5103 assert!(reported, "a paste attempt must always report something");
5104 }
5105
5106 #[test]
5112 fn external_paste_drops_the_selection_and_leaves_visual() {
5113 let mut editor = make_vim_editor();
5114 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5115 editor.set_text("hello world".to_string());
5116 editor.handle_input(
5117 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
5118 &tx,
5119 );
5120 editor.handle_input(
5121 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
5122 &tx,
5123 );
5124 assert_eq!(vim_mode(&editor), EditorMode::Visual);
5125
5126 editor.take_selection_for_external_paste();
5127
5128 assert_eq!(
5129 vim_mode(&editor),
5130 EditorMode::Normal,
5131 "the engine must not keep believing it is in Visual"
5132 );
5133 assert_eq!(
5134 get_ta(&mut editor).selection_range(),
5135 None,
5136 "the selection the incoming content replaces must be gone"
5137 );
5138 assert_eq!(
5139 editor.get_text(),
5140 " world",
5141 "the inclusive visual range is what gets replaced"
5142 );
5143 }
5144
5145 #[test]
5148 fn external_paste_without_a_selection_leaves_the_buffer_alone() {
5149 let mut editor = make_vim_editor();
5150 editor.set_text("hello world".to_string());
5151 editor.take_selection_for_external_paste();
5152 assert_eq!(editor.get_text(), "hello world");
5153 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5154 }
5155
5156 #[test]
5161 fn vim_visual_copy_is_read_only_and_does_not_grow_selection() {
5162 let mut editor = make_vim_editor();
5163 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5164 editor.set_text("hello world".to_string());
5165 editor.handle_input(
5166 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
5167 &tx,
5168 );
5169 editor.handle_input(
5170 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
5171 &tx,
5172 );
5173 let before = get_ta(&mut editor).selection_range();
5174 assert_eq!(before, Some(((0, 0), (0, 4))));
5175 assert_eq!(
5177 editor.inclusive_visual_range(),
5178 Some(((0, 0), (0, 5))),
5179 "copy must read the inclusive range including the cursor char"
5180 );
5181 editor.copy_selection_to_clipboard(&tx);
5183 editor.copy_selection_to_clipboard(&tx);
5184 assert_eq!(
5185 get_ta(&mut editor).selection_range(),
5186 before,
5187 "copy must not move the cursor or grow the live selection"
5188 );
5189 }
5190
5191 #[test]
5201 fn vim_sync_collapsed_sel_stays_normal() {
5202 let mut editor = make_vim_editor();
5203 editor.set_text("hello world".to_string());
5204
5205 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5207
5208 editor.backend.sync_mouse_selection(false);
5211 assert_eq!(
5212 vim_mode(&editor),
5213 EditorMode::Normal,
5214 "collapsed (bare click) selection must not enter Visual mode"
5215 );
5216 }
5217
5218 #[test]
5220 fn vim_sync_real_sel_enters_visual() {
5221 let mut editor = make_vim_editor();
5222 editor.set_text("hello world".to_string());
5223
5224 assert_eq!(vim_mode(&editor), EditorMode::Normal);
5226
5227 editor.backend.sync_mouse_selection(true);
5229 assert_eq!(
5230 vim_mode(&editor),
5231 EditorMode::Visual,
5232 "real drag selection must enter Visual mode"
5233 );
5234 }
5235
5236 #[test]
5240 fn vim_find_bar_captures_typing_not_cursor() {
5241 let mut editor = make_vim_editor();
5242 editor.set_text("hello world\nsecond line".to_string());
5243 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5244
5245 editor.open_or_advance_search();
5247 assert!(editor.search.is_some(), "find bar must be open");
5248
5249 editor.handle_input(
5251 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
5252 &tx,
5253 );
5254 editor.handle_input(
5255 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
5256 &tx,
5257 );
5258
5259 let q = editor
5263 .search
5264 .as_ref()
5265 .map(|s| s.input.value().to_string())
5266 .unwrap_or_default();
5267 assert_eq!(q, "lo", "find query must capture typed characters");
5268
5269 assert_eq!(
5272 editor.get_text(),
5273 "hello world\nsecond line",
5274 "buffer must not be modified while find bar is open"
5275 );
5276
5277 assert_eq!(
5283 editor.cursor_pos().1,
5284 3,
5285 "cursor must jump to the search match (col 3), not to a vim motion position"
5286 );
5287 }
5288
5289 #[test]
5294 fn vim_search_enter_steps_and_esc_keeps_the_pattern_for_n() {
5295 let mut editor = make_vim_editor();
5296 editor.set_text("lo xx lo yy lo".to_string());
5298 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5299
5300 editor.open_or_advance_search();
5302 assert!(editor.search.is_some(), "find bar must open");
5303
5304 editor.handle_input(
5306 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
5307 &tx,
5308 );
5309 editor.handle_input(
5310 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
5311 &tx,
5312 );
5313
5314 editor.handle_input(
5318 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
5319 &tx,
5320 );
5321 assert!(
5322 editor.search.is_some(),
5323 "find bar stays open on Enter — it steps, it does not confirm"
5324 );
5325 let (_, c1) = editor.cursor_pos();
5326 assert_eq!(c1, 6, "Enter must step to the 2nd 'lo' at col 6");
5327
5328 editor.handle_input(&InputEvent::Key(key(KeyCode::Esc, KeyModifiers::NONE)), &tx);
5332 assert!(editor.search.is_none(), "Esc must close the find bar");
5333
5334 editor.handle_input(
5336 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
5337 &tx,
5338 );
5339 let (_, c2) = editor.cursor_pos();
5340 assert_eq!(c2, 12, "'n' must jump to the 3rd 'lo' at col 12");
5341
5342 assert_eq!(editor.get_text(), "lo xx lo yy lo");
5344 }
5345}