1pub mod autocomplete_glue;
2pub mod backend;
3pub mod markdown;
4pub mod nvim_rpc;
5pub mod parse_incremental;
6pub mod snapshot;
7pub mod view;
8mod vim;
9pub mod widener_metrics;
10pub mod word_wrap;
11
12use arboard::Clipboard;
13use ratatui::Frame;
14use ratatui::crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
15use ratatui::layout::Rect;
16use ratatui::style::{Modifier, Style};
17use ratatui::text::{Line, Span};
18use ratatui::widgets::Paragraph;
19use ratatui_textarea::{CursorMove, DataCursor, TextArea};
20use std::num::NonZeroU64;
21
22pub(crate) fn cursor_tuple(ta: &TextArea<'_>) -> (usize, usize) {
26 let DataCursor(r, c) = ta.cursor();
27 (r, c)
28}
29
30fn snapshot_from_backend(
37 backend: &BackendState,
38 content_revision: NonZeroU64,
39) -> EditorSnapshot<'_> {
40 match backend {
41 BackendState::Textarea(tb) => {
42 let cursor = cursor_tuple(&tb.ta);
43 EditorSnapshot::borrowed(tb.ta.lines(), cursor, content_revision)
44 }
45 BackendState::Nvim(nvim) => {
46 let snap = nvim.snapshot();
47 let lines_len = snap.lines.len();
48 let cursor_row = if lines_len == 0 {
49 0
50 } else {
51 snap.cursor.0.min(lines_len - 1)
52 };
53 let cursor = (cursor_row, snap.cursor.1);
54 let lines = snap.lines.clone();
55 let rev = NonZeroU64::new(snap.content_gen.saturating_add(1))
56 .unwrap_or_else(|| NonZeroU64::new(1).unwrap());
57 drop(snap);
58 EditorSnapshot::owned(lines, cursor, rev)
59 }
60 }
61}
62
63fn has_trigger_before_cursor(line: &str, col: usize) -> bool {
74 let cursor_byte = line
75 .char_indices()
76 .nth(col)
77 .map(|(b, _)| b)
78 .unwrap_or(line.len());
79 line[..cursor_byte]
80 .chars()
81 .rev()
82 .any(|c| c == '[' || c == '#')
83}
84
85macro_rules! cursor_move {
91 ($ta:expr, $mv:expr, $shift:expr) => {{
92 if $shift {
93 if $ta.selection_range().is_none() {
94 $ta.start_selection();
95 }
96 } else {
97 $ta.cancel_selection();
98 }
99 $ta.move_cursor($mv);
100 }};
101}
102
103use self::backend::BackendState;
104use self::markdown::ParsedBuffer;
105use self::snapshot::{EditorMode, EditorSnapshot};
106use self::view::MarkdownEditorView;
107use crate::util::single_slot_task::SingleSlotTask;
108
109fn increment_ordered_marker(marker: &str) -> Option<String> {
112 let trimmed = marker.trim_end_matches(' ');
113 let dot = trimmed.strip_suffix('.')?;
114 let n: u32 = dot.parse().ok()?;
115 Some(format!("{}. ", n + 1))
116}
117
118fn char_col_to_byte(line: &str, char_col: usize) -> usize {
121 line.char_indices()
122 .nth(char_col)
123 .map(|(b, _)| b)
124 .unwrap_or(line.len())
125}
126
127fn selection_text(ta: &TextArea<'_>) -> Option<String> {
133 let ((sr, sc), (er, ec)) = ta.selection_range()?;
134 if sr == er && sc == ec {
135 return None;
136 }
137 let lines = ta.lines();
138 Some(if sr == er {
139 let line = &lines[sr];
140 let sb = char_col_to_byte(line, sc);
141 let eb = char_col_to_byte(line, ec);
142 line[sb..eb].to_string()
143 } else {
144 let first = &lines[sr];
145 let sb = char_col_to_byte(first, sc);
146 let mut parts = vec![first[sb..].to_string()];
147 for line in &lines[(sr + 1)..er] {
148 parts.push(line.clone());
149 }
150 let last = &lines[er];
151 let eb = char_col_to_byte(last, ec);
152 parts.push(last[..eb].to_string());
153 parts.join("\n")
154 })
155}
156
157fn surround_pair(c: char) -> Option<(&'static str, &'static str)> {
162 match c {
163 '(' => Some(("(", ")")),
164 '[' => Some(("[", "]")),
165 '{' => Some(("{", "}")),
166 '<' => Some(("<", ">")),
167 '"' => Some(("\"", "\"")),
168 '\'' => Some(("'", "'")),
169 '`' => Some(("`", "`")),
170 '*' => Some(("*", "*")),
171 '_' => Some(("_", "_")),
172 '~' => Some(("~", "~")),
173 _ => None,
174 }
175}
176
177fn set_selection(ta: &mut TextArea<'_>, start: (usize, usize), end: (usize, usize)) {
181 let jump = |(row, col): (usize, usize)| {
182 CursorMove::Jump(
183 u16::try_from(row).unwrap_or(u16::MAX),
184 u16::try_from(col).unwrap_or(u16::MAX),
185 )
186 };
187 ta.cancel_selection();
188 ta.move_cursor(jump(start));
189 ta.start_selection();
190 ta.move_cursor(jump(end));
191}
192
193#[derive(Debug, Clone)]
197pub struct ClipboardImage {
198 pub width: usize,
199 pub height: usize,
200 pub rgba: Vec<u8>,
201}
202
203const LINKABLE_PASTE_SCHEMES: &[&str] = &["http", "https", "ftp", "ftps", "mailto"];
207
208fn linkable_url(s: &str) -> Option<&str> {
209 kimun_core::note::scan::url_with_allowed_scheme(s, LINKABLE_PASTE_SCHEMES)
210}
211
212fn try_build_markdown_link(clip: &str, selection: Option<&str>) -> Option<String> {
216 let url = linkable_url(clip)?;
217 let sel = selection.filter(|s| !s.is_empty())?;
218 let escaped = sel.replace('\\', r"\\").replace(']', r"\]");
219 Some(format!("[{escaped}]({url})"))
220}
221
222use std::sync::Arc;
223
224use kimun_core::NoteVault;
225
226use crate::components::Component;
227use crate::components::autocomplete::{
228 self, AutocompleteController, AutocompleteHost, AutocompleteMode, HandleKeyOutcome,
229};
230use crate::components::event_state::EventState;
231use crate::components::events::AppEvent;
232use crate::components::events::AppTx;
233use crate::components::events::InputEvent;
234use crate::components::events::redraw_callback;
235use crate::components::single_line_input::{InputOutcome, SingleLineInput};
236use crate::components::text_editor::autocomplete_glue::apply_accept_to_textarea;
237use crate::keys::KeyBindings;
238use crate::keys::action_shortcuts::TextAction;
239use crate::settings::AppSettings;
240use crate::settings::themes::Theme;
241
242#[derive(Debug, Clone, PartialEq)]
244pub enum LinkTarget {
245 Note(String),
247 Label(String),
249}
250
251struct SearchState {
252 input: SingleLineInput,
253 status: SearchStatus,
254}
255
256enum SearchStatus {
257 Empty,
258 Match,
259 NoMatch,
260 Invalid(String),
261}
262
263impl SearchStatus {
264 fn from_found(found: bool) -> Self {
265 if found { Self::Match } else { Self::NoMatch }
266 }
267}
268
269const FIND_PROMPT: &str = "Find: ";
270const FIND_HINTS: &str = " [Enter] next [Shift+Enter] prev [Esc] close";
271
272fn render_search_bar(
273 f: &mut Frame,
274 rect: Rect,
275 state: &mut SearchState,
276 theme: &Theme,
277 focused: bool,
278) {
279 let base = theme.base_style();
280 let muted = Style::default()
281 .fg(theme.gray.to_ratatui())
282 .bg(theme.bg.to_ratatui());
283 let err = Style::default()
284 .fg(theme.red.to_ratatui())
285 .bg(theme.bg.to_ratatui());
286 let prompt_cols = unicode_width::UnicodeWidthStr::width(FIND_PROMPT) as u16;
287 let value_total_cols = state.input.display_width() as u16;
291 let tail: Option<(String, Style)> = match &state.status {
292 SearchStatus::Empty => None,
293 SearchStatus::Match => Some((FIND_HINTS.to_string(), muted)),
294 SearchStatus::NoMatch => Some((" no match".to_string(), err)),
295 SearchStatus::Invalid(msg) => Some((format!(" invalid regex: {msg}"), err)),
296 };
297 f.render_widget(
298 Paragraph::new(Line::from(Span::styled(
299 FIND_PROMPT,
300 base.add_modifier(Modifier::BOLD),
301 )))
302 .style(base),
303 Rect {
304 width: prompt_cols.min(rect.width),
305 ..rect
306 },
307 );
308 state.input.render(f, rect, base, prompt_cols, focused);
309 if let Some((text, style)) = tail {
310 let consumed = prompt_cols.saturating_add(value_total_cols);
311 let tail_rect = Rect {
312 x: rect.x.saturating_add(consumed),
313 width: rect.width.saturating_sub(consumed),
314 ..rect
315 };
316 f.render_widget(Paragraph::new(text).style(style), tail_rect);
317 }
318}
319
320struct EditorHostSnapshot<'a> {
327 snap: EditorSnapshot<'a>,
328 cursor_screen: Option<(u16, u16)>,
329 cache_key: Option<NonZeroU64>,
330}
331
332impl<'a> AutocompleteHost for EditorHostSnapshot<'a> {
333 fn buffer_snapshot(&self) -> EditorSnapshot<'_> {
334 EditorSnapshot::borrowed(
339 self.snap.lines.as_ref(),
340 self.snap.cursor,
341 self.snap.content_revision,
342 )
343 }
344 fn cache_key(&self) -> Option<NonZeroU64> {
345 self.cache_key
346 }
347 fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
348 Some(self.cursor_screen.unwrap_or((0, 0)))
362 }
363}
364
365fn build_editor_host_snapshot<'a>(
371 backend: &'a BackendState,
372 content_revision: NonZeroU64,
373 cursor_screen: Option<(u16, u16)>,
374) -> Option<EditorHostSnapshot<'a>> {
375 if !backend.is_textarea() {
376 return None;
377 }
378 Some(EditorHostSnapshot {
379 snap: snapshot_from_backend(backend, content_revision),
380 cursor_screen,
381 cache_key: Some(content_revision),
382 })
383}
384
385pub struct TextEditorComponent {
389 backend: BackendState,
390 rect: Rect,
392 key_bindings: KeyBindings,
393 saved_content_rev: Option<NonZeroU64>,
402 view: MarkdownEditorView,
403 edit_generation: u64,
408 content_revision: NonZeroU64,
431 selection: Option<((usize, usize), (usize, usize))>,
434 clipboard: Option<Clipboard>,
436 nvim_pending_z: bool,
439 search: Option<SearchState>,
441 autocomplete: Option<AutocompleteController>,
445 autocomplete_vault: Option<Arc<NoteVault>>,
449 autocomplete_redraw_bound: bool,
454 full_parse_task: SingleSlotTask<()>,
461 pub wants_context_menu: bool,
464 search_needles: Vec<String>,
468 needles_revision: Option<NonZeroU64>,
470 full_parse_tx: tokio::sync::mpsc::UnboundedSender<(u64, ParsedBuffer)>,
471 full_parse_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, ParsedBuffer)>,
472 redraw_tx: Option<AppTx>,
476}
477
478impl TextEditorComponent {
479 pub fn new(key_bindings: KeyBindings, settings: &AppSettings) -> Self {
480 let (full_parse_tx, full_parse_rx) = tokio::sync::mpsc::unbounded_channel();
481 Self {
482 backend: BackendState::from_settings(
483 &settings.editor_backend,
484 settings.nvim_path.as_ref(),
485 ),
486 rect: Rect::default(),
487 key_bindings,
488 saved_content_rev: NonZeroU64::new(1),
489 view: MarkdownEditorView::new(),
490 edit_generation: 0,
491 content_revision: NonZeroU64::new(1).unwrap(),
492 selection: None,
493 clipboard: Clipboard::new().ok(),
494 nvim_pending_z: false,
495 search: None,
496 autocomplete: None,
497 autocomplete_vault: None,
498 autocomplete_redraw_bound: false,
499 full_parse_task: SingleSlotTask::empty(),
500 wants_context_menu: false,
501 search_needles: Vec::new(),
502 needles_revision: None,
503 full_parse_tx,
504 full_parse_rx,
505 redraw_tx: None,
506 }
507 }
508
509 pub fn set_vault(&mut self, vault: Arc<NoteVault>) {
514 self.autocomplete_vault = Some(vault.clone());
515 if self.backend.is_textarea() {
516 self.autocomplete = Some(AutocompleteController::new(
517 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
518 AutocompleteMode::Both,
519 ));
520 }
521 }
522
523 fn ensure_autocomplete_for_textarea(&mut self) {
528 if self.autocomplete.is_some() {
529 return;
530 }
531 if !self.backend.is_textarea() {
532 return;
533 }
534 let Some(vault) = self.autocomplete_vault.clone() else {
535 return;
536 };
537 self.autocomplete = Some(AutocompleteController::new(
538 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
539 AutocompleteMode::Both,
540 ));
541 self.autocomplete_redraw_bound = false;
544 }
545
546 #[allow(dead_code)]
553 fn autocomplete_host_snapshot(&self) -> Option<EditorHostSnapshot<'_>> {
554 build_editor_host_snapshot(
555 &self.backend,
556 self.content_revision,
557 self.view.last_cursor_screen,
558 )
559 }
560
561 fn poll_autocomplete(&mut self) {
564 if let Some(controller) = self.autocomplete.as_mut() {
565 controller.poll_results();
566 }
567 }
568
569 fn textarea_cursor(&self) -> Option<(usize, usize)> {
573 let ta = self.backend.as_textarea()?;
574 Some(cursor_tuple(ta))
575 }
576
577 fn refresh_autocomplete_if_open(&mut self) {
578 if !self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
580 return;
581 }
582 let Some(snapshot) = build_editor_host_snapshot(
586 &self.backend,
587 self.content_revision,
588 self.view.last_cursor_screen,
589 ) else {
590 self.close_autocomplete();
591 return;
592 };
593 if let Some(controller) = self.autocomplete.as_mut() {
594 controller.refresh_if_open(&snapshot);
595 }
596 }
597
598 fn sync_autocomplete(&mut self) {
602 let Some(controller) = self.autocomplete.as_ref() else {
603 return; };
605
606 if !controller.is_open() {
619 let Some(ta) = self.backend.as_textarea() else {
620 return;
621 };
622 let (row, col) = cursor_tuple(ta);
623 let line = ta.lines().get(row).map(|s| s.as_str()).unwrap_or("");
624 if !has_trigger_before_cursor(line, col) {
625 return;
626 }
627 }
628
629 let Some(snapshot) = build_editor_host_snapshot(
633 &self.backend,
634 self.content_revision,
635 self.view.last_cursor_screen,
636 ) else {
637 if let Some(c) = self.autocomplete.as_mut() {
638 c.close();
639 }
640 return;
641 };
642 if let Some(controller) = self.autocomplete.as_mut() {
643 controller.sync(&snapshot);
644 }
645 }
646
647 pub fn lines(&self) -> &[String] {
653 match &self.backend {
654 BackendState::Textarea(tb) => tb.ta.lines(),
655 BackendState::Nvim(_) => &[],
656 }
657 }
658
659 pub fn view_snapshot(&self) -> EditorSnapshot<'_> {
678 snapshot_from_backend(&self.backend, self.content_revision)
679 }
680
681 pub fn cursor_pos(&self) -> (usize, usize) {
685 self.backend.cursor()
686 }
687
688 pub fn set_search_needles(&mut self, needles: Vec<String>) {
692 self.search_needles = needles
693 .into_iter()
694 .map(|n| n.to_lowercase())
695 .filter(|n| !n.is_empty())
696 .collect();
697 self.needles_revision = Some(self.content_revision);
698 }
699
700 pub fn set_text(&mut self, text: String) {
701 if text == self.get_text() {
708 self.saved_content_rev = Some(self.content_revision);
709 if let Some(nvim) = self.backend.as_nvim() {
710 nvim.mark_clean();
711 }
712 return;
713 }
714 match &mut self.backend {
715 BackendState::Textarea(tb) => {
716 let lines = text.lines();
717 tb.ta = TextArea::from(lines);
718 }
719 BackendState::Nvim(nvim) => {
720 nvim.set_text(&text);
721 }
722 }
723 self.backend.vim_reset_to_normal();
724 self.bump_content();
725 let reconstructed = self.get_text();
726 self.mark_saved(reconstructed);
727 self.close_autocomplete();
730 }
731
732 pub fn get_text(&self) -> String {
733 self.backend.text()
734 }
735
736 pub fn content_revision(&self) -> NonZeroU64 {
743 self.content_revision
744 }
745
746 pub fn mark_saved_at_revision(&mut self, rev: NonZeroU64) {
756 if rev != self.content_revision {
757 return;
758 }
759 if let Some(nvim) = self.backend.as_nvim() {
760 nvim.mark_clean();
761 }
762 self.saved_content_rev = Some(rev);
763 }
764
765 pub fn mark_saved(&mut self, text: String) {
773 let matches = text == self.get_text();
774 if matches {
775 if let Some(nvim) = self.backend.as_nvim() {
776 nvim.mark_clean();
777 }
778 self.saved_content_rev = Some(self.content_revision);
779 } else {
780 self.saved_content_rev = None;
785 }
786 }
787
788 pub fn is_dirty(&self) -> bool {
789 match &self.backend {
790 BackendState::Textarea(_) => self.saved_content_rev != Some(self.content_revision),
791 BackendState::Nvim(nvim) => nvim.snapshot().dirty,
792 }
793 }
794
795 pub fn vim_space_leads(&self) -> bool {
799 self.backend.vim_space_leads()
800 }
801
802 pub fn link_at_cursor(&self) -> Option<LinkTarget> {
805 let (_row, col, line) = match &self.backend {
806 BackendState::Textarea(tb) => {
807 let (row, col) = cursor_tuple(&tb.ta);
808 let line = tb.ta.lines().get(row)?.to_string();
809 (row, col, line)
810 }
811 BackendState::Nvim(nvim) => {
812 let snap = nvim.snapshot();
813 let (row, col) = snap.cursor;
814 let line = snap.lines.get(row)?.to_string();
815 (row, col, line)
816 }
817 };
818
819 if let Some(span) = kimun_core::note::scan::link_char_spans(&line)
822 .into_iter()
823 .find(|s| s.start <= col && col < s.end)
824 {
825 return Some(LinkTarget::Note(span.target));
826 }
827
828 let parsed = self::markdown::ParsedLine::parse(&line);
830 parsed
831 .elements
832 .iter()
833 .find(|e| {
834 e.kind == self::markdown::ElementKind::Label
835 && col >= e.start_char
836 && col < e.end_char
837 })
838 .map(|e| {
839 let span: String = line
840 .chars()
841 .skip(e.start_char)
842 .take(e.end_char - e.start_char)
843 .collect();
844 let name = span.trim_start_matches('#').to_string();
845 LinkTarget::Label(name)
846 })
847 }
848
849 fn copy_selection_to_clipboard(&mut self) {
851 let text = {
852 let Some(ta) = self.backend.as_textarea() else {
853 return;
854 };
855 match selection_text(ta) {
856 Some(t) => t,
857 None => return,
858 }
859 };
860 if let Some(cb) = &mut self.clipboard {
861 let _ = cb.set_text(text);
862 }
863 }
864
865 fn paste_from_clipboard(&mut self, tx: &AppTx) {
867 let text = match &mut self.clipboard {
868 Some(cb) => match cb.get_text() {
869 Ok(t) if !t.is_empty() => t,
870 _ => return,
871 },
872 None => return,
873 };
874 self.paste_text(&text, tx);
875 }
876
877 pub fn paste_text(&mut self, text: &str, tx: &AppTx) {
886 if text.is_empty() {
887 return;
888 }
889 match &mut self.backend {
890 BackendState::Textarea(tb) => {
891 let selection = linkable_url(text).and_then(|_| selection_text(&tb.ta));
892 let wrapped = try_build_markdown_link(text, selection.as_deref());
893 if tb.ta.selection_range().is_some() {
894 tb.ta.cut();
895 }
896 tb.ta.insert_str(wrapped.as_deref().unwrap_or(text));
897 self.selection = tb.ta.selection_range();
898 self.bump_content();
899 }
900 BackendState::Nvim(nvim) => {
901 nvim.paste(text, tx.clone());
902 self.bump_content();
903 }
904 }
905 self.bind_autocomplete_redraw(tx);
909 self.sync_autocomplete();
910 }
911
912 pub fn insert_at_cursor(&mut self, text: &str, tx: &AppTx) {
917 if matches!(self.backend, BackendState::Nvim(_)) {
918 self.paste_text(text, tx);
919 return;
920 }
921 if let Some(ta) = self.backend.as_textarea_mut() {
922 if ta.selection_range().is_some() {
923 ta.cut();
924 }
925 ta.insert_str(text);
926 self.selection = ta.selection_range();
927 self.bump_content();
928 }
929 self.bind_autocomplete_redraw(tx);
932 self.sync_autocomplete();
933 }
934
935 pub fn take_clipboard_image(&mut self) -> Option<ClipboardImage> {
939 let cb = self.clipboard.as_mut()?;
940 let img = cb.get_image().ok()?;
941 Some(ClipboardImage {
942 width: img.width,
943 height: img.height,
944 rgba: img.bytes.into_owned(),
945 })
946 }
947
948 fn wrap_selection(&mut self, open: &str, close: &str) -> bool {
954 let Some(ta) = self.backend.as_textarea_mut() else {
955 return false;
956 };
957 let Some(((sr, sc), (er, ec))) = ta.selection_range() else {
958 return false;
959 };
960 let Some(text) = selection_text(ta) else {
961 return false;
962 };
963 ta.insert_str(format!("{open}{text}{close}"));
964 let shift = open.chars().count();
968 let inner_end_col = if sr == er { ec + shift } else { ec };
969 set_selection(ta, (sr, sc + shift), (er, inner_end_col));
970 self.selection = ta.selection_range();
971 self.bump_content();
972 true
973 }
974
975 pub fn apply_text_action(&mut self, action: TextAction) {
978 let marker = match action {
979 TextAction::Bold => "**",
980 TextAction::Italic => "*",
981 TextAction::Strikethrough => "~~",
982 _ => return,
983 };
984 if self.wrap_selection(marker, marker) {
985 return;
986 }
987 let Some(ta) = self.backend.as_textarea_mut() else {
988 return;
989 };
990 ta.insert_str(format!("{marker}{marker}"));
991 for _ in 0..marker.len() {
992 ta.move_cursor(CursorMove::Back);
993 }
994 self.selection = ta.selection_range();
995 self.bump_content();
996 }
997
998 pub fn smart_enter(&mut self) -> bool {
1003 enum Action {
1004 ClearLine { chars: usize },
1005 InsertPrefix(String),
1006 Dedent,
1007 }
1008 let action = {
1009 let Some(ta) = self.backend.as_textarea() else {
1010 return false;
1011 };
1012 if ta
1015 .selection_range()
1016 .is_some_and(|(start, end)| start != end)
1017 {
1018 return false;
1019 }
1020 let (row, col) = cursor_tuple(ta);
1021 let Some(line) = ta.lines().get(row) else {
1022 return false;
1023 };
1024 let total_chars = line.chars().count();
1025 if col != total_chars {
1026 return false;
1027 }
1028 let ws_end = markdown::leading_ws_byte_len(line);
1030 let (ws, after_ws) = line.split_at(ws_end);
1031 if let Some(marker_len) = markdown::list_marker_len(after_ws) {
1032 if after_ws.len() == marker_len {
1033 if ws_end > 0 {
1036 Action::Dedent
1037 } else {
1038 Action::ClearLine { chars: total_chars }
1039 }
1040 } else {
1041 let marker_str = &after_ws[..marker_len];
1042 let next_marker = increment_ordered_marker(marker_str)
1043 .unwrap_or_else(|| marker_str.to_string());
1044 Action::InsertPrefix(format!("{ws}{next_marker}"))
1045 }
1046 } else if ws_end > 0 && total_chars == ws_end {
1047 Action::Dedent
1048 } else if ws_end > 0 {
1049 Action::InsertPrefix(ws.to_string())
1050 } else {
1051 return false;
1052 }
1053 };
1054
1055 match action {
1056 Action::Dedent => {
1057 self.indent_lines(true);
1058 return true;
1059 }
1060 Action::ClearLine { chars } => {
1061 let Some(ta) = self.backend.as_textarea_mut() else {
1062 unreachable!()
1063 };
1064 ta.move_cursor(CursorMove::Head);
1065 ta.delete_str(chars);
1066 }
1067 Action::InsertPrefix(prefix) => {
1068 let Some(ta) = self.backend.as_textarea_mut() else {
1069 unreachable!()
1070 };
1071 ta.insert_newline();
1072 ta.insert_str(prefix);
1073 }
1074 }
1075 let Some(ta) = self.backend.as_textarea() else {
1076 unreachable!()
1077 };
1078 self.selection = ta.selection_range();
1079 self.bump_content();
1080 true
1081 }
1082
1083 pub fn jump_to_heading(&mut self, heading: &str) {
1088 let Some(ta) = self.backend.as_textarea_mut() else {
1089 return;
1090 };
1091 fn normalise(text: &str) -> String {
1096 text.trim()
1097 .trim_end_matches('#')
1098 .trim()
1099 .replace(['*', '_', '`'], "")
1100 }
1101 let wanted = normalise(heading);
1102 let row = ta.lines().iter().position(|l| {
1103 let t = l.trim_start();
1104 let stripped = t.trim_start_matches('#');
1105 stripped.len() != t.len() && normalise(stripped) == wanted
1106 });
1107 if let Some(row) = row {
1108 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1109 self.bump_cursor();
1110 }
1111 }
1112
1113 pub fn indent_lines(&mut self, dedent: bool) {
1117 let Some(ta) = self.backend.as_textarea_mut() else {
1118 return;
1119 };
1120 let tab_len = ta.tab_length() as usize;
1121 let hard_tab = ta.hard_tab_indent();
1122 let indent: String = if hard_tab {
1123 "\t".to_string()
1124 } else {
1125 " ".repeat(tab_len)
1126 };
1127 if indent.is_empty() {
1128 return;
1129 }
1130 let indent_chars = indent.len();
1131
1132 let sel = ta.selection_range();
1133 let saved_cursor = if sel.is_none() {
1134 Some(cursor_tuple(ta))
1135 } else {
1136 None
1137 };
1138 let (start_row, end_row) = match sel {
1139 Some(((sr, _), (er, ec))) => {
1140 let last = if ec == 0 && er > sr { er - 1 } else { er };
1143 (sr, last)
1144 }
1145 None => {
1146 let (r, _) = saved_cursor.unwrap();
1147 (r, r)
1148 }
1149 };
1150
1151 let row_count = end_row.saturating_sub(start_row) + 1;
1152 let mut row_deltas: Vec<isize> = Vec::with_capacity(row_count);
1153 let mut any_change = false;
1154
1155 ta.cancel_selection();
1160
1161 for row in start_row..=end_row {
1162 if dedent {
1163 let count = {
1164 let line = ta.lines().get(row).map(|s| s.as_str()).unwrap_or("");
1165 let max_remove = if hard_tab { 1 } else { tab_len };
1166 let mut count = 0usize;
1167 for (i, c) in line.chars().enumerate() {
1168 if i >= max_remove {
1169 break;
1170 }
1171 if c == '\t' {
1172 count += 1;
1173 break;
1174 } else if c == ' ' && !hard_tab {
1175 count += 1;
1176 } else {
1177 break;
1178 }
1179 }
1180 count
1181 };
1182 if count > 0 {
1183 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1184 ta.delete_str(count);
1185 any_change = true;
1186 }
1187 row_deltas.push(-(count as isize));
1188 } else {
1189 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1190 ta.insert_str(&indent);
1191 row_deltas.push(indent_chars as isize);
1192 any_change = true;
1193 }
1194 }
1195
1196 let adj = |row: usize, col: usize| -> usize {
1197 if row >= start_row && row <= end_row {
1198 let d = row_deltas[row - start_row];
1199 if d >= 0 {
1200 col + d as usize
1201 } else {
1202 col.saturating_sub((-d) as usize)
1203 }
1204 } else {
1205 col
1206 }
1207 };
1208
1209 match sel {
1210 Some(((ssr, ssc), (ser, sec))) => {
1211 set_selection(ta, (ssr, adj(ssr, ssc)), (ser, adj(ser, sec)));
1212 }
1213 None => {
1214 let (cr, cc) = saved_cursor.expect("captured when sel is None");
1215 let new_col = adj(cr, cc);
1216 ta.move_cursor(CursorMove::Jump(cr as u16, new_col as u16));
1217 }
1218 }
1219
1220 if any_change {
1221 self.selection = ta.selection_range();
1222 self.bump_content();
1223 }
1224 }
1225}
1226
1227impl TextEditorComponent {
1228 #[inline]
1233 fn bump_cursor(&mut self) {
1234 self.edit_generation = self.edit_generation.wrapping_add(1);
1235 }
1236
1237 #[inline]
1247 fn bump_content(&mut self) {
1248 self.edit_generation = self.edit_generation.wrapping_add(1);
1249 let next = self.content_revision.get().wrapping_add(1);
1254 self.content_revision = NonZeroU64::new(next).unwrap_or(NonZeroU64::new(1).unwrap());
1255 }
1256
1257 fn maybe_recover_from_dead_nvim(&mut self) {
1259 if self.backend.recover_from_dead_nvim() {
1260 self.ensure_autocomplete_for_textarea();
1264 }
1265 }
1266
1267 fn handle_nvim_key(
1272 &mut self,
1273 key: &ratatui::crossterm::event::KeyEvent,
1274 tx: &AppTx,
1275 ) -> Option<EventState> {
1276 let nvim = self.backend.as_nvim()?;
1277
1278 if self.nvim_pending_z {
1284 self.nvim_pending_z = false;
1285 match key.code {
1286 KeyCode::Char('Z') => {
1287 tx.send(AppEvent::Autosave).ok();
1289 tx.send(AppEvent::FocusSidebar).ok();
1290 return Some(EventState::Consumed);
1291 }
1292 KeyCode::Char('Q') => {
1293 tx.send(AppEvent::FocusSidebar).ok();
1295 return Some(EventState::Consumed);
1296 }
1297 _ => {
1298 nvim.handle_key(
1300 &ratatui::crossterm::event::KeyEvent::new(
1301 KeyCode::Char('Z'),
1302 KeyModifiers::NONE,
1303 ),
1304 tx.clone(),
1305 );
1306 }
1308 }
1309 } else if key.code == KeyCode::Char('Z') {
1310 let in_normal = {
1311 let snap = nvim.snapshot();
1312 snap.mode == EditorMode::Normal
1313 };
1314 if in_normal {
1315 self.nvim_pending_z = true;
1316 return Some(EventState::Consumed);
1317 }
1318 }
1319
1320 if key.code == KeyCode::Enter {
1323 let (is_cmd, cmdline) = {
1324 let snap = nvim.snapshot();
1325 let cmd = if snap.mode == EditorMode::Command {
1326 snap.cmdline
1327 .as_deref()
1328 .unwrap_or("")
1329 .trim_start_matches(':')
1330 .to_string()
1331 } else {
1332 String::new()
1333 };
1334 (snap.mode == EditorMode::Command, cmd)
1335 };
1336 if is_cmd {
1337 let saves = matches!(
1338 cmdline.as_str(),
1339 "w" | "wq" | "wq!" | "wqa" | "wqa!" | "x" | "xa" | "x!"
1340 );
1341 let quits =
1342 saves || matches!(cmdline.as_str(), "q" | "q!" | "qa" | "qa!" | "cq" | "cq!");
1343 if quits {
1344 nvim.handle_key(
1345 &ratatui::crossterm::event::KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
1346 tx.clone(),
1347 );
1348 if saves {
1349 tx.send(AppEvent::Autosave).ok();
1350 }
1351 tx.send(AppEvent::FocusSidebar).ok();
1352 return Some(EventState::Consumed);
1353 }
1354 }
1355 }
1356
1357 nvim.handle_key(key, tx.clone());
1358 self.bump_cursor();
1368 Some(EventState::Consumed)
1369 }
1370
1371 pub fn open_or_advance_search(&mut self) {
1375 if !self.backend.is_textarea() {
1376 return;
1377 }
1378 if self.search.is_some() {
1379 self.search_advance(false);
1380 return;
1381 }
1382 self.close_autocomplete();
1386 self.search = Some(SearchState {
1387 input: SingleLineInput::new(),
1388 status: SearchStatus::Empty,
1389 });
1390 }
1391
1392 pub fn close_autocomplete(&mut self) {
1396 if let Some(c) = self.autocomplete.as_mut() {
1397 c.close();
1398 }
1399 }
1400
1401 pub fn set_redraw_tx(&mut self, tx: &AppTx) {
1406 self.bind_autocomplete_redraw(tx);
1407 }
1408
1409 fn bind_autocomplete_redraw(&mut self, tx: &AppTx) {
1418 if self.redraw_tx.is_none() {
1419 self.redraw_tx = Some(tx.clone());
1420 }
1421 if self.autocomplete_redraw_bound {
1422 return;
1423 }
1424 if let Some(c) = self.autocomplete.as_mut() {
1425 c.set_redraw_callback(redraw_callback(tx.clone()));
1426 self.autocomplete_redraw_bound = true;
1427 }
1428 }
1429
1430 fn close_search(&mut self) {
1431 if let Some(ta) = self.backend.as_textarea_mut() {
1432 let _ = ta.set_search_pattern("");
1433 }
1434 self.search = None;
1435 self.selection = None;
1436 }
1437
1438 fn refresh_search_pattern(&mut self, jump: bool) {
1441 let Some(state) = self.search.as_mut() else {
1442 return;
1443 };
1444 let Some(ta) = self.backend.as_textarea_mut() else {
1445 return;
1446 };
1447 if state.input.is_empty() {
1448 let _ = ta.set_search_pattern("");
1449 state.status = SearchStatus::Empty;
1450 self.selection = None;
1451 return;
1452 }
1453 if let Err(e) = ta.set_search_pattern(state.input.value()) {
1454 state.status = SearchStatus::Invalid(e.to_string());
1455 self.selection = None;
1456 return;
1457 }
1458 if !jump {
1459 state.status = SearchStatus::Match;
1460 return;
1461 }
1462 let found = ta.search_forward(true);
1463 state.status = SearchStatus::from_found(found);
1464 self.highlight_current_match(found);
1465 }
1466
1467 fn search_advance(&mut self, backward: bool) {
1468 let Some(state) = self.search.as_mut() else {
1469 return;
1470 };
1471 if state.input.is_empty() {
1472 return;
1473 }
1474 let Some(ta) = self.backend.as_textarea_mut() else {
1475 return;
1476 };
1477 let found = if backward {
1478 ta.search_back(false)
1479 } else {
1480 ta.search_forward(false)
1481 };
1482 state.status = SearchStatus::from_found(found);
1483 self.highlight_current_match(found);
1484 }
1485
1486 fn highlight_current_match(&mut self, found: bool) {
1491 self.selection = if found {
1492 self.compute_match_selection()
1493 } else {
1494 None
1495 };
1496 }
1497
1498 fn compute_match_selection(&self) -> Option<((usize, usize), (usize, usize))> {
1504 let ta = self.backend.as_textarea()?;
1505 let re = ta.search_pattern()?;
1506 let DataCursor(row, col_chars) = ta.cursor();
1507 let line = ta.lines().get(row)?;
1508 let byte_off = char_col_to_byte(line, col_chars);
1509 let m = re.find_at(line, byte_off)?;
1510 if m.start() != byte_off {
1511 return None;
1512 }
1513 let match_chars = line[m.range()].chars().count();
1514 Some(((row, col_chars), (row, col_chars + match_chars)))
1515 }
1516
1517 fn handle_search_key(&mut self, key: &ratatui::crossterm::event::KeyEvent) -> bool {
1519 let Some(state) = self.search.as_mut() else {
1520 return false;
1521 };
1522 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1523 let outcome = state.input.handle_key(key);
1524 match outcome {
1525 InputOutcome::Cancel => self.close_search(),
1526 InputOutcome::Submit => {
1527 if self.backend.is_vim() {
1528 self.search = None;
1532 } else {
1533 self.search_advance(shift);
1534 }
1535 }
1536 InputOutcome::Changed => self.refresh_search_pattern(true),
1537 InputOutcome::Consumed | InputOutcome::NotConsumed => {}
1538 }
1539 true
1540 }
1541
1542 fn vim_search_repeat(&mut self, backward: bool) {
1545 let found = {
1546 let Some(ta) = self.backend.as_textarea_mut() else {
1547 return;
1548 };
1549 if backward {
1550 ta.search_back(false)
1551 } else {
1552 ta.search_forward(false)
1553 }
1554 };
1555 self.highlight_current_match(found);
1556 }
1557
1558 fn handle_textarea_key(
1560 &mut self,
1561 key: &ratatui::crossterm::event::KeyEvent,
1562 tx: &AppTx,
1563 ) -> EventState {
1564 if self.handle_search_key(key) {
1566 return EventState::Consumed;
1567 }
1568
1569 if key.modifiers == KeyModifiers::CONTROL {
1571 match key.code {
1572 KeyCode::Char('c') => {
1573 self.copy_selection_to_clipboard();
1574 return EventState::Consumed;
1575 }
1576 KeyCode::Char('v') => {
1577 self.paste_from_clipboard(tx);
1578 return EventState::Consumed;
1579 }
1580 KeyCode::Char('x') => {
1581 self.copy_selection_to_clipboard();
1582 let cut = if let Some(ta) = self.backend.as_textarea_mut() {
1583 let cut = ta.cut();
1589 self.selection = ta.selection_range();
1590 cut
1591 } else {
1592 false
1593 };
1594 if cut {
1595 self.bump_content();
1596 }
1597 return EventState::Consumed;
1598 }
1599 _ => {}
1600 }
1601 }
1602
1603 let Some(ta) = self.backend.as_textarea_mut() else {
1604 unreachable!("handle_textarea_key called with non-Textarea backend")
1605 };
1606
1607 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1609 let handled = match (key.modifiers & !KeyModifiers::SHIFT, key.code) {
1610 (KeyModifiers::ALT, KeyCode::Left) => {
1611 cursor_move!(ta, CursorMove::WordBack, shift);
1612 true
1613 }
1614 (KeyModifiers::ALT, KeyCode::Right) => {
1615 cursor_move!(ta, CursorMove::WordForward, shift);
1616 true
1617 }
1618 (KeyModifiers::ALT, KeyCode::Char('b') | KeyCode::Char('B')) => {
1623 cursor_move!(ta, CursorMove::WordBack, shift);
1624 true
1625 }
1626 (KeyModifiers::ALT, KeyCode::Char('f') | KeyCode::Char('F')) => {
1627 cursor_move!(ta, CursorMove::WordForward, shift);
1628 true
1629 }
1630 (KeyModifiers::SUPER, KeyCode::Left) => {
1631 cursor_move!(ta, CursorMove::Head, shift);
1632 true
1633 }
1634 (KeyModifiers::SUPER, KeyCode::Right) => {
1635 cursor_move!(ta, CursorMove::End, shift);
1636 true
1637 }
1638 (KeyModifiers::SUPER, KeyCode::Up) => {
1639 cursor_move!(ta, CursorMove::Top, shift);
1640 true
1641 }
1642 (KeyModifiers::SUPER, KeyCode::Down) => {
1643 cursor_move!(ta, CursorMove::Bottom, shift);
1644 true
1645 }
1646 _ => false,
1647 };
1648 if handled {
1649 self.selection = ta.selection_range();
1650 self.bump_cursor();
1651 return EventState::Consumed;
1652 }
1653
1654 enum ShortcutOutcome {
1665 NoOp,
1666 CursorOnly,
1667 TextMutated,
1668 }
1669 let outcome: Option<ShortcutOutcome> =
1670 match (key.modifiers & !KeyModifiers::SHIFT, key.code) {
1671 (KeyModifiers::NONE, KeyCode::Left) => {
1673 cursor_move!(ta, CursorMove::Back, shift);
1674 Some(ShortcutOutcome::CursorOnly)
1675 }
1676 (KeyModifiers::NONE, KeyCode::Right) => {
1677 cursor_move!(ta, CursorMove::Forward, shift);
1678 Some(ShortcutOutcome::CursorOnly)
1679 }
1680 (KeyModifiers::NONE, KeyCode::Up) => {
1681 cursor_move!(ta, CursorMove::Up, shift);
1682 Some(ShortcutOutcome::CursorOnly)
1683 }
1684 (KeyModifiers::NONE, KeyCode::Down) => {
1685 cursor_move!(ta, CursorMove::Down, shift);
1686 Some(ShortcutOutcome::CursorOnly)
1687 }
1688 (KeyModifiers::NONE, KeyCode::Home) => {
1689 cursor_move!(ta, CursorMove::Head, shift);
1690 Some(ShortcutOutcome::CursorOnly)
1691 }
1692 (KeyModifiers::NONE, KeyCode::End) => {
1693 cursor_move!(ta, CursorMove::End, shift);
1694 Some(ShortcutOutcome::CursorOnly)
1695 }
1696 (KeyModifiers::NONE, KeyCode::PageUp) => {
1697 cursor_move!(ta, CursorMove::ParagraphBack, shift);
1698 Some(ShortcutOutcome::CursorOnly)
1699 }
1700 (KeyModifiers::NONE, KeyCode::PageDown) => {
1701 cursor_move!(ta, CursorMove::ParagraphForward, shift);
1702 Some(ShortcutOutcome::CursorOnly)
1703 }
1704 (KeyModifiers::CONTROL, KeyCode::Left) => {
1706 cursor_move!(ta, CursorMove::WordBack, shift);
1707 Some(ShortcutOutcome::CursorOnly)
1708 }
1709 (KeyModifiers::CONTROL, KeyCode::Right) => {
1710 cursor_move!(ta, CursorMove::WordForward, shift);
1711 Some(ShortcutOutcome::CursorOnly)
1712 }
1713 (KeyModifiers::CONTROL, KeyCode::Home) => {
1715 cursor_move!(ta, CursorMove::Top, shift);
1716 Some(ShortcutOutcome::CursorOnly)
1717 }
1718 (KeyModifiers::CONTROL, KeyCode::End) => {
1719 cursor_move!(ta, CursorMove::Bottom, shift);
1720 Some(ShortcutOutcome::CursorOnly)
1721 }
1722 (KeyModifiers::CONTROL, KeyCode::Char('z')) => {
1726 if ta.undo() {
1727 Some(ShortcutOutcome::TextMutated)
1728 } else {
1729 Some(ShortcutOutcome::NoOp)
1730 }
1731 }
1732 (KeyModifiers::CONTROL, KeyCode::Char('y'))
1733 | (KeyModifiers::CONTROL, KeyCode::Char('Z')) => {
1734 if ta.redo() {
1735 Some(ShortcutOutcome::TextMutated)
1736 } else {
1737 Some(ShortcutOutcome::NoOp)
1738 }
1739 }
1740 (KeyModifiers::CONTROL, KeyCode::Char('a')) => {
1742 ta.move_cursor(CursorMove::Top);
1743 ta.start_selection();
1744 ta.move_cursor(CursorMove::Bottom);
1745 Some(ShortcutOutcome::CursorOnly)
1746 }
1747 (KeyModifiers::CONTROL, KeyCode::Backspace)
1750 | (KeyModifiers::ALT, KeyCode::Backspace) => {
1751 if ta.delete_word() {
1752 Some(ShortcutOutcome::TextMutated)
1753 } else {
1754 Some(ShortcutOutcome::NoOp)
1755 }
1756 }
1757 (KeyModifiers::CONTROL, KeyCode::Delete) | (KeyModifiers::ALT, KeyCode::Delete) => {
1758 if ta.delete_next_word() {
1759 Some(ShortcutOutcome::TextMutated)
1760 } else {
1761 Some(ShortcutOutcome::NoOp)
1762 }
1763 }
1764 _ => None,
1765 };
1766 if let Some(kind) = outcome {
1767 self.selection = ta.selection_range();
1768 match kind {
1769 ShortcutOutcome::NoOp => {}
1770 ShortcutOutcome::CursorOnly => self.bump_cursor(),
1771 ShortcutOutcome::TextMutated => self.bump_content(),
1772 }
1773 return EventState::Consumed;
1774 }
1775
1776 match (key.modifiers, key.code) {
1778 (m, KeyCode::Tab)
1779 if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
1780 {
1781 self.indent_lines(m.contains(KeyModifiers::SHIFT));
1782 return EventState::Consumed;
1783 }
1784 (_, KeyCode::BackTab) => {
1785 self.indent_lines(true);
1786 return EventState::Consumed;
1787 }
1788 _ => {}
1789 }
1790 if key.code == KeyCode::Enter && key.modifiers.is_empty() && self.smart_enter() {
1791 return EventState::Consumed;
1792 }
1793
1794 if let KeyCode::Char(c) = key.code
1801 && (key.modifiers & !KeyModifiers::SHIFT).is_empty()
1802 && let Some((open, close)) = surround_pair(c)
1803 && self.wrap_selection(open, close)
1804 {
1805 return EventState::Consumed;
1806 }
1807
1808 let Some(ta) = self.backend.as_textarea_mut() else {
1809 unreachable!("handle_textarea_key called with non-Textarea backend")
1810 };
1811 let mutated = ta.input_without_shortcuts(*key);
1817 self.selection = ta.selection_range();
1818 if mutated {
1819 self.bump_content();
1820 } else {
1821 self.bump_cursor();
1822 }
1823 EventState::Consumed
1824 }
1825
1826 fn handle_mouse(&mut self, mouse: &ratatui::crossterm::event::MouseEvent) -> EventState {
1828 let r = &self.rect;
1829 let in_bounds = mouse.column >= r.x
1830 && mouse.column < r.x + r.width
1831 && mouse.row >= r.y
1832 && mouse.row < r.y + r.height;
1833 if !in_bounds {
1834 return EventState::NotConsumed;
1835 }
1836 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right))
1840 && self.selection.is_none_or(|(start, end)| start == end)
1841 {
1842 self.wants_context_menu = true;
1843 return EventState::Consumed;
1844 }
1845 if !self.backend.is_textarea() {
1849 return EventState::NotConsumed;
1850 }
1851 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
1853 self.copy_selection_to_clipboard();
1854 self.selection = if let Some(ta) = self.backend.as_textarea() {
1855 ta.selection_range()
1856 } else {
1857 None
1858 };
1859 self.bump_cursor();
1860 return EventState::Consumed;
1861 }
1862 let Some(ta) = self.backend.as_textarea_mut() else {
1864 unreachable!()
1865 };
1866 match mouse.kind {
1867 MouseEventKind::Down(_) => {
1868 ta.cancel_selection();
1869 let (lrow, lcol) = self
1870 .view
1871 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1872 ta.move_cursor(CursorMove::Jump(lrow, lcol));
1873 ta.start_selection();
1874 }
1875 MouseEventKind::Drag(_) => {
1876 let (lrow, lcol) = self
1877 .view
1878 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1879 ta.move_cursor(CursorMove::Jump(lrow, lcol));
1880 }
1881 _ => {
1882 ta.input(*mouse);
1883 }
1884 }
1885 self.selection = ta.selection_range();
1886 self.bump_cursor();
1889 EventState::Consumed
1890 }
1891}
1892
1893fn paint_viewport_extras(
1898 buf: &mut ratatui::buffer::Buffer,
1899 area: Rect,
1900 needles: &[String],
1901 theme: &Theme,
1902) {
1903 use ratatui::layout::Position;
1904 let match_fg = theme.color_search_match.to_ratatui();
1905 let checkbox_fg = theme.accent.to_ratatui();
1906
1907 for y in area.y..area.bottom() {
1908 if needles.is_empty() {
1913 let mut lead = String::new();
1914 for x in area.x..area.right().min(area.x + 16) {
1915 if let Some(cell) = buf.cell(Position::new(x, y)) {
1916 lead.push_str(cell.symbol());
1917 }
1918 }
1919 if !lead.trim_start().starts_with("- [") {
1920 continue;
1921 }
1922 }
1923 let mut row_text = String::new();
1926 let mut byte_to_col: Vec<(usize, u16)> = Vec::new();
1927 for x in area.x..area.right() {
1928 let Some(cell) = buf.cell(Position::new(x, y)) else {
1929 continue;
1930 };
1931 let sym = cell.symbol();
1932 if sym.is_empty() {
1933 continue;
1934 }
1935 byte_to_col.push((row_text.len(), x));
1936 row_text.push_str(sym);
1937 }
1938 if row_text.trim().is_empty() {
1939 continue;
1940 }
1941 let lower = row_text.to_lowercase();
1942 let fold_safe = lower.len() == row_text.len();
1943
1944 let mut restyle =
1945 |from_byte: usize, to_byte: usize, f: &mut dyn FnMut(&mut ratatui::buffer::Cell)| {
1946 for (b, x) in &byte_to_col {
1947 if *b >= from_byte
1948 && *b < to_byte
1949 && let Some(cell) = buf.cell_mut(Position::new(*x, y))
1950 {
1951 f(cell);
1952 }
1953 }
1954 };
1955
1956 let trimmed_start = row_text.len() - row_text.trim_start().len();
1958 let after_indent = &row_text[trimmed_start..];
1959 let is_done = after_indent.starts_with("- [x] ") || after_indent.starts_with("- [X] ");
1960 let is_open = after_indent.starts_with("- [ ] ");
1961 if is_done || is_open {
1962 let box_start = trimmed_start + 2;
1963 let box_end = box_start + 3;
1964 restyle(box_start, box_end, &mut |cell| {
1965 cell.set_fg(checkbox_fg);
1966 });
1967 if is_done {
1968 restyle(box_end, row_text.len(), &mut |cell| {
1969 let style = cell
1970 .style()
1971 .add_modifier(Modifier::DIM | Modifier::CROSSED_OUT);
1972 cell.set_style(style);
1973 });
1974 }
1975 }
1976
1977 if fold_safe {
1979 for needle in needles {
1980 for (start, m) in lower.match_indices(needle.as_str()) {
1981 restyle(start, start + m.len(), &mut |cell| {
1982 let style = cell.style().fg(match_fg).add_modifier(Modifier::BOLD);
1983 cell.set_style(style);
1984 });
1985 }
1986 }
1987 }
1988 }
1989}
1990
1991impl Component for TextEditorComponent {
1992 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
1993 self.maybe_recover_from_dead_nvim();
1994 self.bind_autocomplete_redraw(tx);
1995
1996 match event {
1997 InputEvent::Key(key) => {
1998 let popup_open = self.autocomplete.as_ref().is_some_and(|c| c.is_open());
2006 if popup_open
2007 && let Some(host) = build_editor_host_snapshot(
2008 &self.backend,
2009 self.content_revision,
2010 self.view.last_cursor_screen,
2011 )
2012 && let Some(controller) = self.autocomplete.as_mut()
2013 {
2014 match controller.handle_key(*key, &host) {
2015 HandleKeyOutcome::Accepted(action) => {
2016 if let Some(ta) = self.backend.as_textarea_mut() {
2017 apply_accept_to_textarea(ta, &action);
2018 self.selection = ta.selection_range();
2019 }
2020 self.bump_content();
2021 return EventState::Consumed;
2022 }
2023 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
2024 return EventState::Consumed;
2025 }
2026 HandleKeyOutcome::NotHandled => {}
2027 }
2028 }
2029 if self.search.is_some() && self.handle_search_key(key) {
2034 return EventState::Consumed;
2035 }
2036 if let Some(outcome) = self.backend.vim_handle_key(key) {
2041 use self::vim::VimKeyOutcome;
2042 match outcome {
2043 VimKeyOutcome::TextMutated => {
2044 self.selection = None;
2045 self.bump_content();
2046 return EventState::Consumed;
2047 }
2048 VimKeyOutcome::CursorOnly => {
2049 self.selection = self
2054 .backend
2055 .as_textarea()
2056 .and_then(|ta| ta.selection_range());
2057 if self.backend.vim_is_charwise_visual()
2062 && let Some(((sr, sc), (er, ec))) = self.selection
2063 {
2064 let len = self
2065 .backend
2066 .as_textarea()
2067 .and_then(|ta| ta.lines().get(er))
2068 .map(|l| l.chars().count())
2069 .unwrap_or(ec);
2070 self.selection = Some(((sr, sc), (er, (ec + 1).min(len))));
2071 }
2072 self.refresh_autocomplete_if_open();
2073 self.edit_generation = self.edit_generation.wrapping_add(1);
2074 return EventState::Consumed;
2075 }
2076 VimKeyOutcome::NoOp => return EventState::Consumed,
2077 VimKeyOutcome::PassThrough => { }
2078 VimKeyOutcome::Host(action) => {
2079 use self::vim::VimHostAction;
2080 match action {
2081 VimHostAction::OpenPalette => {
2082 tx.send(AppEvent::ExecuteLeaderAction(
2084 crate::keys::leader::LeaderAction::Palette,
2085 ))
2086 .ok();
2087 }
2088 VimHostAction::OpenSearch { forward: _ } => {
2089 self.open_or_advance_search();
2093 }
2094 VimHostAction::SearchNext => self.vim_search_repeat(false),
2095 VimHostAction::SearchPrev => self.vim_search_repeat(true),
2096 }
2097 return EventState::Consumed;
2098 }
2099 }
2100 }
2101 if let Some(state) = self.handle_nvim_key(key, tx) {
2102 return state;
2103 }
2104 let text_rev_before = self.content_revision;
2115 let cursor_before = self.textarea_cursor();
2116 let result = self.handle_textarea_key(key, tx);
2117 let cursor_after = self.textarea_cursor();
2118 if self.content_revision != text_rev_before {
2119 self.sync_autocomplete();
2120 } else if cursor_before != cursor_after {
2121 self.refresh_autocomplete_if_open();
2122 }
2123 result
2124 }
2125 InputEvent::Mouse(mouse) => {
2126 let text_rev_before = self.content_revision;
2127 let cursor_before = self.textarea_cursor();
2128 let result = self.handle_mouse(mouse);
2129 let cursor_after = self.textarea_cursor();
2130 if self.content_revision != text_rev_before {
2133 self.sync_autocomplete();
2134 } else if cursor_before != cursor_after {
2135 self.refresh_autocomplete_if_open();
2136 }
2137 if result == EventState::Consumed
2142 && matches!(
2143 mouse.kind,
2144 ratatui::crossterm::event::MouseEventKind::Down(
2145 ratatui::crossterm::event::MouseButton::Left
2146 )
2147 )
2148 {
2149 match self.link_at_cursor() {
2150 Some(LinkTarget::Note(target)) => {
2151 tx.send(AppEvent::FollowLink(target)).ok();
2152 }
2153 Some(LinkTarget::Label(name)) => {
2154 tx.send(AppEvent::FollowLabel(name)).ok();
2155 }
2156 None => {}
2157 }
2158 }
2159 let has_sel = self
2170 .backend
2171 .as_textarea()
2172 .and_then(|ta| ta.selection_range())
2173 .is_some_and(|(s, e)| s != e);
2174 self.backend.vim_sync_mouse_selection(has_sel);
2175 result
2176 }
2177 InputEvent::Paste(_) => EventState::NotConsumed,
2180 }
2181 }
2182
2183 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
2184 let (editor_rect, search_rect) = if self.search.is_some() && rect.height > 1 {
2186 (
2187 Rect {
2188 height: rect.height - 1,
2189 ..rect
2190 },
2191 Some(Rect {
2192 y: rect.y + rect.height - 1,
2193 height: 1,
2194 ..rect
2195 }),
2196 )
2197 } else {
2198 (rect, None)
2199 };
2200 self.rect = editor_rect;
2203 let (selection, nvim_rev_to_mirror) = match &self.backend {
2208 BackendState::Textarea(_) => (self.selection, None),
2209 BackendState::Nvim(nvim) => {
2210 nvim.maybe_resize(editor_rect.width, editor_rect.height);
2211 let snap = nvim.snapshot();
2212 let visual_selection = snap.visual_selection;
2213 let content_gen = snap.content_gen;
2214 drop(snap);
2215 let rev = NonZeroU64::new(content_gen.saturating_add(1));
2224 (visual_selection, rev)
2225 }
2226 };
2227 if let Some(rev) = nvim_rev_to_mirror {
2228 self.content_revision = rev;
2229 }
2230 while let Ok((generation, buf)) = self.full_parse_rx.try_recv() {
2236 self.view.install_full_parse(generation, buf);
2237 }
2238
2239 let snap = snapshot_from_backend(&self.backend, self.content_revision);
2244 self.view.update(&snap, editor_rect, selection);
2245
2246 if let Some(generation) = self.view.take_pending_full_parse() {
2254 let lines: Vec<String> = snap.lines.iter().cloned().collect();
2255 let tx = self.full_parse_tx.clone();
2256 let redraw = self.redraw_tx.clone();
2257 self.full_parse_task.spawn(async move {
2258 let buf = ParsedBuffer::parse(&lines);
2259 let _ = tx.send((generation, buf));
2260 if let Some(redraw) = redraw {
2263 let _ = redraw.send(AppEvent::Redraw);
2264 }
2265 });
2266 }
2267 let bar_focused = self.search.is_some() && focused;
2270 let editor_focused = focused && !bar_focused;
2271 use self::view::CursorShape;
2272 let cursor_shape = match self.backend.modal_is_insert() {
2273 None => None, Some(true) => Some(CursorShape::Bar),
2275 Some(false) => Some(CursorShape::Block),
2276 };
2277 self.view
2278 .render(f, editor_rect, theme, editor_focused, cursor_shape);
2279
2280 if self
2284 .needles_revision
2285 .is_some_and(|r| r != self.content_revision)
2286 {
2287 self.search_needles.clear();
2288 self.needles_revision = None;
2289 }
2290 let mut emphasis_needles = self.search_needles.clone();
2291 if let Some(state) = &self.search {
2292 let q = state.input.value().trim().to_lowercase();
2293 if !q.is_empty() {
2294 emphasis_needles.push(q);
2295 }
2296 }
2297 paint_viewport_extras(f.buffer_mut(), editor_rect, &emphasis_needles, theme);
2298
2299 if snap.lines.iter().all(|l| l.is_empty()) && editor_rect.height > 0 {
2303 let leader = self
2304 .key_bindings
2305 .first_combo_for(&crate::keys::action_shortcuts::ActionShortcuts::Leader)
2306 .unwrap_or_else(|| "leader".to_string());
2307 f.render_widget(
2308 ratatui::widgets::Paragraph::new(format!(
2309 "Type to start · [[ to link · # to tag · {leader} for commands"
2310 ))
2311 .style(
2312 Style::default()
2313 .fg(theme.gray.to_ratatui())
2314 .add_modifier(Modifier::ITALIC),
2315 ),
2316 Rect {
2317 x: editor_rect.x.saturating_add(2),
2318 width: editor_rect.width.saturating_sub(2),
2319 height: 1,
2320 ..editor_rect
2321 },
2322 );
2323 }
2324 if let (Some(state), Some(bar_rect)) = (self.search.as_mut(), search_rect) {
2325 render_search_bar(f, bar_rect, state, theme, bar_focused);
2326 }
2327
2328 self.poll_autocomplete();
2336 if let (Some(controller), Some(live_anchor)) =
2343 (self.autocomplete.as_mut(), self.view.last_cursor_screen)
2344 {
2345 if let Some(state) = controller.state_mut() {
2346 state.anchor = live_anchor;
2347 }
2348 if let Some(state) = controller.state() {
2349 autocomplete::render(f, state, editor_rect, theme);
2350 }
2351 }
2352 }
2353
2354 fn hint_shortcuts(&self) -> Vec<(String, String)> {
2355 use crate::keys::action_shortcuts::ActionShortcuts;
2356
2357 if let Some(mut label) = self.backend.mode_label() {
2362 if let Some(p) = self.backend.vim_pending_hint() {
2363 label = format!("{label} {p}");
2364 }
2365 let mut hints = vec![(String::new(), label)];
2366 hints.extend(
2367 [
2368 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2369 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2370 (ActionShortcuts::FileOperations, "file ops"),
2371 ]
2372 .iter()
2373 .filter_map(|(action, label)| {
2374 self.key_bindings
2375 .first_combo_for(action)
2376 .map(|k| (k, label.to_string()))
2377 }),
2378 );
2379 return hints;
2380 }
2381
2382 let mut hints: Vec<(String, String)> = Vec::new();
2385 match self.link_at_cursor() {
2386 Some(LinkTarget::Note(_)) => {
2387 if let Some(k) = self
2388 .key_bindings
2389 .first_combo_for(&ActionShortcuts::FollowLink)
2390 {
2391 hints.push((k, "follow link".to_string()));
2392 }
2393 }
2394 Some(LinkTarget::Label(_)) => {
2395 if let Some(k) = self
2396 .key_bindings
2397 .first_combo_for(&ActionShortcuts::FollowLink)
2398 {
2399 hints.push((k, "browse tag".to_string()));
2400 }
2401 }
2402 None => {}
2403 }
2404 hints.extend(crate::components::hints::hints_for(
2405 &self.key_bindings,
2406 &[
2407 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2408 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2409 (ActionShortcuts::FileOperations, "file ops"),
2410 (ActionShortcuts::FindInBuffer, "find"),
2411 ],
2412 ));
2413 hints
2414 }
2415}
2416
2417#[cfg(test)]
2418mod tests {
2419 use super::*;
2420 use crate::keys::KeyBindings;
2421
2422 fn make_editor() -> TextEditorComponent {
2423 TextEditorComponent::new(
2424 KeyBindings::empty(),
2425 &crate::settings::AppSettings::default(),
2426 )
2427 }
2428
2429 fn dummy_tx() -> AppTx {
2430 tokio::sync::mpsc::unbounded_channel().0
2431 }
2432
2433 fn get_ta(editor: &mut TextEditorComponent) -> &mut TextArea<'static> {
2434 match &mut editor.backend {
2435 BackendState::Textarea(tb) => &mut tb.ta,
2436 _ => panic!("expected Textarea backend"),
2437 }
2438 }
2439
2440 #[test]
2441 fn has_trigger_before_cursor_finds_bracket() {
2442 assert!(has_trigger_before_cursor("hello [[foo", 11));
2443 assert!(has_trigger_before_cursor("[[a b c", 7));
2444 }
2445
2446 #[test]
2447 fn has_trigger_before_cursor_finds_hashtag() {
2448 assert!(has_trigger_before_cursor("text #tag", 9));
2449 }
2450
2451 #[test]
2452 fn has_trigger_before_cursor_no_trigger_bails() {
2453 assert!(!has_trigger_before_cursor("plain prose here", 16));
2454 assert!(!has_trigger_before_cursor("", 0));
2455 }
2456
2457 #[test]
2458 fn has_trigger_before_cursor_handles_multibyte_no_panic() {
2459 let line = "你好世界".to_string() + &"a".repeat(80);
2462 let col = line.chars().count();
2463 assert!(!has_trigger_before_cursor(&line, col));
2464
2465 let with_emoji = "🦀".repeat(20) + "[[note";
2466 let col = with_emoji.chars().count();
2467 assert!(has_trigger_before_cursor(&with_emoji, col));
2468
2469 let accented = "é".repeat(100);
2470 let col = accented.chars().count();
2471 assert!(!has_trigger_before_cursor(&accented, col));
2472 }
2473
2474 #[test]
2475 fn has_trigger_before_cursor_ignores_chars_after_cursor() {
2476 assert!(!has_trigger_before_cursor("foo [[bar", 3));
2478 }
2479
2480 #[test]
2481 fn has_trigger_before_cursor_wikilink_with_spaces() {
2482 assert!(has_trigger_before_cursor("[[my note title", 15));
2485 }
2486
2487 #[test]
2488 fn fresh_editor_is_not_dirty() {
2489 let editor = make_editor();
2490 assert!(!editor.is_dirty());
2491 }
2492
2493 #[test]
2494 fn after_set_text_not_dirty() {
2495 let mut editor = make_editor();
2496 editor.set_text("hello world".to_string());
2497 assert!(!editor.is_dirty());
2498 }
2499
2500 #[test]
2501 fn get_text_returns_loaded_content() {
2502 let mut editor = make_editor();
2503 editor.set_text("line one\nline two".to_string());
2504 assert_eq!(editor.get_text(), "line one\nline two");
2505 }
2506
2507 #[test]
2508 fn mark_saved_clears_dirty() {
2509 let mut editor = make_editor();
2510 editor.set_text("initial".to_string());
2511 let text = editor.get_text();
2512 editor.mark_saved(text.clone() + "x"); assert!(editor.is_dirty());
2514 editor.mark_saved(text); assert!(!editor.is_dirty());
2516 }
2517
2518 #[test]
2519 fn trailing_newline_does_not_cause_false_dirty() {
2520 let mut editor = make_editor();
2521 editor.set_text("content\n".to_string());
2522 assert!(
2523 !editor.is_dirty(),
2524 "trailing newline should not make editor dirty after load"
2525 );
2526 }
2527
2528 #[test]
2529 fn cursor_move_does_not_dirty_buffer() {
2530 let mut editor = make_editor();
2531 editor.set_text("hello world".to_string());
2532 assert!(!editor.is_dirty());
2533 let tx = dummy_tx();
2534 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
2538 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2539 assert!(
2540 !editor.is_dirty(),
2541 "cursor move must not mark the editor as dirty"
2542 );
2543 }
2544
2545 #[test]
2546 fn empty_stack_undo_redo_does_not_dirty_or_bump_revision() {
2547 let mut editor = make_editor();
2551 editor.set_text("foo".to_string());
2552 let rev_before = editor.content_revision();
2553 assert!(!editor.is_dirty());
2554 let tx = dummy_tx();
2555 for key_code in [KeyCode::Char('z'), KeyCode::Char('y')] {
2556 let key = ratatui::crossterm::event::KeyEvent::new(key_code, KeyModifiers::CONTROL);
2557 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2558 }
2559 assert!(
2560 !editor.is_dirty(),
2561 "empty-stack undo/redo must not flip is_dirty"
2562 );
2563 assert_eq!(
2564 editor.content_revision(),
2565 rev_before,
2566 "empty-stack undo/redo must not bump content_revision"
2567 );
2568 }
2569
2570 #[test]
2571 fn fresh_editor_content_revision_is_nonzero() {
2572 let editor = make_editor();
2579 assert!(editor.content_revision().get() >= 1);
2580 }
2581
2582 #[test]
2583 fn mouse_down_clears_selection() {
2584 let mut editor = make_editor();
2585 editor.set_text("hello world".to_string());
2586 let ta = get_ta(&mut editor);
2587 ta.start_selection();
2588 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2589 assert!(ta.selection_range().is_some());
2590 ta.cancel_selection();
2591 editor.selection = if let BackendState::Textarea(tb) = &editor.backend {
2592 tb.ta.selection_range()
2593 } else {
2594 None
2595 };
2596 assert!(editor.selection.is_none());
2597 }
2598
2599 #[test]
2600 fn ctrl_c_copies_selected_text() {
2601 let mut editor = make_editor();
2602 editor.set_text("hello world".to_string());
2603 let ta = get_ta(&mut editor);
2604 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2605 ta.start_selection();
2606 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2607 let range = ta.selection_range().unwrap();
2608 let ((sr, sc), (er, ec)) = range;
2609 let lines = ta.lines();
2610 let selected = if sr == er {
2611 lines[sr][sc..ec].to_string()
2612 } else {
2613 lines[sr][sc..].to_string()
2614 };
2615 assert_eq!(selected, "hello ");
2616 }
2617
2618 fn select_range(editor: &mut TextEditorComponent, start: (u16, u16), end: (u16, u16)) {
2620 let ta = get_ta(editor);
2621 ta.cancel_selection();
2622 ta.move_cursor(CursorMove::Jump(start.0, start.1));
2623 ta.start_selection();
2624 ta.move_cursor(CursorMove::Jump(end.0, end.1));
2625 assert!(ta.selection_range().is_some());
2626 }
2627
2628 fn send_char(editor: &mut TextEditorComponent, c: char) {
2629 let tx = dummy_tx();
2630 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
2631 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2632 }
2633
2634 #[test]
2635 fn surround_pair_maps_open_and_symmetric_chars() {
2636 assert_eq!(surround_pair('('), Some(("(", ")")));
2637 assert_eq!(surround_pair('['), Some(("[", "]")));
2638 assert_eq!(surround_pair('{'), Some(("{", "}")));
2639 assert_eq!(surround_pair('<'), Some(("<", ">")));
2640 assert_eq!(surround_pair('"'), Some(("\"", "\"")));
2641 assert_eq!(surround_pair('\''), Some(("'", "'")));
2642 assert_eq!(surround_pair('`'), Some(("`", "`")));
2643 assert_eq!(surround_pair('*'), Some(("*", "*")));
2644 assert_eq!(surround_pair('_'), Some(("_", "_")));
2645 assert_eq!(surround_pair('~'), Some(("~", "~")));
2646 assert_eq!(surround_pair(')'), None);
2648 assert_eq!(surround_pair(']'), None);
2649 assert_eq!(surround_pair('}'), None);
2650 assert_eq!(surround_pair('>'), None);
2651 assert_eq!(surround_pair('a'), None);
2652 }
2653
2654 #[test]
2655 fn typing_open_paren_with_selection_wraps_it() {
2656 let mut editor = make_editor();
2657 editor.set_text("hello world".to_string());
2658 select_range(&mut editor, (0, 0), (0, 5)); send_char(&mut editor, '(');
2660 assert_eq!(editor.get_text(), "(hello) world");
2661 assert!(editor.is_dirty(), "wrap must mark the buffer dirty");
2662 }
2663
2664 #[test]
2665 fn wrap_keeps_selection_on_inner_text() {
2666 let mut editor = make_editor();
2667 editor.set_text("hello world".to_string());
2668 select_range(&mut editor, (0, 0), (0, 5));
2669 send_char(&mut editor, '(');
2670 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2672 }
2673
2674 #[test]
2675 fn chained_brackets_build_a_wikilink() {
2676 let mut editor = make_editor();
2677 editor.set_text("my note".to_string());
2678 select_range(&mut editor, (0, 0), (0, 7));
2679 send_char(&mut editor, '[');
2680 send_char(&mut editor, '[');
2681 assert_eq!(editor.get_text(), "[[my note]]");
2682 assert_eq!(editor.selection, Some(((0, 2), (0, 9))));
2683 }
2684
2685 #[test]
2686 fn symmetric_chars_wrap_and_chain() {
2687 let mut editor = make_editor();
2688 editor.set_text("bold".to_string());
2689 select_range(&mut editor, (0, 0), (0, 4));
2690 send_char(&mut editor, '*');
2691 assert_eq!(editor.get_text(), "*bold*");
2692 send_char(&mut editor, '*');
2693 assert_eq!(editor.get_text(), "**bold**");
2694 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2695 }
2696
2697 #[test]
2698 fn closing_char_replaces_selection() {
2699 let mut editor = make_editor();
2700 editor.set_text("hello world".to_string());
2701 select_range(&mut editor, (0, 0), (0, 5));
2702 send_char(&mut editor, ')');
2703 assert_eq!(editor.get_text(), ") world");
2704 }
2705
2706 #[test]
2707 fn open_char_without_selection_inserts_normally() {
2708 let mut editor = make_editor();
2709 editor.set_text("hello".to_string());
2710 let ta = get_ta(&mut editor);
2711 ta.move_cursor(CursorMove::End);
2712 send_char(&mut editor, '(');
2713 assert_eq!(editor.get_text(), "hello(");
2714 }
2715
2716 #[test]
2717 fn wrap_spans_multiline_selection() {
2718 let mut editor = make_editor();
2719 editor.set_text("abc\ndef".to_string());
2720 select_range(&mut editor, (0, 0), (1, 3));
2721 send_char(&mut editor, '(');
2722 assert_eq!(editor.get_text(), "(abc\ndef)");
2723 assert_eq!(editor.selection, Some(((0, 1), (1, 3))));
2725 }
2726
2727 #[test]
2728 fn wrap_handles_multibyte_selection() {
2729 let mut editor = make_editor();
2730 editor.set_text("héllo🦀 x".to_string());
2731 select_range(&mut editor, (0, 0), (0, 6)); send_char(&mut editor, '`');
2733 assert_eq!(editor.get_text(), "`héllo🦀` x");
2734 assert_eq!(editor.selection, Some(((0, 1), (0, 7))));
2735 }
2736
2737 #[test]
2738 fn wrap_with_reversed_selection_direction() {
2739 let mut editor = make_editor();
2741 editor.set_text("hello world".to_string());
2742 select_range(&mut editor, (0, 5), (0, 0));
2743 send_char(&mut editor, '(');
2744 assert_eq!(editor.get_text(), "(hello) world");
2745 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2746 }
2747
2748 #[test]
2749 fn text_action_keeps_selection_on_inner_text() {
2750 let mut editor = make_editor();
2753 editor.set_text("bold word".to_string());
2754 select_range(&mut editor, (0, 0), (0, 4));
2755 editor.apply_text_action(TextAction::Bold);
2756 assert_eq!(editor.get_text(), "**bold** word");
2757 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2758 }
2759
2760 #[test]
2761 fn wrap_undo_is_two_steps_back_to_original() {
2762 let mut editor = make_editor();
2766 editor.set_text("hello world".to_string());
2767 select_range(&mut editor, (0, 0), (0, 5));
2768 send_char(&mut editor, '(');
2769 assert_eq!(editor.get_text(), "(hello) world");
2770 let ta = get_ta(&mut editor);
2771 ta.undo();
2772 ta.undo();
2773 assert_eq!(editor.get_text(), "hello world");
2774 }
2775
2776 #[test]
2777 fn linkable_url_accepts_supported_schemes() {
2778 assert_eq!(
2779 linkable_url("https://example.com"),
2780 Some("https://example.com")
2781 );
2782 assert_eq!(
2783 linkable_url("http://example.com/path?q=1#frag"),
2784 Some("http://example.com/path?q=1#frag"),
2785 );
2786 assert_eq!(
2787 linkable_url(" https://example.com "),
2788 Some("https://example.com")
2789 );
2790 assert_eq!(
2791 linkable_url("ftp://files.example.com/x"),
2792 Some("ftp://files.example.com/x"),
2793 );
2794 assert_eq!(
2795 linkable_url("ftps://files.example.com/x"),
2796 Some("ftps://files.example.com/x"),
2797 );
2798 assert_eq!(
2799 linkable_url("mailto:user@example.com"),
2800 Some("mailto:user@example.com"),
2801 );
2802 assert_eq!(
2803 linkable_url("mailto:user@example.com?subject=hi"),
2804 Some("mailto:user@example.com?subject=hi"),
2805 );
2806 }
2807
2808 #[test]
2809 fn linkable_url_rejects_other_schemes_and_plain_text() {
2810 assert_eq!(linkable_url("file:///etc/passwd"), None);
2811 assert_eq!(linkable_url("ssh://host"), None);
2812 assert_eq!(linkable_url("javascript:alert(1)"), None);
2813 assert_eq!(linkable_url("example.com"), None);
2814 assert_eq!(linkable_url("not a url"), None);
2815 assert_eq!(linkable_url(""), None);
2816 assert_eq!(linkable_url("https://example.com\nmore"), None);
2817 }
2818
2819 #[test]
2820 fn try_build_markdown_link_wraps_selection_when_clip_is_url() {
2821 assert_eq!(
2822 try_build_markdown_link("https://example.com", Some("click here")).as_deref(),
2823 Some("[click here](https://example.com)"),
2824 );
2825 }
2826
2827 #[test]
2828 fn try_build_markdown_link_trims_url_whitespace() {
2829 assert_eq!(
2830 try_build_markdown_link(" https://example.com\n", Some("link")).as_deref(),
2831 Some("[link](https://example.com)"),
2832 );
2833 }
2834
2835 #[test]
2836 fn try_build_markdown_link_returns_none_when_no_selection() {
2837 assert_eq!(try_build_markdown_link("https://example.com", None), None);
2838 }
2839
2840 #[test]
2841 fn try_build_markdown_link_returns_none_when_not_url() {
2842 assert_eq!(try_build_markdown_link("plain text", Some("sel")), None);
2843 }
2844
2845 #[test]
2846 fn try_build_markdown_link_returns_none_when_selection_empty() {
2847 assert_eq!(
2848 try_build_markdown_link("https://example.com", Some("")),
2849 None
2850 );
2851 }
2852
2853 #[test]
2854 fn try_build_markdown_link_escapes_close_bracket_in_selection() {
2855 assert_eq!(
2856 try_build_markdown_link("https://example.com", Some("a]b")).as_deref(),
2857 Some(r"[a\]b](https://example.com)"),
2858 );
2859 }
2860
2861 #[test]
2862 fn try_build_markdown_link_wraps_ftp_url() {
2863 assert_eq!(
2864 try_build_markdown_link("ftp://files.example.com/x", Some("download")).as_deref(),
2865 Some("[download](ftp://files.example.com/x)"),
2866 );
2867 }
2868
2869 fn key(code: KeyCode, mods: KeyModifiers) -> ratatui::crossterm::event::KeyEvent {
2870 ratatui::crossterm::event::KeyEvent::new(code, mods)
2871 }
2872
2873 #[test]
2875 fn paint_viewport_extras_emphasizes_needles_and_tasks() {
2876 use ratatui::buffer::Buffer;
2877 use ratatui::layout::Position;
2878 let theme = crate::settings::themes::Theme::default();
2879 let area = Rect::new(0, 0, 30, 3);
2880 let mut buf = Buffer::empty(area);
2881 buf.set_string(0, 0, "find the needle here", Style::default());
2882 buf.set_string(0, 1, "- [x] done task", Style::default());
2883 buf.set_string(0, 2, "- [ ] open task", Style::default());
2884
2885 paint_viewport_extras(&mut buf, area, &["needle".to_string()], &theme);
2886
2887 let cell = buf.cell(Position::new(9, 0)).unwrap();
2889 assert_eq!(cell.fg, theme.color_search_match.to_ratatui());
2890 assert!(cell.style().add_modifier.contains(Modifier::BOLD));
2891 let cell = buf.cell(Position::new(8, 1)).unwrap();
2893 assert!(cell.style().add_modifier.contains(Modifier::CROSSED_OUT));
2894 let cell = buf.cell(Position::new(8, 2)).unwrap();
2896 assert!(!cell.style().add_modifier.contains(Modifier::CROSSED_OUT));
2897 let cb = buf.cell(Position::new(3, 2)).unwrap();
2898 assert_eq!(cb.fg, theme.accent.to_ratatui());
2899 }
2900
2901 #[test]
2903 fn search_needles_clear_on_edit() {
2904 let settings = crate::settings::AppSettings::default();
2905 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2906 ed.set_text("alpha beta".to_string());
2907 ed.set_search_needles(vec!["Alpha".to_string()]);
2908 assert_eq!(ed.search_needles, vec!["alpha"]);
2909 assert_eq!(ed.needles_revision, Some(ed.content_revision));
2910
2911 ed.set_text("alpha beta gamma".to_string());
2913 assert_ne!(ed.needles_revision, Some(ed.content_revision));
2914 }
2915
2916 #[test]
2917 fn jump_to_heading_moves_cursor_to_heading_line() {
2918 let settings = crate::settings::AppSettings::default();
2919 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2920 ed.set_text("intro\n# Top\nbody\n## Sub One\nmore\n".to_string());
2921
2922 ed.jump_to_heading("Sub One");
2923 assert_eq!(ed.view_snapshot().cursor.0, 3);
2924
2925 ed.jump_to_heading("Top");
2926 assert_eq!(ed.view_snapshot().cursor.0, 1);
2927
2928 ed.jump_to_heading("Nope");
2930 assert_eq!(ed.view_snapshot().cursor.0, 1);
2931 }
2932
2933 #[test]
2934 fn open_or_advance_search_opens_find_bar_with_empty_query() {
2935 let mut editor = make_editor();
2936 editor.set_text("hello world".to_string());
2937 editor.open_or_advance_search();
2938 let state = editor.search.as_ref().expect("find bar opened");
2939 assert!(state.input.is_empty());
2940 assert!(matches!(state.status, SearchStatus::Empty));
2941 }
2942
2943 #[test]
2944 fn open_or_advance_search_advances_when_already_open() {
2945 let mut editor = make_editor();
2946 editor.set_text("ab ab ab".to_string());
2947 let tx = dummy_tx();
2948 editor.open_or_advance_search();
2949 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2950 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2951 editor.open_or_advance_search();
2953 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2954 assert_eq!(col, 3, "second invocation advances to next match");
2955 }
2956
2957 #[test]
2958 fn typing_in_find_bar_jumps_cursor_to_first_match() {
2959 let mut editor = make_editor();
2960 editor.set_text("foo bar baz".to_string());
2961 let tx = dummy_tx();
2962 editor.open_or_advance_search();
2963 for ch in ['b', 'a', 'r'] {
2964 editor.handle_textarea_key(&key(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
2965 }
2966 let state = editor.search.as_ref().unwrap();
2967 assert_eq!(state.input.value(), "bar");
2968 assert!(matches!(state.status, SearchStatus::Match));
2969 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2970 assert_eq!(col, 4, "cursor jumped to start of 'bar'");
2971 }
2972
2973 #[test]
2974 fn enter_in_find_bar_advances_to_next_match() {
2975 let mut editor = make_editor();
2976 editor.set_text("ab ab ab".to_string());
2977 let tx = dummy_tx();
2978 editor.open_or_advance_search();
2979 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2980 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2981 editor.handle_textarea_key(&key(KeyCode::Enter, KeyModifiers::NONE), &tx);
2983 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2984 assert_eq!(col, 3, "Enter advances to second match");
2985 }
2986
2987 #[test]
2988 fn match_is_highlighted_as_selection_after_search() {
2989 let mut editor = make_editor();
2990 editor.set_text("foo bar baz".to_string());
2991 let tx = dummy_tx();
2992 editor.open_or_advance_search();
2993 for ch in ['b', 'a', 'r'] {
2994 editor.handle_textarea_key(&key(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
2995 }
2996 assert_eq!(editor.selection, Some(((0, 4), (0, 7))));
2998 }
2999
3000 #[test]
3001 fn no_match_clears_selection() {
3002 let mut editor = make_editor();
3003 editor.set_text("hello".to_string());
3004 let tx = dummy_tx();
3005 editor.open_or_advance_search();
3006 editor.handle_textarea_key(&key(KeyCode::Char('z'), KeyModifiers::NONE), &tx);
3007 assert_eq!(editor.selection, None);
3008 }
3009
3010 #[test]
3011 fn esc_in_find_bar_clears_selection_highlight() {
3012 let mut editor = make_editor();
3013 editor.set_text("foo bar".to_string());
3014 let tx = dummy_tx();
3015 editor.open_or_advance_search();
3016 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
3017 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
3018 editor.handle_textarea_key(&key(KeyCode::Char('r'), KeyModifiers::NONE), &tx);
3019 assert!(editor.selection.is_some());
3020 editor.handle_textarea_key(&key(KeyCode::Esc, KeyModifiers::NONE), &tx);
3021 assert!(editor.selection.is_none());
3022 }
3023
3024 #[test]
3025 fn esc_in_find_bar_closes_it() {
3026 let mut editor = make_editor();
3027 editor.set_text("hello".to_string());
3028 let tx = dummy_tx();
3029 editor.open_or_advance_search();
3030 assert!(editor.search.is_some());
3031 editor.handle_textarea_key(&key(KeyCode::Esc, KeyModifiers::NONE), &tx);
3032 assert!(editor.search.is_none());
3033 }
3034
3035 #[test]
3036 fn find_bar_consumes_typing_so_editor_text_is_unchanged() {
3037 let mut editor = make_editor();
3038 editor.set_text("hello".to_string());
3039 let tx = dummy_tx();
3040 editor.open_or_advance_search();
3041 editor.handle_textarea_key(&key(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
3042 assert_eq!(editor.get_text(), "hello");
3043 }
3044
3045 #[test]
3046 fn no_match_status_when_query_absent() {
3047 let mut editor = make_editor();
3048 editor.set_text("hello".to_string());
3049 let tx = dummy_tx();
3050 editor.open_or_advance_search();
3051 editor.handle_textarea_key(&key(KeyCode::Char('z'), KeyModifiers::NONE), &tx);
3052 let state = editor.search.as_ref().unwrap();
3053 assert!(matches!(state.status, SearchStatus::NoMatch));
3054 }
3055
3056 #[test]
3057 fn try_build_markdown_link_wraps_mailto_url() {
3058 assert_eq!(
3059 try_build_markdown_link("mailto:user@example.com", Some("email me")).as_deref(),
3060 Some("[email me](mailto:user@example.com)"),
3061 );
3062 }
3063
3064 #[test]
3065 fn insert_at_cursor_appends_text() {
3066 let mut editor = make_editor();
3067 editor.set_text("hello".to_string());
3068 {
3069 let ta = get_ta(&mut editor);
3070 ta.move_cursor(ratatui_textarea::CursorMove::End);
3071 }
3072 editor.insert_at_cursor(" world", &dummy_tx());
3073 assert_eq!(editor.get_text(), "hello world");
3074 }
3075
3076 #[test]
3077 fn insert_at_cursor_replaces_selection() {
3078 let mut editor = make_editor();
3079 editor.set_text("hello world".to_string());
3080 {
3081 let ta = get_ta(&mut editor);
3082 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3083 ta.start_selection();
3084 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3085 }
3086 editor.insert_at_cursor("HEY ", &dummy_tx());
3087 assert_eq!(editor.get_text(), "HEY world");
3088 }
3089
3090 #[test]
3091 fn paste_inserts_text_at_cursor() {
3092 let mut editor = make_editor();
3093 editor.set_text("hello".to_string());
3094 let ta = get_ta(&mut editor);
3095 ta.move_cursor(ratatui_textarea::CursorMove::End);
3096 ta.insert_str(" world");
3097 assert_eq!(editor.get_text(), "hello world");
3098 }
3099
3100 #[test]
3101 fn bold_action_with_no_selection_inserts_pair_and_centers_cursor() {
3102 let mut editor = make_editor();
3103 editor.set_text("hello".to_string());
3104 {
3105 let ta = get_ta(&mut editor);
3106 ta.move_cursor(ratatui_textarea::CursorMove::End);
3107 }
3108 editor.apply_text_action(TextAction::Bold);
3109 assert_eq!(editor.get_text(), "hello****");
3110 let ta = get_ta(&mut editor);
3111 assert_eq!(ta.cursor(), (0, 7));
3112 }
3113
3114 #[test]
3115 fn italic_action_with_no_selection_inserts_single_pair() {
3116 let mut editor = make_editor();
3117 editor.set_text(String::new());
3118 editor.apply_text_action(TextAction::Italic);
3119 assert_eq!(editor.get_text(), "**");
3120 let ta = get_ta(&mut editor);
3121 assert_eq!(ta.cursor(), (0, 1));
3122 }
3123
3124 #[test]
3125 fn strikethrough_action_with_selection_wraps_text() {
3126 let mut editor = make_editor();
3127 editor.set_text("hello world".to_string());
3128 {
3129 let ta = get_ta(&mut editor);
3130 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3131 ta.start_selection();
3132 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3133 }
3134 editor.apply_text_action(TextAction::Strikethrough);
3135 assert_eq!(editor.get_text(), "~~hello ~~world");
3136 }
3137
3138 #[test]
3139 fn bold_action_wraps_non_ascii_selection() {
3140 let mut editor = make_editor();
3141 editor.set_text("hello 你好 world".to_string());
3142 {
3143 let ta = get_ta(&mut editor);
3144 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3145 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3146 ta.start_selection();
3147 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3148 }
3149 editor.apply_text_action(TextAction::Bold);
3150 assert_eq!(editor.get_text(), "hello **你好 **world");
3151 }
3152
3153 #[test]
3154 fn bold_action_wraps_selected_text() {
3155 let mut editor = make_editor();
3156 editor.set_text("foo bar".to_string());
3157 {
3158 let ta = get_ta(&mut editor);
3159 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3160 ta.start_selection();
3161 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3162 }
3163 editor.apply_text_action(TextAction::Bold);
3164 assert_eq!(editor.get_text(), "**foo **bar");
3165 }
3166
3167 #[test]
3168 fn indent_no_selection_indents_current_line() {
3169 let mut editor = make_editor();
3170 editor.set_text("foo\nbar".to_string());
3171 {
3172 let ta = get_ta(&mut editor);
3173 ta.move_cursor(ratatui_textarea::CursorMove::Bottom);
3174 }
3175 editor.indent_lines(false);
3176 let lines = get_ta(&mut editor).lines();
3177 assert_eq!(lines[0], "foo");
3178 assert!(lines[1].starts_with(' ') || lines[1].starts_with('\t'));
3179 assert!(lines[1].trim_start() == "bar");
3180 }
3181
3182 #[test]
3183 fn indent_midline_selection_keeps_text_before_and_selection() {
3184 let mut editor = make_editor();
3185 editor.set_text("hello world".to_string());
3186 {
3187 let ta = get_ta(&mut editor);
3188 ta.move_cursor(ratatui_textarea::CursorMove::Jump(0, 6));
3189 ta.start_selection();
3190 ta.move_cursor(ratatui_textarea::CursorMove::End);
3191 }
3192 editor.indent_lines(false);
3193 let ta = get_ta(&mut editor);
3194 assert_eq!(ta.lines()[0].trim_start(), "hello world");
3196 let indent = ta.lines()[0].len() - "hello world".len();
3198 assert_eq!(
3199 ta.selection_range(),
3200 Some(((0, 6 + indent), (0, 11 + indent)))
3201 );
3202 }
3203
3204 #[test]
3205 fn indent_with_selection_indents_all_touched_lines() {
3206 let mut editor = make_editor();
3207 editor.set_text("foo\nbar\nbaz".to_string());
3208 {
3209 let ta = get_ta(&mut editor);
3210 ta.move_cursor(ratatui_textarea::CursorMove::Top);
3211 ta.start_selection();
3212 ta.move_cursor(ratatui_textarea::CursorMove::Down);
3213 ta.move_cursor(ratatui_textarea::CursorMove::End);
3214 }
3215 editor.indent_lines(false);
3216 let lines: Vec<String> = get_ta(&mut editor).lines().to_vec();
3217 assert_eq!(lines[0].trim_start(), "foo");
3218 assert_eq!(lines[1].trim_start(), "bar");
3219 assert_eq!(lines[2], "baz");
3220 assert!(lines[0].len() > 3);
3221 assert!(lines[1].len() > 3);
3222 }
3223
3224 #[test]
3225 fn dedent_removes_leading_indent() {
3226 let mut editor = make_editor();
3227 editor.set_text(" foo\n bar\nbaz".to_string());
3228 let tab_len = get_ta(&mut editor).tab_length() as usize;
3229 {
3230 let ta = get_ta(&mut editor);
3231 ta.move_cursor(ratatui_textarea::CursorMove::Top);
3232 ta.start_selection();
3233 ta.move_cursor(ratatui_textarea::CursorMove::Bottom);
3234 ta.move_cursor(ratatui_textarea::CursorMove::End);
3235 }
3236 editor.indent_lines(true);
3237 let lines: Vec<String> = get_ta(&mut editor).lines().to_vec();
3238 assert_eq!(lines[0], format!("{}foo", " ".repeat(4 - tab_len.min(4))));
3240 assert_eq!(
3242 lines[1],
3243 format!("{}bar", " ".repeat(2usize.saturating_sub(tab_len)))
3244 );
3245 assert_eq!(lines[2], "baz");
3246 }
3247
3248 #[test]
3249 fn dedent_no_leading_whitespace_is_noop_for_that_line() {
3250 let mut editor = make_editor();
3251 editor.set_text("foo".to_string());
3252 editor.indent_lines(true);
3253 assert_eq!(editor.get_text(), "foo");
3254 }
3255
3256 #[test]
3257 fn smart_enter_continues_unordered_list() {
3258 let mut editor = make_editor();
3259 editor.set_text("- foo".to_string());
3260 {
3261 let ta = get_ta(&mut editor);
3262 ta.move_cursor(ratatui_textarea::CursorMove::End);
3263 }
3264 assert!(editor.smart_enter());
3265 assert_eq!(editor.get_text(), "- foo\n- ");
3266 }
3267
3268 #[test]
3269 fn smart_enter_continues_ordered_list_increments() {
3270 let mut editor = make_editor();
3271 editor.set_text("1. foo".to_string());
3272 {
3273 let ta = get_ta(&mut editor);
3274 ta.move_cursor(ratatui_textarea::CursorMove::End);
3275 }
3276 assert!(editor.smart_enter());
3277 assert_eq!(editor.get_text(), "1. foo\n2. ");
3278 }
3279
3280 #[test]
3281 fn smart_enter_on_empty_list_marker_clears_line() {
3282 let mut editor = make_editor();
3283 editor.set_text("- ".to_string());
3284 {
3285 let ta = get_ta(&mut editor);
3286 ta.move_cursor(ratatui_textarea::CursorMove::End);
3287 }
3288 assert!(editor.smart_enter());
3289 assert_eq!(editor.get_text(), "");
3290 }
3291
3292 #[test]
3293 fn smart_enter_preserves_indent() {
3294 let mut editor = make_editor();
3295 editor.set_text(" body".to_string());
3296 {
3297 let ta = get_ta(&mut editor);
3298 ta.move_cursor(ratatui_textarea::CursorMove::End);
3299 }
3300 assert!(editor.smart_enter());
3301 assert_eq!(editor.get_text(), " body\n ");
3302 }
3303
3304 #[test]
3305 fn smart_enter_on_empty_indent_dedents() {
3306 let mut editor = make_editor();
3307 editor.set_text(" ".to_string());
3308 {
3309 let ta = get_ta(&mut editor);
3310 ta.move_cursor(ratatui_textarea::CursorMove::End);
3311 }
3312 let tab_len = get_ta(&mut editor).tab_length() as usize;
3313 assert!(editor.smart_enter());
3314 assert_eq!(
3315 editor.get_text(),
3316 " ".repeat(4usize.saturating_sub(tab_len))
3317 );
3318 }
3319
3320 #[test]
3321 fn smart_enter_no_indent_no_marker_returns_false() {
3322 let mut editor = make_editor();
3323 editor.set_text("plain".to_string());
3324 {
3325 let ta = get_ta(&mut editor);
3326 ta.move_cursor(ratatui_textarea::CursorMove::End);
3327 }
3328 assert!(!editor.smart_enter());
3329 assert_eq!(editor.get_text(), "plain");
3330 }
3331
3332 #[test]
3333 fn smart_enter_mid_line_returns_false() {
3334 let mut editor = make_editor();
3335 editor.set_text("- foo".to_string());
3336 {
3337 let ta = get_ta(&mut editor);
3338 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3339 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3340 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3341 }
3342 assert!(!editor.smart_enter());
3343 }
3344
3345 #[test]
3346 fn smart_enter_on_empty_indented_list_marker_dedents_keeping_marker() {
3347 let mut editor = make_editor();
3348 let tab_len = get_ta(&mut editor).tab_length() as usize;
3349 let indent = " ".repeat(tab_len);
3350 editor.set_text(format!("{indent}- "));
3351 {
3352 let ta = get_ta(&mut editor);
3353 ta.move_cursor(ratatui_textarea::CursorMove::End);
3354 }
3355 assert!(editor.smart_enter());
3356 assert_eq!(editor.get_text(), "- ");
3357 }
3358
3359 #[test]
3360 fn smart_enter_on_empty_list_marker_clears_line_after_full_dedent() {
3361 let mut editor = make_editor();
3362 let tab_len = get_ta(&mut editor).tab_length() as usize;
3363 let indent = " ".repeat(tab_len);
3364 editor.set_text(format!("{indent}- "));
3365 {
3366 let ta = get_ta(&mut editor);
3367 ta.move_cursor(ratatui_textarea::CursorMove::End);
3368 }
3369 assert!(editor.smart_enter());
3371 assert_eq!(editor.get_text(), "- ");
3372 {
3375 let ta = get_ta(&mut editor);
3376 ta.move_cursor(ratatui_textarea::CursorMove::End);
3377 }
3378 assert!(editor.smart_enter());
3379 assert_eq!(editor.get_text(), "");
3380 }
3381
3382 #[test]
3383 fn smart_enter_continues_list_with_non_ascii_content() {
3384 let mut editor = make_editor();
3385 editor.set_text("- 你好".to_string());
3386 {
3387 let ta = get_ta(&mut editor);
3388 ta.move_cursor(ratatui_textarea::CursorMove::End);
3389 }
3390 assert!(editor.smart_enter());
3391 assert_eq!(editor.get_text(), "- 你好\n- ");
3392 }
3393
3394 #[test]
3395 fn smart_enter_preserves_tab_indent() {
3396 let mut editor = make_editor();
3397 editor.set_text("\tbody".to_string());
3398 {
3399 let ta = get_ta(&mut editor);
3400 ta.move_cursor(ratatui_textarea::CursorMove::End);
3401 }
3402 assert!(editor.smart_enter());
3403 assert_eq!(editor.get_text(), "\tbody\n\t");
3404 }
3405
3406 #[test]
3407 fn smart_enter_on_tab_only_line_dedents() {
3408 let mut editor = make_editor();
3409 editor.set_text("\t\t".to_string());
3410 {
3411 let ta = get_ta(&mut editor);
3412 ta.move_cursor(ratatui_textarea::CursorMove::End);
3413 }
3414 assert!(editor.smart_enter());
3415 assert_eq!(editor.get_text(), "\t");
3417 }
3418
3419 #[test]
3420 fn smart_enter_continues_indented_list() {
3421 let mut editor = make_editor();
3422 editor.set_text(" - foo".to_string());
3423 {
3424 let ta = get_ta(&mut editor);
3425 ta.move_cursor(ratatui_textarea::CursorMove::End);
3426 }
3427 assert!(editor.smart_enter());
3428 assert_eq!(editor.get_text(), " - foo\n - ");
3429 }
3430
3431 #[test]
3432 fn unsupported_text_action_is_noop() {
3433 let mut editor = make_editor();
3434 editor.set_text("hello".to_string());
3435 editor.apply_text_action(TextAction::Underline);
3436 assert_eq!(editor.get_text(), "hello");
3437 }
3438
3439 #[test]
3440 fn textarea_hint_shortcuts_has_no_mode_indicator() {
3441 let editor = make_editor();
3442 let hints = editor.hint_shortcuts();
3443 assert!(
3445 !hints
3446 .iter()
3447 .any(|(_, label)| label == "NORMAL" || label == "INSERT")
3448 );
3449 }
3450
3451 fn place_cursor_at_col(editor: &mut TextEditorComponent, col: usize) {
3455 let ta = get_ta(editor);
3456 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3457 for _ in 0..col {
3458 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3459 }
3460 }
3461
3462 #[test]
3463 fn link_at_cursor_returns_label_when_cursor_on_hashtag() {
3464 let mut editor = make_editor();
3465 editor.set_text("see #rust now".to_string());
3466 place_cursor_at_col(&mut editor, 5);
3468 assert_eq!(
3469 editor.link_at_cursor(),
3470 Some(LinkTarget::Label("rust".into())),
3471 );
3472 }
3473
3474 #[test]
3475 fn link_at_cursor_returns_label_at_hash_char() {
3476 let mut editor = make_editor();
3477 editor.set_text("see #rust now".to_string());
3478 place_cursor_at_col(&mut editor, 4);
3480 assert_eq!(
3481 editor.link_at_cursor(),
3482 Some(LinkTarget::Label("rust".into())),
3483 );
3484 }
3485
3486 #[test]
3487 fn link_at_cursor_returns_none_outside_hashtag() {
3488 let mut editor = make_editor();
3489 editor.set_text("see #rust now".to_string());
3490 place_cursor_at_col(&mut editor, 0);
3492 assert_eq!(editor.link_at_cursor(), None);
3493 }
3494
3495 #[test]
3496 fn link_at_cursor_returns_note_for_wikilink() {
3497 let mut editor = make_editor();
3498 editor.set_text("open [[my note]] please".to_string());
3499 place_cursor_at_col(&mut editor, 7);
3501 let result = editor.link_at_cursor();
3502 assert!(
3503 matches!(result, Some(LinkTarget::Note(_))),
3504 "expected Note variant, got {result:?}"
3505 );
3506 }
3507
3508 #[test]
3511 fn link_at_cursor_returns_note_for_markdown_link_with_fragment() {
3512 let line = "[see docs](#section)";
3517 let mut editor = make_editor();
3518 editor.set_text(line.to_string());
3519 let cursor = "[see docs](#sec".chars().count(); place_cursor_at_col(&mut editor, cursor);
3522 let result = editor.link_at_cursor();
3523 assert!(
3524 matches!(result, Some(LinkTarget::Note(_))),
3525 "expected Note variant for markdown link fragment, got {result:?}"
3526 );
3527 }
3528
3529 #[test]
3530 fn vim_normal_i_then_typing_inserts_text() {
3531 let mut settings = crate::settings::AppSettings::default();
3532 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
3533 let mut editor = TextEditorComponent::new(KeyBindings::empty(), &settings);
3534 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3535 editor.handle_input(
3537 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3538 &tx,
3539 );
3540 assert_eq!(editor.get_text(), "");
3541 editor.handle_input(
3543 &InputEvent::Key(key(KeyCode::Char('i'), KeyModifiers::NONE)),
3544 &tx,
3545 );
3546 editor.handle_input(
3547 &InputEvent::Key(key(KeyCode::Char('x'), KeyModifiers::NONE)),
3548 &tx,
3549 );
3550 assert_eq!(editor.get_text(), "x");
3551 }
3552
3553 fn make_vim_editor() -> TextEditorComponent {
3555 let mut settings = crate::settings::AppSettings::default();
3556 settings.editor_backend = crate::settings::EditorBackendSetting::Vim;
3557 TextEditorComponent::new(KeyBindings::empty(), &settings)
3558 }
3559
3560 fn vim_mode(editor: &TextEditorComponent) -> EditorMode {
3563 match &editor.backend {
3564 BackendState::Textarea(tb) => match &tb.input {
3565 backend::InputInterpreter::Vim(e) => e.mode().clone(),
3566 _ => panic!("expected Vim input interpreter"),
3567 },
3568 _ => panic!("expected Textarea backend"),
3569 }
3570 }
3571
3572 #[test]
3582 fn vim_sync_collapsed_sel_stays_normal() {
3583 let mut editor = make_vim_editor();
3584 editor.set_text("hello world".to_string());
3585
3586 assert_eq!(vim_mode(&editor), EditorMode::Normal);
3588
3589 editor.backend.vim_sync_mouse_selection(false);
3592 assert_eq!(
3593 vim_mode(&editor),
3594 EditorMode::Normal,
3595 "collapsed (bare click) selection must not enter Visual mode"
3596 );
3597 }
3598
3599 #[test]
3601 fn vim_sync_real_sel_enters_visual() {
3602 let mut editor = make_vim_editor();
3603 editor.set_text("hello world".to_string());
3604
3605 assert_eq!(vim_mode(&editor), EditorMode::Normal);
3607
3608 editor.backend.vim_sync_mouse_selection(true);
3610 assert_eq!(
3611 vim_mode(&editor),
3612 EditorMode::Visual,
3613 "real drag selection must enter Visual mode"
3614 );
3615 }
3616
3617 #[test]
3621 fn vim_find_bar_captures_typing_not_cursor() {
3622 let mut editor = make_vim_editor();
3623 editor.set_text("hello world\nsecond line".to_string());
3624 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3625
3626 editor.open_or_advance_search();
3628 assert!(editor.search.is_some(), "find bar must be open");
3629
3630 editor.handle_input(
3632 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
3633 &tx,
3634 );
3635 editor.handle_input(
3636 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
3637 &tx,
3638 );
3639
3640 let q = editor
3644 .search
3645 .as_ref()
3646 .map(|s| s.input.value().to_string())
3647 .unwrap_or_default();
3648 assert_eq!(q, "lo", "find query must capture typed characters");
3649
3650 assert_eq!(
3653 editor.get_text(),
3654 "hello world\nsecond line",
3655 "buffer must not be modified while find bar is open"
3656 );
3657
3658 assert_eq!(
3664 editor.cursor_pos().1,
3665 3,
3666 "cursor must jump to the search match (col 3), not to a vim motion position"
3667 );
3668 }
3669
3670 #[test]
3673 fn vim_search_enter_confirms_and_n_navigates() {
3674 let mut editor = make_vim_editor();
3675 editor.set_text("lo xx lo yy lo".to_string());
3677 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3678
3679 editor.open_or_advance_search();
3681 assert!(editor.search.is_some(), "find bar must open");
3682
3683 editor.handle_input(
3685 &InputEvent::Key(key(KeyCode::Char('l'), KeyModifiers::NONE)),
3686 &tx,
3687 );
3688 editor.handle_input(
3689 &InputEvent::Key(key(KeyCode::Char('o'), KeyModifiers::NONE)),
3690 &tx,
3691 );
3692
3693 editor.handle_input(
3695 &InputEvent::Key(key(KeyCode::Enter, KeyModifiers::NONE)),
3696 &tx,
3697 );
3698 assert!(
3699 editor.search.is_none(),
3700 "find bar must close after Enter in vim mode"
3701 );
3702
3703 editor.handle_input(
3707 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
3708 &tx,
3709 );
3710 let (_, c1) = editor.cursor_pos();
3711 assert_eq!(c1, 6, "'n' must jump to the 2nd 'lo' at col 6");
3712
3713 editor.handle_input(
3714 &InputEvent::Key(key(KeyCode::Char('n'), KeyModifiers::NONE)),
3715 &tx,
3716 );
3717 let (_, c2) = editor.cursor_pos();
3718 assert_eq!(c2, 12, "'n' must jump to the 3rd 'lo' at col 12");
3719
3720 assert_eq!(editor.get_text(), "lo xx lo yy lo");
3722 }
3723}