1pub mod autocomplete_glue;
2pub mod backend;
3pub mod markdown;
4pub mod nvim_rpc;
5pub mod parse_incremental;
6pub mod snapshot;
7pub mod view;
8pub mod widener_metrics;
9pub mod word_wrap;
10
11use arboard::Clipboard;
12use ratatui::Frame;
13use ratatui::crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
14use ratatui::layout::Rect;
15use ratatui::style::{Modifier, Style};
16use ratatui::text::{Line, Span};
17use ratatui::widgets::Paragraph;
18use ratatui_textarea::{CursorMove, DataCursor, TextArea};
19use std::num::NonZeroU64;
20
21fn cursor_tuple(ta: &TextArea<'_>) -> (usize, usize) {
25 let DataCursor(r, c) = ta.cursor();
26 (r, c)
27}
28
29fn snapshot_from_backend(
36 backend: &BackendState,
37 content_revision: NonZeroU64,
38) -> EditorSnapshot<'_> {
39 match backend {
40 BackendState::Textarea(ta) => {
41 let cursor = cursor_tuple(ta);
42 EditorSnapshot::borrowed(ta.lines(), cursor, content_revision)
43 }
44 BackendState::Nvim(nvim) => {
45 let snap = nvim.snapshot.lock().unwrap_or_else(|p| p.into_inner());
46 let lines_len = snap.lines.len();
47 let cursor_row = if lines_len == 0 {
48 0
49 } else {
50 snap.cursor.0.min(lines_len - 1)
51 };
52 let cursor = (cursor_row, snap.cursor.1);
53 let lines = snap.lines.clone();
54 let rev = NonZeroU64::new(snap.content_gen.saturating_add(1))
55 .unwrap_or_else(|| NonZeroU64::new(1).unwrap());
56 drop(snap);
57 EditorSnapshot::owned(lines, cursor, rev)
58 }
59 }
60}
61
62fn has_trigger_before_cursor(line: &str, col: usize) -> bool {
73 let cursor_byte = line
74 .char_indices()
75 .nth(col)
76 .map(|(b, _)| b)
77 .unwrap_or(line.len());
78 line[..cursor_byte]
79 .chars()
80 .rev()
81 .any(|c| c == '[' || c == '#')
82}
83
84macro_rules! cursor_move {
90 ($ta:expr, $mv:expr, $shift:expr) => {{
91 if $shift {
92 if $ta.selection_range().is_none() {
93 $ta.start_selection();
94 }
95 } else {
96 $ta.cancel_selection();
97 }
98 $ta.move_cursor($mv);
99 }};
100}
101
102use self::backend::BackendState;
103use self::markdown::ParsedBuffer;
104use self::snapshot::{EditorSnapshot, NvimMode};
105use self::view::MarkdownEditorView;
106use crate::util::single_slot_task::SingleSlotTask;
107
108fn increment_ordered_marker(marker: &str) -> Option<String> {
111 let trimmed = marker.trim_end_matches(' ');
112 let dot = trimmed.strip_suffix('.')?;
113 let n: u32 = dot.parse().ok()?;
114 Some(format!("{}. ", n + 1))
115}
116
117fn char_col_to_byte(line: &str, char_col: usize) -> usize {
120 line.char_indices()
121 .nth(char_col)
122 .map(|(b, _)| b)
123 .unwrap_or(line.len())
124}
125
126fn selection_text(ta: &TextArea<'_>) -> Option<String> {
132 let ((sr, sc), (er, ec)) = ta.selection_range()?;
133 if sr == er && sc == ec {
134 return None;
135 }
136 let lines = ta.lines();
137 Some(if sr == er {
138 let line = &lines[sr];
139 let sb = char_col_to_byte(line, sc);
140 let eb = char_col_to_byte(line, ec);
141 line[sb..eb].to_string()
142 } else {
143 let first = &lines[sr];
144 let sb = char_col_to_byte(first, sc);
145 let mut parts = vec![first[sb..].to_string()];
146 for line in &lines[(sr + 1)..er] {
147 parts.push(line.clone());
148 }
149 let last = &lines[er];
150 let eb = char_col_to_byte(last, ec);
151 parts.push(last[..eb].to_string());
152 parts.join("\n")
153 })
154}
155
156fn surround_pair(c: char) -> Option<(&'static str, &'static str)> {
161 match c {
162 '(' => Some(("(", ")")),
163 '[' => Some(("[", "]")),
164 '{' => Some(("{", "}")),
165 '<' => Some(("<", ">")),
166 '"' => Some(("\"", "\"")),
167 '\'' => Some(("'", "'")),
168 '`' => Some(("`", "`")),
169 '*' => Some(("*", "*")),
170 '_' => Some(("_", "_")),
171 '~' => Some(("~", "~")),
172 _ => None,
173 }
174}
175
176fn set_selection(ta: &mut TextArea<'_>, start: (usize, usize), end: (usize, usize)) {
180 let jump = |(row, col): (usize, usize)| {
181 CursorMove::Jump(
182 u16::try_from(row).unwrap_or(u16::MAX),
183 u16::try_from(col).unwrap_or(u16::MAX),
184 )
185 };
186 ta.cancel_selection();
187 ta.move_cursor(jump(start));
188 ta.start_selection();
189 ta.move_cursor(jump(end));
190}
191
192#[derive(Debug, Clone)]
196pub struct ClipboardImage {
197 pub width: usize,
198 pub height: usize,
199 pub rgba: Vec<u8>,
200}
201
202const LINKABLE_PASTE_SCHEMES: &[&str] = &["http", "https", "ftp", "ftps", "mailto"];
206
207fn linkable_url(s: &str) -> Option<&str> {
208 kimun_core::note::scan::url_with_allowed_scheme(s, LINKABLE_PASTE_SCHEMES)
209}
210
211fn try_build_markdown_link(clip: &str, selection: Option<&str>) -> Option<String> {
215 let url = linkable_url(clip)?;
216 let sel = selection.filter(|s| !s.is_empty())?;
217 let escaped = sel.replace('\\', r"\\").replace(']', r"\]");
218 Some(format!("[{escaped}]({url})"))
219}
220
221use std::sync::Arc;
222
223use kimun_core::NoteVault;
224
225use crate::components::Component;
226use crate::components::autocomplete::{
227 self, AutocompleteController, AutocompleteHost, AutocompleteMode, HandleKeyOutcome,
228};
229use crate::components::event_state::EventState;
230use crate::components::events::AppEvent;
231use crate::components::events::AppTx;
232use crate::components::events::InputEvent;
233use crate::components::events::redraw_callback;
234use crate::components::single_line_input::{InputOutcome, SingleLineInput};
235use crate::components::text_editor::autocomplete_glue::apply_accept_to_textarea;
236use crate::keys::KeyBindings;
237use crate::keys::action_shortcuts::TextAction;
238use crate::settings::AppSettings;
239use crate::settings::themes::Theme;
240
241#[derive(Debug, Clone, PartialEq)]
243pub enum LinkTarget {
244 Note(String),
246 Label(String),
248}
249
250struct SearchState {
251 input: SingleLineInput,
252 status: SearchStatus,
253}
254
255enum SearchStatus {
256 Empty,
257 Match,
258 NoMatch,
259 Invalid(String),
260}
261
262impl SearchStatus {
263 fn from_found(found: bool) -> Self {
264 if found { Self::Match } else { Self::NoMatch }
265 }
266}
267
268const FIND_PROMPT: &str = "Find: ";
269const FIND_HINTS: &str = " [Enter] next [Shift+Enter] prev [Esc] close";
270
271fn render_search_bar(
272 f: &mut Frame,
273 rect: Rect,
274 state: &mut SearchState,
275 theme: &Theme,
276 focused: bool,
277) {
278 let base = theme.base_style();
279 let muted = Style::default()
280 .fg(theme.fg_muted.to_ratatui())
281 .bg(theme.bg.to_ratatui());
282 let err = Style::default()
283 .fg(ratatui::style::Color::Red)
284 .bg(theme.bg.to_ratatui());
285 let prompt_cols = unicode_width::UnicodeWidthStr::width(FIND_PROMPT) as u16;
286 let value_total_cols = state.input.display_width() as u16;
290 let tail: Option<(String, Style)> = match &state.status {
291 SearchStatus::Empty => None,
292 SearchStatus::Match => Some((FIND_HINTS.to_string(), muted)),
293 SearchStatus::NoMatch => Some((" no match".to_string(), err)),
294 SearchStatus::Invalid(msg) => Some((format!(" invalid regex: {msg}"), err)),
295 };
296 f.render_widget(
297 Paragraph::new(Line::from(Span::styled(
298 FIND_PROMPT,
299 base.add_modifier(Modifier::BOLD),
300 )))
301 .style(base),
302 Rect {
303 width: prompt_cols.min(rect.width),
304 ..rect
305 },
306 );
307 state.input.render(f, rect, base, prompt_cols, focused);
308 if let Some((text, style)) = tail {
309 let consumed = prompt_cols.saturating_add(value_total_cols);
310 let tail_rect = Rect {
311 x: rect.x.saturating_add(consumed),
312 width: rect.width.saturating_sub(consumed),
313 ..rect
314 };
315 f.render_widget(Paragraph::new(text).style(style), tail_rect);
316 }
317}
318
319struct EditorHostSnapshot<'a> {
326 snap: EditorSnapshot<'a>,
327 cursor_screen: Option<(u16, u16)>,
328 cache_key: Option<NonZeroU64>,
329}
330
331impl<'a> AutocompleteHost for EditorHostSnapshot<'a> {
332 fn buffer_snapshot(&self) -> EditorSnapshot<'_> {
333 EditorSnapshot::borrowed(
338 self.snap.lines.as_ref(),
339 self.snap.cursor,
340 self.snap.content_revision,
341 )
342 }
343 fn cache_key(&self) -> Option<NonZeroU64> {
344 self.cache_key
345 }
346 fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
347 Some(self.cursor_screen.unwrap_or((0, 0)))
361 }
362}
363
364fn build_editor_host_snapshot<'a>(
370 backend: &'a BackendState,
371 content_revision: NonZeroU64,
372 cursor_screen: Option<(u16, u16)>,
373) -> Option<EditorHostSnapshot<'a>> {
374 if !matches!(backend, BackendState::Textarea(_)) {
375 return None;
376 }
377 Some(EditorHostSnapshot {
378 snap: snapshot_from_backend(backend, content_revision),
379 cursor_screen,
380 cache_key: Some(content_revision),
381 })
382}
383
384pub struct TextEditorComponent {
388 backend: BackendState,
389 rect: Rect,
391 key_bindings: KeyBindings,
392 saved_content_rev: Option<NonZeroU64>,
401 view: MarkdownEditorView,
402 edit_generation: u64,
407 content_revision: NonZeroU64,
430 selection: Option<((usize, usize), (usize, usize))>,
433 clipboard: Option<Clipboard>,
435 nvim_pending_z: bool,
438 search: Option<SearchState>,
440 autocomplete: Option<AutocompleteController>,
444 autocomplete_vault: Option<Arc<NoteVault>>,
448 autocomplete_redraw_bound: bool,
453 full_parse_task: SingleSlotTask<()>,
460 full_parse_tx: tokio::sync::mpsc::UnboundedSender<(u64, ParsedBuffer)>,
461 full_parse_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, ParsedBuffer)>,
462 redraw_tx: Option<AppTx>,
466}
467
468impl TextEditorComponent {
469 pub fn new(key_bindings: KeyBindings, settings: &AppSettings) -> Self {
470 let (full_parse_tx, full_parse_rx) = tokio::sync::mpsc::unbounded_channel();
471 Self {
472 backend: BackendState::from_settings(
473 &settings.editor_backend,
474 settings.nvim_path.as_ref(),
475 ),
476 rect: Rect::default(),
477 key_bindings,
478 saved_content_rev: NonZeroU64::new(1),
479 view: MarkdownEditorView::new(),
480 edit_generation: 0,
481 content_revision: NonZeroU64::new(1).unwrap(),
482 selection: None,
483 clipboard: Clipboard::new().ok(),
484 nvim_pending_z: false,
485 search: None,
486 autocomplete: None,
487 autocomplete_vault: None,
488 autocomplete_redraw_bound: false,
489 full_parse_task: SingleSlotTask::empty(),
490 full_parse_tx,
491 full_parse_rx,
492 redraw_tx: None,
493 }
494 }
495
496 pub fn set_vault(&mut self, vault: Arc<NoteVault>) {
501 self.autocomplete_vault = Some(vault.clone());
502 if matches!(self.backend, BackendState::Textarea(_)) {
503 self.autocomplete = Some(AutocompleteController::new(
504 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
505 AutocompleteMode::Both,
506 ));
507 }
508 }
509
510 fn ensure_autocomplete_for_textarea(&mut self) {
515 if self.autocomplete.is_some() {
516 return;
517 }
518 if !matches!(self.backend, BackendState::Textarea(_)) {
519 return;
520 }
521 let Some(vault) = self.autocomplete_vault.clone() else {
522 return;
523 };
524 self.autocomplete = Some(AutocompleteController::new(
525 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
526 AutocompleteMode::Both,
527 ));
528 self.autocomplete_redraw_bound = false;
531 }
532
533 #[allow(dead_code)]
540 fn autocomplete_host_snapshot(&self) -> Option<EditorHostSnapshot<'_>> {
541 build_editor_host_snapshot(
542 &self.backend,
543 self.content_revision,
544 self.view.last_cursor_screen,
545 )
546 }
547
548 fn poll_autocomplete(&mut self) {
551 if let Some(controller) = self.autocomplete.as_mut() {
552 controller.poll_results();
553 }
554 }
555
556 fn textarea_cursor(&self) -> Option<(usize, usize)> {
560 let BackendState::Textarea(ta) = &self.backend else {
561 return None;
562 };
563 Some(cursor_tuple(ta))
564 }
565
566 fn refresh_autocomplete_if_open(&mut self) {
567 if !self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
569 return;
570 }
571 let Some(snapshot) = build_editor_host_snapshot(
575 &self.backend,
576 self.content_revision,
577 self.view.last_cursor_screen,
578 ) else {
579 self.close_autocomplete();
580 return;
581 };
582 if let Some(controller) = self.autocomplete.as_mut() {
583 controller.refresh_if_open(&snapshot);
584 }
585 }
586
587 fn sync_autocomplete(&mut self) {
591 let Some(controller) = self.autocomplete.as_ref() else {
592 return; };
594
595 if !controller.is_open() {
608 let BackendState::Textarea(ta) = &self.backend else {
609 return;
610 };
611 let (row, col) = cursor_tuple(ta);
612 let line = ta.lines().get(row).map(|s| s.as_str()).unwrap_or("");
613 if !has_trigger_before_cursor(line, col) {
614 return;
615 }
616 }
617
618 let Some(snapshot) = build_editor_host_snapshot(
622 &self.backend,
623 self.content_revision,
624 self.view.last_cursor_screen,
625 ) else {
626 if let Some(c) = self.autocomplete.as_mut() {
627 c.close();
628 }
629 return;
630 };
631 if let Some(controller) = self.autocomplete.as_mut() {
632 controller.sync(&snapshot);
633 }
634 }
635
636 pub fn lines(&self) -> &[String] {
642 match &self.backend {
643 BackendState::Textarea(ta) => ta.lines(),
644 BackendState::Nvim(_) => &[],
645 }
646 }
647
648 pub fn view_snapshot(&self) -> EditorSnapshot<'_> {
667 snapshot_from_backend(&self.backend, self.content_revision)
668 }
669
670 pub fn set_text(&mut self, text: String) {
671 if text == self.get_text() {
678 self.saved_content_rev = Some(self.content_revision);
679 if let BackendState::Nvim(nvim) = &self.backend {
680 nvim.snapshot
681 .lock()
682 .unwrap_or_else(|p| p.into_inner())
683 .dirty = false;
684 }
685 return;
686 }
687 match &mut self.backend {
688 BackendState::Textarea(ta) => {
689 let lines = text.lines();
690 *ta = TextArea::from(lines);
691 }
692 BackendState::Nvim(nvim) => {
693 nvim.set_text(&text);
694 }
695 }
696 self.bump_content();
697 let reconstructed = self.get_text();
698 self.mark_saved(reconstructed);
699 self.close_autocomplete();
702 }
703
704 pub fn get_text(&self) -> String {
705 match &self.backend {
706 BackendState::Textarea(ta) => ta.lines().join("\n"),
707 BackendState::Nvim(nvim) => nvim
708 .snapshot
709 .lock()
710 .unwrap_or_else(|p| p.into_inner())
711 .lines
712 .join("\n"),
713 }
714 }
715
716 pub fn content_revision(&self) -> NonZeroU64 {
723 self.content_revision
724 }
725
726 pub fn mark_saved_at_revision(&mut self, rev: NonZeroU64) {
736 if rev != self.content_revision {
737 return;
738 }
739 if let BackendState::Nvim(nvim) = &self.backend {
740 nvim.snapshot
741 .lock()
742 .unwrap_or_else(|p| p.into_inner())
743 .dirty = false;
744 }
745 self.saved_content_rev = Some(rev);
746 }
747
748 pub fn mark_saved(&mut self, text: String) {
756 let matches = text == self.get_text();
757 if matches {
758 if let BackendState::Nvim(nvim) = &self.backend {
759 nvim.snapshot
760 .lock()
761 .unwrap_or_else(|p| p.into_inner())
762 .dirty = false;
763 }
764 self.saved_content_rev = Some(self.content_revision);
765 } else {
766 self.saved_content_rev = None;
771 }
772 }
773
774 pub fn is_dirty(&self) -> bool {
775 match &self.backend {
776 BackendState::Textarea(_) => self.saved_content_rev != Some(self.content_revision),
777 BackendState::Nvim(nvim) => {
778 nvim.snapshot
779 .lock()
780 .unwrap_or_else(|p| p.into_inner())
781 .dirty
782 }
783 }
784 }
785
786 pub fn link_at_cursor(&self) -> Option<LinkTarget> {
789 let (_row, col, line) = match &self.backend {
790 BackendState::Textarea(ta) => {
791 let (row, col) = cursor_tuple(ta);
792 let line = ta.lines().get(row)?.to_string();
793 (row, col, line)
794 }
795 BackendState::Nvim(nvim) => {
796 let snap = nvim.snapshot.lock().unwrap_or_else(|p| p.into_inner());
797 let (row, col) = snap.cursor;
798 let line = snap.lines.get(row)?.to_string();
799 (row, col, line)
800 }
801 };
802
803 if let Some(span) = kimun_core::note::scan::link_char_spans(&line)
806 .into_iter()
807 .find(|s| s.start <= col && col < s.end)
808 {
809 return Some(LinkTarget::Note(span.target));
810 }
811
812 let parsed = self::markdown::ParsedLine::parse(&line);
814 parsed
815 .elements
816 .iter()
817 .find(|e| {
818 e.kind == self::markdown::ElementKind::Label
819 && col >= e.start_char
820 && col < e.end_char
821 })
822 .map(|e| {
823 let span: String = line
824 .chars()
825 .skip(e.start_char)
826 .take(e.end_char - e.start_char)
827 .collect();
828 let name = span.trim_start_matches('#').to_string();
829 LinkTarget::Label(name)
830 })
831 }
832
833 fn copy_selection_to_clipboard(&mut self) {
835 let text = {
836 let BackendState::Textarea(ta) = &self.backend else {
837 return;
838 };
839 match selection_text(ta) {
840 Some(t) => t,
841 None => return,
842 }
843 };
844 if let Some(cb) = &mut self.clipboard {
845 let _ = cb.set_text(text);
846 }
847 }
848
849 fn paste_from_clipboard(&mut self, tx: &AppTx) {
851 let text = match &mut self.clipboard {
852 Some(cb) => match cb.get_text() {
853 Ok(t) if !t.is_empty() => t,
854 _ => return,
855 },
856 None => return,
857 };
858 self.paste_text(&text, tx);
859 }
860
861 pub fn paste_text(&mut self, text: &str, tx: &AppTx) {
870 if text.is_empty() {
871 return;
872 }
873 match &mut self.backend {
874 BackendState::Textarea(ta) => {
875 let selection = linkable_url(text).and_then(|_| selection_text(ta));
876 let wrapped = try_build_markdown_link(text, selection.as_deref());
877 if ta.selection_range().is_some() {
878 ta.cut();
879 }
880 ta.insert_str(wrapped.as_deref().unwrap_or(text));
881 self.selection = ta.selection_range();
882 self.bump_content();
883 }
884 BackendState::Nvim(nvim) => {
885 nvim.paste(text, tx.clone());
886 self.bump_content();
887 }
888 }
889 self.bind_autocomplete_redraw(tx);
893 self.sync_autocomplete();
894 }
895
896 pub fn insert_at_cursor(&mut self, text: &str, tx: &AppTx) {
901 if matches!(self.backend, BackendState::Nvim(_)) {
902 self.paste_text(text, tx);
903 return;
904 }
905 if let BackendState::Textarea(ta) = &mut self.backend {
906 if ta.selection_range().is_some() {
907 ta.cut();
908 }
909 ta.insert_str(text);
910 self.selection = ta.selection_range();
911 self.bump_content();
912 }
913 self.bind_autocomplete_redraw(tx);
916 self.sync_autocomplete();
917 }
918
919 pub fn take_clipboard_image(&mut self) -> Option<ClipboardImage> {
923 let cb = self.clipboard.as_mut()?;
924 let img = cb.get_image().ok()?;
925 Some(ClipboardImage {
926 width: img.width,
927 height: img.height,
928 rgba: img.bytes.into_owned(),
929 })
930 }
931
932 fn wrap_selection(&mut self, open: &str, close: &str) -> bool {
938 let BackendState::Textarea(ta) = &mut self.backend else {
939 return false;
940 };
941 let Some(((sr, sc), (er, ec))) = ta.selection_range() else {
942 return false;
943 };
944 let Some(text) = selection_text(ta) else {
945 return false;
946 };
947 ta.insert_str(format!("{open}{text}{close}"));
948 let shift = open.chars().count();
952 let inner_end_col = if sr == er { ec + shift } else { ec };
953 set_selection(ta, (sr, sc + shift), (er, inner_end_col));
954 self.selection = ta.selection_range();
955 self.bump_content();
956 true
957 }
958
959 pub fn apply_text_action(&mut self, action: TextAction) {
962 let marker = match action {
963 TextAction::Bold => "**",
964 TextAction::Italic => "*",
965 TextAction::Strikethrough => "~~",
966 _ => return,
967 };
968 if self.wrap_selection(marker, marker) {
969 return;
970 }
971 let BackendState::Textarea(ta) = &mut self.backend else {
972 return;
973 };
974 ta.insert_str(format!("{marker}{marker}"));
975 for _ in 0..marker.len() {
976 ta.move_cursor(CursorMove::Back);
977 }
978 self.selection = ta.selection_range();
979 self.bump_content();
980 }
981
982 pub fn smart_enter(&mut self) -> bool {
987 enum Action {
988 ClearLine { chars: usize },
989 InsertPrefix(String),
990 Dedent,
991 }
992 let action = {
993 let BackendState::Textarea(ta) = &self.backend else {
994 return false;
995 };
996 if ta.selection_range().is_some() {
997 return false;
998 }
999 let (row, col) = cursor_tuple(ta);
1000 let Some(line) = ta.lines().get(row) else {
1001 return false;
1002 };
1003 let total_chars = line.chars().count();
1004 if col != total_chars {
1005 return false;
1006 }
1007 let ws_end = markdown::leading_ws_byte_len(line);
1009 let (ws, after_ws) = line.split_at(ws_end);
1010 if let Some(marker_len) = markdown::list_marker_len(after_ws) {
1011 if after_ws.len() == marker_len {
1012 if ws_end > 0 {
1015 Action::Dedent
1016 } else {
1017 Action::ClearLine { chars: total_chars }
1018 }
1019 } else {
1020 let marker_str = &after_ws[..marker_len];
1021 let next_marker = increment_ordered_marker(marker_str)
1022 .unwrap_or_else(|| marker_str.to_string());
1023 Action::InsertPrefix(format!("{ws}{next_marker}"))
1024 }
1025 } else if ws_end > 0 && total_chars == ws_end {
1026 Action::Dedent
1027 } else if ws_end > 0 {
1028 Action::InsertPrefix(ws.to_string())
1029 } else {
1030 return false;
1031 }
1032 };
1033
1034 match action {
1035 Action::Dedent => {
1036 self.indent_lines(true);
1037 return true;
1038 }
1039 Action::ClearLine { chars } => {
1040 let BackendState::Textarea(ta) = &mut self.backend else {
1041 unreachable!()
1042 };
1043 ta.move_cursor(CursorMove::Head);
1044 ta.delete_str(chars);
1045 }
1046 Action::InsertPrefix(prefix) => {
1047 let BackendState::Textarea(ta) = &mut self.backend else {
1048 unreachable!()
1049 };
1050 ta.insert_newline();
1051 ta.insert_str(prefix);
1052 }
1053 }
1054 let BackendState::Textarea(ta) = &self.backend else {
1055 unreachable!()
1056 };
1057 self.selection = ta.selection_range();
1058 self.bump_content();
1059 true
1060 }
1061
1062 pub fn indent_lines(&mut self, dedent: bool) {
1066 let BackendState::Textarea(ta) = &mut self.backend else {
1067 return;
1068 };
1069 let tab_len = ta.tab_length() as usize;
1070 let hard_tab = ta.hard_tab_indent();
1071 let indent: String = if hard_tab {
1072 "\t".to_string()
1073 } else {
1074 " ".repeat(tab_len)
1075 };
1076 if indent.is_empty() {
1077 return;
1078 }
1079 let indent_chars = indent.len();
1080
1081 let sel = ta.selection_range();
1082 let saved_cursor = if sel.is_none() {
1083 Some(cursor_tuple(ta))
1084 } else {
1085 None
1086 };
1087 let (start_row, end_row) = match sel {
1088 Some(((sr, _), (er, ec))) => {
1089 let last = if ec == 0 && er > sr { er - 1 } else { er };
1092 (sr, last)
1093 }
1094 None => {
1095 let (r, _) = saved_cursor.unwrap();
1096 (r, r)
1097 }
1098 };
1099
1100 let row_count = end_row.saturating_sub(start_row) + 1;
1101 let mut row_deltas: Vec<isize> = Vec::with_capacity(row_count);
1102 let mut any_change = false;
1103
1104 for row in start_row..=end_row {
1105 if dedent {
1106 let count = {
1107 let line = ta.lines().get(row).map(|s| s.as_str()).unwrap_or("");
1108 let max_remove = if hard_tab { 1 } else { tab_len };
1109 let mut count = 0usize;
1110 for (i, c) in line.chars().enumerate() {
1111 if i >= max_remove {
1112 break;
1113 }
1114 if c == '\t' {
1115 count += 1;
1116 break;
1117 } else if c == ' ' && !hard_tab {
1118 count += 1;
1119 } else {
1120 break;
1121 }
1122 }
1123 count
1124 };
1125 if count > 0 {
1126 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1127 ta.delete_str(count);
1128 any_change = true;
1129 }
1130 row_deltas.push(-(count as isize));
1131 } else {
1132 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1133 ta.insert_str(&indent);
1134 row_deltas.push(indent_chars as isize);
1135 any_change = true;
1136 }
1137 }
1138
1139 let adj = |row: usize, col: usize| -> usize {
1140 if row >= start_row && row <= end_row {
1141 let d = row_deltas[row - start_row];
1142 if d >= 0 {
1143 col + d as usize
1144 } else {
1145 col.saturating_sub((-d) as usize)
1146 }
1147 } else {
1148 col
1149 }
1150 };
1151
1152 match sel {
1153 Some(((ssr, ssc), (ser, sec))) => {
1154 set_selection(ta, (ssr, adj(ssr, ssc)), (ser, adj(ser, sec)));
1155 }
1156 None => {
1157 let (cr, cc) = saved_cursor.expect("captured when sel is None");
1158 let new_col = adj(cr, cc);
1159 ta.move_cursor(CursorMove::Jump(cr as u16, new_col as u16));
1160 }
1161 }
1162
1163 if any_change {
1164 self.selection = ta.selection_range();
1165 self.bump_content();
1166 }
1167 }
1168}
1169
1170impl TextEditorComponent {
1171 #[inline]
1176 fn bump_cursor(&mut self) {
1177 self.edit_generation = self.edit_generation.wrapping_add(1);
1178 }
1179
1180 #[inline]
1190 fn bump_content(&mut self) {
1191 self.edit_generation = self.edit_generation.wrapping_add(1);
1192 let next = self.content_revision.get().wrapping_add(1);
1197 self.content_revision = NonZeroU64::new(next).unwrap_or(NonZeroU64::new(1).unwrap());
1198 }
1199
1200 fn maybe_recover_from_dead_nvim(&mut self) {
1202 use std::sync::atomic::Ordering;
1203 let fallback_text = if let BackendState::Nvim(nvim) = &self.backend {
1204 if nvim.is_dead.load(Ordering::SeqCst) {
1205 Some(
1206 nvim.snapshot
1207 .lock()
1208 .unwrap_or_else(|p| p.into_inner())
1209 .lines
1210 .join("\n"),
1211 )
1212 } else {
1213 None
1214 }
1215 } else {
1216 None
1217 };
1218 if let Some(text) = fallback_text {
1219 tracing::warn!("nvim process died; falling back to textarea backend");
1220 self.backend = BackendState::Textarea(TextArea::from(text.lines()));
1221 self.ensure_autocomplete_for_textarea();
1225 }
1226 }
1227
1228 fn handle_nvim_key(
1233 &mut self,
1234 key: &ratatui::crossterm::event::KeyEvent,
1235 tx: &AppTx,
1236 ) -> Option<EventState> {
1237 let BackendState::Nvim(nvim) = &self.backend else {
1238 return None;
1239 };
1240
1241 if self.nvim_pending_z {
1247 self.nvim_pending_z = false;
1248 match key.code {
1249 KeyCode::Char('Z') => {
1250 tx.send(AppEvent::Autosave).ok();
1252 tx.send(AppEvent::FocusSidebar).ok();
1253 return Some(EventState::Consumed);
1254 }
1255 KeyCode::Char('Q') => {
1256 tx.send(AppEvent::FocusSidebar).ok();
1258 return Some(EventState::Consumed);
1259 }
1260 _ => {
1261 nvim.handle_key(
1263 &ratatui::crossterm::event::KeyEvent::new(
1264 KeyCode::Char('Z'),
1265 KeyModifiers::NONE,
1266 ),
1267 tx.clone(),
1268 );
1269 }
1271 }
1272 } else if key.code == KeyCode::Char('Z') {
1273 let in_normal = {
1274 let snap = nvim.snapshot.lock().unwrap_or_else(|p| p.into_inner());
1275 snap.mode == NvimMode::Normal
1276 };
1277 if in_normal {
1278 self.nvim_pending_z = true;
1279 return Some(EventState::Consumed);
1280 }
1281 }
1282
1283 if key.code == KeyCode::Enter {
1286 let (is_cmd, cmdline) = {
1287 let snap = nvim.snapshot.lock().unwrap_or_else(|p| p.into_inner());
1288 let cmd = if snap.mode == NvimMode::Command {
1289 snap.cmdline
1290 .as_deref()
1291 .unwrap_or("")
1292 .trim_start_matches(':')
1293 .to_string()
1294 } else {
1295 String::new()
1296 };
1297 (snap.mode == NvimMode::Command, cmd)
1298 };
1299 if is_cmd {
1300 let saves = matches!(
1301 cmdline.as_str(),
1302 "w" | "wq" | "wq!" | "wqa" | "wqa!" | "x" | "xa" | "x!"
1303 );
1304 let quits =
1305 saves || matches!(cmdline.as_str(), "q" | "q!" | "qa" | "qa!" | "cq" | "cq!");
1306 if quits {
1307 nvim.handle_key(
1308 &ratatui::crossterm::event::KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
1309 tx.clone(),
1310 );
1311 if saves {
1312 tx.send(AppEvent::Autosave).ok();
1313 }
1314 tx.send(AppEvent::FocusSidebar).ok();
1315 return Some(EventState::Consumed);
1316 }
1317 }
1318 }
1319
1320 nvim.handle_key(key, tx.clone());
1321 self.bump_cursor();
1331 Some(EventState::Consumed)
1332 }
1333
1334 pub fn open_or_advance_search(&mut self) {
1338 if !matches!(self.backend, BackendState::Textarea(_)) {
1339 return;
1340 }
1341 if self.search.is_some() {
1342 self.search_advance(false);
1343 return;
1344 }
1345 self.close_autocomplete();
1349 self.search = Some(SearchState {
1350 input: SingleLineInput::new(),
1351 status: SearchStatus::Empty,
1352 });
1353 }
1354
1355 pub fn close_autocomplete(&mut self) {
1359 if let Some(c) = self.autocomplete.as_mut() {
1360 c.close();
1361 }
1362 }
1363
1364 pub fn set_redraw_tx(&mut self, tx: &AppTx) {
1369 self.bind_autocomplete_redraw(tx);
1370 }
1371
1372 fn bind_autocomplete_redraw(&mut self, tx: &AppTx) {
1381 if self.redraw_tx.is_none() {
1382 self.redraw_tx = Some(tx.clone());
1383 }
1384 if self.autocomplete_redraw_bound {
1385 return;
1386 }
1387 if let Some(c) = self.autocomplete.as_mut() {
1388 c.set_redraw_callback(redraw_callback(tx.clone()));
1389 self.autocomplete_redraw_bound = true;
1390 }
1391 }
1392
1393 fn close_search(&mut self) {
1394 if let BackendState::Textarea(ta) = &mut self.backend {
1395 let _ = ta.set_search_pattern("");
1396 }
1397 self.search = None;
1398 self.selection = None;
1399 }
1400
1401 fn refresh_search_pattern(&mut self, jump: bool) {
1404 let Some(state) = self.search.as_mut() else {
1405 return;
1406 };
1407 let BackendState::Textarea(ta) = &mut self.backend else {
1408 return;
1409 };
1410 if state.input.is_empty() {
1411 let _ = ta.set_search_pattern("");
1412 state.status = SearchStatus::Empty;
1413 self.selection = None;
1414 return;
1415 }
1416 if let Err(e) = ta.set_search_pattern(state.input.value()) {
1417 state.status = SearchStatus::Invalid(e.to_string());
1418 self.selection = None;
1419 return;
1420 }
1421 if !jump {
1422 state.status = SearchStatus::Match;
1423 return;
1424 }
1425 let found = ta.search_forward(true);
1426 state.status = SearchStatus::from_found(found);
1427 self.highlight_current_match(found);
1428 }
1429
1430 fn search_advance(&mut self, backward: bool) {
1431 let Some(state) = self.search.as_mut() else {
1432 return;
1433 };
1434 if state.input.is_empty() {
1435 return;
1436 }
1437 let BackendState::Textarea(ta) = &mut self.backend else {
1438 return;
1439 };
1440 let found = if backward {
1441 ta.search_back(false)
1442 } else {
1443 ta.search_forward(false)
1444 };
1445 state.status = SearchStatus::from_found(found);
1446 self.highlight_current_match(found);
1447 }
1448
1449 fn highlight_current_match(&mut self, found: bool) {
1454 self.selection = if found {
1455 self.compute_match_selection()
1456 } else {
1457 None
1458 };
1459 }
1460
1461 fn compute_match_selection(&self) -> Option<((usize, usize), (usize, usize))> {
1467 let BackendState::Textarea(ta) = &self.backend else {
1468 return None;
1469 };
1470 let re = ta.search_pattern()?;
1471 let DataCursor(row, col_chars) = ta.cursor();
1472 let line = ta.lines().get(row)?;
1473 let byte_off = char_col_to_byte(line, col_chars);
1474 let m = re.find_at(line, byte_off)?;
1475 if m.start() != byte_off {
1476 return None;
1477 }
1478 let match_chars = line[m.range()].chars().count();
1479 Some(((row, col_chars), (row, col_chars + match_chars)))
1480 }
1481
1482 fn handle_search_key(&mut self, key: &ratatui::crossterm::event::KeyEvent) -> bool {
1484 let Some(state) = self.search.as_mut() else {
1485 return false;
1486 };
1487 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1488 match state.input.handle_key(key) {
1489 InputOutcome::Cancel => self.close_search(),
1490 InputOutcome::Submit => self.search_advance(shift),
1491 InputOutcome::Changed => self.refresh_search_pattern(true),
1492 InputOutcome::Consumed | InputOutcome::NotConsumed => {}
1493 }
1494 true
1495 }
1496
1497 fn handle_textarea_key(
1499 &mut self,
1500 key: &ratatui::crossterm::event::KeyEvent,
1501 tx: &AppTx,
1502 ) -> EventState {
1503 if self.handle_search_key(key) {
1505 return EventState::Consumed;
1506 }
1507
1508 if key.modifiers == KeyModifiers::CONTROL {
1510 match key.code {
1511 KeyCode::Char('c') => {
1512 self.copy_selection_to_clipboard();
1513 return EventState::Consumed;
1514 }
1515 KeyCode::Char('v') => {
1516 self.paste_from_clipboard(tx);
1517 return EventState::Consumed;
1518 }
1519 KeyCode::Char('x') => {
1520 self.copy_selection_to_clipboard();
1521 let cut = if let BackendState::Textarea(ta) = &mut self.backend {
1522 let cut = ta.cut();
1528 self.selection = ta.selection_range();
1529 cut
1530 } else {
1531 false
1532 };
1533 if cut {
1534 self.bump_content();
1535 }
1536 return EventState::Consumed;
1537 }
1538 _ => {}
1539 }
1540 }
1541
1542 let BackendState::Textarea(ta) = &mut self.backend else {
1543 unreachable!("handle_textarea_key called with non-Textarea backend")
1544 };
1545
1546 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1548 let handled = match (key.modifiers & !KeyModifiers::SHIFT, key.code) {
1549 (KeyModifiers::ALT, KeyCode::Left) => {
1550 cursor_move!(ta, CursorMove::WordBack, shift);
1551 true
1552 }
1553 (KeyModifiers::ALT, KeyCode::Right) => {
1554 cursor_move!(ta, CursorMove::WordForward, shift);
1555 true
1556 }
1557 (KeyModifiers::SUPER, KeyCode::Left) => {
1558 cursor_move!(ta, CursorMove::Head, shift);
1559 true
1560 }
1561 (KeyModifiers::SUPER, KeyCode::Right) => {
1562 cursor_move!(ta, CursorMove::End, shift);
1563 true
1564 }
1565 (KeyModifiers::SUPER, KeyCode::Up) => {
1566 cursor_move!(ta, CursorMove::Top, shift);
1567 true
1568 }
1569 (KeyModifiers::SUPER, KeyCode::Down) => {
1570 cursor_move!(ta, CursorMove::Bottom, shift);
1571 true
1572 }
1573 _ => false,
1574 };
1575 if handled {
1576 self.selection = ta.selection_range();
1577 self.bump_cursor();
1578 return EventState::Consumed;
1579 }
1580
1581 enum ShortcutOutcome {
1592 NoOp,
1593 CursorOnly,
1594 TextMutated,
1595 }
1596 let outcome: Option<ShortcutOutcome> =
1597 match (key.modifiers & !KeyModifiers::SHIFT, key.code) {
1598 (KeyModifiers::NONE, KeyCode::Left) => {
1600 cursor_move!(ta, CursorMove::Back, shift);
1601 Some(ShortcutOutcome::CursorOnly)
1602 }
1603 (KeyModifiers::NONE, KeyCode::Right) => {
1604 cursor_move!(ta, CursorMove::Forward, shift);
1605 Some(ShortcutOutcome::CursorOnly)
1606 }
1607 (KeyModifiers::NONE, KeyCode::Up) => {
1608 cursor_move!(ta, CursorMove::Up, shift);
1609 Some(ShortcutOutcome::CursorOnly)
1610 }
1611 (KeyModifiers::NONE, KeyCode::Down) => {
1612 cursor_move!(ta, CursorMove::Down, shift);
1613 Some(ShortcutOutcome::CursorOnly)
1614 }
1615 (KeyModifiers::NONE, KeyCode::Home) => {
1616 cursor_move!(ta, CursorMove::Head, shift);
1617 Some(ShortcutOutcome::CursorOnly)
1618 }
1619 (KeyModifiers::NONE, KeyCode::End) => {
1620 cursor_move!(ta, CursorMove::End, shift);
1621 Some(ShortcutOutcome::CursorOnly)
1622 }
1623 (KeyModifiers::NONE, KeyCode::PageUp) => {
1624 cursor_move!(ta, CursorMove::ParagraphBack, shift);
1625 Some(ShortcutOutcome::CursorOnly)
1626 }
1627 (KeyModifiers::NONE, KeyCode::PageDown) => {
1628 cursor_move!(ta, CursorMove::ParagraphForward, shift);
1629 Some(ShortcutOutcome::CursorOnly)
1630 }
1631 (KeyModifiers::CONTROL, KeyCode::Left) => {
1633 cursor_move!(ta, CursorMove::WordBack, shift);
1634 Some(ShortcutOutcome::CursorOnly)
1635 }
1636 (KeyModifiers::CONTROL, KeyCode::Right) => {
1637 cursor_move!(ta, CursorMove::WordForward, shift);
1638 Some(ShortcutOutcome::CursorOnly)
1639 }
1640 (KeyModifiers::CONTROL, KeyCode::Home) => {
1642 cursor_move!(ta, CursorMove::Top, shift);
1643 Some(ShortcutOutcome::CursorOnly)
1644 }
1645 (KeyModifiers::CONTROL, KeyCode::End) => {
1646 cursor_move!(ta, CursorMove::Bottom, shift);
1647 Some(ShortcutOutcome::CursorOnly)
1648 }
1649 (KeyModifiers::CONTROL, KeyCode::Char('z')) => {
1653 if ta.undo() {
1654 Some(ShortcutOutcome::TextMutated)
1655 } else {
1656 Some(ShortcutOutcome::NoOp)
1657 }
1658 }
1659 (KeyModifiers::CONTROL, KeyCode::Char('y'))
1660 | (KeyModifiers::CONTROL, KeyCode::Char('Z')) => {
1661 if ta.redo() {
1662 Some(ShortcutOutcome::TextMutated)
1663 } else {
1664 Some(ShortcutOutcome::NoOp)
1665 }
1666 }
1667 (KeyModifiers::CONTROL, KeyCode::Char('a')) => {
1669 ta.move_cursor(CursorMove::Top);
1670 ta.start_selection();
1671 ta.move_cursor(CursorMove::Bottom);
1672 Some(ShortcutOutcome::CursorOnly)
1673 }
1674 (KeyModifiers::CONTROL, KeyCode::Backspace)
1677 | (KeyModifiers::ALT, KeyCode::Backspace) => {
1678 if ta.delete_word() {
1679 Some(ShortcutOutcome::TextMutated)
1680 } else {
1681 Some(ShortcutOutcome::NoOp)
1682 }
1683 }
1684 (KeyModifiers::CONTROL, KeyCode::Delete) | (KeyModifiers::ALT, KeyCode::Delete) => {
1685 if ta.delete_next_word() {
1686 Some(ShortcutOutcome::TextMutated)
1687 } else {
1688 Some(ShortcutOutcome::NoOp)
1689 }
1690 }
1691 _ => None,
1692 };
1693 if let Some(kind) = outcome {
1694 self.selection = ta.selection_range();
1695 match kind {
1696 ShortcutOutcome::NoOp => {}
1697 ShortcutOutcome::CursorOnly => self.bump_cursor(),
1698 ShortcutOutcome::TextMutated => self.bump_content(),
1699 }
1700 return EventState::Consumed;
1701 }
1702
1703 match (key.modifiers, key.code) {
1705 (m, KeyCode::Tab)
1706 if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
1707 {
1708 self.indent_lines(m.contains(KeyModifiers::SHIFT));
1709 return EventState::Consumed;
1710 }
1711 (_, KeyCode::BackTab) => {
1712 self.indent_lines(true);
1713 return EventState::Consumed;
1714 }
1715 _ => {}
1716 }
1717 if key.code == KeyCode::Enter && key.modifiers.is_empty() && self.smart_enter() {
1718 return EventState::Consumed;
1719 }
1720
1721 if let KeyCode::Char(c) = key.code
1728 && (key.modifiers & !KeyModifiers::SHIFT).is_empty()
1729 && let Some((open, close)) = surround_pair(c)
1730 && self.wrap_selection(open, close)
1731 {
1732 return EventState::Consumed;
1733 }
1734
1735 let BackendState::Textarea(ta) = &mut self.backend else {
1736 unreachable!("handle_textarea_key called with non-Textarea backend")
1737 };
1738 let mutated = ta.input_without_shortcuts(*key);
1744 self.selection = ta.selection_range();
1745 if mutated {
1746 self.bump_content();
1747 } else {
1748 self.bump_cursor();
1749 }
1750 EventState::Consumed
1751 }
1752
1753 fn handle_mouse(&mut self, mouse: &ratatui::crossterm::event::MouseEvent) -> EventState {
1755 let BackendState::Textarea(_) = &self.backend else {
1756 return EventState::NotConsumed;
1757 };
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)) {
1768 self.copy_selection_to_clipboard();
1769 self.selection = if let BackendState::Textarea(ta) = &self.backend {
1770 ta.selection_range()
1771 } else {
1772 None
1773 };
1774 self.bump_cursor();
1775 return EventState::Consumed;
1776 }
1777 let BackendState::Textarea(ta) = &mut self.backend else {
1779 unreachable!()
1780 };
1781 match mouse.kind {
1782 MouseEventKind::Down(_) => {
1783 ta.cancel_selection();
1784 let (lrow, lcol) = self
1785 .view
1786 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1787 ta.move_cursor(CursorMove::Jump(lrow, lcol));
1788 ta.start_selection();
1789 }
1790 MouseEventKind::Drag(_) => {
1791 let (lrow, lcol) = self
1792 .view
1793 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1794 ta.move_cursor(CursorMove::Jump(lrow, lcol));
1795 }
1796 _ => {
1797 ta.input(*mouse);
1798 }
1799 }
1800 self.selection = ta.selection_range();
1801 self.bump_cursor();
1804 EventState::Consumed
1805 }
1806}
1807
1808impl Component for TextEditorComponent {
1809 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
1810 self.maybe_recover_from_dead_nvim();
1811 self.bind_autocomplete_redraw(tx);
1812
1813 match event {
1814 InputEvent::Key(key) => {
1815 let popup_open = self.autocomplete.as_ref().is_some_and(|c| c.is_open());
1823 if popup_open
1824 && let Some(host) = build_editor_host_snapshot(
1825 &self.backend,
1826 self.content_revision,
1827 self.view.last_cursor_screen,
1828 )
1829 && let Some(controller) = self.autocomplete.as_mut()
1830 {
1831 match controller.handle_key(*key, &host) {
1832 HandleKeyOutcome::Accepted(action) => {
1833 if let BackendState::Textarea(ta) = &mut self.backend {
1834 apply_accept_to_textarea(ta, &action);
1835 self.selection = ta.selection_range();
1836 }
1837 self.bump_content();
1838 return EventState::Consumed;
1839 }
1840 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
1841 return EventState::Consumed;
1842 }
1843 HandleKeyOutcome::NotHandled => {}
1844 }
1845 }
1846 if let Some(state) = self.handle_nvim_key(key, tx) {
1847 return state;
1848 }
1849 let text_rev_before = self.content_revision;
1860 let cursor_before = self.textarea_cursor();
1861 let result = self.handle_textarea_key(key, tx);
1862 let cursor_after = self.textarea_cursor();
1863 if self.content_revision != text_rev_before {
1864 self.sync_autocomplete();
1865 } else if cursor_before != cursor_after {
1866 self.refresh_autocomplete_if_open();
1867 }
1868 result
1869 }
1870 InputEvent::Mouse(mouse) => {
1871 let text_rev_before = self.content_revision;
1872 let cursor_before = self.textarea_cursor();
1873 let result = self.handle_mouse(mouse);
1874 let cursor_after = self.textarea_cursor();
1875 if self.content_revision != text_rev_before {
1878 self.sync_autocomplete();
1879 } else if cursor_before != cursor_after {
1880 self.refresh_autocomplete_if_open();
1881 }
1882 result
1883 }
1884 InputEvent::Paste(_) => EventState::NotConsumed,
1887 }
1888 }
1889
1890 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
1891 let (editor_rect, search_rect) = if self.search.is_some() && rect.height > 1 {
1893 (
1894 Rect {
1895 height: rect.height - 1,
1896 ..rect
1897 },
1898 Some(Rect {
1899 y: rect.y + rect.height - 1,
1900 height: 1,
1901 ..rect
1902 }),
1903 )
1904 } else {
1905 (rect, None)
1906 };
1907 self.rect = editor_rect;
1910 let (selection, nvim_rev_to_mirror) = match &self.backend {
1915 BackendState::Textarea(_) => (self.selection, None),
1916 BackendState::Nvim(nvim) => {
1917 nvim.maybe_resize(editor_rect.width, editor_rect.height);
1918 let snap = nvim.snapshot.lock().unwrap_or_else(|p| p.into_inner());
1919 let visual_selection = snap.visual_selection;
1920 let content_gen = snap.content_gen;
1921 drop(snap);
1922 let rev = NonZeroU64::new(content_gen.saturating_add(1));
1931 (visual_selection, rev)
1932 }
1933 };
1934 if let Some(rev) = nvim_rev_to_mirror {
1935 self.content_revision = rev;
1936 }
1937 while let Ok((generation, buf)) = self.full_parse_rx.try_recv() {
1943 self.view.install_full_parse(generation, buf);
1944 }
1945
1946 let snap = snapshot_from_backend(&self.backend, self.content_revision);
1951 self.view.update(&snap, editor_rect, selection);
1952
1953 if let Some(generation) = self.view.take_pending_full_parse() {
1961 let lines: Vec<String> = snap.lines.iter().cloned().collect();
1962 let tx = self.full_parse_tx.clone();
1963 let redraw = self.redraw_tx.clone();
1964 self.full_parse_task.spawn(async move {
1965 let buf = ParsedBuffer::parse(&lines);
1966 let _ = tx.send((generation, buf));
1967 if let Some(redraw) = redraw {
1970 let _ = redraw.send(AppEvent::Redraw);
1971 }
1972 });
1973 }
1974 let bar_focused = self.search.is_some() && focused;
1977 let editor_focused = focused && !bar_focused;
1978 self.view.render(f, editor_rect, theme, editor_focused);
1979 if let (Some(state), Some(bar_rect)) = (self.search.as_mut(), search_rect) {
1980 render_search_bar(f, bar_rect, state, theme, bar_focused);
1981 }
1982
1983 self.poll_autocomplete();
1991 if let (Some(controller), Some(live_anchor)) =
1998 (self.autocomplete.as_mut(), self.view.last_cursor_screen)
1999 {
2000 if let Some(state) = controller.state_mut() {
2001 state.anchor = live_anchor;
2002 }
2003 if let Some(state) = controller.state() {
2004 autocomplete::render(f, state, editor_rect, theme);
2005 }
2006 }
2007 }
2008
2009 fn hint_shortcuts(&self) -> Vec<(String, String)> {
2010 use crate::keys::action_shortcuts::ActionShortcuts;
2011
2012 if let BackendState::Nvim(nvim) = &self.backend {
2014 let label = nvim
2015 .snapshot
2016 .lock()
2017 .unwrap_or_else(|p| p.into_inner())
2018 .footer_label();
2019 let mut hints = vec![(String::new(), label)];
2020 hints.extend(
2021 [
2022 (ActionShortcuts::FocusSidebar, "\u{2190} sidebar"),
2023 (ActionShortcuts::FocusEditor, "backlinks \u{2192}"),
2024 (ActionShortcuts::FileOperations, "file ops"),
2025 ]
2026 .iter()
2027 .filter_map(|(action, label)| {
2028 self.key_bindings
2029 .first_combo_for(action)
2030 .map(|k| (k, label.to_string()))
2031 }),
2032 );
2033 return hints;
2034 }
2035
2036 [
2037 (ActionShortcuts::FocusSidebar, "\u{2190} sidebar"),
2038 (ActionShortcuts::FocusEditor, "backlinks \u{2192}"),
2039 (ActionShortcuts::FileOperations, "file ops"),
2040 (ActionShortcuts::FindInBuffer, "find"),
2041 ]
2042 .iter()
2043 .filter_map(|(action, label)| {
2044 self.key_bindings
2045 .first_combo_for(action)
2046 .map(|k| (k, label.to_string()))
2047 })
2048 .collect()
2049 }
2050}
2051
2052#[cfg(test)]
2053mod tests {
2054 use super::*;
2055 use crate::keys::KeyBindings;
2056
2057 fn make_editor() -> TextEditorComponent {
2058 TextEditorComponent::new(
2059 KeyBindings::empty(),
2060 &crate::settings::AppSettings::default(),
2061 )
2062 }
2063
2064 fn dummy_tx() -> AppTx {
2065 tokio::sync::mpsc::unbounded_channel().0
2066 }
2067
2068 fn get_ta(editor: &mut TextEditorComponent) -> &mut TextArea<'static> {
2069 match &mut editor.backend {
2070 BackendState::Textarea(ta) => ta,
2071 _ => panic!("expected Textarea backend"),
2072 }
2073 }
2074
2075 #[test]
2076 fn has_trigger_before_cursor_finds_bracket() {
2077 assert!(has_trigger_before_cursor("hello [[foo", 11));
2078 assert!(has_trigger_before_cursor("[[a b c", 7));
2079 }
2080
2081 #[test]
2082 fn has_trigger_before_cursor_finds_hashtag() {
2083 assert!(has_trigger_before_cursor("text #tag", 9));
2084 }
2085
2086 #[test]
2087 fn has_trigger_before_cursor_no_trigger_bails() {
2088 assert!(!has_trigger_before_cursor("plain prose here", 16));
2089 assert!(!has_trigger_before_cursor("", 0));
2090 }
2091
2092 #[test]
2093 fn has_trigger_before_cursor_handles_multibyte_no_panic() {
2094 let line = "你好世界".to_string() + &"a".repeat(80);
2097 let col = line.chars().count();
2098 assert!(!has_trigger_before_cursor(&line, col));
2099
2100 let with_emoji = "🦀".repeat(20) + "[[note";
2101 let col = with_emoji.chars().count();
2102 assert!(has_trigger_before_cursor(&with_emoji, col));
2103
2104 let accented = "é".repeat(100);
2105 let col = accented.chars().count();
2106 assert!(!has_trigger_before_cursor(&accented, col));
2107 }
2108
2109 #[test]
2110 fn has_trigger_before_cursor_ignores_chars_after_cursor() {
2111 assert!(!has_trigger_before_cursor("foo [[bar", 3));
2113 }
2114
2115 #[test]
2116 fn has_trigger_before_cursor_wikilink_with_spaces() {
2117 assert!(has_trigger_before_cursor("[[my note title", 15));
2120 }
2121
2122 #[test]
2123 fn fresh_editor_is_not_dirty() {
2124 let editor = make_editor();
2125 assert!(!editor.is_dirty());
2126 }
2127
2128 #[test]
2129 fn after_set_text_not_dirty() {
2130 let mut editor = make_editor();
2131 editor.set_text("hello world".to_string());
2132 assert!(!editor.is_dirty());
2133 }
2134
2135 #[test]
2136 fn get_text_returns_loaded_content() {
2137 let mut editor = make_editor();
2138 editor.set_text("line one\nline two".to_string());
2139 assert_eq!(editor.get_text(), "line one\nline two");
2140 }
2141
2142 #[test]
2143 fn mark_saved_clears_dirty() {
2144 let mut editor = make_editor();
2145 editor.set_text("initial".to_string());
2146 let text = editor.get_text();
2147 editor.mark_saved(text.clone() + "x"); assert!(editor.is_dirty());
2149 editor.mark_saved(text); assert!(!editor.is_dirty());
2151 }
2152
2153 #[test]
2154 fn trailing_newline_does_not_cause_false_dirty() {
2155 let mut editor = make_editor();
2156 editor.set_text("content\n".to_string());
2157 assert!(
2158 !editor.is_dirty(),
2159 "trailing newline should not make editor dirty after load"
2160 );
2161 }
2162
2163 #[test]
2164 fn cursor_move_does_not_dirty_buffer() {
2165 let mut editor = make_editor();
2166 editor.set_text("hello world".to_string());
2167 assert!(!editor.is_dirty());
2168 let tx = dummy_tx();
2169 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
2173 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2174 assert!(
2175 !editor.is_dirty(),
2176 "cursor move must not mark the editor as dirty"
2177 );
2178 }
2179
2180 #[test]
2181 fn empty_stack_undo_redo_does_not_dirty_or_bump_revision() {
2182 let mut editor = make_editor();
2186 editor.set_text("foo".to_string());
2187 let rev_before = editor.content_revision();
2188 assert!(!editor.is_dirty());
2189 let tx = dummy_tx();
2190 for key_code in [KeyCode::Char('z'), KeyCode::Char('y')] {
2191 let key = ratatui::crossterm::event::KeyEvent::new(key_code, KeyModifiers::CONTROL);
2192 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2193 }
2194 assert!(
2195 !editor.is_dirty(),
2196 "empty-stack undo/redo must not flip is_dirty"
2197 );
2198 assert_eq!(
2199 editor.content_revision(),
2200 rev_before,
2201 "empty-stack undo/redo must not bump content_revision"
2202 );
2203 }
2204
2205 #[test]
2206 fn fresh_editor_content_revision_is_nonzero() {
2207 let editor = make_editor();
2214 assert!(editor.content_revision().get() >= 1);
2215 }
2216
2217 #[test]
2218 fn mouse_down_clears_selection() {
2219 let mut editor = make_editor();
2220 editor.set_text("hello world".to_string());
2221 let ta = get_ta(&mut editor);
2222 ta.start_selection();
2223 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2224 assert!(ta.selection_range().is_some());
2225 ta.cancel_selection();
2226 editor.selection = if let BackendState::Textarea(ta) = &editor.backend {
2227 ta.selection_range()
2228 } else {
2229 None
2230 };
2231 assert!(editor.selection.is_none());
2232 }
2233
2234 #[test]
2235 fn ctrl_c_copies_selected_text() {
2236 let mut editor = make_editor();
2237 editor.set_text("hello world".to_string());
2238 let ta = get_ta(&mut editor);
2239 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2240 ta.start_selection();
2241 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2242 let range = ta.selection_range().unwrap();
2243 let ((sr, sc), (er, ec)) = range;
2244 let lines = ta.lines();
2245 let selected = if sr == er {
2246 lines[sr][sc..ec].to_string()
2247 } else {
2248 lines[sr][sc..].to_string()
2249 };
2250 assert_eq!(selected, "hello ");
2251 }
2252
2253 fn select_range(editor: &mut TextEditorComponent, start: (u16, u16), end: (u16, u16)) {
2255 let ta = get_ta(editor);
2256 ta.cancel_selection();
2257 ta.move_cursor(CursorMove::Jump(start.0, start.1));
2258 ta.start_selection();
2259 ta.move_cursor(CursorMove::Jump(end.0, end.1));
2260 assert!(ta.selection_range().is_some());
2261 }
2262
2263 fn send_char(editor: &mut TextEditorComponent, c: char) {
2264 let tx = dummy_tx();
2265 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
2266 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2267 }
2268
2269 #[test]
2270 fn surround_pair_maps_open_and_symmetric_chars() {
2271 assert_eq!(surround_pair('('), Some(("(", ")")));
2272 assert_eq!(surround_pair('['), Some(("[", "]")));
2273 assert_eq!(surround_pair('{'), Some(("{", "}")));
2274 assert_eq!(surround_pair('<'), Some(("<", ">")));
2275 assert_eq!(surround_pair('"'), Some(("\"", "\"")));
2276 assert_eq!(surround_pair('\''), Some(("'", "'")));
2277 assert_eq!(surround_pair('`'), Some(("`", "`")));
2278 assert_eq!(surround_pair('*'), Some(("*", "*")));
2279 assert_eq!(surround_pair('_'), Some(("_", "_")));
2280 assert_eq!(surround_pair('~'), Some(("~", "~")));
2281 assert_eq!(surround_pair(')'), None);
2283 assert_eq!(surround_pair(']'), None);
2284 assert_eq!(surround_pair('}'), None);
2285 assert_eq!(surround_pair('>'), None);
2286 assert_eq!(surround_pair('a'), None);
2287 }
2288
2289 #[test]
2290 fn typing_open_paren_with_selection_wraps_it() {
2291 let mut editor = make_editor();
2292 editor.set_text("hello world".to_string());
2293 select_range(&mut editor, (0, 0), (0, 5)); send_char(&mut editor, '(');
2295 assert_eq!(editor.get_text(), "(hello) world");
2296 assert!(editor.is_dirty(), "wrap must mark the buffer dirty");
2297 }
2298
2299 #[test]
2300 fn wrap_keeps_selection_on_inner_text() {
2301 let mut editor = make_editor();
2302 editor.set_text("hello world".to_string());
2303 select_range(&mut editor, (0, 0), (0, 5));
2304 send_char(&mut editor, '(');
2305 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2307 }
2308
2309 #[test]
2310 fn chained_brackets_build_a_wikilink() {
2311 let mut editor = make_editor();
2312 editor.set_text("my note".to_string());
2313 select_range(&mut editor, (0, 0), (0, 7));
2314 send_char(&mut editor, '[');
2315 send_char(&mut editor, '[');
2316 assert_eq!(editor.get_text(), "[[my note]]");
2317 assert_eq!(editor.selection, Some(((0, 2), (0, 9))));
2318 }
2319
2320 #[test]
2321 fn symmetric_chars_wrap_and_chain() {
2322 let mut editor = make_editor();
2323 editor.set_text("bold".to_string());
2324 select_range(&mut editor, (0, 0), (0, 4));
2325 send_char(&mut editor, '*');
2326 assert_eq!(editor.get_text(), "*bold*");
2327 send_char(&mut editor, '*');
2328 assert_eq!(editor.get_text(), "**bold**");
2329 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2330 }
2331
2332 #[test]
2333 fn closing_char_replaces_selection() {
2334 let mut editor = make_editor();
2335 editor.set_text("hello world".to_string());
2336 select_range(&mut editor, (0, 0), (0, 5));
2337 send_char(&mut editor, ')');
2338 assert_eq!(editor.get_text(), ") world");
2339 }
2340
2341 #[test]
2342 fn open_char_without_selection_inserts_normally() {
2343 let mut editor = make_editor();
2344 editor.set_text("hello".to_string());
2345 let ta = get_ta(&mut editor);
2346 ta.move_cursor(CursorMove::End);
2347 send_char(&mut editor, '(');
2348 assert_eq!(editor.get_text(), "hello(");
2349 }
2350
2351 #[test]
2352 fn wrap_spans_multiline_selection() {
2353 let mut editor = make_editor();
2354 editor.set_text("abc\ndef".to_string());
2355 select_range(&mut editor, (0, 0), (1, 3));
2356 send_char(&mut editor, '(');
2357 assert_eq!(editor.get_text(), "(abc\ndef)");
2358 assert_eq!(editor.selection, Some(((0, 1), (1, 3))));
2360 }
2361
2362 #[test]
2363 fn wrap_handles_multibyte_selection() {
2364 let mut editor = make_editor();
2365 editor.set_text("héllo🦀 x".to_string());
2366 select_range(&mut editor, (0, 0), (0, 6)); send_char(&mut editor, '`');
2368 assert_eq!(editor.get_text(), "`héllo🦀` x");
2369 assert_eq!(editor.selection, Some(((0, 1), (0, 7))));
2370 }
2371
2372 #[test]
2373 fn wrap_with_reversed_selection_direction() {
2374 let mut editor = make_editor();
2376 editor.set_text("hello world".to_string());
2377 select_range(&mut editor, (0, 5), (0, 0));
2378 send_char(&mut editor, '(');
2379 assert_eq!(editor.get_text(), "(hello) world");
2380 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2381 }
2382
2383 #[test]
2384 fn text_action_keeps_selection_on_inner_text() {
2385 let mut editor = make_editor();
2388 editor.set_text("bold word".to_string());
2389 select_range(&mut editor, (0, 0), (0, 4));
2390 editor.apply_text_action(TextAction::Bold);
2391 assert_eq!(editor.get_text(), "**bold** word");
2392 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2393 }
2394
2395 #[test]
2396 fn wrap_undo_is_two_steps_back_to_original() {
2397 let mut editor = make_editor();
2401 editor.set_text("hello world".to_string());
2402 select_range(&mut editor, (0, 0), (0, 5));
2403 send_char(&mut editor, '(');
2404 assert_eq!(editor.get_text(), "(hello) world");
2405 let ta = get_ta(&mut editor);
2406 ta.undo();
2407 ta.undo();
2408 assert_eq!(editor.get_text(), "hello world");
2409 }
2410
2411 #[test]
2412 fn linkable_url_accepts_supported_schemes() {
2413 assert_eq!(
2414 linkable_url("https://example.com"),
2415 Some("https://example.com")
2416 );
2417 assert_eq!(
2418 linkable_url("http://example.com/path?q=1#frag"),
2419 Some("http://example.com/path?q=1#frag"),
2420 );
2421 assert_eq!(
2422 linkable_url(" https://example.com "),
2423 Some("https://example.com")
2424 );
2425 assert_eq!(
2426 linkable_url("ftp://files.example.com/x"),
2427 Some("ftp://files.example.com/x"),
2428 );
2429 assert_eq!(
2430 linkable_url("ftps://files.example.com/x"),
2431 Some("ftps://files.example.com/x"),
2432 );
2433 assert_eq!(
2434 linkable_url("mailto:user@example.com"),
2435 Some("mailto:user@example.com"),
2436 );
2437 assert_eq!(
2438 linkable_url("mailto:user@example.com?subject=hi"),
2439 Some("mailto:user@example.com?subject=hi"),
2440 );
2441 }
2442
2443 #[test]
2444 fn linkable_url_rejects_other_schemes_and_plain_text() {
2445 assert_eq!(linkable_url("file:///etc/passwd"), None);
2446 assert_eq!(linkable_url("ssh://host"), None);
2447 assert_eq!(linkable_url("javascript:alert(1)"), None);
2448 assert_eq!(linkable_url("example.com"), None);
2449 assert_eq!(linkable_url("not a url"), None);
2450 assert_eq!(linkable_url(""), None);
2451 assert_eq!(linkable_url("https://example.com\nmore"), None);
2452 }
2453
2454 #[test]
2455 fn try_build_markdown_link_wraps_selection_when_clip_is_url() {
2456 assert_eq!(
2457 try_build_markdown_link("https://example.com", Some("click here")).as_deref(),
2458 Some("[click here](https://example.com)"),
2459 );
2460 }
2461
2462 #[test]
2463 fn try_build_markdown_link_trims_url_whitespace() {
2464 assert_eq!(
2465 try_build_markdown_link(" https://example.com\n", Some("link")).as_deref(),
2466 Some("[link](https://example.com)"),
2467 );
2468 }
2469
2470 #[test]
2471 fn try_build_markdown_link_returns_none_when_no_selection() {
2472 assert_eq!(try_build_markdown_link("https://example.com", None), None);
2473 }
2474
2475 #[test]
2476 fn try_build_markdown_link_returns_none_when_not_url() {
2477 assert_eq!(try_build_markdown_link("plain text", Some("sel")), None);
2478 }
2479
2480 #[test]
2481 fn try_build_markdown_link_returns_none_when_selection_empty() {
2482 assert_eq!(
2483 try_build_markdown_link("https://example.com", Some("")),
2484 None
2485 );
2486 }
2487
2488 #[test]
2489 fn try_build_markdown_link_escapes_close_bracket_in_selection() {
2490 assert_eq!(
2491 try_build_markdown_link("https://example.com", Some("a]b")).as_deref(),
2492 Some(r"[a\]b](https://example.com)"),
2493 );
2494 }
2495
2496 #[test]
2497 fn try_build_markdown_link_wraps_ftp_url() {
2498 assert_eq!(
2499 try_build_markdown_link("ftp://files.example.com/x", Some("download")).as_deref(),
2500 Some("[download](ftp://files.example.com/x)"),
2501 );
2502 }
2503
2504 fn key(code: KeyCode, mods: KeyModifiers) -> ratatui::crossterm::event::KeyEvent {
2505 ratatui::crossterm::event::KeyEvent::new(code, mods)
2506 }
2507
2508 #[test]
2509 fn open_or_advance_search_opens_find_bar_with_empty_query() {
2510 let mut editor = make_editor();
2511 editor.set_text("hello world".to_string());
2512 editor.open_or_advance_search();
2513 let state = editor.search.as_ref().expect("find bar opened");
2514 assert!(state.input.is_empty());
2515 assert!(matches!(state.status, SearchStatus::Empty));
2516 }
2517
2518 #[test]
2519 fn open_or_advance_search_advances_when_already_open() {
2520 let mut editor = make_editor();
2521 editor.set_text("ab ab ab".to_string());
2522 let tx = dummy_tx();
2523 editor.open_or_advance_search();
2524 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2525 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2526 editor.open_or_advance_search();
2528 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2529 assert_eq!(col, 3, "second invocation advances to next match");
2530 }
2531
2532 #[test]
2533 fn typing_in_find_bar_jumps_cursor_to_first_match() {
2534 let mut editor = make_editor();
2535 editor.set_text("foo bar baz".to_string());
2536 let tx = dummy_tx();
2537 editor.open_or_advance_search();
2538 for ch in ['b', 'a', 'r'] {
2539 editor.handle_textarea_key(&key(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
2540 }
2541 let state = editor.search.as_ref().unwrap();
2542 assert_eq!(state.input.value(), "bar");
2543 assert!(matches!(state.status, SearchStatus::Match));
2544 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2545 assert_eq!(col, 4, "cursor jumped to start of 'bar'");
2546 }
2547
2548 #[test]
2549 fn enter_in_find_bar_advances_to_next_match() {
2550 let mut editor = make_editor();
2551 editor.set_text("ab ab ab".to_string());
2552 let tx = dummy_tx();
2553 editor.open_or_advance_search();
2554 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2555 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2556 editor.handle_textarea_key(&key(KeyCode::Enter, KeyModifiers::NONE), &tx);
2558 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2559 assert_eq!(col, 3, "Enter advances to second match");
2560 }
2561
2562 #[test]
2563 fn match_is_highlighted_as_selection_after_search() {
2564 let mut editor = make_editor();
2565 editor.set_text("foo bar baz".to_string());
2566 let tx = dummy_tx();
2567 editor.open_or_advance_search();
2568 for ch in ['b', 'a', 'r'] {
2569 editor.handle_textarea_key(&key(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
2570 }
2571 assert_eq!(editor.selection, Some(((0, 4), (0, 7))));
2573 }
2574
2575 #[test]
2576 fn no_match_clears_selection() {
2577 let mut editor = make_editor();
2578 editor.set_text("hello".to_string());
2579 let tx = dummy_tx();
2580 editor.open_or_advance_search();
2581 editor.handle_textarea_key(&key(KeyCode::Char('z'), KeyModifiers::NONE), &tx);
2582 assert_eq!(editor.selection, None);
2583 }
2584
2585 #[test]
2586 fn esc_in_find_bar_clears_selection_highlight() {
2587 let mut editor = make_editor();
2588 editor.set_text("foo bar".to_string());
2589 let tx = dummy_tx();
2590 editor.open_or_advance_search();
2591 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2592 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2593 editor.handle_textarea_key(&key(KeyCode::Char('r'), KeyModifiers::NONE), &tx);
2594 assert!(editor.selection.is_some());
2595 editor.handle_textarea_key(&key(KeyCode::Esc, KeyModifiers::NONE), &tx);
2596 assert!(editor.selection.is_none());
2597 }
2598
2599 #[test]
2600 fn esc_in_find_bar_closes_it() {
2601 let mut editor = make_editor();
2602 editor.set_text("hello".to_string());
2603 let tx = dummy_tx();
2604 editor.open_or_advance_search();
2605 assert!(editor.search.is_some());
2606 editor.handle_textarea_key(&key(KeyCode::Esc, KeyModifiers::NONE), &tx);
2607 assert!(editor.search.is_none());
2608 }
2609
2610 #[test]
2611 fn find_bar_consumes_typing_so_editor_text_is_unchanged() {
2612 let mut editor = make_editor();
2613 editor.set_text("hello".to_string());
2614 let tx = dummy_tx();
2615 editor.open_or_advance_search();
2616 editor.handle_textarea_key(&key(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
2617 assert_eq!(editor.get_text(), "hello");
2618 }
2619
2620 #[test]
2621 fn no_match_status_when_query_absent() {
2622 let mut editor = make_editor();
2623 editor.set_text("hello".to_string());
2624 let tx = dummy_tx();
2625 editor.open_or_advance_search();
2626 editor.handle_textarea_key(&key(KeyCode::Char('z'), KeyModifiers::NONE), &tx);
2627 let state = editor.search.as_ref().unwrap();
2628 assert!(matches!(state.status, SearchStatus::NoMatch));
2629 }
2630
2631 #[test]
2632 fn try_build_markdown_link_wraps_mailto_url() {
2633 assert_eq!(
2634 try_build_markdown_link("mailto:user@example.com", Some("email me")).as_deref(),
2635 Some("[email me](mailto:user@example.com)"),
2636 );
2637 }
2638
2639 #[test]
2640 fn insert_at_cursor_appends_text() {
2641 let mut editor = make_editor();
2642 editor.set_text("hello".to_string());
2643 {
2644 let ta = get_ta(&mut editor);
2645 ta.move_cursor(ratatui_textarea::CursorMove::End);
2646 }
2647 editor.insert_at_cursor(" world", &dummy_tx());
2648 assert_eq!(editor.get_text(), "hello world");
2649 }
2650
2651 #[test]
2652 fn insert_at_cursor_replaces_selection() {
2653 let mut editor = make_editor();
2654 editor.set_text("hello world".to_string());
2655 {
2656 let ta = get_ta(&mut editor);
2657 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2658 ta.start_selection();
2659 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2660 }
2661 editor.insert_at_cursor("HEY ", &dummy_tx());
2662 assert_eq!(editor.get_text(), "HEY world");
2663 }
2664
2665 #[test]
2666 fn paste_inserts_text_at_cursor() {
2667 let mut editor = make_editor();
2668 editor.set_text("hello".to_string());
2669 let ta = get_ta(&mut editor);
2670 ta.move_cursor(ratatui_textarea::CursorMove::End);
2671 ta.insert_str(" world");
2672 assert_eq!(editor.get_text(), "hello world");
2673 }
2674
2675 #[test]
2676 fn bold_action_with_no_selection_inserts_pair_and_centers_cursor() {
2677 let mut editor = make_editor();
2678 editor.set_text("hello".to_string());
2679 {
2680 let ta = get_ta(&mut editor);
2681 ta.move_cursor(ratatui_textarea::CursorMove::End);
2682 }
2683 editor.apply_text_action(TextAction::Bold);
2684 assert_eq!(editor.get_text(), "hello****");
2685 let ta = get_ta(&mut editor);
2686 assert_eq!(ta.cursor(), (0, 7));
2687 }
2688
2689 #[test]
2690 fn italic_action_with_no_selection_inserts_single_pair() {
2691 let mut editor = make_editor();
2692 editor.set_text(String::new());
2693 editor.apply_text_action(TextAction::Italic);
2694 assert_eq!(editor.get_text(), "**");
2695 let ta = get_ta(&mut editor);
2696 assert_eq!(ta.cursor(), (0, 1));
2697 }
2698
2699 #[test]
2700 fn strikethrough_action_with_selection_wraps_text() {
2701 let mut editor = make_editor();
2702 editor.set_text("hello world".to_string());
2703 {
2704 let ta = get_ta(&mut editor);
2705 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2706 ta.start_selection();
2707 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2708 }
2709 editor.apply_text_action(TextAction::Strikethrough);
2710 assert_eq!(editor.get_text(), "~~hello ~~world");
2711 }
2712
2713 #[test]
2714 fn bold_action_wraps_non_ascii_selection() {
2715 let mut editor = make_editor();
2716 editor.set_text("hello 你好 world".to_string());
2717 {
2718 let ta = get_ta(&mut editor);
2719 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2720 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2721 ta.start_selection();
2722 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2723 }
2724 editor.apply_text_action(TextAction::Bold);
2725 assert_eq!(editor.get_text(), "hello **你好 **world");
2726 }
2727
2728 #[test]
2729 fn bold_action_wraps_selected_text() {
2730 let mut editor = make_editor();
2731 editor.set_text("foo bar".to_string());
2732 {
2733 let ta = get_ta(&mut editor);
2734 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2735 ta.start_selection();
2736 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2737 }
2738 editor.apply_text_action(TextAction::Bold);
2739 assert_eq!(editor.get_text(), "**foo **bar");
2740 }
2741
2742 #[test]
2743 fn indent_no_selection_indents_current_line() {
2744 let mut editor = make_editor();
2745 editor.set_text("foo\nbar".to_string());
2746 {
2747 let ta = get_ta(&mut editor);
2748 ta.move_cursor(ratatui_textarea::CursorMove::Bottom);
2749 }
2750 editor.indent_lines(false);
2751 let lines = get_ta(&mut editor).lines();
2752 assert_eq!(lines[0], "foo");
2753 assert!(lines[1].starts_with(' ') || lines[1].starts_with('\t'));
2754 assert!(lines[1].trim_start() == "bar");
2755 }
2756
2757 #[test]
2758 fn indent_with_selection_indents_all_touched_lines() {
2759 let mut editor = make_editor();
2760 editor.set_text("foo\nbar\nbaz".to_string());
2761 {
2762 let ta = get_ta(&mut editor);
2763 ta.move_cursor(ratatui_textarea::CursorMove::Top);
2764 ta.start_selection();
2765 ta.move_cursor(ratatui_textarea::CursorMove::Down);
2766 ta.move_cursor(ratatui_textarea::CursorMove::End);
2767 }
2768 editor.indent_lines(false);
2769 let lines: Vec<String> = get_ta(&mut editor).lines().to_vec();
2770 assert_eq!(lines[0].trim_start(), "foo");
2771 assert_eq!(lines[1].trim_start(), "bar");
2772 assert_eq!(lines[2], "baz");
2773 assert!(lines[0].len() > 3);
2774 assert!(lines[1].len() > 3);
2775 }
2776
2777 #[test]
2778 fn dedent_removes_leading_indent() {
2779 let mut editor = make_editor();
2780 editor.set_text(" foo\n bar\nbaz".to_string());
2781 let tab_len = get_ta(&mut editor).tab_length() as usize;
2782 {
2783 let ta = get_ta(&mut editor);
2784 ta.move_cursor(ratatui_textarea::CursorMove::Top);
2785 ta.start_selection();
2786 ta.move_cursor(ratatui_textarea::CursorMove::Bottom);
2787 ta.move_cursor(ratatui_textarea::CursorMove::End);
2788 }
2789 editor.indent_lines(true);
2790 let lines: Vec<String> = get_ta(&mut editor).lines().to_vec();
2791 assert_eq!(lines[0], format!("{}foo", " ".repeat(4 - tab_len.min(4))));
2793 assert_eq!(
2795 lines[1],
2796 format!("{}bar", " ".repeat(2usize.saturating_sub(tab_len)))
2797 );
2798 assert_eq!(lines[2], "baz");
2799 }
2800
2801 #[test]
2802 fn dedent_no_leading_whitespace_is_noop_for_that_line() {
2803 let mut editor = make_editor();
2804 editor.set_text("foo".to_string());
2805 editor.indent_lines(true);
2806 assert_eq!(editor.get_text(), "foo");
2807 }
2808
2809 #[test]
2810 fn smart_enter_continues_unordered_list() {
2811 let mut editor = make_editor();
2812 editor.set_text("- foo".to_string());
2813 {
2814 let ta = get_ta(&mut editor);
2815 ta.move_cursor(ratatui_textarea::CursorMove::End);
2816 }
2817 assert!(editor.smart_enter());
2818 assert_eq!(editor.get_text(), "- foo\n- ");
2819 }
2820
2821 #[test]
2822 fn smart_enter_continues_ordered_list_increments() {
2823 let mut editor = make_editor();
2824 editor.set_text("1. foo".to_string());
2825 {
2826 let ta = get_ta(&mut editor);
2827 ta.move_cursor(ratatui_textarea::CursorMove::End);
2828 }
2829 assert!(editor.smart_enter());
2830 assert_eq!(editor.get_text(), "1. foo\n2. ");
2831 }
2832
2833 #[test]
2834 fn smart_enter_on_empty_list_marker_clears_line() {
2835 let mut editor = make_editor();
2836 editor.set_text("- ".to_string());
2837 {
2838 let ta = get_ta(&mut editor);
2839 ta.move_cursor(ratatui_textarea::CursorMove::End);
2840 }
2841 assert!(editor.smart_enter());
2842 assert_eq!(editor.get_text(), "");
2843 }
2844
2845 #[test]
2846 fn smart_enter_preserves_indent() {
2847 let mut editor = make_editor();
2848 editor.set_text(" body".to_string());
2849 {
2850 let ta = get_ta(&mut editor);
2851 ta.move_cursor(ratatui_textarea::CursorMove::End);
2852 }
2853 assert!(editor.smart_enter());
2854 assert_eq!(editor.get_text(), " body\n ");
2855 }
2856
2857 #[test]
2858 fn smart_enter_on_empty_indent_dedents() {
2859 let mut editor = make_editor();
2860 editor.set_text(" ".to_string());
2861 {
2862 let ta = get_ta(&mut editor);
2863 ta.move_cursor(ratatui_textarea::CursorMove::End);
2864 }
2865 let tab_len = get_ta(&mut editor).tab_length() as usize;
2866 assert!(editor.smart_enter());
2867 assert_eq!(
2868 editor.get_text(),
2869 " ".repeat(4usize.saturating_sub(tab_len))
2870 );
2871 }
2872
2873 #[test]
2874 fn smart_enter_no_indent_no_marker_returns_false() {
2875 let mut editor = make_editor();
2876 editor.set_text("plain".to_string());
2877 {
2878 let ta = get_ta(&mut editor);
2879 ta.move_cursor(ratatui_textarea::CursorMove::End);
2880 }
2881 assert!(!editor.smart_enter());
2882 assert_eq!(editor.get_text(), "plain");
2883 }
2884
2885 #[test]
2886 fn smart_enter_mid_line_returns_false() {
2887 let mut editor = make_editor();
2888 editor.set_text("- foo".to_string());
2889 {
2890 let ta = get_ta(&mut editor);
2891 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2892 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
2893 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
2894 }
2895 assert!(!editor.smart_enter());
2896 }
2897
2898 #[test]
2899 fn smart_enter_on_empty_indented_list_marker_dedents_keeping_marker() {
2900 let mut editor = make_editor();
2901 let tab_len = get_ta(&mut editor).tab_length() as usize;
2902 let indent = " ".repeat(tab_len);
2903 editor.set_text(format!("{indent}- "));
2904 {
2905 let ta = get_ta(&mut editor);
2906 ta.move_cursor(ratatui_textarea::CursorMove::End);
2907 }
2908 assert!(editor.smart_enter());
2909 assert_eq!(editor.get_text(), "- ");
2910 }
2911
2912 #[test]
2913 fn smart_enter_on_empty_list_marker_clears_line_after_full_dedent() {
2914 let mut editor = make_editor();
2915 let tab_len = get_ta(&mut editor).tab_length() as usize;
2916 let indent = " ".repeat(tab_len);
2917 editor.set_text(format!("{indent}- "));
2918 {
2919 let ta = get_ta(&mut editor);
2920 ta.move_cursor(ratatui_textarea::CursorMove::End);
2921 }
2922 assert!(editor.smart_enter());
2924 assert_eq!(editor.get_text(), "- ");
2925 {
2928 let ta = get_ta(&mut editor);
2929 ta.move_cursor(ratatui_textarea::CursorMove::End);
2930 }
2931 assert!(editor.smart_enter());
2932 assert_eq!(editor.get_text(), "");
2933 }
2934
2935 #[test]
2936 fn smart_enter_continues_list_with_non_ascii_content() {
2937 let mut editor = make_editor();
2938 editor.set_text("- 你好".to_string());
2939 {
2940 let ta = get_ta(&mut editor);
2941 ta.move_cursor(ratatui_textarea::CursorMove::End);
2942 }
2943 assert!(editor.smart_enter());
2944 assert_eq!(editor.get_text(), "- 你好\n- ");
2945 }
2946
2947 #[test]
2948 fn smart_enter_preserves_tab_indent() {
2949 let mut editor = make_editor();
2950 editor.set_text("\tbody".to_string());
2951 {
2952 let ta = get_ta(&mut editor);
2953 ta.move_cursor(ratatui_textarea::CursorMove::End);
2954 }
2955 assert!(editor.smart_enter());
2956 assert_eq!(editor.get_text(), "\tbody\n\t");
2957 }
2958
2959 #[test]
2960 fn smart_enter_on_tab_only_line_dedents() {
2961 let mut editor = make_editor();
2962 editor.set_text("\t\t".to_string());
2963 {
2964 let ta = get_ta(&mut editor);
2965 ta.move_cursor(ratatui_textarea::CursorMove::End);
2966 }
2967 assert!(editor.smart_enter());
2968 assert_eq!(editor.get_text(), "\t");
2970 }
2971
2972 #[test]
2973 fn smart_enter_continues_indented_list() {
2974 let mut editor = make_editor();
2975 editor.set_text(" - foo".to_string());
2976 {
2977 let ta = get_ta(&mut editor);
2978 ta.move_cursor(ratatui_textarea::CursorMove::End);
2979 }
2980 assert!(editor.smart_enter());
2981 assert_eq!(editor.get_text(), " - foo\n - ");
2982 }
2983
2984 #[test]
2985 fn unsupported_text_action_is_noop() {
2986 let mut editor = make_editor();
2987 editor.set_text("hello".to_string());
2988 editor.apply_text_action(TextAction::Underline);
2989 assert_eq!(editor.get_text(), "hello");
2990 }
2991
2992 #[test]
2993 fn textarea_hint_shortcuts_has_no_mode_indicator() {
2994 let editor = make_editor();
2995 let hints = editor.hint_shortcuts();
2996 assert!(
2998 !hints
2999 .iter()
3000 .any(|(_, label)| label == "NORMAL" || label == "INSERT")
3001 );
3002 }
3003
3004 fn place_cursor_at_col(editor: &mut TextEditorComponent, col: usize) {
3008 let ta = get_ta(editor);
3009 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3010 for _ in 0..col {
3011 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3012 }
3013 }
3014
3015 #[test]
3016 fn link_at_cursor_returns_label_when_cursor_on_hashtag() {
3017 let mut editor = make_editor();
3018 editor.set_text("see #rust now".to_string());
3019 place_cursor_at_col(&mut editor, 5);
3021 assert_eq!(
3022 editor.link_at_cursor(),
3023 Some(LinkTarget::Label("rust".into())),
3024 );
3025 }
3026
3027 #[test]
3028 fn link_at_cursor_returns_label_at_hash_char() {
3029 let mut editor = make_editor();
3030 editor.set_text("see #rust now".to_string());
3031 place_cursor_at_col(&mut editor, 4);
3033 assert_eq!(
3034 editor.link_at_cursor(),
3035 Some(LinkTarget::Label("rust".into())),
3036 );
3037 }
3038
3039 #[test]
3040 fn link_at_cursor_returns_none_outside_hashtag() {
3041 let mut editor = make_editor();
3042 editor.set_text("see #rust now".to_string());
3043 place_cursor_at_col(&mut editor, 0);
3045 assert_eq!(editor.link_at_cursor(), None);
3046 }
3047
3048 #[test]
3049 fn link_at_cursor_returns_note_for_wikilink() {
3050 let mut editor = make_editor();
3051 editor.set_text("open [[my note]] please".to_string());
3052 place_cursor_at_col(&mut editor, 7);
3054 let result = editor.link_at_cursor();
3055 assert!(
3056 matches!(result, Some(LinkTarget::Note(_))),
3057 "expected Note variant, got {result:?}"
3058 );
3059 }
3060
3061 #[test]
3064 fn link_at_cursor_returns_note_for_markdown_link_with_fragment() {
3065 let line = "[see docs](#section)";
3070 let mut editor = make_editor();
3071 editor.set_text(line.to_string());
3072 let cursor = "[see docs](#sec".chars().count(); place_cursor_at_col(&mut editor, cursor);
3075 let result = editor.link_at_cursor();
3076 assert!(
3077 matches!(result, Some(LinkTarget::Note(_))),
3078 "expected Note variant for markdown link fragment, got {result:?}"
3079 );
3080 }
3081}