1use std::{cell::Cell, cmp::Reverse, collections::HashMap, sync::LazyLock};
8
9use omp_core::{Str, fmts, str::IntoStr};
10use smallvec::SmallVec;
11use xutf::Text;
12
13use crate::{
14 input::{Key, sanitize_paste},
15 rich::cell_width,
16};
17
18const KILL_CAP: usize = 60;
19const UNDO_CAP: usize = 100;
20const PICKER_ROWS: usize = 5;
21const MAX_INPUT_ROWS: usize = 8;
22const MAX_EMOJI_SUGGESTIONS: usize = 12;
23const HISTORY_CAPACITY: usize = 100;
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum BufferOutcome {
28 Changed,
30 Ignored,
32}
33
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35enum Action {
36 Kill,
37 Yank,
38 YankPop,
39 TypeWord,
40 Other,
41}
42
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44enum Jump {
45 Forward,
46 Backward,
47}
48
49#[derive(Clone, Debug)]
54struct Atom {
55 start: usize,
56 end: usize,
57 payload: Str,
58}
59
60#[derive(Clone, Copy, Debug)]
61pub struct VisualRow<'a> {
63 pub text: &'a str,
65 pub cursor_column: Option<u16>,
67}
68
69#[derive(Clone, Copy, Debug)]
70struct Segment {
71 start: usize,
72 end: usize,
73 last: bool,
74}
75
76#[derive(Clone, Debug)]
78pub struct EditBuffer {
79 text: String,
80 cursor: usize,
81 desired: Option<u16>,
82 kill_ring: Vec<String>,
83 kill_index: usize,
84 last_yank: Option<(usize, usize)>,
85 last_action: Action,
86 undo: Vec<(String, usize, Vec<Atom>)>,
87 atoms: Vec<Atom>,
88 jump: Option<Jump>,
89 layout_width: u16,
90 xml: bool,
91 view_offset: Cell<usize>,
92 manual_scroll: Cell<bool>,
93}
94
95impl Default for EditBuffer {
96 fn default() -> Self {
97 Self::new("")
98 }
99}
100
101impl EditBuffer {
102 #[must_use]
103 pub fn new(text: &str) -> Self {
105 let text = sanitize_paste(text);
106 let cursor = text.len();
107 Self {
108 text,
109 cursor,
110 desired: None,
111 kill_ring: Vec::new(),
112 kill_index: 0,
113 last_yank: None,
114 last_action: Action::Other,
115 undo: Vec::new(),
116 atoms: Vec::new(),
117 jump: None,
118 layout_width: 80,
119 view_offset: Cell::new(0),
120 manual_scroll: Cell::new(false),
121 xml: true,
122 }
123 }
124
125 pub const fn set_xml(&mut self, xml: bool) {
127 self.xml = xml;
128 }
129
130 #[must_use]
131 pub fn text(&self) -> &str {
133 &self.text
134 }
135
136 #[must_use]
137 pub const fn cursor(&self) -> usize {
139 self.cursor
140 }
141
142 #[must_use]
143 pub fn line_count(&self) -> usize {
145 self.text.bytes().filter(|byte| *byte == b'\n').count() + 1
146 }
147
148 #[must_use]
149 pub fn cursor_line(&self) -> usize {
151 self.text[..self.cursor]
152 .bytes()
153 .filter(|byte| *byte == b'\n')
154 .count()
155 }
156
157 #[must_use]
158 pub fn cursor_column(&self) -> u16 {
160 let (start, _) = self.line_bounds();
161 cell_width(&self.text[start..self.cursor])
162 }
163
164 pub fn logical_lines(
166 &self,
167 ) -> impl DoubleEndedIterator<Item = &str> + Clone + std::iter::FusedIterator + '_ {
168 self.text.split('\n')
169 }
170
171 pub fn replace_external(&mut self, text: &str, cursor_at_start: bool) {
173 self.text = sanitize_paste(text);
174 self.atoms.clear();
175 self.cursor = if cursor_at_start { 0 } else { self.text.len() };
176 self.undo.clear();
177 self.desired = None;
178 self.break_sequence();
179 }
180
181 pub fn set_cursor_line_column(&mut self, line: usize, column: u16) {
183 let mut start = 0;
184 for _ in 0..line {
185 let Some(offset) = self.text[start..].find('\n') else {
186 break;
187 };
188 start += offset + 1;
189 }
190 let end = self.text[start..]
191 .find('\n')
192 .map_or(self.text.len(), |offset| start + offset);
193 let at = start + byte_at_column(&self.text[start..end], column);
194 self.cursor = self.snap_position(at, at >= self.cursor);
195 self.desired = None;
196 self.break_sequence();
197 }
198
199 pub fn set_cursor_visual_row(&mut self, row: usize, column: u16, width_limit: u16) {
201 let segments = self.segments(width_limit.max(1));
202 let segment = segments[row.min(segments.len() - 1)];
203 let at = segment.start + byte_at_column(&self.text[segment.start..segment.end], column);
204 self.cursor = self.snap_position(at, at >= self.cursor);
205 self.desired = None;
206 self.break_sequence();
207 }
208
209 pub fn replace_range(&mut self, range: std::ops::Range<usize>, replacement: &str) {
213 self.snapshot();
214 let (start, end) = if range.is_empty() {
215 (range.start, range.end)
216 } else {
217 self.expand_to_atoms(range.start, range.end)
218 };
219 self.cursor = start + replacement.len();
220 self.splice(start..end, replacement);
221 self.desired = None;
222 self.break_sequence();
223 }
224
225 fn expand_to_atoms(&self, mut start: usize, mut end: usize) -> (usize, usize) {
227 for atom in &self.atoms {
228 if start < atom.end && end > atom.start {
229 start = start.min(atom.start);
230 end = end.max(atom.end);
231 }
232 }
233 (start, end)
234 }
235
236 fn splice(&mut self, range: std::ops::Range<usize>, replacement: &str) {
239 let inserted = replacement.len();
240 self.atoms.retain_mut(|atom| {
241 if atom.end <= range.start {
242 return true;
243 }
244 if atom.start >= range.end {
245 atom.start = atom.start - range.len() + inserted;
246 atom.end = atom.end - range.len() + inserted;
247 return true;
248 }
249 false
250 });
251 self.text.replace_range(range, replacement);
252 }
253
254 pub fn insert_text(&mut self, text: &str) -> BufferOutcome {
259 let sanitized = sanitize_paste(text);
260 if sanitized.is_empty() {
261 return BufferOutcome::Ignored;
262 }
263 self.snapshot();
264 self.break_sequence();
265 let start = self.cursor;
266 self.splice(start..start, &sanitized);
267 self.cursor += sanitized.len();
268 self.desired = None;
269 BufferOutcome::Changed
270 }
271
272 #[must_use]
273 pub fn expanded_text(&self) -> String {
275 let mut atoms: SmallVec<&Atom, 4> = self.atoms.iter().collect();
276 atoms.sort_unstable_by_key(|atom| atom.start);
277 let mut result = String::with_capacity(self.text.len());
278 let mut at = 0;
279 for atom in atoms {
280 result.push_str(&self.text[at..atom.start]);
281 result.push_str(&atom.payload);
282 at = atom.end;
283 }
284 result.push_str(&self.text[at..]);
285 result
286 }
287
288 pub fn insert_reference(&mut self, marker: &str, payload: &str) -> BufferOutcome {
295 if marker.is_empty() || marker.contains('\n') {
296 return BufferOutcome::Ignored;
297 }
298 self.snapshot();
299 self.break_sequence();
300 let start = self.cursor;
301 self.splice(start..start, marker);
302 self.cursor = start + marker.len();
303 self
304 .atoms
305 .push(Atom { start, end: start + marker.len(), payload: Str::new(payload) });
306 self.desired = None;
307 BufferOutcome::Changed
308 }
309
310 pub fn atom_ranges(&self) -> SmallVec<(usize, usize), 4> {
313 let mut ranges: SmallVec<(usize, usize), 4> = self
314 .atoms
315 .iter()
316 .map(|atom| (atom.start, atom.end))
317 .collect();
318 ranges.sort_unstable();
319 ranges
320 }
321
322 pub fn clear_after_submit(&mut self) -> String {
324 let result = self.expanded_text();
325 self.text.clear();
326 self.cursor = 0;
327 self.desired = None;
328 self.undo.clear();
329 self.atoms.clear();
330 self.break_sequence();
331 result
332 }
333
334 pub fn handle(&mut self, key: Key, width: u16, page_rows: usize) -> BufferOutcome {
336 self.layout_width = width.max(1);
337 self.manual_scroll.set(false);
338 if let Some(jump) = self.jump.take() {
339 return match key {
340 Key::Char(ch) => self.jump_to(ch, jump),
341 Key::Space => self.jump_to(' ', jump),
342 _ => BufferOutcome::Ignored,
343 };
344 }
345 match key {
346 Key::Ctrl(']') => {
347 self.jump = Some(Jump::Forward);
348 self.break_sequence();
349 BufferOutcome::Changed
350 },
351 Key::CtrlAlt(']') => {
352 self.jump = Some(Jump::Backward);
353 self.break_sequence();
354 BufferOutcome::Changed
355 },
356 Key::Ctrl('-' | '_') => self.undo(),
357 Key::Ctrl('y') => self.yank(),
358 Key::Alt('y') => self.yank_pop(),
359 Key::Ctrl('k') => self.kill_line_end(),
360 Key::Ctrl('u') => self.kill_line_start(),
361 Key::Ctrl('w') => self.kill_word_backward(),
362 Key::WordDelete => self.kill_word_forward(),
363 Key::Backspace => self.backspace(),
364 Key::Delete | Key::Ctrl('d') => self.delete(),
365 Key::Left | Key::Ctrl('b') => self.move_left(),
366 Key::Right | Key::Ctrl('f') => self.move_right(),
367 Key::WordLeft => {
368 let at = self.word_left();
369 self.move_to(at)
370 },
371 Key::WordRight => {
372 let at = self.word_right();
373 self.move_to(at)
374 },
375 Key::Home | Key::Ctrl('a') => {
376 let at = self.line_bounds().0;
377 self.move_to(at)
378 },
379 Key::End | Key::Ctrl('e') => {
380 let at = self.line_bounds().1;
381 self.move_to(at)
382 },
383 Key::Up => self.move_visual(-1),
384 Key::Down => self.move_visual(1),
385 Key::PageUp => self.move_visual(-(page_rows.max(1) as isize)),
386 Key::PageDown => self.move_visual(page_rows.max(1) as isize),
387 Key::Enter | Key::ShiftEnter => self.insert_char('\n'),
388 Key::Space => self.insert_char(' '),
389 Key::Char(ch) => self.insert_char(ch),
390 _ => {
391 self.break_sequence();
392 BufferOutcome::Ignored
393 },
394 }
395 }
396
397 #[must_use]
402 pub fn rows(&self, width_limit: u16, max_rows: usize) -> SmallVec<VisualRow<'_>, 8> {
403 let segments = self.segments(width_limit.max(1));
404 let cursor_row = self.segment_at_cursor(&segments);
405 let visible = segments.len().min(max_rows);
406 let max_offset = segments.len() - visible;
407 let first = if self.manual_scroll.get() {
408 self.view_offset.get().min(max_offset)
409 } else {
410 cursor_row
411 .saturating_sub(max_rows.saturating_sub(1))
412 .min(max_offset)
413 };
414 self.view_offset.set(first);
415 segments[first..first + visible]
416 .iter()
417 .map(|segment| VisualRow {
418 text: &self.text[segment.start..segment.end],
419 cursor_column: (self.cursor >= segment.start
420 && self.cursor <= segment.end
421 && (segment.last || self.cursor < segment.end))
422 .then(|| cell_width(&self.text[segment.start..self.cursor])),
423 })
424 .collect()
425 }
426
427 pub fn scroll_rows(&self, delta: i32, width_limit: u16, max_rows: usize) -> bool {
431 let segments = self.segments(width_limit.max(1));
432 let visible = segments.len().min(max_rows);
433 let max_offset = segments.len().saturating_sub(visible);
434 let current = if self.manual_scroll.get() {
435 self.view_offset.get().min(max_offset)
436 } else {
437 self
438 .segment_at_cursor(&segments)
439 .saturating_sub(max_rows.saturating_sub(1))
440 .min(max_offset)
441 };
442 let next = (current as i64 + i64::from(delta)).clamp(0, max_offset as i64) as usize;
443 self.view_offset.set(next);
444 self.manual_scroll.set(true);
445 next != current
446 }
447
448 #[must_use]
449 pub fn visual_height(&self, width: u16, max_rows: usize) -> usize {
451 self.segments(width.max(1)).len().min(max_rows)
452 }
453
454 #[must_use]
455 pub fn at_visual_start(&self) -> bool {
457 self.segment_at_cursor(&self.segments(self.layout_width)) == 0 && self.cursor == 0
458 }
459
460 #[must_use]
461 pub fn at_visual_end(&self) -> bool {
463 let segments = self.segments(self.layout_width);
464 self.segment_at_cursor(&segments) + 1 == segments.len() && self.cursor == self.text.len()
465 }
466
467 fn snapshot(&mut self) {
468 if self
469 .undo
470 .last()
471 .is_some_and(|state| state.0 == self.text && state.1 == self.cursor)
472 {
473 return;
474 }
475 if self.undo.len() == UNDO_CAP {
476 self.undo.remove(0);
477 }
478 self
479 .undo
480 .push((self.text.clone(), self.cursor, self.atoms.clone()));
481 }
482
483 fn undo(&mut self) -> BufferOutcome {
484 let Some((text, cursor, atoms)) = self.undo.pop() else {
485 self.break_sequence();
486 return BufferOutcome::Ignored;
487 };
488 self.text = text;
489 self.cursor = cursor;
490 self.atoms = atoms;
491 self.desired = None;
492 self.break_sequence();
493 BufferOutcome::Changed
494 }
495
496 const fn break_sequence(&mut self) {
497 self.last_action = Action::Other;
498 self.last_yank = None;
499 }
500
501 fn insert_char(&mut self, ch: char) -> BufferOutcome {
502 let word = ch.is_alphanumeric() || ch == '_';
503 if !word || self.last_action != Action::TypeWord {
504 self.snapshot();
505 }
506 if ch == '/'
507 && self.xml
508 && self.text[..self.cursor].ends_with('<')
509 && let Some(name) = nearest_open_tag(&self.text[..self.cursor - 1])
510 {
511 let name = Str::new(name);
512 let mut expansion = String::with_capacity(name.len() + 2);
513 expansion.push('/');
514 expansion.push_str(&name);
515 expansion.push('>');
516 self.splice(self.cursor..self.cursor, &expansion);
517 self.cursor += expansion.len();
518 } else {
519 let mut encoded = [0_u8; 4];
520 self.splice(self.cursor..self.cursor, ch.encode_utf8(&mut encoded));
521 self.cursor += ch.len_utf8();
522 }
523 self.desired = None;
524 self.last_action = if word {
525 Action::TypeWord
526 } else {
527 Action::Other
528 };
529 self.last_yank = None;
530 BufferOutcome::Changed
531 }
532
533 fn move_to(&mut self, cursor: usize) -> BufferOutcome {
534 self.break_sequence();
535 self.desired = None;
536 let forward = cursor >= self.cursor;
537 let cursor = self.snap_position(cursor, forward);
538 if cursor == self.cursor {
539 BufferOutcome::Ignored
540 } else {
541 self.cursor = cursor;
542 BufferOutcome::Changed
543 }
544 }
545
546 fn move_left(&mut self) -> BufferOutcome {
547 let Some((mut at, _)) = self.text[..self.cursor].grapheme_indices().next_back() else {
548 self.break_sequence();
549 return BufferOutcome::Ignored;
550 };
551 if let Some((start, _)) = self.atomic_at(at) {
552 at = start;
553 }
554 self.move_to(at)
555 }
556
557 fn move_right(&mut self) -> BufferOutcome {
558 let Some(grapheme) = self.text[self.cursor..].graphemes().next() else {
559 self.break_sequence();
560 return BufferOutcome::Ignored;
561 };
562 let at = self
563 .atomic_at(self.cursor)
564 .map_or(self.cursor + grapheme.len(), |(_, end)| end);
565 self.move_to(at)
566 }
567
568 fn move_visual(&mut self, delta: isize) -> BufferOutcome {
569 self.break_sequence();
570 let segments = self.segments(self.layout_width);
571 let current = self.segment_at_cursor(&segments);
572 let target = current.saturating_add_signed(delta).min(segments.len() - 1);
573 if target == current {
574 let edge = if delta < 0 {
575 segments[current].start
576 } else {
577 segments[current].end
578 };
579 if edge == self.cursor {
580 return BufferOutcome::Ignored;
581 }
582 self.cursor = edge;
583 return BufferOutcome::Changed;
584 }
585 let source = segments[current];
586 let destination = segments[target];
587 let column = self
588 .desired
589 .unwrap_or_else(|| cell_width(&self.text[source.start..self.cursor]));
590 let max = if destination.last {
591 cell_width(&self.text[destination.start..destination.end])
592 } else {
593 let text = &self.text[destination.start..destination.end];
594 text
595 .graphemes()
596 .next_back()
597 .map_or(0, |g| cell_width(text).saturating_sub(cell_width(g)))
598 };
599 let at = destination.start
600 + byte_at_column(&self.text[destination.start..destination.end], column.min(max));
601 self.cursor = self.snap_position(at, delta > 0);
602 self.desired = Some(column);
603 BufferOutcome::Changed
604 }
605
606 fn backspace(&mut self) -> BufferOutcome {
607 let Some((mut start, _)) = self.text[..self.cursor].grapheme_indices().next_back() else {
608 self.break_sequence();
609 return BufferOutcome::Ignored;
610 };
611 if let Some((token_start, _)) = self.atomic_at(start) {
612 start = token_start;
613 }
614 self.delete_range(start, self.cursor, false)
615 }
616
617 fn delete(&mut self) -> BufferOutcome {
618 let Some(grapheme) = self.text[self.cursor..].graphemes().next() else {
619 self.break_sequence();
620 return BufferOutcome::Ignored;
621 };
622 let end = self
623 .atomic_at(self.cursor)
624 .map_or(self.cursor + grapheme.len(), |(_, end)| end);
625 self.delete_range(self.cursor, end, false)
626 }
627
628 fn kill_line_start(&mut self) -> BufferOutcome {
629 let (start, _) = self.line_bounds();
630 let start = if start == self.cursor && start > 0 {
631 start - 1
632 } else {
633 start
634 };
635 self.delete_range(start, self.cursor, true)
636 }
637
638 fn kill_line_end(&mut self) -> BufferOutcome {
639 let (_, end) = self.line_bounds();
640 let end = if self.cursor < end {
641 end
642 } else if end < self.text.len() {
643 end + 1
644 } else {
645 end
646 };
647 self.delete_range(self.cursor, end, true)
648 }
649
650 fn kill_word_backward(&mut self) -> BufferOutcome {
651 let start = self.word_left();
652 self.delete_range(start, self.cursor, true)
653 }
654
655 fn kill_word_forward(&mut self) -> BufferOutcome {
656 let end = self.word_right();
657 self.delete_range(self.cursor, end, true)
658 }
659
660 fn delete_range(&mut self, start: usize, end: usize, kill: bool) -> BufferOutcome {
661 if start == end {
662 if !kill {
663 self.break_sequence();
664 }
665 return BufferOutcome::Ignored;
666 }
667 let (start, end) = self.expand_to_atoms(start, end);
668 self.snapshot();
669 let removed = self.text[start..end].to_owned();
670 let backward = end == self.cursor;
671 self.splice(start..end, "");
672 self.cursor = start;
673 self.desired = None;
674 self.last_yank = None;
675 if kill {
676 self.record_kill(removed, backward);
677 } else {
678 self.last_action = Action::Other;
679 }
680 BufferOutcome::Changed
681 }
682
683 fn record_kill(&mut self, killed: String, backward: bool) {
684 if self.last_action == Action::Kill && !self.kill_ring.is_empty() {
685 if backward {
686 self.kill_ring[0].insert_str(0, &killed);
687 } else {
688 self.kill_ring[0].push_str(&killed);
689 }
690 } else {
691 self.kill_ring.insert(0, killed);
692 if self.kill_ring.len() > KILL_CAP {
693 self.kill_ring.pop();
694 }
695 }
696 self.kill_index = 0;
697 self.last_action = Action::Kill;
698 }
699
700 fn yank(&mut self) -> BufferOutcome {
701 let Some(value) = self.kill_ring.first().cloned() else {
702 self.break_sequence();
703 return BufferOutcome::Ignored;
704 };
705 self.snapshot();
706 let start = self.cursor;
707 self.splice(start..start, &value);
708 self.cursor += value.len();
709 self.kill_index = 0;
710 self.last_yank = Some((start, self.cursor));
711 self.last_action = Action::Yank;
712 self.desired = None;
713 BufferOutcome::Changed
714 }
715
716 fn yank_pop(&mut self) -> BufferOutcome {
717 if !matches!(self.last_action, Action::Yank | Action::YankPop) || self.kill_ring.len() < 2 {
718 self.break_sequence();
719 return BufferOutcome::Ignored;
720 }
721 let Some((start, end)) = self.last_yank else {
722 return BufferOutcome::Ignored;
723 };
724 self.snapshot();
725 self.kill_index = (self.kill_index + 1) % self.kill_ring.len();
726 let value = self.kill_ring[self.kill_index].clone();
727 self.splice(start..end, &value);
728 self.cursor = start + value.len();
729 self.last_yank = Some((start, self.cursor));
730 self.last_action = Action::YankPop;
731 BufferOutcome::Changed
732 }
733
734 fn jump_to(&mut self, ch: char, jump: Jump) -> BufferOutcome {
735 self.break_sequence();
736 let found = match jump {
737 Jump::Forward => self.text[self.cursor..]
738 .char_indices()
739 .find(|(offset, candidate)| *offset > 0 && *candidate == ch)
740 .map(|(offset, _)| self.cursor + offset),
741 Jump::Backward => self.text[..self.cursor]
742 .char_indices()
743 .rev()
744 .find(|(_, candidate)| *candidate == ch)
745 .map(|(offset, _)| offset),
746 };
747 found.map_or(BufferOutcome::Ignored, |at| {
748 self.cursor = self.snap_position(at, matches!(jump, Jump::Forward));
749 BufferOutcome::Changed
750 })
751 }
752
753 fn snap_position(&self, at: usize, forward: bool) -> usize {
754 self
755 .atomic_at(at)
756 .map_or(at, |(start, end)| if forward { end } else { start })
757 }
758
759 fn line_bounds(&self) -> (usize, usize) {
760 let start = self.text[..self.cursor].rfind('\n').map_or(0, |at| at + 1);
761 let end = self.text[self.cursor..]
762 .find('\n')
763 .map_or(self.text.len(), |at| self.cursor + at);
764 (start, end)
765 }
766
767 fn word_left(&self) -> usize {
768 if self.cursor > 0 && self.text.as_bytes()[self.cursor - 1] == b'\n' {
769 self.cursor - 1
770 } else {
771 word_left(&self.text, self.cursor)
772 }
773 }
774
775 fn word_right(&self) -> usize {
776 if self.text.as_bytes().get(self.cursor) == Some(&b'\n') {
777 self.cursor + 1
778 } else {
779 word_right(&self.text, self.cursor)
780 }
781 }
782
783 fn atomic_at(&self, index: usize) -> Option<(usize, usize)> {
784 self
785 .atoms
786 .iter()
787 .find(|atom| index >= atom.start && index < atom.end)
788 .map(|atom| (atom.start, atom.end))
789 }
790
791 fn segments(&self, width_limit: u16) -> SmallVec<Segment, 16> {
792 let mut result = SmallVec::new();
793 let mut logical_start = 0;
794 loop {
795 let logical_end = self.text[logical_start..]
796 .find('\n')
797 .map_or(self.text.len(), |at| logical_start + at);
798 if logical_start == logical_end {
799 result.push(Segment { start: logical_start, end: logical_end, last: true });
800 } else if self.text[logical_start..logical_end]
801 .bytes()
802 .all(|byte| matches!(byte, b' '..=b'~'))
803 {
804 let limit = usize::from(width_limit.max(1));
805 let mut start = logical_start;
806 while start < logical_end {
807 let hard_end = start.saturating_add(limit).min(logical_end);
808 let end = if hard_end < logical_end {
809 self.text.as_bytes()[start..hard_end]
810 .iter()
811 .rposition(|byte| *byte == b' ')
812 .map_or(hard_end, |offset| start + offset + 1)
813 } else {
814 hard_end
815 };
816 result.push(Segment { start, end, last: end == logical_end });
817 start = end;
818 }
819 } else {
820 let mut start = logical_start;
821 while start < logical_end {
822 let mut cells = 0u16;
823 let mut end = start;
824 let mut whitespace_end = None;
825 for (offset, grapheme) in self.text[start..logical_end].grapheme_indices() {
826 let next = cells.saturating_add(cell_width(grapheme));
827 if next > width_limit && end > start {
828 break;
829 }
830 cells = next;
831 end = start + offset + grapheme.len();
832 if grapheme.chars().all(char::is_whitespace) {
833 whitespace_end = Some(end);
834 }
835 if cells >= width_limit {
836 break;
837 }
838 }
839 if end < logical_end
840 && let Some(boundary) = whitespace_end.filter(|at| *at > start)
841 {
842 end = boundary;
843 }
844 if end == start {
845 end = start + self.text[start..].graphemes().next().map_or(0, str::len);
846 }
847 result.push(Segment { start, end, last: end == logical_end });
848 start = end;
849 }
850 }
851 if logical_end == self.text.len() {
852 break;
853 }
854 logical_start = logical_end + 1;
855 }
856 result
857 }
858
859 fn segment_at_cursor(&self, segments: &[Segment]) -> usize {
860 segments
861 .iter()
862 .position(|segment| {
863 self.cursor >= segment.start
864 && (self.cursor < segment.end || segment.last && self.cursor == segment.end)
865 })
866 .unwrap_or(segments.len() - 1)
867 }
868}
869
870fn nearest_open_tag(text: &str) -> Option<&str> {
871 let mut stack: SmallVec<&str, 16> = SmallVec::new();
872 let mut offset = 0;
873 while let Some(relative) = text[offset..].find('<') {
874 let start = offset + relative;
875 let rest = &text[start..];
876 if let Some(body) = rest.strip_prefix("<!--") {
877 let Some(end) = body.find("-->") else {
878 break;
879 };
880 offset = start + 4 + end + 3;
881 continue;
882 }
883 let processing = rest.starts_with("<?");
884 let Some(end) = tag_end(text, start + 1, processing) else {
885 break;
886 };
887 offset = end + 1;
888 if processing || rest.starts_with("<!") {
889 continue;
890 }
891
892 let mut name_start = start + 1;
893 let closing = text.as_bytes().get(name_start) == Some(&b'/');
894 if closing {
895 name_start += 1;
896 }
897 let name_end = text[name_start..end]
898 .find(|ch: char| {
899 ch.is_whitespace() || matches!(ch, '/' | '>' | '<' | '=' | '?' | '!' | '"' | '\'')
900 })
901 .map_or(end, |relative| name_start + relative);
902 if name_end == name_start {
903 continue;
904 }
905 if closing {
906 stack.pop();
907 } else if !text[name_end..end].trim_ascii_end().ends_with('/') {
908 stack.push(&text[name_start..name_end]);
909 }
910 }
911 stack.pop()
912}
913
914fn tag_end(text: &str, start: usize, processing: bool) -> Option<usize> {
915 let mut quote = None;
916 let mut previous = None;
917 for (relative, ch) in text[start..].char_indices() {
918 if let Some(delimiter) = quote {
919 if ch == delimiter {
920 quote = None;
921 }
922 } else if matches!(ch, '"' | '\'') {
923 quote = Some(ch);
924 } else if ch == '>' && (!processing || previous == Some('?')) {
925 return Some(start + relative);
926 }
927 previous = Some(ch);
928 }
929 None
930}
931
932fn byte_at_column(text: &str, column: u16) -> usize {
933 text.truncate_width(usize::from(column)).len()
934}
935#[derive(Clone, Copy, Eq, PartialEq)]
936enum WordClass {
937 Word,
938 Whitespace,
939 Cjk,
940 Delimiter,
941}
942
943const fn is_cjk(character: char) -> bool {
944 matches!(
945 character as u32,
946 0x2E80..=0x2FFF
947 | 0x3040..=0x30FF
948 | 0x3100..=0x312F
949 | 0x3130..=0x318F
950 | 0x31A0..=0x31BF
951 | 0x31F0..=0x31FF
952 | 0x3400..=0x4DBF
953 | 0x4E00..=0x9FFF
954 | 0xA960..=0xA97F
955 | 0xAC00..=0xD7AF
956 | 0xF900..=0xFAFF
957 | 0x20000..=0x2FA1F
958 )
959}
960
961fn word_class(grapheme: &str) -> WordClass {
962 let Some(character) = grapheme.chars().next() else {
963 return WordClass::Delimiter;
964 };
965 if character.is_whitespace() {
966 WordClass::Whitespace
967 } else if is_cjk(character) {
968 WordClass::Cjk
969 } else if character.is_alphanumeric() || character == '_' {
970 WordClass::Word
971 } else {
972 WordClass::Delimiter
973 }
974}
975
976fn is_word_joiner(grapheme: &str) -> bool {
977 matches!(grapheme, "'" | "’" | "-" | "‐" | "‑")
978}
979
980fn word_left(text: &str, at: usize) -> usize {
981 let mut graphemes = text[..at].grapheme_indices().rev().peekable();
982 while graphemes
983 .peek()
984 .is_some_and(|(_, grapheme)| word_class(grapheme) == WordClass::Whitespace)
985 {
986 graphemes.next();
987 }
988 let Some((offset, grapheme)) = graphemes.next() else {
989 return 0;
990 };
991 let class = word_class(grapheme);
992 if class == WordClass::Cjk {
993 return offset;
994 }
995 if class != WordClass::Word {
996 let mut target = offset;
997 while let Some((offset, grapheme)) = graphemes.peek() {
998 if word_class(grapheme) != class {
999 break;
1000 }
1001 target = *offset;
1002 graphemes.next();
1003 }
1004 return target;
1005 }
1006 let mut target = offset;
1007 while let Some((offset, grapheme)) = graphemes.next() {
1008 if word_class(grapheme) == WordClass::Word {
1009 target = offset;
1010 } else if is_word_joiner(grapheme)
1011 && graphemes
1012 .peek()
1013 .is_some_and(|(_, left)| word_class(left) == WordClass::Word)
1014 {
1015 let (left, _) = graphemes.next().expect("peeked left word");
1016 target = left;
1017 } else {
1018 break;
1019 }
1020 }
1021 target
1022}
1023
1024fn word_right(text: &str, at: usize) -> usize {
1025 let mut graphemes = text[at..].grapheme_indices().peekable();
1026 while graphemes
1027 .peek()
1028 .is_some_and(|(_, grapheme)| word_class(grapheme) == WordClass::Whitespace)
1029 {
1030 graphemes.next();
1031 }
1032 let Some((first_at, first)) = graphemes.next() else {
1033 return text.len();
1034 };
1035 let class = word_class(first);
1036 let mut end = at + first_at + first.len();
1037 if class == WordClass::Cjk {
1038 return end;
1039 }
1040 if class != WordClass::Word {
1041 while let Some((_, grapheme)) = graphemes.peek() {
1042 if word_class(grapheme) != class {
1043 break;
1044 }
1045 let (offset, grapheme) = graphemes.next().expect("peeked delimiter");
1046 end = at + offset + grapheme.len();
1047 }
1048 return end;
1049 }
1050 while let Some((offset, grapheme)) = graphemes.next() {
1051 if word_class(grapheme) == WordClass::Word
1052 || (is_word_joiner(grapheme)
1053 && graphemes
1054 .peek()
1055 .is_some_and(|(_, right)| word_class(right) == WordClass::Word))
1056 {
1057 end = at + offset + grapheme.len();
1058 } else {
1059 break;
1060 }
1061 }
1062 end
1063}
1064
1065type EmojiBuckets = HashMap<&'static str, Vec<[&'static str; 2]>>;
1066
1067static EMOJI_BUCKETS: LazyLock<EmojiBuckets> = LazyLock::new(|| {
1068 serde_json::from_str(include_str!("emojis.json")).expect("embedded emoji data must be valid")
1069});
1070
1071#[derive(Clone, Copy, Debug)]
1074pub struct EditorOptions {
1075 pub emoji: bool,
1078 pub history: bool,
1080 pub xml: bool,
1083}
1084
1085impl Default for EditorOptions {
1086 fn default() -> Self {
1087 Self { emoji: true, history: true, xml: true }
1088 }
1089}
1090
1091const EMOTICONS: &[(&str, &str)] = &[
1092 (":'-(", "😢"),
1093 (">:-(", "😠"),
1094 (":-)", "🙂"),
1095 (":-(", "🙁"),
1096 (":-D", "😃"),
1097 (":-P", "😛"),
1098 (":-p", "😛"),
1099 (":-O", "😮"),
1100 (":-o", "😮"),
1101 (":-|", "😐"),
1102 (":-/", "😕"),
1103 (":-\\", "😕"),
1104 (":-*", "😘"),
1105 (";-)", "😉"),
1106 (";-P", "😜"),
1107 (":')", "🥲"),
1108 (":'D", "😂"),
1109 (":'(", "😢"),
1110 ("</3", "💔"),
1111 (">:(", "😠"),
1112 ("B-)", "😎"),
1113 ("8-)", "😎"),
1114 ("o.O", "😳"),
1115 ("O.o", "😳"),
1116 (":)", "🙂"),
1117 (":(", "🙁"),
1118 (":D", "😃"),
1119 (":P", "😛"),
1120 (":p", "😛"),
1121 (":O", "😮"),
1122 (":o", "😮"),
1123 (":|", "😐"),
1124 (":/", "😕"),
1125 (":\\", "😕"),
1126 (":*", "😘"),
1127 (";)", "😉"),
1128 (":3", "😺"),
1129 ("<3", "❤️"),
1130 ("xD", "😆"),
1131 ("XD", "😆"),
1132 ("B)", "😎"),
1133 ("8)", "😎"),
1134];
1135
1136#[derive(Clone, Debug, Eq, PartialEq)]
1138pub enum SuggestionDisplay {
1139 Text(Str),
1141 Emoji {
1143 emoji: &'static str,
1145 shortcode: &'static str,
1147 },
1148}
1149
1150#[derive(Clone, Debug, Eq, PartialEq)]
1152pub struct Suggestion {
1153 value: Str,
1154 display: SuggestionDisplay,
1155 description: Option<Str>,
1156 hint: Option<Str>,
1157}
1158
1159impl Suggestion {
1160 pub fn new(insert: impl Into<Str>, label: impl Into<Str>) -> Self {
1163 Self {
1164 value: insert.into(),
1165 display: SuggestionDisplay::Text(label.into()),
1166 description: None,
1167 hint: None,
1168 }
1169 }
1170
1171 #[must_use]
1173 pub fn with_description(mut self, description: impl Into<Str>) -> Self {
1174 self.description = Some(description.into());
1175 self
1176 }
1177
1178 #[must_use]
1180 pub fn with_hint(mut self, hint: impl Into<Str>) -> Self {
1181 self.hint = Some(hint.into());
1182 self
1183 }
1184
1185 #[must_use]
1187 pub const fn display(&self) -> &SuggestionDisplay {
1188 &self.display
1189 }
1190
1191 #[must_use]
1193 pub fn description(&self) -> Option<&str> {
1194 self.description.as_deref()
1195 }
1196}
1197
1198pub type SuggestionList = SmallVec<Suggestion, 8>;
1200
1201pub struct Suggestions {
1203 pub prefix_start: usize,
1206 pub items: SuggestionList,
1208}
1209
1210pub struct CompletionEdit {
1213 pub range: std::ops::Range<usize>,
1215 pub insert: Str,
1217}
1218
1219pub enum TabAction {
1221 Accept,
1223 Edit(CompletionEdit),
1225 Pass,
1227}
1228
1229pub trait EditorCompletion {
1236 fn suggest(&mut self, text: &str, cursor: usize) -> Option<Suggestions>;
1239
1240 fn hint(&mut self, text: &str, cursor: usize) -> Option<Str> {
1243 let _ = (text, cursor);
1244 None
1245 }
1246
1247 fn tab(&mut self, text: &str, cursor: usize, selected: Option<&Suggestion>) -> TabAction {
1252 let _ = (text, cursor);
1253 if selected.is_some() {
1254 TabAction::Accept
1255 } else {
1256 TabAction::Pass
1257 }
1258 }
1259}
1260
1261pub struct Picker {
1263 prefix_start: usize,
1264 suggestions: SuggestionList,
1265 selected: usize,
1266 provided: bool,
1268}
1269
1270impl Picker {
1271 #[must_use]
1273 pub fn visible_suggestions(&self) -> (usize, &[Suggestion]) {
1274 let visible = self.suggestions.len().min(PICKER_ROWS);
1275 let max_start = self.suggestions.len().saturating_sub(visible);
1276 let start = self.selected.saturating_sub(PICKER_ROWS / 2).min(max_start);
1277 (start, &self.suggestions[start..start + visible])
1278 }
1279
1280 #[must_use]
1282 pub const fn selected(&self) -> usize {
1283 self.selected
1284 }
1285
1286 #[must_use]
1288 pub const fn len(&self) -> usize {
1289 self.suggestions.len()
1290 }
1291
1292 #[must_use]
1294 pub const fn is_empty(&self) -> bool {
1295 self.suggestions.is_empty()
1296 }
1297}
1298
1299#[derive(Debug, Eq, PartialEq)]
1301pub enum EditOutcome {
1302 Changed,
1304 Submitted(String),
1306 Ignored,
1308}
1309
1310pub struct Editor {
1316 buffer: EditBuffer,
1317 picker: Option<Picker>,
1318 completion: Option<Box<dyn EditorCompletion>>,
1319 options: EditorOptions,
1320 hint: Option<Str>,
1321 history: Vec<Str>,
1322 history_index: Option<usize>,
1323 history_draft: Str,
1324 last_layout_width: Cell<u16>,
1325}
1326
1327impl Editor {
1328 #[must_use]
1330 pub fn new(options: EditorOptions) -> Self {
1331 let mut buffer = EditBuffer::default();
1332 buffer.set_xml(options.xml);
1333 Self {
1334 buffer,
1335 picker: None,
1336 completion: None,
1337 options,
1338 hint: None,
1339 history: Vec::new(),
1340 history_index: None,
1341 history_draft: Str::new_static(""),
1342 last_layout_width: Cell::new(80),
1343 }
1344 }
1345
1346 pub fn set_completion(&mut self, completion: Box<dyn EditorCompletion>) {
1349 self.completion = Some(completion);
1350 self.refresh();
1351 }
1352
1353 #[must_use]
1356 pub const fn options(&self) -> EditorOptions {
1357 self.options
1358 }
1359
1360 #[must_use]
1362 pub fn text(&self) -> &str {
1363 self.buffer.text()
1364 }
1365
1366 #[must_use]
1368 pub const fn picker(&self) -> Option<&Picker> {
1369 self.picker.as_ref()
1370 }
1371
1372 #[must_use]
1374 pub fn picker_height(&self) -> u16 {
1375 u16::try_from(
1376 self
1377 .picker
1378 .as_ref()
1379 .map_or(0, |picker| picker.len().min(PICKER_ROWS)),
1380 )
1381 .unwrap_or(u16::MAX)
1382 }
1383
1384 #[cfg(test)]
1385 fn input_height(&self) -> u16 {
1386 u16::try_from(
1387 self
1388 .buffer
1389 .visual_height(self.last_layout_width.get(), MAX_INPUT_ROWS),
1390 )
1391 .unwrap_or(u16::MAX)
1392 }
1393
1394 pub fn input_height_for(&self, width: u16) -> u16 {
1397 self.last_layout_width.set(width.max(1));
1398 u16::try_from(self.buffer.visual_height(width.max(1), MAX_INPUT_ROWS)).unwrap_or(u16::MAX)
1399 }
1400
1401 pub fn view(&self, width: u16) -> SmallVec<VisualRow<'_>, 8> {
1403 self.last_layout_width.set(width.max(1));
1404 self.buffer.rows(width, MAX_INPUT_ROWS)
1405 }
1406
1407 pub fn set_cursor_visual_row(&mut self, row: usize, column: u16, width: u16) {
1410 self.buffer.set_cursor_visual_row(row, column, width);
1411 self.refresh();
1412 }
1413
1414 pub fn scroll_rows(&self, delta: i32, width: u16, max_rows: usize) -> bool {
1418 self.buffer.scroll_rows(delta, width, max_rows)
1419 }
1420
1421 pub fn handle_key(&mut self, key: Key) -> EditOutcome {
1423 self.handle(key)
1424 }
1425
1426 pub fn handle(&mut self, key: Key) -> EditOutcome {
1431 if self.picker.is_some() {
1432 return match key {
1433 Key::Esc => {
1434 self.picker = None;
1435 EditOutcome::Changed
1436 },
1437 Key::Up => self.select_previous(),
1438 Key::Down => self.select_next(),
1439 Key::PageUp => self.select_page(false),
1440 Key::PageDown => self.select_page(true),
1441 Key::Enter => self.accept_picker(),
1442 Key::Tab => self.tab_complete(),
1443 _ => self.handle_without_picker(key),
1444 };
1445 }
1446 self.handle_without_picker(key)
1447 }
1448
1449 fn handle_without_picker(&mut self, key: Key) -> EditOutcome {
1450 match key {
1451 Key::Enter => self.submit(),
1452 Key::Tab => self.tab_complete(),
1453 Key::Up if self.options.history && self.history_gate_up() => self.history_older(),
1454 Key::Down
1455 if self.options.history
1456 && self.history_index.is_some()
1457 && self.buffer.at_visual_end() =>
1458 {
1459 self.history_newer()
1460 },
1461 _ => {
1462 if matches!(key, Key::Char(_) | Key::Space | Key::Backspace | Key::Delete)
1463 && self.history_index.is_some()
1464 {
1465 self.history_index = None;
1466 }
1467 let outcome = self
1468 .buffer
1469 .handle(key, self.last_layout_width.get(), MAX_INPUT_ROWS);
1470 if matches!(outcome, BufferOutcome::Changed) {
1471 if self.options.emoji {
1472 match key {
1473 Key::Char(':') => self.replace_shortcode(),
1474 Key::Char(character) if character.is_whitespace() => self.replace_emoticon(),
1475 Key::Space => self.replace_emoticon(),
1476 _ => {},
1477 }
1478 }
1479 self.refresh();
1480 EditOutcome::Changed
1481 } else {
1482 EditOutcome::Ignored
1483 }
1484 },
1485 }
1486 }
1487
1488 fn tab_complete(&mut self) -> EditOutcome {
1489 if self.picker.as_ref().is_some_and(|picker| !picker.provided) {
1491 return self.accept_picker();
1492 }
1493 let action = match self.completion.as_mut() {
1494 Some(completion) => {
1495 let selected = self
1496 .picker
1497 .as_ref()
1498 .map(|picker| &picker.suggestions[picker.selected]);
1499 completion.tab(self.buffer.text(), self.buffer.cursor(), selected)
1500 },
1501 None if self.picker.is_some() => TabAction::Accept,
1502 None => TabAction::Pass,
1503 };
1504 match action {
1505 TabAction::Accept if self.picker.is_some() => self.accept_picker(),
1506 TabAction::Edit(edit) => {
1507 self.buffer.replace_range(edit.range, &edit.insert);
1508 self.refresh();
1509 EditOutcome::Changed
1510 },
1511 TabAction::Accept | TabAction::Pass => EditOutcome::Ignored,
1512 }
1513 }
1514
1515 fn history_gate_up(&self) -> bool {
1516 if !self.buffer.at_visual_start() {
1517 return false;
1518 }
1519 self.history_index.is_some() || self.buffer.text().is_empty()
1520 }
1521
1522 fn history_older(&mut self) -> EditOutcome {
1523 if self.history.is_empty() {
1524 return EditOutcome::Ignored;
1525 }
1526 let next = self.history_index.map_or(0, |index| index + 1);
1527 if next >= self.history.len() {
1528 return EditOutcome::Ignored;
1529 }
1530 if self.history_index.is_none() {
1531 self.history_draft = Str::new(self.buffer.text());
1532 }
1533 self.history_index = Some(next);
1534 self.buffer.replace_external(&self.history[next], true);
1535 self.refresh();
1536 EditOutcome::Changed
1537 }
1538
1539 fn history_newer(&mut self) -> EditOutcome {
1540 let Some(index) = self.history_index else {
1541 return EditOutcome::Ignored;
1542 };
1543 if index == 0 {
1544 self.history_index = None;
1545 self.buffer.replace_external(&self.history_draft, false);
1546 } else {
1547 self.history_index = Some(index - 1);
1548 self
1549 .buffer
1550 .replace_external(&self.history[index - 1], false);
1551 }
1552 self.refresh();
1553 EditOutcome::Changed
1554 }
1555
1556 pub fn insert_text(&mut self, text: &str) -> EditOutcome {
1558 self.history_index = None;
1559 if matches!(self.buffer.insert_text(text), BufferOutcome::Changed) {
1560 self.refresh();
1561 EditOutcome::Changed
1562 } else {
1563 EditOutcome::Ignored
1564 }
1565 }
1566
1567 pub fn insert_reference(&mut self, marker: &str, payload: &str) -> EditOutcome {
1570 self.history_index = None;
1571 if matches!(self.buffer.insert_reference(marker, payload), BufferOutcome::Changed) {
1572 self.refresh();
1573 EditOutcome::Changed
1574 } else {
1575 EditOutcome::Ignored
1576 }
1577 }
1578
1579 #[must_use]
1582 pub fn atom_ranges(&self) -> SmallVec<(usize, usize), 4> {
1583 self.buffer.atom_ranges()
1584 }
1585
1586 fn submit(&mut self) -> EditOutcome {
1587 if self.buffer.text().trim().is_empty() {
1588 return EditOutcome::Ignored;
1589 }
1590 if self.options.history {
1591 let submitted = self.buffer.expanded_text();
1592 self
1593 .history
1594 .retain(|entry| entry.as_str() != submitted.as_str());
1595 self.history.insert(0, submitted.into_str());
1596 self.history.truncate(HISTORY_CAPACITY);
1597 }
1598 self.history_index = None;
1599 self.picker = None;
1600 self.hint = None;
1601 EditOutcome::Submitted(self.buffer.clear_after_submit())
1602 }
1603
1604 const fn select_previous(&mut self) -> EditOutcome {
1605 let picker = self.picker.as_mut().expect("picker presence was checked");
1606 picker.selected = if picker.selected == 0 {
1607 picker.len() - 1
1608 } else {
1609 picker.selected - 1
1610 };
1611 EditOutcome::Changed
1612 }
1613
1614 const fn select_next(&mut self) -> EditOutcome {
1615 let picker = self.picker.as_mut().expect("picker presence was checked");
1616 picker.selected = (picker.selected + 1) % picker.len();
1617 EditOutcome::Changed
1618 }
1619
1620 fn select_page(&mut self, down: bool) -> EditOutcome {
1621 let picker = self.picker.as_mut().expect("picker presence was checked");
1622 picker.selected = if down {
1623 picker
1624 .selected
1625 .saturating_add(PICKER_ROWS)
1626 .min(picker.len() - 1)
1627 } else {
1628 picker.selected.saturating_sub(PICKER_ROWS)
1629 };
1630 EditOutcome::Changed
1631 }
1632
1633 fn accept_picker(&mut self) -> EditOutcome {
1634 let picker = self.picker.take().expect("picker presence was checked");
1635 let suggestion = &picker.suggestions[picker.selected];
1636 self
1637 .buffer
1638 .replace_range(picker.prefix_start..self.buffer.cursor(), &suggestion.value);
1639 self.refresh();
1640 EditOutcome::Changed
1641 }
1642
1643 fn refresh(&mut self) {
1646 let cursor = self.buffer.cursor();
1647 let text = self.buffer.text();
1648 let mut picker = self.completion.as_mut().and_then(|completion| {
1649 let suggestions = completion.suggest(text, cursor)?;
1650 (!suggestions.items.is_empty()).then_some(Picker {
1651 prefix_start: suggestions.prefix_start,
1652 suggestions: suggestions.items,
1653 selected: 0,
1654 provided: true,
1655 })
1656 });
1657 if picker.is_none() && self.options.emoji {
1658 picker = emoji_picker(&text[..cursor]);
1659 }
1660 self.hint = self
1661 .completion
1662 .as_mut()
1663 .and_then(|completion| completion.hint(text, cursor));
1664 self.picker = picker;
1665 }
1666
1667 #[must_use]
1671 pub fn inline_hint(&self) -> Option<Str> {
1672 if let Some(picker) = &self.picker
1673 && let Some(hint) = &picker.suggestions[picker.selected].hint
1674 {
1675 return Some(hint.clone());
1676 }
1677 self.hint.clone()
1678 }
1679
1680 fn replace_shortcode(&mut self) {
1681 let cursor = self.buffer.cursor();
1682 let before = &self.buffer.text()[..cursor];
1683 let bytes = before.as_bytes();
1684 if bytes.last() != Some(&b':') {
1685 return;
1686 }
1687 let close = bytes.len() - 1;
1688 let mut name_start = close;
1689 while name_start > 0 && is_name_byte(bytes[name_start - 1]) {
1690 name_start -= 1;
1691 }
1692 if name_start == close || name_start == 0 || bytes[name_start - 1] != b':' {
1693 return;
1694 }
1695 let open = name_start - 1;
1696 if !has_left_boundary(bytes, open) {
1697 return;
1698 }
1699 let name = before[name_start..close].to_ascii_lowercase();
1700 if let Some(emoji) = lookup_emoji(&name) {
1701 self.buffer.replace_range(open..cursor, emoji);
1702 }
1703 }
1704
1705 fn replace_emoticon(&mut self) {
1706 let cursor = self.buffer.cursor();
1707 let before = &self.buffer.text()[..cursor];
1708 let Some(terminator) = before.chars().next_back() else {
1709 return;
1710 };
1711 let tail = before.len() - terminator.len_utf8();
1712 for &(pattern, emoji) in EMOTICONS {
1713 let Some(start) = tail.checked_sub(pattern.len()) else {
1714 continue;
1715 };
1716 if before.get(start..tail) != Some(pattern) || !has_left_boundary(before.as_bytes(), start)
1717 {
1718 continue;
1719 }
1720 let mut replacement = String::with_capacity(emoji.len() + terminator.len_utf8());
1721 replacement.push_str(emoji);
1722 replacement.push(terminator);
1723 self.buffer.replace_range(start..cursor, &replacement);
1724 break;
1725 }
1726 }
1727}
1728
1729#[derive(Clone)]
1731pub struct Command {
1732 name: Str,
1733 description: Str,
1734 aliases: SmallVec<Str, 1>,
1735 args: Box<[CommandArg]>,
1736 hint: Option<Str>,
1737}
1738
1739#[derive(Clone)]
1741struct CommandArg {
1742 name: Str,
1743 description: Str,
1744 usage: Option<Str>,
1745}
1746
1747impl Command {
1748 pub fn new(name: &str, description: &str, aliases: &[&str]) -> Self {
1750 Self {
1751 name: Str::new(name),
1752 description: Str::new(description),
1753 aliases: aliases.iter().map(Str::new).collect(),
1754 args: Box::default(),
1755 hint: None,
1756 }
1757 }
1758
1759 #[must_use]
1763 pub fn with_args(mut self, args: &[(&str, &str, &str)]) -> Self {
1764 self.args = args
1765 .iter()
1766 .map(|&(name, description, usage)| CommandArg {
1767 name: Str::new(name),
1768 description: Str::new(description),
1769 usage: (!usage.is_empty()).then(|| Str::new(usage)),
1770 })
1771 .collect();
1772 self
1773 }
1774
1775 #[must_use]
1778 pub fn with_hint(mut self, hint: &str) -> Self {
1779 self.hint = Some(Str::new(hint));
1780 self
1781 }
1782
1783 pub fn name(&self) -> &str {
1785 &self.name
1786 }
1787
1788 pub fn description(&self) -> &str {
1790 &self.description
1791 }
1792}
1793
1794pub struct SlashCommands {
1800 commands: Box<[Command]>,
1801}
1802
1803impl SlashCommands {
1804 pub fn new(commands: impl Into<Box<[Command]>>) -> Self {
1806 Self { commands: commands.into() }
1807 }
1808
1809 fn find(&self, name: &str) -> Option<&Command> {
1810 self
1811 .commands
1812 .iter()
1813 .find(|command| command.name == name || command.aliases.iter().any(|a| a == name))
1814 }
1815
1816 fn name_suggestions(&self, line_start: usize, line: &str) -> Option<Suggestions> {
1817 let trimmed = line.trim_start_matches([' ', '\t']);
1818 let body = trimmed.strip_prefix('/')?;
1819 if body.contains('/') {
1821 return None;
1822 }
1823 let prefix_start = line_start + line.len() - trimmed.len();
1824 let query = body.to_ascii_lowercase();
1825 let mut ranked: SmallVec<(u16, Suggestion), 8> = SmallVec::new();
1826 for command in &self.commands {
1827 let mut selected_name = &command.name;
1828 let mut score = command_score(&query, &command.name);
1829 for alias in &command.aliases {
1830 let alias_score = command_score(&query, alias);
1831 if alias_score > score {
1832 selected_name = alias;
1833 score = alias_score;
1834 }
1835 }
1836 let description_score = fuzzy_score(&query, &command.description.to_ascii_lowercase()) / 2;
1837 score = score.max(description_score);
1838 if score > 0 {
1839 ranked.push((score, Suggestion {
1840 value: fmts!("/{selected_name} "),
1841 display: SuggestionDisplay::Text(selected_name.clone()),
1842 description: Some(command.description.clone()),
1843 hint: command.hint.clone(),
1844 }));
1845 }
1846 }
1847 ranked.sort_by_key(|(score, _)| Reverse(*score));
1848 let items = ranked
1849 .into_iter()
1850 .map(|(_, suggestion)| suggestion)
1851 .collect::<SuggestionList>();
1852 (!items.is_empty()).then_some(Suggestions { prefix_start, items })
1853 }
1854
1855 fn argument_suggestions(&self, cursor: usize, body: &str, space: usize) -> Option<Suggestions> {
1860 let (name, rest) = body.split_at(space);
1861 let partial = rest.trim_start_matches([' ', '\t']);
1862 if partial.contains(char::is_whitespace) {
1863 return None;
1864 }
1865 let command = self.find(name)?;
1866 if command.args.is_empty() {
1867 return None;
1868 }
1869 let prefix_start = cursor - partial.len();
1870 let query = partial.to_ascii_lowercase();
1871 let mut ranked: SmallVec<(u16, Suggestion), 8> = SmallVec::new();
1872 for arg in &command.args {
1873 let score = command_score(&query, &arg.name);
1874 if score > 0 {
1875 ranked.push((score, Suggestion {
1876 value: fmts!("{} ", arg.name),
1877 display: SuggestionDisplay::Text(arg.name.clone()),
1878 description: Some(arg.description.clone()),
1879 hint: None,
1880 }));
1881 }
1882 }
1883 ranked.sort_by_key(|(score, _)| Reverse(*score));
1884 let items = ranked
1885 .into_iter()
1886 .map(|(_, suggestion)| suggestion)
1887 .collect::<SuggestionList>();
1888 (!items.is_empty()).then_some(Suggestions { prefix_start, items })
1889 }
1890}
1891
1892impl EditorCompletion for SlashCommands {
1893 fn suggest(&mut self, text: &str, cursor: usize) -> Option<Suggestions> {
1894 let before = &text[..cursor];
1895 let line_start = before.rfind('\n').map_or(0, |index| index + 1);
1896 let line = &before[line_start..];
1897 let body = line.trim_start_matches([' ', '\t']).strip_prefix('/')?;
1898 match body.find(char::is_whitespace) {
1899 Some(space) => self.argument_suggestions(cursor, body, space),
1900 None => self.name_suggestions(line_start, line),
1901 }
1902 }
1903
1904 fn hint(&mut self, text: &str, cursor: usize) -> Option<Str> {
1908 let line_start = text[..cursor].rfind('\n').map_or(0, |at| at + 1);
1909 let line = &text[line_start..cursor];
1910 let body = line.trim_start_matches([' ', '\t']).strip_prefix('/')?;
1911 let space = body.find(char::is_whitespace)?;
1912 let (name, rest) = body.split_at(space);
1913 let command = self.find(name)?;
1914 let argument = rest.trim_start_matches([' ', '\t']);
1915 if argument.is_empty() {
1916 return command.hint.clone();
1917 }
1918 match argument.find(char::is_whitespace) {
1919 None => {
1920 let prefix = argument.to_ascii_lowercase();
1922 let matched = command
1923 .args
1924 .iter()
1925 .find(|arg| arg.name.starts_with(&prefix))?;
1926 let remaining = &matched.name.as_str()[prefix.len()..];
1927 match &matched.usage {
1928 Some(usage) => Some(fmts!("{remaining} {usage}")),
1929 None if remaining.is_empty() => None,
1930 None => Some(Str::new(remaining)),
1931 }
1932 },
1933 Some(argument_end) => {
1934 let (chosen, after) = argument.split_at(argument_end);
1936 let arg = command.args.iter().find(|arg| arg.name == chosen)?;
1937 let usage = arg.usage.as_deref()?;
1938 let typed = after.split_whitespace().count();
1939 if typed == 0 {
1940 return Some(Str::new(usage));
1941 }
1942 let mut words = usage.split(' ');
1943 for _ in 0..typed {
1944 words.next()?;
1945 }
1946 let remaining = words.collect::<Vec<_>>().join(" ");
1947 (!remaining.is_empty()).then(|| Str::new(&remaining))
1948 },
1949 }
1950 }
1951}
1952
1953fn emoji_picker(text_before_cursor: &str) -> Option<Picker> {
1954 let (prefix_start, query) = emoji_trigger(text_before_cursor)?;
1955 let mut suggestions = SuggestionList::new();
1956 let wanted = format!(":{query}");
1957 for &(pattern, emoji) in EMOTICONS {
1958 if suggestions.len() >= MAX_EMOJI_SUGGESTIONS {
1959 break;
1960 }
1961 if pattern.len() >= wanted.len() && pattern[..wanted.len()].eq_ignore_ascii_case(&wanted) {
1962 suggestions.push(Suggestion {
1963 value: Str::new_static(emoji),
1964 display: SuggestionDisplay::Emoji { emoji, shortcode: pattern },
1965 description: None,
1966 hint: None,
1967 });
1968 }
1969 }
1970 let first = query.get(..1)?;
1971 if let Some(bucket) = EMOJI_BUCKETS.get(first) {
1972 let start = bucket.partition_point(|entry| entry[0] < query.as_str());
1973 for entry in &bucket[start..] {
1974 if suggestions.len() >= MAX_EMOJI_SUGGESTIONS || !entry[0].starts_with(&query) {
1975 break;
1976 }
1977 suggestions.push(Suggestion {
1978 value: Str::new_static(entry[1]),
1979 display: SuggestionDisplay::Emoji { emoji: entry[1], shortcode: entry[0] },
1980 description: None,
1981 hint: None,
1982 });
1983 }
1984 }
1985 if suggestions.is_empty() {
1986 None
1987 } else {
1988 Some(Picker { prefix_start, suggestions, selected: 0, provided: false })
1989 }
1990}
1991
1992fn emoji_trigger(text: &str) -> Option<(usize, String)> {
1993 let bytes = text.as_bytes();
1994 let mut index = bytes.len();
1995 while index > 0 && is_name_byte(bytes[index - 1]) {
1996 index -= 1;
1997 }
1998 if index == 0 || bytes[index - 1] != b':' {
1999 return None;
2000 }
2001 let colon = index - 1;
2002 if !has_left_boundary(bytes, colon) || index == bytes.len() {
2003 return None;
2004 }
2005 Some((colon, text[index..].to_ascii_lowercase()))
2006}
2007
2008const fn is_name_byte(byte: u8) -> bool {
2009 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'+' | b'-')
2010}
2011
2012const fn has_left_boundary(bytes: &[u8], index: usize) -> bool {
2013 index == 0
2014 || matches!(bytes[index - 1], b' ' | b'\t' | b'\n' | b'\r' | b'(' | b'[' | b'{' | b'>')
2015}
2016
2017fn lookup_emoji(name: &str) -> Option<&'static str> {
2018 let bucket = EMOJI_BUCKETS.get(name.get(..1)?)?;
2019 let index = bucket.partition_point(|entry| entry[0] < name);
2020 bucket
2021 .get(index)
2022 .filter(|entry| entry[0] == name)
2023 .map(|entry| entry[1])
2024}
2025
2026fn command_score(query: &str, target: &str) -> u16 {
2027 if query.is_empty() {
2028 1
2029 } else if query == target {
2030 1_000
2031 } else if target.starts_with(query) {
2032 900
2033 } else {
2034 fuzzy_score(query, target)
2035 }
2036}
2037
2038fn fuzzy_score(query: &str, target: &str) -> u16 {
2039 if query.is_empty() {
2040 return 1;
2041 }
2042 let mut query_bytes = query.bytes();
2043 let Some(mut wanted) = query_bytes.next() else {
2044 return 1;
2045 };
2046 let mut matched = 0_u16;
2047 let mut gaps = 0_u16;
2048 for byte in target.bytes() {
2049 if byte == wanted {
2050 matched = matched.saturating_add(1);
2051 if let Some(next) = query_bytes.next() {
2052 wanted = next;
2053 } else {
2054 return 500_u16
2055 .saturating_add(matched.saturating_mul(8))
2056 .saturating_sub(gaps);
2057 }
2058 } else if matched > 0 {
2059 gaps = gaps.saturating_add(1);
2060 }
2061 }
2062 0
2063}
2064
2065#[cfg(test)]
2066mod tests {
2067 use super::*;
2068
2069 fn type_slash(text: &str) -> EditBuffer {
2070 let mut buffer = EditBuffer::new(text);
2071 assert_eq!(buffer.handle(Key::Char('/'), 80, 10), BufferOutcome::Changed);
2072 buffer
2073 }
2074
2075 #[test]
2076 fn close_tag_completes_innermost_open_element() {
2077 let buffer = type_slash("<box><row gap=1><Foo.Bar>hi<");
2078 assert_eq!(buffer.text(), "<box><row gap=1><Foo.Bar>hi</Foo.Bar>");
2079 assert_eq!(buffer.cursor(), buffer.text().len());
2080 }
2081
2082 #[test]
2083 fn close_tag_ignores_self_closing_elements() {
2084 let buffer = type_slash("<a><hr/><");
2085 assert_eq!(buffer.text(), "<a><hr/></a>");
2086 assert_eq!(buffer.cursor(), buffer.text().len());
2087 }
2088
2089 #[test]
2090 fn close_tag_pops_already_closed_pair() {
2091 let buffer = type_slash("<a><b></b><");
2092 assert_eq!(buffer.text(), "<a><b></b></a>");
2093 assert_eq!(buffer.cursor(), buffer.text().len());
2094 }
2095
2096 #[test]
2097 fn close_tag_respects_quoted_attribute_delimiters() {
2098 let buffer = type_slash("<a t=\"x>y\"><");
2099 assert_eq!(buffer.text(), "<a t=\"x>y\"></a>");
2100 assert_eq!(buffer.cursor(), buffer.text().len());
2101 }
2102
2103 #[test]
2104 fn close_tag_ignores_comment_contents() {
2105 let buffer = type_slash("<a><!-- <b> --><");
2106 assert_eq!(buffer.text(), "<a><!-- <b> --></a>");
2107 assert_eq!(buffer.cursor(), buffer.text().len());
2108 }
2109
2110 #[test]
2111 fn close_tag_types_literal_slash_when_stack_is_empty() {
2112 let buffer = type_slash("<");
2113 assert_eq!(buffer.text(), "</");
2114 assert_eq!(buffer.cursor(), buffer.text().len());
2115 }
2116 #[test]
2117 fn pasting_a_document_does_not_complete_its_closing_tags() {
2118 let document = "<box bg=\"black\">\n <row gap=\"1\">\n <col>hi</col>\n </row>\n</box>";
2121 let mut buffer = EditBuffer::new("");
2122 assert_eq!(buffer.insert_text(document), BufferOutcome::Changed);
2123 assert_eq!(buffer.text(), document);
2124 }
2125
2126 fn key(key: Key) -> Key {
2127 key
2128 }
2129
2130 fn palette() -> Vec<Command> {
2132 vec![
2133 Command::new("security", "Plan, run, inspect, and compare security scans", &[])
2134 .with_args(&[
2135 ("plan", "Draft a scan plan", ""),
2136 ("import", "Import an external report", "<path>"),
2137 ("compare", "Diff two runs", "<run-a> <run-b>"),
2138 ])
2139 .with_hint("plan|import|compare"),
2140 Command::new("settings", "Open settings menu", &[]),
2141 Command::new("setup", "Open provider setup", &["providers"]),
2142 ]
2143 }
2144
2145 fn editor() -> Editor {
2146 let mut editor = Editor::new(EditorOptions::default());
2147 editor.set_completion(Box::new(SlashCommands::new(palette())));
2148 editor
2149 }
2150
2151 fn type_text(editor: &mut Editor, text: &str) {
2152 for character in text.chars() {
2153 assert_eq!(editor.handle_key(key(Key::Char(character))), EditOutcome::Changed);
2154 }
2155 }
2156
2157 #[test]
2158 fn command_picker_navigates_and_inserts_a_trailing_space() {
2159 let mut editor = editor();
2160 type_text(&mut editor, "/");
2161 assert_eq!(editor.handle_key(key(Key::Down)), EditOutcome::Changed);
2162 assert_eq!(editor.handle_key(key(Key::Enter)), EditOutcome::Changed);
2163 assert_eq!(editor.text(), "/settings ");
2164 }
2165
2166 #[test]
2167 fn options_gate_emoji_history_and_xml() {
2168 let mut editor = Editor::new(EditorOptions::default());
2170 type_text(&mut editor, "/se");
2171 assert!(editor.picker().is_none(), "no completion registered");
2172 assert!(editor.inline_hint().is_none());
2173
2174 let mut editor = Editor::new(EditorOptions { emoji: false, ..EditorOptions::default() });
2175 type_text(&mut editor, ":joy");
2176 assert!(editor.picker().is_none(), "emoji dropdown disabled");
2177 type_text(&mut editor, ": ");
2178 assert_eq!(editor.text(), ":joy: ", "shortcode expansion disabled");
2179
2180 let mut editor = Editor::new(EditorOptions { history: false, ..EditorOptions::default() });
2181 type_text(&mut editor, "one");
2182 assert_eq!(editor.handle(Key::Enter), EditOutcome::Submitted("one".into()));
2183 assert_eq!(editor.handle(Key::Up), EditOutcome::Ignored, "history disabled");
2184
2185 let mut editor = Editor::new(EditorOptions { xml: false, ..EditorOptions::default() });
2186 type_text(&mut editor, "<a></");
2187 assert_eq!(editor.text(), "<a></", "close-tag completion disabled");
2188 }
2189
2190 #[test]
2191 fn argument_completion_inside_a_slash_command() {
2192 let mut editor = editor();
2193 type_text(&mut editor, "/security i");
2194 let picker = editor.picker().expect("argument candidates open");
2195 assert_eq!(
2196 *picker.suggestions[picker.selected].display(),
2197 SuggestionDisplay::Text("import".into())
2198 );
2199 assert_eq!(editor.handle_key(key(Key::Enter)), EditOutcome::Changed);
2200 assert_eq!(editor.text(), "/security import ");
2201 assert!(editor.picker().is_none());
2203 }
2204
2205 #[test]
2206 fn inline_hint_follows_selection_arguments_and_usage() {
2207 let mut editor = editor();
2208 type_text(&mut editor, "/sec");
2209 assert_eq!(editor.inline_hint().as_deref(), Some("plan|import|compare"));
2210 assert_eq!(editor.handle_key(key(Key::Enter)), EditOutcome::Changed);
2211 assert_eq!(editor.text(), "/security ");
2212 assert_eq!(editor.inline_hint().as_deref(), Some("plan|import|compare"));
2214 type_text(&mut editor, "im");
2216 assert_eq!(editor.inline_hint().as_deref(), Some("port <path>"));
2217 assert_eq!(editor.handle_key(key(Key::Enter)), EditOutcome::Changed);
2219 assert_eq!(editor.text(), "/security import ");
2220 assert_eq!(editor.inline_hint().as_deref(), Some("<path>"));
2221 type_text(&mut editor, "report.json");
2223 assert_eq!(editor.inline_hint(), None);
2224 let mut compare = self::editor();
2227 type_text(&mut compare, "/security compare one");
2228 assert_eq!(compare.inline_hint().as_deref(), Some("<run-b>"));
2229 type_text(&mut compare, " two");
2230 assert_eq!(compare.inline_hint(), None, "usage fully consumed");
2231 }
2232
2233 #[test]
2234 fn emoji_picker_and_shortcode_use_the_same_dataset() {
2235 let mut picker_editor = editor();
2236 type_text(&mut picker_editor, ":joy");
2237 let picker = picker_editor.picker().expect("joy opens the picker");
2238 assert_eq!(*picker.suggestions[picker.selected].display(), SuggestionDisplay::Emoji {
2239 emoji: "😂",
2240 shortcode: "joy",
2241 });
2242 assert_eq!(picker_editor.handle_key(key(Key::Enter)), EditOutcome::Changed);
2243 assert_eq!(picker_editor.text(), "😂");
2244
2245 let mut shortcode_editor = editor();
2246 type_text(&mut shortcode_editor, ":joy:");
2247 assert_eq!(shortcode_editor.text(), "😂");
2248 }
2249
2250 #[test]
2251 fn emoticon_replacement_is_unicode_boundary_safe() {
2252 let mut editor = editor();
2253 type_text(&mut editor, "é:) ");
2254 assert_eq!(editor.text(), "é:) ");
2255 }
2256
2257 #[test]
2258 fn cursor_navigation_and_backspace_preserve_graphemes() {
2259 let mut editor = editor();
2260 type_text(&mut editor, "a👩💻b");
2261 assert_eq!(editor.handle_key(key(Key::Left)), EditOutcome::Changed);
2262 assert_eq!(editor.handle_key(key(Key::Backspace)), EditOutcome::Changed);
2263 assert_eq!(editor.text(), "ab");
2264 }
2265
2266 #[test]
2267 fn shift_enter_adds_lines_and_vertical_navigation_preserves_column() {
2268 let mut editor = editor();
2269 type_text(&mut editor, "first");
2270 assert_eq!(editor.handle_key(Key::ShiftEnter), EditOutcome::Changed);
2271 type_text(&mut editor, "second");
2272
2273 assert_eq!(editor.text(), "first\nsecond");
2274 assert_eq!(editor.input_height(), 2);
2275 {
2276 let rows = editor.view(20);
2277 assert_eq!(rows.iter().map(|row| row.text).collect::<Vec<_>>(), ["first", "second"]);
2278 assert_eq!(rows[0].cursor_column, None);
2279 assert_eq!(rows[1].cursor_column, Some(6));
2280 }
2281
2282 assert_eq!(editor.handle_key(key(Key::Up)), EditOutcome::Changed);
2283 assert_eq!(editor.view(20)[0].cursor_column, Some(5));
2284 assert_eq!(editor.handle_key(key(Key::Down)), EditOutcome::Changed);
2285 assert_eq!(editor.view(20)[1].cursor_column, Some(6));
2286
2287 assert_eq!(
2288 editor.handle_key(key(Key::Enter)),
2289 EditOutcome::Submitted("first\nsecond".to_owned())
2290 );
2291 }
2292
2293 #[test]
2294 fn slash_commands_complete_at_the_start_of_later_lines() {
2295 let mut editor = editor();
2296 type_text(&mut editor, "context");
2297 editor.handle_key(Key::ShiftEnter);
2298 type_text(&mut editor, "/set");
2299
2300 assert!(editor.picker().is_some());
2301 assert_eq!(editor.handle_key(key(Key::Enter)), EditOutcome::Changed);
2302 assert_eq!(editor.text(), "context\n/settings ");
2303 }
2304
2305 #[test]
2306 fn pi_control_a_e_and_u_are_scoped_to_logical_lines() {
2307 let mut editor = editor();
2308 assert_eq!(editor.insert_text("one\ntwo"), EditOutcome::Changed);
2309 assert_eq!(editor.handle_key(Key::Ctrl('a')), EditOutcome::Changed);
2310 assert_eq!(editor.buffer.cursor(), 4);
2311 assert_eq!(editor.handle_key(Key::Ctrl('e')), EditOutcome::Changed);
2312 assert_eq!(editor.buffer.cursor(), 7);
2313 assert_eq!(editor.handle_key(Key::Ctrl('u')), EditOutcome::Changed);
2315 assert_eq!(editor.text(), "one\n");
2316 assert_eq!(editor.handle_key(Key::Ctrl('u')), EditOutcome::Changed);
2317 assert_eq!(editor.text(), "one");
2318 }
2319
2320 #[test]
2321 fn pi_word_motion_keeps_apostrophes_and_hyphens_inside_words() {
2322 let mut editor = editor();
2323 editor.insert_text("don't foo-bar");
2324 assert_eq!(editor.handle(Key::WordLeft), EditOutcome::Changed);
2325 assert_eq!(editor.buffer.cursor(), 6);
2326 assert_eq!(editor.handle(Key::WordLeft), EditOutcome::Changed);
2327 assert_eq!(editor.buffer.cursor(), 0);
2328 assert_eq!(editor.handle(Key::WordRight), EditOutcome::Changed);
2329 assert_eq!(editor.buffer.cursor(), 5);
2330 }
2331
2332 #[test]
2333 fn pi_word_deletes_merge_logical_lines() {
2334 let mut editor = editor();
2335 editor.insert_text("first\nsecond");
2336 editor.handle(Key::Ctrl('a'));
2337 assert_eq!(editor.handle(Key::Ctrl('w')), EditOutcome::Changed);
2338 assert_eq!(editor.text(), "firstsecond");
2339
2340 let mut forward = self::editor();
2341 forward.insert_text("first\nsecond");
2342 forward.buffer.set_cursor_line_column(0, 5);
2343 assert_eq!(forward.handle(Key::WordDelete), EditOutcome::Changed);
2344 assert_eq!(forward.text(), "firstsecond");
2345 }
2346
2347 #[test]
2348 fn paste_normalizes_newlines_controls_and_unicode_composition() {
2349 let mut editor = editor();
2350 assert_eq!(editor.insert_text("a\r\nb\u{0007}e\u{301}"), EditOutcome::Changed);
2351 assert_eq!(editor.text(), "a\nbé");
2352 }
2353
2354 #[test]
2355 fn vertical_motion_snaps_at_document_boundaries_before_ignoring() {
2356 let mut editor = editor();
2357 editor.insert_text("abc");
2358 assert_eq!(editor.handle(Key::Up), EditOutcome::Changed);
2359 assert_eq!(editor.buffer.cursor(), 0);
2360 assert_eq!(editor.handle(Key::Up), EditOutcome::Ignored);
2361 assert_eq!(editor.handle(Key::Down), EditOutcome::Changed);
2362 assert_eq!(editor.buffer.cursor(), 3);
2363 assert_eq!(editor.handle(Key::Down), EditOutcome::Ignored);
2364 }
2365
2366 #[test]
2367 fn kill_ring_accumulates_yanks_and_cycles_older_entries() {
2368 let mut editor = editor();
2369 type_text(&mut editor, "alpha beta gamma");
2370 editor.handle(Key::Ctrl('w'));
2371 editor.handle(Key::Ctrl('w'));
2372 assert_eq!(editor.handle(Key::Ctrl('y')), EditOutcome::Changed);
2373 assert_eq!(editor.text(), "alpha beta gamma");
2374 editor.handle(Key::Space);
2375 type_text(&mut editor, "older");
2376 editor.handle(Key::Ctrl('w'));
2377 assert_eq!(editor.handle(Key::Ctrl('y')), EditOutcome::Changed);
2378 assert_eq!(editor.handle(Key::Alt('y')), EditOutcome::Changed);
2379 assert!(editor.text().ends_with("beta gamma"));
2380 }
2381
2382 #[test]
2383 fn undo_coalesces_word_typing_and_splits_at_punctuation() {
2384 let mut editor = editor();
2385 type_text(&mut editor, "abc def");
2386 assert_eq!(editor.handle(Key::Ctrl('-')), EditOutcome::Changed);
2387 assert_eq!(editor.text(), "abc ");
2388 assert_eq!(editor.handle(Key::Ctrl('_')), EditOutcome::Changed);
2389 assert_eq!(editor.text(), "abc");
2390 assert_eq!(editor.handle(Key::Ctrl('-')), EditOutcome::Changed);
2391 assert_eq!(editor.text(), "");
2392 }
2393
2394 #[test]
2395 fn history_deduplicates_navigates_and_restores_the_draft() {
2396 let mut editor = editor();
2397 type_text(&mut editor, "one");
2398 assert_eq!(editor.handle(Key::Enter), EditOutcome::Submitted("one".into()));
2399 type_text(&mut editor, "two");
2400 assert_eq!(editor.handle(Key::Enter), EditOutcome::Submitted("two".into()));
2401 type_text(&mut editor, "one");
2402 editor.handle(Key::Enter);
2403 assert_eq!(editor.history.len(), 2);
2404 assert_eq!(editor.handle(Key::Up), EditOutcome::Changed);
2405 assert_eq!(editor.text(), "one");
2406 assert_eq!(editor.handle(Key::Up), EditOutcome::Changed);
2407 assert_eq!(editor.text(), "two");
2408 editor.history_draft = "draft".into();
2409 editor.handle(Key::End);
2410 assert_eq!(editor.handle(Key::Down), EditOutcome::Changed);
2411 assert_eq!(editor.handle(Key::Down), EditOutcome::Changed);
2412 assert_eq!(editor.text(), "draft");
2413 }
2414
2415 #[test]
2416 fn reference_markers_are_atomic_for_every_delete_and_expand_on_submit() {
2417 let payload = (0..12)
2418 .map(|n| format!("line{n}"))
2419 .collect::<Vec<_>>()
2420 .join("\n");
2421 for key in [
2422 Key::Backspace,
2423 Key::Delete,
2424 Key::Ctrl('w'),
2425 Key::WordDelete,
2426 Key::Ctrl('k'),
2427 Key::Ctrl('u'),
2428 ] {
2429 let mut editor = editor();
2430 editor.insert_reference("txt #1", &payload);
2431 assert_eq!(editor.text(), "txt #1");
2432 if matches!(key, Key::Delete | Key::WordDelete | Key::Ctrl('k')) {
2433 editor.buffer.set_cursor_line_column(0, 0);
2434 }
2435 assert_eq!(editor.handle(key), EditOutcome::Changed);
2436 assert_eq!(editor.text(), "", "{key:?}");
2437 }
2438 let mut editor = editor();
2439 editor.insert_reference("txt #1", &payload);
2440 assert_eq!(editor.handle(Key::Enter), EditOutcome::Submitted(payload));
2441 }
2442
2443 #[test]
2444 fn references_are_positional_atoms_immune_to_lookalike_text() {
2445 let mut editor = editor();
2446 assert_eq!(editor.insert_reference("* #1", "<ref image=1/>"), EditOutcome::Changed);
2447 type_text(&mut editor, " hi ");
2448 editor.insert_text("* #1");
2449 assert_eq!(editor.text(), "* #1 hi * #1");
2450 assert_eq!(
2451 editor.atom_ranges().as_slice(),
2452 &[(0, 4)],
2453 "typed lookalike text never becomes an atom"
2454 );
2455 assert_eq!(
2456 editor.handle(Key::Enter),
2457 EditOutcome::Submitted("<ref image=1/> hi * #1".into()),
2458 "only the real reference expands"
2459 );
2460 }
2461
2462 #[test]
2463 fn reference_markers_delete_atomically_and_undo_restores_them() {
2464 let mut editor = editor();
2465 editor.insert_reference("* #1", "<ref image=1/>");
2466 assert_eq!(editor.handle(Key::Backspace), EditOutcome::Changed);
2467 assert_eq!(editor.text(), "");
2468 assert!(editor.atom_ranges().is_empty());
2469 assert_eq!(editor.handle(Key::Ctrl('_')), EditOutcome::Changed);
2470 assert_eq!(editor.text(), "* #1");
2471 assert_eq!(
2472 editor.atom_ranges().as_slice(),
2473 &[(0, 4)],
2474 "undo restores the atom, not just its text"
2475 );
2476 }
2477
2478 #[test]
2479 fn partial_replacements_widen_to_whole_reference_markers() {
2480 let mut torn = editor();
2481 type_text(&mut torn, "ab");
2482 torn.insert_reference("* #1", "<ref image=1/>");
2483 type_text(&mut torn, "cd");
2484 torn.buffer.replace_range(1..3, "X");
2486 assert_eq!(torn.text(), "aXcd");
2487 assert!(torn.atom_ranges().is_empty(), "torn atom is dropped whole");
2488 let mut fresh = editor();
2490 fresh.insert_reference("* #1", "<ref image=1/>");
2491 fresh.buffer.replace_range(0..0, ">");
2492 assert_eq!(fresh.text(), ">* #1");
2493 assert_eq!(fresh.atom_ranges().as_slice(), &[(1, 5)]);
2494 }
2495
2496 #[test]
2497 fn character_jump_moves_forward_and_backward() {
2498 let mut editor = editor();
2499 editor.insert_text("abacad");
2500 editor.buffer.set_cursor_line_column(0, 0);
2501 editor.handle(Key::Ctrl(']'));
2502 editor.handle(Key::Char('a'));
2503 assert_eq!(editor.buffer.cursor(), 2);
2504 editor.handle(Key::CtrlAlt(']'));
2505 editor.handle(Key::Char('a'));
2506 assert_eq!(editor.buffer.cursor(), 0);
2507 }
2508
2509 #[test]
2510 fn page_motion_uses_visible_rows_and_keeps_sticky_column() {
2511 let mut editor = editor();
2512 editor.insert_text("abcd\nx\nabcd\nx\nabcd\nx\nabcd\nx\nabcd");
2513 editor.buffer.set_cursor_line_column(0, 3);
2514 editor.handle(Key::PageDown);
2515 assert_eq!((editor.buffer.cursor_line(), editor.buffer.cursor_column()), (8, 3));
2516 editor.handle(Key::PageUp);
2517 assert_eq!((editor.buffer.cursor_line(), editor.buffer.cursor_column()), (0, 3));
2518 }
2519
2520 #[test]
2521 fn view_word_wraps_and_vertical_motion_uses_visual_rows() {
2522 let mut editor = editor();
2523 editor.insert_text("hello world");
2524 let rows = editor
2525 .view(7)
2526 .iter()
2527 .map(|row| row.text)
2528 .collect::<Vec<_>>();
2529 assert_eq!(rows, ["hello ", "world"]);
2530 editor.buffer.set_cursor_line_column(0, 4);
2531 editor.handle(Key::Down);
2532 assert_eq!(editor.buffer.cursor(), 10);
2533 editor.handle(Key::Up);
2534 assert_eq!(editor.buffer.cursor(), 4);
2535 }
2536
2537 struct AtNames;
2540
2541 impl EditorCompletion for AtNames {
2542 fn suggest(&mut self, text: &str, cursor: usize) -> Option<Suggestions> {
2543 let before = &text[..cursor];
2544 let at = before.rfind('@')?;
2545 let query = &before[at + 1..];
2546 let items = ["alice", "bob"]
2547 .iter()
2548 .filter(|name| !query.is_empty() && name.starts_with(query))
2549 .map(|name| Suggestion::new(fmts!("@{name} "), *name))
2550 .collect::<SuggestionList>();
2551 (!items.is_empty()).then_some(Suggestions { prefix_start: at, items })
2552 }
2553
2554 fn hint(&mut self, text: &str, cursor: usize) -> Option<Str> {
2555 text[..cursor]
2556 .ends_with("hel")
2557 .then(|| Str::new("lo world"))
2558 }
2559
2560 fn tab(&mut self, text: &str, cursor: usize, selected: Option<&Suggestion>) -> TabAction {
2561 if selected.is_some() {
2563 return TabAction::Pass;
2564 }
2565 match self.hint(text, cursor) {
2566 Some(insert) => TabAction::Edit(CompletionEdit { range: cursor..cursor, insert }),
2567 None => TabAction::Pass,
2568 }
2569 }
2570 }
2571
2572 #[test]
2573 fn custom_completion_controls_trigger_ghost_text_and_tab() {
2574 let mut editor = Editor::new(EditorOptions::default());
2575 editor.set_completion(Box::new(AtNames));
2576 type_text(&mut editor, "hi @al");
2577 let picker = editor.picker().expect("@ trigger opens the dropdown");
2578 assert_eq!(picker.len(), 1);
2579 assert_eq!(editor.handle(Key::Tab), EditOutcome::Ignored);
2581 assert!(editor.picker().is_some(), "passthrough leaves the dropdown open");
2582 assert_eq!(editor.handle(Key::Enter), EditOutcome::Changed);
2583 assert_eq!(editor.text(), "hi @alice ");
2584
2585 type_text(&mut editor, "hel");
2586 assert_eq!(editor.inline_hint().as_deref(), Some("lo world"));
2587 assert_eq!(editor.handle(Key::Tab), EditOutcome::Changed);
2588 assert_eq!(editor.text(), "hi @alice hello world");
2589 assert_eq!(editor.handle(Key::Tab), EditOutcome::Ignored);
2591
2592 type_text(&mut editor, " :joy");
2594 assert!(editor.picker().is_some(), "emoji dropdown open");
2595 assert_eq!(editor.handle(Key::Tab), EditOutcome::Changed);
2596 assert!(editor.text().ends_with("😂"), "{}", editor.text());
2597 }
2598}