1pub mod autocomplete_glue;
2pub mod backend;
3pub mod markdown;
4pub mod nvim_decode;
5pub mod nvim_host;
6pub mod nvim_rpc;
7pub mod parse_incremental;
8pub mod snapshot;
9pub mod text_coords;
10pub mod view;
11mod vim;
12pub mod widener_metrics;
13pub mod word_wrap;
14
15use arboard::Clipboard;
16use ratatui::Frame;
17use ratatui::crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
18use ratatui::layout::Rect;
19use ratatui::style::{Modifier, Style};
20use ratatui::text::{Line, Span};
21use ratatui::widgets::Paragraph;
22use ratatui_textarea::{CursorMove, DataCursor, TextArea};
23use std::num::NonZeroU64;
24
25pub(crate) fn cursor_tuple(ta: &TextArea<'_>) -> (usize, usize) {
29 let DataCursor(r, c) = ta.cursor();
30 (r, c)
31}
32
33fn snapshot_from_backend(
40 backend: &BackendState,
41 content_revision: NonZeroU64,
42) -> EditorSnapshot<'_> {
43 match backend {
44 BackendState::Textarea(tb) => {
45 let cursor = cursor_tuple(&tb.ta);
46 EditorSnapshot::borrowed(tb.ta.lines(), 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 = NonZeroU64::new(snap.content_gen.saturating_add(1))
59 .unwrap_or_else(|| NonZeroU64::new(1).unwrap());
60 drop(snap);
61 EditorSnapshot::owned(lines, cursor, rev)
62 }
63 }
64}
65
66fn has_trigger_before_cursor(line: &str, col: usize) -> bool {
77 let cursor_byte = line
78 .char_indices()
79 .nth(col)
80 .map(|(b, _)| b)
81 .unwrap_or(line.len());
82 line[..cursor_byte]
83 .chars()
84 .rev()
85 .any(|c| c == '[' || c == '#')
86}
87
88macro_rules! cursor_move {
94 ($ta:expr, $mv:expr, $shift:expr) => {{
95 if $shift {
96 if $ta.selection_range().is_none() {
97 $ta.start_selection();
98 }
99 } else {
100 $ta.cancel_selection();
101 }
102 $ta.move_cursor($mv);
103 }};
104}
105
106use self::backend::BackendState;
107use self::markdown::ParsedBuffer;
108use self::nvim_host::{NvimHost, NvimKeyResult};
109use self::snapshot::EditorSnapshot;
110use self::view::MarkdownEditorView;
111use crate::util::single_slot_task::SingleSlotTask;
112
113fn increment_ordered_marker(marker: &str) -> Option<String> {
116 let trimmed = marker.trim_end_matches(' ');
117 let dot = trimmed.strip_suffix('.')?;
118 let n: u32 = dot.parse().ok()?;
119 Some(format!("{}. ", n + 1))
120}
121
122fn char_col_to_byte(line: &str, char_col: usize) -> usize {
125 line.char_indices()
126 .nth(char_col)
127 .map(|(b, _)| b)
128 .unwrap_or(line.len())
129}
130
131fn selection_text(ta: &TextArea<'_>) -> Option<String> {
137 selection_text_in(ta, ta.selection_range()?)
138}
139
140fn selection_text_in(ta: &TextArea<'_>, range: ((usize, usize), (usize, usize))) -> Option<String> {
144 let ((sr, sc), (er, ec)) = range;
145 if sr == er && sc == ec {
146 return None;
147 }
148 let lines = ta.lines();
149 Some(if sr == er {
150 let line = &lines[sr];
151 let sb = char_col_to_byte(line, sc);
152 let eb = char_col_to_byte(line, ec);
153 line[sb..eb].to_string()
154 } else {
155 let first = &lines[sr];
156 let sb = char_col_to_byte(first, sc);
157 let mut parts = vec![first[sb..].to_string()];
158 for line in &lines[(sr + 1)..er] {
159 parts.push(line.clone());
160 }
161 let last = &lines[er];
162 let eb = char_col_to_byte(last, ec);
163 parts.push(last[..eb].to_string());
164 parts.join("\n")
165 })
166}
167
168fn surround_pair(c: char) -> Option<(&'static str, &'static str)> {
173 match c {
174 '(' => Some(("(", ")")),
175 '[' => Some(("[", "]")),
176 '{' => Some(("{", "}")),
177 '<' => Some(("<", ">")),
178 '"' => Some(("\"", "\"")),
179 '\'' => Some(("'", "'")),
180 '`' => Some(("`", "`")),
181 '*' => Some(("*", "*")),
182 '_' => Some(("_", "_")),
183 '~' => Some(("~", "~")),
184 _ => None,
185 }
186}
187
188fn set_selection(ta: &mut TextArea<'_>, start: (usize, usize), end: (usize, usize)) {
192 let jump = |(row, col): (usize, usize)| {
193 CursorMove::Jump(
194 u16::try_from(row).unwrap_or(u16::MAX),
195 u16::try_from(col).unwrap_or(u16::MAX),
196 )
197 };
198 ta.cancel_selection();
199 ta.move_cursor(jump(start));
200 ta.start_selection();
201 ta.move_cursor(jump(end));
202}
203
204#[derive(Debug, Clone)]
208pub struct ClipboardImage {
209 pub width: usize,
210 pub height: usize,
211 pub rgba: Vec<u8>,
212}
213
214const LINKABLE_PASTE_SCHEMES: &[&str] = &["http", "https", "ftp", "ftps", "mailto"];
218
219fn linkable_url(s: &str) -> Option<&str> {
220 kimun_core::note::scan::url_with_allowed_scheme(s, LINKABLE_PASTE_SCHEMES)
221}
222
223fn try_build_markdown_link(clip: &str, selection: Option<&str>) -> Option<String> {
227 let url = linkable_url(clip)?;
228 let sel = selection.filter(|s| !s.is_empty())?;
229 let escaped = sel.replace('\\', r"\\").replace(']', r"\]");
230 Some(format!("[{escaped}]({url})"))
231}
232
233use std::sync::Arc;
234
235use kimun_core::NoteVault;
236
237use crate::components::Component;
238use crate::components::autocomplete::{
239 self, AutocompleteController, AutocompleteHost, AutocompleteMode, HandleKeyOutcome,
240};
241use crate::components::event_state::EventState;
242use crate::components::events::AppEvent;
243use crate::components::events::AppTx;
244use crate::components::events::InputEvent;
245use crate::components::events::redraw_callback;
246use crate::components::single_line_input::{InputOutcome, SingleLineInput};
247use crate::components::text_editor::autocomplete_glue::apply_accept_to_textarea;
248use crate::keys::KeyBindings;
249use crate::keys::action_shortcuts::TextAction;
250use crate::settings::AppSettings;
251use crate::settings::themes::Theme;
252
253#[derive(Debug, Clone, PartialEq)]
255pub enum LinkTarget {
256 Note(String),
258 Label(String),
260}
261
262struct SearchState {
263 input: SingleLineInput,
264 status: SearchStatus,
265}
266
267enum SearchStatus {
268 Empty,
269 Match,
270 NoMatch,
271 Invalid(String),
272}
273
274impl SearchStatus {
275 fn from_found(found: bool) -> Self {
276 if found { Self::Match } else { Self::NoMatch }
277 }
278}
279
280const FIND_PROMPT: &str = "Find: ";
281const FIND_HINTS: &str = " [Enter] next [Shift+Enter] prev [Esc] close";
282
283fn render_search_bar(
284 f: &mut Frame,
285 rect: Rect,
286 state: &mut SearchState,
287 theme: &Theme,
288 focused: bool,
289) {
290 let base = theme.base_style();
291 let muted = Style::default()
292 .fg(theme.gray.to_ratatui())
293 .bg(theme.bg.to_ratatui());
294 let err = Style::default()
295 .fg(theme.red.to_ratatui())
296 .bg(theme.bg.to_ratatui());
297 let prompt_cols = unicode_width::UnicodeWidthStr::width(FIND_PROMPT) as u16;
298 let value_total_cols = state.input.display_width() as u16;
302 let tail: Option<(String, Style)> = match &state.status {
303 SearchStatus::Empty => None,
304 SearchStatus::Match => Some((FIND_HINTS.to_string(), muted)),
305 SearchStatus::NoMatch => Some((" no match".to_string(), err)),
306 SearchStatus::Invalid(msg) => Some((format!(" invalid regex: {msg}"), err)),
307 };
308 f.render_widget(
309 Paragraph::new(Line::from(Span::styled(
310 FIND_PROMPT,
311 base.add_modifier(Modifier::BOLD),
312 )))
313 .style(base),
314 Rect {
315 width: prompt_cols.min(rect.width),
316 ..rect
317 },
318 );
319 state.input.render(f, rect, base, prompt_cols, focused);
320 if let Some((text, style)) = tail {
321 let consumed = prompt_cols.saturating_add(value_total_cols);
322 let tail_rect = Rect {
323 x: rect.x.saturating_add(consumed),
324 width: rect.width.saturating_sub(consumed),
325 ..rect
326 };
327 f.render_widget(Paragraph::new(text).style(style), tail_rect);
328 }
329}
330
331struct EditorHostSnapshot<'a> {
338 snap: EditorSnapshot<'a>,
339 cursor_screen: Option<(u16, u16)>,
340 cache_key: Option<NonZeroU64>,
341}
342
343impl<'a> AutocompleteHost for EditorHostSnapshot<'a> {
344 fn buffer_snapshot(&self) -> EditorSnapshot<'_> {
345 EditorSnapshot::borrowed(
350 self.snap.lines.as_ref(),
351 self.snap.cursor,
352 self.snap.content_revision,
353 )
354 }
355 fn cache_key(&self) -> Option<NonZeroU64> {
356 self.cache_key
357 }
358 fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
359 Some(self.cursor_screen.unwrap_or((0, 0)))
373 }
374}
375
376fn build_editor_host_snapshot<'a>(
382 backend: &'a BackendState,
383 content_revision: NonZeroU64,
384 cursor_screen: Option<(u16, u16)>,
385) -> Option<EditorHostSnapshot<'a>> {
386 if !backend.is_textarea() {
387 return None;
388 }
389 Some(EditorHostSnapshot {
390 snap: snapshot_from_backend(backend, content_revision),
391 cursor_screen,
392 cache_key: Some(content_revision),
393 })
394}
395
396pub struct TextEditorComponent {
400 backend: BackendState,
401 rect: Rect,
403 key_bindings: KeyBindings,
404 saved_content_rev: Option<NonZeroU64>,
413 view: MarkdownEditorView,
414 edit_generation: u64,
419 content_revision: NonZeroU64,
442 selection: Option<((usize, usize), (usize, usize))>,
445 clipboard: Option<Clipboard>,
447 nvim_host: NvimHost,
450 search: Option<SearchState>,
452 autocomplete: Option<AutocompleteController>,
456 autocomplete_vault: Option<Arc<NoteVault>>,
460 autocomplete_redraw_bound: bool,
465 full_parse_task: SingleSlotTask<()>,
472 pub wants_context_menu: bool,
475 search_needles: Vec<String>,
479 needles_revision: Option<NonZeroU64>,
481 full_parse_tx: tokio::sync::mpsc::UnboundedSender<(u64, ParsedBuffer)>,
482 full_parse_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, ParsedBuffer)>,
483 redraw_tx: Option<AppTx>,
487}
488
489impl TextEditorComponent {
490 pub fn new(key_bindings: KeyBindings, settings: &AppSettings) -> Self {
491 let (full_parse_tx, full_parse_rx) = tokio::sync::mpsc::unbounded_channel();
492 Self {
493 backend: BackendState::from_settings(
494 &settings.editor_backend,
495 settings.nvim_path.as_ref(),
496 ),
497 rect: Rect::default(),
498 key_bindings,
499 saved_content_rev: NonZeroU64::new(1),
500 view: MarkdownEditorView::new(),
501 edit_generation: 0,
502 content_revision: NonZeroU64::new(1).unwrap(),
503 selection: None,
504 clipboard: Clipboard::new().ok(),
505 nvim_host: NvimHost::new(),
506 search: None,
507 autocomplete: None,
508 autocomplete_vault: None,
509 autocomplete_redraw_bound: false,
510 full_parse_task: SingleSlotTask::empty(),
511 wants_context_menu: false,
512 search_needles: Vec::new(),
513 needles_revision: None,
514 full_parse_tx,
515 full_parse_rx,
516 redraw_tx: None,
517 }
518 }
519
520 pub fn set_vault(&mut self, vault: Arc<NoteVault>) {
525 self.autocomplete_vault = Some(vault.clone());
526 if self.backend.is_textarea() {
527 self.autocomplete = Some(AutocompleteController::new(
528 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
529 AutocompleteMode::Both,
530 ));
531 }
532 }
533
534 fn ensure_autocomplete_for_textarea(&mut self) {
539 if self.autocomplete.is_some() {
540 return;
541 }
542 if !self.backend.is_textarea() {
543 return;
544 }
545 let Some(vault) = self.autocomplete_vault.clone() else {
546 return;
547 };
548 self.autocomplete = Some(AutocompleteController::new(
549 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
550 AutocompleteMode::Both,
551 ));
552 self.autocomplete_redraw_bound = false;
555 }
556
557 #[allow(dead_code)]
564 fn autocomplete_host_snapshot(&self) -> Option<EditorHostSnapshot<'_>> {
565 build_editor_host_snapshot(
566 &self.backend,
567 self.content_revision,
568 self.view.last_cursor_screen,
569 )
570 }
571
572 fn poll_autocomplete(&mut self) {
575 if let Some(controller) = self.autocomplete.as_mut() {
576 controller.poll_results();
577 }
578 }
579
580 fn textarea_cursor(&self) -> Option<(usize, usize)> {
584 let ta = self.backend.as_textarea()?;
585 Some(cursor_tuple(ta))
586 }
587
588 fn refresh_autocomplete_if_open(&mut self) {
589 if !self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
591 return;
592 }
593 let Some(snapshot) = build_editor_host_snapshot(
597 &self.backend,
598 self.content_revision,
599 self.view.last_cursor_screen,
600 ) else {
601 self.close_autocomplete();
602 return;
603 };
604 if let Some(controller) = self.autocomplete.as_mut() {
605 controller.refresh_if_open(&snapshot);
606 }
607 }
608
609 fn sync_autocomplete(&mut self) {
613 let Some(controller) = self.autocomplete.as_ref() else {
614 return; };
616
617 if !controller.is_open() {
630 let Some(ta) = self.backend.as_textarea() else {
631 return;
632 };
633 let (row, col) = cursor_tuple(ta);
634 let line = ta.lines().get(row).map(|s| s.as_str()).unwrap_or("");
635 if !has_trigger_before_cursor(line, col) {
636 return;
637 }
638 }
639
640 let Some(snapshot) = build_editor_host_snapshot(
644 &self.backend,
645 self.content_revision,
646 self.view.last_cursor_screen,
647 ) else {
648 if let Some(c) = self.autocomplete.as_mut() {
649 c.close();
650 }
651 return;
652 };
653 if let Some(controller) = self.autocomplete.as_mut() {
654 controller.sync(&snapshot);
655 }
656 }
657
658 pub fn lines(&self) -> &[String] {
664 match &self.backend {
665 BackendState::Textarea(tb) => tb.ta.lines(),
666 BackendState::Nvim(_) => &[],
667 }
668 }
669
670 pub fn view_snapshot(&self) -> EditorSnapshot<'_> {
689 snapshot_from_backend(&self.backend, self.content_revision)
690 }
691
692 pub fn cursor_pos(&self) -> (usize, usize) {
696 self.backend.cursor()
697 }
698
699 pub fn set_search_needles(&mut self, needles: Vec<String>) {
703 self.search_needles = needles
704 .into_iter()
705 .map(|n| n.to_lowercase())
706 .filter(|n| !n.is_empty())
707 .collect();
708 self.needles_revision = Some(self.content_revision);
709 }
710
711 pub fn set_text(&mut self, text: String) {
712 if text == self.get_text() {
719 self.saved_content_rev = Some(self.content_revision);
720 if let Some(nvim) = self.backend.as_nvim() {
721 nvim.mark_clean();
722 }
723 return;
724 }
725 match &mut self.backend {
726 BackendState::Textarea(tb) => {
727 let lines = text.lines();
728 tb.ta = TextArea::from(lines);
729 }
730 BackendState::Nvim(nvim) => {
731 nvim.set_text(&text);
732 }
733 }
734 self.backend.vim_reset_to_normal();
735 self.bump_content();
736 let reconstructed = self.get_text();
737 self.mark_saved(reconstructed);
738 self.close_autocomplete();
741 }
742
743 pub fn get_text(&self) -> String {
744 self.backend.text()
745 }
746
747 pub fn content_revision(&self) -> NonZeroU64 {
754 self.content_revision
755 }
756
757 pub fn mark_saved_at_revision(&mut self, rev: NonZeroU64) {
767 if rev != self.content_revision {
768 return;
769 }
770 if let Some(nvim) = self.backend.as_nvim() {
771 nvim.mark_clean();
772 }
773 self.saved_content_rev = Some(rev);
774 }
775
776 pub fn mark_saved(&mut self, text: String) {
784 let matches = text == self.get_text();
785 if matches {
786 if let Some(nvim) = self.backend.as_nvim() {
787 nvim.mark_clean();
788 }
789 self.saved_content_rev = Some(self.content_revision);
790 } else {
791 self.saved_content_rev = None;
796 }
797 }
798
799 pub fn is_dirty(&self) -> bool {
800 match &self.backend {
801 BackendState::Textarea(_) => self.saved_content_rev != Some(self.content_revision),
802 BackendState::Nvim(nvim) => nvim.snapshot().dirty,
803 }
804 }
805
806 pub fn vim_space_leads(&self) -> bool {
810 self.backend.vim_space_leads()
811 }
812
813 pub fn link_at_cursor(&self) -> Option<LinkTarget> {
816 let (_row, col, line) = match &self.backend {
817 BackendState::Textarea(tb) => {
818 let (row, col) = cursor_tuple(&tb.ta);
819 let line = tb.ta.lines().get(row)?.to_string();
820 (row, col, line)
821 }
822 BackendState::Nvim(nvim) => {
823 let snap = nvim.snapshot();
824 let (row, col) = snap.cursor;
825 let line = snap.lines.get(row)?.to_string();
826 (row, col, line)
827 }
828 };
829
830 if let Some(span) = kimun_core::note::scan::link_char_spans(&line)
833 .into_iter()
834 .find(|s| s.start <= col && col < s.end)
835 {
836 return Some(LinkTarget::Note(span.target));
837 }
838
839 let parsed = self::markdown::ParsedLine::parse(&line);
841 parsed
842 .elements
843 .iter()
844 .find(|e| {
845 e.kind == self::markdown::ElementKind::Label
846 && col >= e.start_char
847 && col < e.end_char
848 })
849 .map(|e| {
850 let span: String = line
851 .chars()
852 .skip(e.start_char)
853 .take(e.end_char - e.start_char)
854 .collect();
855 let name = span.trim_start_matches('#').to_string();
856 LinkTarget::Label(name)
857 })
858 }
859
860 fn copy_selection_to_clipboard(&mut self) {
862 let text = {
863 let range = match self.inclusive_visual_range() {
872 Some(r) => r,
873 None => return,
874 };
875 let Some(ta) = self.backend.as_textarea() else {
876 return;
877 };
878 match selection_text_in(ta, range) {
879 Some(t) => t,
880 None => return,
881 }
882 };
883 if let Some(cb) = &mut self.clipboard {
884 let _ = cb.set_text(text);
885 }
886 }
887
888 fn inclusive_visual_range(&self) -> Option<((usize, usize), (usize, usize))> {
894 let charwise = self.backend.vim_is_charwise_visual();
895 let ta = self.backend.as_textarea()?;
896 let (start, (er, ec)) = ta.selection_range()?;
897 let end = if charwise {
898 let len = ta.lines().get(er).map(|l| l.chars().count()).unwrap_or(ec);
899 (er, (ec + 1).min(len))
900 } else {
901 (er, ec)
902 };
903 Some((start, end))
904 }
905
906 fn paste_from_clipboard(&mut self, tx: &AppTx) {
908 let text = match &mut self.clipboard {
909 Some(cb) => match cb.get_text() {
910 Ok(t) if !t.is_empty() => t,
911 _ => return,
912 },
913 None => return,
914 };
915 self.paste_text(&text, tx);
916 }
917
918 fn extend_visual_selection_inclusive(&mut self) {
935 if !self.backend.vim_is_charwise_visual() {
936 return;
937 }
938 if let Some((start, end)) = self.inclusive_visual_range()
939 && let Some(ta) = self.backend.as_textarea_mut()
940 {
941 set_selection(ta, start, end);
942 }
943 }
944
945 pub fn paste_text(&mut self, text: &str, tx: &AppTx) {
946 if text.is_empty() {
947 return;
948 }
949 self.extend_visual_selection_inclusive();
950 match &mut self.backend {
951 BackendState::Textarea(tb) => {
952 let selection = linkable_url(text).and_then(|_| selection_text(&tb.ta));
953 let wrapped = try_build_markdown_link(text, selection.as_deref());
954 if tb.ta.selection_range().is_some() {
955 tb.ta.cut();
956 }
957 tb.ta.insert_str(wrapped.as_deref().unwrap_or(text));
958 self.selection = tb.ta.selection_range();
959 self.bump_content();
960 }
961 BackendState::Nvim(nvim) => {
962 nvim.paste(text, tx.clone());
963 self.bump_content();
964 }
965 }
966 self.bind_autocomplete_redraw(tx);
970 self.sync_autocomplete();
971 }
972
973 pub fn insert_at_cursor(&mut self, text: &str, tx: &AppTx) {
978 if matches!(self.backend, BackendState::Nvim(_)) {
979 self.paste_text(text, tx);
980 return;
981 }
982 if let Some(ta) = self.backend.as_textarea_mut() {
983 if ta.selection_range().is_some() {
984 ta.cut();
985 }
986 ta.insert_str(text);
987 self.selection = ta.selection_range();
988 self.bump_content();
989 }
990 self.bind_autocomplete_redraw(tx);
993 self.sync_autocomplete();
994 }
995
996 pub fn take_clipboard_image(&mut self) -> Option<ClipboardImage> {
1000 let cb = self.clipboard.as_mut()?;
1001 let img = cb.get_image().ok()?;
1002 Some(ClipboardImage {
1003 width: img.width,
1004 height: img.height,
1005 rgba: img.bytes.into_owned(),
1006 })
1007 }
1008
1009 fn wrap_selection(&mut self, open: &str, close: &str) -> bool {
1015 self.extend_visual_selection_inclusive();
1019 let Some(ta) = self.backend.as_textarea_mut() else {
1020 return false;
1021 };
1022 let Some(((sr, sc), (er, ec))) = ta.selection_range() else {
1023 return false;
1024 };
1025 let Some(text) = selection_text(ta) else {
1026 return false;
1027 };
1028 ta.insert_str(format!("{open}{text}{close}"));
1029 let shift = open.chars().count();
1033 let inner_end_col = if sr == er { ec + shift } else { ec };
1034 set_selection(ta, (sr, sc + shift), (er, inner_end_col));
1035 self.selection = ta.selection_range();
1036 self.bump_content();
1037 true
1038 }
1039
1040 pub fn apply_text_action(&mut self, action: TextAction) {
1043 let marker = match action {
1044 TextAction::Bold => "**",
1045 TextAction::Italic => "*",
1046 TextAction::Strikethrough => "~~",
1047 _ => return,
1048 };
1049 if self.wrap_selection(marker, marker) {
1050 return;
1051 }
1052 let Some(ta) = self.backend.as_textarea_mut() else {
1053 return;
1054 };
1055 ta.insert_str(format!("{marker}{marker}"));
1056 for _ in 0..marker.len() {
1057 ta.move_cursor(CursorMove::Back);
1058 }
1059 self.selection = ta.selection_range();
1060 self.bump_content();
1061 }
1062
1063 pub fn smart_enter(&mut self) -> bool {
1068 enum Action {
1069 ClearLine { chars: usize },
1070 InsertPrefix(String),
1071 Dedent,
1072 }
1073 let action = {
1074 let Some(ta) = self.backend.as_textarea() else {
1075 return false;
1076 };
1077 if ta
1080 .selection_range()
1081 .is_some_and(|(start, end)| start != end)
1082 {
1083 return false;
1084 }
1085 let (row, col) = cursor_tuple(ta);
1086 let Some(line) = ta.lines().get(row) else {
1087 return false;
1088 };
1089 let total_chars = line.chars().count();
1090 if col != total_chars {
1091 return false;
1092 }
1093 let ws_end = markdown::leading_ws_byte_len(line);
1095 let (ws, after_ws) = line.split_at(ws_end);
1096 if let Some(marker_len) = markdown::list_marker_len(after_ws) {
1097 if after_ws.len() == marker_len {
1098 if ws_end > 0 {
1101 Action::Dedent
1102 } else {
1103 Action::ClearLine { chars: total_chars }
1104 }
1105 } else {
1106 let marker_str = &after_ws[..marker_len];
1107 let next_marker = increment_ordered_marker(marker_str)
1108 .unwrap_or_else(|| marker_str.to_string());
1109 Action::InsertPrefix(format!("{ws}{next_marker}"))
1110 }
1111 } else if ws_end > 0 && total_chars == ws_end {
1112 Action::Dedent
1113 } else if ws_end > 0 {
1114 Action::InsertPrefix(ws.to_string())
1115 } else {
1116 return false;
1117 }
1118 };
1119
1120 match action {
1121 Action::Dedent => {
1122 self.indent_lines(true);
1123 return true;
1124 }
1125 Action::ClearLine { chars } => {
1126 let Some(ta) = self.backend.as_textarea_mut() else {
1127 unreachable!()
1128 };
1129 ta.move_cursor(CursorMove::Head);
1130 ta.delete_str(chars);
1131 }
1132 Action::InsertPrefix(prefix) => {
1133 let Some(ta) = self.backend.as_textarea_mut() else {
1134 unreachable!()
1135 };
1136 ta.insert_newline();
1137 ta.insert_str(prefix);
1138 }
1139 }
1140 let Some(ta) = self.backend.as_textarea() else {
1141 unreachable!()
1142 };
1143 self.selection = ta.selection_range();
1144 self.bump_content();
1145 true
1146 }
1147
1148 pub fn jump_to_heading(&mut self, heading: &str) {
1153 let Some(ta) = self.backend.as_textarea_mut() else {
1154 return;
1155 };
1156 fn normalise(text: &str) -> String {
1161 text.trim()
1162 .trim_end_matches('#')
1163 .trim()
1164 .replace(['*', '_', '`'], "")
1165 }
1166 let wanted = normalise(heading);
1167 let row = ta.lines().iter().position(|l| {
1168 let t = l.trim_start();
1169 let stripped = t.trim_start_matches('#');
1170 stripped.len() != t.len() && normalise(stripped) == wanted
1171 });
1172 if let Some(row) = row {
1173 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1174 self.bump_cursor();
1175 }
1176 }
1177
1178 pub fn indent_lines(&mut self, dedent: bool) {
1182 let Some(ta) = self.backend.as_textarea_mut() else {
1183 return;
1184 };
1185 let tab_len = ta.tab_length() as usize;
1186 let hard_tab = ta.hard_tab_indent();
1187 let indent: String = if hard_tab {
1188 "\t".to_string()
1189 } else {
1190 " ".repeat(tab_len)
1191 };
1192 if indent.is_empty() {
1193 return;
1194 }
1195 let indent_chars = indent.len();
1196
1197 let sel = ta.selection_range();
1198 let saved_cursor = if sel.is_none() {
1199 Some(cursor_tuple(ta))
1200 } else {
1201 None
1202 };
1203 let (start_row, end_row) = match sel {
1204 Some(((sr, _), (er, ec))) => {
1205 let last = if ec == 0 && er > sr { er - 1 } else { er };
1208 (sr, last)
1209 }
1210 None => {
1211 let (r, _) = saved_cursor.unwrap();
1212 (r, r)
1213 }
1214 };
1215
1216 let row_count = end_row.saturating_sub(start_row) + 1;
1217 let mut row_deltas: Vec<isize> = Vec::with_capacity(row_count);
1218 let mut any_change = false;
1219
1220 ta.cancel_selection();
1225
1226 for row in start_row..=end_row {
1227 if dedent {
1228 let count = {
1229 let line = ta.lines().get(row).map(|s| s.as_str()).unwrap_or("");
1230 let max_remove = if hard_tab { 1 } else { tab_len };
1231 let mut count = 0usize;
1232 for (i, c) in line.chars().enumerate() {
1233 if i >= max_remove {
1234 break;
1235 }
1236 if c == '\t' {
1237 count += 1;
1238 break;
1239 } else if c == ' ' && !hard_tab {
1240 count += 1;
1241 } else {
1242 break;
1243 }
1244 }
1245 count
1246 };
1247 if count > 0 {
1248 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1249 ta.delete_str(count);
1250 any_change = true;
1251 }
1252 row_deltas.push(-(count as isize));
1253 } else {
1254 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1255 ta.insert_str(&indent);
1256 row_deltas.push(indent_chars as isize);
1257 any_change = true;
1258 }
1259 }
1260
1261 let adj = |row: usize, col: usize| -> usize {
1262 if row >= start_row && row <= end_row {
1263 let d = row_deltas[row - start_row];
1264 if d >= 0 {
1265 col + d as usize
1266 } else {
1267 col.saturating_sub((-d) as usize)
1268 }
1269 } else {
1270 col
1271 }
1272 };
1273
1274 match sel {
1275 Some(((ssr, ssc), (ser, sec))) => {
1276 set_selection(ta, (ssr, adj(ssr, ssc)), (ser, adj(ser, sec)));
1277 }
1278 None => {
1279 let (cr, cc) = saved_cursor.expect("captured when sel is None");
1280 let new_col = adj(cr, cc);
1281 ta.move_cursor(CursorMove::Jump(cr as u16, new_col as u16));
1282 }
1283 }
1284
1285 if any_change {
1286 self.selection = ta.selection_range();
1287 self.bump_content();
1288 }
1289 }
1290}
1291
1292impl TextEditorComponent {
1293 #[inline]
1298 fn bump_cursor(&mut self) {
1299 self.edit_generation = self.edit_generation.wrapping_add(1);
1300 }
1301
1302 #[inline]
1312 fn bump_content(&mut self) {
1313 self.edit_generation = self.edit_generation.wrapping_add(1);
1314 let next = self.content_revision.get().wrapping_add(1);
1319 self.content_revision = NonZeroU64::new(next).unwrap_or(NonZeroU64::new(1).unwrap());
1320 }
1321
1322 fn maybe_recover_from_dead_nvim(&mut self) {
1324 if self.backend.recover_from_dead_nvim() {
1325 self.ensure_autocomplete_for_textarea();
1329 }
1330 }
1331
1332 fn handle_nvim_key(
1337 &mut self,
1338 key: &ratatui::crossterm::event::KeyEvent,
1339 tx: &AppTx,
1340 ) -> Option<EventState> {
1341 let nvim = self.backend.as_nvim()?;
1345 let result = self.nvim_host.handle_key(nvim, key, tx);
1346
1347 if result == NvimKeyResult::Forwarded {
1352 self.bump_cursor();
1353 }
1354 Some(EventState::Consumed)
1355 }
1356
1357 pub fn open_or_advance_search(&mut self) {
1361 if !self.backend.is_textarea() {
1362 return;
1363 }
1364 if self.search.is_some() {
1365 self.search_advance(false);
1366 return;
1367 }
1368 self.close_autocomplete();
1372 self.search = Some(SearchState {
1373 input: SingleLineInput::new(),
1374 status: SearchStatus::Empty,
1375 });
1376 }
1377
1378 pub fn close_autocomplete(&mut self) {
1382 if let Some(c) = self.autocomplete.as_mut() {
1383 c.close();
1384 }
1385 }
1386
1387 pub fn set_redraw_tx(&mut self, tx: &AppTx) {
1392 self.bind_autocomplete_redraw(tx);
1393 }
1394
1395 fn bind_autocomplete_redraw(&mut self, tx: &AppTx) {
1404 if self.redraw_tx.is_none() {
1405 self.redraw_tx = Some(tx.clone());
1406 }
1407 if self.autocomplete_redraw_bound {
1408 return;
1409 }
1410 if let Some(c) = self.autocomplete.as_mut() {
1411 c.set_redraw_callback(redraw_callback(tx.clone()));
1412 self.autocomplete_redraw_bound = true;
1413 }
1414 }
1415
1416 fn close_search(&mut self) {
1417 if let Some(ta) = self.backend.as_textarea_mut() {
1418 let _ = ta.set_search_pattern("");
1419 }
1420 self.search = None;
1421 self.selection = None;
1422 }
1423
1424 fn refresh_search_pattern(&mut self, jump: bool) {
1427 let Some(state) = self.search.as_mut() else {
1428 return;
1429 };
1430 let Some(ta) = self.backend.as_textarea_mut() else {
1431 return;
1432 };
1433 if state.input.is_empty() {
1434 let _ = ta.set_search_pattern("");
1435 state.status = SearchStatus::Empty;
1436 self.selection = None;
1437 return;
1438 }
1439 if let Err(e) = ta.set_search_pattern(state.input.value()) {
1440 state.status = SearchStatus::Invalid(e.to_string());
1441 self.selection = None;
1442 return;
1443 }
1444 if !jump {
1445 state.status = SearchStatus::Match;
1446 return;
1447 }
1448 let found = ta.search_forward(true);
1449 state.status = SearchStatus::from_found(found);
1450 self.highlight_current_match(found);
1451 }
1452
1453 fn search_advance(&mut self, backward: bool) {
1454 let Some(state) = self.search.as_mut() else {
1455 return;
1456 };
1457 if state.input.is_empty() {
1458 return;
1459 }
1460 let Some(ta) = self.backend.as_textarea_mut() else {
1461 return;
1462 };
1463 let found = if backward {
1464 ta.search_back(false)
1465 } else {
1466 ta.search_forward(false)
1467 };
1468 state.status = SearchStatus::from_found(found);
1469 self.highlight_current_match(found);
1470 }
1471
1472 fn highlight_current_match(&mut self, found: bool) {
1477 self.selection = if found {
1478 self.compute_match_selection()
1479 } else {
1480 None
1481 };
1482 }
1483
1484 fn compute_match_selection(&self) -> Option<((usize, usize), (usize, usize))> {
1490 let ta = self.backend.as_textarea()?;
1491 let re = ta.search_pattern()?;
1492 let DataCursor(row, col_chars) = ta.cursor();
1493 let line = ta.lines().get(row)?;
1494 let byte_off = char_col_to_byte(line, col_chars);
1495 let m = re.find_at(line, byte_off)?;
1496 if m.start() != byte_off {
1497 return None;
1498 }
1499 let match_chars = line[m.range()].chars().count();
1500 Some(((row, col_chars), (row, col_chars + match_chars)))
1501 }
1502
1503 fn handle_search_key(&mut self, key: &ratatui::crossterm::event::KeyEvent) -> bool {
1505 let Some(state) = self.search.as_mut() else {
1506 return false;
1507 };
1508 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1509 let outcome = state.input.handle_key(key);
1510 match outcome {
1511 InputOutcome::Cancel => self.close_search(),
1512 InputOutcome::Submit => {
1513 if self.backend.is_vim() {
1514 self.search = None;
1518 } else {
1519 self.search_advance(shift);
1520 }
1521 }
1522 InputOutcome::Changed => self.refresh_search_pattern(true),
1523 InputOutcome::Consumed | InputOutcome::NotConsumed => {}
1524 }
1525 true
1526 }
1527
1528 fn vim_search_repeat(&mut self, backward: bool) {
1531 let found = {
1532 let Some(ta) = self.backend.as_textarea_mut() else {
1533 return;
1534 };
1535 if backward {
1536 ta.search_back(false)
1537 } else {
1538 ta.search_forward(false)
1539 }
1540 };
1541 self.highlight_current_match(found);
1542 }
1543
1544 fn handle_textarea_key(
1546 &mut self,
1547 key: &ratatui::crossterm::event::KeyEvent,
1548 tx: &AppTx,
1549 ) -> EventState {
1550 if self.handle_search_key(key) {
1552 return EventState::Consumed;
1553 }
1554
1555 if key.modifiers == KeyModifiers::CONTROL {
1557 match key.code {
1558 KeyCode::Char('c') => {
1559 self.copy_selection_to_clipboard();
1560 return EventState::Consumed;
1561 }
1562 KeyCode::Char('v') => {
1563 self.paste_from_clipboard(tx);
1564 return EventState::Consumed;
1565 }
1566 KeyCode::Char('x') => {
1567 self.copy_selection_to_clipboard();
1568 let cut = if let Some(ta) = self.backend.as_textarea_mut() {
1569 let cut = ta.cut();
1575 self.selection = ta.selection_range();
1576 cut
1577 } else {
1578 false
1579 };
1580 if cut {
1581 self.bump_content();
1582 }
1583 return EventState::Consumed;
1584 }
1585 _ => {}
1586 }
1587 }
1588
1589 let Some(ta) = self.backend.as_textarea_mut() else {
1590 unreachable!("handle_textarea_key called with non-Textarea backend")
1591 };
1592
1593 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1595 let handled = match (key.modifiers & !KeyModifiers::SHIFT, key.code) {
1596 (KeyModifiers::ALT, KeyCode::Left) => {
1597 cursor_move!(ta, CursorMove::WordBack, shift);
1598 true
1599 }
1600 (KeyModifiers::ALT, KeyCode::Right) => {
1601 cursor_move!(ta, CursorMove::WordForward, shift);
1602 true
1603 }
1604 (KeyModifiers::ALT, KeyCode::Char('b') | KeyCode::Char('B')) => {
1609 cursor_move!(ta, CursorMove::WordBack, shift);
1610 true
1611 }
1612 (KeyModifiers::ALT, KeyCode::Char('f') | KeyCode::Char('F')) => {
1613 cursor_move!(ta, CursorMove::WordForward, shift);
1614 true
1615 }
1616 (KeyModifiers::SUPER, KeyCode::Left) => {
1617 cursor_move!(ta, CursorMove::Head, shift);
1618 true
1619 }
1620 (KeyModifiers::SUPER, KeyCode::Right) => {
1621 cursor_move!(ta, CursorMove::End, shift);
1622 true
1623 }
1624 (KeyModifiers::SUPER, KeyCode::Up) => {
1625 cursor_move!(ta, CursorMove::Top, shift);
1626 true
1627 }
1628 (KeyModifiers::SUPER, KeyCode::Down) => {
1629 cursor_move!(ta, CursorMove::Bottom, shift);
1630 true
1631 }
1632 _ => false,
1633 };
1634 if handled {
1635 self.selection = ta.selection_range();
1636 self.bump_cursor();
1637 return EventState::Consumed;
1638 }
1639
1640 enum ShortcutOutcome {
1651 NoOp,
1652 CursorOnly,
1653 TextMutated,
1654 }
1655 let outcome: Option<ShortcutOutcome> =
1656 match (key.modifiers & !KeyModifiers::SHIFT, key.code) {
1657 (KeyModifiers::NONE, KeyCode::Left) => {
1659 cursor_move!(ta, CursorMove::Back, shift);
1660 Some(ShortcutOutcome::CursorOnly)
1661 }
1662 (KeyModifiers::NONE, KeyCode::Right) => {
1663 cursor_move!(ta, CursorMove::Forward, shift);
1664 Some(ShortcutOutcome::CursorOnly)
1665 }
1666 (KeyModifiers::NONE, KeyCode::Up) => {
1667 cursor_move!(ta, CursorMove::Up, shift);
1668 Some(ShortcutOutcome::CursorOnly)
1669 }
1670 (KeyModifiers::NONE, KeyCode::Down) => {
1671 cursor_move!(ta, CursorMove::Down, shift);
1672 Some(ShortcutOutcome::CursorOnly)
1673 }
1674 (KeyModifiers::NONE, KeyCode::Home) => {
1675 cursor_move!(ta, CursorMove::Head, shift);
1676 Some(ShortcutOutcome::CursorOnly)
1677 }
1678 (KeyModifiers::NONE, KeyCode::End) => {
1679 cursor_move!(ta, CursorMove::End, shift);
1680 Some(ShortcutOutcome::CursorOnly)
1681 }
1682 (KeyModifiers::NONE, KeyCode::PageUp) => {
1683 cursor_move!(ta, CursorMove::ParagraphBack, shift);
1684 Some(ShortcutOutcome::CursorOnly)
1685 }
1686 (KeyModifiers::NONE, KeyCode::PageDown) => {
1687 cursor_move!(ta, CursorMove::ParagraphForward, shift);
1688 Some(ShortcutOutcome::CursorOnly)
1689 }
1690 (KeyModifiers::CONTROL, KeyCode::Left) => {
1692 cursor_move!(ta, CursorMove::WordBack, shift);
1693 Some(ShortcutOutcome::CursorOnly)
1694 }
1695 (KeyModifiers::CONTROL, KeyCode::Right) => {
1696 cursor_move!(ta, CursorMove::WordForward, shift);
1697 Some(ShortcutOutcome::CursorOnly)
1698 }
1699 (KeyModifiers::CONTROL, KeyCode::Home) => {
1701 cursor_move!(ta, CursorMove::Top, shift);
1702 Some(ShortcutOutcome::CursorOnly)
1703 }
1704 (KeyModifiers::CONTROL, KeyCode::End) => {
1705 cursor_move!(ta, CursorMove::Bottom, shift);
1706 Some(ShortcutOutcome::CursorOnly)
1707 }
1708 (KeyModifiers::CONTROL, KeyCode::Char('z')) => {
1712 if ta.undo() {
1713 Some(ShortcutOutcome::TextMutated)
1714 } else {
1715 Some(ShortcutOutcome::NoOp)
1716 }
1717 }
1718 (KeyModifiers::CONTROL, KeyCode::Char('y'))
1719 | (KeyModifiers::CONTROL, KeyCode::Char('Z')) => {
1720 if ta.redo() {
1721 Some(ShortcutOutcome::TextMutated)
1722 } else {
1723 Some(ShortcutOutcome::NoOp)
1724 }
1725 }
1726 (KeyModifiers::CONTROL, KeyCode::Char('a')) => {
1728 ta.move_cursor(CursorMove::Top);
1729 ta.start_selection();
1730 ta.move_cursor(CursorMove::Bottom);
1731 Some(ShortcutOutcome::CursorOnly)
1732 }
1733 (KeyModifiers::CONTROL, KeyCode::Backspace)
1736 | (KeyModifiers::ALT, KeyCode::Backspace) => {
1737 if ta.delete_word() {
1738 Some(ShortcutOutcome::TextMutated)
1739 } else {
1740 Some(ShortcutOutcome::NoOp)
1741 }
1742 }
1743 (KeyModifiers::CONTROL, KeyCode::Delete) | (KeyModifiers::ALT, KeyCode::Delete) => {
1744 if ta.delete_next_word() {
1745 Some(ShortcutOutcome::TextMutated)
1746 } else {
1747 Some(ShortcutOutcome::NoOp)
1748 }
1749 }
1750 _ => None,
1751 };
1752 if let Some(kind) = outcome {
1753 self.selection = ta.selection_range();
1754 match kind {
1755 ShortcutOutcome::NoOp => {}
1756 ShortcutOutcome::CursorOnly => self.bump_cursor(),
1757 ShortcutOutcome::TextMutated => self.bump_content(),
1758 }
1759 return EventState::Consumed;
1760 }
1761
1762 match (key.modifiers, key.code) {
1764 (m, KeyCode::Tab)
1765 if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
1766 {
1767 self.indent_lines(m.contains(KeyModifiers::SHIFT));
1768 return EventState::Consumed;
1769 }
1770 (_, KeyCode::BackTab) => {
1771 self.indent_lines(true);
1772 return EventState::Consumed;
1773 }
1774 _ => {}
1775 }
1776 if key.code == KeyCode::Enter && key.modifiers.is_empty() && self.smart_enter() {
1777 return EventState::Consumed;
1778 }
1779
1780 if let KeyCode::Char(c) = key.code
1787 && (key.modifiers & !KeyModifiers::SHIFT).is_empty()
1788 && let Some((open, close)) = surround_pair(c)
1789 && self.wrap_selection(open, close)
1790 {
1791 return EventState::Consumed;
1792 }
1793
1794 let Some(ta) = self.backend.as_textarea_mut() else {
1795 unreachable!("handle_textarea_key called with non-Textarea backend")
1796 };
1797 let mutated = ta.input_without_shortcuts(*key);
1803 self.selection = ta.selection_range();
1804 if mutated {
1805 self.bump_content();
1806 } else {
1807 self.bump_cursor();
1808 }
1809 EventState::Consumed
1810 }
1811
1812 fn handle_mouse(&mut self, mouse: &ratatui::crossterm::event::MouseEvent) -> EventState {
1814 let r = &self.rect;
1815 let in_bounds = mouse.column >= r.x
1816 && mouse.column < r.x + r.width
1817 && mouse.row >= r.y
1818 && mouse.row < r.y + r.height;
1819 if !in_bounds {
1820 return EventState::NotConsumed;
1821 }
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();
1840 self.selection = if let Some(ta) = self.backend.as_textarea() {
1841 ta.selection_range()
1842 } else {
1843 None
1844 };
1845 self.bump_cursor();
1846 return EventState::Consumed;
1847 }
1848 let Some(ta) = self.backend.as_textarea_mut() else {
1850 unreachable!()
1851 };
1852 match mouse.kind {
1853 MouseEventKind::Down(_) => {
1854 ta.cancel_selection();
1855 let (lrow, lcol) = self
1856 .view
1857 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1858 ta.move_cursor(CursorMove::Jump(lrow, lcol));
1859 ta.start_selection();
1860 }
1861 MouseEventKind::Drag(_) => {
1862 let (lrow, lcol) = self
1863 .view
1864 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1865 ta.move_cursor(CursorMove::Jump(lrow, lcol));
1866 }
1867 _ => {
1868 ta.input(*mouse);
1869 }
1870 }
1871 self.selection = ta.selection_range();
1872 self.bump_cursor();
1875 EventState::Consumed
1876 }
1877}
1878
1879fn paint_viewport_extras(
1884 buf: &mut ratatui::buffer::Buffer,
1885 area: Rect,
1886 needles: &[String],
1887 theme: &Theme,
1888) {
1889 use ratatui::layout::Position;
1890 let match_fg = theme.color_search_match.to_ratatui();
1891 let checkbox_fg = theme.accent.to_ratatui();
1892
1893 for y in area.y..area.bottom() {
1894 if needles.is_empty() {
1899 let mut lead = String::new();
1900 for x in area.x..area.right().min(area.x + 16) {
1901 if let Some(cell) = buf.cell(Position::new(x, y)) {
1902 lead.push_str(cell.symbol());
1903 }
1904 }
1905 if !lead.trim_start().starts_with("- [") {
1906 continue;
1907 }
1908 }
1909 let mut row_text = String::new();
1912 let mut byte_to_col: Vec<(usize, u16)> = Vec::new();
1913 for x in area.x..area.right() {
1914 let Some(cell) = buf.cell(Position::new(x, y)) else {
1915 continue;
1916 };
1917 let sym = cell.symbol();
1918 if sym.is_empty() {
1919 continue;
1920 }
1921 byte_to_col.push((row_text.len(), x));
1922 row_text.push_str(sym);
1923 }
1924 if row_text.trim().is_empty() {
1925 continue;
1926 }
1927 let lower = row_text.to_lowercase();
1928 let fold_safe = lower.len() == row_text.len();
1929
1930 let mut restyle =
1931 |from_byte: usize, to_byte: usize, f: &mut dyn FnMut(&mut ratatui::buffer::Cell)| {
1932 for (b, x) in &byte_to_col {
1933 if *b >= from_byte
1934 && *b < to_byte
1935 && let Some(cell) = buf.cell_mut(Position::new(*x, y))
1936 {
1937 f(cell);
1938 }
1939 }
1940 };
1941
1942 let trimmed_start = row_text.len() - row_text.trim_start().len();
1944 let after_indent = &row_text[trimmed_start..];
1945 let is_done = after_indent.starts_with("- [x] ") || after_indent.starts_with("- [X] ");
1946 let is_open = after_indent.starts_with("- [ ] ");
1947 if is_done || is_open {
1948 let box_start = trimmed_start + 2;
1949 let box_end = box_start + 3;
1950 restyle(box_start, box_end, &mut |cell| {
1951 cell.set_fg(checkbox_fg);
1952 });
1953 if is_done {
1954 restyle(box_end, row_text.len(), &mut |cell| {
1955 let style = cell
1956 .style()
1957 .add_modifier(Modifier::DIM | Modifier::CROSSED_OUT);
1958 cell.set_style(style);
1959 });
1960 }
1961 }
1962
1963 if fold_safe {
1965 for needle in needles {
1966 for (start, m) in lower.match_indices(needle.as_str()) {
1967 restyle(start, start + m.len(), &mut |cell| {
1968 let style = cell.style().fg(match_fg).add_modifier(Modifier::BOLD);
1969 cell.set_style(style);
1970 });
1971 }
1972 }
1973 }
1974 }
1975}
1976
1977impl Component for TextEditorComponent {
1978 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
1979 self.maybe_recover_from_dead_nvim();
1980 self.bind_autocomplete_redraw(tx);
1981
1982 match event {
1983 InputEvent::Key(key) => {
1984 let popup_open = self.autocomplete.as_ref().is_some_and(|c| c.is_open());
1992 if popup_open
1993 && let Some(host) = build_editor_host_snapshot(
1994 &self.backend,
1995 self.content_revision,
1996 self.view.last_cursor_screen,
1997 )
1998 && let Some(controller) = self.autocomplete.as_mut()
1999 {
2000 match controller.handle_key(*key, &host) {
2001 HandleKeyOutcome::Accepted(action) => {
2002 if let Some(ta) = self.backend.as_textarea_mut() {
2003 apply_accept_to_textarea(ta, &action);
2004 self.selection = ta.selection_range();
2005 }
2006 self.bump_content();
2007 return EventState::Consumed;
2008 }
2009 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
2010 return EventState::Consumed;
2011 }
2012 HandleKeyOutcome::NotHandled => {}
2013 }
2014 }
2015 if self.search.is_some() && self.handle_search_key(key) {
2020 return EventState::Consumed;
2021 }
2022 if let Some(outcome) = self.backend.vim_handle_key(key) {
2027 use self::vim::VimKeyOutcome;
2028 match outcome {
2029 VimKeyOutcome::TextMutated => {
2030 self.selection = None;
2031 self.bump_content();
2032 return EventState::Consumed;
2033 }
2034 VimKeyOutcome::CursorOnly => {
2035 self.selection = self
2040 .backend
2041 .as_textarea()
2042 .and_then(|ta| ta.selection_range());
2043 if self.backend.vim_is_charwise_visual()
2048 && let Some(((sr, sc), (er, ec))) = self.selection
2049 {
2050 let len = self
2051 .backend
2052 .as_textarea()
2053 .and_then(|ta| ta.lines().get(er))
2054 .map(|l| l.chars().count())
2055 .unwrap_or(ec);
2056 self.selection = Some(((sr, sc), (er, (ec + 1).min(len))));
2057 }
2058 self.refresh_autocomplete_if_open();
2059 self.edit_generation = self.edit_generation.wrapping_add(1);
2060 return EventState::Consumed;
2061 }
2062 VimKeyOutcome::NoOp => return EventState::Consumed,
2063 VimKeyOutcome::PassThrough => { }
2064 VimKeyOutcome::Host(action) => {
2065 use self::vim::VimHostAction;
2066 match action {
2067 VimHostAction::OpenPalette => {
2068 tx.send(AppEvent::ExecuteLeaderAction(
2070 crate::keys::leader::LeaderAction::Palette,
2071 ))
2072 .ok();
2073 }
2074 VimHostAction::OpenSearch { forward: _ } => {
2075 self.open_or_advance_search();
2079 }
2080 VimHostAction::SearchNext => self.vim_search_repeat(false),
2081 VimHostAction::SearchPrev => self.vim_search_repeat(true),
2082 }
2083 return EventState::Consumed;
2084 }
2085 }
2086 }
2087 if let Some(state) = self.handle_nvim_key(key, tx) {
2088 return state;
2089 }
2090 let text_rev_before = self.content_revision;
2101 let cursor_before = self.textarea_cursor();
2102 let result = self.handle_textarea_key(key, tx);
2103 let cursor_after = self.textarea_cursor();
2104 if self.content_revision != text_rev_before {
2105 self.sync_autocomplete();
2106 } else if cursor_before != cursor_after {
2107 self.refresh_autocomplete_if_open();
2108 }
2109 result
2110 }
2111 InputEvent::Mouse(mouse) => {
2112 let text_rev_before = self.content_revision;
2113 let cursor_before = self.textarea_cursor();
2114 let result = self.handle_mouse(mouse);
2115 let cursor_after = self.textarea_cursor();
2116 if self.content_revision != text_rev_before {
2119 self.sync_autocomplete();
2120 } else if cursor_before != cursor_after {
2121 self.refresh_autocomplete_if_open();
2122 }
2123 if result == EventState::Consumed
2128 && matches!(
2129 mouse.kind,
2130 ratatui::crossterm::event::MouseEventKind::Down(
2131 ratatui::crossterm::event::MouseButton::Left
2132 )
2133 )
2134 {
2135 match self.link_at_cursor() {
2136 Some(LinkTarget::Note(target)) => {
2137 tx.send(AppEvent::FollowLink(target)).ok();
2138 }
2139 Some(LinkTarget::Label(name)) => {
2140 tx.send(AppEvent::FollowLabel(name)).ok();
2141 }
2142 None => {}
2143 }
2144 }
2145 let has_sel = self
2156 .backend
2157 .as_textarea()
2158 .and_then(|ta| ta.selection_range())
2159 .is_some_and(|(s, e)| s != e);
2160 self.backend.vim_sync_mouse_selection(has_sel);
2161 result
2162 }
2163 InputEvent::Paste(_) => EventState::NotConsumed,
2166 }
2167 }
2168
2169 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
2170 let (editor_rect, search_rect) = if self.search.is_some() && rect.height > 1 {
2172 (
2173 Rect {
2174 height: rect.height - 1,
2175 ..rect
2176 },
2177 Some(Rect {
2178 y: rect.y + rect.height - 1,
2179 height: 1,
2180 ..rect
2181 }),
2182 )
2183 } else {
2184 (rect, None)
2185 };
2186 self.rect = editor_rect;
2189 let (selection, nvim_rev_to_mirror) = match &self.backend {
2194 BackendState::Textarea(_) => (self.selection, None),
2195 BackendState::Nvim(nvim) => {
2196 let fs = self
2197 .nvim_host
2198 .frame_sync(nvim, editor_rect.width, editor_rect.height);
2199 (fs.selection, fs.rev)
2200 }
2201 };
2202 if let Some(rev) = nvim_rev_to_mirror {
2203 self.content_revision = rev;
2204 }
2205 while let Ok((generation, buf)) = self.full_parse_rx.try_recv() {
2211 self.view.install_full_parse(generation, buf);
2212 }
2213
2214 let snap = snapshot_from_backend(&self.backend, self.content_revision);
2219 self.view.update(&snap, editor_rect, selection);
2220
2221 if let Some(generation) = self.view.take_pending_full_parse() {
2229 let lines: Vec<String> = snap.lines.iter().cloned().collect();
2230 let tx = self.full_parse_tx.clone();
2231 let redraw = self.redraw_tx.clone();
2232 self.full_parse_task.spawn(async move {
2233 let buf = ParsedBuffer::parse(&lines);
2234 let _ = tx.send((generation, buf));
2235 if let Some(redraw) = redraw {
2238 let _ = redraw.send(AppEvent::Redraw);
2239 }
2240 });
2241 }
2242 let bar_focused = self.search.is_some() && focused;
2245 let editor_focused = focused && !bar_focused;
2246 use self::view::CursorShape;
2247 let cursor_shape = match self.backend.modal_is_insert() {
2248 None => None, Some(true) => Some(CursorShape::Bar),
2250 Some(false) => Some(CursorShape::Block),
2251 };
2252 self.view
2253 .render(f, editor_rect, theme, editor_focused, cursor_shape);
2254
2255 if self
2259 .needles_revision
2260 .is_some_and(|r| r != self.content_revision)
2261 {
2262 self.search_needles.clear();
2263 self.needles_revision = None;
2264 }
2265 let mut emphasis_needles = self.search_needles.clone();
2266 if let Some(state) = &self.search {
2267 let q = state.input.value().trim().to_lowercase();
2268 if !q.is_empty() {
2269 emphasis_needles.push(q);
2270 }
2271 }
2272 paint_viewport_extras(f.buffer_mut(), editor_rect, &emphasis_needles, theme);
2273
2274 if snap.lines.iter().all(|l| l.is_empty()) && editor_rect.height > 0 {
2278 let leader = self
2279 .key_bindings
2280 .first_combo_for(&crate::keys::action_shortcuts::ActionShortcuts::Leader)
2281 .unwrap_or_else(|| "leader".to_string());
2282 f.render_widget(
2283 ratatui::widgets::Paragraph::new(format!(
2284 "Type to start · [[ to link · # to tag · {leader} for commands"
2285 ))
2286 .style(
2287 Style::default()
2288 .fg(theme.gray.to_ratatui())
2289 .add_modifier(Modifier::ITALIC),
2290 ),
2291 Rect {
2292 x: editor_rect.x.saturating_add(2),
2293 width: editor_rect.width.saturating_sub(2),
2294 height: 1,
2295 ..editor_rect
2296 },
2297 );
2298 }
2299 if let (Some(state), Some(bar_rect)) = (self.search.as_mut(), search_rect) {
2300 render_search_bar(f, bar_rect, state, theme, bar_focused);
2301 }
2302
2303 self.poll_autocomplete();
2311 if let (Some(controller), Some(live_anchor)) =
2318 (self.autocomplete.as_mut(), self.view.last_cursor_screen)
2319 {
2320 if let Some(state) = controller.state_mut() {
2321 state.anchor = live_anchor;
2322 }
2323 if let Some(state) = controller.state() {
2324 autocomplete::render(f, state, editor_rect, theme);
2325 }
2326 }
2327 }
2328
2329 fn hint_shortcuts(&self) -> Vec<(String, String)> {
2330 use crate::keys::action_shortcuts::ActionShortcuts;
2331
2332 if let Some(mut label) = self.backend.mode_label() {
2337 if let Some(p) = self.backend.vim_pending_hint() {
2338 label = format!("{label} {p}");
2339 }
2340 let mut hints = vec![(String::new(), label)];
2341 hints.extend(
2342 [
2343 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2344 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2345 (ActionShortcuts::FileOperations, "file ops"),
2346 ]
2347 .iter()
2348 .filter_map(|(action, label)| {
2349 self.key_bindings
2350 .first_combo_for(action)
2351 .map(|k| (k, label.to_string()))
2352 }),
2353 );
2354 return hints;
2355 }
2356
2357 let mut hints: Vec<(String, String)> = Vec::new();
2360 match self.link_at_cursor() {
2361 Some(LinkTarget::Note(_)) => {
2362 if let Some(k) = self
2363 .key_bindings
2364 .first_combo_for(&ActionShortcuts::FollowLink)
2365 {
2366 hints.push((k, "follow link".to_string()));
2367 }
2368 }
2369 Some(LinkTarget::Label(_)) => {
2370 if let Some(k) = self
2371 .key_bindings
2372 .first_combo_for(&ActionShortcuts::FollowLink)
2373 {
2374 hints.push((k, "browse tag".to_string()));
2375 }
2376 }
2377 None => {}
2378 }
2379 hints.extend(crate::components::hints::hints_for(
2380 &self.key_bindings,
2381 &[
2382 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2383 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2384 (ActionShortcuts::FileOperations, "file ops"),
2385 (ActionShortcuts::FindInBuffer, "find"),
2386 ],
2387 ));
2388 hints
2389 }
2390}
2391
2392#[cfg(test)]
2393mod tests {
2394 use super::snapshot::EditorMode;
2395 use super::*;
2396 use crate::keys::KeyBindings;
2397
2398 fn make_editor() -> TextEditorComponent {
2399 TextEditorComponent::new(
2400 KeyBindings::empty(),
2401 &crate::settings::AppSettings::default(),
2402 )
2403 }
2404
2405 fn dummy_tx() -> AppTx {
2406 tokio::sync::mpsc::unbounded_channel().0
2407 }
2408
2409 fn get_ta(editor: &mut TextEditorComponent) -> &mut TextArea<'static> {
2410 match &mut editor.backend {
2411 BackendState::Textarea(tb) => &mut tb.ta,
2412 _ => panic!("expected Textarea backend"),
2413 }
2414 }
2415
2416 #[test]
2417 fn has_trigger_before_cursor_finds_bracket() {
2418 assert!(has_trigger_before_cursor("hello [[foo", 11));
2419 assert!(has_trigger_before_cursor("[[a b c", 7));
2420 }
2421
2422 #[test]
2423 fn has_trigger_before_cursor_finds_hashtag() {
2424 assert!(has_trigger_before_cursor("text #tag", 9));
2425 }
2426
2427 #[test]
2428 fn has_trigger_before_cursor_no_trigger_bails() {
2429 assert!(!has_trigger_before_cursor("plain prose here", 16));
2430 assert!(!has_trigger_before_cursor("", 0));
2431 }
2432
2433 #[test]
2434 fn has_trigger_before_cursor_handles_multibyte_no_panic() {
2435 let line = "你好世界".to_string() + &"a".repeat(80);
2438 let col = line.chars().count();
2439 assert!(!has_trigger_before_cursor(&line, col));
2440
2441 let with_emoji = "🦀".repeat(20) + "[[note";
2442 let col = with_emoji.chars().count();
2443 assert!(has_trigger_before_cursor(&with_emoji, col));
2444
2445 let accented = "é".repeat(100);
2446 let col = accented.chars().count();
2447 assert!(!has_trigger_before_cursor(&accented, col));
2448 }
2449
2450 #[test]
2451 fn has_trigger_before_cursor_ignores_chars_after_cursor() {
2452 assert!(!has_trigger_before_cursor("foo [[bar", 3));
2454 }
2455
2456 #[test]
2457 fn has_trigger_before_cursor_wikilink_with_spaces() {
2458 assert!(has_trigger_before_cursor("[[my note title", 15));
2461 }
2462
2463 #[test]
2464 fn fresh_editor_is_not_dirty() {
2465 let editor = make_editor();
2466 assert!(!editor.is_dirty());
2467 }
2468
2469 #[test]
2470 fn after_set_text_not_dirty() {
2471 let mut editor = make_editor();
2472 editor.set_text("hello world".to_string());
2473 assert!(!editor.is_dirty());
2474 }
2475
2476 #[test]
2477 fn get_text_returns_loaded_content() {
2478 let mut editor = make_editor();
2479 editor.set_text("line one\nline two".to_string());
2480 assert_eq!(editor.get_text(), "line one\nline two");
2481 }
2482
2483 #[test]
2484 fn mark_saved_clears_dirty() {
2485 let mut editor = make_editor();
2486 editor.set_text("initial".to_string());
2487 let text = editor.get_text();
2488 editor.mark_saved(text.clone() + "x"); assert!(editor.is_dirty());
2490 editor.mark_saved(text); assert!(!editor.is_dirty());
2492 }
2493
2494 #[test]
2495 fn trailing_newline_does_not_cause_false_dirty() {
2496 let mut editor = make_editor();
2497 editor.set_text("content\n".to_string());
2498 assert!(
2499 !editor.is_dirty(),
2500 "trailing newline should not make editor dirty after load"
2501 );
2502 }
2503
2504 #[test]
2505 fn cursor_move_does_not_dirty_buffer() {
2506 let mut editor = make_editor();
2507 editor.set_text("hello world".to_string());
2508 assert!(!editor.is_dirty());
2509 let tx = dummy_tx();
2510 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
2514 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2515 assert!(
2516 !editor.is_dirty(),
2517 "cursor move must not mark the editor as dirty"
2518 );
2519 }
2520
2521 #[test]
2522 fn empty_stack_undo_redo_does_not_dirty_or_bump_revision() {
2523 let mut editor = make_editor();
2527 editor.set_text("foo".to_string());
2528 let rev_before = editor.content_revision();
2529 assert!(!editor.is_dirty());
2530 let tx = dummy_tx();
2531 for key_code in [KeyCode::Char('z'), KeyCode::Char('y')] {
2532 let key = ratatui::crossterm::event::KeyEvent::new(key_code, KeyModifiers::CONTROL);
2533 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2534 }
2535 assert!(
2536 !editor.is_dirty(),
2537 "empty-stack undo/redo must not flip is_dirty"
2538 );
2539 assert_eq!(
2540 editor.content_revision(),
2541 rev_before,
2542 "empty-stack undo/redo must not bump content_revision"
2543 );
2544 }
2545
2546 #[test]
2547 fn fresh_editor_content_revision_is_nonzero() {
2548 let editor = make_editor();
2555 assert!(editor.content_revision().get() >= 1);
2556 }
2557
2558 #[test]
2559 fn mouse_down_clears_selection() {
2560 let mut editor = make_editor();
2561 editor.set_text("hello world".to_string());
2562 let ta = get_ta(&mut editor);
2563 ta.start_selection();
2564 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2565 assert!(ta.selection_range().is_some());
2566 ta.cancel_selection();
2567 editor.selection = if let BackendState::Textarea(tb) = &editor.backend {
2568 tb.ta.selection_range()
2569 } else {
2570 None
2571 };
2572 assert!(editor.selection.is_none());
2573 }
2574
2575 #[test]
2576 fn ctrl_c_copies_selected_text() {
2577 let mut editor = make_editor();
2578 editor.set_text("hello world".to_string());
2579 let ta = get_ta(&mut editor);
2580 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2581 ta.start_selection();
2582 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2583 let range = ta.selection_range().unwrap();
2584 let ((sr, sc), (er, ec)) = range;
2585 let lines = ta.lines();
2586 let selected = if sr == er {
2587 lines[sr][sc..ec].to_string()
2588 } else {
2589 lines[sr][sc..].to_string()
2590 };
2591 assert_eq!(selected, "hello ");
2592 }
2593
2594 fn select_range(editor: &mut TextEditorComponent, start: (u16, u16), end: (u16, u16)) {
2596 let ta = get_ta(editor);
2597 ta.cancel_selection();
2598 ta.move_cursor(CursorMove::Jump(start.0, start.1));
2599 ta.start_selection();
2600 ta.move_cursor(CursorMove::Jump(end.0, end.1));
2601 assert!(ta.selection_range().is_some());
2602 }
2603
2604 fn send_char(editor: &mut TextEditorComponent, c: char) {
2605 let tx = dummy_tx();
2606 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
2607 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2608 }
2609
2610 #[test]
2611 fn surround_pair_maps_open_and_symmetric_chars() {
2612 assert_eq!(surround_pair('('), Some(("(", ")")));
2613 assert_eq!(surround_pair('['), Some(("[", "]")));
2614 assert_eq!(surround_pair('{'), Some(("{", "}")));
2615 assert_eq!(surround_pair('<'), Some(("<", ">")));
2616 assert_eq!(surround_pair('"'), Some(("\"", "\"")));
2617 assert_eq!(surround_pair('\''), Some(("'", "'")));
2618 assert_eq!(surround_pair('`'), Some(("`", "`")));
2619 assert_eq!(surround_pair('*'), Some(("*", "*")));
2620 assert_eq!(surround_pair('_'), Some(("_", "_")));
2621 assert_eq!(surround_pair('~'), Some(("~", "~")));
2622 assert_eq!(surround_pair(')'), None);
2624 assert_eq!(surround_pair(']'), None);
2625 assert_eq!(surround_pair('}'), None);
2626 assert_eq!(surround_pair('>'), None);
2627 assert_eq!(surround_pair('a'), None);
2628 }
2629
2630 #[test]
2631 fn typing_open_paren_with_selection_wraps_it() {
2632 let mut editor = make_editor();
2633 editor.set_text("hello world".to_string());
2634 select_range(&mut editor, (0, 0), (0, 5)); send_char(&mut editor, '(');
2636 assert_eq!(editor.get_text(), "(hello) world");
2637 assert!(editor.is_dirty(), "wrap must mark the buffer dirty");
2638 }
2639
2640 #[test]
2641 fn wrap_keeps_selection_on_inner_text() {
2642 let mut editor = make_editor();
2643 editor.set_text("hello world".to_string());
2644 select_range(&mut editor, (0, 0), (0, 5));
2645 send_char(&mut editor, '(');
2646 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2648 }
2649
2650 #[test]
2651 fn chained_brackets_build_a_wikilink() {
2652 let mut editor = make_editor();
2653 editor.set_text("my note".to_string());
2654 select_range(&mut editor, (0, 0), (0, 7));
2655 send_char(&mut editor, '[');
2656 send_char(&mut editor, '[');
2657 assert_eq!(editor.get_text(), "[[my note]]");
2658 assert_eq!(editor.selection, Some(((0, 2), (0, 9))));
2659 }
2660
2661 #[test]
2662 fn symmetric_chars_wrap_and_chain() {
2663 let mut editor = make_editor();
2664 editor.set_text("bold".to_string());
2665 select_range(&mut editor, (0, 0), (0, 4));
2666 send_char(&mut editor, '*');
2667 assert_eq!(editor.get_text(), "*bold*");
2668 send_char(&mut editor, '*');
2669 assert_eq!(editor.get_text(), "**bold**");
2670 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2671 }
2672
2673 #[test]
2674 fn closing_char_replaces_selection() {
2675 let mut editor = make_editor();
2676 editor.set_text("hello world".to_string());
2677 select_range(&mut editor, (0, 0), (0, 5));
2678 send_char(&mut editor, ')');
2679 assert_eq!(editor.get_text(), ") world");
2680 }
2681
2682 #[test]
2683 fn open_char_without_selection_inserts_normally() {
2684 let mut editor = make_editor();
2685 editor.set_text("hello".to_string());
2686 let ta = get_ta(&mut editor);
2687 ta.move_cursor(CursorMove::End);
2688 send_char(&mut editor, '(');
2689 assert_eq!(editor.get_text(), "hello(");
2690 }
2691
2692 #[test]
2693 fn wrap_spans_multiline_selection() {
2694 let mut editor = make_editor();
2695 editor.set_text("abc\ndef".to_string());
2696 select_range(&mut editor, (0, 0), (1, 3));
2697 send_char(&mut editor, '(');
2698 assert_eq!(editor.get_text(), "(abc\ndef)");
2699 assert_eq!(editor.selection, Some(((0, 1), (1, 3))));
2701 }
2702
2703 #[test]
2704 fn wrap_handles_multibyte_selection() {
2705 let mut editor = make_editor();
2706 editor.set_text("héllo🦀 x".to_string());
2707 select_range(&mut editor, (0, 0), (0, 6)); send_char(&mut editor, '`');
2709 assert_eq!(editor.get_text(), "`héllo🦀` x");
2710 assert_eq!(editor.selection, Some(((0, 1), (0, 7))));
2711 }
2712
2713 #[test]
2714 fn wrap_with_reversed_selection_direction() {
2715 let mut editor = make_editor();
2717 editor.set_text("hello world".to_string());
2718 select_range(&mut editor, (0, 5), (0, 0));
2719 send_char(&mut editor, '(');
2720 assert_eq!(editor.get_text(), "(hello) world");
2721 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2722 }
2723
2724 #[test]
2725 fn text_action_keeps_selection_on_inner_text() {
2726 let mut editor = make_editor();
2729 editor.set_text("bold word".to_string());
2730 select_range(&mut editor, (0, 0), (0, 4));
2731 editor.apply_text_action(TextAction::Bold);
2732 assert_eq!(editor.get_text(), "**bold** word");
2733 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2734 }
2735
2736 #[test]
2737 fn wrap_undo_is_two_steps_back_to_original() {
2738 let mut editor = make_editor();
2742 editor.set_text("hello world".to_string());
2743 select_range(&mut editor, (0, 0), (0, 5));
2744 send_char(&mut editor, '(');
2745 assert_eq!(editor.get_text(), "(hello) world");
2746 let ta = get_ta(&mut editor);
2747 ta.undo();
2748 ta.undo();
2749 assert_eq!(editor.get_text(), "hello world");
2750 }
2751
2752 #[test]
2753 fn linkable_url_accepts_supported_schemes() {
2754 assert_eq!(
2755 linkable_url("https://example.com"),
2756 Some("https://example.com")
2757 );
2758 assert_eq!(
2759 linkable_url("http://example.com/path?q=1#frag"),
2760 Some("http://example.com/path?q=1#frag"),
2761 );
2762 assert_eq!(
2763 linkable_url(" https://example.com "),
2764 Some("https://example.com")
2765 );
2766 assert_eq!(
2767 linkable_url("ftp://files.example.com/x"),
2768 Some("ftp://files.example.com/x"),
2769 );
2770 assert_eq!(
2771 linkable_url("ftps://files.example.com/x"),
2772 Some("ftps://files.example.com/x"),
2773 );
2774 assert_eq!(
2775 linkable_url("mailto:user@example.com"),
2776 Some("mailto:user@example.com"),
2777 );
2778 assert_eq!(
2779 linkable_url("mailto:user@example.com?subject=hi"),
2780 Some("mailto:user@example.com?subject=hi"),
2781 );
2782 }
2783
2784 #[test]
2785 fn linkable_url_rejects_other_schemes_and_plain_text() {
2786 assert_eq!(linkable_url("file:///etc/passwd"), None);
2787 assert_eq!(linkable_url("ssh://host"), None);
2788 assert_eq!(linkable_url("javascript:alert(1)"), None);
2789 assert_eq!(linkable_url("example.com"), None);
2790 assert_eq!(linkable_url("not a url"), None);
2791 assert_eq!(linkable_url(""), None);
2792 assert_eq!(linkable_url("https://example.com\nmore"), None);
2793 }
2794
2795 #[test]
2796 fn try_build_markdown_link_wraps_selection_when_clip_is_url() {
2797 assert_eq!(
2798 try_build_markdown_link("https://example.com", Some("click here")).as_deref(),
2799 Some("[click here](https://example.com)"),
2800 );
2801 }
2802
2803 #[test]
2804 fn try_build_markdown_link_trims_url_whitespace() {
2805 assert_eq!(
2806 try_build_markdown_link(" https://example.com\n", Some("link")).as_deref(),
2807 Some("[link](https://example.com)"),
2808 );
2809 }
2810
2811 #[test]
2812 fn try_build_markdown_link_returns_none_when_no_selection() {
2813 assert_eq!(try_build_markdown_link("https://example.com", None), None);
2814 }
2815
2816 #[test]
2817 fn try_build_markdown_link_returns_none_when_not_url() {
2818 assert_eq!(try_build_markdown_link("plain text", Some("sel")), None);
2819 }
2820
2821 #[test]
2822 fn try_build_markdown_link_returns_none_when_selection_empty() {
2823 assert_eq!(
2824 try_build_markdown_link("https://example.com", Some("")),
2825 None
2826 );
2827 }
2828
2829 #[test]
2830 fn try_build_markdown_link_escapes_close_bracket_in_selection() {
2831 assert_eq!(
2832 try_build_markdown_link("https://example.com", Some("a]b")).as_deref(),
2833 Some(r"[a\]b](https://example.com)"),
2834 );
2835 }
2836
2837 #[test]
2838 fn try_build_markdown_link_wraps_ftp_url() {
2839 assert_eq!(
2840 try_build_markdown_link("ftp://files.example.com/x", Some("download")).as_deref(),
2841 Some("[download](ftp://files.example.com/x)"),
2842 );
2843 }
2844
2845 fn key(code: KeyCode, mods: KeyModifiers) -> ratatui::crossterm::event::KeyEvent {
2846 ratatui::crossterm::event::KeyEvent::new(code, mods)
2847 }
2848
2849 #[test]
2851 fn paint_viewport_extras_emphasizes_needles_and_tasks() {
2852 use ratatui::buffer::Buffer;
2853 use ratatui::layout::Position;
2854 let theme = crate::settings::themes::Theme::default();
2855 let area = Rect::new(0, 0, 30, 3);
2856 let mut buf = Buffer::empty(area);
2857 buf.set_string(0, 0, "find the needle here", Style::default());
2858 buf.set_string(0, 1, "- [x] done task", Style::default());
2859 buf.set_string(0, 2, "- [ ] open task", Style::default());
2860
2861 paint_viewport_extras(&mut buf, area, &["needle".to_string()], &theme);
2862
2863 let cell = buf.cell(Position::new(9, 0)).unwrap();
2865 assert_eq!(cell.fg, theme.color_search_match.to_ratatui());
2866 assert!(cell.style().add_modifier.contains(Modifier::BOLD));
2867 let cell = buf.cell(Position::new(8, 1)).unwrap();
2869 assert!(cell.style().add_modifier.contains(Modifier::CROSSED_OUT));
2870 let cell = buf.cell(Position::new(8, 2)).unwrap();
2872 assert!(!cell.style().add_modifier.contains(Modifier::CROSSED_OUT));
2873 let cb = buf.cell(Position::new(3, 2)).unwrap();
2874 assert_eq!(cb.fg, theme.accent.to_ratatui());
2875 }
2876
2877 #[test]
2879 fn search_needles_clear_on_edit() {
2880 let settings = crate::settings::AppSettings::default();
2881 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2882 ed.set_text("alpha beta".to_string());
2883 ed.set_search_needles(vec!["Alpha".to_string()]);
2884 assert_eq!(ed.search_needles, vec!["alpha"]);
2885 assert_eq!(ed.needles_revision, Some(ed.content_revision));
2886
2887 ed.set_text("alpha beta gamma".to_string());
2889 assert_ne!(ed.needles_revision, Some(ed.content_revision));
2890 }
2891
2892 #[test]
2893 fn jump_to_heading_moves_cursor_to_heading_line() {
2894 let settings = crate::settings::AppSettings::default();
2895 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2896 ed.set_text("intro\n# Top\nbody\n## Sub One\nmore\n".to_string());
2897
2898 ed.jump_to_heading("Sub One");
2899 assert_eq!(ed.view_snapshot().cursor.0, 3);
2900
2901 ed.jump_to_heading("Top");
2902 assert_eq!(ed.view_snapshot().cursor.0, 1);
2903
2904 ed.jump_to_heading("Nope");
2906 assert_eq!(ed.view_snapshot().cursor.0, 1);
2907 }
2908
2909 #[test]
2910 fn open_or_advance_search_opens_find_bar_with_empty_query() {
2911 let mut editor = make_editor();
2912 editor.set_text("hello world".to_string());
2913 editor.open_or_advance_search();
2914 let state = editor.search.as_ref().expect("find bar opened");
2915 assert!(state.input.is_empty());
2916 assert!(matches!(state.status, SearchStatus::Empty));
2917 }
2918
2919 #[test]
2920 fn open_or_advance_search_advances_when_already_open() {
2921 let mut editor = make_editor();
2922 editor.set_text("ab ab ab".to_string());
2923 let tx = dummy_tx();
2924 editor.open_or_advance_search();
2925 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2926 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2927 editor.open_or_advance_search();
2929 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2930 assert_eq!(col, 3, "second invocation advances to next match");
2931 }
2932
2933 #[test]
2934 fn typing_in_find_bar_jumps_cursor_to_first_match() {
2935 let mut editor = make_editor();
2936 editor.set_text("foo bar baz".to_string());
2937 let tx = dummy_tx();
2938 editor.open_or_advance_search();
2939 for ch in ['b', 'a', 'r'] {
2940 editor.handle_textarea_key(&key(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
2941 }
2942 let state = editor.search.as_ref().unwrap();
2943 assert_eq!(state.input.value(), "bar");
2944 assert!(matches!(state.status, SearchStatus::Match));
2945 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2946 assert_eq!(col, 4, "cursor jumped to start of 'bar'");
2947 }
2948
2949 #[test]
2950 fn enter_in_find_bar_advances_to_next_match() {
2951 let mut editor = make_editor();
2952 editor.set_text("ab ab ab".to_string());
2953 let tx = dummy_tx();
2954 editor.open_or_advance_search();
2955 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2956 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2957 editor.handle_textarea_key(&key(KeyCode::Enter, KeyModifiers::NONE), &tx);
2959 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2960 assert_eq!(col, 3, "Enter advances to second match");
2961 }
2962
2963 #[test]
2964 fn match_is_highlighted_as_selection_after_search() {
2965 let mut editor = make_editor();
2966 editor.set_text("foo bar baz".to_string());
2967 let tx = dummy_tx();
2968 editor.open_or_advance_search();
2969 for ch in ['b', 'a', 'r'] {
2970 editor.handle_textarea_key(&key(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
2971 }
2972 assert_eq!(editor.selection, Some(((0, 4), (0, 7))));
2974 }
2975
2976 #[test]
2977 fn no_match_clears_selection() {
2978 let mut editor = make_editor();
2979 editor.set_text("hello".to_string());
2980 let tx = dummy_tx();
2981 editor.open_or_advance_search();
2982 editor.handle_textarea_key(&key(KeyCode::Char('z'), KeyModifiers::NONE), &tx);
2983 assert_eq!(editor.selection, None);
2984 }
2985
2986 #[test]
2987 fn esc_in_find_bar_clears_selection_highlight() {
2988 let mut editor = make_editor();
2989 editor.set_text("foo bar".to_string());
2990 let tx = dummy_tx();
2991 editor.open_or_advance_search();
2992 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2993 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2994 editor.handle_textarea_key(&key(KeyCode::Char('r'), KeyModifiers::NONE), &tx);
2995 assert!(editor.selection.is_some());
2996 editor.handle_textarea_key(&key(KeyCode::Esc, KeyModifiers::NONE), &tx);
2997 assert!(editor.selection.is_none());
2998 }
2999
3000 #[test]
3001 fn esc_in_find_bar_closes_it() {
3002 let mut editor = make_editor();
3003 editor.set_text("hello".to_string());
3004 let tx = dummy_tx();
3005 editor.open_or_advance_search();
3006 assert!(editor.search.is_some());
3007 editor.handle_textarea_key(&key(KeyCode::Esc, KeyModifiers::NONE), &tx);
3008 assert!(editor.search.is_none());
3009 }
3010
3011 #[test]
3012 fn find_bar_consumes_typing_so_editor_text_is_unchanged() {
3013 let mut editor = make_editor();
3014 editor.set_text("hello".to_string());
3015 let tx = dummy_tx();
3016 editor.open_or_advance_search();
3017 editor.handle_textarea_key(&key(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
3018 assert_eq!(editor.get_text(), "hello");
3019 }
3020
3021 #[test]
3022 fn no_match_status_when_query_absent() {
3023 let mut editor = make_editor();
3024 editor.set_text("hello".to_string());
3025 let tx = dummy_tx();
3026 editor.open_or_advance_search();
3027 editor.handle_textarea_key(&key(KeyCode::Char('z'), KeyModifiers::NONE), &tx);
3028 let state = editor.search.as_ref().unwrap();
3029 assert!(matches!(state.status, SearchStatus::NoMatch));
3030 }
3031
3032 #[test]
3033 fn try_build_markdown_link_wraps_mailto_url() {
3034 assert_eq!(
3035 try_build_markdown_link("mailto:user@example.com", Some("email me")).as_deref(),
3036 Some("[email me](mailto:user@example.com)"),
3037 );
3038 }
3039
3040 #[test]
3041 fn insert_at_cursor_appends_text() {
3042 let mut editor = make_editor();
3043 editor.set_text("hello".to_string());
3044 {
3045 let ta = get_ta(&mut editor);
3046 ta.move_cursor(ratatui_textarea::CursorMove::End);
3047 }
3048 editor.insert_at_cursor(" world", &dummy_tx());
3049 assert_eq!(editor.get_text(), "hello world");
3050 }
3051
3052 #[test]
3053 fn insert_at_cursor_replaces_selection() {
3054 let mut editor = make_editor();
3055 editor.set_text("hello world".to_string());
3056 {
3057 let ta = get_ta(&mut editor);
3058 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3059 ta.start_selection();
3060 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3061 }
3062 editor.insert_at_cursor("HEY ", &dummy_tx());
3063 assert_eq!(editor.get_text(), "HEY world");
3064 }
3065
3066 #[test]
3067 fn paste_inserts_text_at_cursor() {
3068 let mut editor = make_editor();
3069 editor.set_text("hello".to_string());
3070 let ta = get_ta(&mut editor);
3071 ta.move_cursor(ratatui_textarea::CursorMove::End);
3072 ta.insert_str(" world");
3073 assert_eq!(editor.get_text(), "hello world");
3074 }
3075
3076 #[test]
3077 fn bold_action_with_no_selection_inserts_pair_and_centers_cursor() {
3078 let mut editor = make_editor();
3079 editor.set_text("hello".to_string());
3080 {
3081 let ta = get_ta(&mut editor);
3082 ta.move_cursor(ratatui_textarea::CursorMove::End);
3083 }
3084 editor.apply_text_action(TextAction::Bold);
3085 assert_eq!(editor.get_text(), "hello****");
3086 let ta = get_ta(&mut editor);
3087 assert_eq!(ta.cursor(), (0, 7));
3088 }
3089
3090 #[test]
3091 fn italic_action_with_no_selection_inserts_single_pair() {
3092 let mut editor = make_editor();
3093 editor.set_text(String::new());
3094 editor.apply_text_action(TextAction::Italic);
3095 assert_eq!(editor.get_text(), "**");
3096 let ta = get_ta(&mut editor);
3097 assert_eq!(ta.cursor(), (0, 1));
3098 }
3099
3100 #[test]
3101 fn strikethrough_action_with_selection_wraps_text() {
3102 let mut editor = make_editor();
3103 editor.set_text("hello world".to_string());
3104 {
3105 let ta = get_ta(&mut editor);
3106 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3107 ta.start_selection();
3108 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3109 }
3110 editor.apply_text_action(TextAction::Strikethrough);
3111 assert_eq!(editor.get_text(), "~~hello ~~world");
3112 }
3113
3114 #[test]
3115 fn bold_action_wraps_non_ascii_selection() {
3116 let mut editor = make_editor();
3117 editor.set_text("hello 你好 world".to_string());
3118 {
3119 let ta = get_ta(&mut editor);
3120 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3121 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3122 ta.start_selection();
3123 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3124 }
3125 editor.apply_text_action(TextAction::Bold);
3126 assert_eq!(editor.get_text(), "hello **你好 **world");
3127 }
3128
3129 #[test]
3130 fn bold_action_wraps_selected_text() {
3131 let mut editor = make_editor();
3132 editor.set_text("foo bar".to_string());
3133 {
3134 let ta = get_ta(&mut editor);
3135 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3136 ta.start_selection();
3137 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3138 }
3139 editor.apply_text_action(TextAction::Bold);
3140 assert_eq!(editor.get_text(), "**foo **bar");
3141 }
3142
3143 #[test]
3144 fn indent_no_selection_indents_current_line() {
3145 let mut editor = make_editor();
3146 editor.set_text("foo\nbar".to_string());
3147 {
3148 let ta = get_ta(&mut editor);
3149 ta.move_cursor(ratatui_textarea::CursorMove::Bottom);
3150 }
3151 editor.indent_lines(false);
3152 let lines = get_ta(&mut editor).lines();
3153 assert_eq!(lines[0], "foo");
3154 assert!(lines[1].starts_with(' ') || lines[1].starts_with('\t'));
3155 assert!(lines[1].trim_start() == "bar");
3156 }
3157
3158 #[test]
3159 fn indent_midline_selection_keeps_text_before_and_selection() {
3160 let mut editor = make_editor();
3161 editor.set_text("hello world".to_string());
3162 {
3163 let ta = get_ta(&mut editor);
3164 ta.move_cursor(ratatui_textarea::CursorMove::Jump(0, 6));
3165 ta.start_selection();
3166 ta.move_cursor(ratatui_textarea::CursorMove::End);
3167 }
3168 editor.indent_lines(false);
3169 let ta = get_ta(&mut editor);
3170 assert_eq!(ta.lines()[0].trim_start(), "hello world");
3172 let indent = ta.lines()[0].len() - "hello world".len();
3174 assert_eq!(
3175 ta.selection_range(),
3176 Some(((0, 6 + indent), (0, 11 + indent)))
3177 );
3178 }
3179
3180 #[test]
3181 fn indent_with_selection_indents_all_touched_lines() {
3182 let mut editor = make_editor();
3183 editor.set_text("foo\nbar\nbaz".to_string());
3184 {
3185 let ta = get_ta(&mut editor);
3186 ta.move_cursor(ratatui_textarea::CursorMove::Top);
3187 ta.start_selection();
3188 ta.move_cursor(ratatui_textarea::CursorMove::Down);
3189 ta.move_cursor(ratatui_textarea::CursorMove::End);
3190 }
3191 editor.indent_lines(false);
3192 let lines: Vec<String> = get_ta(&mut editor).lines().to_vec();
3193 assert_eq!(lines[0].trim_start(), "foo");
3194 assert_eq!(lines[1].trim_start(), "bar");
3195 assert_eq!(lines[2], "baz");
3196 assert!(lines[0].len() > 3);
3197 assert!(lines[1].len() > 3);
3198 }
3199
3200 #[test]
3201 fn dedent_removes_leading_indent() {
3202 let mut editor = make_editor();
3203 editor.set_text(" foo\n bar\nbaz".to_string());
3204 let tab_len = get_ta(&mut editor).tab_length() as usize;
3205 {
3206 let ta = get_ta(&mut editor);
3207 ta.move_cursor(ratatui_textarea::CursorMove::Top);
3208 ta.start_selection();
3209 ta.move_cursor(ratatui_textarea::CursorMove::Bottom);
3210 ta.move_cursor(ratatui_textarea::CursorMove::End);
3211 }
3212 editor.indent_lines(true);
3213 let lines: Vec<String> = get_ta(&mut editor).lines().to_vec();
3214 assert_eq!(lines[0], format!("{}foo", " ".repeat(4 - tab_len.min(4))));
3216 assert_eq!(
3218 lines[1],
3219 format!("{}bar", " ".repeat(2usize.saturating_sub(tab_len)))
3220 );
3221 assert_eq!(lines[2], "baz");
3222 }
3223
3224 #[test]
3225 fn dedent_no_leading_whitespace_is_noop_for_that_line() {
3226 let mut editor = make_editor();
3227 editor.set_text("foo".to_string());
3228 editor.indent_lines(true);
3229 assert_eq!(editor.get_text(), "foo");
3230 }
3231
3232 #[test]
3233 fn smart_enter_continues_unordered_list() {
3234 let mut editor = make_editor();
3235 editor.set_text("- foo".to_string());
3236 {
3237 let ta = get_ta(&mut editor);
3238 ta.move_cursor(ratatui_textarea::CursorMove::End);
3239 }
3240 assert!(editor.smart_enter());
3241 assert_eq!(editor.get_text(), "- foo\n- ");
3242 }
3243
3244 #[test]
3245 fn smart_enter_continues_ordered_list_increments() {
3246 let mut editor = make_editor();
3247 editor.set_text("1. foo".to_string());
3248 {
3249 let ta = get_ta(&mut editor);
3250 ta.move_cursor(ratatui_textarea::CursorMove::End);
3251 }
3252 assert!(editor.smart_enter());
3253 assert_eq!(editor.get_text(), "1. foo\n2. ");
3254 }
3255
3256 #[test]
3257 fn smart_enter_on_empty_list_marker_clears_line() {
3258 let mut editor = make_editor();
3259 editor.set_text("- ".to_string());
3260 {
3261 let ta = get_ta(&mut editor);
3262 ta.move_cursor(ratatui_textarea::CursorMove::End);
3263 }
3264 assert!(editor.smart_enter());
3265 assert_eq!(editor.get_text(), "");
3266 }
3267
3268 #[test]
3269 fn smart_enter_preserves_indent() {
3270 let mut editor = make_editor();
3271 editor.set_text(" body".to_string());
3272 {
3273 let ta = get_ta(&mut editor);
3274 ta.move_cursor(ratatui_textarea::CursorMove::End);
3275 }
3276 assert!(editor.smart_enter());
3277 assert_eq!(editor.get_text(), " body\n ");
3278 }
3279
3280 #[test]
3281 fn smart_enter_on_empty_indent_dedents() {
3282 let mut editor = make_editor();
3283 editor.set_text(" ".to_string());
3284 {
3285 let ta = get_ta(&mut editor);
3286 ta.move_cursor(ratatui_textarea::CursorMove::End);
3287 }
3288 let tab_len = get_ta(&mut editor).tab_length() as usize;
3289 assert!(editor.smart_enter());
3290 assert_eq!(
3291 editor.get_text(),
3292 " ".repeat(4usize.saturating_sub(tab_len))
3293 );
3294 }
3295
3296 #[test]
3297 fn smart_enter_no_indent_no_marker_returns_false() {
3298 let mut editor = make_editor();
3299 editor.set_text("plain".to_string());
3300 {
3301 let ta = get_ta(&mut editor);
3302 ta.move_cursor(ratatui_textarea::CursorMove::End);
3303 }
3304 assert!(!editor.smart_enter());
3305 assert_eq!(editor.get_text(), "plain");
3306 }
3307
3308 #[test]
3309 fn smart_enter_mid_line_returns_false() {
3310 let mut editor = make_editor();
3311 editor.set_text("- foo".to_string());
3312 {
3313 let ta = get_ta(&mut editor);
3314 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3315 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3316 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3317 }
3318 assert!(!editor.smart_enter());
3319 }
3320
3321 #[test]
3322 fn smart_enter_on_empty_indented_list_marker_dedents_keeping_marker() {
3323 let mut editor = make_editor();
3324 let tab_len = get_ta(&mut editor).tab_length() as usize;
3325 let indent = " ".repeat(tab_len);
3326 editor.set_text(format!("{indent}- "));
3327 {
3328 let ta = get_ta(&mut editor);
3329 ta.move_cursor(ratatui_textarea::CursorMove::End);
3330 }
3331 assert!(editor.smart_enter());
3332 assert_eq!(editor.get_text(), "- ");
3333 }
3334
3335 #[test]
3336 fn smart_enter_on_empty_list_marker_clears_line_after_full_dedent() {
3337 let mut editor = make_editor();
3338 let tab_len = get_ta(&mut editor).tab_length() as usize;
3339 let indent = " ".repeat(tab_len);
3340 editor.set_text(format!("{indent}- "));
3341 {
3342 let ta = get_ta(&mut editor);
3343 ta.move_cursor(ratatui_textarea::CursorMove::End);
3344 }
3345 assert!(editor.smart_enter());
3347 assert_eq!(editor.get_text(), "- ");
3348 {
3351 let ta = get_ta(&mut editor);
3352 ta.move_cursor(ratatui_textarea::CursorMove::End);
3353 }
3354 assert!(editor.smart_enter());
3355 assert_eq!(editor.get_text(), "");
3356 }
3357
3358 #[test]
3359 fn smart_enter_continues_list_with_non_ascii_content() {
3360 let mut editor = make_editor();
3361 editor.set_text("- 你好".to_string());
3362 {
3363 let ta = get_ta(&mut editor);
3364 ta.move_cursor(ratatui_textarea::CursorMove::End);
3365 }
3366 assert!(editor.smart_enter());
3367 assert_eq!(editor.get_text(), "- 你好\n- ");
3368 }
3369
3370 #[test]
3371 fn smart_enter_preserves_tab_indent() {
3372 let mut editor = make_editor();
3373 editor.set_text("\tbody".to_string());
3374 {
3375 let ta = get_ta(&mut editor);
3376 ta.move_cursor(ratatui_textarea::CursorMove::End);
3377 }
3378 assert!(editor.smart_enter());
3379 assert_eq!(editor.get_text(), "\tbody\n\t");
3380 }
3381
3382 #[test]
3383 fn smart_enter_on_tab_only_line_dedents() {
3384 let mut editor = make_editor();
3385 editor.set_text("\t\t".to_string());
3386 {
3387 let ta = get_ta(&mut editor);
3388 ta.move_cursor(ratatui_textarea::CursorMove::End);
3389 }
3390 assert!(editor.smart_enter());
3391 assert_eq!(editor.get_text(), "\t");
3393 }
3394
3395 #[test]
3396 fn smart_enter_continues_indented_list() {
3397 let mut editor = make_editor();
3398 editor.set_text(" - foo".to_string());
3399 {
3400 let ta = get_ta(&mut editor);
3401 ta.move_cursor(ratatui_textarea::CursorMove::End);
3402 }
3403 assert!(editor.smart_enter());
3404 assert_eq!(editor.get_text(), " - foo\n - ");
3405 }
3406
3407 #[test]
3408 fn unsupported_text_action_is_noop() {
3409 let mut editor = make_editor();
3410 editor.set_text("hello".to_string());
3411 editor.apply_text_action(TextAction::Underline);
3412 assert_eq!(editor.get_text(), "hello");
3413 }
3414
3415 #[test]
3416 fn textarea_hint_shortcuts_has_no_mode_indicator() {
3417 let editor = make_editor();
3418 let hints = editor.hint_shortcuts();
3419 assert!(
3421 !hints
3422 .iter()
3423 .any(|(_, label)| label == "NORMAL" || label == "INSERT")
3424 );
3425 }
3426
3427 fn place_cursor_at_col(editor: &mut TextEditorComponent, col: usize) {
3431 let ta = get_ta(editor);
3432 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3433 for _ in 0..col {
3434 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3435 }
3436 }
3437
3438 #[test]
3439 fn link_at_cursor_returns_label_when_cursor_on_hashtag() {
3440 let mut editor = make_editor();
3441 editor.set_text("see #rust now".to_string());
3442 place_cursor_at_col(&mut editor, 5);
3444 assert_eq!(
3445 editor.link_at_cursor(),
3446 Some(LinkTarget::Label("rust".into())),
3447 );
3448 }
3449
3450 #[test]
3451 fn link_at_cursor_returns_label_at_hash_char() {
3452 let mut editor = make_editor();
3453 editor.set_text("see #rust now".to_string());
3454 place_cursor_at_col(&mut editor, 4);
3456 assert_eq!(
3457 editor.link_at_cursor(),
3458 Some(LinkTarget::Label("rust".into())),
3459 );
3460 }
3461
3462 #[test]
3463 fn link_at_cursor_returns_none_outside_hashtag() {
3464 let mut editor = make_editor();
3465 editor.set_text("see #rust now".to_string());
3466 place_cursor_at_col(&mut editor, 0);
3468 assert_eq!(editor.link_at_cursor(), None);
3469 }
3470
3471 #[test]
3472 fn link_at_cursor_returns_note_for_wikilink() {
3473 let mut editor = make_editor();
3474 editor.set_text("open [[my note]] please".to_string());
3475 place_cursor_at_col(&mut editor, 7);
3477 let result = editor.link_at_cursor();
3478 assert!(
3479 matches!(result, Some(LinkTarget::Note(_))),
3480 "expected Note variant, got {result:?}"
3481 );
3482 }
3483
3484 #[test]
3487 fn link_at_cursor_returns_note_for_markdown_link_with_fragment() {
3488 let line = "[see docs](#section)";
3493 let mut editor = make_editor();
3494 editor.set_text(line.to_string());
3495 let cursor = "[see docs](#sec".chars().count(); place_cursor_at_col(&mut editor, cursor);
3498 let result = editor.link_at_cursor();
3499 assert!(
3500 matches!(result, Some(LinkTarget::Note(_))),
3501 "expected Note variant for markdown link fragment, got {result:?}"
3502 );
3503 }
3504
3505 #[test]
3506 fn vim_normal_i_then_typing_inserts_text() {
3507 let mut settings = crate::settings::AppSettings::default();
3508 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
3509 let mut editor = TextEditorComponent::new(KeyBindings::empty(), &settings);
3510 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3511 editor.handle_input(
3513 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3514 &tx,
3515 );
3516 assert_eq!(editor.get_text(), "");
3517 editor.handle_input(
3519 &InputEvent::Key(key(KeyCode::Char('i'), KeyModifiers::NONE)),
3520 &tx,
3521 );
3522 editor.handle_input(
3523 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3524 &tx,
3525 );
3526 assert_eq!(editor.get_text(), "x");
3527 }
3528
3529 fn make_vim_editor() -> TextEditorComponent {
3531 let mut settings = crate::settings::AppSettings::default();
3532 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
3533 TextEditorComponent::new(KeyBindings::empty(), &settings)
3534 }
3535
3536 fn vim_mode(editor: &TextEditorComponent) -> EditorMode {
3539 match &editor.backend {
3540 BackendState::Textarea(tb) => match &tb.input {
3541 backend::InputInterpreter::Vim(e) => e.mode().clone(),
3542 _ => panic!("expected Vim input interpreter"),
3543 },
3544 _ => panic!("expected Textarea backend"),
3545 }
3546 }
3547
3548 #[test]
3554 fn vim_visual_paste_url_wraps_whole_selected_word() {
3555 let mut editor = make_vim_editor();
3556 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3557 editor.set_text("hello world".to_string());
3558 editor.handle_input(
3561 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
3562 &tx,
3563 );
3564 editor.handle_input(
3565 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
3566 &tx,
3567 );
3568 assert_eq!(vim_mode(&editor), EditorMode::Visual);
3569 editor.paste_text("https://example.com", &tx);
3570 assert_eq!(
3571 editor.get_text(),
3572 "[hello](https://example.com) world",
3573 "the whole selected word (including the char under the cursor) must be wrapped"
3574 );
3575 }
3576
3577 #[test]
3583 fn vim_visual_bold_wraps_whole_selected_word() {
3584 let mut editor = make_vim_editor();
3585 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3586 editor.set_text("hello world".to_string());
3587 editor.handle_input(
3588 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
3589 &tx,
3590 );
3591 editor.handle_input(
3592 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
3593 &tx,
3594 );
3595 assert_eq!(vim_mode(&editor), EditorMode::Visual);
3596 editor.apply_text_action(TextAction::Bold);
3597 assert_eq!(
3598 editor.get_text(),
3599 "**hello** world",
3600 "the whole selected word (including the char under the cursor) must be wrapped"
3601 );
3602 }
3603
3604 #[test]
3610 fn vim_visual_copy_is_read_only_and_does_not_grow_selection() {
3611 let mut editor = make_vim_editor();
3612 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3613 editor.set_text("hello world".to_string());
3614 editor.handle_input(
3615 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
3616 &tx,
3617 );
3618 editor.handle_input(
3619 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
3620 &tx,
3621 );
3622 let before = get_ta(&mut editor).selection_range();
3623 assert_eq!(before, Some(((0, 0), (0, 4))));
3624 assert_eq!(
3626 editor.inclusive_visual_range(),
3627 Some(((0, 0), (0, 5))),
3628 "copy must read the inclusive range including the cursor char"
3629 );
3630 editor.copy_selection_to_clipboard();
3632 editor.copy_selection_to_clipboard();
3633 assert_eq!(
3634 get_ta(&mut editor).selection_range(),
3635 before,
3636 "copy must not move the cursor or grow the live selection"
3637 );
3638 }
3639
3640 #[test]
3650 fn vim_sync_collapsed_sel_stays_normal() {
3651 let mut editor = make_vim_editor();
3652 editor.set_text("hello world".to_string());
3653
3654 assert_eq!(vim_mode(&editor), EditorMode::Normal);
3656
3657 editor.backend.vim_sync_mouse_selection(false);
3660 assert_eq!(
3661 vim_mode(&editor),
3662 EditorMode::Normal,
3663 "collapsed (bare click) selection must not enter Visual mode"
3664 );
3665 }
3666
3667 #[test]
3669 fn vim_sync_real_sel_enters_visual() {
3670 let mut editor = make_vim_editor();
3671 editor.set_text("hello world".to_string());
3672
3673 assert_eq!(vim_mode(&editor), EditorMode::Normal);
3675
3676 editor.backend.vim_sync_mouse_selection(true);
3678 assert_eq!(
3679 vim_mode(&editor),
3680 EditorMode::Visual,
3681 "real drag selection must enter Visual mode"
3682 );
3683 }
3684
3685 #[test]
3689 fn vim_find_bar_captures_typing_not_cursor() {
3690 let mut editor = make_vim_editor();
3691 editor.set_text("hello world\nsecond line".to_string());
3692 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3693
3694 editor.open_or_advance_search();
3696 assert!(editor.search.is_some(), "find bar must be open");
3697
3698 editor.handle_input(
3700 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
3701 &tx,
3702 );
3703 editor.handle_input(
3704 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
3705 &tx,
3706 );
3707
3708 let q = editor
3712 .search
3713 .as_ref()
3714 .map(|s| s.input.value().to_string())
3715 .unwrap_or_default();
3716 assert_eq!(q, "lo", "find query must capture typed characters");
3717
3718 assert_eq!(
3721 editor.get_text(),
3722 "hello world\nsecond line",
3723 "buffer must not be modified while find bar is open"
3724 );
3725
3726 assert_eq!(
3732 editor.cursor_pos().1,
3733 3,
3734 "cursor must jump to the search match (col 3), not to a vim motion position"
3735 );
3736 }
3737
3738 #[test]
3741 fn vim_search_enter_confirms_and_n_navigates() {
3742 let mut editor = make_vim_editor();
3743 editor.set_text("lo xx lo yy lo".to_string());
3745 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3746
3747 editor.open_or_advance_search();
3749 assert!(editor.search.is_some(), "find bar must open");
3750
3751 editor.handle_input(
3753 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
3754 &tx,
3755 );
3756 editor.handle_input(
3757 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
3758 &tx,
3759 );
3760
3761 editor.handle_input(
3763 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3764 &tx,
3765 );
3766 assert!(
3767 editor.search.is_none(),
3768 "find bar must close after Enter in vim mode"
3769 );
3770
3771 editor.handle_input(
3775 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
3776 &tx,
3777 );
3778 let (_, c1) = editor.cursor_pos();
3779 assert_eq!(c1, 6, "'n' must jump to the 2nd 'lo' at col 6");
3780
3781 editor.handle_input(
3782 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
3783 &tx,
3784 );
3785 let (_, c2) = editor.cursor_pos();
3786 assert_eq!(c2, 12, "'n' must jump to the 3rd 'lo' at col 12");
3787
3788 assert_eq!(editor.get_text(), "lo xx lo yy lo");
3790 }
3791}