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