Skip to main content

omp_tui/
editcore.rs

1//! Pi-compatible editing core: the flat-text [`EditBuffer`] (grapheme-safe
2//! word wrapping and navigation, undo, kill-ring yank/yank-pop, atomic
3//! references, character jumps, sticky page motion) and the
4//! [`Editor`] built on top of it (pluggable completion, inline ghost
5//! hints, emoji expansion, prompt history).
6
7use 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)]
26/// Whether an editing command changed the buffer.
27pub enum BufferOutcome {
28	/// Text, cursor, or transient editing state changed.
29	Changed,
30	/// The key had no applicable effect.
31	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/// One atomic unit in the visible text: the `start..end` marker range is
50/// displayed, navigated, and deleted as a whole, and `payload` replaces it
51/// in the submitted text. Ranges are maintained through every edit, so
52/// text that merely looks like a marker is never treated as one.
53#[derive(Clone, Debug)]
54struct Atom {
55	start:   usize,
56	end:     usize,
57	payload: Str,
58}
59
60#[derive(Clone, Copy, Debug)]
61/// One visual word-wrapped row borrowed from an [`EditBuffer`].
62pub struct VisualRow<'a> {
63	/// Grapheme-aligned text belonging to the row.
64	pub text:          &'a str,
65	/// Cursor cell column when this row owns the cursor.
66	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/// Shared Pi-style flat text editing model used by the widget and chat editors.
77#[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	/// Creates a buffer with the cursor at the end of sanitized `text`.
104	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	/// Enables `</` close-tag completion (on by default).
126	pub const fn set_xml(&mut self, xml: bool) {
127		self.xml = xml;
128	}
129
130	#[must_use]
131	/// Returns the visible marker text.
132	pub fn text(&self) -> &str {
133		&self.text
134	}
135
136	#[must_use]
137	/// Returns the UTF-8 byte cursor.
138	pub const fn cursor(&self) -> usize {
139		self.cursor
140	}
141
142	#[must_use]
143	/// Returns the number of logical newline-delimited lines.
144	pub fn line_count(&self) -> usize {
145		self.text.bytes().filter(|byte| *byte == b'\n').count() + 1
146	}
147
148	#[must_use]
149	/// Returns the zero-based logical cursor line.
150	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	/// Returns the cursor's cell column within its logical line.
159	pub fn cursor_column(&self) -> u16 {
160		let (start, _) = self.line_bounds();
161		cell_width(&self.text[start..self.cursor])
162	}
163
164	/// Iterates logical lines without allocating.
165	pub fn logical_lines(
166		&self,
167	) -> impl DoubleEndedIterator<Item = &str> + Clone + std::iter::FusedIterator + '_ {
168		self.text.split('\n')
169	}
170
171	/// Replaces text without creating an undo entry, for history browsing.
172	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	/// Places the cursor on a logical line and cell column.
182	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	/// Places the cursor on a visual row and cell column.
200	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	/// Replaces a byte range as one undoable edit. A non-empty range that
210	/// touches an atomic marker widens to the whole marker, so partial
211	/// replacements can never tear a unit apart.
212	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	/// Widens `start..end` to whole-atom bounds for every atom it touches.
226	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	/// Replaces `range` with `replacement`, shifting the atoms behind the
237	/// edit and dropping any atom the edit tears through.
238	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	/// Inserts sanitized text at the cursor.
255	///
256	/// Text is inserted verbatim; hosts that collapse large pastes into
257	/// compact chips stage them through [`EditBuffer::insert_reference`].
258	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	/// Returns text with every atomic reference expanded to its payload.
274	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	/// Inserts an atomic reference at the cursor: `marker` is displayed,
289	/// navigated, and deleted as one unit, and expands to `payload` in the
290	/// submitted text. `marker` must be a single line.
291	///
292	/// The unit is tracked by position, not by content: typed text that
293	/// happens to equal `marker` stays ordinary text.
294	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	/// Byte ranges of atomic markers present in the text, ascending. Slice
311	/// [`EditBuffer::text`] with a range to recover the marker.
312	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	/// Returns expanded text and resets the buffer after submission.
323	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	/// Applies a decoded editor key at the given layout width.
335	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	/// Returns the visible visual rows.
398	///
399	/// Keyboard editing keeps the cursor in view. A manual viewport scroll
400	/// remains detached until the next editing command.
401	#[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	/// Moves the visible row window without moving the cursor.
428	///
429	/// Returns whether the clamped viewport offset changed.
430	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	/// Returns the clipped visual row count.
450	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	/// Reports whether the cursor is at the document's visual start.
456	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	/// Reports whether the cursor is at the document's visual end.
462	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/// Feature switches for [`Editor::new`]; everything defaults on.
1072/// Completion is not a switch: register one with [`Editor::set_completion`].
1073#[derive(Clone, Copy, Debug)]
1074pub struct EditorOptions {
1075	/// `:emoji` shortcode dropdown plus inline `:shortcode:` and
1076	/// emoticon (`:-)`) expansion while typing.
1077	pub emoji:   bool,
1078	/// Up/Down prompt history with draft restore below the newest entry.
1079	pub history: bool,
1080	/// XML affordances: `</` completes the innermost open tag, and
1081	/// renderers should apply structural markup highlighting.
1082	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/// Display content for one completion row.
1137#[derive(Clone, Debug, Eq, PartialEq)]
1138pub enum SuggestionDisplay {
1139	/// A plain text label (command name, file path, mention, …).
1140	Text(Str),
1141	/// An emoji paired with its shortcode or emoticon.
1142	Emoji {
1143		/// The emoji inserted on acceptance.
1144		emoji:     &'static str,
1145		/// The `:shortcode:` name or emoticon spelling that matched.
1146		shortcode: &'static str,
1147	},
1148}
1149
1150/// One selectable completion row.
1151#[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	/// Builds a row: on acceptance `insert` replaces the completion's
1161	/// prefix range verbatim; `label` is shown in the dropdown.
1162	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	/// Explanatory text shown beside the label.
1172	#[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	/// Ghost text shown after the cursor while this row is selected.
1179	#[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	/// Returns the row's dropdown label.
1186	#[must_use]
1187	pub const fn display(&self) -> &SuggestionDisplay {
1188		&self.display
1189	}
1190
1191	/// Returns optional explanatory text shown beside the label.
1192	#[must_use]
1193	pub fn description(&self) -> Option<&str> {
1194		self.description.as_deref()
1195	}
1196}
1197
1198/// Ranked dropdown rows; inline up to eight before spilling.
1199pub type SuggestionList = SmallVec<Suggestion, 8>;
1200
1201/// Ranked dropdown suggestions returned by [`EditorCompletion::suggest`].
1202pub struct Suggestions {
1203	/// Byte offset where the completed prefix starts; acceptance replaces
1204	/// `prefix_start..cursor` with the chosen suggestion's insert text.
1205	pub prefix_start: usize,
1206	/// Rows in display order; empty closes the dropdown.
1207	pub items:        SuggestionList,
1208}
1209
1210/// Buffer edit returned by [`EditorCompletion::tab`]: replaces `range`
1211/// with `insert` and leaves the cursor after it.
1212pub struct CompletionEdit {
1213	/// Byte range to replace.
1214	pub range:  std::ops::Range<usize>,
1215	/// Replacement text.
1216	pub insert: Str,
1217}
1218
1219/// Provider verdict for a Tab press, from [`EditorCompletion::tab`].
1220pub enum TabAction {
1221	/// Accept the selected dropdown row (no-op when none is open).
1222	Accept,
1223	/// Apply a buffer edit, e.g. materializing the current ghost hint.
1224	Edit(CompletionEdit),
1225	/// Pass Tab through to the embedding app.
1226	Pass,
1227}
1228
1229/// Pluggable completion engine registered with [`Editor::set_completion`].
1230///
1231/// The editor consults it after every edit, so an implementation chooses
1232/// its own trigger convention (`/`, `@`, `#`, or none at all) by
1233/// inspecting the text before the cursor. [`SlashCommands`] is the
1234/// built-in pi-style implementation.
1235pub trait EditorCompletion {
1236	/// Dropdown suggestions for the current text and byte cursor, or
1237	/// `None` to close the dropdown.
1238	fn suggest(&mut self, text: &str, cursor: usize) -> Option<Suggestions>;
1239
1240	/// Dim ghost text rendered after the cursor (usage hints, AI
1241	/// completion). Re-queried after every edit.
1242	fn hint(&mut self, text: &str, cursor: usize) -> Option<Str> {
1243		let _ = (text, cursor);
1244		None
1245	}
1246
1247	/// Tab pressed. `selected` is the highlighted row while this engine's
1248	/// dropdown is open. Defaults to pi's behavior: accept the open row,
1249	/// otherwise pass Tab through to the embedding app. The built-in
1250	/// emoji dropdown always accepts without consulting the engine.
1251	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
1261/// Active completion dropdown state.
1262pub struct Picker {
1263	prefix_start: usize,
1264	suggestions:  SuggestionList,
1265	selected:     usize,
1266	/// Produced by the registered engine (vs the built-in emoji dropdown).
1267	provided:     bool,
1268}
1269
1270impl Picker {
1271	/// Returns the centered five-row suggestion window and its first index.
1272	#[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	/// Returns the selected suggestion's absolute index.
1281	#[must_use]
1282	pub const fn selected(&self) -> usize {
1283		self.selected
1284	}
1285
1286	/// Returns the total number of matching suggestions.
1287	#[must_use]
1288	pub const fn len(&self) -> usize {
1289		self.suggestions.len()
1290	}
1291
1292	/// Reports whether no suggestions matched (never true for a live picker).
1293	#[must_use]
1294	pub const fn is_empty(&self) -> bool {
1295		self.suggestions.is_empty()
1296	}
1297}
1298
1299/// Result of handling one terminal key event.
1300#[derive(Debug, Eq, PartialEq)]
1301pub enum EditOutcome {
1302	/// Editor contents or selection changed.
1303	Changed,
1304	/// Complete input was submitted, with paste markers expanded.
1305	Submitted(String),
1306	/// The key had no editor meaning; the embedding app may act on it.
1307	Ignored,
1308}
1309
1310/// Editable multiline input with Pi-compatible completion and editing.
1311///
1312/// Wraps an [`EditBuffer`] with a pluggable [`EditorCompletion`] dropdown,
1313/// inline ghost hints, built-in emoji expansion, and prompt history —
1314/// each governed by [`EditorOptions`].
1315pub 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	/// Creates an empty editor with the given feature switches.
1329	#[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	/// Registers the completion engine driving the dropdown, ghost text,
1347	/// and Tab behavior; replaces any previous one.
1348	pub fn set_completion(&mut self, completion: Box<dyn EditorCompletion>) {
1349		self.completion = Some(completion);
1350		self.refresh();
1351	}
1352
1353	/// Returns the feature switches the editor was built with, so
1354	/// renderers can honor them (e.g. XML highlighting).
1355	#[must_use]
1356	pub const fn options(&self) -> EditorOptions {
1357		self.options
1358	}
1359
1360	/// Returns the visible text, with paste markers unexpanded.
1361	#[must_use]
1362	pub fn text(&self) -> &str {
1363		self.buffer.text()
1364	}
1365
1366	/// Returns the open completion dropdown, if any.
1367	#[must_use]
1368	pub const fn picker(&self) -> Option<&Picker> {
1369		self.picker.as_ref()
1370	}
1371
1372	/// Returns the rows the open completion dropdown occupies (0 when closed).
1373	#[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	/// Returns the clipped input row count at `width`, remembering the
1395	/// width for subsequent key handling.
1396	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	/// Returns the cursor-centered visible input rows at `width`.
1402	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	/// Places the cursor on a visual input row and refreshes derived editor
1408	/// state.
1409	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	/// Scrolls the input viewport by `delta` visual rows.
1415	///
1416	/// Returns whether the clamped viewport offset changed.
1417	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	/// Applies one decoded terminal key.
1422	pub fn handle_key(&mut self, key: Key) -> EditOutcome {
1423		self.handle(key)
1424	}
1425
1426	/// Applies one decoded editor key.
1427	///
1428	/// While the dropdown is open, navigation and acceptance keys drive
1429	/// it and `Esc` closes it; every other key edits the buffer as usual.
1430	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		// the built-in emoji dropdown accepts without consulting the engine
1490		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	/// Inserts sanitized text at the cursor (pastes, programmatic prefill).
1557	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	/// Inserts an atomic reference at the cursor; see
1568	/// [`EditBuffer::insert_reference`].
1569	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	/// Byte ranges of atomic markers in the visible text; see
1580	/// [`EditBuffer::atom_ranges`].
1581	#[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	/// Re-queries the completion engine (dropdown and ghost hint), falling
1644	/// back to the built-in emoji dropdown when the engine declines.
1645	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	/// Dim ghost text rendered after the cursor: the selected suggestion's
1668	/// hint while the dropdown is open, otherwise the completion engine's
1669	/// latest [`EditorCompletion::hint`].
1670	#[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/// One slash-command palette entry completed by [`SlashCommands`].
1730#[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/// One argument candidate completed after a command name (`/mcp add …`).
1740#[derive(Clone)]
1741struct CommandArg {
1742	name:        Str,
1743	description: Str,
1744	usage:       Option<Str>,
1745}
1746
1747impl Command {
1748	/// Builds a palette entry from its name, blurb, and alias spellings.
1749	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	/// Argument candidates offered once the command name is complete:
1760	/// `(name, description, usage)`, with `""` usage meaning none. Usage
1761	/// text ghosts after the argument pi-style (`<path>`, `<a> <b>`).
1762	#[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	/// Usage hint shown as dim ghost text after the cursor, pi-style
1776	/// (e.g. `<name> [--scope project|user]`).
1777	#[must_use]
1778	pub fn with_hint(mut self, hint: &str) -> Self {
1779		self.hint = Some(Str::new(hint));
1780		self
1781	}
1782
1783	/// The command's primary spelling, without the leading `/`.
1784	pub fn name(&self) -> &str {
1785		&self.name
1786	}
1787
1788	/// The one-line blurb shown beside the command name.
1789	pub fn description(&self) -> &str {
1790		&self.description
1791	}
1792}
1793
1794/// Pi-compatible slash-command completion over a fixed [`Command`] palette.
1795///
1796/// `/` at a line start opens ranked name completion, the first argument
1797/// completes against candidates, and usage text ghosts after the cursor
1798/// (pi `buildSubcommandInlineHint`).
1799pub struct SlashCommands {
1800	commands: Box<[Command]>,
1801}
1802
1803impl SlashCommands {
1804	/// Wraps a command palette for [`Editor::set_completion`].
1805	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		// a second slash means a path, not a command
1820		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	/// Completion for the argument position of a recognized command: the
1856	/// text after `/name ` matches against the command's argument
1857	/// candidates. Only the first argument completes; later words are
1858	/// free-form.
1859	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	/// Pi-style usage ghosting: bare `/name ` shows the command's own
1905	/// usage; a partial argument shows its remaining characters plus
1906	/// usage; a chosen argument ghosts the usage words not yet typed.
1907	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				// still typing the argument name: remaining chars + usage
1921				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				// argument chosen: ghost the usage words not yet typed
1935				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		// completion is a typing affordance; a pasted document already
2119		// carries its closers, so duplicating them would corrupt the paste
2120		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	/// Small palette with enough shape for ranking-sensitive expectations.
2131	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		// no completion registered: `/` never opens a dropdown
2169		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		// second word is free-form: no picker re-opens
2202		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		// bare `/name ` ghosts the command usage, picker open or not
2213		assert_eq!(editor.inline_hint().as_deref(), Some("plan|import|compare"));
2214		// typing an argument prefix ghosts the remaining name + its usage
2215		type_text(&mut editor, "im");
2216		assert_eq!(editor.inline_hint().as_deref(), Some("port <path>"));
2217		// accepting the argument ghosts its remaining usage
2218		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		// usage words already typed stop ghosting
2222		type_text(&mut editor, "report.json");
2223		assert_eq!(editor.inline_hint(), None);
2224		// multi-word usages ghost only the remainder (pi counts whole and
2225		// in-progress words alike)
2226		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		// Pi kills only to this line's start, rather than clearing the document.
2314		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		// Overlap the marker's first byte only: the whole unit must go.
2485		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		// Insertions at the marker boundary leave the unit intact.
2489		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	/// Toy engine: `@` mentions with any-key trigger, a fixed ghost
2538	/// completion after `hel`, and Tab materializing that ghost text.
2539	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			// with our dropdown open, Tab belongs to the app (focus switch)
2562			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		// the engine overrides Tab even while its own dropdown is open
2580		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		// nothing to complete: Tab passes through to the embedding app
2590		assert_eq!(editor.handle(Key::Tab), EditOutcome::Ignored);
2591
2592		// the emoji dropdown accepts on Tab without consulting the engine
2593		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}