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;
8mod revisions;
9use revisions::Revisions;
10pub mod snapshot;
11pub mod text_coords;
12pub mod view;
13mod vim;
14pub mod widener_metrics;
15pub mod word_wrap;
16
17use arboard::Clipboard;
18use ratatui::Frame;
19use ratatui::crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
20use ratatui::layout::Rect;
21use ratatui::style::{Modifier, Style};
22use ratatui::text::{Line, Span};
23use ratatui::widgets::Paragraph;
24use ratatui_textarea::{CursorMove, DataCursor, TextArea};
25use std::num::NonZeroU64;
26
27pub(crate) fn cursor_tuple(ta: &TextArea<'_>) -> (usize, usize) {
31 let DataCursor(r, c) = ta.cursor();
32 (r, c)
33}
34
35fn snapshot_from_backend(
42 backend: &BackendState,
43 content_revision: NonZeroU64,
44) -> EditorSnapshot<'_> {
45 match backend {
46 BackendState::Textarea(tb) => {
47 let cursor = cursor_tuple(&tb.ta);
48 EditorSnapshot::borrowed(tb.ta.lines(), cursor, content_revision)
49 }
50 BackendState::Nvim(nvim) => {
51 let snap = nvim.snapshot();
52 let lines_len = snap.lines.len();
53 let cursor_row = if lines_len == 0 {
54 0
55 } else {
56 snap.cursor.0.min(lines_len - 1)
57 };
58 let cursor = (cursor_row, snap.cursor.1);
59 let lines = snap.lines.clone();
60 let rev = Revisions::rev_from_gen(snap.content_gen);
61 drop(snap);
62 EditorSnapshot::owned(lines, cursor, rev)
63 }
64 }
65}
66
67fn has_trigger_before_cursor(line: &str, col: usize) -> bool {
78 let cursor_byte = line
79 .char_indices()
80 .nth(col)
81 .map(|(b, _)| b)
82 .unwrap_or(line.len());
83 line[..cursor_byte]
84 .chars()
85 .rev()
86 .any(|c| c == '[' || c == '#')
87}
88
89macro_rules! cursor_move {
95 ($ta:expr, $mv:expr, $shift:expr) => {{
96 if $shift {
97 if $ta.selection_range().is_none() {
98 $ta.start_selection();
99 }
100 } else {
101 $ta.cancel_selection();
102 }
103 $ta.move_cursor($mv);
104 }};
105}
106
107use self::backend::BackendState;
108use self::markdown::ParsedBuffer;
109use self::nvim_host::NvimHost;
110use self::snapshot::EditorSnapshot;
111use self::view::MarkdownEditorView;
112use crate::util::single_slot_task::SingleSlotTask;
113
114fn increment_ordered_marker(marker: &str) -> Option<String> {
117 let trimmed = marker.trim_end_matches(' ');
118 let dot = trimmed.strip_suffix('.')?;
119 let n: u32 = dot.parse().ok()?;
120 Some(format!("{}. ", n + 1))
121}
122
123fn char_col_to_byte(line: &str, char_col: usize) -> usize {
126 line.char_indices()
127 .nth(char_col)
128 .map(|(b, _)| b)
129 .unwrap_or(line.len())
130}
131
132fn selection_text(ta: &TextArea<'_>) -> Option<String> {
138 selection_text_in(ta, ta.selection_range()?)
139}
140
141fn selection_text_in(ta: &TextArea<'_>, range: ((usize, usize), (usize, usize))) -> Option<String> {
145 let ((sr, sc), (er, ec)) = range;
146 if sr == er && sc == ec {
147 return None;
148 }
149 let lines = ta.lines();
150 Some(if sr == er {
151 let line = &lines[sr];
152 let sb = char_col_to_byte(line, sc);
153 let eb = char_col_to_byte(line, ec);
154 line[sb..eb].to_string()
155 } else {
156 let first = &lines[sr];
157 let sb = char_col_to_byte(first, sc);
158 let mut parts = vec![first[sb..].to_string()];
159 for line in &lines[(sr + 1)..er] {
160 parts.push(line.clone());
161 }
162 let last = &lines[er];
163 let eb = char_col_to_byte(last, ec);
164 parts.push(last[..eb].to_string());
165 parts.join("\n")
166 })
167}
168
169fn surround_pair(c: char) -> Option<(&'static str, &'static str)> {
174 match c {
175 '(' => Some(("(", ")")),
176 '[' => Some(("[", "]")),
177 '{' => Some(("{", "}")),
178 '<' => Some(("<", ">")),
179 '"' => Some(("\"", "\"")),
180 '\'' => Some(("'", "'")),
181 '`' => Some(("`", "`")),
182 '*' => Some(("*", "*")),
183 '_' => Some(("_", "_")),
184 '~' => Some(("~", "~")),
185 _ => None,
186 }
187}
188
189fn set_selection(ta: &mut TextArea<'_>, start: (usize, usize), end: (usize, usize)) {
193 let jump = |(row, col): (usize, usize)| {
194 CursorMove::Jump(
195 u16::try_from(row).unwrap_or(u16::MAX),
196 u16::try_from(col).unwrap_or(u16::MAX),
197 )
198 };
199 ta.cancel_selection();
200 ta.move_cursor(jump(start));
201 ta.start_selection();
202 ta.move_cursor(jump(end));
203}
204
205#[derive(Debug, Clone)]
209pub struct ClipboardImage {
210 pub width: usize,
211 pub height: usize,
212 pub rgba: Vec<u8>,
213}
214
215const LINKABLE_PASTE_SCHEMES: &[&str] = &["http", "https", "ftp", "ftps", "mailto"];
219
220fn linkable_url(s: &str) -> Option<&str> {
221 kimun_core::note::scan::url_with_allowed_scheme(s, LINKABLE_PASTE_SCHEMES)
222}
223
224fn try_build_markdown_link(clip: &str, selection: Option<&str>) -> Option<String> {
228 let url = linkable_url(clip)?;
229 let sel = selection.filter(|s| !s.is_empty())?;
230 let escaped = sel.replace('\\', r"\\").replace(']', r"\]");
231 Some(format!("[{escaped}]({url})"))
232}
233
234use std::sync::Arc;
235
236use kimun_core::NoteVault;
237
238use crate::components::Component;
239use crate::components::autocomplete::{
240 self, AutocompleteController, AutocompleteHost, AutocompleteMode, HandleKeyOutcome,
241};
242use crate::components::event_state::EventState;
243use crate::components::events::AppEvent;
244use crate::components::events::AppTx;
245use crate::components::events::InputEvent;
246use crate::components::events::redraw_callback;
247use crate::components::preview_highlight;
248use crate::components::single_line_input::{InputOutcome, SingleLineInput};
249use crate::components::text_editor::autocomplete_glue::apply_accept_to_textarea;
250use crate::keys::KeyBindings;
251use crate::keys::action_shortcuts::TextAction;
252use crate::settings::AppSettings;
253use crate::settings::themes::Theme;
254
255#[derive(Debug, Clone, PartialEq)]
257pub enum LinkTarget {
258 Note(String),
260 Label(String),
262}
263
264struct SearchState {
265 input: SingleLineInput,
266 status: SearchStatus,
267}
268
269enum SearchStatus {
270 Empty,
271 Match,
272 NoMatch,
273 Invalid(String),
274}
275
276impl SearchStatus {
277 fn from_found(found: bool) -> Self {
278 if found { Self::Match } else { Self::NoMatch }
279 }
280}
281
282const FIND_PROMPT: &str = "Find: ";
283const FIND_HINTS: &str = " [Enter] next [Shift+Enter] prev [Esc] close";
284
285fn render_search_bar(
286 f: &mut Frame,
287 rect: Rect,
288 state: &mut SearchState,
289 theme: &Theme,
290 focused: bool,
291) {
292 let base = theme.base_style();
293 let muted = Style::default()
294 .fg(theme.gray.to_ratatui())
295 .bg(theme.bg.to_ratatui());
296 let err = Style::default()
297 .fg(theme.red.to_ratatui())
298 .bg(theme.bg.to_ratatui());
299 let prompt_cols = unicode_width::UnicodeWidthStr::width(FIND_PROMPT) as u16;
300 let value_total_cols = state.input.display_width() as u16;
304 let tail: Option<(String, Style)> = match &state.status {
305 SearchStatus::Empty => None,
306 SearchStatus::Match => Some((FIND_HINTS.to_string(), muted)),
307 SearchStatus::NoMatch => Some((" no match".to_string(), err)),
308 SearchStatus::Invalid(msg) => Some((format!(" invalid regex: {msg}"), err)),
309 };
310 f.render_widget(
311 Paragraph::new(Line::from(Span::styled(
312 FIND_PROMPT,
313 base.add_modifier(Modifier::BOLD),
314 )))
315 .style(base),
316 Rect {
317 width: prompt_cols.min(rect.width),
318 ..rect
319 },
320 );
321 state.input.render(f, rect, base, prompt_cols, focused);
322 if let Some((text, style)) = tail {
323 let consumed = prompt_cols.saturating_add(value_total_cols);
324 let tail_rect = Rect {
325 x: rect.x.saturating_add(consumed),
326 width: rect.width.saturating_sub(consumed),
327 ..rect
328 };
329 f.render_widget(Paragraph::new(text).style(style), tail_rect);
330 }
331}
332
333struct EditorHostSnapshot<'a> {
340 snap: EditorSnapshot<'a>,
341 cursor_screen: Option<(u16, u16)>,
342 cache_key: Option<NonZeroU64>,
343}
344
345impl<'a> AutocompleteHost for EditorHostSnapshot<'a> {
346 fn buffer_snapshot(&self) -> EditorSnapshot<'_> {
347 EditorSnapshot::borrowed(
352 self.snap.lines.as_ref(),
353 self.snap.cursor,
354 self.snap.content_revision,
355 )
356 }
357 fn cache_key(&self) -> Option<NonZeroU64> {
358 self.cache_key
359 }
360 fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
361 Some(self.cursor_screen.unwrap_or((0, 0)))
375 }
376}
377
378fn build_editor_host_snapshot<'a>(
384 backend: &'a BackendState,
385 content_revision: NonZeroU64,
386 cursor_screen: Option<(u16, u16)>,
387) -> Option<EditorHostSnapshot<'a>> {
388 if !backend.is_textarea() {
389 return None;
390 }
391 Some(EditorHostSnapshot {
392 snap: snapshot_from_backend(backend, content_revision),
393 cursor_screen,
394 cache_key: Some(content_revision),
395 })
396}
397
398pub struct TextEditorComponent {
402 backend: BackendState,
403 rect: Rect,
405 key_bindings: KeyBindings,
406 view: MarkdownEditorView,
407 revs: Revisions,
416 selection: Option<((usize, usize), (usize, usize))>,
419 clipboard: Option<Clipboard>,
421 nvim_host: NvimHost,
424 search: Option<SearchState>,
426 autocomplete: Option<AutocompleteController>,
430 autocomplete_vault: Option<Arc<NoteVault>>,
434 autocomplete_redraw_bound: bool,
439 full_parse_task: SingleSlotTask<()>,
446 pub wants_context_menu: bool,
449 search_needles: Vec<String>,
453 full_parse_tx: tokio::sync::mpsc::UnboundedSender<(u64, ParsedBuffer)>,
454 full_parse_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, ParsedBuffer)>,
455 redraw_tx: Option<AppTx>,
459}
460
461impl TextEditorComponent {
462 pub fn new(key_bindings: KeyBindings, settings: &AppSettings) -> Self {
463 let (full_parse_tx, full_parse_rx) = tokio::sync::mpsc::unbounded_channel();
464 Self {
465 backend: BackendState::from_settings(
466 &settings.editor_backend,
467 settings.nvim_path.as_ref(),
468 ),
469 rect: Rect::default(),
470 key_bindings,
471 view: MarkdownEditorView::new(),
472 revs: Revisions::new(),
473 selection: None,
474 clipboard: Clipboard::new().ok(),
475 nvim_host: NvimHost::new(),
476 search: None,
477 autocomplete: None,
478 autocomplete_vault: None,
479 autocomplete_redraw_bound: false,
480 full_parse_task: SingleSlotTask::empty(),
481 wants_context_menu: false,
482 search_needles: Vec::new(),
483 full_parse_tx,
484 full_parse_rx,
485 redraw_tx: None,
486 }
487 }
488
489 pub fn set_vault(&mut self, vault: Arc<NoteVault>) {
494 self.autocomplete_vault = Some(vault.clone());
495 if self.backend.is_textarea() {
496 self.autocomplete = Some(AutocompleteController::new(
497 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
498 AutocompleteMode::Both,
499 ));
500 }
501 }
502
503 fn ensure_autocomplete_for_textarea(&mut self) {
508 if self.autocomplete.is_some() {
509 return;
510 }
511 if !self.backend.is_textarea() {
512 return;
513 }
514 let Some(vault) = self.autocomplete_vault.clone() else {
515 return;
516 };
517 self.autocomplete = Some(AutocompleteController::new(
518 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
519 AutocompleteMode::Both,
520 ));
521 self.autocomplete_redraw_bound = false;
524 }
525
526 #[allow(dead_code)]
533 fn autocomplete_host_snapshot(&self) -> Option<EditorHostSnapshot<'_>> {
534 build_editor_host_snapshot(
535 &self.backend,
536 self.revs.current(),
537 self.view.last_cursor_screen,
538 )
539 }
540
541 fn poll_autocomplete(&mut self) {
544 if let Some(controller) = self.autocomplete.as_mut() {
545 controller.poll_results();
546 }
547 }
548
549 fn textarea_cursor(&self) -> Option<(usize, usize)> {
553 let ta = self.backend.as_textarea()?;
554 Some(cursor_tuple(ta))
555 }
556
557 fn refresh_autocomplete_if_open(&mut self) {
558 if !self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
560 return;
561 }
562 let Some(snapshot) = build_editor_host_snapshot(
566 &self.backend,
567 self.revs.current(),
568 self.view.last_cursor_screen,
569 ) else {
570 self.close_autocomplete();
571 return;
572 };
573 if let Some(controller) = self.autocomplete.as_mut() {
574 controller.refresh_if_open(&snapshot);
575 }
576 }
577
578 fn sync_autocomplete(&mut self) {
582 let Some(controller) = self.autocomplete.as_ref() else {
583 return; };
585
586 if !controller.is_open() {
599 let Some(ta) = self.backend.as_textarea() else {
600 return;
601 };
602 let (row, col) = cursor_tuple(ta);
603 let line = ta.lines().get(row).map(|s| s.as_str()).unwrap_or("");
604 if !has_trigger_before_cursor(line, col) {
605 return;
606 }
607 }
608
609 let Some(snapshot) = build_editor_host_snapshot(
613 &self.backend,
614 self.revs.current(),
615 self.view.last_cursor_screen,
616 ) else {
617 if let Some(c) = self.autocomplete.as_mut() {
618 c.close();
619 }
620 return;
621 };
622 if let Some(controller) = self.autocomplete.as_mut() {
623 controller.sync(&snapshot);
624 }
625 }
626
627 pub fn lines(&self) -> &[String] {
633 match &self.backend {
634 BackendState::Textarea(tb) => tb.ta.lines(),
635 BackendState::Nvim(_) => &[],
636 }
637 }
638
639 pub fn view_snapshot(&self) -> EditorSnapshot<'_> {
658 snapshot_from_backend(&self.backend, self.revs.current())
659 }
660
661 pub fn cursor_pos(&self) -> (usize, usize) {
665 self.backend.cursor()
666 }
667
668 pub fn set_search_needles(&mut self, needles: Vec<String>) {
672 self.search_needles = needles
673 .into_iter()
674 .map(|n| n.to_lowercase())
675 .filter(|n| !n.is_empty())
676 .collect();
677 self.revs.arm_needles();
678 }
679
680 pub fn set_text(&mut self, text: String) {
681 if text == self.get_text() {
688 self.revs.mark_saved_current();
689 if let Some(nvim) = self.backend.as_nvim() {
690 nvim.mark_clean();
691 }
692 return;
693 }
694 match &mut self.backend {
695 BackendState::Textarea(tb) => {
696 let lines = text.lines();
697 tb.ta = TextArea::from(lines);
698 }
699 BackendState::Nvim(nvim) => {
700 nvim.set_text(&text);
701 }
702 }
703 self.backend.vim_reset_to_normal();
704 self.bump_content();
705 let reconstructed = self.get_text();
706 self.mark_saved(reconstructed);
707 self.close_autocomplete();
710 }
711
712 pub fn get_text(&self) -> String {
713 self.backend.text()
714 }
715
716 pub fn content_revision(&self) -> NonZeroU64 {
723 self.revs.current()
724 }
725
726 pub fn mark_saved_at_revision(&mut self, rev: NonZeroU64) {
736 if !self.revs.mark_saved_at(rev) {
737 return;
738 }
739 if let Some(nvim) = self.backend.as_nvim() {
740 nvim.mark_clean();
741 }
742 }
743
744 pub fn mark_saved(&mut self, text: String) {
752 let matches = text == self.get_text();
753 if matches {
754 if let Some(nvim) = self.backend.as_nvim() {
755 nvim.mark_clean();
756 }
757 self.revs.mark_saved_current();
758 } else {
759 self.revs.mark_diverged();
764 }
765 }
766
767 pub fn is_dirty(&self) -> bool {
768 match &self.backend {
769 BackendState::Textarea(_) => self.revs.is_dirty(),
770 BackendState::Nvim(nvim) => nvim.snapshot().dirty,
771 }
772 }
773
774 pub fn vim_space_leads(&self) -> bool {
778 self.backend.vim_space_leads()
779 }
780
781 pub fn link_at_cursor(&self) -> Option<LinkTarget> {
784 let (_row, col, line) = match &self.backend {
785 BackendState::Textarea(tb) => {
786 let (row, col) = cursor_tuple(&tb.ta);
787 let line = tb.ta.lines().get(row)?.to_string();
788 (row, col, line)
789 }
790 BackendState::Nvim(nvim) => {
791 let snap = nvim.snapshot();
792 let (row, col) = snap.cursor;
793 let line = snap.lines.get(row)?.to_string();
794 (row, col, line)
795 }
796 };
797
798 if let Some(span) = kimun_core::note::scan::link_char_spans(&line)
801 .into_iter()
802 .find(|s| s.start <= col && col < s.end)
803 {
804 return Some(LinkTarget::Note(span.target));
805 }
806
807 let parsed = self::markdown::ParsedLine::parse(&line);
809 parsed
810 .elements
811 .iter()
812 .find(|e| {
813 e.kind == self::markdown::ElementKind::Label
814 && col >= e.start_char
815 && col < e.end_char
816 })
817 .map(|e| {
818 let span: String = line
819 .chars()
820 .skip(e.start_char)
821 .take(e.end_char - e.start_char)
822 .collect();
823 let name = span.trim_start_matches('#').to_string();
824 LinkTarget::Label(name)
825 })
826 }
827
828 fn copy_selection_to_clipboard(&mut self) {
830 let text = {
831 let range = match self.inclusive_visual_range() {
840 Some(r) => r,
841 None => return,
842 };
843 let Some(ta) = self.backend.as_textarea() else {
844 return;
845 };
846 match selection_text_in(ta, range) {
847 Some(t) => t,
848 None => return,
849 }
850 };
851 if let Some(cb) = &mut self.clipboard {
852 let _ = cb.set_text(text);
853 }
854 }
855
856 fn inclusive_visual_range(&self) -> Option<((usize, usize), (usize, usize))> {
862 let charwise = self.backend.vim_is_charwise_visual();
863 let ta = self.backend.as_textarea()?;
864 let (start, (er, ec)) = ta.selection_range()?;
865 let end = if charwise {
866 let len = ta.lines().get(er).map(|l| l.chars().count()).unwrap_or(ec);
867 (er, (ec + 1).min(len))
868 } else {
869 (er, ec)
870 };
871 Some((start, end))
872 }
873
874 fn paste_from_clipboard(&mut self, tx: &AppTx) {
876 let text = match &mut self.clipboard {
877 Some(cb) => match cb.get_text() {
878 Ok(t) if !t.is_empty() => t,
879 _ => return,
880 },
881 None => return,
882 };
883 self.paste_text(&text, tx);
884 }
885
886 fn extend_visual_selection_inclusive(&mut self) {
903 if !self.backend.vim_is_charwise_visual() {
904 return;
905 }
906 if let Some((start, end)) = self.inclusive_visual_range()
907 && let Some(ta) = self.backend.as_textarea_mut()
908 {
909 set_selection(ta, start, end);
910 }
911 }
912
913 pub fn paste_text(&mut self, text: &str, tx: &AppTx) {
914 if text.is_empty() {
915 return;
916 }
917 self.extend_visual_selection_inclusive();
918 match &mut self.backend {
919 BackendState::Textarea(tb) => {
920 let selection = linkable_url(text).and_then(|_| selection_text(&tb.ta));
921 let wrapped = try_build_markdown_link(text, selection.as_deref());
922 if tb.ta.selection_range().is_some() {
923 tb.ta.cut();
924 }
925 tb.ta.insert_str(wrapped.as_deref().unwrap_or(text));
926 self.selection = tb.ta.selection_range();
927 self.bump_content();
928 }
929 BackendState::Nvim(nvim) => {
930 nvim.paste(text, tx.clone());
931 self.bump_content();
932 }
933 }
934 self.bind_autocomplete_redraw(tx);
938 self.sync_autocomplete();
939 }
940
941 pub fn insert_at_cursor(&mut self, text: &str, tx: &AppTx) {
946 if matches!(self.backend, BackendState::Nvim(_)) {
947 self.paste_text(text, tx);
948 return;
949 }
950 if let Some(ta) = self.backend.as_textarea_mut() {
951 if ta.selection_range().is_some() {
952 ta.cut();
953 }
954 ta.insert_str(text);
955 self.selection = ta.selection_range();
956 self.bump_content();
957 }
958 self.bind_autocomplete_redraw(tx);
961 self.sync_autocomplete();
962 }
963
964 pub fn take_clipboard_image(&mut self) -> Option<ClipboardImage> {
968 let cb = self.clipboard.as_mut()?;
969 let img = cb.get_image().ok()?;
970 Some(ClipboardImage {
971 width: img.width,
972 height: img.height,
973 rgba: img.bytes.into_owned(),
974 })
975 }
976
977 fn wrap_selection(&mut self, open: &str, close: &str) -> bool {
983 self.extend_visual_selection_inclusive();
987 let Some(ta) = self.backend.as_textarea_mut() else {
988 return false;
989 };
990 let Some(((sr, sc), (er, ec))) = ta.selection_range() else {
991 return false;
992 };
993 let Some(text) = selection_text(ta) else {
994 return false;
995 };
996 ta.insert_str(format!("{open}{text}{close}"));
997 let shift = open.chars().count();
1001 let inner_end_col = if sr == er { ec + shift } else { ec };
1002 set_selection(ta, (sr, sc + shift), (er, inner_end_col));
1003 self.selection = ta.selection_range();
1004 self.bump_content();
1005 true
1006 }
1007
1008 pub fn apply_text_action(&mut self, action: TextAction) {
1011 let marker = match action {
1012 TextAction::Bold => "**",
1013 TextAction::Italic => "*",
1014 TextAction::Strikethrough => "~~",
1015 _ => return,
1016 };
1017 if self.wrap_selection(marker, marker) {
1018 return;
1019 }
1020 let Some(ta) = self.backend.as_textarea_mut() else {
1021 return;
1022 };
1023 ta.insert_str(format!("{marker}{marker}"));
1024 for _ in 0..marker.len() {
1025 ta.move_cursor(CursorMove::Back);
1026 }
1027 self.selection = ta.selection_range();
1028 self.bump_content();
1029 }
1030
1031 pub fn smart_enter(&mut self) -> bool {
1036 enum Action {
1037 ClearLine { chars: usize },
1038 InsertPrefix(String),
1039 Dedent,
1040 }
1041 let action = {
1042 let Some(ta) = self.backend.as_textarea() else {
1043 return false;
1044 };
1045 if ta
1048 .selection_range()
1049 .is_some_and(|(start, end)| start != end)
1050 {
1051 return false;
1052 }
1053 let (row, col) = cursor_tuple(ta);
1054 let Some(line) = ta.lines().get(row) else {
1055 return false;
1056 };
1057 let total_chars = line.chars().count();
1058 if col != total_chars {
1059 return false;
1060 }
1061 let ws_end = markdown::leading_ws_byte_len(line);
1063 let (ws, after_ws) = line.split_at(ws_end);
1064 if let Some(marker_len) = markdown::list_marker_len(after_ws) {
1065 if after_ws.len() == marker_len {
1066 if ws_end > 0 {
1069 Action::Dedent
1070 } else {
1071 Action::ClearLine { chars: total_chars }
1072 }
1073 } else {
1074 let marker_str = &after_ws[..marker_len];
1075 let next_marker = increment_ordered_marker(marker_str)
1076 .unwrap_or_else(|| marker_str.to_string());
1077 Action::InsertPrefix(format!("{ws}{next_marker}"))
1078 }
1079 } else if ws_end > 0 && total_chars == ws_end {
1080 Action::Dedent
1081 } else if ws_end > 0 {
1082 Action::InsertPrefix(ws.to_string())
1083 } else {
1084 return false;
1085 }
1086 };
1087
1088 match action {
1089 Action::Dedent => {
1090 self.indent_lines(true);
1091 return true;
1092 }
1093 Action::ClearLine { chars } => {
1094 let Some(ta) = self.backend.as_textarea_mut() else {
1095 unreachable!()
1096 };
1097 ta.move_cursor(CursorMove::Head);
1098 ta.delete_str(chars);
1099 }
1100 Action::InsertPrefix(prefix) => {
1101 let Some(ta) = self.backend.as_textarea_mut() else {
1102 unreachable!()
1103 };
1104 ta.insert_newline();
1105 ta.insert_str(prefix);
1106 }
1107 }
1108 let Some(ta) = self.backend.as_textarea() else {
1109 unreachable!()
1110 };
1111 self.selection = ta.selection_range();
1112 self.bump_content();
1113 true
1114 }
1115
1116 pub fn jump_to_heading(&mut self, heading: &str) {
1121 let Some(ta) = self.backend.as_textarea_mut() else {
1122 return;
1123 };
1124 fn normalise(text: &str) -> String {
1129 text.trim()
1130 .trim_end_matches('#')
1131 .trim()
1132 .replace(['*', '_', '`'], "")
1133 }
1134 let wanted = normalise(heading);
1135 let row = ta.lines().iter().position(|l| {
1136 let t = l.trim_start();
1137 let stripped = t.trim_start_matches('#');
1138 stripped.len() != t.len() && normalise(stripped) == wanted
1139 });
1140 if let Some(row) = row {
1141 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1142 }
1143 }
1144
1145 pub fn indent_lines(&mut self, dedent: bool) {
1149 let Some(ta) = self.backend.as_textarea_mut() else {
1150 return;
1151 };
1152 let tab_len = ta.tab_length() as usize;
1153 let hard_tab = ta.hard_tab_indent();
1154 let indent: String = if hard_tab {
1155 "\t".to_string()
1156 } else {
1157 " ".repeat(tab_len)
1158 };
1159 if indent.is_empty() {
1160 return;
1161 }
1162 let indent_chars = indent.len();
1163
1164 let sel = ta.selection_range();
1165 let saved_cursor = if sel.is_none() {
1166 Some(cursor_tuple(ta))
1167 } else {
1168 None
1169 };
1170 let (start_row, end_row) = match sel {
1171 Some(((sr, _), (er, ec))) => {
1172 let last = if ec == 0 && er > sr { er - 1 } else { er };
1175 (sr, last)
1176 }
1177 None => {
1178 let (r, _) = saved_cursor.unwrap();
1179 (r, r)
1180 }
1181 };
1182
1183 let row_count = end_row.saturating_sub(start_row) + 1;
1184 let mut row_deltas: Vec<isize> = Vec::with_capacity(row_count);
1185 let mut any_change = false;
1186
1187 ta.cancel_selection();
1192
1193 for row in start_row..=end_row {
1194 if dedent {
1195 let count = {
1196 let line = ta.lines().get(row).map(|s| s.as_str()).unwrap_or("");
1197 let max_remove = if hard_tab { 1 } else { tab_len };
1198 let mut count = 0usize;
1199 for (i, c) in line.chars().enumerate() {
1200 if i >= max_remove {
1201 break;
1202 }
1203 if c == '\t' {
1204 count += 1;
1205 break;
1206 } else if c == ' ' && !hard_tab {
1207 count += 1;
1208 } else {
1209 break;
1210 }
1211 }
1212 count
1213 };
1214 if count > 0 {
1215 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1216 ta.delete_str(count);
1217 any_change = true;
1218 }
1219 row_deltas.push(-(count as isize));
1220 } else {
1221 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1222 ta.insert_str(&indent);
1223 row_deltas.push(indent_chars as isize);
1224 any_change = true;
1225 }
1226 }
1227
1228 let adj = |row: usize, col: usize| -> usize {
1229 if row >= start_row && row <= end_row {
1230 let d = row_deltas[row - start_row];
1231 if d >= 0 {
1232 col + d as usize
1233 } else {
1234 col.saturating_sub((-d) as usize)
1235 }
1236 } else {
1237 col
1238 }
1239 };
1240
1241 match sel {
1242 Some(((ssr, ssc), (ser, sec))) => {
1243 set_selection(ta, (ssr, adj(ssr, ssc)), (ser, adj(ser, sec)));
1244 }
1245 None => {
1246 let (cr, cc) = saved_cursor.expect("captured when sel is None");
1247 let new_col = adj(cr, cc);
1248 ta.move_cursor(CursorMove::Jump(cr as u16, new_col as u16));
1249 }
1250 }
1251
1252 if any_change {
1253 self.selection = ta.selection_range();
1254 self.bump_content();
1255 }
1256 }
1257}
1258
1259impl TextEditorComponent {
1260 #[inline]
1270 fn bump_content(&mut self) {
1271 self.revs.bump();
1272 }
1273
1274 fn maybe_recover_from_dead_nvim(&mut self) {
1276 if self.backend.recover_from_dead_nvim() {
1277 self.ensure_autocomplete_for_textarea();
1281 }
1282 }
1283
1284 fn handle_nvim_key(
1289 &mut self,
1290 key: &ratatui::crossterm::event::KeyEvent,
1291 tx: &AppTx,
1292 ) -> Option<EventState> {
1293 let nvim = self.backend.as_nvim()?;
1297 self.nvim_host.handle_key(nvim, key, tx);
1302 Some(EventState::Consumed)
1303 }
1304
1305 pub fn open_or_advance_search(&mut self) {
1309 if !self.backend.is_textarea() {
1310 return;
1311 }
1312 if self.search.is_some() {
1313 self.search_advance(false);
1314 return;
1315 }
1316 self.close_autocomplete();
1320 self.search = Some(SearchState {
1321 input: SingleLineInput::new(),
1322 status: SearchStatus::Empty,
1323 });
1324 }
1325
1326 pub fn close_autocomplete(&mut self) {
1330 if let Some(c) = self.autocomplete.as_mut() {
1331 c.close();
1332 }
1333 }
1334
1335 pub fn set_redraw_tx(&mut self, tx: &AppTx) {
1340 self.bind_autocomplete_redraw(tx);
1341 }
1342
1343 fn bind_autocomplete_redraw(&mut self, tx: &AppTx) {
1352 if self.redraw_tx.is_none() {
1353 self.redraw_tx = Some(tx.clone());
1354 }
1355 if self.autocomplete_redraw_bound {
1356 return;
1357 }
1358 if let Some(c) = self.autocomplete.as_mut() {
1359 c.set_redraw_callback(redraw_callback(tx.clone()));
1360 self.autocomplete_redraw_bound = true;
1361 }
1362 }
1363
1364 fn close_search(&mut self) {
1365 if let Some(ta) = self.backend.as_textarea_mut() {
1366 let _ = ta.set_search_pattern("");
1367 }
1368 self.search = None;
1369 self.selection = None;
1370 }
1371
1372 fn refresh_search_pattern(&mut self, jump: bool) {
1375 let Some(state) = self.search.as_mut() else {
1376 return;
1377 };
1378 let Some(ta) = self.backend.as_textarea_mut() else {
1379 return;
1380 };
1381 if state.input.is_empty() {
1382 let _ = ta.set_search_pattern("");
1383 state.status = SearchStatus::Empty;
1384 self.selection = None;
1385 return;
1386 }
1387 if let Err(e) = ta.set_search_pattern(state.input.value()) {
1388 state.status = SearchStatus::Invalid(e.to_string());
1389 self.selection = None;
1390 return;
1391 }
1392 if !jump {
1393 state.status = SearchStatus::Match;
1394 return;
1395 }
1396 let found = ta.search_forward(true);
1397 state.status = SearchStatus::from_found(found);
1398 self.highlight_current_match(found);
1399 }
1400
1401 fn search_advance(&mut self, backward: bool) {
1402 let Some(state) = self.search.as_mut() else {
1403 return;
1404 };
1405 if state.input.is_empty() {
1406 return;
1407 }
1408 let Some(ta) = self.backend.as_textarea_mut() else {
1409 return;
1410 };
1411 let found = if backward {
1412 ta.search_back(false)
1413 } else {
1414 ta.search_forward(false)
1415 };
1416 state.status = SearchStatus::from_found(found);
1417 self.highlight_current_match(found);
1418 }
1419
1420 fn highlight_current_match(&mut self, found: bool) {
1425 self.selection = if found {
1426 self.compute_match_selection()
1427 } else {
1428 None
1429 };
1430 }
1431
1432 fn compute_match_selection(&self) -> Option<((usize, usize), (usize, usize))> {
1438 let ta = self.backend.as_textarea()?;
1439 let re = ta.search_pattern()?;
1440 let DataCursor(row, col_chars) = ta.cursor();
1441 let line = ta.lines().get(row)?;
1442 let byte_off = char_col_to_byte(line, col_chars);
1443 let m = re.find_at(line, byte_off)?;
1444 if m.start() != byte_off {
1445 return None;
1446 }
1447 let match_chars = line[m.range()].chars().count();
1448 Some(((row, col_chars), (row, col_chars + match_chars)))
1449 }
1450
1451 fn handle_search_key(&mut self, key: &ratatui::crossterm::event::KeyEvent) -> bool {
1453 let Some(state) = self.search.as_mut() else {
1454 return false;
1455 };
1456 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1457 let outcome = state.input.handle_key(key);
1458 match outcome {
1459 InputOutcome::Cancel => self.close_search(),
1460 InputOutcome::Submit => {
1461 if self.backend.is_vim() {
1462 self.search = None;
1466 } else {
1467 self.search_advance(shift);
1468 }
1469 }
1470 InputOutcome::Changed => self.refresh_search_pattern(true),
1471 InputOutcome::Consumed | InputOutcome::NotConsumed => {}
1472 }
1473 true
1474 }
1475
1476 fn vim_search_repeat(&mut self, backward: bool) {
1479 let found = {
1480 let Some(ta) = self.backend.as_textarea_mut() else {
1481 return;
1482 };
1483 if backward {
1484 ta.search_back(false)
1485 } else {
1486 ta.search_forward(false)
1487 }
1488 };
1489 self.highlight_current_match(found);
1490 }
1491
1492 fn handle_textarea_key(
1494 &mut self,
1495 key: &ratatui::crossterm::event::KeyEvent,
1496 tx: &AppTx,
1497 ) -> EventState {
1498 if self.handle_search_key(key) {
1500 return EventState::Consumed;
1501 }
1502
1503 if key.modifiers == KeyModifiers::CONTROL {
1505 match key.code {
1506 KeyCode::Char('c') => {
1507 self.copy_selection_to_clipboard();
1508 return EventState::Consumed;
1509 }
1510 KeyCode::Char('v') => {
1511 self.paste_from_clipboard(tx);
1512 return EventState::Consumed;
1513 }
1514 KeyCode::Char('x') => {
1515 self.copy_selection_to_clipboard();
1516 let cut = if let Some(ta) = self.backend.as_textarea_mut() {
1517 let cut = ta.cut();
1523 self.selection = ta.selection_range();
1524 cut
1525 } else {
1526 false
1527 };
1528 if cut {
1529 self.bump_content();
1530 }
1531 return EventState::Consumed;
1532 }
1533 _ => {}
1534 }
1535 }
1536
1537 let Some(ta) = self.backend.as_textarea_mut() else {
1538 unreachable!("handle_textarea_key called with non-Textarea backend")
1539 };
1540
1541 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1543 let handled = match (key.modifiers & !KeyModifiers::SHIFT, key.code) {
1544 (KeyModifiers::ALT, KeyCode::Left) => {
1545 cursor_move!(ta, CursorMove::WordBack, shift);
1546 true
1547 }
1548 (KeyModifiers::ALT, KeyCode::Right) => {
1549 cursor_move!(ta, CursorMove::WordForward, shift);
1550 true
1551 }
1552 (KeyModifiers::ALT, KeyCode::Char('b') | KeyCode::Char('B')) => {
1557 cursor_move!(ta, CursorMove::WordBack, shift);
1558 true
1559 }
1560 (KeyModifiers::ALT, KeyCode::Char('f') | KeyCode::Char('F')) => {
1561 cursor_move!(ta, CursorMove::WordForward, shift);
1562 true
1563 }
1564 (KeyModifiers::SUPER, KeyCode::Left) => {
1565 cursor_move!(ta, CursorMove::Head, shift);
1566 true
1567 }
1568 (KeyModifiers::SUPER, KeyCode::Right) => {
1569 cursor_move!(ta, CursorMove::End, shift);
1570 true
1571 }
1572 (KeyModifiers::SUPER, KeyCode::Up) => {
1573 cursor_move!(ta, CursorMove::Top, shift);
1574 true
1575 }
1576 (KeyModifiers::SUPER, KeyCode::Down) => {
1577 cursor_move!(ta, CursorMove::Bottom, shift);
1578 true
1579 }
1580 _ => false,
1581 };
1582 if handled {
1583 self.selection = ta.selection_range();
1584 return EventState::Consumed;
1585 }
1586
1587 enum ShortcutOutcome {
1598 NoOp,
1599 CursorOnly,
1600 TextMutated,
1601 }
1602 let outcome: Option<ShortcutOutcome> =
1603 match (key.modifiers & !KeyModifiers::SHIFT, key.code) {
1604 (KeyModifiers::NONE, KeyCode::Left) => {
1606 cursor_move!(ta, CursorMove::Back, shift);
1607 Some(ShortcutOutcome::CursorOnly)
1608 }
1609 (KeyModifiers::NONE, KeyCode::Right) => {
1610 cursor_move!(ta, CursorMove::Forward, shift);
1611 Some(ShortcutOutcome::CursorOnly)
1612 }
1613 (KeyModifiers::NONE, KeyCode::Up) => {
1614 cursor_move!(ta, CursorMove::Up, shift);
1615 Some(ShortcutOutcome::CursorOnly)
1616 }
1617 (KeyModifiers::NONE, KeyCode::Down) => {
1618 cursor_move!(ta, CursorMove::Down, shift);
1619 Some(ShortcutOutcome::CursorOnly)
1620 }
1621 (KeyModifiers::NONE, KeyCode::Home) => {
1622 cursor_move!(ta, CursorMove::Head, shift);
1623 Some(ShortcutOutcome::CursorOnly)
1624 }
1625 (KeyModifiers::NONE, KeyCode::End) => {
1626 cursor_move!(ta, CursorMove::End, shift);
1627 Some(ShortcutOutcome::CursorOnly)
1628 }
1629 (KeyModifiers::NONE, KeyCode::PageUp) => {
1630 cursor_move!(ta, CursorMove::ParagraphBack, shift);
1631 Some(ShortcutOutcome::CursorOnly)
1632 }
1633 (KeyModifiers::NONE, KeyCode::PageDown) => {
1634 cursor_move!(ta, CursorMove::ParagraphForward, shift);
1635 Some(ShortcutOutcome::CursorOnly)
1636 }
1637 (KeyModifiers::CONTROL, KeyCode::Left) => {
1639 cursor_move!(ta, CursorMove::WordBack, shift);
1640 Some(ShortcutOutcome::CursorOnly)
1641 }
1642 (KeyModifiers::CONTROL, KeyCode::Right) => {
1643 cursor_move!(ta, CursorMove::WordForward, shift);
1644 Some(ShortcutOutcome::CursorOnly)
1645 }
1646 (KeyModifiers::CONTROL, KeyCode::Home) => {
1648 cursor_move!(ta, CursorMove::Top, shift);
1649 Some(ShortcutOutcome::CursorOnly)
1650 }
1651 (KeyModifiers::CONTROL, KeyCode::End) => {
1652 cursor_move!(ta, CursorMove::Bottom, shift);
1653 Some(ShortcutOutcome::CursorOnly)
1654 }
1655 (KeyModifiers::CONTROL, KeyCode::Char('z')) => {
1659 if ta.undo() {
1660 Some(ShortcutOutcome::TextMutated)
1661 } else {
1662 Some(ShortcutOutcome::NoOp)
1663 }
1664 }
1665 (KeyModifiers::CONTROL, KeyCode::Char('y'))
1666 | (KeyModifiers::CONTROL, KeyCode::Char('Z')) => {
1667 if ta.redo() {
1668 Some(ShortcutOutcome::TextMutated)
1669 } else {
1670 Some(ShortcutOutcome::NoOp)
1671 }
1672 }
1673 (KeyModifiers::CONTROL, KeyCode::Char('a')) => {
1675 ta.move_cursor(CursorMove::Top);
1676 ta.start_selection();
1677 ta.move_cursor(CursorMove::Bottom);
1678 Some(ShortcutOutcome::CursorOnly)
1679 }
1680 (KeyModifiers::CONTROL, KeyCode::Backspace)
1683 | (KeyModifiers::ALT, KeyCode::Backspace) => {
1684 if ta.delete_word() {
1685 Some(ShortcutOutcome::TextMutated)
1686 } else {
1687 Some(ShortcutOutcome::NoOp)
1688 }
1689 }
1690 (KeyModifiers::CONTROL, KeyCode::Delete) | (KeyModifiers::ALT, KeyCode::Delete) => {
1691 if ta.delete_next_word() {
1692 Some(ShortcutOutcome::TextMutated)
1693 } else {
1694 Some(ShortcutOutcome::NoOp)
1695 }
1696 }
1697 _ => None,
1698 };
1699 if let Some(kind) = outcome {
1700 self.selection = ta.selection_range();
1701 match kind {
1702 ShortcutOutcome::NoOp | ShortcutOutcome::CursorOnly => {}
1703 ShortcutOutcome::TextMutated => self.bump_content(),
1704 }
1705 return EventState::Consumed;
1706 }
1707
1708 match (key.modifiers, key.code) {
1710 (m, KeyCode::Tab)
1711 if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
1712 {
1713 self.indent_lines(m.contains(KeyModifiers::SHIFT));
1714 return EventState::Consumed;
1715 }
1716 (_, KeyCode::BackTab) => {
1717 self.indent_lines(true);
1718 return EventState::Consumed;
1719 }
1720 _ => {}
1721 }
1722 if key.code == KeyCode::Enter && key.modifiers.is_empty() && self.smart_enter() {
1723 return EventState::Consumed;
1724 }
1725
1726 if let KeyCode::Char(c) = key.code
1733 && (key.modifiers & !KeyModifiers::SHIFT).is_empty()
1734 && let Some((open, close)) = surround_pair(c)
1735 && self.wrap_selection(open, close)
1736 {
1737 return EventState::Consumed;
1738 }
1739
1740 let Some(ta) = self.backend.as_textarea_mut() else {
1741 unreachable!("handle_textarea_key called with non-Textarea backend")
1742 };
1743 let mutated = ta.input_without_shortcuts(*key);
1749 self.selection = ta.selection_range();
1750 if mutated {
1751 self.bump_content();
1752 }
1753 EventState::Consumed
1754 }
1755
1756 fn handle_mouse(&mut self, mouse: &ratatui::crossterm::event::MouseEvent) -> EventState {
1758 let r = &self.rect;
1759 let in_bounds = mouse.column >= r.x
1760 && mouse.column < r.x + r.width
1761 && mouse.row >= r.y
1762 && mouse.row < r.y + r.height;
1763 if !in_bounds {
1764 return EventState::NotConsumed;
1765 }
1766 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right))
1770 && self.selection.is_none_or(|(start, end)| start == end)
1771 {
1772 self.wants_context_menu = true;
1773 return EventState::Consumed;
1774 }
1775 if !self.backend.is_textarea() {
1779 return EventState::NotConsumed;
1780 }
1781 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
1783 self.copy_selection_to_clipboard();
1784 self.selection = if let Some(ta) = self.backend.as_textarea() {
1785 ta.selection_range()
1786 } else {
1787 None
1788 };
1789 return EventState::Consumed;
1790 }
1791 let Some(ta) = self.backend.as_textarea_mut() else {
1793 unreachable!()
1794 };
1795 match mouse.kind {
1796 MouseEventKind::Down(_) => {
1797 ta.cancel_selection();
1798 let (lrow, lcol) = self
1799 .view
1800 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1801 ta.move_cursor(CursorMove::Jump(lrow, lcol));
1802 ta.start_selection();
1803 }
1804 MouseEventKind::Drag(_) => {
1805 let (lrow, lcol) = self
1806 .view
1807 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1808 ta.move_cursor(CursorMove::Jump(lrow, lcol));
1809 }
1810 _ => {
1811 ta.input(*mouse);
1812 }
1813 }
1814 self.selection = ta.selection_range();
1815 EventState::Consumed
1818 }
1819}
1820
1821fn paint_viewport_extras(
1826 buf: &mut ratatui::buffer::Buffer,
1827 area: Rect,
1828 needles: &[String],
1829 theme: &Theme,
1830) {
1831 use ratatui::layout::Position;
1832 let match_fg = theme.color_search_match.to_ratatui();
1833 let checkbox_fg = theme.accent.to_ratatui();
1834
1835 for y in area.y..area.bottom() {
1836 if needles.is_empty() {
1841 let mut lead = String::new();
1842 for x in area.x..area.right().min(area.x + 16) {
1843 if let Some(cell) = buf.cell(Position::new(x, y)) {
1844 lead.push_str(cell.symbol());
1845 }
1846 }
1847 if !lead.trim_start().starts_with("- [") {
1848 continue;
1849 }
1850 }
1851 let mut row_text = String::new();
1854 let mut byte_to_col: Vec<(usize, u16)> = Vec::new();
1855 for x in area.x..area.right() {
1856 let Some(cell) = buf.cell(Position::new(x, y)) else {
1857 continue;
1858 };
1859 let sym = cell.symbol();
1860 if sym.is_empty() {
1861 continue;
1862 }
1863 byte_to_col.push((row_text.len(), x));
1864 row_text.push_str(sym);
1865 }
1866 if row_text.trim().is_empty() {
1867 continue;
1868 }
1869
1870 let mut restyle =
1871 |from_byte: usize, to_byte: usize, f: &mut dyn FnMut(&mut ratatui::buffer::Cell)| {
1872 for (b, x) in &byte_to_col {
1873 if *b >= from_byte
1874 && *b < to_byte
1875 && let Some(cell) = buf.cell_mut(Position::new(*x, y))
1876 {
1877 f(cell);
1878 }
1879 }
1880 };
1881
1882 let trimmed_start = row_text.len() - row_text.trim_start().len();
1884 let after_indent = &row_text[trimmed_start..];
1885 let is_done = after_indent.starts_with("- [x] ") || after_indent.starts_with("- [X] ");
1886 let is_open = after_indent.starts_with("- [ ] ");
1887 if is_done || is_open {
1888 let box_start = trimmed_start + 2;
1889 let box_end = box_start + 3;
1890 restyle(box_start, box_end, &mut |cell| {
1891 cell.set_fg(checkbox_fg);
1892 });
1893 if is_done {
1894 restyle(box_end, row_text.len(), &mut |cell| {
1895 let style = cell
1896 .style()
1897 .add_modifier(Modifier::DIM | Modifier::CROSSED_OUT);
1898 cell.set_style(style);
1899 });
1900 }
1901 }
1902
1903 for (start, end) in preview_highlight::match_ranges(&row_text, needles) {
1908 restyle(start, end, &mut |cell| {
1909 let style = cell.style().fg(match_fg).add_modifier(Modifier::BOLD);
1910 cell.set_style(style);
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.revs.current(),
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 return EventState::Consumed;
1999 }
2000 VimKeyOutcome::NoOp => return EventState::Consumed,
2001 VimKeyOutcome::PassThrough => { }
2002 VimKeyOutcome::Host(action) => {
2003 use self::vim::VimHostAction;
2004 match action {
2005 VimHostAction::OpenPalette => {
2006 tx.send(AppEvent::ExecuteLeaderAction(
2008 crate::keys::leader::LeaderAction::Palette,
2009 ))
2010 .ok();
2011 }
2012 VimHostAction::OpenSearch { forward: _ } => {
2013 self.open_or_advance_search();
2017 }
2018 VimHostAction::SearchNext => self.vim_search_repeat(false),
2019 VimHostAction::SearchPrev => self.vim_search_repeat(true),
2020 }
2021 return EventState::Consumed;
2022 }
2023 }
2024 }
2025 if let Some(state) = self.handle_nvim_key(key, tx) {
2026 return state;
2027 }
2028 let text_rev_before = self.revs.current();
2039 let cursor_before = self.textarea_cursor();
2040 let result = self.handle_textarea_key(key, tx);
2041 let cursor_after = self.textarea_cursor();
2042 if self.revs.current() != text_rev_before {
2043 self.sync_autocomplete();
2044 } else if cursor_before != cursor_after {
2045 self.refresh_autocomplete_if_open();
2046 }
2047 result
2048 }
2049 InputEvent::Mouse(mouse) => {
2050 let text_rev_before = self.revs.current();
2051 let cursor_before = self.textarea_cursor();
2052 let result = self.handle_mouse(mouse);
2053 let cursor_after = self.textarea_cursor();
2054 if self.revs.current() != text_rev_before {
2057 self.sync_autocomplete();
2058 } else if cursor_before != cursor_after {
2059 self.refresh_autocomplete_if_open();
2060 }
2061 if result == EventState::Consumed
2066 && matches!(
2067 mouse.kind,
2068 ratatui::crossterm::event::MouseEventKind::Down(
2069 ratatui::crossterm::event::MouseButton::Left
2070 )
2071 )
2072 {
2073 match self.link_at_cursor() {
2074 Some(LinkTarget::Note(target)) => {
2075 tx.send(AppEvent::FollowLink(target)).ok();
2076 }
2077 Some(LinkTarget::Label(name)) => {
2078 tx.send(AppEvent::FollowLabel(name)).ok();
2079 }
2080 None => {}
2081 }
2082 }
2083 let has_sel = self
2094 .backend
2095 .as_textarea()
2096 .and_then(|ta| ta.selection_range())
2097 .is_some_and(|(s, e)| s != e);
2098 self.backend.vim_sync_mouse_selection(has_sel);
2099 result
2100 }
2101 InputEvent::Paste(_) => EventState::NotConsumed,
2104 }
2105 }
2106
2107 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
2108 let (editor_rect, search_rect) = if self.search.is_some() && rect.height > 1 {
2110 (
2111 Rect {
2112 height: rect.height - 1,
2113 ..rect
2114 },
2115 Some(Rect {
2116 y: rect.y + rect.height - 1,
2117 height: 1,
2118 ..rect
2119 }),
2120 )
2121 } else {
2122 (rect, None)
2123 };
2124 self.rect = editor_rect;
2127 let selection = match &self.backend {
2132 BackendState::Textarea(_) => self.selection,
2133 BackendState::Nvim(nvim) => {
2134 self.nvim_host
2135 .frame_sync(nvim, editor_rect.width, editor_rect.height)
2136 }
2137 };
2138 while let Ok((generation, buf)) = self.full_parse_rx.try_recv() {
2144 self.view.install_full_parse(generation, buf);
2145 }
2146
2147 let snap = snapshot_from_backend(&self.backend, self.revs.current());
2152 self.revs.adopt(snap.content_revision);
2156 self.view.update(&snap, editor_rect, selection);
2157
2158 if let Some(generation) = self.view.take_pending_full_parse() {
2166 let lines: Vec<String> = snap.lines.iter().cloned().collect();
2167 let tx = self.full_parse_tx.clone();
2168 let redraw = self.redraw_tx.clone();
2169 self.full_parse_task.spawn(async move {
2170 let buf = ParsedBuffer::parse(&lines);
2171 let _ = tx.send((generation, buf));
2172 if let Some(redraw) = redraw {
2175 let _ = redraw.send(AppEvent::Redraw);
2176 }
2177 });
2178 }
2179 let bar_focused = self.search.is_some() && focused;
2182 let editor_focused = focused && !bar_focused;
2183 use self::view::CursorShape;
2184 let cursor_shape = match self.backend.modal_is_insert() {
2185 None => None, Some(true) => Some(CursorShape::Bar),
2187 Some(false) => Some(CursorShape::Block),
2188 };
2189 self.view
2190 .render(f, editor_rect, theme, editor_focused, cursor_shape);
2191
2192 if self.revs.needles_stale() {
2196 self.search_needles.clear();
2197 self.revs.disarm_needles();
2198 }
2199 let mut emphasis_needles = self.search_needles.clone();
2200 if let Some(state) = &self.search {
2201 let q = state.input.value().trim().to_lowercase();
2202 if !q.is_empty() {
2203 emphasis_needles.push(q);
2204 }
2205 }
2206 paint_viewport_extras(f.buffer_mut(), editor_rect, &emphasis_needles, theme);
2207
2208 if snap.lines.iter().all(|l| l.is_empty()) && editor_rect.height > 0 {
2212 let leader = self
2213 .key_bindings
2214 .first_combo_for(&crate::keys::action_shortcuts::ActionShortcuts::Leader)
2215 .unwrap_or_else(|| "leader".to_string());
2216 f.render_widget(
2217 ratatui::widgets::Paragraph::new(format!(
2218 "Type to start · [[ to link · # to tag · {leader} for commands"
2219 ))
2220 .style(
2221 Style::default()
2222 .fg(theme.gray.to_ratatui())
2223 .add_modifier(Modifier::ITALIC),
2224 ),
2225 Rect {
2226 x: editor_rect.x.saturating_add(2),
2227 width: editor_rect.width.saturating_sub(2),
2228 height: 1,
2229 ..editor_rect
2230 },
2231 );
2232 }
2233 if let (Some(state), Some(bar_rect)) = (self.search.as_mut(), search_rect) {
2234 render_search_bar(f, bar_rect, state, theme, bar_focused);
2235 }
2236
2237 self.poll_autocomplete();
2245 if let (Some(controller), Some(live_anchor)) =
2252 (self.autocomplete.as_mut(), self.view.last_cursor_screen)
2253 {
2254 if let Some(state) = controller.state_mut() {
2255 state.anchor = live_anchor;
2256 }
2257 if let Some(state) = controller.state() {
2258 autocomplete::render(f, state, editor_rect, theme);
2259 }
2260 }
2261 }
2262
2263 fn hint_shortcuts(&self) -> Vec<(String, String)> {
2264 use crate::keys::action_shortcuts::ActionShortcuts;
2265
2266 if let Some(mut label) = self.backend.mode_label() {
2271 if let Some(p) = self.backend.vim_pending_hint() {
2272 label = format!("{label} {p}");
2273 }
2274 let mut hints = vec![(String::new(), label)];
2275 hints.extend(
2276 [
2277 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2278 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2279 (ActionShortcuts::FileOperations, "file ops"),
2280 ]
2281 .iter()
2282 .filter_map(|(action, label)| {
2283 self.key_bindings
2284 .first_combo_for(action)
2285 .map(|k| (k, label.to_string()))
2286 }),
2287 );
2288 return hints;
2289 }
2290
2291 let mut hints: Vec<(String, String)> = Vec::new();
2294 match self.link_at_cursor() {
2295 Some(LinkTarget::Note(_)) => {
2296 if let Some(k) = self
2297 .key_bindings
2298 .first_combo_for(&ActionShortcuts::FollowLink)
2299 {
2300 hints.push((k, "follow link".to_string()));
2301 }
2302 }
2303 Some(LinkTarget::Label(_)) => {
2304 if let Some(k) = self
2305 .key_bindings
2306 .first_combo_for(&ActionShortcuts::FollowLink)
2307 {
2308 hints.push((k, "browse tag".to_string()));
2309 }
2310 }
2311 None => {}
2312 }
2313 hints.extend(crate::components::hints::hints_for(
2314 &self.key_bindings,
2315 &[
2316 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2317 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2318 (ActionShortcuts::FileOperations, "file ops"),
2319 (ActionShortcuts::FindInBuffer, "find"),
2320 ],
2321 ));
2322 hints
2323 }
2324}
2325
2326#[cfg(test)]
2327mod tests {
2328 use super::snapshot::EditorMode;
2329 use super::*;
2330 use crate::keys::KeyBindings;
2331
2332 fn make_editor() -> TextEditorComponent {
2333 TextEditorComponent::new(
2334 KeyBindings::empty(),
2335 &crate::settings::AppSettings::default(),
2336 )
2337 }
2338
2339 fn dummy_tx() -> AppTx {
2340 tokio::sync::mpsc::unbounded_channel().0
2341 }
2342
2343 fn get_ta(editor: &mut TextEditorComponent) -> &mut TextArea<'static> {
2344 match &mut editor.backend {
2345 BackendState::Textarea(tb) => &mut tb.ta,
2346 _ => panic!("expected Textarea backend"),
2347 }
2348 }
2349
2350 #[test]
2351 fn has_trigger_before_cursor_finds_bracket() {
2352 assert!(has_trigger_before_cursor("hello [[foo", 11));
2353 assert!(has_trigger_before_cursor("[[a b c", 7));
2354 }
2355
2356 #[test]
2357 fn has_trigger_before_cursor_finds_hashtag() {
2358 assert!(has_trigger_before_cursor("text #tag", 9));
2359 }
2360
2361 #[test]
2362 fn has_trigger_before_cursor_no_trigger_bails() {
2363 assert!(!has_trigger_before_cursor("plain prose here", 16));
2364 assert!(!has_trigger_before_cursor("", 0));
2365 }
2366
2367 #[test]
2368 fn has_trigger_before_cursor_handles_multibyte_no_panic() {
2369 let line = "你好世界".to_string() + &"a".repeat(80);
2372 let col = line.chars().count();
2373 assert!(!has_trigger_before_cursor(&line, col));
2374
2375 let with_emoji = "🦀".repeat(20) + "[[note";
2376 let col = with_emoji.chars().count();
2377 assert!(has_trigger_before_cursor(&with_emoji, col));
2378
2379 let accented = "é".repeat(100);
2380 let col = accented.chars().count();
2381 assert!(!has_trigger_before_cursor(&accented, col));
2382 }
2383
2384 #[test]
2385 fn has_trigger_before_cursor_ignores_chars_after_cursor() {
2386 assert!(!has_trigger_before_cursor("foo [[bar", 3));
2388 }
2389
2390 #[test]
2391 fn has_trigger_before_cursor_wikilink_with_spaces() {
2392 assert!(has_trigger_before_cursor("[[my note title", 15));
2395 }
2396
2397 #[test]
2398 fn fresh_editor_is_not_dirty() {
2399 let editor = make_editor();
2400 assert!(!editor.is_dirty());
2401 }
2402
2403 #[test]
2404 fn after_set_text_not_dirty() {
2405 let mut editor = make_editor();
2406 editor.set_text("hello world".to_string());
2407 assert!(!editor.is_dirty());
2408 }
2409
2410 #[test]
2411 fn get_text_returns_loaded_content() {
2412 let mut editor = make_editor();
2413 editor.set_text("line one\nline two".to_string());
2414 assert_eq!(editor.get_text(), "line one\nline two");
2415 }
2416
2417 #[test]
2418 fn mark_saved_clears_dirty() {
2419 let mut editor = make_editor();
2420 editor.set_text("initial".to_string());
2421 let text = editor.get_text();
2422 editor.mark_saved(text.clone() + "x"); assert!(editor.is_dirty());
2424 editor.mark_saved(text); assert!(!editor.is_dirty());
2426 }
2427
2428 #[test]
2429 fn trailing_newline_does_not_cause_false_dirty() {
2430 let mut editor = make_editor();
2431 editor.set_text("content\n".to_string());
2432 assert!(
2433 !editor.is_dirty(),
2434 "trailing newline should not make editor dirty after load"
2435 );
2436 }
2437
2438 #[test]
2439 fn cursor_move_does_not_dirty_buffer() {
2440 let mut editor = make_editor();
2441 editor.set_text("hello world".to_string());
2442 assert!(!editor.is_dirty());
2443 let tx = dummy_tx();
2444 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
2447 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2448 assert!(
2449 !editor.is_dirty(),
2450 "cursor move must not mark the editor as dirty"
2451 );
2452 }
2453
2454 #[test]
2455 fn empty_stack_undo_redo_does_not_dirty_or_bump_revision() {
2456 let mut editor = make_editor();
2460 editor.set_text("foo".to_string());
2461 let rev_before = editor.content_revision();
2462 assert!(!editor.is_dirty());
2463 let tx = dummy_tx();
2464 for key_code in [KeyCode::Char('z'), KeyCode::Char('y')] {
2465 let key = ratatui::crossterm::event::KeyEvent::new(key_code, KeyModifiers::CONTROL);
2466 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2467 }
2468 assert!(
2469 !editor.is_dirty(),
2470 "empty-stack undo/redo must not flip is_dirty"
2471 );
2472 assert_eq!(
2473 editor.content_revision(),
2474 rev_before,
2475 "empty-stack undo/redo must not bump content_revision"
2476 );
2477 }
2478
2479 #[test]
2480 fn fresh_editor_content_revision_is_nonzero() {
2481 let editor = make_editor();
2488 assert!(editor.content_revision().get() >= 1);
2489 }
2490
2491 #[test]
2492 fn mouse_down_clears_selection() {
2493 let mut editor = make_editor();
2494 editor.set_text("hello world".to_string());
2495 let ta = get_ta(&mut editor);
2496 ta.start_selection();
2497 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2498 assert!(ta.selection_range().is_some());
2499 ta.cancel_selection();
2500 editor.selection = if let BackendState::Textarea(tb) = &editor.backend {
2501 tb.ta.selection_range()
2502 } else {
2503 None
2504 };
2505 assert!(editor.selection.is_none());
2506 }
2507
2508 #[test]
2509 fn ctrl_c_copies_selected_text() {
2510 let mut editor = make_editor();
2511 editor.set_text("hello world".to_string());
2512 let ta = get_ta(&mut editor);
2513 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2514 ta.start_selection();
2515 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2516 let range = ta.selection_range().unwrap();
2517 let ((sr, sc), (er, ec)) = range;
2518 let lines = ta.lines();
2519 let selected = if sr == er {
2520 lines[sr][sc..ec].to_string()
2521 } else {
2522 lines[sr][sc..].to_string()
2523 };
2524 assert_eq!(selected, "hello ");
2525 }
2526
2527 fn select_range(editor: &mut TextEditorComponent, start: (u16, u16), end: (u16, u16)) {
2529 let ta = get_ta(editor);
2530 ta.cancel_selection();
2531 ta.move_cursor(CursorMove::Jump(start.0, start.1));
2532 ta.start_selection();
2533 ta.move_cursor(CursorMove::Jump(end.0, end.1));
2534 assert!(ta.selection_range().is_some());
2535 }
2536
2537 fn send_char(editor: &mut TextEditorComponent, c: char) {
2538 let tx = dummy_tx();
2539 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
2540 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2541 }
2542
2543 #[test]
2544 fn surround_pair_maps_open_and_symmetric_chars() {
2545 assert_eq!(surround_pair('('), Some(("(", ")")));
2546 assert_eq!(surround_pair('['), Some(("[", "]")));
2547 assert_eq!(surround_pair('{'), Some(("{", "}")));
2548 assert_eq!(surround_pair('<'), Some(("<", ">")));
2549 assert_eq!(surround_pair('"'), Some(("\"", "\"")));
2550 assert_eq!(surround_pair('\''), Some(("'", "'")));
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(')'), None);
2557 assert_eq!(surround_pair(']'), None);
2558 assert_eq!(surround_pair('}'), None);
2559 assert_eq!(surround_pair('>'), None);
2560 assert_eq!(surround_pair('a'), None);
2561 }
2562
2563 #[test]
2564 fn typing_open_paren_with_selection_wraps_it() {
2565 let mut editor = make_editor();
2566 editor.set_text("hello world".to_string());
2567 select_range(&mut editor, (0, 0), (0, 5)); send_char(&mut editor, '(');
2569 assert_eq!(editor.get_text(), "(hello) world");
2570 assert!(editor.is_dirty(), "wrap must mark the buffer dirty");
2571 }
2572
2573 #[test]
2574 fn wrap_keeps_selection_on_inner_text() {
2575 let mut editor = make_editor();
2576 editor.set_text("hello world".to_string());
2577 select_range(&mut editor, (0, 0), (0, 5));
2578 send_char(&mut editor, '(');
2579 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2581 }
2582
2583 #[test]
2584 fn chained_brackets_build_a_wikilink() {
2585 let mut editor = make_editor();
2586 editor.set_text("my note".to_string());
2587 select_range(&mut editor, (0, 0), (0, 7));
2588 send_char(&mut editor, '[');
2589 send_char(&mut editor, '[');
2590 assert_eq!(editor.get_text(), "[[my note]]");
2591 assert_eq!(editor.selection, Some(((0, 2), (0, 9))));
2592 }
2593
2594 #[test]
2595 fn symmetric_chars_wrap_and_chain() {
2596 let mut editor = make_editor();
2597 editor.set_text("bold".to_string());
2598 select_range(&mut editor, (0, 0), (0, 4));
2599 send_char(&mut editor, '*');
2600 assert_eq!(editor.get_text(), "*bold*");
2601 send_char(&mut editor, '*');
2602 assert_eq!(editor.get_text(), "**bold**");
2603 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2604 }
2605
2606 #[test]
2607 fn closing_char_replaces_selection() {
2608 let mut editor = make_editor();
2609 editor.set_text("hello world".to_string());
2610 select_range(&mut editor, (0, 0), (0, 5));
2611 send_char(&mut editor, ')');
2612 assert_eq!(editor.get_text(), ") world");
2613 }
2614
2615 #[test]
2616 fn open_char_without_selection_inserts_normally() {
2617 let mut editor = make_editor();
2618 editor.set_text("hello".to_string());
2619 let ta = get_ta(&mut editor);
2620 ta.move_cursor(CursorMove::End);
2621 send_char(&mut editor, '(');
2622 assert_eq!(editor.get_text(), "hello(");
2623 }
2624
2625 #[test]
2626 fn wrap_spans_multiline_selection() {
2627 let mut editor = make_editor();
2628 editor.set_text("abc\ndef".to_string());
2629 select_range(&mut editor, (0, 0), (1, 3));
2630 send_char(&mut editor, '(');
2631 assert_eq!(editor.get_text(), "(abc\ndef)");
2632 assert_eq!(editor.selection, Some(((0, 1), (1, 3))));
2634 }
2635
2636 #[test]
2637 fn wrap_handles_multibyte_selection() {
2638 let mut editor = make_editor();
2639 editor.set_text("héllo🦀 x".to_string());
2640 select_range(&mut editor, (0, 0), (0, 6)); send_char(&mut editor, '`');
2642 assert_eq!(editor.get_text(), "`héllo🦀` x");
2643 assert_eq!(editor.selection, Some(((0, 1), (0, 7))));
2644 }
2645
2646 #[test]
2647 fn wrap_with_reversed_selection_direction() {
2648 let mut editor = make_editor();
2650 editor.set_text("hello world".to_string());
2651 select_range(&mut editor, (0, 5), (0, 0));
2652 send_char(&mut editor, '(');
2653 assert_eq!(editor.get_text(), "(hello) world");
2654 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2655 }
2656
2657 #[test]
2658 fn text_action_keeps_selection_on_inner_text() {
2659 let mut editor = make_editor();
2662 editor.set_text("bold word".to_string());
2663 select_range(&mut editor, (0, 0), (0, 4));
2664 editor.apply_text_action(TextAction::Bold);
2665 assert_eq!(editor.get_text(), "**bold** word");
2666 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2667 }
2668
2669 #[test]
2670 fn wrap_undo_is_two_steps_back_to_original() {
2671 let mut editor = make_editor();
2675 editor.set_text("hello world".to_string());
2676 select_range(&mut editor, (0, 0), (0, 5));
2677 send_char(&mut editor, '(');
2678 assert_eq!(editor.get_text(), "(hello) world");
2679 let ta = get_ta(&mut editor);
2680 ta.undo();
2681 ta.undo();
2682 assert_eq!(editor.get_text(), "hello world");
2683 }
2684
2685 #[test]
2686 fn linkable_url_accepts_supported_schemes() {
2687 assert_eq!(
2688 linkable_url("https://example.com"),
2689 Some("https://example.com")
2690 );
2691 assert_eq!(
2692 linkable_url("http://example.com/path?q=1#frag"),
2693 Some("http://example.com/path?q=1#frag"),
2694 );
2695 assert_eq!(
2696 linkable_url(" https://example.com "),
2697 Some("https://example.com")
2698 );
2699 assert_eq!(
2700 linkable_url("ftp://files.example.com/x"),
2701 Some("ftp://files.example.com/x"),
2702 );
2703 assert_eq!(
2704 linkable_url("ftps://files.example.com/x"),
2705 Some("ftps://files.example.com/x"),
2706 );
2707 assert_eq!(
2708 linkable_url("mailto:user@example.com"),
2709 Some("mailto:user@example.com"),
2710 );
2711 assert_eq!(
2712 linkable_url("mailto:user@example.com?subject=hi"),
2713 Some("mailto:user@example.com?subject=hi"),
2714 );
2715 }
2716
2717 #[test]
2718 fn linkable_url_rejects_other_schemes_and_plain_text() {
2719 assert_eq!(linkable_url("file:///etc/passwd"), None);
2720 assert_eq!(linkable_url("ssh://host"), None);
2721 assert_eq!(linkable_url("javascript:alert(1)"), None);
2722 assert_eq!(linkable_url("example.com"), None);
2723 assert_eq!(linkable_url("not a url"), None);
2724 assert_eq!(linkable_url(""), None);
2725 assert_eq!(linkable_url("https://example.com\nmore"), None);
2726 }
2727
2728 #[test]
2729 fn try_build_markdown_link_wraps_selection_when_clip_is_url() {
2730 assert_eq!(
2731 try_build_markdown_link("https://example.com", Some("click here")).as_deref(),
2732 Some("[click here](https://example.com)"),
2733 );
2734 }
2735
2736 #[test]
2737 fn try_build_markdown_link_trims_url_whitespace() {
2738 assert_eq!(
2739 try_build_markdown_link(" https://example.com\n", Some("link")).as_deref(),
2740 Some("[link](https://example.com)"),
2741 );
2742 }
2743
2744 #[test]
2745 fn try_build_markdown_link_returns_none_when_no_selection() {
2746 assert_eq!(try_build_markdown_link("https://example.com", None), None);
2747 }
2748
2749 #[test]
2750 fn try_build_markdown_link_returns_none_when_not_url() {
2751 assert_eq!(try_build_markdown_link("plain text", Some("sel")), None);
2752 }
2753
2754 #[test]
2755 fn try_build_markdown_link_returns_none_when_selection_empty() {
2756 assert_eq!(
2757 try_build_markdown_link("https://example.com", Some("")),
2758 None
2759 );
2760 }
2761
2762 #[test]
2763 fn try_build_markdown_link_escapes_close_bracket_in_selection() {
2764 assert_eq!(
2765 try_build_markdown_link("https://example.com", Some("a]b")).as_deref(),
2766 Some(r"[a\]b](https://example.com)"),
2767 );
2768 }
2769
2770 #[test]
2771 fn try_build_markdown_link_wraps_ftp_url() {
2772 assert_eq!(
2773 try_build_markdown_link("ftp://files.example.com/x", Some("download")).as_deref(),
2774 Some("[download](ftp://files.example.com/x)"),
2775 );
2776 }
2777
2778 fn key(code: KeyCode, mods: KeyModifiers) -> ratatui::crossterm::event::KeyEvent {
2779 ratatui::crossterm::event::KeyEvent::new(code, mods)
2780 }
2781
2782 #[test]
2784 fn paint_viewport_extras_emphasizes_needles_and_tasks() {
2785 use ratatui::buffer::Buffer;
2786 use ratatui::layout::Position;
2787 let theme = crate::settings::themes::Theme::default();
2788 let area = Rect::new(0, 0, 30, 3);
2789 let mut buf = Buffer::empty(area);
2790 buf.set_string(0, 0, "find the needle here", Style::default());
2791 buf.set_string(0, 1, "- [x] done task", Style::default());
2792 buf.set_string(0, 2, "- [ ] open task", Style::default());
2793
2794 paint_viewport_extras(&mut buf, area, &["needle".to_string()], &theme);
2795
2796 let cell = buf.cell(Position::new(9, 0)).unwrap();
2798 assert_eq!(cell.fg, theme.color_search_match.to_ratatui());
2799 assert!(cell.style().add_modifier.contains(Modifier::BOLD));
2800 let cell = buf.cell(Position::new(8, 1)).unwrap();
2802 assert!(cell.style().add_modifier.contains(Modifier::CROSSED_OUT));
2803 let cell = buf.cell(Position::new(8, 2)).unwrap();
2805 assert!(!cell.style().add_modifier.contains(Modifier::CROSSED_OUT));
2806 let cb = buf.cell(Position::new(3, 2)).unwrap();
2807 assert_eq!(cb.fg, theme.accent.to_ratatui());
2808 }
2809
2810 #[test]
2812 fn search_needles_clear_on_edit() {
2813 let settings = crate::settings::AppSettings::default();
2814 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2815 ed.set_text("alpha beta".to_string());
2816 ed.set_search_needles(vec!["Alpha".to_string()]);
2817 assert_eq!(ed.search_needles, vec!["alpha"]);
2818 assert!(!ed.revs.needles_stale());
2819
2820 ed.set_text("alpha beta gamma".to_string());
2822 assert!(ed.revs.needles_stale());
2823 }
2824
2825 #[test]
2826 fn jump_to_heading_moves_cursor_to_heading_line() {
2827 let settings = crate::settings::AppSettings::default();
2828 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2829 ed.set_text("intro\n# Top\nbody\n## Sub One\nmore\n".to_string());
2830
2831 ed.jump_to_heading("Sub One");
2832 assert_eq!(ed.view_snapshot().cursor.0, 3);
2833
2834 ed.jump_to_heading("Top");
2835 assert_eq!(ed.view_snapshot().cursor.0, 1);
2836
2837 ed.jump_to_heading("Nope");
2839 assert_eq!(ed.view_snapshot().cursor.0, 1);
2840 }
2841
2842 #[test]
2843 fn open_or_advance_search_opens_find_bar_with_empty_query() {
2844 let mut editor = make_editor();
2845 editor.set_text("hello world".to_string());
2846 editor.open_or_advance_search();
2847 let state = editor.search.as_ref().expect("find bar opened");
2848 assert!(state.input.is_empty());
2849 assert!(matches!(state.status, SearchStatus::Empty));
2850 }
2851
2852 #[test]
2853 fn open_or_advance_search_advances_when_already_open() {
2854 let mut editor = make_editor();
2855 editor.set_text("ab ab ab".to_string());
2856 let tx = dummy_tx();
2857 editor.open_or_advance_search();
2858 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2859 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2860 editor.open_or_advance_search();
2862 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2863 assert_eq!(col, 3, "second invocation advances to next match");
2864 }
2865
2866 #[test]
2867 fn typing_in_find_bar_jumps_cursor_to_first_match() {
2868 let mut editor = make_editor();
2869 editor.set_text("foo bar baz".to_string());
2870 let tx = dummy_tx();
2871 editor.open_or_advance_search();
2872 for ch in ['b', 'a', 'r'] {
2873 editor.handle_textarea_key(&key(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
2874 }
2875 let state = editor.search.as_ref().unwrap();
2876 assert_eq!(state.input.value(), "bar");
2877 assert!(matches!(state.status, SearchStatus::Match));
2878 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2879 assert_eq!(col, 4, "cursor jumped to start of 'bar'");
2880 }
2881
2882 #[test]
2883 fn enter_in_find_bar_advances_to_next_match() {
2884 let mut editor = make_editor();
2885 editor.set_text("ab ab ab".to_string());
2886 let tx = dummy_tx();
2887 editor.open_or_advance_search();
2888 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2889 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2890 editor.handle_textarea_key(&key(KeyCode::Enter, KeyModifiers::NONE), &tx);
2892 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2893 assert_eq!(col, 3, "Enter advances to second match");
2894 }
2895
2896 #[test]
2897 fn match_is_highlighted_as_selection_after_search() {
2898 let mut editor = make_editor();
2899 editor.set_text("foo bar baz".to_string());
2900 let tx = dummy_tx();
2901 editor.open_or_advance_search();
2902 for ch in ['b', 'a', 'r'] {
2903 editor.handle_textarea_key(&key(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
2904 }
2905 assert_eq!(editor.selection, Some(((0, 4), (0, 7))));
2907 }
2908
2909 #[test]
2910 fn no_match_clears_selection() {
2911 let mut editor = make_editor();
2912 editor.set_text("hello".to_string());
2913 let tx = dummy_tx();
2914 editor.open_or_advance_search();
2915 editor.handle_textarea_key(&key(KeyCode::Char('z'), KeyModifiers::NONE), &tx);
2916 assert_eq!(editor.selection, None);
2917 }
2918
2919 #[test]
2920 fn esc_in_find_bar_clears_selection_highlight() {
2921 let mut editor = make_editor();
2922 editor.set_text("foo bar".to_string());
2923 let tx = dummy_tx();
2924 editor.open_or_advance_search();
2925 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2926 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2927 editor.handle_textarea_key(&key(KeyCode::Char('r'), KeyModifiers::NONE), &tx);
2928 assert!(editor.selection.is_some());
2929 editor.handle_textarea_key(&key(KeyCode::Esc, KeyModifiers::NONE), &tx);
2930 assert!(editor.selection.is_none());
2931 }
2932
2933 #[test]
2934 fn esc_in_find_bar_closes_it() {
2935 let mut editor = make_editor();
2936 editor.set_text("hello".to_string());
2937 let tx = dummy_tx();
2938 editor.open_or_advance_search();
2939 assert!(editor.search.is_some());
2940 editor.handle_textarea_key(&key(KeyCode::Esc, KeyModifiers::NONE), &tx);
2941 assert!(editor.search.is_none());
2942 }
2943
2944 #[test]
2945 fn find_bar_consumes_typing_so_editor_text_is_unchanged() {
2946 let mut editor = make_editor();
2947 editor.set_text("hello".to_string());
2948 let tx = dummy_tx();
2949 editor.open_or_advance_search();
2950 editor.handle_textarea_key(&key(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
2951 assert_eq!(editor.get_text(), "hello");
2952 }
2953
2954 #[test]
2955 fn no_match_status_when_query_absent() {
2956 let mut editor = make_editor();
2957 editor.set_text("hello".to_string());
2958 let tx = dummy_tx();
2959 editor.open_or_advance_search();
2960 editor.handle_textarea_key(&key(KeyCode::Char('z'), KeyModifiers::NONE), &tx);
2961 let state = editor.search.as_ref().unwrap();
2962 assert!(matches!(state.status, SearchStatus::NoMatch));
2963 }
2964
2965 #[test]
2966 fn try_build_markdown_link_wraps_mailto_url() {
2967 assert_eq!(
2968 try_build_markdown_link("mailto:user@example.com", Some("email me")).as_deref(),
2969 Some("[email me](mailto:user@example.com)"),
2970 );
2971 }
2972
2973 #[test]
2974 fn insert_at_cursor_appends_text() {
2975 let mut editor = make_editor();
2976 editor.set_text("hello".to_string());
2977 {
2978 let ta = get_ta(&mut editor);
2979 ta.move_cursor(ratatui_textarea::CursorMove::End);
2980 }
2981 editor.insert_at_cursor(" world", &dummy_tx());
2982 assert_eq!(editor.get_text(), "hello world");
2983 }
2984
2985 #[test]
2986 fn insert_at_cursor_replaces_selection() {
2987 let mut editor = make_editor();
2988 editor.set_text("hello world".to_string());
2989 {
2990 let ta = get_ta(&mut editor);
2991 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2992 ta.start_selection();
2993 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2994 }
2995 editor.insert_at_cursor("HEY ", &dummy_tx());
2996 assert_eq!(editor.get_text(), "HEY world");
2997 }
2998
2999 #[test]
3000 fn paste_inserts_text_at_cursor() {
3001 let mut editor = make_editor();
3002 editor.set_text("hello".to_string());
3003 let ta = get_ta(&mut editor);
3004 ta.move_cursor(ratatui_textarea::CursorMove::End);
3005 ta.insert_str(" world");
3006 assert_eq!(editor.get_text(), "hello world");
3007 }
3008
3009 #[test]
3010 fn bold_action_with_no_selection_inserts_pair_and_centers_cursor() {
3011 let mut editor = make_editor();
3012 editor.set_text("hello".to_string());
3013 {
3014 let ta = get_ta(&mut editor);
3015 ta.move_cursor(ratatui_textarea::CursorMove::End);
3016 }
3017 editor.apply_text_action(TextAction::Bold);
3018 assert_eq!(editor.get_text(), "hello****");
3019 let ta = get_ta(&mut editor);
3020 assert_eq!(ta.cursor(), (0, 7));
3021 }
3022
3023 #[test]
3024 fn italic_action_with_no_selection_inserts_single_pair() {
3025 let mut editor = make_editor();
3026 editor.set_text(String::new());
3027 editor.apply_text_action(TextAction::Italic);
3028 assert_eq!(editor.get_text(), "**");
3029 let ta = get_ta(&mut editor);
3030 assert_eq!(ta.cursor(), (0, 1));
3031 }
3032
3033 #[test]
3034 fn strikethrough_action_with_selection_wraps_text() {
3035 let mut editor = make_editor();
3036 editor.set_text("hello world".to_string());
3037 {
3038 let ta = get_ta(&mut editor);
3039 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3040 ta.start_selection();
3041 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3042 }
3043 editor.apply_text_action(TextAction::Strikethrough);
3044 assert_eq!(editor.get_text(), "~~hello ~~world");
3045 }
3046
3047 #[test]
3048 fn bold_action_wraps_non_ascii_selection() {
3049 let mut editor = make_editor();
3050 editor.set_text("hello 你好 world".to_string());
3051 {
3052 let ta = get_ta(&mut editor);
3053 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3054 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3055 ta.start_selection();
3056 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3057 }
3058 editor.apply_text_action(TextAction::Bold);
3059 assert_eq!(editor.get_text(), "hello **你好 **world");
3060 }
3061
3062 #[test]
3063 fn bold_action_wraps_selected_text() {
3064 let mut editor = make_editor();
3065 editor.set_text("foo bar".to_string());
3066 {
3067 let ta = get_ta(&mut editor);
3068 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3069 ta.start_selection();
3070 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3071 }
3072 editor.apply_text_action(TextAction::Bold);
3073 assert_eq!(editor.get_text(), "**foo **bar");
3074 }
3075
3076 #[test]
3077 fn indent_no_selection_indents_current_line() {
3078 let mut editor = make_editor();
3079 editor.set_text("foo\nbar".to_string());
3080 {
3081 let ta = get_ta(&mut editor);
3082 ta.move_cursor(ratatui_textarea::CursorMove::Bottom);
3083 }
3084 editor.indent_lines(false);
3085 let lines = get_ta(&mut editor).lines();
3086 assert_eq!(lines[0], "foo");
3087 assert!(lines[1].starts_with(' ') || lines[1].starts_with('\t'));
3088 assert!(lines[1].trim_start() == "bar");
3089 }
3090
3091 #[test]
3092 fn indent_midline_selection_keeps_text_before_and_selection() {
3093 let mut editor = make_editor();
3094 editor.set_text("hello world".to_string());
3095 {
3096 let ta = get_ta(&mut editor);
3097 ta.move_cursor(ratatui_textarea::CursorMove::Jump(0, 6));
3098 ta.start_selection();
3099 ta.move_cursor(ratatui_textarea::CursorMove::End);
3100 }
3101 editor.indent_lines(false);
3102 let ta = get_ta(&mut editor);
3103 assert_eq!(ta.lines()[0].trim_start(), "hello world");
3105 let indent = ta.lines()[0].len() - "hello world".len();
3107 assert_eq!(
3108 ta.selection_range(),
3109 Some(((0, 6 + indent), (0, 11 + indent)))
3110 );
3111 }
3112
3113 #[test]
3114 fn indent_with_selection_indents_all_touched_lines() {
3115 let mut editor = make_editor();
3116 editor.set_text("foo\nbar\nbaz".to_string());
3117 {
3118 let ta = get_ta(&mut editor);
3119 ta.move_cursor(ratatui_textarea::CursorMove::Top);
3120 ta.start_selection();
3121 ta.move_cursor(ratatui_textarea::CursorMove::Down);
3122 ta.move_cursor(ratatui_textarea::CursorMove::End);
3123 }
3124 editor.indent_lines(false);
3125 let lines: Vec<String> = get_ta(&mut editor).lines().to_vec();
3126 assert_eq!(lines[0].trim_start(), "foo");
3127 assert_eq!(lines[1].trim_start(), "bar");
3128 assert_eq!(lines[2], "baz");
3129 assert!(lines[0].len() > 3);
3130 assert!(lines[1].len() > 3);
3131 }
3132
3133 #[test]
3134 fn dedent_removes_leading_indent() {
3135 let mut editor = make_editor();
3136 editor.set_text(" foo\n bar\nbaz".to_string());
3137 let tab_len = get_ta(&mut editor).tab_length() as usize;
3138 {
3139 let ta = get_ta(&mut editor);
3140 ta.move_cursor(ratatui_textarea::CursorMove::Top);
3141 ta.start_selection();
3142 ta.move_cursor(ratatui_textarea::CursorMove::Bottom);
3143 ta.move_cursor(ratatui_textarea::CursorMove::End);
3144 }
3145 editor.indent_lines(true);
3146 let lines: Vec<String> = get_ta(&mut editor).lines().to_vec();
3147 assert_eq!(lines[0], format!("{}foo", " ".repeat(4 - tab_len.min(4))));
3149 assert_eq!(
3151 lines[1],
3152 format!("{}bar", " ".repeat(2usize.saturating_sub(tab_len)))
3153 );
3154 assert_eq!(lines[2], "baz");
3155 }
3156
3157 #[test]
3158 fn dedent_no_leading_whitespace_is_noop_for_that_line() {
3159 let mut editor = make_editor();
3160 editor.set_text("foo".to_string());
3161 editor.indent_lines(true);
3162 assert_eq!(editor.get_text(), "foo");
3163 }
3164
3165 #[test]
3166 fn smart_enter_continues_unordered_list() {
3167 let mut editor = make_editor();
3168 editor.set_text("- foo".to_string());
3169 {
3170 let ta = get_ta(&mut editor);
3171 ta.move_cursor(ratatui_textarea::CursorMove::End);
3172 }
3173 assert!(editor.smart_enter());
3174 assert_eq!(editor.get_text(), "- foo\n- ");
3175 }
3176
3177 #[test]
3178 fn smart_enter_continues_ordered_list_increments() {
3179 let mut editor = make_editor();
3180 editor.set_text("1. foo".to_string());
3181 {
3182 let ta = get_ta(&mut editor);
3183 ta.move_cursor(ratatui_textarea::CursorMove::End);
3184 }
3185 assert!(editor.smart_enter());
3186 assert_eq!(editor.get_text(), "1. foo\n2. ");
3187 }
3188
3189 #[test]
3190 fn smart_enter_on_empty_list_marker_clears_line() {
3191 let mut editor = make_editor();
3192 editor.set_text("- ".to_string());
3193 {
3194 let ta = get_ta(&mut editor);
3195 ta.move_cursor(ratatui_textarea::CursorMove::End);
3196 }
3197 assert!(editor.smart_enter());
3198 assert_eq!(editor.get_text(), "");
3199 }
3200
3201 #[test]
3202 fn smart_enter_preserves_indent() {
3203 let mut editor = make_editor();
3204 editor.set_text(" body".to_string());
3205 {
3206 let ta = get_ta(&mut editor);
3207 ta.move_cursor(ratatui_textarea::CursorMove::End);
3208 }
3209 assert!(editor.smart_enter());
3210 assert_eq!(editor.get_text(), " body\n ");
3211 }
3212
3213 #[test]
3214 fn smart_enter_on_empty_indent_dedents() {
3215 let mut editor = make_editor();
3216 editor.set_text(" ".to_string());
3217 {
3218 let ta = get_ta(&mut editor);
3219 ta.move_cursor(ratatui_textarea::CursorMove::End);
3220 }
3221 let tab_len = get_ta(&mut editor).tab_length() as usize;
3222 assert!(editor.smart_enter());
3223 assert_eq!(
3224 editor.get_text(),
3225 " ".repeat(4usize.saturating_sub(tab_len))
3226 );
3227 }
3228
3229 #[test]
3230 fn smart_enter_no_indent_no_marker_returns_false() {
3231 let mut editor = make_editor();
3232 editor.set_text("plain".to_string());
3233 {
3234 let ta = get_ta(&mut editor);
3235 ta.move_cursor(ratatui_textarea::CursorMove::End);
3236 }
3237 assert!(!editor.smart_enter());
3238 assert_eq!(editor.get_text(), "plain");
3239 }
3240
3241 #[test]
3242 fn smart_enter_mid_line_returns_false() {
3243 let mut editor = make_editor();
3244 editor.set_text("- foo".to_string());
3245 {
3246 let ta = get_ta(&mut editor);
3247 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3248 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3249 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3250 }
3251 assert!(!editor.smart_enter());
3252 }
3253
3254 #[test]
3255 fn smart_enter_on_empty_indented_list_marker_dedents_keeping_marker() {
3256 let mut editor = make_editor();
3257 let tab_len = get_ta(&mut editor).tab_length() as usize;
3258 let indent = " ".repeat(tab_len);
3259 editor.set_text(format!("{indent}- "));
3260 {
3261 let ta = get_ta(&mut editor);
3262 ta.move_cursor(ratatui_textarea::CursorMove::End);
3263 }
3264 assert!(editor.smart_enter());
3265 assert_eq!(editor.get_text(), "- ");
3266 }
3267
3268 #[test]
3269 fn smart_enter_on_empty_list_marker_clears_line_after_full_dedent() {
3270 let mut editor = make_editor();
3271 let tab_len = get_ta(&mut editor).tab_length() as usize;
3272 let indent = " ".repeat(tab_len);
3273 editor.set_text(format!("{indent}- "));
3274 {
3275 let ta = get_ta(&mut editor);
3276 ta.move_cursor(ratatui_textarea::CursorMove::End);
3277 }
3278 assert!(editor.smart_enter());
3280 assert_eq!(editor.get_text(), "- ");
3281 {
3284 let ta = get_ta(&mut editor);
3285 ta.move_cursor(ratatui_textarea::CursorMove::End);
3286 }
3287 assert!(editor.smart_enter());
3288 assert_eq!(editor.get_text(), "");
3289 }
3290
3291 #[test]
3292 fn smart_enter_continues_list_with_non_ascii_content() {
3293 let mut editor = make_editor();
3294 editor.set_text("- 你好".to_string());
3295 {
3296 let ta = get_ta(&mut editor);
3297 ta.move_cursor(ratatui_textarea::CursorMove::End);
3298 }
3299 assert!(editor.smart_enter());
3300 assert_eq!(editor.get_text(), "- 你好\n- ");
3301 }
3302
3303 #[test]
3304 fn smart_enter_preserves_tab_indent() {
3305 let mut editor = make_editor();
3306 editor.set_text("\tbody".to_string());
3307 {
3308 let ta = get_ta(&mut editor);
3309 ta.move_cursor(ratatui_textarea::CursorMove::End);
3310 }
3311 assert!(editor.smart_enter());
3312 assert_eq!(editor.get_text(), "\tbody\n\t");
3313 }
3314
3315 #[test]
3316 fn smart_enter_on_tab_only_line_dedents() {
3317 let mut editor = make_editor();
3318 editor.set_text("\t\t".to_string());
3319 {
3320 let ta = get_ta(&mut editor);
3321 ta.move_cursor(ratatui_textarea::CursorMove::End);
3322 }
3323 assert!(editor.smart_enter());
3324 assert_eq!(editor.get_text(), "\t");
3326 }
3327
3328 #[test]
3329 fn smart_enter_continues_indented_list() {
3330 let mut editor = make_editor();
3331 editor.set_text(" - foo".to_string());
3332 {
3333 let ta = get_ta(&mut editor);
3334 ta.move_cursor(ratatui_textarea::CursorMove::End);
3335 }
3336 assert!(editor.smart_enter());
3337 assert_eq!(editor.get_text(), " - foo\n - ");
3338 }
3339
3340 #[test]
3341 fn unsupported_text_action_is_noop() {
3342 let mut editor = make_editor();
3343 editor.set_text("hello".to_string());
3344 editor.apply_text_action(TextAction::Underline);
3345 assert_eq!(editor.get_text(), "hello");
3346 }
3347
3348 #[test]
3349 fn textarea_hint_shortcuts_has_no_mode_indicator() {
3350 let editor = make_editor();
3351 let hints = editor.hint_shortcuts();
3352 assert!(
3354 !hints
3355 .iter()
3356 .any(|(_, label)| label == "NORMAL" || label == "INSERT")
3357 );
3358 }
3359
3360 fn place_cursor_at_col(editor: &mut TextEditorComponent, col: usize) {
3364 let ta = get_ta(editor);
3365 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3366 for _ in 0..col {
3367 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3368 }
3369 }
3370
3371 #[test]
3372 fn link_at_cursor_returns_label_when_cursor_on_hashtag() {
3373 let mut editor = make_editor();
3374 editor.set_text("see #rust now".to_string());
3375 place_cursor_at_col(&mut editor, 5);
3377 assert_eq!(
3378 editor.link_at_cursor(),
3379 Some(LinkTarget::Label("rust".into())),
3380 );
3381 }
3382
3383 #[test]
3384 fn link_at_cursor_returns_label_at_hash_char() {
3385 let mut editor = make_editor();
3386 editor.set_text("see #rust now".to_string());
3387 place_cursor_at_col(&mut editor, 4);
3389 assert_eq!(
3390 editor.link_at_cursor(),
3391 Some(LinkTarget::Label("rust".into())),
3392 );
3393 }
3394
3395 #[test]
3396 fn link_at_cursor_returns_none_outside_hashtag() {
3397 let mut editor = make_editor();
3398 editor.set_text("see #rust now".to_string());
3399 place_cursor_at_col(&mut editor, 0);
3401 assert_eq!(editor.link_at_cursor(), None);
3402 }
3403
3404 #[test]
3405 fn link_at_cursor_returns_note_for_wikilink() {
3406 let mut editor = make_editor();
3407 editor.set_text("open [[my note]] please".to_string());
3408 place_cursor_at_col(&mut editor, 7);
3410 let result = editor.link_at_cursor();
3411 assert!(
3412 matches!(result, Some(LinkTarget::Note(_))),
3413 "expected Note variant, got {result:?}"
3414 );
3415 }
3416
3417 #[test]
3420 fn link_at_cursor_returns_note_for_markdown_link_with_fragment() {
3421 let line = "[see docs](#section)";
3426 let mut editor = make_editor();
3427 editor.set_text(line.to_string());
3428 let cursor = "[see docs](#sec".chars().count(); place_cursor_at_col(&mut editor, cursor);
3431 let result = editor.link_at_cursor();
3432 assert!(
3433 matches!(result, Some(LinkTarget::Note(_))),
3434 "expected Note variant for markdown link fragment, got {result:?}"
3435 );
3436 }
3437
3438 #[test]
3439 fn vim_normal_i_then_typing_inserts_text() {
3440 let mut settings = crate::settings::AppSettings::default();
3441 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
3442 let mut editor = TextEditorComponent::new(KeyBindings::empty(), &settings);
3443 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3444 editor.handle_input(
3446 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3447 &tx,
3448 );
3449 assert_eq!(editor.get_text(), "");
3450 editor.handle_input(
3452 &InputEvent::Key(key(KeyCode::Char('i'), KeyModifiers::NONE)),
3453 &tx,
3454 );
3455 editor.handle_input(
3456 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3457 &tx,
3458 );
3459 assert_eq!(editor.get_text(), "x");
3460 }
3461
3462 fn make_vim_editor() -> TextEditorComponent {
3464 let mut settings = crate::settings::AppSettings::default();
3465 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
3466 TextEditorComponent::new(KeyBindings::empty(), &settings)
3467 }
3468
3469 fn vim_mode(editor: &TextEditorComponent) -> EditorMode {
3472 match &editor.backend {
3473 BackendState::Textarea(tb) => match &tb.input {
3474 backend::InputInterpreter::Vim(e) => e.mode().clone(),
3475 _ => panic!("expected Vim input interpreter"),
3476 },
3477 _ => panic!("expected Textarea backend"),
3478 }
3479 }
3480
3481 #[test]
3487 fn vim_visual_paste_url_wraps_whole_selected_word() {
3488 let mut editor = make_vim_editor();
3489 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3490 editor.set_text("hello world".to_string());
3491 editor.handle_input(
3494 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
3495 &tx,
3496 );
3497 editor.handle_input(
3498 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
3499 &tx,
3500 );
3501 assert_eq!(vim_mode(&editor), EditorMode::Visual);
3502 editor.paste_text("https://example.com", &tx);
3503 assert_eq!(
3504 editor.get_text(),
3505 "[hello](https://example.com) world",
3506 "the whole selected word (including the char under the cursor) must be wrapped"
3507 );
3508 }
3509
3510 #[test]
3516 fn vim_visual_bold_wraps_whole_selected_word() {
3517 let mut editor = make_vim_editor();
3518 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3519 editor.set_text("hello world".to_string());
3520 editor.handle_input(
3521 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
3522 &tx,
3523 );
3524 editor.handle_input(
3525 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
3526 &tx,
3527 );
3528 assert_eq!(vim_mode(&editor), EditorMode::Visual);
3529 editor.apply_text_action(TextAction::Bold);
3530 assert_eq!(
3531 editor.get_text(),
3532 "**hello** world",
3533 "the whole selected word (including the char under the cursor) must be wrapped"
3534 );
3535 }
3536
3537 #[test]
3543 fn vim_visual_copy_is_read_only_and_does_not_grow_selection() {
3544 let mut editor = make_vim_editor();
3545 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3546 editor.set_text("hello world".to_string());
3547 editor.handle_input(
3548 &InputEvent::Key(key(KeyCode::Char('v'), KeyModifiers::NONE)),
3549 &tx,
3550 );
3551 editor.handle_input(
3552 &InputEvent::Key(key(KeyCode::Char('e'), KeyModifiers::NONE)),
3553 &tx,
3554 );
3555 let before = get_ta(&mut editor).selection_range();
3556 assert_eq!(before, Some(((0, 0), (0, 4))));
3557 assert_eq!(
3559 editor.inclusive_visual_range(),
3560 Some(((0, 0), (0, 5))),
3561 "copy must read the inclusive range including the cursor char"
3562 );
3563 editor.copy_selection_to_clipboard();
3565 editor.copy_selection_to_clipboard();
3566 assert_eq!(
3567 get_ta(&mut editor).selection_range(),
3568 before,
3569 "copy must not move the cursor or grow the live selection"
3570 );
3571 }
3572
3573 #[test]
3583 fn vim_sync_collapsed_sel_stays_normal() {
3584 let mut editor = make_vim_editor();
3585 editor.set_text("hello world".to_string());
3586
3587 assert_eq!(vim_mode(&editor), EditorMode::Normal);
3589
3590 editor.backend.vim_sync_mouse_selection(false);
3593 assert_eq!(
3594 vim_mode(&editor),
3595 EditorMode::Normal,
3596 "collapsed (bare click) selection must not enter Visual mode"
3597 );
3598 }
3599
3600 #[test]
3602 fn vim_sync_real_sel_enters_visual() {
3603 let mut editor = make_vim_editor();
3604 editor.set_text("hello world".to_string());
3605
3606 assert_eq!(vim_mode(&editor), EditorMode::Normal);
3608
3609 editor.backend.vim_sync_mouse_selection(true);
3611 assert_eq!(
3612 vim_mode(&editor),
3613 EditorMode::Visual,
3614 "real drag selection must enter Visual mode"
3615 );
3616 }
3617
3618 #[test]
3622 fn vim_find_bar_captures_typing_not_cursor() {
3623 let mut editor = make_vim_editor();
3624 editor.set_text("hello world\nsecond line".to_string());
3625 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3626
3627 editor.open_or_advance_search();
3629 assert!(editor.search.is_some(), "find bar must be open");
3630
3631 editor.handle_input(
3633 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
3634 &tx,
3635 );
3636 editor.handle_input(
3637 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
3638 &tx,
3639 );
3640
3641 let q = editor
3645 .search
3646 .as_ref()
3647 .map(|s| s.input.value().to_string())
3648 .unwrap_or_default();
3649 assert_eq!(q, "lo", "find query must capture typed characters");
3650
3651 assert_eq!(
3654 editor.get_text(),
3655 "hello world\nsecond line",
3656 "buffer must not be modified while find bar is open"
3657 );
3658
3659 assert_eq!(
3665 editor.cursor_pos().1,
3666 3,
3667 "cursor must jump to the search match (col 3), not to a vim motion position"
3668 );
3669 }
3670
3671 #[test]
3674 fn vim_search_enter_confirms_and_n_navigates() {
3675 let mut editor = make_vim_editor();
3676 editor.set_text("lo xx lo yy lo".to_string());
3678 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3679
3680 editor.open_or_advance_search();
3682 assert!(editor.search.is_some(), "find bar must open");
3683
3684 editor.handle_input(
3686 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
3687 &tx,
3688 );
3689 editor.handle_input(
3690 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
3691 &tx,
3692 );
3693
3694 editor.handle_input(
3696 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3697 &tx,
3698 );
3699 assert!(
3700 editor.search.is_none(),
3701 "find bar must close after Enter in vim mode"
3702 );
3703
3704 editor.handle_input(
3708 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
3709 &tx,
3710 );
3711 let (_, c1) = editor.cursor_pos();
3712 assert_eq!(c1, 6, "'n' must jump to the 2nd 'lo' at col 6");
3713
3714 editor.handle_input(
3715 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
3716 &tx,
3717 );
3718 let (_, c2) = editor.cursor_pos();
3719 assert_eq!(c2, 12, "'n' must jump to the 3rd 'lo' at col 12");
3720
3721 assert_eq!(editor.get_text(), "lo xx lo yy lo");
3723 }
3724}