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
21pub(crate) fn 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();
46 let lines_len = snap.lines.len();
47 let cursor_row = if lines_len == 0 {
48 0
49 } else {
50 snap.cursor.0.min(lines_len - 1)
51 };
52 let cursor = (cursor_row, snap.cursor.1);
53 let lines = snap.lines.clone();
54 let rev = NonZeroU64::new(snap.content_gen.saturating_add(1))
55 .unwrap_or_else(|| NonZeroU64::new(1).unwrap());
56 drop(snap);
57 EditorSnapshot::owned(lines, cursor, rev)
58 }
59 }
60}
61
62fn has_trigger_before_cursor(line: &str, col: usize) -> bool {
73 let cursor_byte = line
74 .char_indices()
75 .nth(col)
76 .map(|(b, _)| b)
77 .unwrap_or(line.len());
78 line[..cursor_byte]
79 .chars()
80 .rev()
81 .any(|c| c == '[' || c == '#')
82}
83
84macro_rules! cursor_move {
90 ($ta:expr, $mv:expr, $shift:expr) => {{
91 if $shift {
92 if $ta.selection_range().is_none() {
93 $ta.start_selection();
94 }
95 } else {
96 $ta.cancel_selection();
97 }
98 $ta.move_cursor($mv);
99 }};
100}
101
102use self::backend::BackendState;
103use self::markdown::ParsedBuffer;
104use self::snapshot::{EditorSnapshot, NvimMode};
105use self::view::MarkdownEditorView;
106use crate::util::single_slot_task::SingleSlotTask;
107
108fn increment_ordered_marker(marker: &str) -> Option<String> {
111 let trimmed = marker.trim_end_matches(' ');
112 let dot = trimmed.strip_suffix('.')?;
113 let n: u32 = dot.parse().ok()?;
114 Some(format!("{}. ", n + 1))
115}
116
117fn char_col_to_byte(line: &str, char_col: usize) -> usize {
120 line.char_indices()
121 .nth(char_col)
122 .map(|(b, _)| b)
123 .unwrap_or(line.len())
124}
125
126fn selection_text(ta: &TextArea<'_>) -> Option<String> {
132 let ((sr, sc), (er, ec)) = ta.selection_range()?;
133 if sr == er && sc == ec {
134 return None;
135 }
136 let lines = ta.lines();
137 Some(if sr == er {
138 let line = &lines[sr];
139 let sb = char_col_to_byte(line, sc);
140 let eb = char_col_to_byte(line, ec);
141 line[sb..eb].to_string()
142 } else {
143 let first = &lines[sr];
144 let sb = char_col_to_byte(first, sc);
145 let mut parts = vec![first[sb..].to_string()];
146 for line in &lines[(sr + 1)..er] {
147 parts.push(line.clone());
148 }
149 let last = &lines[er];
150 let eb = char_col_to_byte(last, ec);
151 parts.push(last[..eb].to_string());
152 parts.join("\n")
153 })
154}
155
156fn surround_pair(c: char) -> Option<(&'static str, &'static str)> {
161 match c {
162 '(' => Some(("(", ")")),
163 '[' => Some(("[", "]")),
164 '{' => Some(("{", "}")),
165 '<' => Some(("<", ">")),
166 '"' => Some(("\"", "\"")),
167 '\'' => Some(("'", "'")),
168 '`' => Some(("`", "`")),
169 '*' => Some(("*", "*")),
170 '_' => Some(("_", "_")),
171 '~' => Some(("~", "~")),
172 _ => None,
173 }
174}
175
176fn set_selection(ta: &mut TextArea<'_>, start: (usize, usize), end: (usize, usize)) {
180 let jump = |(row, col): (usize, usize)| {
181 CursorMove::Jump(
182 u16::try_from(row).unwrap_or(u16::MAX),
183 u16::try_from(col).unwrap_or(u16::MAX),
184 )
185 };
186 ta.cancel_selection();
187 ta.move_cursor(jump(start));
188 ta.start_selection();
189 ta.move_cursor(jump(end));
190}
191
192#[derive(Debug, Clone)]
196pub struct ClipboardImage {
197 pub width: usize,
198 pub height: usize,
199 pub rgba: Vec<u8>,
200}
201
202const LINKABLE_PASTE_SCHEMES: &[&str] = &["http", "https", "ftp", "ftps", "mailto"];
206
207fn linkable_url(s: &str) -> Option<&str> {
208 kimun_core::note::scan::url_with_allowed_scheme(s, LINKABLE_PASTE_SCHEMES)
209}
210
211fn try_build_markdown_link(clip: &str, selection: Option<&str>) -> Option<String> {
215 let url = linkable_url(clip)?;
216 let sel = selection.filter(|s| !s.is_empty())?;
217 let escaped = sel.replace('\\', r"\\").replace(']', r"\]");
218 Some(format!("[{escaped}]({url})"))
219}
220
221use std::sync::Arc;
222
223use kimun_core::NoteVault;
224
225use crate::components::Component;
226use crate::components::autocomplete::{
227 self, AutocompleteController, AutocompleteHost, AutocompleteMode, HandleKeyOutcome,
228};
229use crate::components::event_state::EventState;
230use crate::components::events::AppEvent;
231use crate::components::events::AppTx;
232use crate::components::events::InputEvent;
233use crate::components::events::redraw_callback;
234use crate::components::single_line_input::{InputOutcome, SingleLineInput};
235use crate::components::text_editor::autocomplete_glue::apply_accept_to_textarea;
236use crate::keys::KeyBindings;
237use crate::keys::action_shortcuts::TextAction;
238use crate::settings::AppSettings;
239use crate::settings::themes::Theme;
240
241#[derive(Debug, Clone, PartialEq)]
243pub enum LinkTarget {
244 Note(String),
246 Label(String),
248}
249
250struct SearchState {
251 input: SingleLineInput,
252 status: SearchStatus,
253}
254
255enum SearchStatus {
256 Empty,
257 Match,
258 NoMatch,
259 Invalid(String),
260}
261
262impl SearchStatus {
263 fn from_found(found: bool) -> Self {
264 if found { Self::Match } else { Self::NoMatch }
265 }
266}
267
268const FIND_PROMPT: &str = "Find: ";
269const FIND_HINTS: &str = " [Enter] next [Shift+Enter] prev [Esc] close";
270
271fn render_search_bar(
272 f: &mut Frame,
273 rect: Rect,
274 state: &mut SearchState,
275 theme: &Theme,
276 focused: bool,
277) {
278 let base = theme.base_style();
279 let muted = Style::default()
280 .fg(theme.gray.to_ratatui())
281 .bg(theme.bg.to_ratatui());
282 let err = Style::default()
283 .fg(theme.red.to_ratatui())
284 .bg(theme.bg.to_ratatui());
285 let prompt_cols = unicode_width::UnicodeWidthStr::width(FIND_PROMPT) as u16;
286 let value_total_cols = state.input.display_width() as u16;
290 let tail: Option<(String, Style)> = match &state.status {
291 SearchStatus::Empty => None,
292 SearchStatus::Match => Some((FIND_HINTS.to_string(), muted)),
293 SearchStatus::NoMatch => Some((" no match".to_string(), err)),
294 SearchStatus::Invalid(msg) => Some((format!(" invalid regex: {msg}"), err)),
295 };
296 f.render_widget(
297 Paragraph::new(Line::from(Span::styled(
298 FIND_PROMPT,
299 base.add_modifier(Modifier::BOLD),
300 )))
301 .style(base),
302 Rect {
303 width: prompt_cols.min(rect.width),
304 ..rect
305 },
306 );
307 state.input.render(f, rect, base, prompt_cols, focused);
308 if let Some((text, style)) = tail {
309 let consumed = prompt_cols.saturating_add(value_total_cols);
310 let tail_rect = Rect {
311 x: rect.x.saturating_add(consumed),
312 width: rect.width.saturating_sub(consumed),
313 ..rect
314 };
315 f.render_widget(Paragraph::new(text).style(style), tail_rect);
316 }
317}
318
319struct EditorHostSnapshot<'a> {
326 snap: EditorSnapshot<'a>,
327 cursor_screen: Option<(u16, u16)>,
328 cache_key: Option<NonZeroU64>,
329}
330
331impl<'a> AutocompleteHost for EditorHostSnapshot<'a> {
332 fn buffer_snapshot(&self) -> EditorSnapshot<'_> {
333 EditorSnapshot::borrowed(
338 self.snap.lines.as_ref(),
339 self.snap.cursor,
340 self.snap.content_revision,
341 )
342 }
343 fn cache_key(&self) -> Option<NonZeroU64> {
344 self.cache_key
345 }
346 fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
347 Some(self.cursor_screen.unwrap_or((0, 0)))
361 }
362}
363
364fn build_editor_host_snapshot<'a>(
370 backend: &'a BackendState,
371 content_revision: NonZeroU64,
372 cursor_screen: Option<(u16, u16)>,
373) -> Option<EditorHostSnapshot<'a>> {
374 if !backend.is_textarea() {
375 return None;
376 }
377 Some(EditorHostSnapshot {
378 snap: snapshot_from_backend(backend, content_revision),
379 cursor_screen,
380 cache_key: Some(content_revision),
381 })
382}
383
384pub struct TextEditorComponent {
388 backend: BackendState,
389 rect: Rect,
391 key_bindings: KeyBindings,
392 saved_content_rev: Option<NonZeroU64>,
401 view: MarkdownEditorView,
402 edit_generation: u64,
407 content_revision: NonZeroU64,
430 selection: Option<((usize, usize), (usize, usize))>,
433 clipboard: Option<Clipboard>,
435 nvim_pending_z: bool,
438 search: Option<SearchState>,
440 autocomplete: Option<AutocompleteController>,
444 autocomplete_vault: Option<Arc<NoteVault>>,
448 autocomplete_redraw_bound: bool,
453 full_parse_task: SingleSlotTask<()>,
460 pub wants_context_menu: bool,
463 search_needles: Vec<String>,
467 needles_revision: Option<NonZeroU64>,
469 full_parse_tx: tokio::sync::mpsc::UnboundedSender<(u64, ParsedBuffer)>,
470 full_parse_rx: tokio::sync::mpsc::UnboundedReceiver<(u64, ParsedBuffer)>,
471 redraw_tx: Option<AppTx>,
475}
476
477impl TextEditorComponent {
478 pub fn new(key_bindings: KeyBindings, settings: &AppSettings) -> Self {
479 let (full_parse_tx, full_parse_rx) = tokio::sync::mpsc::unbounded_channel();
480 Self {
481 backend: BackendState::from_settings(
482 &settings.editor_backend,
483 settings.nvim_path.as_ref(),
484 ),
485 rect: Rect::default(),
486 key_bindings,
487 saved_content_rev: NonZeroU64::new(1),
488 view: MarkdownEditorView::new(),
489 edit_generation: 0,
490 content_revision: NonZeroU64::new(1).unwrap(),
491 selection: None,
492 clipboard: Clipboard::new().ok(),
493 nvim_pending_z: false,
494 search: None,
495 autocomplete: None,
496 autocomplete_vault: None,
497 autocomplete_redraw_bound: false,
498 full_parse_task: SingleSlotTask::empty(),
499 wants_context_menu: false,
500 search_needles: Vec::new(),
501 needles_revision: None,
502 full_parse_tx,
503 full_parse_rx,
504 redraw_tx: None,
505 }
506 }
507
508 pub fn set_vault(&mut self, vault: Arc<NoteVault>) {
513 self.autocomplete_vault = Some(vault.clone());
514 if self.backend.is_textarea() {
515 self.autocomplete = Some(AutocompleteController::new(
516 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
517 AutocompleteMode::Both,
518 ));
519 }
520 }
521
522 fn ensure_autocomplete_for_textarea(&mut self) {
527 if self.autocomplete.is_some() {
528 return;
529 }
530 if !self.backend.is_textarea() {
531 return;
532 }
533 let Some(vault) = self.autocomplete_vault.clone() else {
534 return;
535 };
536 self.autocomplete = Some(AutocompleteController::new(
537 std::sync::Arc::new(crate::components::search_list::VaultSuggestions { vault }),
538 AutocompleteMode::Both,
539 ));
540 self.autocomplete_redraw_bound = false;
543 }
544
545 #[allow(dead_code)]
552 fn autocomplete_host_snapshot(&self) -> Option<EditorHostSnapshot<'_>> {
553 build_editor_host_snapshot(
554 &self.backend,
555 self.content_revision,
556 self.view.last_cursor_screen,
557 )
558 }
559
560 fn poll_autocomplete(&mut self) {
563 if let Some(controller) = self.autocomplete.as_mut() {
564 controller.poll_results();
565 }
566 }
567
568 fn textarea_cursor(&self) -> Option<(usize, usize)> {
572 let ta = self.backend.as_textarea()?;
573 Some(cursor_tuple(ta))
574 }
575
576 fn refresh_autocomplete_if_open(&mut self) {
577 if !self.autocomplete.as_ref().is_some_and(|c| c.is_open()) {
579 return;
580 }
581 let Some(snapshot) = build_editor_host_snapshot(
585 &self.backend,
586 self.content_revision,
587 self.view.last_cursor_screen,
588 ) else {
589 self.close_autocomplete();
590 return;
591 };
592 if let Some(controller) = self.autocomplete.as_mut() {
593 controller.refresh_if_open(&snapshot);
594 }
595 }
596
597 fn sync_autocomplete(&mut self) {
601 let Some(controller) = self.autocomplete.as_ref() else {
602 return; };
604
605 if !controller.is_open() {
618 let Some(ta) = self.backend.as_textarea() else {
619 return;
620 };
621 let (row, col) = cursor_tuple(ta);
622 let line = ta.lines().get(row).map(|s| s.as_str()).unwrap_or("");
623 if !has_trigger_before_cursor(line, col) {
624 return;
625 }
626 }
627
628 let Some(snapshot) = build_editor_host_snapshot(
632 &self.backend,
633 self.content_revision,
634 self.view.last_cursor_screen,
635 ) else {
636 if let Some(c) = self.autocomplete.as_mut() {
637 c.close();
638 }
639 return;
640 };
641 if let Some(controller) = self.autocomplete.as_mut() {
642 controller.sync(&snapshot);
643 }
644 }
645
646 pub fn lines(&self) -> &[String] {
652 match &self.backend {
653 BackendState::Textarea(ta) => ta.lines(),
654 BackendState::Nvim(_) => &[],
655 }
656 }
657
658 pub fn view_snapshot(&self) -> EditorSnapshot<'_> {
677 snapshot_from_backend(&self.backend, self.content_revision)
678 }
679
680 pub fn cursor_pos(&self) -> (usize, usize) {
684 self.backend.cursor()
685 }
686
687 pub fn set_search_needles(&mut self, needles: Vec<String>) {
691 self.search_needles = needles
692 .into_iter()
693 .map(|n| n.to_lowercase())
694 .filter(|n| !n.is_empty())
695 .collect();
696 self.needles_revision = Some(self.content_revision);
697 }
698
699 pub fn set_text(&mut self, text: String) {
700 if text == self.get_text() {
707 self.saved_content_rev = Some(self.content_revision);
708 if let Some(nvim) = self.backend.as_nvim() {
709 nvim.mark_clean();
710 }
711 return;
712 }
713 match &mut self.backend {
714 BackendState::Textarea(ta) => {
715 let lines = text.lines();
716 *ta = TextArea::from(lines);
717 }
718 BackendState::Nvim(nvim) => {
719 nvim.set_text(&text);
720 }
721 }
722 self.bump_content();
723 let reconstructed = self.get_text();
724 self.mark_saved(reconstructed);
725 self.close_autocomplete();
728 }
729
730 pub fn get_text(&self) -> String {
731 self.backend.text()
732 }
733
734 pub fn content_revision(&self) -> NonZeroU64 {
741 self.content_revision
742 }
743
744 pub fn mark_saved_at_revision(&mut self, rev: NonZeroU64) {
754 if rev != self.content_revision {
755 return;
756 }
757 if let Some(nvim) = self.backend.as_nvim() {
758 nvim.mark_clean();
759 }
760 self.saved_content_rev = Some(rev);
761 }
762
763 pub fn mark_saved(&mut self, text: String) {
771 let matches = text == self.get_text();
772 if matches {
773 if let Some(nvim) = self.backend.as_nvim() {
774 nvim.mark_clean();
775 }
776 self.saved_content_rev = Some(self.content_revision);
777 } else {
778 self.saved_content_rev = None;
783 }
784 }
785
786 pub fn is_dirty(&self) -> bool {
787 match &self.backend {
788 BackendState::Textarea(_) => self.saved_content_rev != Some(self.content_revision),
789 BackendState::Nvim(nvim) => nvim.snapshot().dirty,
790 }
791 }
792
793 pub fn link_at_cursor(&self) -> Option<LinkTarget> {
796 let (_row, col, line) = match &self.backend {
797 BackendState::Textarea(ta) => {
798 let (row, col) = cursor_tuple(ta);
799 let line = ta.lines().get(row)?.to_string();
800 (row, col, line)
801 }
802 BackendState::Nvim(nvim) => {
803 let snap = nvim.snapshot();
804 let (row, col) = snap.cursor;
805 let line = snap.lines.get(row)?.to_string();
806 (row, col, line)
807 }
808 };
809
810 if let Some(span) = kimun_core::note::scan::link_char_spans(&line)
813 .into_iter()
814 .find(|s| s.start <= col && col < s.end)
815 {
816 return Some(LinkTarget::Note(span.target));
817 }
818
819 let parsed = self::markdown::ParsedLine::parse(&line);
821 parsed
822 .elements
823 .iter()
824 .find(|e| {
825 e.kind == self::markdown::ElementKind::Label
826 && col >= e.start_char
827 && col < e.end_char
828 })
829 .map(|e| {
830 let span: String = line
831 .chars()
832 .skip(e.start_char)
833 .take(e.end_char - e.start_char)
834 .collect();
835 let name = span.trim_start_matches('#').to_string();
836 LinkTarget::Label(name)
837 })
838 }
839
840 fn copy_selection_to_clipboard(&mut self) {
842 let text = {
843 let Some(ta) = self.backend.as_textarea() else {
844 return;
845 };
846 match selection_text(ta) {
847 Some(t) => t,
848 None => return,
849 }
850 };
851 if let Some(cb) = &mut self.clipboard {
852 let _ = cb.set_text(text);
853 }
854 }
855
856 fn paste_from_clipboard(&mut self, tx: &AppTx) {
858 let text = match &mut self.clipboard {
859 Some(cb) => match cb.get_text() {
860 Ok(t) if !t.is_empty() => t,
861 _ => return,
862 },
863 None => return,
864 };
865 self.paste_text(&text, tx);
866 }
867
868 pub fn paste_text(&mut self, text: &str, tx: &AppTx) {
877 if text.is_empty() {
878 return;
879 }
880 match &mut self.backend {
881 BackendState::Textarea(ta) => {
882 let selection = linkable_url(text).and_then(|_| selection_text(ta));
883 let wrapped = try_build_markdown_link(text, selection.as_deref());
884 if ta.selection_range().is_some() {
885 ta.cut();
886 }
887 ta.insert_str(wrapped.as_deref().unwrap_or(text));
888 self.selection = ta.selection_range();
889 self.bump_content();
890 }
891 BackendState::Nvim(nvim) => {
892 nvim.paste(text, tx.clone());
893 self.bump_content();
894 }
895 }
896 self.bind_autocomplete_redraw(tx);
900 self.sync_autocomplete();
901 }
902
903 pub fn insert_at_cursor(&mut self, text: &str, tx: &AppTx) {
908 if matches!(self.backend, BackendState::Nvim(_)) {
909 self.paste_text(text, tx);
910 return;
911 }
912 if let Some(ta) = self.backend.as_textarea_mut() {
913 if ta.selection_range().is_some() {
914 ta.cut();
915 }
916 ta.insert_str(text);
917 self.selection = ta.selection_range();
918 self.bump_content();
919 }
920 self.bind_autocomplete_redraw(tx);
923 self.sync_autocomplete();
924 }
925
926 pub fn take_clipboard_image(&mut self) -> Option<ClipboardImage> {
930 let cb = self.clipboard.as_mut()?;
931 let img = cb.get_image().ok()?;
932 Some(ClipboardImage {
933 width: img.width,
934 height: img.height,
935 rgba: img.bytes.into_owned(),
936 })
937 }
938
939 fn wrap_selection(&mut self, open: &str, close: &str) -> bool {
945 let Some(ta) = self.backend.as_textarea_mut() else {
946 return false;
947 };
948 let Some(((sr, sc), (er, ec))) = ta.selection_range() else {
949 return false;
950 };
951 let Some(text) = selection_text(ta) else {
952 return false;
953 };
954 ta.insert_str(format!("{open}{text}{close}"));
955 let shift = open.chars().count();
959 let inner_end_col = if sr == er { ec + shift } else { ec };
960 set_selection(ta, (sr, sc + shift), (er, inner_end_col));
961 self.selection = ta.selection_range();
962 self.bump_content();
963 true
964 }
965
966 pub fn apply_text_action(&mut self, action: TextAction) {
969 let marker = match action {
970 TextAction::Bold => "**",
971 TextAction::Italic => "*",
972 TextAction::Strikethrough => "~~",
973 _ => return,
974 };
975 if self.wrap_selection(marker, marker) {
976 return;
977 }
978 let Some(ta) = self.backend.as_textarea_mut() else {
979 return;
980 };
981 ta.insert_str(format!("{marker}{marker}"));
982 for _ in 0..marker.len() {
983 ta.move_cursor(CursorMove::Back);
984 }
985 self.selection = ta.selection_range();
986 self.bump_content();
987 }
988
989 pub fn smart_enter(&mut self) -> bool {
994 enum Action {
995 ClearLine { chars: usize },
996 InsertPrefix(String),
997 Dedent,
998 }
999 let action = {
1000 let Some(ta) = self.backend.as_textarea() else {
1001 return false;
1002 };
1003 if ta
1006 .selection_range()
1007 .is_some_and(|(start, end)| start != end)
1008 {
1009 return false;
1010 }
1011 let (row, col) = cursor_tuple(ta);
1012 let Some(line) = ta.lines().get(row) else {
1013 return false;
1014 };
1015 let total_chars = line.chars().count();
1016 if col != total_chars {
1017 return false;
1018 }
1019 let ws_end = markdown::leading_ws_byte_len(line);
1021 let (ws, after_ws) = line.split_at(ws_end);
1022 if let Some(marker_len) = markdown::list_marker_len(after_ws) {
1023 if after_ws.len() == marker_len {
1024 if ws_end > 0 {
1027 Action::Dedent
1028 } else {
1029 Action::ClearLine { chars: total_chars }
1030 }
1031 } else {
1032 let marker_str = &after_ws[..marker_len];
1033 let next_marker = increment_ordered_marker(marker_str)
1034 .unwrap_or_else(|| marker_str.to_string());
1035 Action::InsertPrefix(format!("{ws}{next_marker}"))
1036 }
1037 } else if ws_end > 0 && total_chars == ws_end {
1038 Action::Dedent
1039 } else if ws_end > 0 {
1040 Action::InsertPrefix(ws.to_string())
1041 } else {
1042 return false;
1043 }
1044 };
1045
1046 match action {
1047 Action::Dedent => {
1048 self.indent_lines(true);
1049 return true;
1050 }
1051 Action::ClearLine { chars } => {
1052 let Some(ta) = self.backend.as_textarea_mut() else {
1053 unreachable!()
1054 };
1055 ta.move_cursor(CursorMove::Head);
1056 ta.delete_str(chars);
1057 }
1058 Action::InsertPrefix(prefix) => {
1059 let Some(ta) = self.backend.as_textarea_mut() else {
1060 unreachable!()
1061 };
1062 ta.insert_newline();
1063 ta.insert_str(prefix);
1064 }
1065 }
1066 let Some(ta) = self.backend.as_textarea() else {
1067 unreachable!()
1068 };
1069 self.selection = ta.selection_range();
1070 self.bump_content();
1071 true
1072 }
1073
1074 pub fn jump_to_heading(&mut self, heading: &str) {
1079 let Some(ta) = self.backend.as_textarea_mut() else {
1080 return;
1081 };
1082 fn normalise(text: &str) -> String {
1087 text.trim()
1088 .trim_end_matches('#')
1089 .trim()
1090 .replace(['*', '_', '`'], "")
1091 }
1092 let wanted = normalise(heading);
1093 let row = ta.lines().iter().position(|l| {
1094 let t = l.trim_start();
1095 let stripped = t.trim_start_matches('#');
1096 stripped.len() != t.len() && normalise(stripped) == wanted
1097 });
1098 if let Some(row) = row {
1099 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1100 self.bump_cursor();
1101 }
1102 }
1103
1104 pub fn indent_lines(&mut self, dedent: bool) {
1108 let Some(ta) = self.backend.as_textarea_mut() else {
1109 return;
1110 };
1111 let tab_len = ta.tab_length() as usize;
1112 let hard_tab = ta.hard_tab_indent();
1113 let indent: String = if hard_tab {
1114 "\t".to_string()
1115 } else {
1116 " ".repeat(tab_len)
1117 };
1118 if indent.is_empty() {
1119 return;
1120 }
1121 let indent_chars = indent.len();
1122
1123 let sel = ta.selection_range();
1124 let saved_cursor = if sel.is_none() {
1125 Some(cursor_tuple(ta))
1126 } else {
1127 None
1128 };
1129 let (start_row, end_row) = match sel {
1130 Some(((sr, _), (er, ec))) => {
1131 let last = if ec == 0 && er > sr { er - 1 } else { er };
1134 (sr, last)
1135 }
1136 None => {
1137 let (r, _) = saved_cursor.unwrap();
1138 (r, r)
1139 }
1140 };
1141
1142 let row_count = end_row.saturating_sub(start_row) + 1;
1143 let mut row_deltas: Vec<isize> = Vec::with_capacity(row_count);
1144 let mut any_change = false;
1145
1146 ta.cancel_selection();
1151
1152 for row in start_row..=end_row {
1153 if dedent {
1154 let count = {
1155 let line = ta.lines().get(row).map(|s| s.as_str()).unwrap_or("");
1156 let max_remove = if hard_tab { 1 } else { tab_len };
1157 let mut count = 0usize;
1158 for (i, c) in line.chars().enumerate() {
1159 if i >= max_remove {
1160 break;
1161 }
1162 if c == '\t' {
1163 count += 1;
1164 break;
1165 } else if c == ' ' && !hard_tab {
1166 count += 1;
1167 } else {
1168 break;
1169 }
1170 }
1171 count
1172 };
1173 if count > 0 {
1174 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1175 ta.delete_str(count);
1176 any_change = true;
1177 }
1178 row_deltas.push(-(count as isize));
1179 } else {
1180 ta.move_cursor(CursorMove::Jump(row as u16, 0));
1181 ta.insert_str(&indent);
1182 row_deltas.push(indent_chars as isize);
1183 any_change = true;
1184 }
1185 }
1186
1187 let adj = |row: usize, col: usize| -> usize {
1188 if row >= start_row && row <= end_row {
1189 let d = row_deltas[row - start_row];
1190 if d >= 0 {
1191 col + d as usize
1192 } else {
1193 col.saturating_sub((-d) as usize)
1194 }
1195 } else {
1196 col
1197 }
1198 };
1199
1200 match sel {
1201 Some(((ssr, ssc), (ser, sec))) => {
1202 set_selection(ta, (ssr, adj(ssr, ssc)), (ser, adj(ser, sec)));
1203 }
1204 None => {
1205 let (cr, cc) = saved_cursor.expect("captured when sel is None");
1206 let new_col = adj(cr, cc);
1207 ta.move_cursor(CursorMove::Jump(cr as u16, new_col as u16));
1208 }
1209 }
1210
1211 if any_change {
1212 self.selection = ta.selection_range();
1213 self.bump_content();
1214 }
1215 }
1216}
1217
1218impl TextEditorComponent {
1219 #[inline]
1224 fn bump_cursor(&mut self) {
1225 self.edit_generation = self.edit_generation.wrapping_add(1);
1226 }
1227
1228 #[inline]
1238 fn bump_content(&mut self) {
1239 self.edit_generation = self.edit_generation.wrapping_add(1);
1240 let next = self.content_revision.get().wrapping_add(1);
1245 self.content_revision = NonZeroU64::new(next).unwrap_or(NonZeroU64::new(1).unwrap());
1246 }
1247
1248 fn maybe_recover_from_dead_nvim(&mut self) {
1250 if self.backend.recover_from_dead_nvim() {
1251 self.ensure_autocomplete_for_textarea();
1255 }
1256 }
1257
1258 fn handle_nvim_key(
1263 &mut self,
1264 key: &ratatui::crossterm::event::KeyEvent,
1265 tx: &AppTx,
1266 ) -> Option<EventState> {
1267 let nvim = self.backend.as_nvim()?;
1268
1269 if self.nvim_pending_z {
1275 self.nvim_pending_z = false;
1276 match key.code {
1277 KeyCode::Char('Z') => {
1278 tx.send(AppEvent::Autosave).ok();
1280 tx.send(AppEvent::FocusSidebar).ok();
1281 return Some(EventState::Consumed);
1282 }
1283 KeyCode::Char('Q') => {
1284 tx.send(AppEvent::FocusSidebar).ok();
1286 return Some(EventState::Consumed);
1287 }
1288 _ => {
1289 nvim.handle_key(
1291 &ratatui::crossterm::event::KeyEvent::new(
1292 KeyCode::Char('Z'),
1293 KeyModifiers::NONE,
1294 ),
1295 tx.clone(),
1296 );
1297 }
1299 }
1300 } else if key.code == KeyCode::Char('Z') {
1301 let in_normal = {
1302 let snap = nvim.snapshot();
1303 snap.mode == NvimMode::Normal
1304 };
1305 if in_normal {
1306 self.nvim_pending_z = true;
1307 return Some(EventState::Consumed);
1308 }
1309 }
1310
1311 if key.code == KeyCode::Enter {
1314 let (is_cmd, cmdline) = {
1315 let snap = nvim.snapshot();
1316 let cmd = if snap.mode == NvimMode::Command {
1317 snap.cmdline
1318 .as_deref()
1319 .unwrap_or("")
1320 .trim_start_matches(':')
1321 .to_string()
1322 } else {
1323 String::new()
1324 };
1325 (snap.mode == NvimMode::Command, cmd)
1326 };
1327 if is_cmd {
1328 let saves = matches!(
1329 cmdline.as_str(),
1330 "w" | "wq" | "wq!" | "wqa" | "wqa!" | "x" | "xa" | "x!"
1331 );
1332 let quits =
1333 saves || matches!(cmdline.as_str(), "q" | "q!" | "qa" | "qa!" | "cq" | "cq!");
1334 if quits {
1335 nvim.handle_key(
1336 &ratatui::crossterm::event::KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
1337 tx.clone(),
1338 );
1339 if saves {
1340 tx.send(AppEvent::Autosave).ok();
1341 }
1342 tx.send(AppEvent::FocusSidebar).ok();
1343 return Some(EventState::Consumed);
1344 }
1345 }
1346 }
1347
1348 nvim.handle_key(key, tx.clone());
1349 self.bump_cursor();
1359 Some(EventState::Consumed)
1360 }
1361
1362 pub fn open_or_advance_search(&mut self) {
1366 if !self.backend.is_textarea() {
1367 return;
1368 }
1369 if self.search.is_some() {
1370 self.search_advance(false);
1371 return;
1372 }
1373 self.close_autocomplete();
1377 self.search = Some(SearchState {
1378 input: SingleLineInput::new(),
1379 status: SearchStatus::Empty,
1380 });
1381 }
1382
1383 pub fn close_autocomplete(&mut self) {
1387 if let Some(c) = self.autocomplete.as_mut() {
1388 c.close();
1389 }
1390 }
1391
1392 pub fn set_redraw_tx(&mut self, tx: &AppTx) {
1397 self.bind_autocomplete_redraw(tx);
1398 }
1399
1400 fn bind_autocomplete_redraw(&mut self, tx: &AppTx) {
1409 if self.redraw_tx.is_none() {
1410 self.redraw_tx = Some(tx.clone());
1411 }
1412 if self.autocomplete_redraw_bound {
1413 return;
1414 }
1415 if let Some(c) = self.autocomplete.as_mut() {
1416 c.set_redraw_callback(redraw_callback(tx.clone()));
1417 self.autocomplete_redraw_bound = true;
1418 }
1419 }
1420
1421 fn close_search(&mut self) {
1422 if let Some(ta) = self.backend.as_textarea_mut() {
1423 let _ = ta.set_search_pattern("");
1424 }
1425 self.search = None;
1426 self.selection = None;
1427 }
1428
1429 fn refresh_search_pattern(&mut self, jump: bool) {
1432 let Some(state) = self.search.as_mut() else {
1433 return;
1434 };
1435 let Some(ta) = self.backend.as_textarea_mut() else {
1436 return;
1437 };
1438 if state.input.is_empty() {
1439 let _ = ta.set_search_pattern("");
1440 state.status = SearchStatus::Empty;
1441 self.selection = None;
1442 return;
1443 }
1444 if let Err(e) = ta.set_search_pattern(state.input.value()) {
1445 state.status = SearchStatus::Invalid(e.to_string());
1446 self.selection = None;
1447 return;
1448 }
1449 if !jump {
1450 state.status = SearchStatus::Match;
1451 return;
1452 }
1453 let found = ta.search_forward(true);
1454 state.status = SearchStatus::from_found(found);
1455 self.highlight_current_match(found);
1456 }
1457
1458 fn search_advance(&mut self, backward: bool) {
1459 let Some(state) = self.search.as_mut() else {
1460 return;
1461 };
1462 if state.input.is_empty() {
1463 return;
1464 }
1465 let Some(ta) = self.backend.as_textarea_mut() else {
1466 return;
1467 };
1468 let found = if backward {
1469 ta.search_back(false)
1470 } else {
1471 ta.search_forward(false)
1472 };
1473 state.status = SearchStatus::from_found(found);
1474 self.highlight_current_match(found);
1475 }
1476
1477 fn highlight_current_match(&mut self, found: bool) {
1482 self.selection = if found {
1483 self.compute_match_selection()
1484 } else {
1485 None
1486 };
1487 }
1488
1489 fn compute_match_selection(&self) -> Option<((usize, usize), (usize, usize))> {
1495 let ta = self.backend.as_textarea()?;
1496 let re = ta.search_pattern()?;
1497 let DataCursor(row, col_chars) = ta.cursor();
1498 let line = ta.lines().get(row)?;
1499 let byte_off = char_col_to_byte(line, col_chars);
1500 let m = re.find_at(line, byte_off)?;
1501 if m.start() != byte_off {
1502 return None;
1503 }
1504 let match_chars = line[m.range()].chars().count();
1505 Some(((row, col_chars), (row, col_chars + match_chars)))
1506 }
1507
1508 fn handle_search_key(&mut self, key: &ratatui::crossterm::event::KeyEvent) -> bool {
1510 let Some(state) = self.search.as_mut() else {
1511 return false;
1512 };
1513 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1514 match state.input.handle_key(key) {
1515 InputOutcome::Cancel => self.close_search(),
1516 InputOutcome::Submit => self.search_advance(shift),
1517 InputOutcome::Changed => self.refresh_search_pattern(true),
1518 InputOutcome::Consumed | InputOutcome::NotConsumed => {}
1519 }
1520 true
1521 }
1522
1523 fn handle_textarea_key(
1525 &mut self,
1526 key: &ratatui::crossterm::event::KeyEvent,
1527 tx: &AppTx,
1528 ) -> EventState {
1529 if self.handle_search_key(key) {
1531 return EventState::Consumed;
1532 }
1533
1534 if key.modifiers == KeyModifiers::CONTROL {
1536 match key.code {
1537 KeyCode::Char('c') => {
1538 self.copy_selection_to_clipboard();
1539 return EventState::Consumed;
1540 }
1541 KeyCode::Char('v') => {
1542 self.paste_from_clipboard(tx);
1543 return EventState::Consumed;
1544 }
1545 KeyCode::Char('x') => {
1546 self.copy_selection_to_clipboard();
1547 let cut = if let Some(ta) = self.backend.as_textarea_mut() {
1548 let cut = ta.cut();
1554 self.selection = ta.selection_range();
1555 cut
1556 } else {
1557 false
1558 };
1559 if cut {
1560 self.bump_content();
1561 }
1562 return EventState::Consumed;
1563 }
1564 _ => {}
1565 }
1566 }
1567
1568 let Some(ta) = self.backend.as_textarea_mut() else {
1569 unreachable!("handle_textarea_key called with non-Textarea backend")
1570 };
1571
1572 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1574 let handled = match (key.modifiers & !KeyModifiers::SHIFT, key.code) {
1575 (KeyModifiers::ALT, KeyCode::Left) => {
1576 cursor_move!(ta, CursorMove::WordBack, shift);
1577 true
1578 }
1579 (KeyModifiers::ALT, KeyCode::Right) => {
1580 cursor_move!(ta, CursorMove::WordForward, shift);
1581 true
1582 }
1583 (KeyModifiers::ALT, KeyCode::Char('b') | KeyCode::Char('B')) => {
1588 cursor_move!(ta, CursorMove::WordBack, shift);
1589 true
1590 }
1591 (KeyModifiers::ALT, KeyCode::Char('f') | KeyCode::Char('F')) => {
1592 cursor_move!(ta, CursorMove::WordForward, shift);
1593 true
1594 }
1595 (KeyModifiers::SUPER, KeyCode::Left) => {
1596 cursor_move!(ta, CursorMove::Head, shift);
1597 true
1598 }
1599 (KeyModifiers::SUPER, KeyCode::Right) => {
1600 cursor_move!(ta, CursorMove::End, shift);
1601 true
1602 }
1603 (KeyModifiers::SUPER, KeyCode::Up) => {
1604 cursor_move!(ta, CursorMove::Top, shift);
1605 true
1606 }
1607 (KeyModifiers::SUPER, KeyCode::Down) => {
1608 cursor_move!(ta, CursorMove::Bottom, shift);
1609 true
1610 }
1611 _ => false,
1612 };
1613 if handled {
1614 self.selection = ta.selection_range();
1615 self.bump_cursor();
1616 return EventState::Consumed;
1617 }
1618
1619 enum ShortcutOutcome {
1630 NoOp,
1631 CursorOnly,
1632 TextMutated,
1633 }
1634 let outcome: Option<ShortcutOutcome> =
1635 match (key.modifiers & !KeyModifiers::SHIFT, key.code) {
1636 (KeyModifiers::NONE, KeyCode::Left) => {
1638 cursor_move!(ta, CursorMove::Back, shift);
1639 Some(ShortcutOutcome::CursorOnly)
1640 }
1641 (KeyModifiers::NONE, KeyCode::Right) => {
1642 cursor_move!(ta, CursorMove::Forward, shift);
1643 Some(ShortcutOutcome::CursorOnly)
1644 }
1645 (KeyModifiers::NONE, KeyCode::Up) => {
1646 cursor_move!(ta, CursorMove::Up, shift);
1647 Some(ShortcutOutcome::CursorOnly)
1648 }
1649 (KeyModifiers::NONE, KeyCode::Down) => {
1650 cursor_move!(ta, CursorMove::Down, shift);
1651 Some(ShortcutOutcome::CursorOnly)
1652 }
1653 (KeyModifiers::NONE, KeyCode::Home) => {
1654 cursor_move!(ta, CursorMove::Head, shift);
1655 Some(ShortcutOutcome::CursorOnly)
1656 }
1657 (KeyModifiers::NONE, KeyCode::End) => {
1658 cursor_move!(ta, CursorMove::End, shift);
1659 Some(ShortcutOutcome::CursorOnly)
1660 }
1661 (KeyModifiers::NONE, KeyCode::PageUp) => {
1662 cursor_move!(ta, CursorMove::ParagraphBack, shift);
1663 Some(ShortcutOutcome::CursorOnly)
1664 }
1665 (KeyModifiers::NONE, KeyCode::PageDown) => {
1666 cursor_move!(ta, CursorMove::ParagraphForward, shift);
1667 Some(ShortcutOutcome::CursorOnly)
1668 }
1669 (KeyModifiers::CONTROL, KeyCode::Left) => {
1671 cursor_move!(ta, CursorMove::WordBack, shift);
1672 Some(ShortcutOutcome::CursorOnly)
1673 }
1674 (KeyModifiers::CONTROL, KeyCode::Right) => {
1675 cursor_move!(ta, CursorMove::WordForward, shift);
1676 Some(ShortcutOutcome::CursorOnly)
1677 }
1678 (KeyModifiers::CONTROL, KeyCode::Home) => {
1680 cursor_move!(ta, CursorMove::Top, shift);
1681 Some(ShortcutOutcome::CursorOnly)
1682 }
1683 (KeyModifiers::CONTROL, KeyCode::End) => {
1684 cursor_move!(ta, CursorMove::Bottom, shift);
1685 Some(ShortcutOutcome::CursorOnly)
1686 }
1687 (KeyModifiers::CONTROL, KeyCode::Char('z')) => {
1691 if ta.undo() {
1692 Some(ShortcutOutcome::TextMutated)
1693 } else {
1694 Some(ShortcutOutcome::NoOp)
1695 }
1696 }
1697 (KeyModifiers::CONTROL, KeyCode::Char('y'))
1698 | (KeyModifiers::CONTROL, KeyCode::Char('Z')) => {
1699 if ta.redo() {
1700 Some(ShortcutOutcome::TextMutated)
1701 } else {
1702 Some(ShortcutOutcome::NoOp)
1703 }
1704 }
1705 (KeyModifiers::CONTROL, KeyCode::Char('a')) => {
1707 ta.move_cursor(CursorMove::Top);
1708 ta.start_selection();
1709 ta.move_cursor(CursorMove::Bottom);
1710 Some(ShortcutOutcome::CursorOnly)
1711 }
1712 (KeyModifiers::CONTROL, KeyCode::Backspace)
1715 | (KeyModifiers::ALT, KeyCode::Backspace) => {
1716 if ta.delete_word() {
1717 Some(ShortcutOutcome::TextMutated)
1718 } else {
1719 Some(ShortcutOutcome::NoOp)
1720 }
1721 }
1722 (KeyModifiers::CONTROL, KeyCode::Delete) | (KeyModifiers::ALT, KeyCode::Delete) => {
1723 if ta.delete_next_word() {
1724 Some(ShortcutOutcome::TextMutated)
1725 } else {
1726 Some(ShortcutOutcome::NoOp)
1727 }
1728 }
1729 _ => None,
1730 };
1731 if let Some(kind) = outcome {
1732 self.selection = ta.selection_range();
1733 match kind {
1734 ShortcutOutcome::NoOp => {}
1735 ShortcutOutcome::CursorOnly => self.bump_cursor(),
1736 ShortcutOutcome::TextMutated => self.bump_content(),
1737 }
1738 return EventState::Consumed;
1739 }
1740
1741 match (key.modifiers, key.code) {
1743 (m, KeyCode::Tab)
1744 if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
1745 {
1746 self.indent_lines(m.contains(KeyModifiers::SHIFT));
1747 return EventState::Consumed;
1748 }
1749 (_, KeyCode::BackTab) => {
1750 self.indent_lines(true);
1751 return EventState::Consumed;
1752 }
1753 _ => {}
1754 }
1755 if key.code == KeyCode::Enter && key.modifiers.is_empty() && self.smart_enter() {
1756 return EventState::Consumed;
1757 }
1758
1759 if let KeyCode::Char(c) = key.code
1766 && (key.modifiers & !KeyModifiers::SHIFT).is_empty()
1767 && let Some((open, close)) = surround_pair(c)
1768 && self.wrap_selection(open, close)
1769 {
1770 return EventState::Consumed;
1771 }
1772
1773 let Some(ta) = self.backend.as_textarea_mut() else {
1774 unreachable!("handle_textarea_key called with non-Textarea backend")
1775 };
1776 let mutated = ta.input_without_shortcuts(*key);
1782 self.selection = ta.selection_range();
1783 if mutated {
1784 self.bump_content();
1785 } else {
1786 self.bump_cursor();
1787 }
1788 EventState::Consumed
1789 }
1790
1791 fn handle_mouse(&mut self, mouse: &ratatui::crossterm::event::MouseEvent) -> EventState {
1793 let r = &self.rect;
1794 let in_bounds = mouse.column >= r.x
1795 && mouse.column < r.x + r.width
1796 && mouse.row >= r.y
1797 && mouse.row < r.y + r.height;
1798 if !in_bounds {
1799 return EventState::NotConsumed;
1800 }
1801 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right))
1805 && self.selection.is_none_or(|(start, end)| start == end)
1806 {
1807 self.wants_context_menu = true;
1808 return EventState::Consumed;
1809 }
1810 if !self.backend.is_textarea() {
1814 return EventState::NotConsumed;
1815 }
1816 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) {
1818 self.copy_selection_to_clipboard();
1819 self.selection = if let Some(ta) = self.backend.as_textarea() {
1820 ta.selection_range()
1821 } else {
1822 None
1823 };
1824 self.bump_cursor();
1825 return EventState::Consumed;
1826 }
1827 let Some(ta) = self.backend.as_textarea_mut() else {
1829 unreachable!()
1830 };
1831 match mouse.kind {
1832 MouseEventKind::Down(_) => {
1833 ta.cancel_selection();
1834 let (lrow, lcol) = self
1835 .view
1836 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1837 ta.move_cursor(CursorMove::Jump(lrow, lcol));
1838 ta.start_selection();
1839 }
1840 MouseEventKind::Drag(_) => {
1841 let (lrow, lcol) = self
1842 .view
1843 .click_at_screen((mouse.row - r.y) as usize, (mouse.column - r.x) as usize);
1844 ta.move_cursor(CursorMove::Jump(lrow, lcol));
1845 }
1846 _ => {
1847 ta.input(*mouse);
1848 }
1849 }
1850 self.selection = ta.selection_range();
1851 self.bump_cursor();
1854 EventState::Consumed
1855 }
1856}
1857
1858fn paint_viewport_extras(
1863 buf: &mut ratatui::buffer::Buffer,
1864 area: Rect,
1865 needles: &[String],
1866 theme: &Theme,
1867) {
1868 use ratatui::layout::Position;
1869 let match_fg = theme.color_search_match.to_ratatui();
1870 let checkbox_fg = theme.accent.to_ratatui();
1871
1872 for y in area.y..area.bottom() {
1873 if needles.is_empty() {
1878 let mut lead = String::new();
1879 for x in area.x..area.right().min(area.x + 16) {
1880 if let Some(cell) = buf.cell(Position::new(x, y)) {
1881 lead.push_str(cell.symbol());
1882 }
1883 }
1884 if !lead.trim_start().starts_with("- [") {
1885 continue;
1886 }
1887 }
1888 let mut row_text = String::new();
1891 let mut byte_to_col: Vec<(usize, u16)> = Vec::new();
1892 for x in area.x..area.right() {
1893 let Some(cell) = buf.cell(Position::new(x, y)) else {
1894 continue;
1895 };
1896 let sym = cell.symbol();
1897 if sym.is_empty() {
1898 continue;
1899 }
1900 byte_to_col.push((row_text.len(), x));
1901 row_text.push_str(sym);
1902 }
1903 if row_text.trim().is_empty() {
1904 continue;
1905 }
1906 let lower = row_text.to_lowercase();
1907 let fold_safe = lower.len() == row_text.len();
1908
1909 let mut restyle =
1910 |from_byte: usize, to_byte: usize, f: &mut dyn FnMut(&mut ratatui::buffer::Cell)| {
1911 for (b, x) in &byte_to_col {
1912 if *b >= from_byte
1913 && *b < to_byte
1914 && let Some(cell) = buf.cell_mut(Position::new(*x, y))
1915 {
1916 f(cell);
1917 }
1918 }
1919 };
1920
1921 let trimmed_start = row_text.len() - row_text.trim_start().len();
1923 let after_indent = &row_text[trimmed_start..];
1924 let is_done = after_indent.starts_with("- [x] ") || after_indent.starts_with("- [X] ");
1925 let is_open = after_indent.starts_with("- [ ] ");
1926 if is_done || is_open {
1927 let box_start = trimmed_start + 2;
1928 let box_end = box_start + 3;
1929 restyle(box_start, box_end, &mut |cell| {
1930 cell.set_fg(checkbox_fg);
1931 });
1932 if is_done {
1933 restyle(box_end, row_text.len(), &mut |cell| {
1934 let style = cell
1935 .style()
1936 .add_modifier(Modifier::DIM | Modifier::CROSSED_OUT);
1937 cell.set_style(style);
1938 });
1939 }
1940 }
1941
1942 if fold_safe {
1944 for needle in needles {
1945 for (start, m) in lower.match_indices(needle.as_str()) {
1946 restyle(start, start + m.len(), &mut |cell| {
1947 let style = cell.style().fg(match_fg).add_modifier(Modifier::BOLD);
1948 cell.set_style(style);
1949 });
1950 }
1951 }
1952 }
1953 }
1954}
1955
1956impl Component for TextEditorComponent {
1957 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
1958 self.maybe_recover_from_dead_nvim();
1959 self.bind_autocomplete_redraw(tx);
1960
1961 match event {
1962 InputEvent::Key(key) => {
1963 let popup_open = self.autocomplete.as_ref().is_some_and(|c| c.is_open());
1971 if popup_open
1972 && let Some(host) = build_editor_host_snapshot(
1973 &self.backend,
1974 self.content_revision,
1975 self.view.last_cursor_screen,
1976 )
1977 && let Some(controller) = self.autocomplete.as_mut()
1978 {
1979 match controller.handle_key(*key, &host) {
1980 HandleKeyOutcome::Accepted(action) => {
1981 if let Some(ta) = self.backend.as_textarea_mut() {
1982 apply_accept_to_textarea(ta, &action);
1983 self.selection = ta.selection_range();
1984 }
1985 self.bump_content();
1986 return EventState::Consumed;
1987 }
1988 HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
1989 return EventState::Consumed;
1990 }
1991 HandleKeyOutcome::NotHandled => {}
1992 }
1993 }
1994 if let Some(state) = self.handle_nvim_key(key, tx) {
1995 return state;
1996 }
1997 let text_rev_before = self.content_revision;
2008 let cursor_before = self.textarea_cursor();
2009 let result = self.handle_textarea_key(key, tx);
2010 let cursor_after = self.textarea_cursor();
2011 if self.content_revision != text_rev_before {
2012 self.sync_autocomplete();
2013 } else if cursor_before != cursor_after {
2014 self.refresh_autocomplete_if_open();
2015 }
2016 result
2017 }
2018 InputEvent::Mouse(mouse) => {
2019 let text_rev_before = self.content_revision;
2020 let cursor_before = self.textarea_cursor();
2021 let result = self.handle_mouse(mouse);
2022 let cursor_after = self.textarea_cursor();
2023 if self.content_revision != text_rev_before {
2026 self.sync_autocomplete();
2027 } else if cursor_before != cursor_after {
2028 self.refresh_autocomplete_if_open();
2029 }
2030 if result == EventState::Consumed
2035 && matches!(
2036 mouse.kind,
2037 ratatui::crossterm::event::MouseEventKind::Down(
2038 ratatui::crossterm::event::MouseButton::Left
2039 )
2040 )
2041 {
2042 match self.link_at_cursor() {
2043 Some(LinkTarget::Note(target)) => {
2044 tx.send(AppEvent::FollowLink(target)).ok();
2045 }
2046 Some(LinkTarget::Label(name)) => {
2047 tx.send(AppEvent::FollowLabel(name)).ok();
2048 }
2049 None => {}
2050 }
2051 }
2052 result
2053 }
2054 InputEvent::Paste(_) => EventState::NotConsumed,
2057 }
2058 }
2059
2060 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
2061 let (editor_rect, search_rect) = if self.search.is_some() && rect.height > 1 {
2063 (
2064 Rect {
2065 height: rect.height - 1,
2066 ..rect
2067 },
2068 Some(Rect {
2069 y: rect.y + rect.height - 1,
2070 height: 1,
2071 ..rect
2072 }),
2073 )
2074 } else {
2075 (rect, None)
2076 };
2077 self.rect = editor_rect;
2080 let (selection, nvim_rev_to_mirror) = match &self.backend {
2085 BackendState::Textarea(_) => (self.selection, None),
2086 BackendState::Nvim(nvim) => {
2087 nvim.maybe_resize(editor_rect.width, editor_rect.height);
2088 let snap = nvim.snapshot();
2089 let visual_selection = snap.visual_selection;
2090 let content_gen = snap.content_gen;
2091 drop(snap);
2092 let rev = NonZeroU64::new(content_gen.saturating_add(1));
2101 (visual_selection, rev)
2102 }
2103 };
2104 if let Some(rev) = nvim_rev_to_mirror {
2105 self.content_revision = rev;
2106 }
2107 while let Ok((generation, buf)) = self.full_parse_rx.try_recv() {
2113 self.view.install_full_parse(generation, buf);
2114 }
2115
2116 let snap = snapshot_from_backend(&self.backend, self.content_revision);
2121 self.view.update(&snap, editor_rect, selection);
2122
2123 if let Some(generation) = self.view.take_pending_full_parse() {
2131 let lines: Vec<String> = snap.lines.iter().cloned().collect();
2132 let tx = self.full_parse_tx.clone();
2133 let redraw = self.redraw_tx.clone();
2134 self.full_parse_task.spawn(async move {
2135 let buf = ParsedBuffer::parse(&lines);
2136 let _ = tx.send((generation, buf));
2137 if let Some(redraw) = redraw {
2140 let _ = redraw.send(AppEvent::Redraw);
2141 }
2142 });
2143 }
2144 let bar_focused = self.search.is_some() && focused;
2147 let editor_focused = focused && !bar_focused;
2148 self.view.render(f, editor_rect, theme, editor_focused);
2149
2150 if self
2154 .needles_revision
2155 .is_some_and(|r| r != self.content_revision)
2156 {
2157 self.search_needles.clear();
2158 self.needles_revision = None;
2159 }
2160 let mut emphasis_needles = self.search_needles.clone();
2161 if let Some(state) = &self.search {
2162 let q = state.input.value().trim().to_lowercase();
2163 if !q.is_empty() {
2164 emphasis_needles.push(q);
2165 }
2166 }
2167 paint_viewport_extras(f.buffer_mut(), editor_rect, &emphasis_needles, theme);
2168
2169 if snap.lines.iter().all(|l| l.is_empty()) && editor_rect.height > 0 {
2173 let leader = self
2174 .key_bindings
2175 .first_combo_for(&crate::keys::action_shortcuts::ActionShortcuts::Leader)
2176 .unwrap_or_else(|| "leader".to_string());
2177 f.render_widget(
2178 ratatui::widgets::Paragraph::new(format!(
2179 "Type to start · [[ to link · # to tag · {leader} for commands"
2180 ))
2181 .style(
2182 Style::default()
2183 .fg(theme.gray.to_ratatui())
2184 .add_modifier(Modifier::ITALIC),
2185 ),
2186 Rect {
2187 x: editor_rect.x.saturating_add(2),
2188 width: editor_rect.width.saturating_sub(2),
2189 height: 1,
2190 ..editor_rect
2191 },
2192 );
2193 }
2194 if let (Some(state), Some(bar_rect)) = (self.search.as_mut(), search_rect) {
2195 render_search_bar(f, bar_rect, state, theme, bar_focused);
2196 }
2197
2198 self.poll_autocomplete();
2206 if let (Some(controller), Some(live_anchor)) =
2213 (self.autocomplete.as_mut(), self.view.last_cursor_screen)
2214 {
2215 if let Some(state) = controller.state_mut() {
2216 state.anchor = live_anchor;
2217 }
2218 if let Some(state) = controller.state() {
2219 autocomplete::render(f, state, editor_rect, theme);
2220 }
2221 }
2222 }
2223
2224 fn hint_shortcuts(&self) -> Vec<(String, String)> {
2225 use crate::keys::action_shortcuts::ActionShortcuts;
2226
2227 if let Some(nvim) = self.backend.as_nvim() {
2229 let label = nvim.snapshot().footer_label();
2230 let mut hints = vec![(String::new(), label)];
2231 hints.extend(
2232 [
2233 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2234 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2235 (ActionShortcuts::FileOperations, "file ops"),
2236 ]
2237 .iter()
2238 .filter_map(|(action, label)| {
2239 self.key_bindings
2240 .first_combo_for(action)
2241 .map(|k| (k, label.to_string()))
2242 }),
2243 );
2244 return hints;
2245 }
2246
2247 let mut hints: Vec<(String, String)> = Vec::new();
2250 match self.link_at_cursor() {
2251 Some(LinkTarget::Note(_)) => {
2252 if let Some(k) = self
2253 .key_bindings
2254 .first_combo_for(&ActionShortcuts::FollowLink)
2255 {
2256 hints.push((k, "follow link".to_string()));
2257 }
2258 }
2259 Some(LinkTarget::Label(_)) => {
2260 if let Some(k) = self
2261 .key_bindings
2262 .first_combo_for(&ActionShortcuts::FollowLink)
2263 {
2264 hints.push((k, "browse tag".to_string()));
2265 }
2266 }
2267 None => {}
2268 }
2269 hints.extend(crate::components::hints::hints_for(
2270 &self.key_bindings,
2271 &[
2272 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
2273 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
2274 (ActionShortcuts::FileOperations, "file ops"),
2275 (ActionShortcuts::FindInBuffer, "find"),
2276 ],
2277 ));
2278 hints
2279 }
2280}
2281
2282#[cfg(test)]
2283mod tests {
2284 use super::*;
2285 use crate::keys::KeyBindings;
2286
2287 fn make_editor() -> TextEditorComponent {
2288 TextEditorComponent::new(
2289 KeyBindings::empty(),
2290 &crate::settings::AppSettings::default(),
2291 )
2292 }
2293
2294 fn dummy_tx() -> AppTx {
2295 tokio::sync::mpsc::unbounded_channel().0
2296 }
2297
2298 fn get_ta(editor: &mut TextEditorComponent) -> &mut TextArea<'static> {
2299 match &mut editor.backend {
2300 BackendState::Textarea(ta) => ta,
2301 _ => panic!("expected Textarea backend"),
2302 }
2303 }
2304
2305 #[test]
2306 fn has_trigger_before_cursor_finds_bracket() {
2307 assert!(has_trigger_before_cursor("hello [[foo", 11));
2308 assert!(has_trigger_before_cursor("[[a b c", 7));
2309 }
2310
2311 #[test]
2312 fn has_trigger_before_cursor_finds_hashtag() {
2313 assert!(has_trigger_before_cursor("text #tag", 9));
2314 }
2315
2316 #[test]
2317 fn has_trigger_before_cursor_no_trigger_bails() {
2318 assert!(!has_trigger_before_cursor("plain prose here", 16));
2319 assert!(!has_trigger_before_cursor("", 0));
2320 }
2321
2322 #[test]
2323 fn has_trigger_before_cursor_handles_multibyte_no_panic() {
2324 let line = "你好世界".to_string() + &"a".repeat(80);
2327 let col = line.chars().count();
2328 assert!(!has_trigger_before_cursor(&line, col));
2329
2330 let with_emoji = "🦀".repeat(20) + "[[note";
2331 let col = with_emoji.chars().count();
2332 assert!(has_trigger_before_cursor(&with_emoji, col));
2333
2334 let accented = "é".repeat(100);
2335 let col = accented.chars().count();
2336 assert!(!has_trigger_before_cursor(&accented, col));
2337 }
2338
2339 #[test]
2340 fn has_trigger_before_cursor_ignores_chars_after_cursor() {
2341 assert!(!has_trigger_before_cursor("foo [[bar", 3));
2343 }
2344
2345 #[test]
2346 fn has_trigger_before_cursor_wikilink_with_spaces() {
2347 assert!(has_trigger_before_cursor("[[my note title", 15));
2350 }
2351
2352 #[test]
2353 fn fresh_editor_is_not_dirty() {
2354 let editor = make_editor();
2355 assert!(!editor.is_dirty());
2356 }
2357
2358 #[test]
2359 fn after_set_text_not_dirty() {
2360 let mut editor = make_editor();
2361 editor.set_text("hello world".to_string());
2362 assert!(!editor.is_dirty());
2363 }
2364
2365 #[test]
2366 fn get_text_returns_loaded_content() {
2367 let mut editor = make_editor();
2368 editor.set_text("line one\nline two".to_string());
2369 assert_eq!(editor.get_text(), "line one\nline two");
2370 }
2371
2372 #[test]
2373 fn mark_saved_clears_dirty() {
2374 let mut editor = make_editor();
2375 editor.set_text("initial".to_string());
2376 let text = editor.get_text();
2377 editor.mark_saved(text.clone() + "x"); assert!(editor.is_dirty());
2379 editor.mark_saved(text); assert!(!editor.is_dirty());
2381 }
2382
2383 #[test]
2384 fn trailing_newline_does_not_cause_false_dirty() {
2385 let mut editor = make_editor();
2386 editor.set_text("content\n".to_string());
2387 assert!(
2388 !editor.is_dirty(),
2389 "trailing newline should not make editor dirty after load"
2390 );
2391 }
2392
2393 #[test]
2394 fn cursor_move_does_not_dirty_buffer() {
2395 let mut editor = make_editor();
2396 editor.set_text("hello world".to_string());
2397 assert!(!editor.is_dirty());
2398 let tx = dummy_tx();
2399 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
2403 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2404 assert!(
2405 !editor.is_dirty(),
2406 "cursor move must not mark the editor as dirty"
2407 );
2408 }
2409
2410 #[test]
2411 fn empty_stack_undo_redo_does_not_dirty_or_bump_revision() {
2412 let mut editor = make_editor();
2416 editor.set_text("foo".to_string());
2417 let rev_before = editor.content_revision();
2418 assert!(!editor.is_dirty());
2419 let tx = dummy_tx();
2420 for key_code in [KeyCode::Char('z'), KeyCode::Char('y')] {
2421 let key = ratatui::crossterm::event::KeyEvent::new(key_code, KeyModifiers::CONTROL);
2422 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2423 }
2424 assert!(
2425 !editor.is_dirty(),
2426 "empty-stack undo/redo must not flip is_dirty"
2427 );
2428 assert_eq!(
2429 editor.content_revision(),
2430 rev_before,
2431 "empty-stack undo/redo must not bump content_revision"
2432 );
2433 }
2434
2435 #[test]
2436 fn fresh_editor_content_revision_is_nonzero() {
2437 let editor = make_editor();
2444 assert!(editor.content_revision().get() >= 1);
2445 }
2446
2447 #[test]
2448 fn mouse_down_clears_selection() {
2449 let mut editor = make_editor();
2450 editor.set_text("hello world".to_string());
2451 let ta = get_ta(&mut editor);
2452 ta.start_selection();
2453 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2454 assert!(ta.selection_range().is_some());
2455 ta.cancel_selection();
2456 editor.selection = if let BackendState::Textarea(ta) = &editor.backend {
2457 ta.selection_range()
2458 } else {
2459 None
2460 };
2461 assert!(editor.selection.is_none());
2462 }
2463
2464 #[test]
2465 fn ctrl_c_copies_selected_text() {
2466 let mut editor = make_editor();
2467 editor.set_text("hello world".to_string());
2468 let ta = get_ta(&mut editor);
2469 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2470 ta.start_selection();
2471 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2472 let range = ta.selection_range().unwrap();
2473 let ((sr, sc), (er, ec)) = range;
2474 let lines = ta.lines();
2475 let selected = if sr == er {
2476 lines[sr][sc..ec].to_string()
2477 } else {
2478 lines[sr][sc..].to_string()
2479 };
2480 assert_eq!(selected, "hello ");
2481 }
2482
2483 fn select_range(editor: &mut TextEditorComponent, start: (u16, u16), end: (u16, u16)) {
2485 let ta = get_ta(editor);
2486 ta.cancel_selection();
2487 ta.move_cursor(CursorMove::Jump(start.0, start.1));
2488 ta.start_selection();
2489 ta.move_cursor(CursorMove::Jump(end.0, end.1));
2490 assert!(ta.selection_range().is_some());
2491 }
2492
2493 fn send_char(editor: &mut TextEditorComponent, c: char) {
2494 let tx = dummy_tx();
2495 let key = ratatui::crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
2496 let _ = editor.handle_input(&InputEvent::Key(key), &tx);
2497 }
2498
2499 #[test]
2500 fn surround_pair_maps_open_and_symmetric_chars() {
2501 assert_eq!(surround_pair('('), Some(("(", ")")));
2502 assert_eq!(surround_pair('['), Some(("[", "]")));
2503 assert_eq!(surround_pair('{'), Some(("{", "}")));
2504 assert_eq!(surround_pair('<'), Some(("<", ">")));
2505 assert_eq!(surround_pair('"'), Some(("\"", "\"")));
2506 assert_eq!(surround_pair('\''), Some(("'", "'")));
2507 assert_eq!(surround_pair('`'), Some(("`", "`")));
2508 assert_eq!(surround_pair('*'), Some(("*", "*")));
2509 assert_eq!(surround_pair('_'), Some(("_", "_")));
2510 assert_eq!(surround_pair('~'), Some(("~", "~")));
2511 assert_eq!(surround_pair(')'), None);
2513 assert_eq!(surround_pair(']'), None);
2514 assert_eq!(surround_pair('}'), None);
2515 assert_eq!(surround_pair('>'), None);
2516 assert_eq!(surround_pair('a'), None);
2517 }
2518
2519 #[test]
2520 fn typing_open_paren_with_selection_wraps_it() {
2521 let mut editor = make_editor();
2522 editor.set_text("hello world".to_string());
2523 select_range(&mut editor, (0, 0), (0, 5)); send_char(&mut editor, '(');
2525 assert_eq!(editor.get_text(), "(hello) world");
2526 assert!(editor.is_dirty(), "wrap must mark the buffer dirty");
2527 }
2528
2529 #[test]
2530 fn wrap_keeps_selection_on_inner_text() {
2531 let mut editor = make_editor();
2532 editor.set_text("hello world".to_string());
2533 select_range(&mut editor, (0, 0), (0, 5));
2534 send_char(&mut editor, '(');
2535 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2537 }
2538
2539 #[test]
2540 fn chained_brackets_build_a_wikilink() {
2541 let mut editor = make_editor();
2542 editor.set_text("my note".to_string());
2543 select_range(&mut editor, (0, 0), (0, 7));
2544 send_char(&mut editor, '[');
2545 send_char(&mut editor, '[');
2546 assert_eq!(editor.get_text(), "[[my note]]");
2547 assert_eq!(editor.selection, Some(((0, 2), (0, 9))));
2548 }
2549
2550 #[test]
2551 fn symmetric_chars_wrap_and_chain() {
2552 let mut editor = make_editor();
2553 editor.set_text("bold".to_string());
2554 select_range(&mut editor, (0, 0), (0, 4));
2555 send_char(&mut editor, '*');
2556 assert_eq!(editor.get_text(), "*bold*");
2557 send_char(&mut editor, '*');
2558 assert_eq!(editor.get_text(), "**bold**");
2559 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2560 }
2561
2562 #[test]
2563 fn closing_char_replaces_selection() {
2564 let mut editor = make_editor();
2565 editor.set_text("hello world".to_string());
2566 select_range(&mut editor, (0, 0), (0, 5));
2567 send_char(&mut editor, ')');
2568 assert_eq!(editor.get_text(), ") world");
2569 }
2570
2571 #[test]
2572 fn open_char_without_selection_inserts_normally() {
2573 let mut editor = make_editor();
2574 editor.set_text("hello".to_string());
2575 let ta = get_ta(&mut editor);
2576 ta.move_cursor(CursorMove::End);
2577 send_char(&mut editor, '(');
2578 assert_eq!(editor.get_text(), "hello(");
2579 }
2580
2581 #[test]
2582 fn wrap_spans_multiline_selection() {
2583 let mut editor = make_editor();
2584 editor.set_text("abc\ndef".to_string());
2585 select_range(&mut editor, (0, 0), (1, 3));
2586 send_char(&mut editor, '(');
2587 assert_eq!(editor.get_text(), "(abc\ndef)");
2588 assert_eq!(editor.selection, Some(((0, 1), (1, 3))));
2590 }
2591
2592 #[test]
2593 fn wrap_handles_multibyte_selection() {
2594 let mut editor = make_editor();
2595 editor.set_text("héllo🦀 x".to_string());
2596 select_range(&mut editor, (0, 0), (0, 6)); send_char(&mut editor, '`');
2598 assert_eq!(editor.get_text(), "`héllo🦀` x");
2599 assert_eq!(editor.selection, Some(((0, 1), (0, 7))));
2600 }
2601
2602 #[test]
2603 fn wrap_with_reversed_selection_direction() {
2604 let mut editor = make_editor();
2606 editor.set_text("hello world".to_string());
2607 select_range(&mut editor, (0, 5), (0, 0));
2608 send_char(&mut editor, '(');
2609 assert_eq!(editor.get_text(), "(hello) world");
2610 assert_eq!(editor.selection, Some(((0, 1), (0, 6))));
2611 }
2612
2613 #[test]
2614 fn text_action_keeps_selection_on_inner_text() {
2615 let mut editor = make_editor();
2618 editor.set_text("bold word".to_string());
2619 select_range(&mut editor, (0, 0), (0, 4));
2620 editor.apply_text_action(TextAction::Bold);
2621 assert_eq!(editor.get_text(), "**bold** word");
2622 assert_eq!(editor.selection, Some(((0, 2), (0, 6))));
2623 }
2624
2625 #[test]
2626 fn wrap_undo_is_two_steps_back_to_original() {
2627 let mut editor = make_editor();
2631 editor.set_text("hello world".to_string());
2632 select_range(&mut editor, (0, 0), (0, 5));
2633 send_char(&mut editor, '(');
2634 assert_eq!(editor.get_text(), "(hello) world");
2635 let ta = get_ta(&mut editor);
2636 ta.undo();
2637 ta.undo();
2638 assert_eq!(editor.get_text(), "hello world");
2639 }
2640
2641 #[test]
2642 fn linkable_url_accepts_supported_schemes() {
2643 assert_eq!(
2644 linkable_url("https://example.com"),
2645 Some("https://example.com")
2646 );
2647 assert_eq!(
2648 linkable_url("http://example.com/path?q=1#frag"),
2649 Some("http://example.com/path?q=1#frag"),
2650 );
2651 assert_eq!(
2652 linkable_url(" https://example.com "),
2653 Some("https://example.com")
2654 );
2655 assert_eq!(
2656 linkable_url("ftp://files.example.com/x"),
2657 Some("ftp://files.example.com/x"),
2658 );
2659 assert_eq!(
2660 linkable_url("ftps://files.example.com/x"),
2661 Some("ftps://files.example.com/x"),
2662 );
2663 assert_eq!(
2664 linkable_url("mailto:user@example.com"),
2665 Some("mailto:user@example.com"),
2666 );
2667 assert_eq!(
2668 linkable_url("mailto:user@example.com?subject=hi"),
2669 Some("mailto:user@example.com?subject=hi"),
2670 );
2671 }
2672
2673 #[test]
2674 fn linkable_url_rejects_other_schemes_and_plain_text() {
2675 assert_eq!(linkable_url("file:///etc/passwd"), None);
2676 assert_eq!(linkable_url("ssh://host"), None);
2677 assert_eq!(linkable_url("javascript:alert(1)"), None);
2678 assert_eq!(linkable_url("example.com"), None);
2679 assert_eq!(linkable_url("not a url"), None);
2680 assert_eq!(linkable_url(""), None);
2681 assert_eq!(linkable_url("https://example.com\nmore"), None);
2682 }
2683
2684 #[test]
2685 fn try_build_markdown_link_wraps_selection_when_clip_is_url() {
2686 assert_eq!(
2687 try_build_markdown_link("https://example.com", Some("click here")).as_deref(),
2688 Some("[click here](https://example.com)"),
2689 );
2690 }
2691
2692 #[test]
2693 fn try_build_markdown_link_trims_url_whitespace() {
2694 assert_eq!(
2695 try_build_markdown_link(" https://example.com\n", Some("link")).as_deref(),
2696 Some("[link](https://example.com)"),
2697 );
2698 }
2699
2700 #[test]
2701 fn try_build_markdown_link_returns_none_when_no_selection() {
2702 assert_eq!(try_build_markdown_link("https://example.com", None), None);
2703 }
2704
2705 #[test]
2706 fn try_build_markdown_link_returns_none_when_not_url() {
2707 assert_eq!(try_build_markdown_link("plain text", Some("sel")), None);
2708 }
2709
2710 #[test]
2711 fn try_build_markdown_link_returns_none_when_selection_empty() {
2712 assert_eq!(
2713 try_build_markdown_link("https://example.com", Some("")),
2714 None
2715 );
2716 }
2717
2718 #[test]
2719 fn try_build_markdown_link_escapes_close_bracket_in_selection() {
2720 assert_eq!(
2721 try_build_markdown_link("https://example.com", Some("a]b")).as_deref(),
2722 Some(r"[a\]b](https://example.com)"),
2723 );
2724 }
2725
2726 #[test]
2727 fn try_build_markdown_link_wraps_ftp_url() {
2728 assert_eq!(
2729 try_build_markdown_link("ftp://files.example.com/x", Some("download")).as_deref(),
2730 Some("[download](ftp://files.example.com/x)"),
2731 );
2732 }
2733
2734 fn key(code: KeyCode, mods: KeyModifiers) -> ratatui::crossterm::event::KeyEvent {
2735 ratatui::crossterm::event::KeyEvent::new(code, mods)
2736 }
2737
2738 #[test]
2740 fn paint_viewport_extras_emphasizes_needles_and_tasks() {
2741 use ratatui::buffer::Buffer;
2742 use ratatui::layout::Position;
2743 let theme = crate::settings::themes::Theme::default();
2744 let area = Rect::new(0, 0, 30, 3);
2745 let mut buf = Buffer::empty(area);
2746 buf.set_string(0, 0, "find the needle here", Style::default());
2747 buf.set_string(0, 1, "- [x] done task", Style::default());
2748 buf.set_string(0, 2, "- [ ] open task", Style::default());
2749
2750 paint_viewport_extras(&mut buf, area, &["needle".to_string()], &theme);
2751
2752 let cell = buf.cell(Position::new(9, 0)).unwrap();
2754 assert_eq!(cell.fg, theme.color_search_match.to_ratatui());
2755 assert!(cell.style().add_modifier.contains(Modifier::BOLD));
2756 let cell = buf.cell(Position::new(8, 1)).unwrap();
2758 assert!(cell.style().add_modifier.contains(Modifier::CROSSED_OUT));
2759 let cell = buf.cell(Position::new(8, 2)).unwrap();
2761 assert!(!cell.style().add_modifier.contains(Modifier::CROSSED_OUT));
2762 let cb = buf.cell(Position::new(3, 2)).unwrap();
2763 assert_eq!(cb.fg, theme.accent.to_ratatui());
2764 }
2765
2766 #[test]
2768 fn search_needles_clear_on_edit() {
2769 let settings = crate::settings::AppSettings::default();
2770 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2771 ed.set_text("alpha beta".to_string());
2772 ed.set_search_needles(vec!["Alpha".to_string()]);
2773 assert_eq!(ed.search_needles, vec!["alpha"]);
2774 assert_eq!(ed.needles_revision, Some(ed.content_revision));
2775
2776 ed.set_text("alpha beta gamma".to_string());
2778 assert_ne!(ed.needles_revision, Some(ed.content_revision));
2779 }
2780
2781 #[test]
2782 fn jump_to_heading_moves_cursor_to_heading_line() {
2783 let settings = crate::settings::AppSettings::default();
2784 let mut ed = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
2785 ed.set_text("intro\n# Top\nbody\n## Sub One\nmore\n".to_string());
2786
2787 ed.jump_to_heading("Sub One");
2788 assert_eq!(ed.view_snapshot().cursor.0, 3);
2789
2790 ed.jump_to_heading("Top");
2791 assert_eq!(ed.view_snapshot().cursor.0, 1);
2792
2793 ed.jump_to_heading("Nope");
2795 assert_eq!(ed.view_snapshot().cursor.0, 1);
2796 }
2797
2798 #[test]
2799 fn open_or_advance_search_opens_find_bar_with_empty_query() {
2800 let mut editor = make_editor();
2801 editor.set_text("hello world".to_string());
2802 editor.open_or_advance_search();
2803 let state = editor.search.as_ref().expect("find bar opened");
2804 assert!(state.input.is_empty());
2805 assert!(matches!(state.status, SearchStatus::Empty));
2806 }
2807
2808 #[test]
2809 fn open_or_advance_search_advances_when_already_open() {
2810 let mut editor = make_editor();
2811 editor.set_text("ab ab ab".to_string());
2812 let tx = dummy_tx();
2813 editor.open_or_advance_search();
2814 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2815 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2816 editor.open_or_advance_search();
2818 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2819 assert_eq!(col, 3, "second invocation advances to next match");
2820 }
2821
2822 #[test]
2823 fn typing_in_find_bar_jumps_cursor_to_first_match() {
2824 let mut editor = make_editor();
2825 editor.set_text("foo bar baz".to_string());
2826 let tx = dummy_tx();
2827 editor.open_or_advance_search();
2828 for ch in ['b', 'a', 'r'] {
2829 editor.handle_textarea_key(&key(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
2830 }
2831 let state = editor.search.as_ref().unwrap();
2832 assert_eq!(state.input.value(), "bar");
2833 assert!(matches!(state.status, SearchStatus::Match));
2834 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2835 assert_eq!(col, 4, "cursor jumped to start of 'bar'");
2836 }
2837
2838 #[test]
2839 fn enter_in_find_bar_advances_to_next_match() {
2840 let mut editor = make_editor();
2841 editor.set_text("ab ab ab".to_string());
2842 let tx = dummy_tx();
2843 editor.open_or_advance_search();
2844 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2845 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2846 editor.handle_textarea_key(&key(KeyCode::Enter, KeyModifiers::NONE), &tx);
2848 let DataCursor(_, col) = get_ta(&mut editor).cursor();
2849 assert_eq!(col, 3, "Enter advances to second match");
2850 }
2851
2852 #[test]
2853 fn match_is_highlighted_as_selection_after_search() {
2854 let mut editor = make_editor();
2855 editor.set_text("foo bar baz".to_string());
2856 let tx = dummy_tx();
2857 editor.open_or_advance_search();
2858 for ch in ['b', 'a', 'r'] {
2859 editor.handle_textarea_key(&key(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
2860 }
2861 assert_eq!(editor.selection, Some(((0, 4), (0, 7))));
2863 }
2864
2865 #[test]
2866 fn no_match_clears_selection() {
2867 let mut editor = make_editor();
2868 editor.set_text("hello".to_string());
2869 let tx = dummy_tx();
2870 editor.open_or_advance_search();
2871 editor.handle_textarea_key(&key(KeyCode::Char('z'), KeyModifiers::NONE), &tx);
2872 assert_eq!(editor.selection, None);
2873 }
2874
2875 #[test]
2876 fn esc_in_find_bar_clears_selection_highlight() {
2877 let mut editor = make_editor();
2878 editor.set_text("foo bar".to_string());
2879 let tx = dummy_tx();
2880 editor.open_or_advance_search();
2881 editor.handle_textarea_key(&key(KeyCode::Char('b'), KeyModifiers::NONE), &tx);
2882 editor.handle_textarea_key(&key(KeyCode::Char('a'), KeyModifiers::NONE), &tx);
2883 editor.handle_textarea_key(&key(KeyCode::Char('r'), KeyModifiers::NONE), &tx);
2884 assert!(editor.selection.is_some());
2885 editor.handle_textarea_key(&key(KeyCode::Esc, KeyModifiers::NONE), &tx);
2886 assert!(editor.selection.is_none());
2887 }
2888
2889 #[test]
2890 fn esc_in_find_bar_closes_it() {
2891 let mut editor = make_editor();
2892 editor.set_text("hello".to_string());
2893 let tx = dummy_tx();
2894 editor.open_or_advance_search();
2895 assert!(editor.search.is_some());
2896 editor.handle_textarea_key(&key(KeyCode::Esc, KeyModifiers::NONE), &tx);
2897 assert!(editor.search.is_none());
2898 }
2899
2900 #[test]
2901 fn find_bar_consumes_typing_so_editor_text_is_unchanged() {
2902 let mut editor = make_editor();
2903 editor.set_text("hello".to_string());
2904 let tx = dummy_tx();
2905 editor.open_or_advance_search();
2906 editor.handle_textarea_key(&key(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
2907 assert_eq!(editor.get_text(), "hello");
2908 }
2909
2910 #[test]
2911 fn no_match_status_when_query_absent() {
2912 let mut editor = make_editor();
2913 editor.set_text("hello".to_string());
2914 let tx = dummy_tx();
2915 editor.open_or_advance_search();
2916 editor.handle_textarea_key(&key(KeyCode::Char('z'), KeyModifiers::NONE), &tx);
2917 let state = editor.search.as_ref().unwrap();
2918 assert!(matches!(state.status, SearchStatus::NoMatch));
2919 }
2920
2921 #[test]
2922 fn try_build_markdown_link_wraps_mailto_url() {
2923 assert_eq!(
2924 try_build_markdown_link("mailto:user@example.com", Some("email me")).as_deref(),
2925 Some("[email me](mailto:user@example.com)"),
2926 );
2927 }
2928
2929 #[test]
2930 fn insert_at_cursor_appends_text() {
2931 let mut editor = make_editor();
2932 editor.set_text("hello".to_string());
2933 {
2934 let ta = get_ta(&mut editor);
2935 ta.move_cursor(ratatui_textarea::CursorMove::End);
2936 }
2937 editor.insert_at_cursor(" world", &dummy_tx());
2938 assert_eq!(editor.get_text(), "hello world");
2939 }
2940
2941 #[test]
2942 fn insert_at_cursor_replaces_selection() {
2943 let mut editor = make_editor();
2944 editor.set_text("hello world".to_string());
2945 {
2946 let ta = get_ta(&mut editor);
2947 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2948 ta.start_selection();
2949 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2950 }
2951 editor.insert_at_cursor("HEY ", &dummy_tx());
2952 assert_eq!(editor.get_text(), "HEY world");
2953 }
2954
2955 #[test]
2956 fn paste_inserts_text_at_cursor() {
2957 let mut editor = make_editor();
2958 editor.set_text("hello".to_string());
2959 let ta = get_ta(&mut editor);
2960 ta.move_cursor(ratatui_textarea::CursorMove::End);
2961 ta.insert_str(" world");
2962 assert_eq!(editor.get_text(), "hello world");
2963 }
2964
2965 #[test]
2966 fn bold_action_with_no_selection_inserts_pair_and_centers_cursor() {
2967 let mut editor = make_editor();
2968 editor.set_text("hello".to_string());
2969 {
2970 let ta = get_ta(&mut editor);
2971 ta.move_cursor(ratatui_textarea::CursorMove::End);
2972 }
2973 editor.apply_text_action(TextAction::Bold);
2974 assert_eq!(editor.get_text(), "hello****");
2975 let ta = get_ta(&mut editor);
2976 assert_eq!(ta.cursor(), (0, 7));
2977 }
2978
2979 #[test]
2980 fn italic_action_with_no_selection_inserts_single_pair() {
2981 let mut editor = make_editor();
2982 editor.set_text(String::new());
2983 editor.apply_text_action(TextAction::Italic);
2984 assert_eq!(editor.get_text(), "**");
2985 let ta = get_ta(&mut editor);
2986 assert_eq!(ta.cursor(), (0, 1));
2987 }
2988
2989 #[test]
2990 fn strikethrough_action_with_selection_wraps_text() {
2991 let mut editor = make_editor();
2992 editor.set_text("hello world".to_string());
2993 {
2994 let ta = get_ta(&mut editor);
2995 ta.move_cursor(ratatui_textarea::CursorMove::Head);
2996 ta.start_selection();
2997 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
2998 }
2999 editor.apply_text_action(TextAction::Strikethrough);
3000 assert_eq!(editor.get_text(), "~~hello ~~world");
3001 }
3002
3003 #[test]
3004 fn bold_action_wraps_non_ascii_selection() {
3005 let mut editor = make_editor();
3006 editor.set_text("hello 你好 world".to_string());
3007 {
3008 let ta = get_ta(&mut editor);
3009 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3010 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3011 ta.start_selection();
3012 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3013 }
3014 editor.apply_text_action(TextAction::Bold);
3015 assert_eq!(editor.get_text(), "hello **你好 **world");
3016 }
3017
3018 #[test]
3019 fn bold_action_wraps_selected_text() {
3020 let mut editor = make_editor();
3021 editor.set_text("foo bar".to_string());
3022 {
3023 let ta = get_ta(&mut editor);
3024 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3025 ta.start_selection();
3026 ta.move_cursor(ratatui_textarea::CursorMove::WordForward);
3027 }
3028 editor.apply_text_action(TextAction::Bold);
3029 assert_eq!(editor.get_text(), "**foo **bar");
3030 }
3031
3032 #[test]
3033 fn indent_no_selection_indents_current_line() {
3034 let mut editor = make_editor();
3035 editor.set_text("foo\nbar".to_string());
3036 {
3037 let ta = get_ta(&mut editor);
3038 ta.move_cursor(ratatui_textarea::CursorMove::Bottom);
3039 }
3040 editor.indent_lines(false);
3041 let lines = get_ta(&mut editor).lines();
3042 assert_eq!(lines[0], "foo");
3043 assert!(lines[1].starts_with(' ') || lines[1].starts_with('\t'));
3044 assert!(lines[1].trim_start() == "bar");
3045 }
3046
3047 #[test]
3048 fn indent_midline_selection_keeps_text_before_and_selection() {
3049 let mut editor = make_editor();
3050 editor.set_text("hello world".to_string());
3051 {
3052 let ta = get_ta(&mut editor);
3053 ta.move_cursor(ratatui_textarea::CursorMove::Jump(0, 6));
3054 ta.start_selection();
3055 ta.move_cursor(ratatui_textarea::CursorMove::End);
3056 }
3057 editor.indent_lines(false);
3058 let ta = get_ta(&mut editor);
3059 assert_eq!(ta.lines()[0].trim_start(), "hello world");
3061 let indent = ta.lines()[0].len() - "hello world".len();
3063 assert_eq!(
3064 ta.selection_range(),
3065 Some(((0, 6 + indent), (0, 11 + indent)))
3066 );
3067 }
3068
3069 #[test]
3070 fn indent_with_selection_indents_all_touched_lines() {
3071 let mut editor = make_editor();
3072 editor.set_text("foo\nbar\nbaz".to_string());
3073 {
3074 let ta = get_ta(&mut editor);
3075 ta.move_cursor(ratatui_textarea::CursorMove::Top);
3076 ta.start_selection();
3077 ta.move_cursor(ratatui_textarea::CursorMove::Down);
3078 ta.move_cursor(ratatui_textarea::CursorMove::End);
3079 }
3080 editor.indent_lines(false);
3081 let lines: Vec<String> = get_ta(&mut editor).lines().to_vec();
3082 assert_eq!(lines[0].trim_start(), "foo");
3083 assert_eq!(lines[1].trim_start(), "bar");
3084 assert_eq!(lines[2], "baz");
3085 assert!(lines[0].len() > 3);
3086 assert!(lines[1].len() > 3);
3087 }
3088
3089 #[test]
3090 fn dedent_removes_leading_indent() {
3091 let mut editor = make_editor();
3092 editor.set_text(" foo\n bar\nbaz".to_string());
3093 let tab_len = get_ta(&mut editor).tab_length() as usize;
3094 {
3095 let ta = get_ta(&mut editor);
3096 ta.move_cursor(ratatui_textarea::CursorMove::Top);
3097 ta.start_selection();
3098 ta.move_cursor(ratatui_textarea::CursorMove::Bottom);
3099 ta.move_cursor(ratatui_textarea::CursorMove::End);
3100 }
3101 editor.indent_lines(true);
3102 let lines: Vec<String> = get_ta(&mut editor).lines().to_vec();
3103 assert_eq!(lines[0], format!("{}foo", " ".repeat(4 - tab_len.min(4))));
3105 assert_eq!(
3107 lines[1],
3108 format!("{}bar", " ".repeat(2usize.saturating_sub(tab_len)))
3109 );
3110 assert_eq!(lines[2], "baz");
3111 }
3112
3113 #[test]
3114 fn dedent_no_leading_whitespace_is_noop_for_that_line() {
3115 let mut editor = make_editor();
3116 editor.set_text("foo".to_string());
3117 editor.indent_lines(true);
3118 assert_eq!(editor.get_text(), "foo");
3119 }
3120
3121 #[test]
3122 fn smart_enter_continues_unordered_list() {
3123 let mut editor = make_editor();
3124 editor.set_text("- foo".to_string());
3125 {
3126 let ta = get_ta(&mut editor);
3127 ta.move_cursor(ratatui_textarea::CursorMove::End);
3128 }
3129 assert!(editor.smart_enter());
3130 assert_eq!(editor.get_text(), "- foo\n- ");
3131 }
3132
3133 #[test]
3134 fn smart_enter_continues_ordered_list_increments() {
3135 let mut editor = make_editor();
3136 editor.set_text("1. foo".to_string());
3137 {
3138 let ta = get_ta(&mut editor);
3139 ta.move_cursor(ratatui_textarea::CursorMove::End);
3140 }
3141 assert!(editor.smart_enter());
3142 assert_eq!(editor.get_text(), "1. foo\n2. ");
3143 }
3144
3145 #[test]
3146 fn smart_enter_on_empty_list_marker_clears_line() {
3147 let mut editor = make_editor();
3148 editor.set_text("- ".to_string());
3149 {
3150 let ta = get_ta(&mut editor);
3151 ta.move_cursor(ratatui_textarea::CursorMove::End);
3152 }
3153 assert!(editor.smart_enter());
3154 assert_eq!(editor.get_text(), "");
3155 }
3156
3157 #[test]
3158 fn smart_enter_preserves_indent() {
3159 let mut editor = make_editor();
3160 editor.set_text(" body".to_string());
3161 {
3162 let ta = get_ta(&mut editor);
3163 ta.move_cursor(ratatui_textarea::CursorMove::End);
3164 }
3165 assert!(editor.smart_enter());
3166 assert_eq!(editor.get_text(), " body\n ");
3167 }
3168
3169 #[test]
3170 fn smart_enter_on_empty_indent_dedents() {
3171 let mut editor = make_editor();
3172 editor.set_text(" ".to_string());
3173 {
3174 let ta = get_ta(&mut editor);
3175 ta.move_cursor(ratatui_textarea::CursorMove::End);
3176 }
3177 let tab_len = get_ta(&mut editor).tab_length() as usize;
3178 assert!(editor.smart_enter());
3179 assert_eq!(
3180 editor.get_text(),
3181 " ".repeat(4usize.saturating_sub(tab_len))
3182 );
3183 }
3184
3185 #[test]
3186 fn smart_enter_no_indent_no_marker_returns_false() {
3187 let mut editor = make_editor();
3188 editor.set_text("plain".to_string());
3189 {
3190 let ta = get_ta(&mut editor);
3191 ta.move_cursor(ratatui_textarea::CursorMove::End);
3192 }
3193 assert!(!editor.smart_enter());
3194 assert_eq!(editor.get_text(), "plain");
3195 }
3196
3197 #[test]
3198 fn smart_enter_mid_line_returns_false() {
3199 let mut editor = make_editor();
3200 editor.set_text("- foo".to_string());
3201 {
3202 let ta = get_ta(&mut editor);
3203 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3204 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3205 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3206 }
3207 assert!(!editor.smart_enter());
3208 }
3209
3210 #[test]
3211 fn smart_enter_on_empty_indented_list_marker_dedents_keeping_marker() {
3212 let mut editor = make_editor();
3213 let tab_len = get_ta(&mut editor).tab_length() as usize;
3214 let indent = " ".repeat(tab_len);
3215 editor.set_text(format!("{indent}- "));
3216 {
3217 let ta = get_ta(&mut editor);
3218 ta.move_cursor(ratatui_textarea::CursorMove::End);
3219 }
3220 assert!(editor.smart_enter());
3221 assert_eq!(editor.get_text(), "- ");
3222 }
3223
3224 #[test]
3225 fn smart_enter_on_empty_list_marker_clears_line_after_full_dedent() {
3226 let mut editor = make_editor();
3227 let tab_len = get_ta(&mut editor).tab_length() as usize;
3228 let indent = " ".repeat(tab_len);
3229 editor.set_text(format!("{indent}- "));
3230 {
3231 let ta = get_ta(&mut editor);
3232 ta.move_cursor(ratatui_textarea::CursorMove::End);
3233 }
3234 assert!(editor.smart_enter());
3236 assert_eq!(editor.get_text(), "- ");
3237 {
3240 let ta = get_ta(&mut editor);
3241 ta.move_cursor(ratatui_textarea::CursorMove::End);
3242 }
3243 assert!(editor.smart_enter());
3244 assert_eq!(editor.get_text(), "");
3245 }
3246
3247 #[test]
3248 fn smart_enter_continues_list_with_non_ascii_content() {
3249 let mut editor = make_editor();
3250 editor.set_text("- 你好".to_string());
3251 {
3252 let ta = get_ta(&mut editor);
3253 ta.move_cursor(ratatui_textarea::CursorMove::End);
3254 }
3255 assert!(editor.smart_enter());
3256 assert_eq!(editor.get_text(), "- 你好\n- ");
3257 }
3258
3259 #[test]
3260 fn smart_enter_preserves_tab_indent() {
3261 let mut editor = make_editor();
3262 editor.set_text("\tbody".to_string());
3263 {
3264 let ta = get_ta(&mut editor);
3265 ta.move_cursor(ratatui_textarea::CursorMove::End);
3266 }
3267 assert!(editor.smart_enter());
3268 assert_eq!(editor.get_text(), "\tbody\n\t");
3269 }
3270
3271 #[test]
3272 fn smart_enter_on_tab_only_line_dedents() {
3273 let mut editor = make_editor();
3274 editor.set_text("\t\t".to_string());
3275 {
3276 let ta = get_ta(&mut editor);
3277 ta.move_cursor(ratatui_textarea::CursorMove::End);
3278 }
3279 assert!(editor.smart_enter());
3280 assert_eq!(editor.get_text(), "\t");
3282 }
3283
3284 #[test]
3285 fn smart_enter_continues_indented_list() {
3286 let mut editor = make_editor();
3287 editor.set_text(" - foo".to_string());
3288 {
3289 let ta = get_ta(&mut editor);
3290 ta.move_cursor(ratatui_textarea::CursorMove::End);
3291 }
3292 assert!(editor.smart_enter());
3293 assert_eq!(editor.get_text(), " - foo\n - ");
3294 }
3295
3296 #[test]
3297 fn unsupported_text_action_is_noop() {
3298 let mut editor = make_editor();
3299 editor.set_text("hello".to_string());
3300 editor.apply_text_action(TextAction::Underline);
3301 assert_eq!(editor.get_text(), "hello");
3302 }
3303
3304 #[test]
3305 fn textarea_hint_shortcuts_has_no_mode_indicator() {
3306 let editor = make_editor();
3307 let hints = editor.hint_shortcuts();
3308 assert!(
3310 !hints
3311 .iter()
3312 .any(|(_, label)| label == "NORMAL" || label == "INSERT")
3313 );
3314 }
3315
3316 fn place_cursor_at_col(editor: &mut TextEditorComponent, col: usize) {
3320 let ta = get_ta(editor);
3321 ta.move_cursor(ratatui_textarea::CursorMove::Head);
3322 for _ in 0..col {
3323 ta.move_cursor(ratatui_textarea::CursorMove::Forward);
3324 }
3325 }
3326
3327 #[test]
3328 fn link_at_cursor_returns_label_when_cursor_on_hashtag() {
3329 let mut editor = make_editor();
3330 editor.set_text("see #rust now".to_string());
3331 place_cursor_at_col(&mut editor, 5);
3333 assert_eq!(
3334 editor.link_at_cursor(),
3335 Some(LinkTarget::Label("rust".into())),
3336 );
3337 }
3338
3339 #[test]
3340 fn link_at_cursor_returns_label_at_hash_char() {
3341 let mut editor = make_editor();
3342 editor.set_text("see #rust now".to_string());
3343 place_cursor_at_col(&mut editor, 4);
3345 assert_eq!(
3346 editor.link_at_cursor(),
3347 Some(LinkTarget::Label("rust".into())),
3348 );
3349 }
3350
3351 #[test]
3352 fn link_at_cursor_returns_none_outside_hashtag() {
3353 let mut editor = make_editor();
3354 editor.set_text("see #rust now".to_string());
3355 place_cursor_at_col(&mut editor, 0);
3357 assert_eq!(editor.link_at_cursor(), None);
3358 }
3359
3360 #[test]
3361 fn link_at_cursor_returns_note_for_wikilink() {
3362 let mut editor = make_editor();
3363 editor.set_text("open [[my note]] please".to_string());
3364 place_cursor_at_col(&mut editor, 7);
3366 let result = editor.link_at_cursor();
3367 assert!(
3368 matches!(result, Some(LinkTarget::Note(_))),
3369 "expected Note variant, got {result:?}"
3370 );
3371 }
3372
3373 #[test]
3376 fn link_at_cursor_returns_note_for_markdown_link_with_fragment() {
3377 let line = "[see docs](#section)";
3382 let mut editor = make_editor();
3383 editor.set_text(line.to_string());
3384 let cursor = "[see docs](#sec".chars().count(); place_cursor_at_col(&mut editor, cursor);
3387 let result = editor.link_at_cursor();
3388 assert!(
3389 matches!(result, Some(LinkTarget::Note(_))),
3390 "expected Note variant for markdown link fragment, got {result:?}"
3391 );
3392 }
3393}