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 let ((sr, sc), (er, ec)) = ta.selection_range()?;
138 if sr == er && sc == ec {
139 return None;
140 }
141 let lines = ta.lines();
142 Some(if sr == er {
143 let line = &lines[sr];
144 let sb = char_col_to_byte(line, sc);
145 let eb = char_col_to_byte(line, ec);
146 line[sb..eb].to_string()
147 } else {
148 let first = &lines[sr];
149 let sb = char_col_to_byte(first, sc);
150 let mut parts = vec![first[sb..].to_string()];
151 for line in &lines[(sr + 1)..er] {
152 parts.push(line.clone());
153 }
154 let last = &lines[er];
155 let eb = char_col_to_byte(last, ec);
156 parts.push(last[..eb].to_string());
157 parts.join("\n")
158 })
159}
160
161fn surround_pair(c: char) -> Option<(&'static str, &'static str)> {
166 match c {
167 '(' => Some(("(", ")")),
168 '[' => Some(("[", "]")),
169 '{' => Some(("{", "}")),
170 '<' => Some(("<", ">")),
171 '"' => Some(("\"", "\"")),
172 '\'' => Some(("'", "'")),
173 '`' => Some(("`", "`")),
174 '*' => Some(("*", "*")),
175 '_' => Some(("_", "_")),
176 '~' => Some(("~", "~")),
177 _ => None,
178 }
179}
180
181fn set_selection(ta: &mut TextArea<'_>, start: (usize, usize), end: (usize, usize)) {
185 let jump = |(row, col): (usize, usize)| {
186 CursorMove::Jump(
187 u16::try_from(row).unwrap_or(u16::MAX),
188 u16::try_from(col).unwrap_or(u16::MAX),
189 )
190 };
191 ta.cancel_selection();
192 ta.move_cursor(jump(start));
193 ta.start_selection();
194 ta.move_cursor(jump(end));
195}
196
197#[derive(Debug, Clone)]
201pub struct ClipboardImage {
202 pub width: usize,
203 pub height: usize,
204 pub rgba: Vec<u8>,
205}
206
207const LINKABLE_PASTE_SCHEMES: &[&str] = &["http", "https", "ftp", "ftps", "mailto"];
211
212fn linkable_url(s: &str) -> Option<&str> {
213 kimun_core::note::scan::url_with_allowed_scheme(s, LINKABLE_PASTE_SCHEMES)
214}
215
216fn try_build_markdown_link(clip: &str, selection: Option<&str>) -> Option<String> {
220 let url = linkable_url(clip)?;
221 let sel = selection.filter(|s| !s.is_empty())?;
222 let escaped = sel.replace('\\', r"\\").replace(']', r"\]");
223 Some(format!("[{escaped}]({url})"))
224}
225
226use std::sync::Arc;
227
228use kimun_core::NoteVault;
229
230use crate::components::Component;
231use crate::components::autocomplete::{
232 self, AutocompleteController, AutocompleteHost, AutocompleteMode, HandleKeyOutcome,
233};
234use crate::components::event_state::EventState;
235use crate::components::events::AppEvent;
236use crate::components::events::AppTx;
237use crate::components::events::InputEvent;
238use crate::components::events::redraw_callback;
239use crate::components::single_line_input::{InputOutcome, SingleLineInput};
240use crate::components::text_editor::autocomplete_glue::apply_accept_to_textarea;
241use crate::keys::KeyBindings;
242use crate::keys::action_shortcuts::TextAction;
243use crate::settings::AppSettings;
244use crate::settings::themes::Theme;
245
246#[derive(Debug, Clone, PartialEq)]
248pub enum LinkTarget {
249 Note(String),
251 Label(String),
253}
254
255struct SearchState {
256 input: SingleLineInput,
257 status: SearchStatus,
258}
259
260enum SearchStatus {
261 Empty,
262 Match,
263 NoMatch,
264 Invalid(String),
265}
266
267impl SearchStatus {
268 fn from_found(found: bool) -> Self {
269 if found { Self::Match } else { Self::NoMatch }
270 }
271}
272
273const FIND_PROMPT: &str = "Find: ";
274const FIND_HINTS: &str = " [Enter] next [Shift+Enter] prev [Esc] close";
275
276fn render_search_bar(
277 f: &mut Frame,
278 rect: Rect,
279 state: &mut SearchState,
280 theme: &Theme,
281 focused: bool,
282) {
283 let base = theme.base_style();
284 let muted = Style::default()
285 .fg(theme.gray.to_ratatui())
286 .bg(theme.bg.to_ratatui());
287 let err = Style::default()
288 .fg(theme.red.to_ratatui())
289 .bg(theme.bg.to_ratatui());
290 let prompt_cols = unicode_width::UnicodeWidthStr::width(FIND_PROMPT) as u16;
291 let value_total_cols = state.input.display_width() as u16;
295 let tail: Option<(String, Style)> = match &state.status {
296 SearchStatus::Empty => None,
297 SearchStatus::Match => Some((FIND_HINTS.to_string(), muted)),
298 SearchStatus::NoMatch => Some((" no match".to_string(), err)),
299 SearchStatus::Invalid(msg) => Some((format!(" invalid regex: {msg}"), err)),
300 };
301 f.render_widget(
302 Paragraph::new(Line::from(Span::styled(
303 FIND_PROMPT,
304 base.add_modifier(Modifier::BOLD),
305 )))
306 .style(base),
307 Rect {
308 width: prompt_cols.min(rect.width),
309 ..rect
310 },
311 );
312 state.input.render(f, rect, base, prompt_cols, focused);
313 if let Some((text, style)) = tail {
314 let consumed = prompt_cols.saturating_add(value_total_cols);
315 let tail_rect = Rect {
316 x: rect.x.saturating_add(consumed),
317 width: rect.width.saturating_sub(consumed),
318 ..rect
319 };
320 f.render_widget(Paragraph::new(text).style(style), tail_rect);
321 }
322}
323
324struct EditorHostSnapshot<'a> {
331 snap: EditorSnapshot<'a>,
332 cursor_screen: Option<(u16, u16)>,
333 cache_key: Option<NonZeroU64>,
334}
335
336impl<'a> AutocompleteHost for EditorHostSnapshot<'a> {
337 fn buffer_snapshot(&self) -> EditorSnapshot<'_> {
338 EditorSnapshot::borrowed(
343 self.snap.lines.as_ref(),
344 self.snap.cursor,
345 self.snap.content_revision,
346 )
347 }
348 fn cache_key(&self) -> Option<NonZeroU64> {
349 self.cache_key
350 }
351 fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
352 Some(self.cursor_screen.unwrap_or((0, 0)))
366 }
367}
368
369fn build_editor_host_snapshot<'a>(
375 backend: &'a BackendState,
376 content_revision: NonZeroU64,
377 cursor_screen: Option<(u16, u16)>,
378) -> Option<EditorHostSnapshot<'a>> {
379 if !backend.is_textarea() {
380 return None;
381 }
382 Some(EditorHostSnapshot {
383 snap: snapshot_from_backend(backend, content_revision),
384 cursor_screen,
385 cache_key: Some(content_revision),
386 })
387}
388
389pub struct TextEditorComponent {
393 backend: BackendState,
394 rect: Rect,
396 key_bindings: KeyBindings,
397 saved_content_rev: Option<NonZeroU64>,
406 view: MarkdownEditorView,
407 edit_generation: u64,
412 content_revision: NonZeroU64,
435 selection: Option<((usize, usize), (usize, usize))>,
438 clipboard: Option<Clipboard>,
440 nvim_host: NvimHost,
443 search: Option<SearchState>,
445 autocomplete: Option<AutocompleteController>,
449 autocomplete_vault: Option<Arc<NoteVault>>,
453 autocomplete_redraw_bound: bool,
458 full_parse_task: SingleSlotTask<()>,
465 pub wants_context_menu: bool,
468 search_needles: Vec<String>,
472 needles_revision: Option<NonZeroU64>,
474 full_parse_tx: tokio::sync::mpsc::UnboundedSender<(u64, ParsedBuffer)>,
475 full_parse_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, ParsedBuffer)>,
476 redraw_tx: Option<AppTx>,
480}
481
482impl TextEditorComponent {
483 pub fn new(key_bindings: KeyBindings, settings: &AppSettings) -> Self {
484 let (full_parse_tx, full_parse_rx) = tokio::sync::mpsc::unbounded_channel();
485 Self {
486 backend: BackendState::from_settings(
487 &settings.editor_backend,
488 settings.nvim_path.as_ref(),
489 ),
490 rect: Rect::default(),
491 key_bindings,
492 saved_content_rev: NonZeroU64::new(1),
493 view: MarkdownEditorView::new(),
494 edit_generation: 0,
495 content_revision: NonZeroU64::new(1).unwrap(),
496 selection: None,
497 clipboard: Clipboard::new().ok(),
498 nvim_host: NvimHost::new(),
499 search: None,
500 autocomplete: None,
501 autocomplete_vault: None,
502 autocomplete_redraw_bound: false,
503 full_parse_task: SingleSlotTask::empty(),
504 wants_context_menu: false,
505 search_needles: Vec::new(),
506 needles_revision: None,
507 full_parse_tx,
508 full_parse_rx,
509 redraw_tx: None,
510 }
511 }
512
513 pub fn set_vault(&mut self, vault: Arc<NoteVault>) {
518 self.autocomplete_vault = Some(vault.clone());
519 if self.backend.is_textarea() {
520 self.autocomplete = Some(AutocompleteController::new(
521 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
522 AutocompleteMode::Both,
523 ));
524 }
525 }
526
527 fn ensure_autocomplete_for_textarea(&mut self) {
532 if self.autocomplete.is_some() {
533 return;
534 }
535 if !self.backend.is_textarea() {
536 return;
537 }
538 let Some(vault) = self.autocomplete_vault.clone() else {
539 return;
540 };
541 self.autocomplete = Some(AutocompleteController::new(
542 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
543 AutocompleteMode::Both,
544 ));
545 self.autocomplete_redraw_bound = false;
548 }
549
550 #[allow(dead_code)]
557 fn autocomplete_host_snapshot(&self) -> Option<EditorHostSnapshot<'_>> {
558 build_editor_host_snapshot(
559 &self.backend,
560 self.content_revision,
561 self.view.last_cursor_screen,
562 )
563 }
564
565 fn poll_autocomplete(&mut self) {
568 if let Some(controller) = self.autocomplete.as_mut() {
569 controller.poll_results();
570 }
571 }
572
573 fn textarea_cursor(&self) -> Option<(usize, usize)> {
577 let ta = self.backend.as_textarea()?;
578 Some(cursor_tuple(ta))
579 }
580
581 fn refresh_autocomplete_if_open(&mut self) {
582 if !self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
584 return;
585 }
586 let Some(snapshot) = build_editor_host_snapshot(
590 &self.backend,
591 self.content_revision,
592 self.view.last_cursor_screen,
593 ) else {
594 self.close_autocomplete();
595 return;
596 };
597 if let Some(controller) = self.autocomplete.as_mut() {
598 controller.refresh_if_open(&snapshot);
599 }
600 }
601
602 fn sync_autocomplete(&mut self) {
606 let Some(controller) = self.autocomplete.as_ref() else {
607 return; };
609
610 if !controller.is_open() {
623 let Some(ta) = self.backend.as_textarea() else {
624 return;
625 };
626 let (row, col) = cursor_tuple(ta);
627 let line = ta.lines().get(row).map(|s| s.as_str()).unwrap_or("");
628 if !has_trigger_before_cursor(line, col) {
629 return;
630 }
631 }
632
633 let Some(snapshot) = build_editor_host_snapshot(
637 &self.backend,
638 self.content_revision,
639 self.view.last_cursor_screen,
640 ) else {
641 if let Some(c) = self.autocomplete.as_mut() {
642 c.close();
643 }
644 return;
645 };
646 if let Some(controller) = self.autocomplete.as_mut() {
647 controller.sync(&snapshot);
648 }
649 }
650
651 pub fn lines(&self) -> &[String] {
657 match &self.backend {
658 BackendState::Textarea(tb) => tb.ta.lines(),
659 BackendState::Nvim(_) => &[],
660 }
661 }
662
663 pub fn view_snapshot(&self) -> EditorSnapshot<'_> {
682 snapshot_from_backend(&self.backend, self.content_revision)
683 }
684
685 pub fn cursor_pos(&self) -> (usize, usize) {
689 self.backend.cursor()
690 }
691
692 pub fn set_search_needles(&mut self, needles: Vec<String>) {
696 self.search_needles = needles
697 .into_iter()
698 .map(|n| n.to_lowercase())
699 .filter(|n| !n.is_empty())
700 .collect();
701 self.needles_revision = Some(self.content_revision);
702 }
703
704 pub fn set_text(&mut self, text: String) {
705 if text == self.get_text() {
712 self.saved_content_rev = Some(self.content_revision);
713 if let Some(nvim) = self.backend.as_nvim() {
714 nvim.mark_clean();
715 }
716 return;
717 }
718 match &mut self.backend {
719 BackendState::Textarea(tb) => {
720 let lines = text.lines();
721 tb.ta = TextArea::from(lines);
722 }
723 BackendState::Nvim(nvim) => {
724 nvim.set_text(&text);
725 }
726 }
727 self.backend.vim_reset_to_normal();
728 self.bump_content();
729 let reconstructed = self.get_text();
730 self.mark_saved(reconstructed);
731 self.close_autocomplete();
734 }
735
736 pub fn get_text(&self) -> String {
737 self.backend.text()
738 }
739
740 pub fn content_revision(&self) -> NonZeroU64 {
747 self.content_revision
748 }
749
750 pub fn mark_saved_at_revision(&mut self, rev: NonZeroU64) {
760 if rev != self.content_revision {
761 return;
762 }
763 if let Some(nvim) = self.backend.as_nvim() {
764 nvim.mark_clean();
765 }
766 self.saved_content_rev = Some(rev);
767 }
768
769 pub fn mark_saved(&mut self, text: String) {
777 let matches = text == self.get_text();
778 if matches {
779 if let Some(nvim) = self.backend.as_nvim() {
780 nvim.mark_clean();
781 }
782 self.saved_content_rev = Some(self.content_revision);
783 } else {
784 self.saved_content_rev = None;
789 }
790 }
791
792 pub fn is_dirty(&self) -> bool {
793 match &self.backend {
794 BackendState::Textarea(_) => self.saved_content_rev != Some(self.content_revision),
795 BackendState::Nvim(nvim) => nvim.snapshot().dirty,
796 }
797 }
798
799 pub fn vim_space_leads(&self) -> bool {
803 self.backend.vim_space_leads()
804 }
805
806 pub fn link_at_cursor(&self) -> Option<LinkTarget> {
809 let (_row, col, line) = match &self.backend {
810 BackendState::Textarea(tb) => {
811 let (row, col) = cursor_tuple(&tb.ta);
812 let line = tb.ta.lines().get(row)?.to_string();
813 (row, col, line)
814 }
815 BackendState::Nvim(nvim) => {
816 let snap = nvim.snapshot();
817 let (row, col) = snap.cursor;
818 let line = snap.lines.get(row)?.to_string();
819 (row, col, line)
820 }
821 };
822
823 if let Some(span) = kimun_core::note::scan::link_char_spans(&line)
826 .into_iter()
827 .find(|s| s.start <= col && col < s.end)
828 {
829 return Some(LinkTarget::Note(span.target));
830 }
831
832 let parsed = self::markdown::ParsedLine::parse(&line);
834 parsed
835 .elements
836 .iter()
837 .find(|e| {
838 e.kind == self::markdown::ElementKind::Label
839 && col >= e.start_char
840 && col < e.end_char
841 })
842 .map(|e| {
843 let span: String = line
844 .chars()
845 .skip(e.start_char)
846 .take(e.end_char - e.start_char)
847 .collect();
848 let name = span.trim_start_matches('#').to_string();
849 LinkTarget::Label(name)
850 })
851 }
852
853 fn copy_selection_to_clipboard(&mut self) {
855 let text = {
856 let Some(ta) = self.backend.as_textarea() else {
857 return;
858 };
859 match selection_text(ta) {
860 Some(t) => t,
861 None => return,
862 }
863 };
864 if let Some(cb) = &mut self.clipboard {
865 let _ = cb.set_text(text);
866 }
867 }
868
869 fn paste_from_clipboard(&mut self, tx: &AppTx) {
871 let text = match &mut self.clipboard {
872 Some(cb) => match cb.get_text() {
873 Ok(t) if !t.is_empty() => t,
874 _ => return,
875 },
876 None => return,
877 };
878 self.paste_text(&text, tx);
879 }
880
881 pub fn paste_text(&mut self, text: &str, tx: &AppTx) {
890 if text.is_empty() {
891 return;
892 }
893 match &mut self.backend {
894 BackendState::Textarea(tb) => {
895 let selection = linkable_url(text).and_then(|_| selection_text(&tb.ta));
896 let wrapped = try_build_markdown_link(text, selection.as_deref());
897 if tb.ta.selection_range().is_some() {
898 tb.ta.cut();
899 }
900 tb.ta.insert_str(wrapped.as_deref().unwrap_or(text));
901 self.selection = tb.ta.selection_range();
902 self.bump_content();
903 }
904 BackendState::Nvim(nvim) => {
905 nvim.paste(text, tx.clone());
906 self.bump_content();
907 }
908 }
909 self.bind_autocomplete_redraw(tx);
913 self.sync_autocomplete();
914 }
915
916 pub fn insert_at_cursor(&mut self, text: &str, tx: &AppTx) {
921 if matches!(self.backend, BackendState::Nvim(_)) {
922 self.paste_text(text, tx);
923 return;
924 }
925 if let Some(ta) = self.backend.as_textarea_mut() {
926 if ta.selection_range().is_some() {
927 ta.cut();
928 }
929 ta.insert_str(text);
930 self.selection = ta.selection_range();
931 self.bump_content();
932 }
933 self.bind_autocomplete_redraw(tx);
936 self.sync_autocomplete();
937 }
938
939 pub fn take_clipboard_image(&mut self) -> Option<ClipboardImage> {
943 let cb = self.clipboard.as_mut()?;
944 let img = cb.get_image().ok()?;
945 Some(ClipboardImage {
946 width: img.width,
947 height: img.height,
948 rgba: img.bytes.into_owned(),
949 })
950 }
951
952 fn wrap_selection(&mut self, open: &str, close: &str) -> bool {
958 let Some(ta) = self.backend.as_textarea_mut() else {
959 return false;
960 };
961 let Some(((sr, sc), (er, ec))) = ta.selection_range() else {
962 return false;
963 };
964 let Some(text) = selection_text(ta) else {
965 return false;
966 };
967 ta.insert_str(format!("{open}{text}{close}"));
968 let shift = open.chars().count();
972 let inner_end_col = if sr == er { ec + shift } else { ec };
973 set_selection(ta, (sr, sc + shift), (er, inner_end_col));
974 self.selection = ta.selection_range();
975 self.bump_content();
976 true
977 }
978
979 pub fn apply_text_action(&mut self, action: TextAction) {
982 let marker = match action {
983 TextAction::Bold => "**",
984 TextAction::Italic => "*",
985 TextAction::Strikethrough => "~~",
986 _ => return,
987 };
988 if self.wrap_selection(marker, marker) {
989 return;
990 }
991 let Some(ta) = self.backend.as_textarea_mut() else {
992 return;
993 };
994 ta.insert_str(format!("{marker}{marker}"));
995 for _ in 0..marker.len() {
996 ta.move_cursor(CursorMove::Back);
997 }
998 self.selection = ta.selection_range();
999 self.bump_content();
1000 }
1001
1002 pub fn smart_enter(&mut self) -> bool {
1007 enum Action {
1008 ClearLine { chars: usize },
1009 InsertPrefix(String),
1010 Dedent,
1011 }
1012 let action = {
1013 let Some(ta) = self.backend.as_textarea() else {
1014 return false;
1015 };
1016 if ta
1019 .selection_range()
1020 .is_some_and(|(start, end)| start != end)
1021 {
1022 return false;
1023 }
1024 let (row, col) = cursor_tuple(ta);
1025 let Some(line) = ta.lines().get(row) else {
1026 return false;
1027 };
1028 let total_chars = line.chars().count();
1029 if col != total_chars {
1030 return false;
1031 }
1032 let ws_end = markdown::leading_ws_byte_len(line);
1034 let (ws, after_ws) = line.split_at(ws_end);
1035 if let Some(marker_len) = markdown::list_marker_len(after_ws) {
1036 if after_ws.len() == marker_len {
1037 if ws_end > 0 {
1040 Action::Dedent
1041 } else {
1042 Action::ClearLine { chars: total_chars }
1043 }
1044 } else {
1045 let marker_str = &after_ws[..marker_len];
1046 let next_marker = increment_ordered_marker(marker_str)
1047 .unwrap_or_else(|| marker_str.to_string());
1048 Action::InsertPrefix(format!("{ws}{next_marker}"))
1049 }
1050 } else if ws_end > 0 && total_chars == ws_end {
1051 Action::Dedent
1052 } else if ws_end > 0 {
1053 Action::InsertPrefix(ws.to_string())
1054 } else {
1055 return false;
1056 }
1057 };
1058
1059 match action {
1060 Action::Dedent => {
1061 self.indent_lines(true);
1062 return true;
1063 }
1064 Action::ClearLine { chars } => {
1065 let Some(ta) = self.backend.as_textarea_mut() else {
1066 unreachable!()
1067 };
1068 ta.move_cursor(CursorMove::Head);
1069 ta.delete_str(chars);
1070 }
1071 Action::InsertPrefix(prefix) => {
1072 let Some(ta) = self.backend.as_textarea_mut() else {
1073 unreachable!()
1074 };
1075 ta.insert_newline();
1076 ta.insert_str(prefix);
1077 }
1078 }
1079 let Some(ta) = self.backend.as_textarea() else {
1080 unreachable!()
1081 };
1082 self.selection = ta.selection_range();
1083 self.bump_content();
1084 true
1085 }
1086
1087 pub fn jump_to_heading(&mut self, heading: &str) {
1092 let Some(ta) = self.backend.as_textarea_mut() else {
1093 return;
1094 };
1095 fn normalise(text: &str) -> String {
1100 text.trim()
1101 .trim_end_matches('#')
1102 .trim()
1103 .replace(['*', '_', '`'], "")
1104 }
1105 let wanted = normalise(heading);
1106 let row = ta.lines().iter().position(|l| {
1107 let t = l.trim_start();
1108 let stripped = t.trim_start_matches('#');
1109 stripped.len() != t.len() && normalise(stripped) == wanted
1110 });
1111 if let Some(row) = row {
1112 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1113 self.bump_cursor();
1114 }
1115 }
1116
1117 pub fn indent_lines(&mut self, dedent: bool) {
1121 let Some(ta) = self.backend.as_textarea_mut() else {
1122 return;
1123 };
1124 let tab_len = ta.tab_length() as usize;
1125 let hard_tab = ta.hard_tab_indent();
1126 let indent: String = if hard_tab {
1127 "\t".to_string()
1128 } else {
1129 " ".repeat(tab_len)
1130 };
1131 if indent.is_empty() {
1132 return;
1133 }
1134 let indent_chars = indent.len();
1135
1136 let sel = ta.selection_range();
1137 let saved_cursor = if sel.is_none() {
1138 Some(cursor_tuple(ta))
1139 } else {
1140 None
1141 };
1142 let (start_row, end_row) = match sel {
1143 Some(((sr, _), (er, ec))) => {
1144 let last = if ec == 0 && er > sr { er - 1 } else { er };
1147 (sr, last)
1148 }
1149 None => {
1150 let (r, _) = saved_cursor.unwrap();
1151 (r, r)
1152 }
1153 };
1154
1155 let row_count = end_row.saturating_sub(start_row) + 1;
1156 let mut row_deltas: Vec<isize> = Vec::with_capacity(row_count);
1157 let mut any_change = false;
1158
1159 ta.cancel_selection();
1164
1165 for row in start_row..=end_row {
1166 if dedent {
1167 let count = {
1168 let line = ta.lines().get(row).map(|s| s.as_str()).unwrap_or("");
1169 let max_remove = if hard_tab { 1 } else { tab_len };
1170 let mut count = 0usize;
1171 for (i, c) in line.chars().enumerate() {
1172 if i >= max_remove {
1173 break;
1174 }
1175 if c == '\t' {
1176 count += 1;
1177 break;
1178 } else if c == ' ' && !hard_tab {
1179 count += 1;
1180 } else {
1181 break;
1182 }
1183 }
1184 count
1185 };
1186 if count > 0 {
1187 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1188 ta.delete_str(count);
1189 any_change = true;
1190 }
1191 row_deltas.push(-(count as isize));
1192 } else {
1193 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1194 ta.insert_str(&indent);
1195 row_deltas.push(indent_chars as isize);
1196 any_change = true;
1197 }
1198 }
1199
1200 let adj = |row: usize, col: usize| -> usize {
1201 if row >= start_row && row <= end_row {
1202 let d = row_deltas[row - start_row];
1203 if d >= 0 {
1204 col + d as usize
1205 } else {
1206 col.saturating_sub((-d) as usize)
1207 }
1208 } else {
1209 col
1210 }
1211 };
1212
1213 match sel {
1214 Some(((ssr, ssc), (ser, sec))) => {
1215 set_selection(ta, (ssr, adj(ssr, ssc)), (ser, adj(ser, sec)));
1216 }
1217 None => {
1218 let (cr, cc) = saved_cursor.expect("captured when sel is None");
1219 let new_col = adj(cr, cc);
1220 ta.move_cursor(CursorMove::Jump(cr as u16, new_col as u16));
1221 }
1222 }
1223
1224 if any_change {
1225 self.selection = ta.selection_range();
1226 self.bump_content();
1227 }
1228 }
1229}
1230
1231impl TextEditorComponent {
1232 #[inline]
1237 fn bump_cursor(&mut self) {
1238 self.edit_generation = self.edit_generation.wrapping_add(1);
1239 }
1240
1241 #[inline]
1251 fn bump_content(&mut self) {
1252 self.edit_generation = self.edit_generation.wrapping_add(1);
1253 let next = self.content_revision.get().wrapping_add(1);
1258 self.content_revision = NonZeroU64::new(next).unwrap_or(NonZeroU64::new(1).unwrap());
1259 }
1260
1261 fn maybe_recover_from_dead_nvim(&mut self) {
1263 if self.backend.recover_from_dead_nvim() {
1264 self.ensure_autocomplete_for_textarea();
1268 }
1269 }
1270
1271 fn handle_nvim_key(
1276 &mut self,
1277 key: &ratatui::crossterm::event::KeyEvent,
1278 tx: &AppTx,
1279 ) -> Option<EventState> {
1280 let nvim = self.backend.as_nvim()?;
1284 let result = self.nvim_host.handle_key(nvim, key, tx);
1285
1286 if result == NvimKeyResult::Forwarded {
1291 self.bump_cursor();
1292 }
1293 Some(EventState::Consumed)
1294 }
1295
1296 pub fn open_or_advance_search(&mut self) {
1300 if !self.backend.is_textarea() {
1301 return;
1302 }
1303 if self.search.is_some() {
1304 self.search_advance(false);
1305 return;
1306 }
1307 self.close_autocomplete();
1311 self.search = Some(SearchState {
1312 input: SingleLineInput::new(),
1313 status: SearchStatus::Empty,
1314 });
1315 }
1316
1317 pub fn close_autocomplete(&mut self) {
1321 if let Some(c) = self.autocomplete.as_mut() {
1322 c.close();
1323 }
1324 }
1325
1326 pub fn set_redraw_tx(&mut self, tx: &AppTx) {
1331 self.bind_autocomplete_redraw(tx);
1332 }
1333
1334 fn bind_autocomplete_redraw(&mut self, tx: &AppTx) {
1343 if self.redraw_tx.is_none() {
1344 self.redraw_tx = Some(tx.clone());
1345 }
1346 if self.autocomplete_redraw_bound {
1347 return;
1348 }
1349 if let Some(c) = self.autocomplete.as_mut() {
1350 c.set_redraw_callback(redraw_callback(tx.clone()));
1351 self.autocomplete_redraw_bound = true;
1352 }
1353 }
1354
1355 fn close_search(&mut self) {
1356 if let Some(ta) = self.backend.as_textarea_mut() {
1357 let _ = ta.set_search_pattern("");
1358 }
1359 self.search = None;
1360 self.selection = None;
1361 }
1362
1363 fn refresh_search_pattern(&mut self, jump: bool) {
1366 let Some(state) = self.search.as_mut() else {
1367 return;
1368 };
1369 let Some(ta) = self.backend.as_textarea_mut() else {
1370 return;
1371 };
1372 if state.input.is_empty() {
1373 let _ = ta.set_search_pattern("");
1374 state.status = SearchStatus::Empty;
1375 self.selection = None;
1376 return;
1377 }
1378 if let Err(e) = ta.set_search_pattern(state.input.value()) {
1379 state.status = SearchStatus::Invalid(e.to_string());
1380 self.selection = None;
1381 return;
1382 }
1383 if !jump {
1384 state.status = SearchStatus::Match;
1385 return;
1386 }
1387 let found = ta.search_forward(true);
1388 state.status = SearchStatus::from_found(found);
1389 self.highlight_current_match(found);
1390 }
1391
1392 fn search_advance(&mut self, backward: bool) {
1393 let Some(state) = self.search.as_mut() else {
1394 return;
1395 };
1396 if state.input.is_empty() {
1397 return;
1398 }
1399 let Some(ta) = self.backend.as_textarea_mut() else {
1400 return;
1401 };
1402 let found = if backward {
1403 ta.search_back(false)
1404 } else {
1405 ta.search_forward(false)
1406 };
1407 state.status = SearchStatus::from_found(found);
1408 self.highlight_current_match(found);
1409 }
1410
1411 fn highlight_current_match(&mut self, found: bool) {
1416 self.selection = if found {
1417 self.compute_match_selection()
1418 } else {
1419 None
1420 };
1421 }
1422
1423 fn compute_match_selection(&self) -> Option<((usize, usize), (usize, usize))> {
1429 let ta = self.backend.as_textarea()?;
1430 let re = ta.search_pattern()?;
1431 let DataCursor(row, col_chars) = ta.cursor();
1432 let line = ta.lines().get(row)?;
1433 let byte_off = char_col_to_byte(line, col_chars);
1434 let m = re.find_at(line, byte_off)?;
1435 if m.start() != byte_off {
1436 return None;
1437 }
1438 let match_chars = line[m.range()].chars().count();
1439 Some(((row, col_chars), (row, col_chars + match_chars)))
1440 }
1441
1442 fn handle_search_key(&mut self, key: &ratatui::crossterm::event::KeyEvent) -> bool {
1444 let Some(state) = self.search.as_mut() else {
1445 return false;
1446 };
1447 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1448 let outcome = state.input.handle_key(key);
1449 match outcome {
1450 InputOutcome::Cancel => self.close_search(),
1451 InputOutcome::Submit => {
1452 if self.backend.is_vim() {
1453 self.search = None;
1457 } else {
1458 self.search_advance(shift);
1459 }
1460 }
1461 InputOutcome::Changed => self.refresh_search_pattern(true),
1462 InputOutcome::Consumed | InputOutcome::NotConsumed => {}
1463 }
1464 true
1465 }
1466
1467 fn vim_search_repeat(&mut self, backward: bool) {
1470 let found = {
1471 let Some(ta) = self.backend.as_textarea_mut() else {
1472 return;
1473 };
1474 if backward {
1475 ta.search_back(false)
1476 } else {
1477 ta.search_forward(false)
1478 }
1479 };
1480 self.highlight_current_match(found);
1481 }
1482
1483 fn handle_textarea_key(
1485 &mut self,
1486 key: &ratatui::crossterm::event::KeyEvent,
1487 tx: &AppTx,
1488 ) -> EventState {
1489 if self.handle_search_key(key) {
1491 return EventState::Consumed;
1492 }
1493
1494 if key.modifiers == KeyModifiers::CONTROL {
1496 match key.code {
1497 KeyCode::Char('c') => {
1498 self.copy_selection_to_clipboard();
1499 return EventState::Consumed;
1500 }
1501 KeyCode::Char('v') => {
1502 self.paste_from_clipboard(tx);
1503 return EventState::Consumed;
1504 }
1505 KeyCode::Char('x') => {
1506 self.copy_selection_to_clipboard();
1507 let cut = if let Some(ta) = self.backend.as_textarea_mut() {
1508 let cut = ta.cut();
1514 self.selection = ta.selection_range();
1515 cut
1516 } else {
1517 false
1518 };
1519 if cut {
1520 self.bump_content();
1521 }
1522 return EventState::Consumed;
1523 }
1524 _ => {}
1525 }
1526 }
1527
1528 let Some(ta) = self.backend.as_textarea_mut() else {
1529 unreachable!("handle_textarea_key called with non-Textarea backend")
1530 };
1531
1532 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1534 let handled = match (key.modifiers & !KeyModifiers::SHIFT, key.code) {
1535 (KeyModifiers::ALT, KeyCode::Left) => {
1536 cursor_move!(ta, CursorMove::WordBack, shift);
1537 true
1538 }
1539 (KeyModifiers::ALT, KeyCode::Right) => {
1540 cursor_move!(ta, CursorMove::WordForward, shift);
1541 true
1542 }
1543 (KeyModifiers::ALT, KeyCode::Char('b') | KeyCode::Char('B')) => {
1548 cursor_move!(ta, CursorMove::WordBack, shift);
1549 true
1550 }
1551 (KeyModifiers::ALT, KeyCode::Char('f') | KeyCode::Char('F')) => {
1552 cursor_move!(ta, CursorMove::WordForward, shift);
1553 true
1554 }
1555 (KeyModifiers::SUPER, KeyCode::Left) => {
1556 cursor_move!(ta, CursorMove::Head, shift);
1557 true
1558 }
1559 (KeyModifiers::SUPER, KeyCode::Right) => {
1560 cursor_move!(ta, CursorMove::End, shift);
1561 true
1562 }
1563 (KeyModifiers::SUPER, KeyCode::Up) => {
1564 cursor_move!(ta, CursorMove::Top, shift);
1565 true
1566 }
1567 (KeyModifiers::SUPER, KeyCode::Down) => {
1568 cursor_move!(ta, CursorMove::Bottom, shift);
1569 true
1570 }
1571 _ => false,
1572 };
1573 if handled {
1574 self.selection = ta.selection_range();
1575 self.bump_cursor();
1576 return EventState::Consumed;
1577 }
1578
1579 enum ShortcutOutcome {
1590 NoOp,
1591 CursorOnly,
1592 TextMutated,
1593 }
1594 let outcome: Option<ShortcutOutcome> =
1595 match (key.modifiers & !KeyModifiers::SHIFT, key.code) {
1596 (KeyModifiers::NONE, KeyCode::Left) => {
1598 cursor_move!(ta, CursorMove::Back, shift);
1599 Some(ShortcutOutcome::CursorOnly)
1600 }
1601 (KeyModifiers::NONE, KeyCode::Right) => {
1602 cursor_move!(ta, CursorMove::Forward, shift);
1603 Some(ShortcutOutcome::CursorOnly)
1604 }
1605 (KeyModifiers::NONE, KeyCode::Up) => {
1606 cursor_move!(ta, CursorMove::Up, shift);
1607 Some(ShortcutOutcome::CursorOnly)
1608 }
1609 (KeyModifiers::NONE, KeyCode::Down) => {
1610 cursor_move!(ta, CursorMove::Down, shift);
1611 Some(ShortcutOutcome::CursorOnly)
1612 }
1613 (KeyModifiers::NONE, KeyCode::Home) => {
1614 cursor_move!(ta, CursorMove::Head, shift);
1615 Some(ShortcutOutcome::CursorOnly)
1616 }
1617 (KeyModifiers::NONE, KeyCode::End) => {
1618 cursor_move!(ta, CursorMove::End, shift);
1619 Some(ShortcutOutcome::CursorOnly)
1620 }
1621 (KeyModifiers::NONE, KeyCode::PageUp) => {
1622 cursor_move!(ta, CursorMove::ParagraphBack, shift);
1623 Some(ShortcutOutcome::CursorOnly)
1624 }
1625 (KeyModifiers::NONE, KeyCode::PageDown) => {
1626 cursor_move!(ta, CursorMove::ParagraphForward, shift);
1627 Some(ShortcutOutcome::CursorOnly)
1628 }
1629 (KeyModifiers::CONTROL, KeyCode::Left) => {
1631 cursor_move!(ta, CursorMove::WordBack, shift);
1632 Some(ShortcutOutcome::CursorOnly)
1633 }
1634 (KeyModifiers::CONTROL, KeyCode::Right) => {
1635 cursor_move!(ta, CursorMove::WordForward, shift);
1636 Some(ShortcutOutcome::CursorOnly)
1637 }
1638 (KeyModifiers::CONTROL, KeyCode::Home) => {
1640 cursor_move!(ta, CursorMove::Top, shift);
1641 Some(ShortcutOutcome::CursorOnly)
1642 }
1643 (KeyModifiers::CONTROL, KeyCode::End) => {
1644 cursor_move!(ta, CursorMove::Bottom, shift);
1645 Some(ShortcutOutcome::CursorOnly)
1646 }
1647 (KeyModifiers::CONTROL, KeyCode::Char('z')) => {
1651 if ta.undo() {
1652 Some(ShortcutOutcome::TextMutated)
1653 } else {
1654 Some(ShortcutOutcome::NoOp)
1655 }
1656 }
1657 (KeyModifiers::CONTROL, KeyCode::Char('y'))
1658 | (KeyModifiers::CONTROL, KeyCode::Char('Z')) => {
1659 if ta.redo() {
1660 Some(ShortcutOutcome::TextMutated)
1661 } else {
1662 Some(ShortcutOutcome::NoOp)
1663 }
1664 }
1665 (KeyModifiers::CONTROL, KeyCode::Char('a')) => {
1667 ta.move_cursor(CursorMove::Top);
1668 ta.start_selection();
1669 ta.move_cursor(CursorMove::Bottom);
1670 Some(ShortcutOutcome::CursorOnly)
1671 }
1672 (KeyModifiers::CONTROL, KeyCode::Backspace)
1675 | (KeyModifiers::ALT, KeyCode::Backspace) => {
1676 if ta.delete_word() {
1677 Some(ShortcutOutcome::TextMutated)
1678 } else {
1679 Some(ShortcutOutcome::NoOp)
1680 }
1681 }
1682 (KeyModifiers::CONTROL, KeyCode::Delete) | (KeyModifiers::ALT, KeyCode::Delete) => {
1683 if ta.delete_next_word() {
1684 Some(ShortcutOutcome::TextMutated)
1685 } else {
1686 Some(ShortcutOutcome::NoOp)
1687 }
1688 }
1689 _ => None,
1690 };
1691 if let Some(kind) = outcome {
1692 self.selection = ta.selection_range();
1693 match kind {
1694 ShortcutOutcome::NoOp => {}
1695 ShortcutOutcome::CursorOnly => self.bump_cursor(),
1696 ShortcutOutcome::TextMutated => self.bump_content(),
1697 }
1698 return EventState::Consumed;
1699 }
1700
1701 match (key.modifiers, key.code) {
1703 (m, KeyCode::Tab)
1704 if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
1705 {
1706 self.indent_lines(m.contains(KeyModifiers::SHIFT));
1707 return EventState::Consumed;
1708 }
1709 (_, KeyCode::BackTab) => {
1710 self.indent_lines(true);
1711 return EventState::Consumed;
1712 }
1713 _ => {}
1714 }
1715 if key.code == KeyCode::Enter && key.modifiers.is_empty() && self.smart_enter() {
1716 return EventState::Consumed;
1717 }
1718
1719 if let KeyCode::Char(c) = key.code
1726 && (key.modifiers & !KeyModifiers::SHIFT).is_empty()
1727 && let Some((open, close)) = surround_pair(c)
1728 && self.wrap_selection(open, close)
1729 {
1730 return EventState::Consumed;
1731 }
1732
1733 let Some(ta) = self.backend.as_textarea_mut() else {
1734 unreachable!("handle_textarea_key called with non-Textarea backend")
1735 };
1736 let mutated = ta.input_without_shortcuts(*key);
1742 self.selection = ta.selection_range();
1743 if mutated {
1744 self.bump_content();
1745 } else {
1746 self.bump_cursor();
1747 }
1748 EventState::Consumed
1749 }
1750
1751 fn handle_mouse(&mut self, mouse: &ratatui::crossterm::event::MouseEvent) -> EventState {
1753 let r = &self.rect;
1754 let in_bounds = mouse.column >= r.x
1755 && mouse.column < r.x + r.width
1756 && mouse.row >= r.y
1757 && mouse.row < r.y + r.height;
1758 if !in_bounds {
1759 return EventState::NotConsumed;
1760 }
1761 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right))
1765 && self.selection.is_none_or(|(start, end)| start == end)
1766 {
1767 self.wants_context_menu = true;
1768 return EventState::Consumed;
1769 }
1770 if !self.backend.is_textarea() {
1774 return EventState::NotConsumed;
1775 }
1776 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
1778 self.copy_selection_to_clipboard();
1779 self.selection = if let Some(ta) = self.backend.as_textarea() {
1780 ta.selection_range()
1781 } else {
1782 None
1783 };
1784 self.bump_cursor();
1785 return EventState::Consumed;
1786 }
1787 let Some(ta) = self.backend.as_textarea_mut() else {
1789 unreachable!()
1790 };
1791 match mouse.kind {
1792 MouseEventKind::Down(_) => {
1793 ta.cancel_selection();
1794 let (lrow, lcol) = self
1795 .view
1796 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1797 ta.move_cursor(CursorMove::Jump(lrow, lcol));
1798 ta.start_selection();
1799 }
1800 MouseEventKind::Drag(_) => {
1801 let (lrow, lcol) = self
1802 .view
1803 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1804 ta.move_cursor(CursorMove::Jump(lrow, lcol));
1805 }
1806 _ => {
1807 ta.input(*mouse);
1808 }
1809 }
1810 self.selection = ta.selection_range();
1811 self.bump_cursor();
1814 EventState::Consumed
1815 }
1816}
1817
1818fn paint_viewport_extras(
1823 buf: &mut ratatui::buffer::Buffer,
1824 area: Rect,
1825 needles: &[String],
1826 theme: &Theme,
1827) {
1828 use ratatui::layout::Position;
1829 let match_fg = theme.color_search_match.to_ratatui();
1830 let checkbox_fg = theme.accent.to_ratatui();
1831
1832 for y in area.y..area.bottom() {
1833 if needles.is_empty() {
1838 let mut lead = String::new();
1839 for x in area.x..area.right().min(area.x + 16) {
1840 if let Some(cell) = buf.cell(Position::new(x, y)) {
1841 lead.push_str(cell.symbol());
1842 }
1843 }
1844 if !lead.trim_start().starts_with("- [") {
1845 continue;
1846 }
1847 }
1848 let mut row_text = String::new();
1851 let mut byte_to_col: Vec<(usize, u16)> = Vec::new();
1852 for x in area.x..area.right() {
1853 let Some(cell) = buf.cell(Position::new(x, y)) else {
1854 continue;
1855 };
1856 let sym = cell.symbol();
1857 if sym.is_empty() {
1858 continue;
1859 }
1860 byte_to_col.push((row_text.len(), x));
1861 row_text.push_str(sym);
1862 }
1863 if row_text.trim().is_empty() {
1864 continue;
1865 }
1866 let lower = row_text.to_lowercase();
1867 let fold_safe = lower.len() == row_text.len();
1868
1869 let mut restyle =
1870 |from_byte: usize, to_byte: usize, f: &mut dyn FnMut(&mut ratatui::buffer::Cell)| {
1871 for (b, x) in &byte_to_col {
1872 if *b >= from_byte
1873 && *b < to_byte
1874 && let Some(cell) = buf.cell_mut(Position::new(*x, y))
1875 {
1876 f(cell);
1877 }
1878 }
1879 };
1880
1881 let trimmed_start = row_text.len() - row_text.trim_start().len();
1883 let after_indent = &row_text[trimmed_start..];
1884 let is_done = after_indent.starts_with("- [x] ") || after_indent.starts_with("- [X] ");
1885 let is_open = after_indent.starts_with("- [ ] ");
1886 if is_done || is_open {
1887 let box_start = trimmed_start + 2;
1888 let box_end = box_start + 3;
1889 restyle(box_start, box_end, &mut |cell| {
1890 cell.set_fg(checkbox_fg);
1891 });
1892 if is_done {
1893 restyle(box_end, row_text.len(), &mut |cell| {
1894 let style = cell
1895 .style()
1896 .add_modifier(Modifier::DIM | Modifier::CROSSED_OUT);
1897 cell.set_style(style);
1898 });
1899 }
1900 }
1901
1902 if fold_safe {
1904 for needle in needles {
1905 for (start, m) in lower.match_indices(needle.as_str()) {
1906 restyle(start, start + m.len(), &mut |cell| {
1907 let style = cell.style().fg(match_fg).add_modifier(Modifier::BOLD);
1908 cell.set_style(style);
1909 });
1910 }
1911 }
1912 }
1913 }
1914}
1915
1916impl Component for TextEditorComponent {
1917 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
1918 self.maybe_recover_from_dead_nvim();
1919 self.bind_autocomplete_redraw(tx);
1920
1921 match event {
1922 InputEvent::Key(key) => {
1923 let popup_open = self.autocomplete.as_ref().is_some_and(|c| c.is_open());
1931 if popup_open
1932 && let Some(host) = build_editor_host_snapshot(
1933 &self.backend,
1934 self.content_revision,
1935 self.view.last_cursor_screen,
1936 )
1937 && let Some(controller) = self.autocomplete.as_mut()
1938 {
1939 match controller.handle_key(*key, &host) {
1940 HandleKeyOutcome::Accepted(action) => {
1941 if let Some(ta) = self.backend.as_textarea_mut() {
1942 apply_accept_to_textarea(ta, &action);
1943 self.selection = ta.selection_range();
1944 }
1945 self.bump_content();
1946 return EventState::Consumed;
1947 }
1948 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
1949 return EventState::Consumed;
1950 }
1951 HandleKeyOutcome::NotHandled => {}
1952 }
1953 }
1954 if self.search.is_some() && self.handle_search_key(key) {
1959 return EventState::Consumed;
1960 }
1961 if let Some(outcome) = self.backend.vim_handle_key(key) {
1966 use self::vim::VimKeyOutcome;
1967 match outcome {
1968 VimKeyOutcome::TextMutated => {
1969 self.selection = None;
1970 self.bump_content();
1971 return EventState::Consumed;
1972 }
1973 VimKeyOutcome::CursorOnly => {
1974 self.selection = self
1979 .backend
1980 .as_textarea()
1981 .and_then(|ta| ta.selection_range());
1982 if self.backend.vim_is_charwise_visual()
1987 && let Some(((sr, sc), (er, ec))) = self.selection
1988 {
1989 let len = self
1990 .backend
1991 .as_textarea()
1992 .and_then(|ta| ta.lines().get(er))
1993 .map(|l| l.chars().count())
1994 .unwrap_or(ec);
1995 self.selection = Some(((sr, sc), (er, (ec + 1).min(len))));
1996 }
1997 self.refresh_autocomplete_if_open();
1998 self.edit_generation = self.edit_generation.wrapping_add(1);
1999 return EventState::Consumed;
2000 }
2001 VimKeyOutcome::NoOp => return EventState::Consumed,
2002 VimKeyOutcome::PassThrough => { }
2003 VimKeyOutcome::Host(action) => {
2004 use self::vim::VimHostAction;
2005 match action {
2006 VimHostAction::OpenPalette => {
2007 tx.send(AppEvent::ExecuteLeaderAction(
2009 crate::keys::leader::LeaderAction::Palette,
2010 ))
2011 .ok();
2012 }
2013 VimHostAction::OpenSearch { forward: _ } => {
2014 self.open_or_advance_search();
2018 }
2019 VimHostAction::SearchNext => self.vim_search_repeat(false),
2020 VimHostAction::SearchPrev => self.vim_search_repeat(true),
2021 }
2022 return EventState::Consumed;
2023 }
2024 }
2025 }
2026 if let Some(state) = self.handle_nvim_key(key, tx) {
2027 return state;
2028 }
2029 let text_rev_before = self.content_revision;
2040 let cursor_before = self.textarea_cursor();
2041 let result = self.handle_textarea_key(key, tx);
2042 let cursor_after = self.textarea_cursor();
2043 if self.content_revision != text_rev_before {
2044 self.sync_autocomplete();
2045 } else if cursor_before != cursor_after {
2046 self.refresh_autocomplete_if_open();
2047 }
2048 result
2049 }
2050 InputEvent::Mouse(mouse) => {
2051 let text_rev_before = self.content_revision;
2052 let cursor_before = self.textarea_cursor();
2053 let result = self.handle_mouse(mouse);
2054 let cursor_after = self.textarea_cursor();
2055 if self.content_revision != text_rev_before {
2058 self.sync_autocomplete();
2059 } else if cursor_before != cursor_after {
2060 self.refresh_autocomplete_if_open();
2061 }
2062 if result == EventState::Consumed
2067 && matches!(
2068 mouse.kind,
2069 ratatui::crossterm::event::MouseEventKind::Down(
2070 ratatui::crossterm::event::MouseButton::Left
2071 )
2072 )
2073 {
2074 match self.link_at_cursor() {
2075 Some(LinkTarget::Note(target)) => {
2076 tx.send(AppEvent::FollowLink(target)).ok();
2077 }
2078 Some(LinkTarget::Label(name)) => {
2079 tx.send(AppEvent::FollowLabel(name)).ok();
2080 }
2081 None => {}
2082 }
2083 }
2084 let has_sel = self
2095 .backend
2096 .as_textarea()
2097 .and_then(|ta| ta.selection_range())
2098 .is_some_and(|(s, e)| s != e);
2099 self.backend.vim_sync_mouse_selection(has_sel);
2100 result
2101 }
2102 InputEvent::Paste(_) => EventState::NotConsumed,
2105 }
2106 }
2107
2108 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
2109 let (editor_rect, search_rect) = if self.search.is_some() && rect.height > 1 {
2111 (
2112 Rect {
2113 height: rect.height - 1,
2114 ..rect
2115 },
2116 Some(Rect {
2117 y: rect.y + rect.height - 1,
2118 height: 1,
2119 ..rect
2120 }),
2121 )
2122 } else {
2123 (rect, None)
2124 };
2125 self.rect = editor_rect;
2128 let (selection, nvim_rev_to_mirror) = match &self.backend {
2133 BackendState::Textarea(_) => (self.selection, None),
2134 BackendState::Nvim(nvim) => {
2135 let fs = self
2136 .nvim_host
2137 .frame_sync(nvim, editor_rect.width, editor_rect.height);
2138 (fs.selection, fs.rev)
2139 }
2140 };
2141 if let Some(rev) = nvim_rev_to_mirror {
2142 self.content_revision = rev;
2143 }
2144 while let Ok((generation, buf)) = self.full_parse_rx.try_recv() {
2150 self.view.install_full_parse(generation, buf);
2151 }
2152
2153 let snap = snapshot_from_backend(&self.backend, self.content_revision);
2158 self.view.update(&snap, editor_rect, selection);
2159
2160 if let Some(generation) = self.view.take_pending_full_parse() {
2168 let lines: Vec<String> = snap.lines.iter().cloned().collect();
2169 let tx = self.full_parse_tx.clone();
2170 let redraw = self.redraw_tx.clone();
2171 self.full_parse_task.spawn(async move {
2172 let buf = ParsedBuffer::parse(&lines);
2173 let _ = tx.send((generation, buf));
2174 if let Some(redraw) = redraw {
2177 let _ = redraw.send(AppEvent::Redraw);
2178 }
2179 });
2180 }
2181 let bar_focused = self.search.is_some() && focused;
2184 let editor_focused = focused && !bar_focused;
2185 use self::view::CursorShape;
2186 let cursor_shape = match self.backend.modal_is_insert() {
2187 None => None, Some(true) => Some(CursorShape::Bar),
2189 Some(false) => Some(CursorShape::Block),
2190 };
2191 self.view
2192 .render(f, editor_rect, theme, editor_focused, cursor_shape);
2193
2194 if self
2198 .needles_revision
2199 .is_some_and(|r| r != self.content_revision)
2200 {
2201 self.search_needles.clear();
2202 self.needles_revision = None;
2203 }
2204 let mut emphasis_needles = self.search_needles.clone();
2205 if let Some(state) = &self.search {
2206 let q = state.input.value().trim().to_lowercase();
2207 if !q.is_empty() {
2208 emphasis_needles.push(q);
2209 }
2210 }
2211 paint_viewport_extras(f.buffer_mut(), editor_rect, &emphasis_needles, theme);
2212
2213 if snap.lines.iter().all(|l| l.is_empty()) && editor_rect.height > 0 {
2217 let leader = self
2218 .key_bindings
2219 .first_combo_for(&crate::keys::action_shortcuts::ActionShortcuts::Leader)
2220 .unwrap_or_else(|| "leader".to_string());
2221 f.render_widget(
2222 ratatui::widgets::Paragraph::new(format!(
2223 "Type to start · [[ to link · # to tag · {leader} for commands"
2224 ))
2225 .style(
2226 Style::default()
2227 .fg(theme.gray.to_ratatui())
2228 .add_modifier(Modifier::ITALIC),
2229 ),
2230 Rect {
2231 x: editor_rect.x.saturating_add(2),
2232 width: editor_rect.width.saturating_sub(2),
2233 height: 1,
2234 ..editor_rect
2235 },
2236 );
2237 }
2238 if let (Some(state), Some(bar_rect)) = (self.search.as_mut(), search_rect) {
2239 render_search_bar(f, bar_rect, state, theme, bar_focused);
2240 }
2241
2242 self.poll_autocomplete();
2250 if let (Some(controller), Some(live_anchor)) =
2257 (self.autocomplete.as_mut(), self.view.last_cursor_screen)
2258 {
2259 if let Some(state) = controller.state_mut() {
2260 state.anchor = live_anchor;
2261 }
2262 if let Some(state) = controller.state() {
2263 autocomplete::render(f, state, editor_rect, theme);
2264 }
2265 }
2266 }
2267
2268 fn hint_shortcuts(&self) -> Vec<(String, String)> {
2269 use crate::keys::action_shortcuts::ActionShortcuts;
2270
2271 if let Some(mut label) = self.backend.mode_label() {
2276 if let Some(p) = self.backend.vim_pending_hint() {
2277 label = format!("{label} {p}");
2278 }
2279 let mut hints = vec![(String::new(), label)];
2280 hints.extend(
2281 [
2282 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2283 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2284 (ActionShortcuts::FileOperations, "file ops"),
2285 ]
2286 .iter()
2287 .filter_map(|(action, label)| {
2288 self.key_bindings
2289 .first_combo_for(action)
2290 .map(|k| (k, label.to_string()))
2291 }),
2292 );
2293 return hints;
2294 }
2295
2296 let mut hints: Vec<(String, String)> = Vec::new();
2299 match self.link_at_cursor() {
2300 Some(LinkTarget::Note(_)) => {
2301 if let Some(k) = self
2302 .key_bindings
2303 .first_combo_for(&ActionShortcuts::FollowLink)
2304 {
2305 hints.push((k, "follow link".to_string()));
2306 }
2307 }
2308 Some(LinkTarget::Label(_)) => {
2309 if let Some(k) = self
2310 .key_bindings
2311 .first_combo_for(&ActionShortcuts::FollowLink)
2312 {
2313 hints.push((k, "browse tag".to_string()));
2314 }
2315 }
2316 None => {}
2317 }
2318 hints.extend(crate::components::hints::hints_for(
2319 &self.key_bindings,
2320 &[
2321 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2322 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2323 (ActionShortcuts::FileOperations, "file ops"),
2324 (ActionShortcuts::FindInBuffer, "find"),
2325 ],
2326 ));
2327 hints
2328 }
2329}
2330
2331#[cfg(test)]
2332mod tests {
2333 use super::snapshot::EditorMode;
2334 use super::*;
2335 use crate::keys::KeyBindings;
2336
2337 fn make_editor() -> TextEditorComponent {
2338 TextEditorComponent::new(
2339 KeyBindings::empty(),
2340 &crate::settings::AppSettings::default(),
2341 )
2342 }
2343
2344 fn dummy_tx() -> AppTx {
2345 tokio::sync::mpsc::unbounded_channel().0
2346 }
2347
2348 fn get_ta(editor: &mut TextEditorComponent) -> &mut TextArea<'static> {
2349 match &mut editor.backend {
2350 BackendState::Textarea(tb) => &mut tb.ta,
2351 _ => panic!("expected Textarea backend"),
2352 }
2353 }
2354
2355 #[test]
2356 fn has_trigger_before_cursor_finds_bracket() {
2357 assert!(has_trigger_before_cursor("hello [[foo", 11));
2358 assert!(has_trigger_before_cursor("[[a b c", 7));
2359 }
2360
2361 #[test]
2362 fn has_trigger_before_cursor_finds_hashtag() {
2363 assert!(has_trigger_before_cursor("text #tag", 9));
2364 }
2365
2366 #[test]
2367 fn has_trigger_before_cursor_no_trigger_bails() {
2368 assert!(!has_trigger_before_cursor("plain prose here", 16));
2369 assert!(!has_trigger_before_cursor("", 0));
2370 }
2371
2372 #[test]
2373 fn has_trigger_before_cursor_handles_multibyte_no_panic() {
2374 let line = "你好世界".to_string() + &"a".repeat(80);
2377 let col = line.chars().count();
2378 assert!(!has_trigger_before_cursor(&line, col));
2379
2380 let with_emoji = "🦀".repeat(20) + "[[note";
2381 let col = with_emoji.chars().count();
2382 assert!(has_trigger_before_cursor(&with_emoji, col));
2383
2384 let accented = "é".repeat(100);
2385 let col = accented.chars().count();
2386 assert!(!has_trigger_before_cursor(&accented, col));
2387 }
2388
2389 #[test]
2390 fn has_trigger_before_cursor_ignores_chars_after_cursor() {
2391 assert!(!has_trigger_before_cursor("foo [[bar", 3));
2393 }
2394
2395 #[test]
2396 fn has_trigger_before_cursor_wikilink_with_spaces() {
2397 assert!(has_trigger_before_cursor("[[my note title", 15));
2400 }
2401
2402 #[test]
2403 fn fresh_editor_is_not_dirty() {
2404 let editor = make_editor();
2405 assert!(!editor.is_dirty());
2406 }
2407
2408 #[test]
2409 fn after_set_text_not_dirty() {
2410 let mut editor = make_editor();
2411 editor.set_text("hello world".to_string());
2412 assert!(!editor.is_dirty());
2413 }
2414
2415 #[test]
2416 fn get_text_returns_loaded_content() {
2417 let mut editor = make_editor();
2418 editor.set_text("line one\nline two".to_string());
2419 assert_eq!(editor.get_text(), "line one\nline two");
2420 }
2421
2422 #[test]
2423 fn mark_saved_clears_dirty() {
2424 let mut editor = make_editor();
2425 editor.set_text("initial".to_string());
2426 let text = editor.get_text();
2427 editor.mark_saved(text.clone() + "x"); assert!(editor.is_dirty());
2429 editor.mark_saved(text); assert!(!editor.is_dirty());
2431 }
2432
2433 #[test]
2434 fn trailing_newline_does_not_cause_false_dirty() {
2435 let mut editor = make_editor();
2436 editor.set_text("content\n".to_string());
2437 assert!(
2438 !editor.is_dirty(),
2439 "trailing newline should not make editor dirty after load"
2440 );
2441 }
2442
2443 #[test]
2444 fn cursor_move_does_not_dirty_buffer() {
2445 let mut editor = make_editor();
2446 editor.set_text("hello world".to_string());
2447 assert!(!editor.is_dirty());
2448 let tx = dummy_tx();
2449 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
2453 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2454 assert!(
2455 !editor.is_dirty(),
2456 "cursor move must not mark the editor as dirty"
2457 );
2458 }
2459
2460 #[test]
2461 fn empty_stack_undo_redo_does_not_dirty_or_bump_revision() {
2462 let mut editor = make_editor();
2466 editor.set_text("foo".to_string());
2467 let rev_before = editor.content_revision();
2468 assert!(!editor.is_dirty());
2469 let tx = dummy_tx();
2470 for key_code in [KeyCode::Char('z'), KeyCode::Char('y')] {
2471 let key = ratatui::crossterm::event::KeyEvent::new(key_code, KeyModifiers::CONTROL);
2472 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2473 }
2474 assert!(
2475 !editor.is_dirty(),
2476 "empty-stack undo/redo must not flip is_dirty"
2477 );
2478 assert_eq!(
2479 editor.content_revision(),
2480 rev_before,
2481 "empty-stack undo/redo must not bump content_revision"
2482 );
2483 }
2484
2485 #[test]
2486 fn fresh_editor_content_revision_is_nonzero() {
2487 let editor = make_editor();
2494 assert!(editor.content_revision().get() >= 1);
2495 }
2496
2497 #[test]
2498 fn mouse_down_clears_selection() {
2499 let mut editor = make_editor();
2500 editor.set_text("hello world".to_string());
2501 let ta = get_ta(&mut editor);
2502 ta.start_selection();
2503 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2504 assert!(ta.selection_range().is_some());
2505 ta.cancel_selection();
2506 editor.selection = if let BackendState::Textarea(tb) = &editor.backend {
2507 tb.ta.selection_range()
2508 } else {
2509 None
2510 };
2511 assert!(editor.selection.is_none());
2512 }
2513
2514 #[test]
2515 fn ctrl_c_copies_selected_text() {
2516 let mut editor = make_editor();
2517 editor.set_text("hello world".to_string());
2518 let ta = get_ta(&mut editor);
2519 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2520 ta.start_selection();
2521 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2522 let range = ta.selection_range().unwrap();
2523 let ((sr, sc), (er, ec)) = range;
2524 let lines = ta.lines();
2525 let selected = if sr == er {
2526 lines[sr][sc..ec].to_string()
2527 } else {
2528 lines[sr][sc..].to_string()
2529 };
2530 assert_eq!(selected, "hello ");
2531 }
2532
2533 fn select_range(editor: &mut TextEditorComponent, start: (u16, u16), end: (u16, u16)) {
2535 let ta = get_ta(editor);
2536 ta.cancel_selection();
2537 ta.move_cursor(CursorMove::Jump(start.0, start.1));
2538 ta.start_selection();
2539 ta.move_cursor(CursorMove::Jump(end.0, end.1));
2540 assert!(ta.selection_range().is_some());
2541 }
2542
2543 fn send_char(editor: &mut TextEditorComponent, c: char) {
2544 let tx = dummy_tx();
2545 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
2546 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2547 }
2548
2549 #[test]
2550 fn surround_pair_maps_open_and_symmetric_chars() {
2551 assert_eq!(surround_pair('('), Some(("(", ")")));
2552 assert_eq!(surround_pair('['), Some(("[", "]")));
2553 assert_eq!(surround_pair('{'), Some(("{", "}")));
2554 assert_eq!(surround_pair('<'), Some(("<", ">")));
2555 assert_eq!(surround_pair('"'), Some(("\"", "\"")));
2556 assert_eq!(surround_pair('\''), Some(("'", "'")));
2557 assert_eq!(surround_pair('`'), Some(("`", "`")));
2558 assert_eq!(surround_pair('*'), Some(("*", "*")));
2559 assert_eq!(surround_pair('_'), Some(("_", "_")));
2560 assert_eq!(surround_pair('~'), Some(("~", "~")));
2561 assert_eq!(surround_pair(')'), None);
2563 assert_eq!(surround_pair(']'), None);
2564 assert_eq!(surround_pair('}'), None);
2565 assert_eq!(surround_pair('>'), None);
2566 assert_eq!(surround_pair('a'), None);
2567 }
2568
2569 #[test]
2570 fn typing_open_paren_with_selection_wraps_it() {
2571 let mut editor = make_editor();
2572 editor.set_text("hello world".to_string());
2573 select_range(&mut editor, (0, 0), (0, 5)); send_char(&mut editor, '(');
2575 assert_eq!(editor.get_text(), "(hello) world");
2576 assert!(editor.is_dirty(), "wrap must mark the buffer dirty");
2577 }
2578
2579 #[test]
2580 fn wrap_keeps_selection_on_inner_text() {
2581 let mut editor = make_editor();
2582 editor.set_text("hello world".to_string());
2583 select_range(&mut editor, (0, 0), (0, 5));
2584 send_char(&mut editor, '(');
2585 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2587 }
2588
2589 #[test]
2590 fn chained_brackets_build_a_wikilink() {
2591 let mut editor = make_editor();
2592 editor.set_text("my note".to_string());
2593 select_range(&mut editor, (0, 0), (0, 7));
2594 send_char(&mut editor, '[');
2595 send_char(&mut editor, '[');
2596 assert_eq!(editor.get_text(), "[[my note]]");
2597 assert_eq!(editor.selection, Some(((0, 2), (0, 9))));
2598 }
2599
2600 #[test]
2601 fn symmetric_chars_wrap_and_chain() {
2602 let mut editor = make_editor();
2603 editor.set_text("bold".to_string());
2604 select_range(&mut editor, (0, 0), (0, 4));
2605 send_char(&mut editor, '*');
2606 assert_eq!(editor.get_text(), "*bold*");
2607 send_char(&mut editor, '*');
2608 assert_eq!(editor.get_text(), "**bold**");
2609 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2610 }
2611
2612 #[test]
2613 fn closing_char_replaces_selection() {
2614 let mut editor = make_editor();
2615 editor.set_text("hello world".to_string());
2616 select_range(&mut editor, (0, 0), (0, 5));
2617 send_char(&mut editor, ')');
2618 assert_eq!(editor.get_text(), ") world");
2619 }
2620
2621 #[test]
2622 fn open_char_without_selection_inserts_normally() {
2623 let mut editor = make_editor();
2624 editor.set_text("hello".to_string());
2625 let ta = get_ta(&mut editor);
2626 ta.move_cursor(CursorMove::End);
2627 send_char(&mut editor, '(');
2628 assert_eq!(editor.get_text(), "hello(");
2629 }
2630
2631 #[test]
2632 fn wrap_spans_multiline_selection() {
2633 let mut editor = make_editor();
2634 editor.set_text("abc\ndef".to_string());
2635 select_range(&mut editor, (0, 0), (1, 3));
2636 send_char(&mut editor, '(');
2637 assert_eq!(editor.get_text(), "(abc\ndef)");
2638 assert_eq!(editor.selection, Some(((0, 1), (1, 3))));
2640 }
2641
2642 #[test]
2643 fn wrap_handles_multibyte_selection() {
2644 let mut editor = make_editor();
2645 editor.set_text("héllo🦀 x".to_string());
2646 select_range(&mut editor, (0, 0), (0, 6)); send_char(&mut editor, '`');
2648 assert_eq!(editor.get_text(), "`héllo🦀` x");
2649 assert_eq!(editor.selection, Some(((0, 1), (0, 7))));
2650 }
2651
2652 #[test]
2653 fn wrap_with_reversed_selection_direction() {
2654 let mut editor = make_editor();
2656 editor.set_text("hello world".to_string());
2657 select_range(&mut editor, (0, 5), (0, 0));
2658 send_char(&mut editor, '(');
2659 assert_eq!(editor.get_text(), "(hello) world");
2660 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2661 }
2662
2663 #[test]
2664 fn text_action_keeps_selection_on_inner_text() {
2665 let mut editor = make_editor();
2668 editor.set_text("bold word".to_string());
2669 select_range(&mut editor, (0, 0), (0, 4));
2670 editor.apply_text_action(TextAction::Bold);
2671 assert_eq!(editor.get_text(), "**bold** word");
2672 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2673 }
2674
2675 #[test]
2676 fn wrap_undo_is_two_steps_back_to_original() {
2677 let mut editor = make_editor();
2681 editor.set_text("hello world".to_string());
2682 select_range(&mut editor, (0, 0), (0, 5));
2683 send_char(&mut editor, '(');
2684 assert_eq!(editor.get_text(), "(hello) world");
2685 let ta = get_ta(&mut editor);
2686 ta.undo();
2687 ta.undo();
2688 assert_eq!(editor.get_text(), "hello world");
2689 }
2690
2691 #[test]
2692 fn linkable_url_accepts_supported_schemes() {
2693 assert_eq!(
2694 linkable_url("https://example.com"),
2695 Some("https://example.com")
2696 );
2697 assert_eq!(
2698 linkable_url("http://example.com/path?q=1#frag"),
2699 Some("http://example.com/path?q=1#frag"),
2700 );
2701 assert_eq!(
2702 linkable_url(" https://example.com "),
2703 Some("https://example.com")
2704 );
2705 assert_eq!(
2706 linkable_url("ftp://files.example.com/x"),
2707 Some("ftp://files.example.com/x"),
2708 );
2709 assert_eq!(
2710 linkable_url("ftps://files.example.com/x"),
2711 Some("ftps://files.example.com/x"),
2712 );
2713 assert_eq!(
2714 linkable_url("mailto:user@example.com"),
2715 Some("mailto:user@example.com"),
2716 );
2717 assert_eq!(
2718 linkable_url("mailto:user@example.com?subject=hi"),
2719 Some("mailto:user@example.com?subject=hi"),
2720 );
2721 }
2722
2723 #[test]
2724 fn linkable_url_rejects_other_schemes_and_plain_text() {
2725 assert_eq!(linkable_url("file:///etc/passwd"), None);
2726 assert_eq!(linkable_url("ssh://host"), None);
2727 assert_eq!(linkable_url("javascript:alert(1)"), None);
2728 assert_eq!(linkable_url("example.com"), None);
2729 assert_eq!(linkable_url("not a url"), None);
2730 assert_eq!(linkable_url(""), None);
2731 assert_eq!(linkable_url("https://example.com\nmore"), None);
2732 }
2733
2734 #[test]
2735 fn try_build_markdown_link_wraps_selection_when_clip_is_url() {
2736 assert_eq!(
2737 try_build_markdown_link("https://example.com", Some("click here")).as_deref(),
2738 Some("[click here](https://example.com)"),
2739 );
2740 }
2741
2742 #[test]
2743 fn try_build_markdown_link_trims_url_whitespace() {
2744 assert_eq!(
2745 try_build_markdown_link(" https://example.com\n", Some("link")).as_deref(),
2746 Some("[link](https://example.com)"),
2747 );
2748 }
2749
2750 #[test]
2751 fn try_build_markdown_link_returns_none_when_no_selection() {
2752 assert_eq!(try_build_markdown_link("https://example.com", None), None);
2753 }
2754
2755 #[test]
2756 fn try_build_markdown_link_returns_none_when_not_url() {
2757 assert_eq!(try_build_markdown_link("plain text", Some("sel")), None);
2758 }
2759
2760 #[test]
2761 fn try_build_markdown_link_returns_none_when_selection_empty() {
2762 assert_eq!(
2763 try_build_markdown_link("https://example.com", Some("")),
2764 None
2765 );
2766 }
2767
2768 #[test]
2769 fn try_build_markdown_link_escapes_close_bracket_in_selection() {
2770 assert_eq!(
2771 try_build_markdown_link("https://example.com", Some("a]b")).as_deref(),
2772 Some(r"[a\]b](https://example.com)"),
2773 );
2774 }
2775
2776 #[test]
2777 fn try_build_markdown_link_wraps_ftp_url() {
2778 assert_eq!(
2779 try_build_markdown_link("ftp://files.example.com/x", Some("download")).as_deref(),
2780 Some("[download](ftp://files.example.com/x)"),
2781 );
2782 }
2783
2784 fn key(code: KeyCode, mods: KeyModifiers) -> ratatui::crossterm::event::KeyEvent {
2785 ratatui::crossterm::event::KeyEvent::new(code, mods)
2786 }
2787
2788 #[test]
2790 fn paint_viewport_extras_emphasizes_needles_and_tasks() {
2791 use ratatui::buffer::Buffer;
2792 use ratatui::layout::Position;
2793 let theme = crate::settings::themes::Theme::default();
2794 let area = Rect::new(0, 0, 30, 3);
2795 let mut buf = Buffer::empty(area);
2796 buf.set_string(0, 0, "find the needle here", Style::default());
2797 buf.set_string(0, 1, "- [x] done task", Style::default());
2798 buf.set_string(0, 2, "- [ ] open task", Style::default());
2799
2800 paint_viewport_extras(&mut buf, area, &["needle".to_string()], &theme);
2801
2802 let cell = buf.cell(Position::new(9, 0)).unwrap();
2804 assert_eq!(cell.fg, theme.color_search_match.to_ratatui());
2805 assert!(cell.style().add_modifier.contains(Modifier::BOLD));
2806 let cell = buf.cell(Position::new(8, 1)).unwrap();
2808 assert!(cell.style().add_modifier.contains(Modifier::CROSSED_OUT));
2809 let cell = buf.cell(Position::new(8, 2)).unwrap();
2811 assert!(!cell.style().add_modifier.contains(Modifier::CROSSED_OUT));
2812 let cb = buf.cell(Position::new(3, 2)).unwrap();
2813 assert_eq!(cb.fg, theme.accent.to_ratatui());
2814 }
2815
2816 #[test]
2818 fn search_needles_clear_on_edit() {
2819 let settings = crate::settings::AppSettings::default();
2820 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2821 ed.set_text("alpha beta".to_string());
2822 ed.set_search_needles(vec!["Alpha".to_string()]);
2823 assert_eq!(ed.search_needles, vec!["alpha"]);
2824 assert_eq!(ed.needles_revision, Some(ed.content_revision));
2825
2826 ed.set_text("alpha beta gamma".to_string());
2828 assert_ne!(ed.needles_revision, Some(ed.content_revision));
2829 }
2830
2831 #[test]
2832 fn jump_to_heading_moves_cursor_to_heading_line() {
2833 let settings = crate::settings::AppSettings::default();
2834 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2835 ed.set_text("intro\n# Top\nbody\n## Sub One\nmore\n".to_string());
2836
2837 ed.jump_to_heading("Sub One");
2838 assert_eq!(ed.view_snapshot().cursor.0, 3);
2839
2840 ed.jump_to_heading("Top");
2841 assert_eq!(ed.view_snapshot().cursor.0, 1);
2842
2843 ed.jump_to_heading("Nope");
2845 assert_eq!(ed.view_snapshot().cursor.0, 1);
2846 }
2847
2848 #[test]
2849 fn open_or_advance_search_opens_find_bar_with_empty_query() {
2850 let mut editor = make_editor();
2851 editor.set_text("hello world".to_string());
2852 editor.open_or_advance_search();
2853 let state = editor.search.as_ref().expect("find bar opened");
2854 assert!(state.input.is_empty());
2855 assert!(matches!(state.status, SearchStatus::Empty));
2856 }
2857
2858 #[test]
2859 fn open_or_advance_search_advances_when_already_open() {
2860 let mut editor = make_editor();
2861 editor.set_text("ab ab ab".to_string());
2862 let tx = dummy_tx();
2863 editor.open_or_advance_search();
2864 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2865 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2866 editor.open_or_advance_search();
2868 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2869 assert_eq!(col, 3, "second invocation advances to next match");
2870 }
2871
2872 #[test]
2873 fn typing_in_find_bar_jumps_cursor_to_first_match() {
2874 let mut editor = make_editor();
2875 editor.set_text("foo bar baz".to_string());
2876 let tx = dummy_tx();
2877 editor.open_or_advance_search();
2878 for ch in ['b', 'a', 'r'] {
2879 editor.handle_textarea_key(&key(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
2880 }
2881 let state = editor.search.as_ref().unwrap();
2882 assert_eq!(state.input.value(), "bar");
2883 assert!(matches!(state.status, SearchStatus::Match));
2884 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2885 assert_eq!(col, 4, "cursor jumped to start of 'bar'");
2886 }
2887
2888 #[test]
2889 fn enter_in_find_bar_advances_to_next_match() {
2890 let mut editor = make_editor();
2891 editor.set_text("ab ab ab".to_string());
2892 let tx = dummy_tx();
2893 editor.open_or_advance_search();
2894 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2895 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2896 editor.handle_textarea_key(&key(KeyCode::Enter, KeyModifiers::NONE), &tx);
2898 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2899 assert_eq!(col, 3, "Enter advances to second match");
2900 }
2901
2902 #[test]
2903 fn match_is_highlighted_as_selection_after_search() {
2904 let mut editor = make_editor();
2905 editor.set_text("foo bar baz".to_string());
2906 let tx = dummy_tx();
2907 editor.open_or_advance_search();
2908 for ch in ['b', 'a', 'r'] {
2909 editor.handle_textarea_key(&key(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
2910 }
2911 assert_eq!(editor.selection, Some(((0, 4), (0, 7))));
2913 }
2914
2915 #[test]
2916 fn no_match_clears_selection() {
2917 let mut editor = make_editor();
2918 editor.set_text("hello".to_string());
2919 let tx = dummy_tx();
2920 editor.open_or_advance_search();
2921 editor.handle_textarea_key(&key(KeyCode::Char('z'), KeyModifiers::NONE), &tx);
2922 assert_eq!(editor.selection, None);
2923 }
2924
2925 #[test]
2926 fn esc_in_find_bar_clears_selection_highlight() {
2927 let mut editor = make_editor();
2928 editor.set_text("foo bar".to_string());
2929 let tx = dummy_tx();
2930 editor.open_or_advance_search();
2931 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2932 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2933 editor.handle_textarea_key(&key(KeyCode::Char('r'), KeyModifiers::NONE), &tx);
2934 assert!(editor.selection.is_some());
2935 editor.handle_textarea_key(&key(KeyCode::Esc, KeyModifiers::NONE), &tx);
2936 assert!(editor.selection.is_none());
2937 }
2938
2939 #[test]
2940 fn esc_in_find_bar_closes_it() {
2941 let mut editor = make_editor();
2942 editor.set_text("hello".to_string());
2943 let tx = dummy_tx();
2944 editor.open_or_advance_search();
2945 assert!(editor.search.is_some());
2946 editor.handle_textarea_key(&key(KeyCode::Esc, KeyModifiers::NONE), &tx);
2947 assert!(editor.search.is_none());
2948 }
2949
2950 #[test]
2951 fn find_bar_consumes_typing_so_editor_text_is_unchanged() {
2952 let mut editor = make_editor();
2953 editor.set_text("hello".to_string());
2954 let tx = dummy_tx();
2955 editor.open_or_advance_search();
2956 editor.handle_textarea_key(&key(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
2957 assert_eq!(editor.get_text(), "hello");
2958 }
2959
2960 #[test]
2961 fn no_match_status_when_query_absent() {
2962 let mut editor = make_editor();
2963 editor.set_text("hello".to_string());
2964 let tx = dummy_tx();
2965 editor.open_or_advance_search();
2966 editor.handle_textarea_key(&key(KeyCode::Char('z'), KeyModifiers::NONE), &tx);
2967 let state = editor.search.as_ref().unwrap();
2968 assert!(matches!(state.status, SearchStatus::NoMatch));
2969 }
2970
2971 #[test]
2972 fn try_build_markdown_link_wraps_mailto_url() {
2973 assert_eq!(
2974 try_build_markdown_link("mailto:user@example.com", Some("email me")).as_deref(),
2975 Some("[email me](mailto:user@example.com)"),
2976 );
2977 }
2978
2979 #[test]
2980 fn insert_at_cursor_appends_text() {
2981 let mut editor = make_editor();
2982 editor.set_text("hello".to_string());
2983 {
2984 let ta = get_ta(&mut editor);
2985 ta.move_cursor(ratatui_textarea::CursorMove::End);
2986 }
2987 editor.insert_at_cursor(" world", &dummy_tx());
2988 assert_eq!(editor.get_text(), "hello world");
2989 }
2990
2991 #[test]
2992 fn insert_at_cursor_replaces_selection() {
2993 let mut editor = make_editor();
2994 editor.set_text("hello world".to_string());
2995 {
2996 let ta = get_ta(&mut editor);
2997 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2998 ta.start_selection();
2999 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3000 }
3001 editor.insert_at_cursor("HEY ", &dummy_tx());
3002 assert_eq!(editor.get_text(), "HEY world");
3003 }
3004
3005 #[test]
3006 fn paste_inserts_text_at_cursor() {
3007 let mut editor = make_editor();
3008 editor.set_text("hello".to_string());
3009 let ta = get_ta(&mut editor);
3010 ta.move_cursor(ratatui_textarea::CursorMove::End);
3011 ta.insert_str(" world");
3012 assert_eq!(editor.get_text(), "hello world");
3013 }
3014
3015 #[test]
3016 fn bold_action_with_no_selection_inserts_pair_and_centers_cursor() {
3017 let mut editor = make_editor();
3018 editor.set_text("hello".to_string());
3019 {
3020 let ta = get_ta(&mut editor);
3021 ta.move_cursor(ratatui_textarea::CursorMove::End);
3022 }
3023 editor.apply_text_action(TextAction::Bold);
3024 assert_eq!(editor.get_text(), "hello****");
3025 let ta = get_ta(&mut editor);
3026 assert_eq!(ta.cursor(), (0, 7));
3027 }
3028
3029 #[test]
3030 fn italic_action_with_no_selection_inserts_single_pair() {
3031 let mut editor = make_editor();
3032 editor.set_text(String::new());
3033 editor.apply_text_action(TextAction::Italic);
3034 assert_eq!(editor.get_text(), "**");
3035 let ta = get_ta(&mut editor);
3036 assert_eq!(ta.cursor(), (0, 1));
3037 }
3038
3039 #[test]
3040 fn strikethrough_action_with_selection_wraps_text() {
3041 let mut editor = make_editor();
3042 editor.set_text("hello world".to_string());
3043 {
3044 let ta = get_ta(&mut editor);
3045 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3046 ta.start_selection();
3047 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3048 }
3049 editor.apply_text_action(TextAction::Strikethrough);
3050 assert_eq!(editor.get_text(), "~~hello ~~world");
3051 }
3052
3053 #[test]
3054 fn bold_action_wraps_non_ascii_selection() {
3055 let mut editor = make_editor();
3056 editor.set_text("hello 你好 world".to_string());
3057 {
3058 let ta = get_ta(&mut editor);
3059 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3060 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3061 ta.start_selection();
3062 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3063 }
3064 editor.apply_text_action(TextAction::Bold);
3065 assert_eq!(editor.get_text(), "hello **你好 **world");
3066 }
3067
3068 #[test]
3069 fn bold_action_wraps_selected_text() {
3070 let mut editor = make_editor();
3071 editor.set_text("foo bar".to_string());
3072 {
3073 let ta = get_ta(&mut editor);
3074 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3075 ta.start_selection();
3076 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3077 }
3078 editor.apply_text_action(TextAction::Bold);
3079 assert_eq!(editor.get_text(), "**foo **bar");
3080 }
3081
3082 #[test]
3083 fn indent_no_selection_indents_current_line() {
3084 let mut editor = make_editor();
3085 editor.set_text("foo\nbar".to_string());
3086 {
3087 let ta = get_ta(&mut editor);
3088 ta.move_cursor(ratatui_textarea::CursorMove::Bottom);
3089 }
3090 editor.indent_lines(false);
3091 let lines = get_ta(&mut editor).lines();
3092 assert_eq!(lines[0], "foo");
3093 assert!(lines[1].starts_with(' ') || lines[1].starts_with('\t'));
3094 assert!(lines[1].trim_start() == "bar");
3095 }
3096
3097 #[test]
3098 fn indent_midline_selection_keeps_text_before_and_selection() {
3099 let mut editor = make_editor();
3100 editor.set_text("hello world".to_string());
3101 {
3102 let ta = get_ta(&mut editor);
3103 ta.move_cursor(ratatui_textarea::CursorMove::Jump(0, 6));
3104 ta.start_selection();
3105 ta.move_cursor(ratatui_textarea::CursorMove::End);
3106 }
3107 editor.indent_lines(false);
3108 let ta = get_ta(&mut editor);
3109 assert_eq!(ta.lines()[0].trim_start(), "hello world");
3111 let indent = ta.lines()[0].len() - "hello world".len();
3113 assert_eq!(
3114 ta.selection_range(),
3115 Some(((0, 6 + indent), (0, 11 + indent)))
3116 );
3117 }
3118
3119 #[test]
3120 fn indent_with_selection_indents_all_touched_lines() {
3121 let mut editor = make_editor();
3122 editor.set_text("foo\nbar\nbaz".to_string());
3123 {
3124 let ta = get_ta(&mut editor);
3125 ta.move_cursor(ratatui_textarea::CursorMove::Top);
3126 ta.start_selection();
3127 ta.move_cursor(ratatui_textarea::CursorMove::Down);
3128 ta.move_cursor(ratatui_textarea::CursorMove::End);
3129 }
3130 editor.indent_lines(false);
3131 let lines: Vec<String> = get_ta(&mut editor).lines().to_vec();
3132 assert_eq!(lines[0].trim_start(), "foo");
3133 assert_eq!(lines[1].trim_start(), "bar");
3134 assert_eq!(lines[2], "baz");
3135 assert!(lines[0].len() > 3);
3136 assert!(lines[1].len() > 3);
3137 }
3138
3139 #[test]
3140 fn dedent_removes_leading_indent() {
3141 let mut editor = make_editor();
3142 editor.set_text(" foo\n bar\nbaz".to_string());
3143 let tab_len = get_ta(&mut editor).tab_length() as usize;
3144 {
3145 let ta = get_ta(&mut editor);
3146 ta.move_cursor(ratatui_textarea::CursorMove::Top);
3147 ta.start_selection();
3148 ta.move_cursor(ratatui_textarea::CursorMove::Bottom);
3149 ta.move_cursor(ratatui_textarea::CursorMove::End);
3150 }
3151 editor.indent_lines(true);
3152 let lines: Vec<String> = get_ta(&mut editor).lines().to_vec();
3153 assert_eq!(lines[0], format!("{}foo", " ".repeat(4 - tab_len.min(4))));
3155 assert_eq!(
3157 lines[1],
3158 format!("{}bar", " ".repeat(2usize.saturating_sub(tab_len)))
3159 );
3160 assert_eq!(lines[2], "baz");
3161 }
3162
3163 #[test]
3164 fn dedent_no_leading_whitespace_is_noop_for_that_line() {
3165 let mut editor = make_editor();
3166 editor.set_text("foo".to_string());
3167 editor.indent_lines(true);
3168 assert_eq!(editor.get_text(), "foo");
3169 }
3170
3171 #[test]
3172 fn smart_enter_continues_unordered_list() {
3173 let mut editor = make_editor();
3174 editor.set_text("- foo".to_string());
3175 {
3176 let ta = get_ta(&mut editor);
3177 ta.move_cursor(ratatui_textarea::CursorMove::End);
3178 }
3179 assert!(editor.smart_enter());
3180 assert_eq!(editor.get_text(), "- foo\n- ");
3181 }
3182
3183 #[test]
3184 fn smart_enter_continues_ordered_list_increments() {
3185 let mut editor = make_editor();
3186 editor.set_text("1. foo".to_string());
3187 {
3188 let ta = get_ta(&mut editor);
3189 ta.move_cursor(ratatui_textarea::CursorMove::End);
3190 }
3191 assert!(editor.smart_enter());
3192 assert_eq!(editor.get_text(), "1. foo\n2. ");
3193 }
3194
3195 #[test]
3196 fn smart_enter_on_empty_list_marker_clears_line() {
3197 let mut editor = make_editor();
3198 editor.set_text("- ".to_string());
3199 {
3200 let ta = get_ta(&mut editor);
3201 ta.move_cursor(ratatui_textarea::CursorMove::End);
3202 }
3203 assert!(editor.smart_enter());
3204 assert_eq!(editor.get_text(), "");
3205 }
3206
3207 #[test]
3208 fn smart_enter_preserves_indent() {
3209 let mut editor = make_editor();
3210 editor.set_text(" body".to_string());
3211 {
3212 let ta = get_ta(&mut editor);
3213 ta.move_cursor(ratatui_textarea::CursorMove::End);
3214 }
3215 assert!(editor.smart_enter());
3216 assert_eq!(editor.get_text(), " body\n ");
3217 }
3218
3219 #[test]
3220 fn smart_enter_on_empty_indent_dedents() {
3221 let mut editor = make_editor();
3222 editor.set_text(" ".to_string());
3223 {
3224 let ta = get_ta(&mut editor);
3225 ta.move_cursor(ratatui_textarea::CursorMove::End);
3226 }
3227 let tab_len = get_ta(&mut editor).tab_length() as usize;
3228 assert!(editor.smart_enter());
3229 assert_eq!(
3230 editor.get_text(),
3231 " ".repeat(4usize.saturating_sub(tab_len))
3232 );
3233 }
3234
3235 #[test]
3236 fn smart_enter_no_indent_no_marker_returns_false() {
3237 let mut editor = make_editor();
3238 editor.set_text("plain".to_string());
3239 {
3240 let ta = get_ta(&mut editor);
3241 ta.move_cursor(ratatui_textarea::CursorMove::End);
3242 }
3243 assert!(!editor.smart_enter());
3244 assert_eq!(editor.get_text(), "plain");
3245 }
3246
3247 #[test]
3248 fn smart_enter_mid_line_returns_false() {
3249 let mut editor = make_editor();
3250 editor.set_text("- foo".to_string());
3251 {
3252 let ta = get_ta(&mut editor);
3253 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3254 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3255 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3256 }
3257 assert!(!editor.smart_enter());
3258 }
3259
3260 #[test]
3261 fn smart_enter_on_empty_indented_list_marker_dedents_keeping_marker() {
3262 let mut editor = make_editor();
3263 let tab_len = get_ta(&mut editor).tab_length() as usize;
3264 let indent = " ".repeat(tab_len);
3265 editor.set_text(format!("{indent}- "));
3266 {
3267 let ta = get_ta(&mut editor);
3268 ta.move_cursor(ratatui_textarea::CursorMove::End);
3269 }
3270 assert!(editor.smart_enter());
3271 assert_eq!(editor.get_text(), "- ");
3272 }
3273
3274 #[test]
3275 fn smart_enter_on_empty_list_marker_clears_line_after_full_dedent() {
3276 let mut editor = make_editor();
3277 let tab_len = get_ta(&mut editor).tab_length() as usize;
3278 let indent = " ".repeat(tab_len);
3279 editor.set_text(format!("{indent}- "));
3280 {
3281 let ta = get_ta(&mut editor);
3282 ta.move_cursor(ratatui_textarea::CursorMove::End);
3283 }
3284 assert!(editor.smart_enter());
3286 assert_eq!(editor.get_text(), "- ");
3287 {
3290 let ta = get_ta(&mut editor);
3291 ta.move_cursor(ratatui_textarea::CursorMove::End);
3292 }
3293 assert!(editor.smart_enter());
3294 assert_eq!(editor.get_text(), "");
3295 }
3296
3297 #[test]
3298 fn smart_enter_continues_list_with_non_ascii_content() {
3299 let mut editor = make_editor();
3300 editor.set_text("- 你好".to_string());
3301 {
3302 let ta = get_ta(&mut editor);
3303 ta.move_cursor(ratatui_textarea::CursorMove::End);
3304 }
3305 assert!(editor.smart_enter());
3306 assert_eq!(editor.get_text(), "- 你好\n- ");
3307 }
3308
3309 #[test]
3310 fn smart_enter_preserves_tab_indent() {
3311 let mut editor = make_editor();
3312 editor.set_text("\tbody".to_string());
3313 {
3314 let ta = get_ta(&mut editor);
3315 ta.move_cursor(ratatui_textarea::CursorMove::End);
3316 }
3317 assert!(editor.smart_enter());
3318 assert_eq!(editor.get_text(), "\tbody\n\t");
3319 }
3320
3321 #[test]
3322 fn smart_enter_on_tab_only_line_dedents() {
3323 let mut editor = make_editor();
3324 editor.set_text("\t\t".to_string());
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(), "\t");
3332 }
3333
3334 #[test]
3335 fn smart_enter_continues_indented_list() {
3336 let mut editor = make_editor();
3337 editor.set_text(" - foo".to_string());
3338 {
3339 let ta = get_ta(&mut editor);
3340 ta.move_cursor(ratatui_textarea::CursorMove::End);
3341 }
3342 assert!(editor.smart_enter());
3343 assert_eq!(editor.get_text(), " - foo\n - ");
3344 }
3345
3346 #[test]
3347 fn unsupported_text_action_is_noop() {
3348 let mut editor = make_editor();
3349 editor.set_text("hello".to_string());
3350 editor.apply_text_action(TextAction::Underline);
3351 assert_eq!(editor.get_text(), "hello");
3352 }
3353
3354 #[test]
3355 fn textarea_hint_shortcuts_has_no_mode_indicator() {
3356 let editor = make_editor();
3357 let hints = editor.hint_shortcuts();
3358 assert!(
3360 !hints
3361 .iter()
3362 .any(|(_, label)| label == "NORMAL" || label == "INSERT")
3363 );
3364 }
3365
3366 fn place_cursor_at_col(editor: &mut TextEditorComponent, col: usize) {
3370 let ta = get_ta(editor);
3371 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3372 for _ in 0..col {
3373 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3374 }
3375 }
3376
3377 #[test]
3378 fn link_at_cursor_returns_label_when_cursor_on_hashtag() {
3379 let mut editor = make_editor();
3380 editor.set_text("see #rust now".to_string());
3381 place_cursor_at_col(&mut editor, 5);
3383 assert_eq!(
3384 editor.link_at_cursor(),
3385 Some(LinkTarget::Label("rust".into())),
3386 );
3387 }
3388
3389 #[test]
3390 fn link_at_cursor_returns_label_at_hash_char() {
3391 let mut editor = make_editor();
3392 editor.set_text("see #rust now".to_string());
3393 place_cursor_at_col(&mut editor, 4);
3395 assert_eq!(
3396 editor.link_at_cursor(),
3397 Some(LinkTarget::Label("rust".into())),
3398 );
3399 }
3400
3401 #[test]
3402 fn link_at_cursor_returns_none_outside_hashtag() {
3403 let mut editor = make_editor();
3404 editor.set_text("see #rust now".to_string());
3405 place_cursor_at_col(&mut editor, 0);
3407 assert_eq!(editor.link_at_cursor(), None);
3408 }
3409
3410 #[test]
3411 fn link_at_cursor_returns_note_for_wikilink() {
3412 let mut editor = make_editor();
3413 editor.set_text("open [[my note]] please".to_string());
3414 place_cursor_at_col(&mut editor, 7);
3416 let result = editor.link_at_cursor();
3417 assert!(
3418 matches!(result, Some(LinkTarget::Note(_))),
3419 "expected Note variant, got {result:?}"
3420 );
3421 }
3422
3423 #[test]
3426 fn link_at_cursor_returns_note_for_markdown_link_with_fragment() {
3427 let line = "[see docs](#section)";
3432 let mut editor = make_editor();
3433 editor.set_text(line.to_string());
3434 let cursor = "[see docs](#sec".chars().count(); place_cursor_at_col(&mut editor, cursor);
3437 let result = editor.link_at_cursor();
3438 assert!(
3439 matches!(result, Some(LinkTarget::Note(_))),
3440 "expected Note variant for markdown link fragment, got {result:?}"
3441 );
3442 }
3443
3444 #[test]
3445 fn vim_normal_i_then_typing_inserts_text() {
3446 let mut settings = crate::settings::AppSettings::default();
3447 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
3448 let mut editor = TextEditorComponent::new(KeyBindings::empty(), &settings);
3449 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3450 editor.handle_input(
3452 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3453 &tx,
3454 );
3455 assert_eq!(editor.get_text(), "");
3456 editor.handle_input(
3458 &InputEvent::Key(key(KeyCode::Char('i'), KeyModifiers::NONE)),
3459 &tx,
3460 );
3461 editor.handle_input(
3462 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3463 &tx,
3464 );
3465 assert_eq!(editor.get_text(), "x");
3466 }
3467
3468 fn make_vim_editor() -> TextEditorComponent {
3470 let mut settings = crate::settings::AppSettings::default();
3471 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
3472 TextEditorComponent::new(KeyBindings::empty(), &settings)
3473 }
3474
3475 fn vim_mode(editor: &TextEditorComponent) -> EditorMode {
3478 match &editor.backend {
3479 BackendState::Textarea(tb) => match &tb.input {
3480 backend::InputInterpreter::Vim(e) => e.mode().clone(),
3481 _ => panic!("expected Vim input interpreter"),
3482 },
3483 _ => panic!("expected Textarea backend"),
3484 }
3485 }
3486
3487 #[test]
3497 fn vim_sync_collapsed_sel_stays_normal() {
3498 let mut editor = make_vim_editor();
3499 editor.set_text("hello world".to_string());
3500
3501 assert_eq!(vim_mode(&editor), EditorMode::Normal);
3503
3504 editor.backend.vim_sync_mouse_selection(false);
3507 assert_eq!(
3508 vim_mode(&editor),
3509 EditorMode::Normal,
3510 "collapsed (bare click) selection must not enter Visual mode"
3511 );
3512 }
3513
3514 #[test]
3516 fn vim_sync_real_sel_enters_visual() {
3517 let mut editor = make_vim_editor();
3518 editor.set_text("hello world".to_string());
3519
3520 assert_eq!(vim_mode(&editor), EditorMode::Normal);
3522
3523 editor.backend.vim_sync_mouse_selection(true);
3525 assert_eq!(
3526 vim_mode(&editor),
3527 EditorMode::Visual,
3528 "real drag selection must enter Visual mode"
3529 );
3530 }
3531
3532 #[test]
3536 fn vim_find_bar_captures_typing_not_cursor() {
3537 let mut editor = make_vim_editor();
3538 editor.set_text("hello world\nsecond line".to_string());
3539 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3540
3541 editor.open_or_advance_search();
3543 assert!(editor.search.is_some(), "find bar must be open");
3544
3545 editor.handle_input(
3547 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
3548 &tx,
3549 );
3550 editor.handle_input(
3551 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
3552 &tx,
3553 );
3554
3555 let q = editor
3559 .search
3560 .as_ref()
3561 .map(|s| s.input.value().to_string())
3562 .unwrap_or_default();
3563 assert_eq!(q, "lo", "find query must capture typed characters");
3564
3565 assert_eq!(
3568 editor.get_text(),
3569 "hello world\nsecond line",
3570 "buffer must not be modified while find bar is open"
3571 );
3572
3573 assert_eq!(
3579 editor.cursor_pos().1,
3580 3,
3581 "cursor must jump to the search match (col 3), not to a vim motion position"
3582 );
3583 }
3584
3585 #[test]
3588 fn vim_search_enter_confirms_and_n_navigates() {
3589 let mut editor = make_vim_editor();
3590 editor.set_text("lo xx lo yy lo".to_string());
3592 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3593
3594 editor.open_or_advance_search();
3596 assert!(editor.search.is_some(), "find bar must open");
3597
3598 editor.handle_input(
3600 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
3601 &tx,
3602 );
3603 editor.handle_input(
3604 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
3605 &tx,
3606 );
3607
3608 editor.handle_input(
3610 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3611 &tx,
3612 );
3613 assert!(
3614 editor.search.is_none(),
3615 "find bar must close after Enter in vim mode"
3616 );
3617
3618 editor.handle_input(
3622 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
3623 &tx,
3624 );
3625 let (_, c1) = editor.cursor_pos();
3626 assert_eq!(c1, 6, "'n' must jump to the 2nd 'lo' at col 6");
3627
3628 editor.handle_input(
3629 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
3630 &tx,
3631 );
3632 let (_, c2) = editor.cursor_pos();
3633 assert_eq!(c2, 12, "'n' must jump to the 3rd 'lo' at col 12");
3634
3635 assert_eq!(editor.get_text(), "lo xx lo yy lo");
3637 }
3638}