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(vault, AutocompleteMode::Both));
468 }
469 }
470
471 fn ensure_autocomplete_for_textarea(&mut self) {
476 if self.autocomplete.is_some() {
477 return;
478 }
479 if !matches!(self.backend, BackendState::Textarea(_)) {
480 return;
481 }
482 let Some(vault) = self.autocomplete_vault.clone() else {
483 return;
484 };
485 self.autocomplete = Some(AutocompleteController::new(vault, AutocompleteMode::Both));
486 self.autocomplete_redraw_bound = false;
489 }
490
491 #[allow(dead_code)]
498 fn autocomplete_host_snapshot(&self) -> Option<EditorHostSnapshot<'_>> {
499 build_editor_host_snapshot(
500 &self.backend,
501 self.content_revision,
502 self.view.last_cursor_screen,
503 )
504 }
505
506 fn poll_autocomplete(&mut self) {
509 if let Some(controller) = self.autocomplete.as_mut() {
510 controller.poll_results();
511 }
512 }
513
514 fn textarea_cursor(&self) -> Option<(usize, usize)> {
518 let BackendState::Textarea(ta) = &self.backend else {
519 return None;
520 };
521 Some(cursor_tuple(ta))
522 }
523
524 fn refresh_autocomplete_if_open(&mut self) {
525 if !self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
527 return;
528 }
529 let Some(snapshot) = build_editor_host_snapshot(
533 &self.backend,
534 self.content_revision,
535 self.view.last_cursor_screen,
536 ) else {
537 self.close_autocomplete();
538 return;
539 };
540 if let Some(controller) = self.autocomplete.as_mut() {
541 controller.refresh_if_open(&snapshot);
542 }
543 }
544
545 fn sync_autocomplete(&mut self) {
549 let Some(controller) = self.autocomplete.as_ref() else {
550 return; };
552
553 if !controller.is_open() {
566 let BackendState::Textarea(ta) = &self.backend else {
567 return;
568 };
569 let (row, col) = cursor_tuple(ta);
570 let line = ta.lines().get(row).map(|s| s.as_str()).unwrap_or("");
571 if !has_trigger_before_cursor(line, col) {
572 return;
573 }
574 }
575
576 let Some(snapshot) = build_editor_host_snapshot(
580 &self.backend,
581 self.content_revision,
582 self.view.last_cursor_screen,
583 ) else {
584 if let Some(c) = self.autocomplete.as_mut() {
585 c.close();
586 }
587 return;
588 };
589 if let Some(controller) = self.autocomplete.as_mut() {
590 controller.sync(&snapshot);
591 }
592 }
593
594 pub fn lines(&self) -> &[String] {
600 match &self.backend {
601 BackendState::Textarea(ta) => ta.lines(),
602 BackendState::Nvim(_) => &[],
603 }
604 }
605
606 pub fn view_snapshot(&self) -> EditorSnapshot<'_> {
625 snapshot_from_backend(&self.backend, self.content_revision)
626 }
627
628 pub fn set_text(&mut self, text: String) {
629 if text == self.get_text() {
636 self.saved_content_rev = Some(self.content_revision);
637 if let BackendState::Nvim(nvim) = &self.backend {
638 nvim.snapshot
639 .lock()
640 .unwrap_or_else(|p| p.into_inner())
641 .dirty = false;
642 }
643 return;
644 }
645 match &mut self.backend {
646 BackendState::Textarea(ta) => {
647 let lines = text.lines();
648 *ta = TextArea::from(lines);
649 }
650 BackendState::Nvim(nvim) => {
651 nvim.set_text(&text);
652 }
653 }
654 self.bump_content();
655 let reconstructed = self.get_text();
656 self.mark_saved(reconstructed);
657 self.close_autocomplete();
660 }
661
662 pub fn get_text(&self) -> String {
663 match &self.backend {
664 BackendState::Textarea(ta) => ta.lines().join("\n"),
665 BackendState::Nvim(nvim) => nvim
666 .snapshot
667 .lock()
668 .unwrap_or_else(|p| p.into_inner())
669 .lines
670 .join("\n"),
671 }
672 }
673
674 pub fn content_revision(&self) -> NonZeroU64 {
681 self.content_revision
682 }
683
684 pub fn mark_saved_at_revision(&mut self, rev: NonZeroU64) {
694 if rev != self.content_revision {
695 return;
696 }
697 if let BackendState::Nvim(nvim) = &self.backend {
698 nvim.snapshot
699 .lock()
700 .unwrap_or_else(|p| p.into_inner())
701 .dirty = false;
702 }
703 self.saved_content_rev = Some(rev);
704 }
705
706 pub fn mark_saved(&mut self, text: String) {
714 let matches = text == self.get_text();
715 if matches {
716 if let BackendState::Nvim(nvim) = &self.backend {
717 nvim.snapshot
718 .lock()
719 .unwrap_or_else(|p| p.into_inner())
720 .dirty = false;
721 }
722 self.saved_content_rev = Some(self.content_revision);
723 } else {
724 self.saved_content_rev = None;
729 }
730 }
731
732 pub fn is_dirty(&self) -> bool {
733 match &self.backend {
734 BackendState::Textarea(_) => self.saved_content_rev != Some(self.content_revision),
735 BackendState::Nvim(nvim) => {
736 nvim.snapshot
737 .lock()
738 .unwrap_or_else(|p| p.into_inner())
739 .dirty
740 }
741 }
742 }
743
744 pub fn link_at_cursor(&self) -> Option<LinkTarget> {
747 let (_row, col, line) = match &self.backend {
748 BackendState::Textarea(ta) => {
749 let (row, col) = cursor_tuple(ta);
750 let line = ta.lines().get(row)?.to_string();
751 (row, col, line)
752 }
753 BackendState::Nvim(nvim) => {
754 let snap = nvim.snapshot.lock().unwrap_or_else(|p| p.into_inner());
755 let (row, col) = snap.cursor;
756 let line = snap.lines.get(row)?.to_string();
757 (row, col, line)
758 }
759 };
760
761 if let Some(span) = kimun_core::note::link_char_spans(&line)
764 .into_iter()
765 .find(|s| s.start <= col && col < s.end)
766 {
767 return Some(LinkTarget::Note(span.target));
768 }
769
770 let parsed = self::markdown::ParsedLine::parse(&line);
772 parsed
773 .elements
774 .iter()
775 .find(|e| {
776 e.kind == self::markdown::ElementKind::Label
777 && col >= e.start_char
778 && col < e.end_char
779 })
780 .map(|e| {
781 let span: String = line
782 .chars()
783 .skip(e.start_char)
784 .take(e.end_char - e.start_char)
785 .collect();
786 let name = span.trim_start_matches('#').to_string();
787 LinkTarget::Label(name)
788 })
789 }
790
791 fn copy_selection_to_clipboard(&mut self) {
793 let text = {
794 let BackendState::Textarea(ta) = &self.backend else {
795 return;
796 };
797 match selection_text(ta) {
798 Some(t) => t,
799 None => return,
800 }
801 };
802 if let Some(cb) = &mut self.clipboard {
803 let _ = cb.set_text(text);
804 }
805 }
806
807 fn paste_from_clipboard(&mut self, tx: &AppTx) {
809 let text = match &mut self.clipboard {
810 Some(cb) => match cb.get_text() {
811 Ok(t) if !t.is_empty() => t,
812 _ => return,
813 },
814 None => return,
815 };
816 self.paste_text(&text, tx);
817 }
818
819 pub fn paste_text(&mut self, text: &str, tx: &AppTx) {
828 if text.is_empty() {
829 return;
830 }
831 match &mut self.backend {
832 BackendState::Textarea(ta) => {
833 let selection = linkable_url(text).and_then(|_| selection_text(ta));
834 let wrapped = try_build_markdown_link(text, selection.as_deref());
835 if ta.selection_range().is_some() {
836 ta.cut();
837 }
838 ta.insert_str(wrapped.as_deref().unwrap_or(text));
839 self.selection = ta.selection_range();
840 self.bump_content();
841 }
842 BackendState::Nvim(nvim) => {
843 nvim.paste(text, tx.clone());
844 self.bump_content();
845 }
846 }
847 self.bind_autocomplete_redraw(tx);
851 self.sync_autocomplete();
852 }
853
854 pub fn insert_at_cursor(&mut self, text: &str, tx: &AppTx) {
859 if matches!(self.backend, BackendState::Nvim(_)) {
860 self.paste_text(text, tx);
861 return;
862 }
863 if let BackendState::Textarea(ta) = &mut self.backend {
864 if ta.selection_range().is_some() {
865 ta.cut();
866 }
867 ta.insert_str(text);
868 self.selection = ta.selection_range();
869 self.bump_content();
870 }
871 self.bind_autocomplete_redraw(tx);
874 self.sync_autocomplete();
875 }
876
877 pub fn take_clipboard_image(&mut self) -> Option<ClipboardImage> {
881 let cb = self.clipboard.as_mut()?;
882 let img = cb.get_image().ok()?;
883 Some(ClipboardImage {
884 width: img.width,
885 height: img.height,
886 rgba: img.bytes.into_owned(),
887 })
888 }
889
890 pub fn apply_text_action(&mut self, action: TextAction) {
893 let marker = match action {
894 TextAction::Bold => "**",
895 TextAction::Italic => "*",
896 TextAction::Strikethrough => "~~",
897 _ => return,
898 };
899 let BackendState::Textarea(ta) = &mut self.backend else {
900 return;
901 };
902 match selection_text(ta) {
903 Some(text) => {
904 ta.insert_str(format!("{marker}{text}{marker}"));
905 }
906 None => {
907 ta.insert_str(format!("{marker}{marker}"));
908 for _ in 0..marker.len() {
909 ta.move_cursor(CursorMove::Back);
910 }
911 }
912 }
913 self.selection = ta.selection_range();
914 self.bump_content();
915 }
916
917 pub fn smart_enter(&mut self) -> bool {
922 enum Action {
923 ClearLine { chars: usize },
924 InsertPrefix(String),
925 Dedent,
926 }
927 let action = {
928 let BackendState::Textarea(ta) = &self.backend else {
929 return false;
930 };
931 if ta.selection_range().is_some() {
932 return false;
933 }
934 let (row, col) = cursor_tuple(ta);
935 let Some(line) = ta.lines().get(row) else {
936 return false;
937 };
938 let total_chars = line.chars().count();
939 if col != total_chars {
940 return false;
941 }
942 let ws_end = markdown::leading_ws_byte_len(line);
944 let (ws, after_ws) = line.split_at(ws_end);
945 if let Some(marker_len) = markdown::list_marker_len(after_ws) {
946 if after_ws.len() == marker_len {
947 if ws_end > 0 {
950 Action::Dedent
951 } else {
952 Action::ClearLine { chars: total_chars }
953 }
954 } else {
955 let marker_str = &after_ws[..marker_len];
956 let next_marker = increment_ordered_marker(marker_str)
957 .unwrap_or_else(|| marker_str.to_string());
958 Action::InsertPrefix(format!("{ws}{next_marker}"))
959 }
960 } else if ws_end > 0 && total_chars == ws_end {
961 Action::Dedent
962 } else if ws_end > 0 {
963 Action::InsertPrefix(ws.to_string())
964 } else {
965 return false;
966 }
967 };
968
969 match action {
970 Action::Dedent => {
971 self.indent_lines(true);
972 return true;
973 }
974 Action::ClearLine { chars } => {
975 let BackendState::Textarea(ta) = &mut self.backend else {
976 unreachable!()
977 };
978 ta.move_cursor(CursorMove::Head);
979 ta.delete_str(chars);
980 }
981 Action::InsertPrefix(prefix) => {
982 let BackendState::Textarea(ta) = &mut self.backend else {
983 unreachable!()
984 };
985 ta.insert_newline();
986 ta.insert_str(prefix);
987 }
988 }
989 let BackendState::Textarea(ta) = &self.backend else {
990 unreachable!()
991 };
992 self.selection = ta.selection_range();
993 self.bump_content();
994 true
995 }
996
997 pub fn indent_lines(&mut self, dedent: bool) {
1001 let BackendState::Textarea(ta) = &mut self.backend else {
1002 return;
1003 };
1004 let tab_len = ta.tab_length() as usize;
1005 let hard_tab = ta.hard_tab_indent();
1006 let indent: String = if hard_tab {
1007 "\t".to_string()
1008 } else {
1009 " ".repeat(tab_len)
1010 };
1011 if indent.is_empty() {
1012 return;
1013 }
1014 let indent_chars = indent.len();
1015
1016 let sel = ta.selection_range();
1017 let saved_cursor = if sel.is_none() {
1018 Some(cursor_tuple(ta))
1019 } else {
1020 None
1021 };
1022 let (start_row, end_row) = match sel {
1023 Some(((sr, _), (er, ec))) => {
1024 let last = if ec == 0 && er > sr { er - 1 } else { er };
1027 (sr, last)
1028 }
1029 None => {
1030 let (r, _) = saved_cursor.unwrap();
1031 (r, r)
1032 }
1033 };
1034
1035 let row_count = end_row.saturating_sub(start_row) + 1;
1036 let mut row_deltas: Vec<isize> = Vec::with_capacity(row_count);
1037 let mut any_change = false;
1038
1039 for row in start_row..=end_row {
1040 if dedent {
1041 let count = {
1042 let line = ta.lines().get(row).map(|s| s.as_str()).unwrap_or("");
1043 let max_remove = if hard_tab { 1 } else { tab_len };
1044 let mut count = 0usize;
1045 for (i, c) in line.chars().enumerate() {
1046 if i >= max_remove {
1047 break;
1048 }
1049 if c == '\t' {
1050 count += 1;
1051 break;
1052 } else if c == ' ' && !hard_tab {
1053 count += 1;
1054 } else {
1055 break;
1056 }
1057 }
1058 count
1059 };
1060 if count > 0 {
1061 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1062 ta.delete_str(count);
1063 any_change = true;
1064 }
1065 row_deltas.push(-(count as isize));
1066 } else {
1067 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1068 ta.insert_str(&indent);
1069 row_deltas.push(indent_chars as isize);
1070 any_change = true;
1071 }
1072 }
1073
1074 let adj = |row: usize, col: usize| -> usize {
1075 if row >= start_row && row <= end_row {
1076 let d = row_deltas[row - start_row];
1077 if d >= 0 {
1078 col + d as usize
1079 } else {
1080 col.saturating_sub((-d) as usize)
1081 }
1082 } else {
1083 col
1084 }
1085 };
1086
1087 match sel {
1088 Some(((ssr, ssc), (ser, sec))) => {
1089 ta.cancel_selection();
1090 let new_ssc = adj(ssr, ssc);
1091 let new_sec = adj(ser, sec);
1092 ta.move_cursor(CursorMove::Jump(ssr as u16, new_ssc as u16));
1093 ta.start_selection();
1094 ta.move_cursor(CursorMove::Jump(ser as u16, new_sec as u16));
1095 }
1096 None => {
1097 let (cr, cc) = saved_cursor.expect("captured when sel is None");
1098 let new_col = adj(cr, cc);
1099 ta.move_cursor(CursorMove::Jump(cr as u16, new_col as u16));
1100 }
1101 }
1102
1103 if any_change {
1104 self.selection = ta.selection_range();
1105 self.bump_content();
1106 }
1107 }
1108}
1109
1110impl TextEditorComponent {
1111 #[inline]
1116 fn bump_cursor(&mut self) {
1117 self.edit_generation = self.edit_generation.wrapping_add(1);
1118 }
1119
1120 #[inline]
1130 fn bump_content(&mut self) {
1131 self.edit_generation = self.edit_generation.wrapping_add(1);
1132 let next = self.content_revision.get().wrapping_add(1);
1137 self.content_revision = NonZeroU64::new(next).unwrap_or(NonZeroU64::new(1).unwrap());
1138 }
1139
1140 fn maybe_recover_from_dead_nvim(&mut self) {
1142 use std::sync::atomic::Ordering;
1143 let fallback_text = if let BackendState::Nvim(nvim) = &self.backend {
1144 if nvim.is_dead.load(Ordering::SeqCst) {
1145 Some(
1146 nvim.snapshot
1147 .lock()
1148 .unwrap_or_else(|p| p.into_inner())
1149 .lines
1150 .join("\n"),
1151 )
1152 } else {
1153 None
1154 }
1155 } else {
1156 None
1157 };
1158 if let Some(text) = fallback_text {
1159 tracing::warn!("nvim process died; falling back to textarea backend");
1160 self.backend = BackendState::Textarea(TextArea::from(text.lines()));
1161 self.ensure_autocomplete_for_textarea();
1165 }
1166 }
1167
1168 fn handle_nvim_key(
1173 &mut self,
1174 key: &ratatui::crossterm::event::KeyEvent,
1175 tx: &AppTx,
1176 ) -> Option<EventState> {
1177 let BackendState::Nvim(nvim) = &self.backend else {
1178 return None;
1179 };
1180
1181 if self.nvim_pending_z {
1187 self.nvim_pending_z = false;
1188 match key.code {
1189 KeyCode::Char('Z') => {
1190 tx.send(AppEvent::Autosave).ok();
1192 tx.send(AppEvent::FocusSidebar).ok();
1193 return Some(EventState::Consumed);
1194 }
1195 KeyCode::Char('Q') => {
1196 tx.send(AppEvent::FocusSidebar).ok();
1198 return Some(EventState::Consumed);
1199 }
1200 _ => {
1201 nvim.handle_key(
1203 &ratatui::crossterm::event::KeyEvent::new(
1204 KeyCode::Char('Z'),
1205 KeyModifiers::NONE,
1206 ),
1207 tx.clone(),
1208 );
1209 }
1211 }
1212 } else if key.code == KeyCode::Char('Z') {
1213 let in_normal = {
1214 let snap = nvim.snapshot.lock().unwrap_or_else(|p| p.into_inner());
1215 snap.mode == NvimMode::Normal
1216 };
1217 if in_normal {
1218 self.nvim_pending_z = true;
1219 return Some(EventState::Consumed);
1220 }
1221 }
1222
1223 if key.code == KeyCode::Enter {
1226 let (is_cmd, cmdline) = {
1227 let snap = nvim.snapshot.lock().unwrap_or_else(|p| p.into_inner());
1228 let cmd = if snap.mode == NvimMode::Command {
1229 snap.cmdline
1230 .as_deref()
1231 .unwrap_or("")
1232 .trim_start_matches(':')
1233 .to_string()
1234 } else {
1235 String::new()
1236 };
1237 (snap.mode == NvimMode::Command, cmd)
1238 };
1239 if is_cmd {
1240 let saves = matches!(
1241 cmdline.as_str(),
1242 "w" | "wq" | "wq!" | "wqa" | "wqa!" | "x" | "xa" | "x!"
1243 );
1244 let quits =
1245 saves || matches!(cmdline.as_str(), "q" | "q!" | "qa" | "qa!" | "cq" | "cq!");
1246 if quits {
1247 nvim.handle_key(
1248 &ratatui::crossterm::event::KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
1249 tx.clone(),
1250 );
1251 if saves {
1252 tx.send(AppEvent::Autosave).ok();
1253 }
1254 tx.send(AppEvent::FocusSidebar).ok();
1255 return Some(EventState::Consumed);
1256 }
1257 }
1258 }
1259
1260 nvim.handle_key(key, tx.clone());
1261 self.bump_cursor();
1271 Some(EventState::Consumed)
1272 }
1273
1274 pub fn open_or_advance_search(&mut self) {
1278 if !matches!(self.backend, BackendState::Textarea(_)) {
1279 return;
1280 }
1281 if self.search.is_some() {
1282 self.search_advance(false);
1283 return;
1284 }
1285 self.close_autocomplete();
1289 self.search = Some(SearchState {
1290 input: SingleLineInput::new(),
1291 status: SearchStatus::Empty,
1292 });
1293 }
1294
1295 pub fn close_autocomplete(&mut self) {
1299 if let Some(c) = self.autocomplete.as_mut() {
1300 c.close();
1301 }
1302 }
1303
1304 pub fn set_redraw_tx(&mut self, tx: &AppTx) {
1309 self.bind_autocomplete_redraw(tx);
1310 }
1311
1312 fn bind_autocomplete_redraw(&mut self, tx: &AppTx) {
1321 if self.redraw_tx.is_none() {
1322 self.redraw_tx = Some(tx.clone());
1323 }
1324 if self.autocomplete_redraw_bound {
1325 return;
1326 }
1327 if let Some(c) = self.autocomplete.as_mut() {
1328 c.set_redraw_callback(redraw_callback(tx.clone()));
1329 self.autocomplete_redraw_bound = true;
1330 }
1331 }
1332
1333 fn close_search(&mut self) {
1334 if let BackendState::Textarea(ta) = &mut self.backend {
1335 let _ = ta.set_search_pattern("");
1336 }
1337 self.search = None;
1338 self.selection = None;
1339 }
1340
1341 fn refresh_search_pattern(&mut self, jump: bool) {
1344 let Some(state) = self.search.as_mut() else {
1345 return;
1346 };
1347 let BackendState::Textarea(ta) = &mut self.backend else {
1348 return;
1349 };
1350 if state.input.is_empty() {
1351 let _ = ta.set_search_pattern("");
1352 state.status = SearchStatus::Empty;
1353 self.selection = None;
1354 return;
1355 }
1356 if let Err(e) = ta.set_search_pattern(state.input.value()) {
1357 state.status = SearchStatus::Invalid(e.to_string());
1358 self.selection = None;
1359 return;
1360 }
1361 if !jump {
1362 state.status = SearchStatus::Match;
1363 return;
1364 }
1365 let found = ta.search_forward(true);
1366 state.status = SearchStatus::from_found(found);
1367 self.highlight_current_match(found);
1368 }
1369
1370 fn search_advance(&mut self, backward: bool) {
1371 let Some(state) = self.search.as_mut() else {
1372 return;
1373 };
1374 if state.input.is_empty() {
1375 return;
1376 }
1377 let BackendState::Textarea(ta) = &mut self.backend else {
1378 return;
1379 };
1380 let found = if backward {
1381 ta.search_back(false)
1382 } else {
1383 ta.search_forward(false)
1384 };
1385 state.status = SearchStatus::from_found(found);
1386 self.highlight_current_match(found);
1387 }
1388
1389 fn highlight_current_match(&mut self, found: bool) {
1394 self.selection = if found {
1395 self.compute_match_selection()
1396 } else {
1397 None
1398 };
1399 }
1400
1401 fn compute_match_selection(&self) -> Option<((usize, usize), (usize, usize))> {
1407 let BackendState::Textarea(ta) = &self.backend else {
1408 return None;
1409 };
1410 let re = ta.search_pattern()?;
1411 let DataCursor(row, col_chars) = ta.cursor();
1412 let line = ta.lines().get(row)?;
1413 let byte_off = char_col_to_byte(line, col_chars);
1414 let m = re.find_at(line, byte_off)?;
1415 if m.start() != byte_off {
1416 return None;
1417 }
1418 let match_chars = line[m.range()].chars().count();
1419 Some(((row, col_chars), (row, col_chars + match_chars)))
1420 }
1421
1422 fn handle_search_key(&mut self, key: &ratatui::crossterm::event::KeyEvent) -> bool {
1424 let Some(state) = self.search.as_mut() else {
1425 return false;
1426 };
1427 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1428 match state.input.handle_key(key) {
1429 InputOutcome::Cancel => self.close_search(),
1430 InputOutcome::Submit => self.search_advance(shift),
1431 InputOutcome::Changed => self.refresh_search_pattern(true),
1432 InputOutcome::Consumed | InputOutcome::NotConsumed => {}
1433 }
1434 true
1435 }
1436
1437 fn handle_textarea_key(
1439 &mut self,
1440 key: &ratatui::crossterm::event::KeyEvent,
1441 tx: &AppTx,
1442 ) -> EventState {
1443 if self.handle_search_key(key) {
1445 return EventState::Consumed;
1446 }
1447
1448 if key.modifiers == KeyModifiers::CONTROL {
1450 match key.code {
1451 KeyCode::Char('c') => {
1452 self.copy_selection_to_clipboard();
1453 return EventState::Consumed;
1454 }
1455 KeyCode::Char('v') => {
1456 self.paste_from_clipboard(tx);
1457 return EventState::Consumed;
1458 }
1459 KeyCode::Char('x') => {
1460 self.copy_selection_to_clipboard();
1461 let cut = if let BackendState::Textarea(ta) = &mut self.backend {
1462 let cut = ta.cut();
1468 self.selection = ta.selection_range();
1469 cut
1470 } else {
1471 false
1472 };
1473 if cut {
1474 self.bump_content();
1475 }
1476 return EventState::Consumed;
1477 }
1478 _ => {}
1479 }
1480 }
1481
1482 let BackendState::Textarea(ta) = &mut self.backend else {
1483 unreachable!("handle_textarea_key called with non-Textarea backend")
1484 };
1485
1486 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1488 let handled = match (key.modifiers & !KeyModifiers::SHIFT, key.code) {
1489 (KeyModifiers::ALT, KeyCode::Left) => {
1490 cursor_move!(ta, CursorMove::WordBack, shift);
1491 true
1492 }
1493 (KeyModifiers::ALT, KeyCode::Right) => {
1494 cursor_move!(ta, CursorMove::WordForward, shift);
1495 true
1496 }
1497 (KeyModifiers::SUPER, KeyCode::Left) => {
1498 cursor_move!(ta, CursorMove::Head, shift);
1499 true
1500 }
1501 (KeyModifiers::SUPER, KeyCode::Right) => {
1502 cursor_move!(ta, CursorMove::End, shift);
1503 true
1504 }
1505 (KeyModifiers::SUPER, KeyCode::Up) => {
1506 cursor_move!(ta, CursorMove::Top, shift);
1507 true
1508 }
1509 (KeyModifiers::SUPER, KeyCode::Down) => {
1510 cursor_move!(ta, CursorMove::Bottom, shift);
1511 true
1512 }
1513 _ => false,
1514 };
1515 if handled {
1516 self.selection = ta.selection_range();
1517 self.bump_cursor();
1518 return EventState::Consumed;
1519 }
1520
1521 enum ShortcutOutcome {
1532 NoOp,
1533 CursorOnly,
1534 TextMutated,
1535 }
1536 let outcome: Option<ShortcutOutcome> =
1537 match (key.modifiers & !KeyModifiers::SHIFT, key.code) {
1538 (KeyModifiers::NONE, KeyCode::Left) => {
1540 cursor_move!(ta, CursorMove::Back, shift);
1541 Some(ShortcutOutcome::CursorOnly)
1542 }
1543 (KeyModifiers::NONE, KeyCode::Right) => {
1544 cursor_move!(ta, CursorMove::Forward, shift);
1545 Some(ShortcutOutcome::CursorOnly)
1546 }
1547 (KeyModifiers::NONE, KeyCode::Up) => {
1548 cursor_move!(ta, CursorMove::Up, shift);
1549 Some(ShortcutOutcome::CursorOnly)
1550 }
1551 (KeyModifiers::NONE, KeyCode::Down) => {
1552 cursor_move!(ta, CursorMove::Down, shift);
1553 Some(ShortcutOutcome::CursorOnly)
1554 }
1555 (KeyModifiers::NONE, KeyCode::Home) => {
1556 cursor_move!(ta, CursorMove::Head, shift);
1557 Some(ShortcutOutcome::CursorOnly)
1558 }
1559 (KeyModifiers::NONE, KeyCode::End) => {
1560 cursor_move!(ta, CursorMove::End, shift);
1561 Some(ShortcutOutcome::CursorOnly)
1562 }
1563 (KeyModifiers::NONE, KeyCode::PageUp) => {
1564 cursor_move!(ta, CursorMove::ParagraphBack, shift);
1565 Some(ShortcutOutcome::CursorOnly)
1566 }
1567 (KeyModifiers::NONE, KeyCode::PageDown) => {
1568 cursor_move!(ta, CursorMove::ParagraphForward, shift);
1569 Some(ShortcutOutcome::CursorOnly)
1570 }
1571 (KeyModifiers::CONTROL, KeyCode::Left) => {
1573 cursor_move!(ta, CursorMove::WordBack, shift);
1574 Some(ShortcutOutcome::CursorOnly)
1575 }
1576 (KeyModifiers::CONTROL, KeyCode::Right) => {
1577 cursor_move!(ta, CursorMove::WordForward, shift);
1578 Some(ShortcutOutcome::CursorOnly)
1579 }
1580 (KeyModifiers::CONTROL, KeyCode::Home) => {
1582 cursor_move!(ta, CursorMove::Top, shift);
1583 Some(ShortcutOutcome::CursorOnly)
1584 }
1585 (KeyModifiers::CONTROL, KeyCode::End) => {
1586 cursor_move!(ta, CursorMove::Bottom, shift);
1587 Some(ShortcutOutcome::CursorOnly)
1588 }
1589 (KeyModifiers::CONTROL, KeyCode::Char('z')) => {
1593 if ta.undo() {
1594 Some(ShortcutOutcome::TextMutated)
1595 } else {
1596 Some(ShortcutOutcome::NoOp)
1597 }
1598 }
1599 (KeyModifiers::CONTROL, KeyCode::Char('y'))
1600 | (KeyModifiers::CONTROL, KeyCode::Char('Z')) => {
1601 if ta.redo() {
1602 Some(ShortcutOutcome::TextMutated)
1603 } else {
1604 Some(ShortcutOutcome::NoOp)
1605 }
1606 }
1607 (KeyModifiers::CONTROL, KeyCode::Char('a')) => {
1609 ta.move_cursor(CursorMove::Top);
1610 ta.start_selection();
1611 ta.move_cursor(CursorMove::Bottom);
1612 Some(ShortcutOutcome::CursorOnly)
1613 }
1614 (KeyModifiers::CONTROL, KeyCode::Backspace)
1617 | (KeyModifiers::ALT, KeyCode::Backspace) => {
1618 if ta.delete_word() {
1619 Some(ShortcutOutcome::TextMutated)
1620 } else {
1621 Some(ShortcutOutcome::NoOp)
1622 }
1623 }
1624 (KeyModifiers::CONTROL, KeyCode::Delete) | (KeyModifiers::ALT, KeyCode::Delete) => {
1625 if ta.delete_next_word() {
1626 Some(ShortcutOutcome::TextMutated)
1627 } else {
1628 Some(ShortcutOutcome::NoOp)
1629 }
1630 }
1631 _ => None,
1632 };
1633 if let Some(kind) = outcome {
1634 self.selection = ta.selection_range();
1635 match kind {
1636 ShortcutOutcome::NoOp => {}
1637 ShortcutOutcome::CursorOnly => self.bump_cursor(),
1638 ShortcutOutcome::TextMutated => self.bump_content(),
1639 }
1640 return EventState::Consumed;
1641 }
1642
1643 match (key.modifiers, key.code) {
1645 (m, KeyCode::Tab)
1646 if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
1647 {
1648 self.indent_lines(m.contains(KeyModifiers::SHIFT));
1649 return EventState::Consumed;
1650 }
1651 (_, KeyCode::BackTab) => {
1652 self.indent_lines(true);
1653 return EventState::Consumed;
1654 }
1655 _ => {}
1656 }
1657 if key.code == KeyCode::Enter && key.modifiers.is_empty() && self.smart_enter() {
1658 return EventState::Consumed;
1659 }
1660
1661 let BackendState::Textarea(ta) = &mut self.backend else {
1662 unreachable!("handle_textarea_key called with non-Textarea backend")
1663 };
1664 let mutated = ta.input_without_shortcuts(*key);
1670 self.selection = ta.selection_range();
1671 if mutated {
1672 self.bump_content();
1673 } else {
1674 self.bump_cursor();
1675 }
1676 EventState::Consumed
1677 }
1678
1679 fn handle_mouse(
1681 &mut self,
1682 mouse: &ratatui::crossterm::event::MouseEvent,
1683 tx: &AppTx,
1684 ) -> EventState {
1685 let BackendState::Textarea(_) = &self.backend else {
1686 return EventState::NotConsumed;
1687 };
1688 let r = &self.rect;
1689 let in_bounds = mouse.column >= r.x
1690 && mouse.column < r.x + r.width
1691 && mouse.row >= r.y
1692 && mouse.row < r.y + r.height;
1693 if !in_bounds {
1694 return EventState::NotConsumed;
1695 }
1696 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
1698 tx.send(AppEvent::FocusEditor).ok();
1699 self.copy_selection_to_clipboard();
1700 self.selection = if let BackendState::Textarea(ta) = &self.backend {
1701 ta.selection_range()
1702 } else {
1703 None
1704 };
1705 self.bump_cursor();
1706 return EventState::Consumed;
1707 }
1708 let BackendState::Textarea(ta) = &mut self.backend else {
1710 unreachable!()
1711 };
1712 match mouse.kind {
1713 MouseEventKind::Down(_) => {
1714 tx.send(AppEvent::FocusEditor).ok();
1715 ta.cancel_selection();
1716 let (lrow, lcol) = self
1717 .view
1718 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1719 ta.move_cursor(CursorMove::Jump(lrow, lcol));
1720 ta.start_selection();
1721 }
1722 MouseEventKind::Drag(_) => {
1723 let (lrow, lcol) = self
1724 .view
1725 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1726 ta.move_cursor(CursorMove::Jump(lrow, lcol));
1727 }
1728 _ => {
1729 ta.input(*mouse);
1730 }
1731 }
1732 self.selection = ta.selection_range();
1733 self.bump_cursor();
1736 EventState::Consumed
1737 }
1738}
1739
1740impl Component for TextEditorComponent {
1741 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
1742 self.maybe_recover_from_dead_nvim();
1743 self.bind_autocomplete_redraw(tx);
1744
1745 match event {
1746 InputEvent::Key(key) => {
1747 let popup_open = self.autocomplete.as_ref().is_some_and(|c| c.is_open());
1755 if popup_open
1756 && let Some(host) = build_editor_host_snapshot(
1757 &self.backend,
1758 self.content_revision,
1759 self.view.last_cursor_screen,
1760 )
1761 && let Some(controller) = self.autocomplete.as_mut()
1762 {
1763 match controller.handle_key(*key, &host) {
1764 HandleKeyOutcome::Accepted(action) => {
1765 if let BackendState::Textarea(ta) = &mut self.backend {
1766 apply_accept_to_textarea(ta, &action);
1767 self.selection = ta.selection_range();
1768 }
1769 self.bump_content();
1770 return EventState::Consumed;
1771 }
1772 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
1773 return EventState::Consumed;
1774 }
1775 HandleKeyOutcome::NotHandled => {}
1776 }
1777 }
1778 if let Some(state) = self.handle_nvim_key(key, tx) {
1779 return state;
1780 }
1781 let text_rev_before = self.content_revision;
1792 let cursor_before = self.textarea_cursor();
1793 let result = self.handle_textarea_key(key, tx);
1794 let cursor_after = self.textarea_cursor();
1795 if self.content_revision != text_rev_before {
1796 self.sync_autocomplete();
1797 } else if cursor_before != cursor_after {
1798 self.refresh_autocomplete_if_open();
1799 }
1800 result
1801 }
1802 InputEvent::Mouse(mouse) => {
1803 let text_rev_before = self.content_revision;
1804 let cursor_before = self.textarea_cursor();
1805 let result = self.handle_mouse(mouse, tx);
1806 let cursor_after = self.textarea_cursor();
1807 if self.content_revision != text_rev_before {
1810 self.sync_autocomplete();
1811 } else if cursor_before != cursor_after {
1812 self.refresh_autocomplete_if_open();
1813 }
1814 result
1815 }
1816 InputEvent::Paste(_) => EventState::NotConsumed,
1819 }
1820 }
1821
1822 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
1823 let (editor_rect, search_rect) = if self.search.is_some() && rect.height > 1 {
1825 (
1826 Rect {
1827 height: rect.height - 1,
1828 ..rect
1829 },
1830 Some(Rect {
1831 y: rect.y + rect.height - 1,
1832 height: 1,
1833 ..rect
1834 }),
1835 )
1836 } else {
1837 (rect, None)
1838 };
1839 self.rect = editor_rect;
1842 let (selection, nvim_rev_to_mirror) = match &self.backend {
1847 BackendState::Textarea(_) => (self.selection, None),
1848 BackendState::Nvim(nvim) => {
1849 nvim.maybe_resize(editor_rect.width, editor_rect.height);
1850 let snap = nvim.snapshot.lock().unwrap_or_else(|p| p.into_inner());
1851 let visual_selection = snap.visual_selection;
1852 let content_gen = snap.content_gen;
1853 drop(snap);
1854 let rev = NonZeroU64::new(content_gen.saturating_add(1));
1863 (visual_selection, rev)
1864 }
1865 };
1866 if let Some(rev) = nvim_rev_to_mirror {
1867 self.content_revision = rev;
1868 }
1869 while let Ok((generation, buf)) = self.full_parse_rx.try_recv() {
1875 self.view.install_full_parse(generation, buf);
1876 }
1877
1878 let snap = snapshot_from_backend(&self.backend, self.content_revision);
1883 self.view.update(&snap, editor_rect, selection);
1884
1885 if let Some(generation) = self.view.take_pending_full_parse() {
1893 let lines: Vec<String> = snap.lines.iter().cloned().collect();
1894 let tx = self.full_parse_tx.clone();
1895 let redraw = self.redraw_tx.clone();
1896 self.full_parse_task.spawn(async move {
1897 let buf = ParsedBuffer::parse(&lines);
1898 let _ = tx.send((generation, buf));
1899 if let Some(redraw) = redraw {
1902 let _ = redraw.send(AppEvent::Redraw);
1903 }
1904 });
1905 }
1906 let bar_focused = self.search.is_some() && focused;
1909 let editor_focused = focused && !bar_focused;
1910 self.view.render(f, editor_rect, theme, editor_focused);
1911 if let (Some(state), Some(bar_rect)) = (self.search.as_mut(), search_rect) {
1912 render_search_bar(f, bar_rect, state, theme, bar_focused);
1913 }
1914
1915 self.poll_autocomplete();
1923 if let (Some(controller), Some(live_anchor)) =
1930 (self.autocomplete.as_mut(), self.view.last_cursor_screen)
1931 {
1932 if let Some(state) = controller.state_mut() {
1933 state.anchor = live_anchor;
1934 }
1935 if let Some(state) = controller.state() {
1936 autocomplete::render(f, state, editor_rect, theme);
1937 }
1938 }
1939 }
1940
1941 fn hint_shortcuts(&self) -> Vec<(String, String)> {
1942 use crate::keys::action_shortcuts::ActionShortcuts;
1943
1944 if let BackendState::Nvim(nvim) = &self.backend {
1946 let label = nvim
1947 .snapshot
1948 .lock()
1949 .unwrap_or_else(|p| p.into_inner())
1950 .footer_label();
1951 let mut hints = vec![(String::new(), label)];
1952 hints.extend(
1953 [
1954 (ActionShortcuts::FocusSidebar, "\u{2190} sidebar"),
1955 (ActionShortcuts::FocusEditor, "backlinks \u{2192}"),
1956 (ActionShortcuts::FileOperations, "file ops"),
1957 ]
1958 .iter()
1959 .filter_map(|(action, label)| {
1960 self.key_bindings
1961 .first_combo_for(action)
1962 .map(|k| (k, label.to_string()))
1963 }),
1964 );
1965 return hints;
1966 }
1967
1968 [
1969 (ActionShortcuts::FocusSidebar, "\u{2190} sidebar"),
1970 (ActionShortcuts::FocusEditor, "backlinks \u{2192}"),
1971 (ActionShortcuts::FileOperations, "file ops"),
1972 (ActionShortcuts::FindInBuffer, "find"),
1973 ]
1974 .iter()
1975 .filter_map(|(action, label)| {
1976 self.key_bindings
1977 .first_combo_for(action)
1978 .map(|k| (k, label.to_string()))
1979 })
1980 .collect()
1981 }
1982}
1983
1984#[cfg(test)]
1985mod tests {
1986 use super::*;
1987 use crate::keys::KeyBindings;
1988
1989 fn make_editor() -> TextEditorComponent {
1990 TextEditorComponent::new(
1991 KeyBindings::empty(),
1992 &crate::settings::AppSettings::default(),
1993 )
1994 }
1995
1996 fn dummy_tx() -> AppTx {
1997 tokio::sync::mpsc::unbounded_channel().0
1998 }
1999
2000 fn get_ta(editor: &mut TextEditorComponent) -> &mut TextArea<'static> {
2001 match &mut editor.backend {
2002 BackendState::Textarea(ta) => ta,
2003 _ => panic!("expected Textarea backend"),
2004 }
2005 }
2006
2007 #[test]
2008 fn has_trigger_before_cursor_finds_bracket() {
2009 assert!(has_trigger_before_cursor("hello [[foo", 11));
2010 assert!(has_trigger_before_cursor("[[a b c", 7));
2011 }
2012
2013 #[test]
2014 fn has_trigger_before_cursor_finds_hashtag() {
2015 assert!(has_trigger_before_cursor("text #tag", 9));
2016 }
2017
2018 #[test]
2019 fn has_trigger_before_cursor_no_trigger_bails() {
2020 assert!(!has_trigger_before_cursor("plain prose here", 16));
2021 assert!(!has_trigger_before_cursor("", 0));
2022 }
2023
2024 #[test]
2025 fn has_trigger_before_cursor_handles_multibyte_no_panic() {
2026 let line = "你好世界".to_string() + &"a".repeat(80);
2029 let col = line.chars().count();
2030 assert!(!has_trigger_before_cursor(&line, col));
2031
2032 let with_emoji = "🦀".repeat(20) + "[[note";
2033 let col = with_emoji.chars().count();
2034 assert!(has_trigger_before_cursor(&with_emoji, col));
2035
2036 let accented = "é".repeat(100);
2037 let col = accented.chars().count();
2038 assert!(!has_trigger_before_cursor(&accented, col));
2039 }
2040
2041 #[test]
2042 fn has_trigger_before_cursor_ignores_chars_after_cursor() {
2043 assert!(!has_trigger_before_cursor("foo [[bar", 3));
2045 }
2046
2047 #[test]
2048 fn has_trigger_before_cursor_wikilink_with_spaces() {
2049 assert!(has_trigger_before_cursor("[[my note title", 15));
2052 }
2053
2054 #[test]
2055 fn fresh_editor_is_not_dirty() {
2056 let editor = make_editor();
2057 assert!(!editor.is_dirty());
2058 }
2059
2060 #[test]
2061 fn after_set_text_not_dirty() {
2062 let mut editor = make_editor();
2063 editor.set_text("hello world".to_string());
2064 assert!(!editor.is_dirty());
2065 }
2066
2067 #[test]
2068 fn get_text_returns_loaded_content() {
2069 let mut editor = make_editor();
2070 editor.set_text("line one\nline two".to_string());
2071 assert_eq!(editor.get_text(), "line one\nline two");
2072 }
2073
2074 #[test]
2075 fn mark_saved_clears_dirty() {
2076 let mut editor = make_editor();
2077 editor.set_text("initial".to_string());
2078 let text = editor.get_text();
2079 editor.mark_saved(text.clone() + "x"); assert!(editor.is_dirty());
2081 editor.mark_saved(text); assert!(!editor.is_dirty());
2083 }
2084
2085 #[test]
2086 fn trailing_newline_does_not_cause_false_dirty() {
2087 let mut editor = make_editor();
2088 editor.set_text("content\n".to_string());
2089 assert!(
2090 !editor.is_dirty(),
2091 "trailing newline should not make editor dirty after load"
2092 );
2093 }
2094
2095 #[test]
2096 fn cursor_move_does_not_dirty_buffer() {
2097 let mut editor = make_editor();
2098 editor.set_text("hello world".to_string());
2099 assert!(!editor.is_dirty());
2100 let tx = dummy_tx();
2101 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
2105 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2106 assert!(
2107 !editor.is_dirty(),
2108 "cursor move must not mark the editor as dirty"
2109 );
2110 }
2111
2112 #[test]
2113 fn empty_stack_undo_redo_does_not_dirty_or_bump_revision() {
2114 let mut editor = make_editor();
2118 editor.set_text("foo".to_string());
2119 let rev_before = editor.content_revision();
2120 assert!(!editor.is_dirty());
2121 let tx = dummy_tx();
2122 for key_code in [KeyCode::Char('z'), KeyCode::Char('y')] {
2123 let key = ratatui::crossterm::event::KeyEvent::new(key_code, KeyModifiers::CONTROL);
2124 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2125 }
2126 assert!(
2127 !editor.is_dirty(),
2128 "empty-stack undo/redo must not flip is_dirty"
2129 );
2130 assert_eq!(
2131 editor.content_revision(),
2132 rev_before,
2133 "empty-stack undo/redo must not bump content_revision"
2134 );
2135 }
2136
2137 #[test]
2138 fn fresh_editor_content_revision_is_nonzero() {
2139 let editor = make_editor();
2146 assert!(editor.content_revision().get() >= 1);
2147 }
2148
2149 #[test]
2150 fn mouse_down_clears_selection() {
2151 let mut editor = make_editor();
2152 editor.set_text("hello world".to_string());
2153 let ta = get_ta(&mut editor);
2154 ta.start_selection();
2155 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2156 assert!(ta.selection_range().is_some());
2157 ta.cancel_selection();
2158 editor.selection = if let BackendState::Textarea(ta) = &editor.backend {
2159 ta.selection_range()
2160 } else {
2161 None
2162 };
2163 assert!(editor.selection.is_none());
2164 }
2165
2166 #[test]
2167 fn ctrl_c_copies_selected_text() {
2168 let mut editor = make_editor();
2169 editor.set_text("hello world".to_string());
2170 let ta = get_ta(&mut editor);
2171 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2172 ta.start_selection();
2173 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2174 let range = ta.selection_range().unwrap();
2175 let ((sr, sc), (er, ec)) = range;
2176 let lines = ta.lines();
2177 let selected = if sr == er {
2178 lines[sr][sc..ec].to_string()
2179 } else {
2180 lines[sr][sc..].to_string()
2181 };
2182 assert_eq!(selected, "hello ");
2183 }
2184
2185 #[test]
2186 fn linkable_url_accepts_supported_schemes() {
2187 assert_eq!(
2188 linkable_url("https://example.com"),
2189 Some("https://example.com")
2190 );
2191 assert_eq!(
2192 linkable_url("http://example.com/path?q=1#frag"),
2193 Some("http://example.com/path?q=1#frag"),
2194 );
2195 assert_eq!(
2196 linkable_url(" https://example.com "),
2197 Some("https://example.com")
2198 );
2199 assert_eq!(
2200 linkable_url("ftp://files.example.com/x"),
2201 Some("ftp://files.example.com/x"),
2202 );
2203 assert_eq!(
2204 linkable_url("ftps://files.example.com/x"),
2205 Some("ftps://files.example.com/x"),
2206 );
2207 assert_eq!(
2208 linkable_url("mailto:user@example.com"),
2209 Some("mailto:user@example.com"),
2210 );
2211 assert_eq!(
2212 linkable_url("mailto:user@example.com?subject=hi"),
2213 Some("mailto:user@example.com?subject=hi"),
2214 );
2215 }
2216
2217 #[test]
2218 fn linkable_url_rejects_other_schemes_and_plain_text() {
2219 assert_eq!(linkable_url("file:///etc/passwd"), None);
2220 assert_eq!(linkable_url("ssh://host"), None);
2221 assert_eq!(linkable_url("javascript:alert(1)"), None);
2222 assert_eq!(linkable_url("example.com"), None);
2223 assert_eq!(linkable_url("not a url"), None);
2224 assert_eq!(linkable_url(""), None);
2225 assert_eq!(linkable_url("https://example.com\nmore"), None);
2226 }
2227
2228 #[test]
2229 fn try_build_markdown_link_wraps_selection_when_clip_is_url() {
2230 assert_eq!(
2231 try_build_markdown_link("https://example.com", Some("click here")).as_deref(),
2232 Some("[click here](https://example.com)"),
2233 );
2234 }
2235
2236 #[test]
2237 fn try_build_markdown_link_trims_url_whitespace() {
2238 assert_eq!(
2239 try_build_markdown_link(" https://example.com\n", Some("link")).as_deref(),
2240 Some("[link](https://example.com)"),
2241 );
2242 }
2243
2244 #[test]
2245 fn try_build_markdown_link_returns_none_when_no_selection() {
2246 assert_eq!(try_build_markdown_link("https://example.com", None), None);
2247 }
2248
2249 #[test]
2250 fn try_build_markdown_link_returns_none_when_not_url() {
2251 assert_eq!(try_build_markdown_link("plain text", Some("sel")), None);
2252 }
2253
2254 #[test]
2255 fn try_build_markdown_link_returns_none_when_selection_empty() {
2256 assert_eq!(
2257 try_build_markdown_link("https://example.com", Some("")),
2258 None
2259 );
2260 }
2261
2262 #[test]
2263 fn try_build_markdown_link_escapes_close_bracket_in_selection() {
2264 assert_eq!(
2265 try_build_markdown_link("https://example.com", Some("a]b")).as_deref(),
2266 Some(r"[a\]b](https://example.com)"),
2267 );
2268 }
2269
2270 #[test]
2271 fn try_build_markdown_link_wraps_ftp_url() {
2272 assert_eq!(
2273 try_build_markdown_link("ftp://files.example.com/x", Some("download")).as_deref(),
2274 Some("[download](ftp://files.example.com/x)"),
2275 );
2276 }
2277
2278 fn key(code: KeyCode, mods: KeyModifiers) -> ratatui::crossterm::event::KeyEvent {
2279 ratatui::crossterm::event::KeyEvent::new(code, mods)
2280 }
2281
2282 #[test]
2283 fn open_or_advance_search_opens_find_bar_with_empty_query() {
2284 let mut editor = make_editor();
2285 editor.set_text("hello world".to_string());
2286 editor.open_or_advance_search();
2287 let state = editor.search.as_ref().expect("find bar opened");
2288 assert!(state.input.is_empty());
2289 assert!(matches!(state.status, SearchStatus::Empty));
2290 }
2291
2292 #[test]
2293 fn open_or_advance_search_advances_when_already_open() {
2294 let mut editor = make_editor();
2295 editor.set_text("ab ab ab".to_string());
2296 let tx = dummy_tx();
2297 editor.open_or_advance_search();
2298 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2299 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2300 editor.open_or_advance_search();
2302 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2303 assert_eq!(col, 3, "second invocation advances to next match");
2304 }
2305
2306 #[test]
2307 fn typing_in_find_bar_jumps_cursor_to_first_match() {
2308 let mut editor = make_editor();
2309 editor.set_text("foo bar baz".to_string());
2310 let tx = dummy_tx();
2311 editor.open_or_advance_search();
2312 for ch in ['b', 'a', 'r'] {
2313 editor.handle_textarea_key(&key(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
2314 }
2315 let state = editor.search.as_ref().unwrap();
2316 assert_eq!(state.input.value(), "bar");
2317 assert!(matches!(state.status, SearchStatus::Match));
2318 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2319 assert_eq!(col, 4, "cursor jumped to start of 'bar'");
2320 }
2321
2322 #[test]
2323 fn enter_in_find_bar_advances_to_next_match() {
2324 let mut editor = make_editor();
2325 editor.set_text("ab ab ab".to_string());
2326 let tx = dummy_tx();
2327 editor.open_or_advance_search();
2328 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2329 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2330 editor.handle_textarea_key(&key(KeyCode::Enter, KeyModifiers::NONE), &tx);
2332 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2333 assert_eq!(col, 3, "Enter advances to second match");
2334 }
2335
2336 #[test]
2337 fn match_is_highlighted_as_selection_after_search() {
2338 let mut editor = make_editor();
2339 editor.set_text("foo bar baz".to_string());
2340 let tx = dummy_tx();
2341 editor.open_or_advance_search();
2342 for ch in ['b', 'a', 'r'] {
2343 editor.handle_textarea_key(&key(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
2344 }
2345 assert_eq!(editor.selection, Some(((0, 4), (0, 7))));
2347 }
2348
2349 #[test]
2350 fn no_match_clears_selection() {
2351 let mut editor = make_editor();
2352 editor.set_text("hello".to_string());
2353 let tx = dummy_tx();
2354 editor.open_or_advance_search();
2355 editor.handle_textarea_key(&key(KeyCode::Char('z'), KeyModifiers::NONE), &tx);
2356 assert_eq!(editor.selection, None);
2357 }
2358
2359 #[test]
2360 fn esc_in_find_bar_clears_selection_highlight() {
2361 let mut editor = make_editor();
2362 editor.set_text("foo bar".to_string());
2363 let tx = dummy_tx();
2364 editor.open_or_advance_search();
2365 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2366 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2367 editor.handle_textarea_key(&key(KeyCode::Char('r'), KeyModifiers::NONE), &tx);
2368 assert!(editor.selection.is_some());
2369 editor.handle_textarea_key(&key(KeyCode::Esc, KeyModifiers::NONE), &tx);
2370 assert!(editor.selection.is_none());
2371 }
2372
2373 #[test]
2374 fn esc_in_find_bar_closes_it() {
2375 let mut editor = make_editor();
2376 editor.set_text("hello".to_string());
2377 let tx = dummy_tx();
2378 editor.open_or_advance_search();
2379 assert!(editor.search.is_some());
2380 editor.handle_textarea_key(&key(KeyCode::Esc, KeyModifiers::NONE), &tx);
2381 assert!(editor.search.is_none());
2382 }
2383
2384 #[test]
2385 fn find_bar_consumes_typing_so_editor_text_is_unchanged() {
2386 let mut editor = make_editor();
2387 editor.set_text("hello".to_string());
2388 let tx = dummy_tx();
2389 editor.open_or_advance_search();
2390 editor.handle_textarea_key(&key(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
2391 assert_eq!(editor.get_text(), "hello");
2392 }
2393
2394 #[test]
2395 fn no_match_status_when_query_absent() {
2396 let mut editor = make_editor();
2397 editor.set_text("hello".to_string());
2398 let tx = dummy_tx();
2399 editor.open_or_advance_search();
2400 editor.handle_textarea_key(&key(KeyCode::Char('z'), KeyModifiers::NONE), &tx);
2401 let state = editor.search.as_ref().unwrap();
2402 assert!(matches!(state.status, SearchStatus::NoMatch));
2403 }
2404
2405 #[test]
2406 fn try_build_markdown_link_wraps_mailto_url() {
2407 assert_eq!(
2408 try_build_markdown_link("mailto:user@example.com", Some("email me")).as_deref(),
2409 Some("[email me](mailto:user@example.com)"),
2410 );
2411 }
2412
2413 #[test]
2414 fn insert_at_cursor_appends_text() {
2415 let mut editor = make_editor();
2416 editor.set_text("hello".to_string());
2417 {
2418 let ta = get_ta(&mut editor);
2419 ta.move_cursor(ratatui_textarea::CursorMove::End);
2420 }
2421 editor.insert_at_cursor(" world", &dummy_tx());
2422 assert_eq!(editor.get_text(), "hello world");
2423 }
2424
2425 #[test]
2426 fn insert_at_cursor_replaces_selection() {
2427 let mut editor = make_editor();
2428 editor.set_text("hello world".to_string());
2429 {
2430 let ta = get_ta(&mut editor);
2431 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2432 ta.start_selection();
2433 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2434 }
2435 editor.insert_at_cursor("HEY ", &dummy_tx());
2436 assert_eq!(editor.get_text(), "HEY world");
2437 }
2438
2439 #[test]
2440 fn paste_inserts_text_at_cursor() {
2441 let mut editor = make_editor();
2442 editor.set_text("hello".to_string());
2443 let ta = get_ta(&mut editor);
2444 ta.move_cursor(ratatui_textarea::CursorMove::End);
2445 ta.insert_str(" world");
2446 assert_eq!(editor.get_text(), "hello world");
2447 }
2448
2449 #[test]
2450 fn bold_action_with_no_selection_inserts_pair_and_centers_cursor() {
2451 let mut editor = make_editor();
2452 editor.set_text("hello".to_string());
2453 {
2454 let ta = get_ta(&mut editor);
2455 ta.move_cursor(ratatui_textarea::CursorMove::End);
2456 }
2457 editor.apply_text_action(TextAction::Bold);
2458 assert_eq!(editor.get_text(), "hello****");
2459 let ta = get_ta(&mut editor);
2460 assert_eq!(ta.cursor(), (0, 7));
2461 }
2462
2463 #[test]
2464 fn italic_action_with_no_selection_inserts_single_pair() {
2465 let mut editor = make_editor();
2466 editor.set_text(String::new());
2467 editor.apply_text_action(TextAction::Italic);
2468 assert_eq!(editor.get_text(), "**");
2469 let ta = get_ta(&mut editor);
2470 assert_eq!(ta.cursor(), (0, 1));
2471 }
2472
2473 #[test]
2474 fn strikethrough_action_with_selection_wraps_text() {
2475 let mut editor = make_editor();
2476 editor.set_text("hello world".to_string());
2477 {
2478 let ta = get_ta(&mut editor);
2479 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2480 ta.start_selection();
2481 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2482 }
2483 editor.apply_text_action(TextAction::Strikethrough);
2484 assert_eq!(editor.get_text(), "~~hello ~~world");
2485 }
2486
2487 #[test]
2488 fn bold_action_wraps_non_ascii_selection() {
2489 let mut editor = make_editor();
2490 editor.set_text("hello 你好 world".to_string());
2491 {
2492 let ta = get_ta(&mut editor);
2493 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2494 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2495 ta.start_selection();
2496 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2497 }
2498 editor.apply_text_action(TextAction::Bold);
2499 assert_eq!(editor.get_text(), "hello **你好 **world");
2500 }
2501
2502 #[test]
2503 fn bold_action_wraps_selected_text() {
2504 let mut editor = make_editor();
2505 editor.set_text("foo bar".to_string());
2506 {
2507 let ta = get_ta(&mut editor);
2508 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2509 ta.start_selection();
2510 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2511 }
2512 editor.apply_text_action(TextAction::Bold);
2513 assert_eq!(editor.get_text(), "**foo **bar");
2514 }
2515
2516 #[test]
2517 fn indent_no_selection_indents_current_line() {
2518 let mut editor = make_editor();
2519 editor.set_text("foo\nbar".to_string());
2520 {
2521 let ta = get_ta(&mut editor);
2522 ta.move_cursor(ratatui_textarea::CursorMove::Bottom);
2523 }
2524 editor.indent_lines(false);
2525 let lines = get_ta(&mut editor).lines();
2526 assert_eq!(lines[0], "foo");
2527 assert!(lines[1].starts_with(' ') || lines[1].starts_with('\t'));
2528 assert!(lines[1].trim_start() == "bar");
2529 }
2530
2531 #[test]
2532 fn indent_with_selection_indents_all_touched_lines() {
2533 let mut editor = make_editor();
2534 editor.set_text("foo\nbar\nbaz".to_string());
2535 {
2536 let ta = get_ta(&mut editor);
2537 ta.move_cursor(ratatui_textarea::CursorMove::Top);
2538 ta.start_selection();
2539 ta.move_cursor(ratatui_textarea::CursorMove::Down);
2540 ta.move_cursor(ratatui_textarea::CursorMove::End);
2541 }
2542 editor.indent_lines(false);
2543 let lines: Vec<String> = get_ta(&mut editor).lines().to_vec();
2544 assert_eq!(lines[0].trim_start(), "foo");
2545 assert_eq!(lines[1].trim_start(), "bar");
2546 assert_eq!(lines[2], "baz");
2547 assert!(lines[0].len() > 3);
2548 assert!(lines[1].len() > 3);
2549 }
2550
2551 #[test]
2552 fn dedent_removes_leading_indent() {
2553 let mut editor = make_editor();
2554 editor.set_text(" foo\n bar\nbaz".to_string());
2555 let tab_len = get_ta(&mut editor).tab_length() as usize;
2556 {
2557 let ta = get_ta(&mut editor);
2558 ta.move_cursor(ratatui_textarea::CursorMove::Top);
2559 ta.start_selection();
2560 ta.move_cursor(ratatui_textarea::CursorMove::Bottom);
2561 ta.move_cursor(ratatui_textarea::CursorMove::End);
2562 }
2563 editor.indent_lines(true);
2564 let lines: Vec<String> = get_ta(&mut editor).lines().to_vec();
2565 assert_eq!(lines[0], format!("{}foo", " ".repeat(4 - tab_len.min(4))));
2567 assert_eq!(
2569 lines[1],
2570 format!("{}bar", " ".repeat(2usize.saturating_sub(tab_len)))
2571 );
2572 assert_eq!(lines[2], "baz");
2573 }
2574
2575 #[test]
2576 fn dedent_no_leading_whitespace_is_noop_for_that_line() {
2577 let mut editor = make_editor();
2578 editor.set_text("foo".to_string());
2579 editor.indent_lines(true);
2580 assert_eq!(editor.get_text(), "foo");
2581 }
2582
2583 #[test]
2584 fn smart_enter_continues_unordered_list() {
2585 let mut editor = make_editor();
2586 editor.set_text("- foo".to_string());
2587 {
2588 let ta = get_ta(&mut editor);
2589 ta.move_cursor(ratatui_textarea::CursorMove::End);
2590 }
2591 assert!(editor.smart_enter());
2592 assert_eq!(editor.get_text(), "- foo\n- ");
2593 }
2594
2595 #[test]
2596 fn smart_enter_continues_ordered_list_increments() {
2597 let mut editor = make_editor();
2598 editor.set_text("1. foo".to_string());
2599 {
2600 let ta = get_ta(&mut editor);
2601 ta.move_cursor(ratatui_textarea::CursorMove::End);
2602 }
2603 assert!(editor.smart_enter());
2604 assert_eq!(editor.get_text(), "1. foo\n2. ");
2605 }
2606
2607 #[test]
2608 fn smart_enter_on_empty_list_marker_clears_line() {
2609 let mut editor = make_editor();
2610 editor.set_text("- ".to_string());
2611 {
2612 let ta = get_ta(&mut editor);
2613 ta.move_cursor(ratatui_textarea::CursorMove::End);
2614 }
2615 assert!(editor.smart_enter());
2616 assert_eq!(editor.get_text(), "");
2617 }
2618
2619 #[test]
2620 fn smart_enter_preserves_indent() {
2621 let mut editor = make_editor();
2622 editor.set_text(" body".to_string());
2623 {
2624 let ta = get_ta(&mut editor);
2625 ta.move_cursor(ratatui_textarea::CursorMove::End);
2626 }
2627 assert!(editor.smart_enter());
2628 assert_eq!(editor.get_text(), " body\n ");
2629 }
2630
2631 #[test]
2632 fn smart_enter_on_empty_indent_dedents() {
2633 let mut editor = make_editor();
2634 editor.set_text(" ".to_string());
2635 {
2636 let ta = get_ta(&mut editor);
2637 ta.move_cursor(ratatui_textarea::CursorMove::End);
2638 }
2639 let tab_len = get_ta(&mut editor).tab_length() as usize;
2640 assert!(editor.smart_enter());
2641 assert_eq!(
2642 editor.get_text(),
2643 " ".repeat(4usize.saturating_sub(tab_len))
2644 );
2645 }
2646
2647 #[test]
2648 fn smart_enter_no_indent_no_marker_returns_false() {
2649 let mut editor = make_editor();
2650 editor.set_text("plain".to_string());
2651 {
2652 let ta = get_ta(&mut editor);
2653 ta.move_cursor(ratatui_textarea::CursorMove::End);
2654 }
2655 assert!(!editor.smart_enter());
2656 assert_eq!(editor.get_text(), "plain");
2657 }
2658
2659 #[test]
2660 fn smart_enter_mid_line_returns_false() {
2661 let mut editor = make_editor();
2662 editor.set_text("- foo".to_string());
2663 {
2664 let ta = get_ta(&mut editor);
2665 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2666 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
2667 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
2668 }
2669 assert!(!editor.smart_enter());
2670 }
2671
2672 #[test]
2673 fn smart_enter_on_empty_indented_list_marker_dedents_keeping_marker() {
2674 let mut editor = make_editor();
2675 let tab_len = get_ta(&mut editor).tab_length() as usize;
2676 let indent = " ".repeat(tab_len);
2677 editor.set_text(format!("{indent}- "));
2678 {
2679 let ta = get_ta(&mut editor);
2680 ta.move_cursor(ratatui_textarea::CursorMove::End);
2681 }
2682 assert!(editor.smart_enter());
2683 assert_eq!(editor.get_text(), "- ");
2684 }
2685
2686 #[test]
2687 fn smart_enter_on_empty_list_marker_clears_line_after_full_dedent() {
2688 let mut editor = make_editor();
2689 let tab_len = get_ta(&mut editor).tab_length() as usize;
2690 let indent = " ".repeat(tab_len);
2691 editor.set_text(format!("{indent}- "));
2692 {
2693 let ta = get_ta(&mut editor);
2694 ta.move_cursor(ratatui_textarea::CursorMove::End);
2695 }
2696 assert!(editor.smart_enter());
2698 assert_eq!(editor.get_text(), "- ");
2699 {
2702 let ta = get_ta(&mut editor);
2703 ta.move_cursor(ratatui_textarea::CursorMove::End);
2704 }
2705 assert!(editor.smart_enter());
2706 assert_eq!(editor.get_text(), "");
2707 }
2708
2709 #[test]
2710 fn smart_enter_continues_list_with_non_ascii_content() {
2711 let mut editor = make_editor();
2712 editor.set_text("- 你好".to_string());
2713 {
2714 let ta = get_ta(&mut editor);
2715 ta.move_cursor(ratatui_textarea::CursorMove::End);
2716 }
2717 assert!(editor.smart_enter());
2718 assert_eq!(editor.get_text(), "- 你好\n- ");
2719 }
2720
2721 #[test]
2722 fn smart_enter_preserves_tab_indent() {
2723 let mut editor = make_editor();
2724 editor.set_text("\tbody".to_string());
2725 {
2726 let ta = get_ta(&mut editor);
2727 ta.move_cursor(ratatui_textarea::CursorMove::End);
2728 }
2729 assert!(editor.smart_enter());
2730 assert_eq!(editor.get_text(), "\tbody\n\t");
2731 }
2732
2733 #[test]
2734 fn smart_enter_on_tab_only_line_dedents() {
2735 let mut editor = make_editor();
2736 editor.set_text("\t\t".to_string());
2737 {
2738 let ta = get_ta(&mut editor);
2739 ta.move_cursor(ratatui_textarea::CursorMove::End);
2740 }
2741 assert!(editor.smart_enter());
2742 assert_eq!(editor.get_text(), "\t");
2744 }
2745
2746 #[test]
2747 fn smart_enter_continues_indented_list() {
2748 let mut editor = make_editor();
2749 editor.set_text(" - foo".to_string());
2750 {
2751 let ta = get_ta(&mut editor);
2752 ta.move_cursor(ratatui_textarea::CursorMove::End);
2753 }
2754 assert!(editor.smart_enter());
2755 assert_eq!(editor.get_text(), " - foo\n - ");
2756 }
2757
2758 #[test]
2759 fn unsupported_text_action_is_noop() {
2760 let mut editor = make_editor();
2761 editor.set_text("hello".to_string());
2762 editor.apply_text_action(TextAction::Underline);
2763 assert_eq!(editor.get_text(), "hello");
2764 }
2765
2766 #[test]
2767 fn textarea_hint_shortcuts_has_no_mode_indicator() {
2768 let editor = make_editor();
2769 let hints = editor.hint_shortcuts();
2770 assert!(
2772 !hints
2773 .iter()
2774 .any(|(_, label)| label == "NORMAL" || label == "INSERT")
2775 );
2776 }
2777
2778 fn place_cursor_at_col(editor: &mut TextEditorComponent, col: usize) {
2782 let ta = get_ta(editor);
2783 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2784 for _ in 0..col {
2785 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
2786 }
2787 }
2788
2789 #[test]
2790 fn link_at_cursor_returns_label_when_cursor_on_hashtag() {
2791 let mut editor = make_editor();
2792 editor.set_text("see #rust now".to_string());
2793 place_cursor_at_col(&mut editor, 5);
2795 assert_eq!(
2796 editor.link_at_cursor(),
2797 Some(LinkTarget::Label("rust".into())),
2798 );
2799 }
2800
2801 #[test]
2802 fn link_at_cursor_returns_label_at_hash_char() {
2803 let mut editor = make_editor();
2804 editor.set_text("see #rust now".to_string());
2805 place_cursor_at_col(&mut editor, 4);
2807 assert_eq!(
2808 editor.link_at_cursor(),
2809 Some(LinkTarget::Label("rust".into())),
2810 );
2811 }
2812
2813 #[test]
2814 fn link_at_cursor_returns_none_outside_hashtag() {
2815 let mut editor = make_editor();
2816 editor.set_text("see #rust now".to_string());
2817 place_cursor_at_col(&mut editor, 0);
2819 assert_eq!(editor.link_at_cursor(), None);
2820 }
2821
2822 #[test]
2823 fn link_at_cursor_returns_note_for_wikilink() {
2824 let mut editor = make_editor();
2825 editor.set_text("open [[my note]] please".to_string());
2826 place_cursor_at_col(&mut editor, 7);
2828 let result = editor.link_at_cursor();
2829 assert!(
2830 matches!(result, Some(LinkTarget::Note(_))),
2831 "expected Note variant, got {result:?}"
2832 );
2833 }
2834
2835 #[test]
2838 fn link_at_cursor_returns_note_for_markdown_link_with_fragment() {
2839 let line = "[see docs](#section)";
2844 let mut editor = make_editor();
2845 editor.set_text(line.to_string());
2846 let cursor = "[see docs](#sec".chars().count(); place_cursor_at_col(&mut editor, cursor);
2849 let result = editor.link_at_cursor();
2850 assert!(
2851 matches!(result, Some(LinkTarget::Note(_))),
2852 "expected Note variant for markdown link fragment, got {result:?}"
2853 );
2854 }
2855}