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
156#[derive(Debug, Clone)]
160pub struct ClipboardImage {
161 pub width: usize,
162 pub height: usize,
163 pub rgba: Vec<u8>,
164}
165
166const LINKABLE_PASTE_SCHEMES: &[&str] = &["http", "https", "ftp", "ftps", "mailto"];
170
171fn linkable_url(s: &str) -> Option<&str> {
172 kimun_core::note::url_with_allowed_scheme(s, LINKABLE_PASTE_SCHEMES)
173}
174
175fn try_build_markdown_link(clip: &str, selection: Option<&str>) -> Option<String> {
179 let url = linkable_url(clip)?;
180 let sel = selection.filter(|s| !s.is_empty())?;
181 let escaped = sel.replace('\\', r"\\").replace(']', r"\]");
182 Some(format!("[{escaped}]({url})"))
183}
184
185use std::sync::Arc;
186
187use kimun_core::NoteVault;
188
189use crate::components::Component;
190use crate::components::autocomplete::{
191 self, AutocompleteController, AutocompleteHost, AutocompleteMode, HandleKeyOutcome,
192};
193use crate::components::event_state::EventState;
194use crate::components::events::AppEvent;
195use crate::components::events::AppTx;
196use crate::components::events::InputEvent;
197use crate::components::events::redraw_callback;
198use crate::components::single_line_input::{InputOutcome, SingleLineInput};
199use crate::components::text_editor::autocomplete_glue::apply_accept_to_textarea;
200use crate::keys::KeyBindings;
201use crate::keys::action_shortcuts::TextAction;
202use crate::settings::AppSettings;
203use crate::settings::themes::Theme;
204
205#[derive(Debug, Clone, PartialEq)]
207pub enum LinkTarget {
208 Note(String),
210 Label(String),
212}
213
214struct SearchState {
215 input: SingleLineInput,
216 status: SearchStatus,
217}
218
219enum SearchStatus {
220 Empty,
221 Match,
222 NoMatch,
223 Invalid(String),
224}
225
226impl SearchStatus {
227 fn from_found(found: bool) -> Self {
228 if found { Self::Match } else { Self::NoMatch }
229 }
230}
231
232const FIND_PROMPT: &str = "Find: ";
233const FIND_HINTS: &str = " [Enter] next [Shift+Enter] prev [Esc] close";
234
235fn render_search_bar(
236 f: &mut Frame,
237 rect: Rect,
238 state: &mut SearchState,
239 theme: &Theme,
240 focused: bool,
241) {
242 let base = theme.base_style();
243 let muted = Style::default()
244 .fg(theme.fg_muted.to_ratatui())
245 .bg(theme.bg.to_ratatui());
246 let err = Style::default()
247 .fg(ratatui::style::Color::Red)
248 .bg(theme.bg.to_ratatui());
249 let prompt_cols = unicode_width::UnicodeWidthStr::width(FIND_PROMPT) as u16;
250 let value_total_cols = state.input.display_width() as u16;
254 let tail: Option<(String, Style)> = match &state.status {
255 SearchStatus::Empty => None,
256 SearchStatus::Match => Some((FIND_HINTS.to_string(), muted)),
257 SearchStatus::NoMatch => Some((" no match".to_string(), err)),
258 SearchStatus::Invalid(msg) => Some((format!(" invalid regex: {msg}"), err)),
259 };
260 f.render_widget(
261 Paragraph::new(Line::from(Span::styled(
262 FIND_PROMPT,
263 base.add_modifier(Modifier::BOLD),
264 )))
265 .style(base),
266 Rect {
267 width: prompt_cols.min(rect.width),
268 ..rect
269 },
270 );
271 state.input.render(f, rect, base, prompt_cols, focused);
272 if let Some((text, style)) = tail {
273 let consumed = prompt_cols.saturating_add(value_total_cols);
274 let tail_rect = Rect {
275 x: rect.x.saturating_add(consumed),
276 width: rect.width.saturating_sub(consumed),
277 ..rect
278 };
279 f.render_widget(Paragraph::new(text).style(style), tail_rect);
280 }
281}
282
283struct EditorHostSnapshot<'a> {
290 snap: EditorSnapshot<'a>,
291 cursor_screen: Option<(u16, u16)>,
292 cache_key: Option<NonZeroU64>,
293}
294
295impl<'a> AutocompleteHost for EditorHostSnapshot<'a> {
296 fn buffer_snapshot(&self) -> EditorSnapshot<'_> {
297 EditorSnapshot::borrowed(
302 self.snap.lines.as_ref(),
303 self.snap.cursor,
304 self.snap.content_revision,
305 )
306 }
307 fn cache_key(&self) -> Option<NonZeroU64> {
308 self.cache_key
309 }
310 fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
311 Some(self.cursor_screen.unwrap_or((0, 0)))
325 }
326}
327
328fn build_editor_host_snapshot<'a>(
334 backend: &'a BackendState,
335 content_revision: NonZeroU64,
336 cursor_screen: Option<(u16, u16)>,
337) -> Option<EditorHostSnapshot<'a>> {
338 if !matches!(backend, BackendState::Textarea(_)) {
339 return None;
340 }
341 Some(EditorHostSnapshot {
342 snap: snapshot_from_backend(backend, content_revision),
343 cursor_screen,
344 cache_key: Some(content_revision),
345 })
346}
347
348pub struct TextEditorComponent {
352 backend: BackendState,
353 rect: Rect,
355 key_bindings: KeyBindings,
356 saved_content_rev: Option<NonZeroU64>,
365 view: MarkdownEditorView,
366 edit_generation: u64,
371 content_revision: NonZeroU64,
394 selection: Option<((usize, usize), (usize, usize))>,
397 clipboard: Option<Clipboard>,
399 nvim_pending_z: bool,
402 search: Option<SearchState>,
404 autocomplete: Option<AutocompleteController>,
408 autocomplete_vault: Option<Arc<NoteVault>>,
412 autocomplete_redraw_bound: bool,
417 full_parse_task: SingleSlotTask<()>,
424 full_parse_tx: tokio::sync::mpsc::UnboundedSender<(u64, ParsedBuffer)>,
425 full_parse_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, ParsedBuffer)>,
426 redraw_tx: Option<AppTx>,
430}
431
432impl TextEditorComponent {
433 pub fn new(key_bindings: KeyBindings, settings: &AppSettings) -> Self {
434 let (full_parse_tx, full_parse_rx) = tokio::sync::mpsc::unbounded_channel();
435 Self {
436 backend: BackendState::from_settings(
437 &settings.editor_backend,
438 settings.nvim_path.as_ref(),
439 ),
440 rect: Rect::default(),
441 key_bindings,
442 saved_content_rev: NonZeroU64::new(1),
443 view: MarkdownEditorView::new(),
444 edit_generation: 0,
445 content_revision: NonZeroU64::new(1).unwrap(),
446 selection: None,
447 clipboard: Clipboard::new().ok(),
448 nvim_pending_z: false,
449 search: None,
450 autocomplete: None,
451 autocomplete_vault: None,
452 autocomplete_redraw_bound: false,
453 full_parse_task: SingleSlotTask::empty(),
454 full_parse_tx,
455 full_parse_rx,
456 redraw_tx: None,
457 }
458 }
459
460 pub fn set_vault(&mut self, vault: Arc<NoteVault>) {
465 self.autocomplete_vault = Some(vault.clone());
466 if matches!(self.backend, BackendState::Textarea(_)) {
467 self.autocomplete = Some(AutocompleteController::new(
468 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
469 AutocompleteMode::Both,
470 ));
471 }
472 }
473
474 fn ensure_autocomplete_for_textarea(&mut self) {
479 if self.autocomplete.is_some() {
480 return;
481 }
482 if !matches!(self.backend, BackendState::Textarea(_)) {
483 return;
484 }
485 let Some(vault) = self.autocomplete_vault.clone() else {
486 return;
487 };
488 self.autocomplete = Some(AutocompleteController::new(
489 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
490 AutocompleteMode::Both,
491 ));
492 self.autocomplete_redraw_bound = false;
495 }
496
497 #[allow(dead_code)]
504 fn autocomplete_host_snapshot(&self) -> Option<EditorHostSnapshot<'_>> {
505 build_editor_host_snapshot(
506 &self.backend,
507 self.content_revision,
508 self.view.last_cursor_screen,
509 )
510 }
511
512 fn poll_autocomplete(&mut self) {
515 if let Some(controller) = self.autocomplete.as_mut() {
516 controller.poll_results();
517 }
518 }
519
520 fn textarea_cursor(&self) -> Option<(usize, usize)> {
524 let BackendState::Textarea(ta) = &self.backend else {
525 return None;
526 };
527 Some(cursor_tuple(ta))
528 }
529
530 fn refresh_autocomplete_if_open(&mut self) {
531 if !self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
533 return;
534 }
535 let Some(snapshot) = build_editor_host_snapshot(
539 &self.backend,
540 self.content_revision,
541 self.view.last_cursor_screen,
542 ) else {
543 self.close_autocomplete();
544 return;
545 };
546 if let Some(controller) = self.autocomplete.as_mut() {
547 controller.refresh_if_open(&snapshot);
548 }
549 }
550
551 fn sync_autocomplete(&mut self) {
555 let Some(controller) = self.autocomplete.as_ref() else {
556 return; };
558
559 if !controller.is_open() {
572 let BackendState::Textarea(ta) = &self.backend else {
573 return;
574 };
575 let (row, col) = cursor_tuple(ta);
576 let line = ta.lines().get(row).map(|s| s.as_str()).unwrap_or("");
577 if !has_trigger_before_cursor(line, col) {
578 return;
579 }
580 }
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 if let Some(c) = self.autocomplete.as_mut() {
591 c.close();
592 }
593 return;
594 };
595 if let Some(controller) = self.autocomplete.as_mut() {
596 controller.sync(&snapshot);
597 }
598 }
599
600 pub fn lines(&self) -> &[String] {
606 match &self.backend {
607 BackendState::Textarea(ta) => ta.lines(),
608 BackendState::Nvim(_) => &[],
609 }
610 }
611
612 pub fn view_snapshot(&self) -> EditorSnapshot<'_> {
631 snapshot_from_backend(&self.backend, self.content_revision)
632 }
633
634 pub fn set_text(&mut self, text: String) {
635 if text == self.get_text() {
642 self.saved_content_rev = Some(self.content_revision);
643 if let BackendState::Nvim(nvim) = &self.backend {
644 nvim.snapshot
645 .lock()
646 .unwrap_or_else(|p| p.into_inner())
647 .dirty = false;
648 }
649 return;
650 }
651 match &mut self.backend {
652 BackendState::Textarea(ta) => {
653 let lines = text.lines();
654 *ta = TextArea::from(lines);
655 }
656 BackendState::Nvim(nvim) => {
657 nvim.set_text(&text);
658 }
659 }
660 self.bump_content();
661 let reconstructed = self.get_text();
662 self.mark_saved(reconstructed);
663 self.close_autocomplete();
666 }
667
668 pub fn get_text(&self) -> String {
669 match &self.backend {
670 BackendState::Textarea(ta) => ta.lines().join("\n"),
671 BackendState::Nvim(nvim) => nvim
672 .snapshot
673 .lock()
674 .unwrap_or_else(|p| p.into_inner())
675 .lines
676 .join("\n"),
677 }
678 }
679
680 pub fn content_revision(&self) -> NonZeroU64 {
687 self.content_revision
688 }
689
690 pub fn mark_saved_at_revision(&mut self, rev: NonZeroU64) {
700 if rev != self.content_revision {
701 return;
702 }
703 if let BackendState::Nvim(nvim) = &self.backend {
704 nvim.snapshot
705 .lock()
706 .unwrap_or_else(|p| p.into_inner())
707 .dirty = false;
708 }
709 self.saved_content_rev = Some(rev);
710 }
711
712 pub fn mark_saved(&mut self, text: String) {
720 let matches = text == self.get_text();
721 if matches {
722 if let BackendState::Nvim(nvim) = &self.backend {
723 nvim.snapshot
724 .lock()
725 .unwrap_or_else(|p| p.into_inner())
726 .dirty = false;
727 }
728 self.saved_content_rev = Some(self.content_revision);
729 } else {
730 self.saved_content_rev = None;
735 }
736 }
737
738 pub fn is_dirty(&self) -> bool {
739 match &self.backend {
740 BackendState::Textarea(_) => self.saved_content_rev != Some(self.content_revision),
741 BackendState::Nvim(nvim) => {
742 nvim.snapshot
743 .lock()
744 .unwrap_or_else(|p| p.into_inner())
745 .dirty
746 }
747 }
748 }
749
750 pub fn link_at_cursor(&self) -> Option<LinkTarget> {
753 let (_row, col, line) = match &self.backend {
754 BackendState::Textarea(ta) => {
755 let (row, col) = cursor_tuple(ta);
756 let line = ta.lines().get(row)?.to_string();
757 (row, col, line)
758 }
759 BackendState::Nvim(nvim) => {
760 let snap = nvim.snapshot.lock().unwrap_or_else(|p| p.into_inner());
761 let (row, col) = snap.cursor;
762 let line = snap.lines.get(row)?.to_string();
763 (row, col, line)
764 }
765 };
766
767 if let Some(span) = kimun_core::note::link_char_spans(&line)
770 .into_iter()
771 .find(|s| s.start <= col && col < s.end)
772 {
773 return Some(LinkTarget::Note(span.target));
774 }
775
776 let parsed = self::markdown::ParsedLine::parse(&line);
778 parsed
779 .elements
780 .iter()
781 .find(|e| {
782 e.kind == self::markdown::ElementKind::Label
783 && col >= e.start_char
784 && col < e.end_char
785 })
786 .map(|e| {
787 let span: String = line
788 .chars()
789 .skip(e.start_char)
790 .take(e.end_char - e.start_char)
791 .collect();
792 let name = span.trim_start_matches('#').to_string();
793 LinkTarget::Label(name)
794 })
795 }
796
797 fn copy_selection_to_clipboard(&mut self) {
799 let text = {
800 let BackendState::Textarea(ta) = &self.backend else {
801 return;
802 };
803 match selection_text(ta) {
804 Some(t) => t,
805 None => return,
806 }
807 };
808 if let Some(cb) = &mut self.clipboard {
809 let _ = cb.set_text(text);
810 }
811 }
812
813 fn paste_from_clipboard(&mut self, tx: &AppTx) {
815 let text = match &mut self.clipboard {
816 Some(cb) => match cb.get_text() {
817 Ok(t) if !t.is_empty() => t,
818 _ => return,
819 },
820 None => return,
821 };
822 self.paste_text(&text, tx);
823 }
824
825 pub fn paste_text(&mut self, text: &str, tx: &AppTx) {
834 if text.is_empty() {
835 return;
836 }
837 match &mut self.backend {
838 BackendState::Textarea(ta) => {
839 let selection = linkable_url(text).and_then(|_| selection_text(ta));
840 let wrapped = try_build_markdown_link(text, selection.as_deref());
841 if ta.selection_range().is_some() {
842 ta.cut();
843 }
844 ta.insert_str(wrapped.as_deref().unwrap_or(text));
845 self.selection = ta.selection_range();
846 self.bump_content();
847 }
848 BackendState::Nvim(nvim) => {
849 nvim.paste(text, tx.clone());
850 self.bump_content();
851 }
852 }
853 self.bind_autocomplete_redraw(tx);
857 self.sync_autocomplete();
858 }
859
860 pub fn insert_at_cursor(&mut self, text: &str, tx: &AppTx) {
865 if matches!(self.backend, BackendState::Nvim(_)) {
866 self.paste_text(text, tx);
867 return;
868 }
869 if let BackendState::Textarea(ta) = &mut self.backend {
870 if ta.selection_range().is_some() {
871 ta.cut();
872 }
873 ta.insert_str(text);
874 self.selection = ta.selection_range();
875 self.bump_content();
876 }
877 self.bind_autocomplete_redraw(tx);
880 self.sync_autocomplete();
881 }
882
883 pub fn take_clipboard_image(&mut self) -> Option<ClipboardImage> {
887 let cb = self.clipboard.as_mut()?;
888 let img = cb.get_image().ok()?;
889 Some(ClipboardImage {
890 width: img.width,
891 height: img.height,
892 rgba: img.bytes.into_owned(),
893 })
894 }
895
896 pub fn apply_text_action(&mut self, action: TextAction) {
899 let marker = match action {
900 TextAction::Bold => "**",
901 TextAction::Italic => "*",
902 TextAction::Strikethrough => "~~",
903 _ => return,
904 };
905 let BackendState::Textarea(ta) = &mut self.backend else {
906 return;
907 };
908 match selection_text(ta) {
909 Some(text) => {
910 ta.insert_str(format!("{marker}{text}{marker}"));
911 }
912 None => {
913 ta.insert_str(format!("{marker}{marker}"));
914 for _ in 0..marker.len() {
915 ta.move_cursor(CursorMove::Back);
916 }
917 }
918 }
919 self.selection = ta.selection_range();
920 self.bump_content();
921 }
922
923 pub fn smart_enter(&mut self) -> bool {
928 enum Action {
929 ClearLine { chars: usize },
930 InsertPrefix(String),
931 Dedent,
932 }
933 let action = {
934 let BackendState::Textarea(ta) = &self.backend else {
935 return false;
936 };
937 if ta.selection_range().is_some() {
938 return false;
939 }
940 let (row, col) = cursor_tuple(ta);
941 let Some(line) = ta.lines().get(row) else {
942 return false;
943 };
944 let total_chars = line.chars().count();
945 if col != total_chars {
946 return false;
947 }
948 let ws_end = markdown::leading_ws_byte_len(line);
950 let (ws, after_ws) = line.split_at(ws_end);
951 if let Some(marker_len) = markdown::list_marker_len(after_ws) {
952 if after_ws.len() == marker_len {
953 if ws_end > 0 {
956 Action::Dedent
957 } else {
958 Action::ClearLine { chars: total_chars }
959 }
960 } else {
961 let marker_str = &after_ws[..marker_len];
962 let next_marker = increment_ordered_marker(marker_str)
963 .unwrap_or_else(|| marker_str.to_string());
964 Action::InsertPrefix(format!("{ws}{next_marker}"))
965 }
966 } else if ws_end > 0 && total_chars == ws_end {
967 Action::Dedent
968 } else if ws_end > 0 {
969 Action::InsertPrefix(ws.to_string())
970 } else {
971 return false;
972 }
973 };
974
975 match action {
976 Action::Dedent => {
977 self.indent_lines(true);
978 return true;
979 }
980 Action::ClearLine { chars } => {
981 let BackendState::Textarea(ta) = &mut self.backend else {
982 unreachable!()
983 };
984 ta.move_cursor(CursorMove::Head);
985 ta.delete_str(chars);
986 }
987 Action::InsertPrefix(prefix) => {
988 let BackendState::Textarea(ta) = &mut self.backend else {
989 unreachable!()
990 };
991 ta.insert_newline();
992 ta.insert_str(prefix);
993 }
994 }
995 let BackendState::Textarea(ta) = &self.backend else {
996 unreachable!()
997 };
998 self.selection = ta.selection_range();
999 self.bump_content();
1000 true
1001 }
1002
1003 pub fn indent_lines(&mut self, dedent: bool) {
1007 let BackendState::Textarea(ta) = &mut self.backend else {
1008 return;
1009 };
1010 let tab_len = ta.tab_length() as usize;
1011 let hard_tab = ta.hard_tab_indent();
1012 let indent: String = if hard_tab {
1013 "\t".to_string()
1014 } else {
1015 " ".repeat(tab_len)
1016 };
1017 if indent.is_empty() {
1018 return;
1019 }
1020 let indent_chars = indent.len();
1021
1022 let sel = ta.selection_range();
1023 let saved_cursor = if sel.is_none() {
1024 Some(cursor_tuple(ta))
1025 } else {
1026 None
1027 };
1028 let (start_row, end_row) = match sel {
1029 Some(((sr, _), (er, ec))) => {
1030 let last = if ec == 0 && er > sr { er - 1 } else { er };
1033 (sr, last)
1034 }
1035 None => {
1036 let (r, _) = saved_cursor.unwrap();
1037 (r, r)
1038 }
1039 };
1040
1041 let row_count = end_row.saturating_sub(start_row) + 1;
1042 let mut row_deltas: Vec<isize> = Vec::with_capacity(row_count);
1043 let mut any_change = false;
1044
1045 for row in start_row..=end_row {
1046 if dedent {
1047 let count = {
1048 let line = ta.lines().get(row).map(|s| s.as_str()).unwrap_or("");
1049 let max_remove = if hard_tab { 1 } else { tab_len };
1050 let mut count = 0usize;
1051 for (i, c) in line.chars().enumerate() {
1052 if i >= max_remove {
1053 break;
1054 }
1055 if c == '\t' {
1056 count += 1;
1057 break;
1058 } else if c == ' ' && !hard_tab {
1059 count += 1;
1060 } else {
1061 break;
1062 }
1063 }
1064 count
1065 };
1066 if count > 0 {
1067 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1068 ta.delete_str(count);
1069 any_change = true;
1070 }
1071 row_deltas.push(-(count as isize));
1072 } else {
1073 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1074 ta.insert_str(&indent);
1075 row_deltas.push(indent_chars as isize);
1076 any_change = true;
1077 }
1078 }
1079
1080 let adj = |row: usize, col: usize| -> usize {
1081 if row >= start_row && row <= end_row {
1082 let d = row_deltas[row - start_row];
1083 if d >= 0 {
1084 col + d as usize
1085 } else {
1086 col.saturating_sub((-d) as usize)
1087 }
1088 } else {
1089 col
1090 }
1091 };
1092
1093 match sel {
1094 Some(((ssr, ssc), (ser, sec))) => {
1095 ta.cancel_selection();
1096 let new_ssc = adj(ssr, ssc);
1097 let new_sec = adj(ser, sec);
1098 ta.move_cursor(CursorMove::Jump(ssr as u16, new_ssc as u16));
1099 ta.start_selection();
1100 ta.move_cursor(CursorMove::Jump(ser as u16, new_sec as u16));
1101 }
1102 None => {
1103 let (cr, cc) = saved_cursor.expect("captured when sel is None");
1104 let new_col = adj(cr, cc);
1105 ta.move_cursor(CursorMove::Jump(cr as u16, new_col as u16));
1106 }
1107 }
1108
1109 if any_change {
1110 self.selection = ta.selection_range();
1111 self.bump_content();
1112 }
1113 }
1114}
1115
1116impl TextEditorComponent {
1117 #[inline]
1122 fn bump_cursor(&mut self) {
1123 self.edit_generation = self.edit_generation.wrapping_add(1);
1124 }
1125
1126 #[inline]
1136 fn bump_content(&mut self) {
1137 self.edit_generation = self.edit_generation.wrapping_add(1);
1138 let next = self.content_revision.get().wrapping_add(1);
1143 self.content_revision = NonZeroU64::new(next).unwrap_or(NonZeroU64::new(1).unwrap());
1144 }
1145
1146 fn maybe_recover_from_dead_nvim(&mut self) {
1148 use std::sync::atomic::Ordering;
1149 let fallback_text = if let BackendState::Nvim(nvim) = &self.backend {
1150 if nvim.is_dead.load(Ordering::SeqCst) {
1151 Some(
1152 nvim.snapshot
1153 .lock()
1154 .unwrap_or_else(|p| p.into_inner())
1155 .lines
1156 .join("\n"),
1157 )
1158 } else {
1159 None
1160 }
1161 } else {
1162 None
1163 };
1164 if let Some(text) = fallback_text {
1165 tracing::warn!("nvim process died; falling back to textarea backend");
1166 self.backend = BackendState::Textarea(TextArea::from(text.lines()));
1167 self.ensure_autocomplete_for_textarea();
1171 }
1172 }
1173
1174 fn handle_nvim_key(
1179 &mut self,
1180 key: &ratatui::crossterm::event::KeyEvent,
1181 tx: &AppTx,
1182 ) -> Option<EventState> {
1183 let BackendState::Nvim(nvim) = &self.backend else {
1184 return None;
1185 };
1186
1187 if self.nvim_pending_z {
1193 self.nvim_pending_z = false;
1194 match key.code {
1195 KeyCode::Char('Z') => {
1196 tx.send(AppEvent::Autosave).ok();
1198 tx.send(AppEvent::FocusSidebar).ok();
1199 return Some(EventState::Consumed);
1200 }
1201 KeyCode::Char('Q') => {
1202 tx.send(AppEvent::FocusSidebar).ok();
1204 return Some(EventState::Consumed);
1205 }
1206 _ => {
1207 nvim.handle_key(
1209 &ratatui::crossterm::event::KeyEvent::new(
1210 KeyCode::Char('Z'),
1211 KeyModifiers::NONE,
1212 ),
1213 tx.clone(),
1214 );
1215 }
1217 }
1218 } else if key.code == KeyCode::Char('Z') {
1219 let in_normal = {
1220 let snap = nvim.snapshot.lock().unwrap_or_else(|p| p.into_inner());
1221 snap.mode == NvimMode::Normal
1222 };
1223 if in_normal {
1224 self.nvim_pending_z = true;
1225 return Some(EventState::Consumed);
1226 }
1227 }
1228
1229 if key.code == KeyCode::Enter {
1232 let (is_cmd, cmdline) = {
1233 let snap = nvim.snapshot.lock().unwrap_or_else(|p| p.into_inner());
1234 let cmd = if snap.mode == NvimMode::Command {
1235 snap.cmdline
1236 .as_deref()
1237 .unwrap_or("")
1238 .trim_start_matches(':')
1239 .to_string()
1240 } else {
1241 String::new()
1242 };
1243 (snap.mode == NvimMode::Command, cmd)
1244 };
1245 if is_cmd {
1246 let saves = matches!(
1247 cmdline.as_str(),
1248 "w" | "wq" | "wq!" | "wqa" | "wqa!" | "x" | "xa" | "x!"
1249 );
1250 let quits =
1251 saves || matches!(cmdline.as_str(), "q" | "q!" | "qa" | "qa!" | "cq" | "cq!");
1252 if quits {
1253 nvim.handle_key(
1254 &ratatui::crossterm::event::KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
1255 tx.clone(),
1256 );
1257 if saves {
1258 tx.send(AppEvent::Autosave).ok();
1259 }
1260 tx.send(AppEvent::FocusSidebar).ok();
1261 return Some(EventState::Consumed);
1262 }
1263 }
1264 }
1265
1266 nvim.handle_key(key, tx.clone());
1267 self.bump_cursor();
1277 Some(EventState::Consumed)
1278 }
1279
1280 pub fn open_or_advance_search(&mut self) {
1284 if !matches!(self.backend, BackendState::Textarea(_)) {
1285 return;
1286 }
1287 if self.search.is_some() {
1288 self.search_advance(false);
1289 return;
1290 }
1291 self.close_autocomplete();
1295 self.search = Some(SearchState {
1296 input: SingleLineInput::new(),
1297 status: SearchStatus::Empty,
1298 });
1299 }
1300
1301 pub fn close_autocomplete(&mut self) {
1305 if let Some(c) = self.autocomplete.as_mut() {
1306 c.close();
1307 }
1308 }
1309
1310 pub fn set_redraw_tx(&mut self, tx: &AppTx) {
1315 self.bind_autocomplete_redraw(tx);
1316 }
1317
1318 fn bind_autocomplete_redraw(&mut self, tx: &AppTx) {
1327 if self.redraw_tx.is_none() {
1328 self.redraw_tx = Some(tx.clone());
1329 }
1330 if self.autocomplete_redraw_bound {
1331 return;
1332 }
1333 if let Some(c) = self.autocomplete.as_mut() {
1334 c.set_redraw_callback(redraw_callback(tx.clone()));
1335 self.autocomplete_redraw_bound = true;
1336 }
1337 }
1338
1339 fn close_search(&mut self) {
1340 if let BackendState::Textarea(ta) = &mut self.backend {
1341 let _ = ta.set_search_pattern("");
1342 }
1343 self.search = None;
1344 self.selection = None;
1345 }
1346
1347 fn refresh_search_pattern(&mut self, jump: bool) {
1350 let Some(state) = self.search.as_mut() else {
1351 return;
1352 };
1353 let BackendState::Textarea(ta) = &mut self.backend else {
1354 return;
1355 };
1356 if state.input.is_empty() {
1357 let _ = ta.set_search_pattern("");
1358 state.status = SearchStatus::Empty;
1359 self.selection = None;
1360 return;
1361 }
1362 if let Err(e) = ta.set_search_pattern(state.input.value()) {
1363 state.status = SearchStatus::Invalid(e.to_string());
1364 self.selection = None;
1365 return;
1366 }
1367 if !jump {
1368 state.status = SearchStatus::Match;
1369 return;
1370 }
1371 let found = ta.search_forward(true);
1372 state.status = SearchStatus::from_found(found);
1373 self.highlight_current_match(found);
1374 }
1375
1376 fn search_advance(&mut self, backward: bool) {
1377 let Some(state) = self.search.as_mut() else {
1378 return;
1379 };
1380 if state.input.is_empty() {
1381 return;
1382 }
1383 let BackendState::Textarea(ta) = &mut self.backend else {
1384 return;
1385 };
1386 let found = if backward {
1387 ta.search_back(false)
1388 } else {
1389 ta.search_forward(false)
1390 };
1391 state.status = SearchStatus::from_found(found);
1392 self.highlight_current_match(found);
1393 }
1394
1395 fn highlight_current_match(&mut self, found: bool) {
1400 self.selection = if found {
1401 self.compute_match_selection()
1402 } else {
1403 None
1404 };
1405 }
1406
1407 fn compute_match_selection(&self) -> Option<((usize, usize), (usize, usize))> {
1413 let BackendState::Textarea(ta) = &self.backend else {
1414 return None;
1415 };
1416 let re = ta.search_pattern()?;
1417 let DataCursor(row, col_chars) = ta.cursor();
1418 let line = ta.lines().get(row)?;
1419 let byte_off = char_col_to_byte(line, col_chars);
1420 let m = re.find_at(line, byte_off)?;
1421 if m.start() != byte_off {
1422 return None;
1423 }
1424 let match_chars = line[m.range()].chars().count();
1425 Some(((row, col_chars), (row, col_chars + match_chars)))
1426 }
1427
1428 fn handle_search_key(&mut self, key: &ratatui::crossterm::event::KeyEvent) -> bool {
1430 let Some(state) = self.search.as_mut() else {
1431 return false;
1432 };
1433 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1434 match state.input.handle_key(key) {
1435 InputOutcome::Cancel => self.close_search(),
1436 InputOutcome::Submit => self.search_advance(shift),
1437 InputOutcome::Changed => self.refresh_search_pattern(true),
1438 InputOutcome::Consumed | InputOutcome::NotConsumed => {}
1439 }
1440 true
1441 }
1442
1443 fn handle_textarea_key(
1445 &mut self,
1446 key: &ratatui::crossterm::event::KeyEvent,
1447 tx: &AppTx,
1448 ) -> EventState {
1449 if self.handle_search_key(key) {
1451 return EventState::Consumed;
1452 }
1453
1454 if key.modifiers == KeyModifiers::CONTROL {
1456 match key.code {
1457 KeyCode::Char('c') => {
1458 self.copy_selection_to_clipboard();
1459 return EventState::Consumed;
1460 }
1461 KeyCode::Char('v') => {
1462 self.paste_from_clipboard(tx);
1463 return EventState::Consumed;
1464 }
1465 KeyCode::Char('x') => {
1466 self.copy_selection_to_clipboard();
1467 let cut = if let BackendState::Textarea(ta) = &mut self.backend {
1468 let cut = ta.cut();
1474 self.selection = ta.selection_range();
1475 cut
1476 } else {
1477 false
1478 };
1479 if cut {
1480 self.bump_content();
1481 }
1482 return EventState::Consumed;
1483 }
1484 _ => {}
1485 }
1486 }
1487
1488 let BackendState::Textarea(ta) = &mut self.backend else {
1489 unreachable!("handle_textarea_key called with non-Textarea backend")
1490 };
1491
1492 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1494 let handled = match (key.modifiers & !KeyModifiers::SHIFT, key.code) {
1495 (KeyModifiers::ALT, KeyCode::Left) => {
1496 cursor_move!(ta, CursorMove::WordBack, shift);
1497 true
1498 }
1499 (KeyModifiers::ALT, KeyCode::Right) => {
1500 cursor_move!(ta, CursorMove::WordForward, shift);
1501 true
1502 }
1503 (KeyModifiers::SUPER, KeyCode::Left) => {
1504 cursor_move!(ta, CursorMove::Head, shift);
1505 true
1506 }
1507 (KeyModifiers::SUPER, KeyCode::Right) => {
1508 cursor_move!(ta, CursorMove::End, shift);
1509 true
1510 }
1511 (KeyModifiers::SUPER, KeyCode::Up) => {
1512 cursor_move!(ta, CursorMove::Top, shift);
1513 true
1514 }
1515 (KeyModifiers::SUPER, KeyCode::Down) => {
1516 cursor_move!(ta, CursorMove::Bottom, shift);
1517 true
1518 }
1519 _ => false,
1520 };
1521 if handled {
1522 self.selection = ta.selection_range();
1523 self.bump_cursor();
1524 return EventState::Consumed;
1525 }
1526
1527 enum ShortcutOutcome {
1538 NoOp,
1539 CursorOnly,
1540 TextMutated,
1541 }
1542 let outcome: Option<ShortcutOutcome> =
1543 match (key.modifiers & !KeyModifiers::SHIFT, key.code) {
1544 (KeyModifiers::NONE, KeyCode::Left) => {
1546 cursor_move!(ta, CursorMove::Back, shift);
1547 Some(ShortcutOutcome::CursorOnly)
1548 }
1549 (KeyModifiers::NONE, KeyCode::Right) => {
1550 cursor_move!(ta, CursorMove::Forward, shift);
1551 Some(ShortcutOutcome::CursorOnly)
1552 }
1553 (KeyModifiers::NONE, KeyCode::Up) => {
1554 cursor_move!(ta, CursorMove::Up, shift);
1555 Some(ShortcutOutcome::CursorOnly)
1556 }
1557 (KeyModifiers::NONE, KeyCode::Down) => {
1558 cursor_move!(ta, CursorMove::Down, shift);
1559 Some(ShortcutOutcome::CursorOnly)
1560 }
1561 (KeyModifiers::NONE, KeyCode::Home) => {
1562 cursor_move!(ta, CursorMove::Head, shift);
1563 Some(ShortcutOutcome::CursorOnly)
1564 }
1565 (KeyModifiers::NONE, KeyCode::End) => {
1566 cursor_move!(ta, CursorMove::End, shift);
1567 Some(ShortcutOutcome::CursorOnly)
1568 }
1569 (KeyModifiers::NONE, KeyCode::PageUp) => {
1570 cursor_move!(ta, CursorMove::ParagraphBack, shift);
1571 Some(ShortcutOutcome::CursorOnly)
1572 }
1573 (KeyModifiers::NONE, KeyCode::PageDown) => {
1574 cursor_move!(ta, CursorMove::ParagraphForward, shift);
1575 Some(ShortcutOutcome::CursorOnly)
1576 }
1577 (KeyModifiers::CONTROL, KeyCode::Left) => {
1579 cursor_move!(ta, CursorMove::WordBack, shift);
1580 Some(ShortcutOutcome::CursorOnly)
1581 }
1582 (KeyModifiers::CONTROL, KeyCode::Right) => {
1583 cursor_move!(ta, CursorMove::WordForward, shift);
1584 Some(ShortcutOutcome::CursorOnly)
1585 }
1586 (KeyModifiers::CONTROL, KeyCode::Home) => {
1588 cursor_move!(ta, CursorMove::Top, shift);
1589 Some(ShortcutOutcome::CursorOnly)
1590 }
1591 (KeyModifiers::CONTROL, KeyCode::End) => {
1592 cursor_move!(ta, CursorMove::Bottom, shift);
1593 Some(ShortcutOutcome::CursorOnly)
1594 }
1595 (KeyModifiers::CONTROL, KeyCode::Char('z')) => {
1599 if ta.undo() {
1600 Some(ShortcutOutcome::TextMutated)
1601 } else {
1602 Some(ShortcutOutcome::NoOp)
1603 }
1604 }
1605 (KeyModifiers::CONTROL, KeyCode::Char('y'))
1606 | (KeyModifiers::CONTROL, KeyCode::Char('Z')) => {
1607 if ta.redo() {
1608 Some(ShortcutOutcome::TextMutated)
1609 } else {
1610 Some(ShortcutOutcome::NoOp)
1611 }
1612 }
1613 (KeyModifiers::CONTROL, KeyCode::Char('a')) => {
1615 ta.move_cursor(CursorMove::Top);
1616 ta.start_selection();
1617 ta.move_cursor(CursorMove::Bottom);
1618 Some(ShortcutOutcome::CursorOnly)
1619 }
1620 (KeyModifiers::CONTROL, KeyCode::Backspace)
1623 | (KeyModifiers::ALT, KeyCode::Backspace) => {
1624 if ta.delete_word() {
1625 Some(ShortcutOutcome::TextMutated)
1626 } else {
1627 Some(ShortcutOutcome::NoOp)
1628 }
1629 }
1630 (KeyModifiers::CONTROL, KeyCode::Delete) | (KeyModifiers::ALT, KeyCode::Delete) => {
1631 if ta.delete_next_word() {
1632 Some(ShortcutOutcome::TextMutated)
1633 } else {
1634 Some(ShortcutOutcome::NoOp)
1635 }
1636 }
1637 _ => None,
1638 };
1639 if let Some(kind) = outcome {
1640 self.selection = ta.selection_range();
1641 match kind {
1642 ShortcutOutcome::NoOp => {}
1643 ShortcutOutcome::CursorOnly => self.bump_cursor(),
1644 ShortcutOutcome::TextMutated => self.bump_content(),
1645 }
1646 return EventState::Consumed;
1647 }
1648
1649 match (key.modifiers, key.code) {
1651 (m, KeyCode::Tab)
1652 if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
1653 {
1654 self.indent_lines(m.contains(KeyModifiers::SHIFT));
1655 return EventState::Consumed;
1656 }
1657 (_, KeyCode::BackTab) => {
1658 self.indent_lines(true);
1659 return EventState::Consumed;
1660 }
1661 _ => {}
1662 }
1663 if key.code == KeyCode::Enter && key.modifiers.is_empty() && self.smart_enter() {
1664 return EventState::Consumed;
1665 }
1666
1667 let BackendState::Textarea(ta) = &mut self.backend else {
1668 unreachable!("handle_textarea_key called with non-Textarea backend")
1669 };
1670 let mutated = ta.input_without_shortcuts(*key);
1676 self.selection = ta.selection_range();
1677 if mutated {
1678 self.bump_content();
1679 } else {
1680 self.bump_cursor();
1681 }
1682 EventState::Consumed
1683 }
1684
1685 fn handle_mouse(
1687 &mut self,
1688 mouse: &ratatui::crossterm::event::MouseEvent,
1689 tx: &AppTx,
1690 ) -> EventState {
1691 let BackendState::Textarea(_) = &self.backend else {
1692 return EventState::NotConsumed;
1693 };
1694 let r = &self.rect;
1695 let in_bounds = mouse.column >= r.x
1696 && mouse.column < r.x + r.width
1697 && mouse.row >= r.y
1698 && mouse.row < r.y + r.height;
1699 if !in_bounds {
1700 return EventState::NotConsumed;
1701 }
1702 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
1704 tx.send(AppEvent::FocusEditor).ok();
1705 self.copy_selection_to_clipboard();
1706 self.selection = if let BackendState::Textarea(ta) = &self.backend {
1707 ta.selection_range()
1708 } else {
1709 None
1710 };
1711 self.bump_cursor();
1712 return EventState::Consumed;
1713 }
1714 let BackendState::Textarea(ta) = &mut self.backend else {
1716 unreachable!()
1717 };
1718 match mouse.kind {
1719 MouseEventKind::Down(_) => {
1720 tx.send(AppEvent::FocusEditor).ok();
1721 ta.cancel_selection();
1722 let (lrow, lcol) = self
1723 .view
1724 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1725 ta.move_cursor(CursorMove::Jump(lrow, lcol));
1726 ta.start_selection();
1727 }
1728 MouseEventKind::Drag(_) => {
1729 let (lrow, lcol) = self
1730 .view
1731 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1732 ta.move_cursor(CursorMove::Jump(lrow, lcol));
1733 }
1734 _ => {
1735 ta.input(*mouse);
1736 }
1737 }
1738 self.selection = ta.selection_range();
1739 self.bump_cursor();
1742 EventState::Consumed
1743 }
1744}
1745
1746impl Component for TextEditorComponent {
1747 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
1748 self.maybe_recover_from_dead_nvim();
1749 self.bind_autocomplete_redraw(tx);
1750
1751 match event {
1752 InputEvent::Key(key) => {
1753 let popup_open = self.autocomplete.as_ref().is_some_and(|c| c.is_open());
1761 if popup_open
1762 && let Some(host) = build_editor_host_snapshot(
1763 &self.backend,
1764 self.content_revision,
1765 self.view.last_cursor_screen,
1766 )
1767 && let Some(controller) = self.autocomplete.as_mut()
1768 {
1769 match controller.handle_key(*key, &host) {
1770 HandleKeyOutcome::Accepted(action) => {
1771 if let BackendState::Textarea(ta) = &mut self.backend {
1772 apply_accept_to_textarea(ta, &action);
1773 self.selection = ta.selection_range();
1774 }
1775 self.bump_content();
1776 return EventState::Consumed;
1777 }
1778 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
1779 return EventState::Consumed;
1780 }
1781 HandleKeyOutcome::NotHandled => {}
1782 }
1783 }
1784 if let Some(state) = self.handle_nvim_key(key, tx) {
1785 return state;
1786 }
1787 let text_rev_before = self.content_revision;
1798 let cursor_before = self.textarea_cursor();
1799 let result = self.handle_textarea_key(key, tx);
1800 let cursor_after = self.textarea_cursor();
1801 if self.content_revision != text_rev_before {
1802 self.sync_autocomplete();
1803 } else if cursor_before != cursor_after {
1804 self.refresh_autocomplete_if_open();
1805 }
1806 result
1807 }
1808 InputEvent::Mouse(mouse) => {
1809 let text_rev_before = self.content_revision;
1810 let cursor_before = self.textarea_cursor();
1811 let result = self.handle_mouse(mouse, tx);
1812 let cursor_after = self.textarea_cursor();
1813 if self.content_revision != text_rev_before {
1816 self.sync_autocomplete();
1817 } else if cursor_before != cursor_after {
1818 self.refresh_autocomplete_if_open();
1819 }
1820 result
1821 }
1822 InputEvent::Paste(_) => EventState::NotConsumed,
1825 }
1826 }
1827
1828 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
1829 let (editor_rect, search_rect) = if self.search.is_some() && rect.height > 1 {
1831 (
1832 Rect {
1833 height: rect.height - 1,
1834 ..rect
1835 },
1836 Some(Rect {
1837 y: rect.y + rect.height - 1,
1838 height: 1,
1839 ..rect
1840 }),
1841 )
1842 } else {
1843 (rect, None)
1844 };
1845 self.rect = editor_rect;
1848 let (selection, nvim_rev_to_mirror) = match &self.backend {
1853 BackendState::Textarea(_) => (self.selection, None),
1854 BackendState::Nvim(nvim) => {
1855 nvim.maybe_resize(editor_rect.width, editor_rect.height);
1856 let snap = nvim.snapshot.lock().unwrap_or_else(|p| p.into_inner());
1857 let visual_selection = snap.visual_selection;
1858 let content_gen = snap.content_gen;
1859 drop(snap);
1860 let rev = NonZeroU64::new(content_gen.saturating_add(1));
1869 (visual_selection, rev)
1870 }
1871 };
1872 if let Some(rev) = nvim_rev_to_mirror {
1873 self.content_revision = rev;
1874 }
1875 while let Ok((generation, buf)) = self.full_parse_rx.try_recv() {
1881 self.view.install_full_parse(generation, buf);
1882 }
1883
1884 let snap = snapshot_from_backend(&self.backend, self.content_revision);
1889 self.view.update(&snap, editor_rect, selection);
1890
1891 if let Some(generation) = self.view.take_pending_full_parse() {
1899 let lines: Vec<String> = snap.lines.iter().cloned().collect();
1900 let tx = self.full_parse_tx.clone();
1901 let redraw = self.redraw_tx.clone();
1902 self.full_parse_task.spawn(async move {
1903 let buf = ParsedBuffer::parse(&lines);
1904 let _ = tx.send((generation, buf));
1905 if let Some(redraw) = redraw {
1908 let _ = redraw.send(AppEvent::Redraw);
1909 }
1910 });
1911 }
1912 let bar_focused = self.search.is_some() && focused;
1915 let editor_focused = focused && !bar_focused;
1916 self.view.render(f, editor_rect, theme, editor_focused);
1917 if let (Some(state), Some(bar_rect)) = (self.search.as_mut(), search_rect) {
1918 render_search_bar(f, bar_rect, state, theme, bar_focused);
1919 }
1920
1921 self.poll_autocomplete();
1929 if let (Some(controller), Some(live_anchor)) =
1936 (self.autocomplete.as_mut(), self.view.last_cursor_screen)
1937 {
1938 if let Some(state) = controller.state_mut() {
1939 state.anchor = live_anchor;
1940 }
1941 if let Some(state) = controller.state() {
1942 autocomplete::render(f, state, editor_rect, theme);
1943 }
1944 }
1945 }
1946
1947 fn hint_shortcuts(&self) -> Vec<(String, String)> {
1948 use crate::keys::action_shortcuts::ActionShortcuts;
1949
1950 if let BackendState::Nvim(nvim) = &self.backend {
1952 let label = nvim
1953 .snapshot
1954 .lock()
1955 .unwrap_or_else(|p| p.into_inner())
1956 .footer_label();
1957 let mut hints = vec![(String::new(), label)];
1958 hints.extend(
1959 [
1960 (ActionShortcuts::FocusSidebar, "\u{2190} sidebar"),
1961 (ActionShortcuts::FocusEditor, "backlinks \u{2192}"),
1962 (ActionShortcuts::FileOperations, "file ops"),
1963 ]
1964 .iter()
1965 .filter_map(|(action, label)| {
1966 self.key_bindings
1967 .first_combo_for(action)
1968 .map(|k| (k, label.to_string()))
1969 }),
1970 );
1971 return hints;
1972 }
1973
1974 [
1975 (ActionShortcuts::FocusSidebar, "\u{2190} sidebar"),
1976 (ActionShortcuts::FocusEditor, "backlinks \u{2192}"),
1977 (ActionShortcuts::FileOperations, "file ops"),
1978 (ActionShortcuts::FindInBuffer, "find"),
1979 ]
1980 .iter()
1981 .filter_map(|(action, label)| {
1982 self.key_bindings
1983 .first_combo_for(action)
1984 .map(|k| (k, label.to_string()))
1985 })
1986 .collect()
1987 }
1988}
1989
1990#[cfg(test)]
1991mod tests {
1992 use super::*;
1993 use crate::keys::KeyBindings;
1994
1995 fn make_editor() -> TextEditorComponent {
1996 TextEditorComponent::new(
1997 KeyBindings::empty(),
1998 &crate::settings::AppSettings::default(),
1999 )
2000 }
2001
2002 fn dummy_tx() -> AppTx {
2003 tokio::sync::mpsc::unbounded_channel().0
2004 }
2005
2006 fn get_ta(editor: &mut TextEditorComponent) -> &mut TextArea<'static> {
2007 match &mut editor.backend {
2008 BackendState::Textarea(ta) => ta,
2009 _ => panic!("expected Textarea backend"),
2010 }
2011 }
2012
2013 #[test]
2014 fn has_trigger_before_cursor_finds_bracket() {
2015 assert!(has_trigger_before_cursor("hello [[foo", 11));
2016 assert!(has_trigger_before_cursor("[[a b c", 7));
2017 }
2018
2019 #[test]
2020 fn has_trigger_before_cursor_finds_hashtag() {
2021 assert!(has_trigger_before_cursor("text #tag", 9));
2022 }
2023
2024 #[test]
2025 fn has_trigger_before_cursor_no_trigger_bails() {
2026 assert!(!has_trigger_before_cursor("plain prose here", 16));
2027 assert!(!has_trigger_before_cursor("", 0));
2028 }
2029
2030 #[test]
2031 fn has_trigger_before_cursor_handles_multibyte_no_panic() {
2032 let line = "你好世界".to_string() + &"a".repeat(80);
2035 let col = line.chars().count();
2036 assert!(!has_trigger_before_cursor(&line, col));
2037
2038 let with_emoji = "🦀".repeat(20) + "[[note";
2039 let col = with_emoji.chars().count();
2040 assert!(has_trigger_before_cursor(&with_emoji, col));
2041
2042 let accented = "é".repeat(100);
2043 let col = accented.chars().count();
2044 assert!(!has_trigger_before_cursor(&accented, col));
2045 }
2046
2047 #[test]
2048 fn has_trigger_before_cursor_ignores_chars_after_cursor() {
2049 assert!(!has_trigger_before_cursor("foo [[bar", 3));
2051 }
2052
2053 #[test]
2054 fn has_trigger_before_cursor_wikilink_with_spaces() {
2055 assert!(has_trigger_before_cursor("[[my note title", 15));
2058 }
2059
2060 #[test]
2061 fn fresh_editor_is_not_dirty() {
2062 let editor = make_editor();
2063 assert!(!editor.is_dirty());
2064 }
2065
2066 #[test]
2067 fn after_set_text_not_dirty() {
2068 let mut editor = make_editor();
2069 editor.set_text("hello world".to_string());
2070 assert!(!editor.is_dirty());
2071 }
2072
2073 #[test]
2074 fn get_text_returns_loaded_content() {
2075 let mut editor = make_editor();
2076 editor.set_text("line one\nline two".to_string());
2077 assert_eq!(editor.get_text(), "line one\nline two");
2078 }
2079
2080 #[test]
2081 fn mark_saved_clears_dirty() {
2082 let mut editor = make_editor();
2083 editor.set_text("initial".to_string());
2084 let text = editor.get_text();
2085 editor.mark_saved(text.clone() + "x"); assert!(editor.is_dirty());
2087 editor.mark_saved(text); assert!(!editor.is_dirty());
2089 }
2090
2091 #[test]
2092 fn trailing_newline_does_not_cause_false_dirty() {
2093 let mut editor = make_editor();
2094 editor.set_text("content\n".to_string());
2095 assert!(
2096 !editor.is_dirty(),
2097 "trailing newline should not make editor dirty after load"
2098 );
2099 }
2100
2101 #[test]
2102 fn cursor_move_does_not_dirty_buffer() {
2103 let mut editor = make_editor();
2104 editor.set_text("hello world".to_string());
2105 assert!(!editor.is_dirty());
2106 let tx = dummy_tx();
2107 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
2111 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2112 assert!(
2113 !editor.is_dirty(),
2114 "cursor move must not mark the editor as dirty"
2115 );
2116 }
2117
2118 #[test]
2119 fn empty_stack_undo_redo_does_not_dirty_or_bump_revision() {
2120 let mut editor = make_editor();
2124 editor.set_text("foo".to_string());
2125 let rev_before = editor.content_revision();
2126 assert!(!editor.is_dirty());
2127 let tx = dummy_tx();
2128 for key_code in [KeyCode::Char('z'), KeyCode::Char('y')] {
2129 let key = ratatui::crossterm::event::KeyEvent::new(key_code, KeyModifiers::CONTROL);
2130 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2131 }
2132 assert!(
2133 !editor.is_dirty(),
2134 "empty-stack undo/redo must not flip is_dirty"
2135 );
2136 assert_eq!(
2137 editor.content_revision(),
2138 rev_before,
2139 "empty-stack undo/redo must not bump content_revision"
2140 );
2141 }
2142
2143 #[test]
2144 fn fresh_editor_content_revision_is_nonzero() {
2145 let editor = make_editor();
2152 assert!(editor.content_revision().get() >= 1);
2153 }
2154
2155 #[test]
2156 fn mouse_down_clears_selection() {
2157 let mut editor = make_editor();
2158 editor.set_text("hello world".to_string());
2159 let ta = get_ta(&mut editor);
2160 ta.start_selection();
2161 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2162 assert!(ta.selection_range().is_some());
2163 ta.cancel_selection();
2164 editor.selection = if let BackendState::Textarea(ta) = &editor.backend {
2165 ta.selection_range()
2166 } else {
2167 None
2168 };
2169 assert!(editor.selection.is_none());
2170 }
2171
2172 #[test]
2173 fn ctrl_c_copies_selected_text() {
2174 let mut editor = make_editor();
2175 editor.set_text("hello world".to_string());
2176 let ta = get_ta(&mut editor);
2177 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2178 ta.start_selection();
2179 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2180 let range = ta.selection_range().unwrap();
2181 let ((sr, sc), (er, ec)) = range;
2182 let lines = ta.lines();
2183 let selected = if sr == er {
2184 lines[sr][sc..ec].to_string()
2185 } else {
2186 lines[sr][sc..].to_string()
2187 };
2188 assert_eq!(selected, "hello ");
2189 }
2190
2191 #[test]
2192 fn linkable_url_accepts_supported_schemes() {
2193 assert_eq!(
2194 linkable_url("https://example.com"),
2195 Some("https://example.com")
2196 );
2197 assert_eq!(
2198 linkable_url("http://example.com/path?q=1#frag"),
2199 Some("http://example.com/path?q=1#frag"),
2200 );
2201 assert_eq!(
2202 linkable_url(" https://example.com "),
2203 Some("https://example.com")
2204 );
2205 assert_eq!(
2206 linkable_url("ftp://files.example.com/x"),
2207 Some("ftp://files.example.com/x"),
2208 );
2209 assert_eq!(
2210 linkable_url("ftps://files.example.com/x"),
2211 Some("ftps://files.example.com/x"),
2212 );
2213 assert_eq!(
2214 linkable_url("mailto:user@example.com"),
2215 Some("mailto:user@example.com"),
2216 );
2217 assert_eq!(
2218 linkable_url("mailto:user@example.com?subject=hi"),
2219 Some("mailto:user@example.com?subject=hi"),
2220 );
2221 }
2222
2223 #[test]
2224 fn linkable_url_rejects_other_schemes_and_plain_text() {
2225 assert_eq!(linkable_url("file:///etc/passwd"), None);
2226 assert_eq!(linkable_url("ssh://host"), None);
2227 assert_eq!(linkable_url("javascript:alert(1)"), None);
2228 assert_eq!(linkable_url("example.com"), None);
2229 assert_eq!(linkable_url("not a url"), None);
2230 assert_eq!(linkable_url(""), None);
2231 assert_eq!(linkable_url("https://example.com\nmore"), None);
2232 }
2233
2234 #[test]
2235 fn try_build_markdown_link_wraps_selection_when_clip_is_url() {
2236 assert_eq!(
2237 try_build_markdown_link("https://example.com", Some("click here")).as_deref(),
2238 Some("[click here](https://example.com)"),
2239 );
2240 }
2241
2242 #[test]
2243 fn try_build_markdown_link_trims_url_whitespace() {
2244 assert_eq!(
2245 try_build_markdown_link(" https://example.com\n", Some("link")).as_deref(),
2246 Some("[link](https://example.com)"),
2247 );
2248 }
2249
2250 #[test]
2251 fn try_build_markdown_link_returns_none_when_no_selection() {
2252 assert_eq!(try_build_markdown_link("https://example.com", None), None);
2253 }
2254
2255 #[test]
2256 fn try_build_markdown_link_returns_none_when_not_url() {
2257 assert_eq!(try_build_markdown_link("plain text", Some("sel")), None);
2258 }
2259
2260 #[test]
2261 fn try_build_markdown_link_returns_none_when_selection_empty() {
2262 assert_eq!(
2263 try_build_markdown_link("https://example.com", Some("")),
2264 None
2265 );
2266 }
2267
2268 #[test]
2269 fn try_build_markdown_link_escapes_close_bracket_in_selection() {
2270 assert_eq!(
2271 try_build_markdown_link("https://example.com", Some("a]b")).as_deref(),
2272 Some(r"[a\]b](https://example.com)"),
2273 );
2274 }
2275
2276 #[test]
2277 fn try_build_markdown_link_wraps_ftp_url() {
2278 assert_eq!(
2279 try_build_markdown_link("ftp://files.example.com/x", Some("download")).as_deref(),
2280 Some("[download](ftp://files.example.com/x)"),
2281 );
2282 }
2283
2284 fn key(code: KeyCode, mods: KeyModifiers) -> ratatui::crossterm::event::KeyEvent {
2285 ratatui::crossterm::event::KeyEvent::new(code, mods)
2286 }
2287
2288 #[test]
2289 fn open_or_advance_search_opens_find_bar_with_empty_query() {
2290 let mut editor = make_editor();
2291 editor.set_text("hello world".to_string());
2292 editor.open_or_advance_search();
2293 let state = editor.search.as_ref().expect("find bar opened");
2294 assert!(state.input.is_empty());
2295 assert!(matches!(state.status, SearchStatus::Empty));
2296 }
2297
2298 #[test]
2299 fn open_or_advance_search_advances_when_already_open() {
2300 let mut editor = make_editor();
2301 editor.set_text("ab ab ab".to_string());
2302 let tx = dummy_tx();
2303 editor.open_or_advance_search();
2304 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2305 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2306 editor.open_or_advance_search();
2308 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2309 assert_eq!(col, 3, "second invocation advances to next match");
2310 }
2311
2312 #[test]
2313 fn typing_in_find_bar_jumps_cursor_to_first_match() {
2314 let mut editor = make_editor();
2315 editor.set_text("foo bar baz".to_string());
2316 let tx = dummy_tx();
2317 editor.open_or_advance_search();
2318 for ch in ['b', 'a', 'r'] {
2319 editor.handle_textarea_key(&key(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
2320 }
2321 let state = editor.search.as_ref().unwrap();
2322 assert_eq!(state.input.value(), "bar");
2323 assert!(matches!(state.status, SearchStatus::Match));
2324 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2325 assert_eq!(col, 4, "cursor jumped to start of 'bar'");
2326 }
2327
2328 #[test]
2329 fn enter_in_find_bar_advances_to_next_match() {
2330 let mut editor = make_editor();
2331 editor.set_text("ab ab ab".to_string());
2332 let tx = dummy_tx();
2333 editor.open_or_advance_search();
2334 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2335 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2336 editor.handle_textarea_key(&key(KeyCode::Enter, KeyModifiers::NONE), &tx);
2338 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2339 assert_eq!(col, 3, "Enter advances to second match");
2340 }
2341
2342 #[test]
2343 fn match_is_highlighted_as_selection_after_search() {
2344 let mut editor = make_editor();
2345 editor.set_text("foo bar baz".to_string());
2346 let tx = dummy_tx();
2347 editor.open_or_advance_search();
2348 for ch in ['b', 'a', 'r'] {
2349 editor.handle_textarea_key(&key(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
2350 }
2351 assert_eq!(editor.selection, Some(((0, 4), (0, 7))));
2353 }
2354
2355 #[test]
2356 fn no_match_clears_selection() {
2357 let mut editor = make_editor();
2358 editor.set_text("hello".to_string());
2359 let tx = dummy_tx();
2360 editor.open_or_advance_search();
2361 editor.handle_textarea_key(&key(KeyCode::Char('z'), KeyModifiers::NONE), &tx);
2362 assert_eq!(editor.selection, None);
2363 }
2364
2365 #[test]
2366 fn esc_in_find_bar_clears_selection_highlight() {
2367 let mut editor = make_editor();
2368 editor.set_text("foo bar".to_string());
2369 let tx = dummy_tx();
2370 editor.open_or_advance_search();
2371 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2372 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2373 editor.handle_textarea_key(&key(KeyCode::Char('r'), KeyModifiers::NONE), &tx);
2374 assert!(editor.selection.is_some());
2375 editor.handle_textarea_key(&key(KeyCode::Esc, KeyModifiers::NONE), &tx);
2376 assert!(editor.selection.is_none());
2377 }
2378
2379 #[test]
2380 fn esc_in_find_bar_closes_it() {
2381 let mut editor = make_editor();
2382 editor.set_text("hello".to_string());
2383 let tx = dummy_tx();
2384 editor.open_or_advance_search();
2385 assert!(editor.search.is_some());
2386 editor.handle_textarea_key(&key(KeyCode::Esc, KeyModifiers::NONE), &tx);
2387 assert!(editor.search.is_none());
2388 }
2389
2390 #[test]
2391 fn find_bar_consumes_typing_so_editor_text_is_unchanged() {
2392 let mut editor = make_editor();
2393 editor.set_text("hello".to_string());
2394 let tx = dummy_tx();
2395 editor.open_or_advance_search();
2396 editor.handle_textarea_key(&key(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
2397 assert_eq!(editor.get_text(), "hello");
2398 }
2399
2400 #[test]
2401 fn no_match_status_when_query_absent() {
2402 let mut editor = make_editor();
2403 editor.set_text("hello".to_string());
2404 let tx = dummy_tx();
2405 editor.open_or_advance_search();
2406 editor.handle_textarea_key(&key(KeyCode::Char('z'), KeyModifiers::NONE), &tx);
2407 let state = editor.search.as_ref().unwrap();
2408 assert!(matches!(state.status, SearchStatus::NoMatch));
2409 }
2410
2411 #[test]
2412 fn try_build_markdown_link_wraps_mailto_url() {
2413 assert_eq!(
2414 try_build_markdown_link("mailto:user@example.com", Some("email me")).as_deref(),
2415 Some("[email me](mailto:user@example.com)"),
2416 );
2417 }
2418
2419 #[test]
2420 fn insert_at_cursor_appends_text() {
2421 let mut editor = make_editor();
2422 editor.set_text("hello".to_string());
2423 {
2424 let ta = get_ta(&mut editor);
2425 ta.move_cursor(ratatui_textarea::CursorMove::End);
2426 }
2427 editor.insert_at_cursor(" world", &dummy_tx());
2428 assert_eq!(editor.get_text(), "hello world");
2429 }
2430
2431 #[test]
2432 fn insert_at_cursor_replaces_selection() {
2433 let mut editor = make_editor();
2434 editor.set_text("hello world".to_string());
2435 {
2436 let ta = get_ta(&mut editor);
2437 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2438 ta.start_selection();
2439 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2440 }
2441 editor.insert_at_cursor("HEY ", &dummy_tx());
2442 assert_eq!(editor.get_text(), "HEY world");
2443 }
2444
2445 #[test]
2446 fn paste_inserts_text_at_cursor() {
2447 let mut editor = make_editor();
2448 editor.set_text("hello".to_string());
2449 let ta = get_ta(&mut editor);
2450 ta.move_cursor(ratatui_textarea::CursorMove::End);
2451 ta.insert_str(" world");
2452 assert_eq!(editor.get_text(), "hello world");
2453 }
2454
2455 #[test]
2456 fn bold_action_with_no_selection_inserts_pair_and_centers_cursor() {
2457 let mut editor = make_editor();
2458 editor.set_text("hello".to_string());
2459 {
2460 let ta = get_ta(&mut editor);
2461 ta.move_cursor(ratatui_textarea::CursorMove::End);
2462 }
2463 editor.apply_text_action(TextAction::Bold);
2464 assert_eq!(editor.get_text(), "hello****");
2465 let ta = get_ta(&mut editor);
2466 assert_eq!(ta.cursor(), (0, 7));
2467 }
2468
2469 #[test]
2470 fn italic_action_with_no_selection_inserts_single_pair() {
2471 let mut editor = make_editor();
2472 editor.set_text(String::new());
2473 editor.apply_text_action(TextAction::Italic);
2474 assert_eq!(editor.get_text(), "**");
2475 let ta = get_ta(&mut editor);
2476 assert_eq!(ta.cursor(), (0, 1));
2477 }
2478
2479 #[test]
2480 fn strikethrough_action_with_selection_wraps_text() {
2481 let mut editor = make_editor();
2482 editor.set_text("hello world".to_string());
2483 {
2484 let ta = get_ta(&mut editor);
2485 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2486 ta.start_selection();
2487 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2488 }
2489 editor.apply_text_action(TextAction::Strikethrough);
2490 assert_eq!(editor.get_text(), "~~hello ~~world");
2491 }
2492
2493 #[test]
2494 fn bold_action_wraps_non_ascii_selection() {
2495 let mut editor = make_editor();
2496 editor.set_text("hello 你好 world".to_string());
2497 {
2498 let ta = get_ta(&mut editor);
2499 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2500 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2501 ta.start_selection();
2502 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2503 }
2504 editor.apply_text_action(TextAction::Bold);
2505 assert_eq!(editor.get_text(), "hello **你好 **world");
2506 }
2507
2508 #[test]
2509 fn bold_action_wraps_selected_text() {
2510 let mut editor = make_editor();
2511 editor.set_text("foo bar".to_string());
2512 {
2513 let ta = get_ta(&mut editor);
2514 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2515 ta.start_selection();
2516 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2517 }
2518 editor.apply_text_action(TextAction::Bold);
2519 assert_eq!(editor.get_text(), "**foo **bar");
2520 }
2521
2522 #[test]
2523 fn indent_no_selection_indents_current_line() {
2524 let mut editor = make_editor();
2525 editor.set_text("foo\nbar".to_string());
2526 {
2527 let ta = get_ta(&mut editor);
2528 ta.move_cursor(ratatui_textarea::CursorMove::Bottom);
2529 }
2530 editor.indent_lines(false);
2531 let lines = get_ta(&mut editor).lines();
2532 assert_eq!(lines[0], "foo");
2533 assert!(lines[1].starts_with(' ') || lines[1].starts_with('\t'));
2534 assert!(lines[1].trim_start() == "bar");
2535 }
2536
2537 #[test]
2538 fn indent_with_selection_indents_all_touched_lines() {
2539 let mut editor = make_editor();
2540 editor.set_text("foo\nbar\nbaz".to_string());
2541 {
2542 let ta = get_ta(&mut editor);
2543 ta.move_cursor(ratatui_textarea::CursorMove::Top);
2544 ta.start_selection();
2545 ta.move_cursor(ratatui_textarea::CursorMove::Down);
2546 ta.move_cursor(ratatui_textarea::CursorMove::End);
2547 }
2548 editor.indent_lines(false);
2549 let lines: Vec<String> = get_ta(&mut editor).lines().to_vec();
2550 assert_eq!(lines[0].trim_start(), "foo");
2551 assert_eq!(lines[1].trim_start(), "bar");
2552 assert_eq!(lines[2], "baz");
2553 assert!(lines[0].len() > 3);
2554 assert!(lines[1].len() > 3);
2555 }
2556
2557 #[test]
2558 fn dedent_removes_leading_indent() {
2559 let mut editor = make_editor();
2560 editor.set_text(" foo\n bar\nbaz".to_string());
2561 let tab_len = get_ta(&mut editor).tab_length() as usize;
2562 {
2563 let ta = get_ta(&mut editor);
2564 ta.move_cursor(ratatui_textarea::CursorMove::Top);
2565 ta.start_selection();
2566 ta.move_cursor(ratatui_textarea::CursorMove::Bottom);
2567 ta.move_cursor(ratatui_textarea::CursorMove::End);
2568 }
2569 editor.indent_lines(true);
2570 let lines: Vec<String> = get_ta(&mut editor).lines().to_vec();
2571 assert_eq!(lines[0], format!("{}foo", " ".repeat(4 - tab_len.min(4))));
2573 assert_eq!(
2575 lines[1],
2576 format!("{}bar", " ".repeat(2usize.saturating_sub(tab_len)))
2577 );
2578 assert_eq!(lines[2], "baz");
2579 }
2580
2581 #[test]
2582 fn dedent_no_leading_whitespace_is_noop_for_that_line() {
2583 let mut editor = make_editor();
2584 editor.set_text("foo".to_string());
2585 editor.indent_lines(true);
2586 assert_eq!(editor.get_text(), "foo");
2587 }
2588
2589 #[test]
2590 fn smart_enter_continues_unordered_list() {
2591 let mut editor = make_editor();
2592 editor.set_text("- foo".to_string());
2593 {
2594 let ta = get_ta(&mut editor);
2595 ta.move_cursor(ratatui_textarea::CursorMove::End);
2596 }
2597 assert!(editor.smart_enter());
2598 assert_eq!(editor.get_text(), "- foo\n- ");
2599 }
2600
2601 #[test]
2602 fn smart_enter_continues_ordered_list_increments() {
2603 let mut editor = make_editor();
2604 editor.set_text("1. foo".to_string());
2605 {
2606 let ta = get_ta(&mut editor);
2607 ta.move_cursor(ratatui_textarea::CursorMove::End);
2608 }
2609 assert!(editor.smart_enter());
2610 assert_eq!(editor.get_text(), "1. foo\n2. ");
2611 }
2612
2613 #[test]
2614 fn smart_enter_on_empty_list_marker_clears_line() {
2615 let mut editor = make_editor();
2616 editor.set_text("- ".to_string());
2617 {
2618 let ta = get_ta(&mut editor);
2619 ta.move_cursor(ratatui_textarea::CursorMove::End);
2620 }
2621 assert!(editor.smart_enter());
2622 assert_eq!(editor.get_text(), "");
2623 }
2624
2625 #[test]
2626 fn smart_enter_preserves_indent() {
2627 let mut editor = make_editor();
2628 editor.set_text(" body".to_string());
2629 {
2630 let ta = get_ta(&mut editor);
2631 ta.move_cursor(ratatui_textarea::CursorMove::End);
2632 }
2633 assert!(editor.smart_enter());
2634 assert_eq!(editor.get_text(), " body\n ");
2635 }
2636
2637 #[test]
2638 fn smart_enter_on_empty_indent_dedents() {
2639 let mut editor = make_editor();
2640 editor.set_text(" ".to_string());
2641 {
2642 let ta = get_ta(&mut editor);
2643 ta.move_cursor(ratatui_textarea::CursorMove::End);
2644 }
2645 let tab_len = get_ta(&mut editor).tab_length() as usize;
2646 assert!(editor.smart_enter());
2647 assert_eq!(
2648 editor.get_text(),
2649 " ".repeat(4usize.saturating_sub(tab_len))
2650 );
2651 }
2652
2653 #[test]
2654 fn smart_enter_no_indent_no_marker_returns_false() {
2655 let mut editor = make_editor();
2656 editor.set_text("plain".to_string());
2657 {
2658 let ta = get_ta(&mut editor);
2659 ta.move_cursor(ratatui_textarea::CursorMove::End);
2660 }
2661 assert!(!editor.smart_enter());
2662 assert_eq!(editor.get_text(), "plain");
2663 }
2664
2665 #[test]
2666 fn smart_enter_mid_line_returns_false() {
2667 let mut editor = make_editor();
2668 editor.set_text("- foo".to_string());
2669 {
2670 let ta = get_ta(&mut editor);
2671 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2672 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
2673 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
2674 }
2675 assert!(!editor.smart_enter());
2676 }
2677
2678 #[test]
2679 fn smart_enter_on_empty_indented_list_marker_dedents_keeping_marker() {
2680 let mut editor = make_editor();
2681 let tab_len = get_ta(&mut editor).tab_length() as usize;
2682 let indent = " ".repeat(tab_len);
2683 editor.set_text(format!("{indent}- "));
2684 {
2685 let ta = get_ta(&mut editor);
2686 ta.move_cursor(ratatui_textarea::CursorMove::End);
2687 }
2688 assert!(editor.smart_enter());
2689 assert_eq!(editor.get_text(), "- ");
2690 }
2691
2692 #[test]
2693 fn smart_enter_on_empty_list_marker_clears_line_after_full_dedent() {
2694 let mut editor = make_editor();
2695 let tab_len = get_ta(&mut editor).tab_length() as usize;
2696 let indent = " ".repeat(tab_len);
2697 editor.set_text(format!("{indent}- "));
2698 {
2699 let ta = get_ta(&mut editor);
2700 ta.move_cursor(ratatui_textarea::CursorMove::End);
2701 }
2702 assert!(editor.smart_enter());
2704 assert_eq!(editor.get_text(), "- ");
2705 {
2708 let ta = get_ta(&mut editor);
2709 ta.move_cursor(ratatui_textarea::CursorMove::End);
2710 }
2711 assert!(editor.smart_enter());
2712 assert_eq!(editor.get_text(), "");
2713 }
2714
2715 #[test]
2716 fn smart_enter_continues_list_with_non_ascii_content() {
2717 let mut editor = make_editor();
2718 editor.set_text("- 你好".to_string());
2719 {
2720 let ta = get_ta(&mut editor);
2721 ta.move_cursor(ratatui_textarea::CursorMove::End);
2722 }
2723 assert!(editor.smart_enter());
2724 assert_eq!(editor.get_text(), "- 你好\n- ");
2725 }
2726
2727 #[test]
2728 fn smart_enter_preserves_tab_indent() {
2729 let mut editor = make_editor();
2730 editor.set_text("\tbody".to_string());
2731 {
2732 let ta = get_ta(&mut editor);
2733 ta.move_cursor(ratatui_textarea::CursorMove::End);
2734 }
2735 assert!(editor.smart_enter());
2736 assert_eq!(editor.get_text(), "\tbody\n\t");
2737 }
2738
2739 #[test]
2740 fn smart_enter_on_tab_only_line_dedents() {
2741 let mut editor = make_editor();
2742 editor.set_text("\t\t".to_string());
2743 {
2744 let ta = get_ta(&mut editor);
2745 ta.move_cursor(ratatui_textarea::CursorMove::End);
2746 }
2747 assert!(editor.smart_enter());
2748 assert_eq!(editor.get_text(), "\t");
2750 }
2751
2752 #[test]
2753 fn smart_enter_continues_indented_list() {
2754 let mut editor = make_editor();
2755 editor.set_text(" - foo".to_string());
2756 {
2757 let ta = get_ta(&mut editor);
2758 ta.move_cursor(ratatui_textarea::CursorMove::End);
2759 }
2760 assert!(editor.smart_enter());
2761 assert_eq!(editor.get_text(), " - foo\n - ");
2762 }
2763
2764 #[test]
2765 fn unsupported_text_action_is_noop() {
2766 let mut editor = make_editor();
2767 editor.set_text("hello".to_string());
2768 editor.apply_text_action(TextAction::Underline);
2769 assert_eq!(editor.get_text(), "hello");
2770 }
2771
2772 #[test]
2773 fn textarea_hint_shortcuts_has_no_mode_indicator() {
2774 let editor = make_editor();
2775 let hints = editor.hint_shortcuts();
2776 assert!(
2778 !hints
2779 .iter()
2780 .any(|(_, label)| label == "NORMAL" || label == "INSERT")
2781 );
2782 }
2783
2784 fn place_cursor_at_col(editor: &mut TextEditorComponent, col: usize) {
2788 let ta = get_ta(editor);
2789 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2790 for _ in 0..col {
2791 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
2792 }
2793 }
2794
2795 #[test]
2796 fn link_at_cursor_returns_label_when_cursor_on_hashtag() {
2797 let mut editor = make_editor();
2798 editor.set_text("see #rust now".to_string());
2799 place_cursor_at_col(&mut editor, 5);
2801 assert_eq!(
2802 editor.link_at_cursor(),
2803 Some(LinkTarget::Label("rust".into())),
2804 );
2805 }
2806
2807 #[test]
2808 fn link_at_cursor_returns_label_at_hash_char() {
2809 let mut editor = make_editor();
2810 editor.set_text("see #rust now".to_string());
2811 place_cursor_at_col(&mut editor, 4);
2813 assert_eq!(
2814 editor.link_at_cursor(),
2815 Some(LinkTarget::Label("rust".into())),
2816 );
2817 }
2818
2819 #[test]
2820 fn link_at_cursor_returns_none_outside_hashtag() {
2821 let mut editor = make_editor();
2822 editor.set_text("see #rust now".to_string());
2823 place_cursor_at_col(&mut editor, 0);
2825 assert_eq!(editor.link_at_cursor(), None);
2826 }
2827
2828 #[test]
2829 fn link_at_cursor_returns_note_for_wikilink() {
2830 let mut editor = make_editor();
2831 editor.set_text("open [[my note]] please".to_string());
2832 place_cursor_at_col(&mut editor, 7);
2834 let result = editor.link_at_cursor();
2835 assert!(
2836 matches!(result, Some(LinkTarget::Note(_))),
2837 "expected Note variant, got {result:?}"
2838 );
2839 }
2840
2841 #[test]
2844 fn link_at_cursor_returns_note_for_markdown_link_with_fragment() {
2845 let line = "[see docs](#section)";
2850 let mut editor = make_editor();
2851 editor.set_text(line.to_string());
2852 let cursor = "[see docs](#sec".chars().count(); place_cursor_at_col(&mut editor, cursor);
2855 let result = editor.link_at_cursor();
2856 assert!(
2857 matches!(result, Some(LinkTarget::Note(_))),
2858 "expected Note variant for markdown link fragment, got {result:?}"
2859 );
2860 }
2861}