Skip to main content

mathtex_editor_core/
editor.rs

1//! Editor owns the `Tree`, cursor, selection, host callbacks, render output, and undo snapshots.
2
3use crate::command::{Command, CommandResult, Dir, ExitDir};
4use crate::doc::{Document, DocFragment};
5use crate::host::{Host, RenderOutput};
6use crate::menu::{Menu, RowEffect};
7use crate::model::{Cursor, NodeId, Selection, Symbol};
8use crate::{nav, selection};
9
10/// An opaque undo entry containing the previous document snapshot.
11#[derive(Debug, Clone)]
12pub struct UndoEntry(pub(crate) Document);
13
14impl UndoEntry {
15    /// The document snapshot this entry restores, so hosts can audit token liveness.
16    pub fn document(&self) -> &Document {
17        &self.0
18    }
19}
20
21/// The headless editor. Generic over the host's callback implementation.
22pub struct Editor<H: Host> {
23    tree: crate::model::Tree,
24    cursor: Cursor,
25    /// Selection anchor whose seq can differ from `cursor.seq` during structural drag detours.
26    sel_anchor: Option<Cursor>,
27    /// The Backspace swap or delete dropdown while it is open.
28    pending_menu: Option<Menu>,
29    host: H,
30}
31
32impl<H: Host> Editor<H> {
33    /// Create an editor, optionally seeded with a document.
34    pub fn new(host: H, initial: Option<Document>) -> Self {
35        let tree = match initial {
36            Some(d) => crate::model::Tree::from_doc(&d),
37            None => crate::model::Tree::new(),
38        };
39        let cursor = Cursor {
40            seq: tree.root(),
41            index: 0,
42        };
43        Self {
44            tree,
45            cursor,
46            sel_anchor: None,
47            pending_menu: None,
48            host,
49        }
50    }
51
52    /// Return the current cursor.
53    pub fn cursor(&self) -> Cursor {
54        self.cursor
55    }
56
57    /// Return matrix shape at the cursor so the host can show row and column controls.
58    pub fn matrix_shape(&self) -> Option<(usize, usize)> {
59        self.tree.matrix_shape_at(self.cursor)
60    }
61
62    /// Return the active selection when anchor and cursor share a seq and distinct indexes.
63    pub fn current_selection(&self) -> Option<Selection> {
64        self.sel_anchor
65            .filter(|a| a.seq == self.cursor.seq && a.index != self.cursor.index)
66            .map(|a| Selection {
67                seq: self.cursor.seq,
68                anchor: a.index,
69                focus: self.cursor.index,
70            })
71    }
72
73    /// The single mutation command entry point. The host maps keys and pointer input to `Command`.
74    pub fn exec(&mut self, cmd: Command) -> CommandResult {
75        if self.pending_menu.is_some() {
76            if let Some(result) = self.exec_menu(&cmd) {
77                return result;
78            }
79            // Unrecognized menu commands close it and then execute normally.
80            self.pending_menu = None;
81        }
82        match cmd {
83            Command::Move(Dir::Left) => {
84                // Crossing a host object routes entry to the host, which may consume the motion.
85                if let Some(token) = self.host_box_neighbor(false) {
86                    if self.host.on_object_enter(token, crate::command::Side::After) {
87                        return self.moved(self.cursor);
88                    }
89                }
90                match nav::move_left(&self.tree, self.cursor) {
91                    Some(c) => self.moved(c),
92                    None => self.boundary(ExitDir::Left),
93                }
94            }
95            Command::Move(Dir::Right) => {
96                if let Some(token) = self.host_box_neighbor(true) {
97                    if self.host.on_object_enter(token, crate::command::Side::Before) {
98                        return self.moved(self.cursor);
99                    }
100                }
101                match nav::move_right(&self.tree, self.cursor) {
102                    Some(c) => self.moved(c),
103                    None => self.boundary(ExitDir::Right),
104                }
105            }
106            Command::Move(Dir::Up) => match nav::vertical(&self.tree, self.cursor, true) {
107                Some(c) => self.moved(c),
108                None => self.vertical_boundary(true),
109            },
110            Command::Move(Dir::Down) => match nav::vertical(&self.tree, self.cursor, false) {
111                Some(c) => self.moved(c),
112                None => self.vertical_boundary(false),
113            },
114            Command::MoveLineStart => self.moved(Cursor {
115                seq: self.cursor.seq,
116                index: 0,
117            }),
118            Command::MoveLineEnd => self.moved(Cursor {
119                seq: self.cursor.seq,
120                index: self.tree.len(self.cursor.seq),
121            }),
122            Command::Tab => match self.next_fill_target(true) {
123                Some(c) => self.moved(c),
124                None => self.exit_result(ExitDir::Right),
125            },
126            Command::ShiftTab => match self.next_fill_target(false) {
127                Some(c) => self.moved(c),
128                None => self.exit_result(ExitDir::Left),
129            },
130            Command::MoveTo(p) => {
131                let (latex, spans) = self.tree.export_latex();
132                let ir = self.host.typeset(&latex);
133                let c = crate::matcher::hit_test(&ir, &spans, &self.tree, p);
134                self.moved(crate::nav::normalize(&self.tree, c))
135            }
136            Command::ExtendTo(p) => {
137                let (latex, spans) = self.tree.export_latex();
138                let ir = self.host.typeset(&latex);
139                let target = crate::nav::normalize(
140                    &self.tree,
141                    crate::matcher::hit_test(&ir, &spans, &self.tree, p),
142                );
143                self.extend_to(target)
144            }
145
146            Command::Extend(Dir::Left) => self.extend(false),
147            Command::Extend(Dir::Right) => self.extend(true),
148            Command::Extend(Dir::Up) | Command::Extend(Dir::Down) => self.no_op(),
149            Command::SelectAll => {
150                let s = selection::select_all(&self.tree);
151                self.cursor = Cursor {
152                    seq: s.seq,
153                    index: s.focus,
154                };
155                self.sel_anchor = Some(Cursor { seq: s.seq, index: s.anchor });
156                self.no_op()
157            }
158            Command::Collapse => {
159                self.sel_anchor = None;
160                self.no_op()
161            }
162            // With no pending menu, `Confirm` behaves like `Collapse`.
163            Command::Confirm => {
164                self.sel_anchor = None;
165                self.no_op()
166            }
167            Command::MenuSelect(_) => self.no_op(),
168
169            Command::InsertAtom(sym) => {
170                let before = self.snapshot();
171                self.replace_selection();
172                let c = self.tree.insert_atom(self.cursor, sym);
173                self.after_edit(before, c)
174            }
175            Command::InsertHostBox(token) => {
176                let before = self.snapshot();
177                self.replace_selection();
178                let c = self.tree.insert_host_box(self.cursor, token);
179                self.after_edit(before, c)
180            }
181            Command::InsertText(text) => {
182                let before = self.snapshot();
183                self.replace_selection();
184                let mut c = self.cursor;
185                for ch in text.chars() {
186                    c = self.tree.insert_atom(c, Symbol::from_char(ch));
187                }
188                self.after_edit(before, c)
189            }
190            Command::InsertFraction(style) => {
191                let before = self.snapshot();
192                let sel = self.current_selection();
193                let c = self.tree.insert_fraction(self.cursor, style, sel);
194                self.after_edit(before, c)
195            }
196            Command::InsertScript(slot) => {
197                // `_` and `^` next to a big operator navigate into limits instead of editing.
198                if let Some(c) = self.tree.bigop_limit_target(self.cursor, slot) {
199                    self.moved(c)
200                } else {
201                    let before = self.snapshot();
202                    let c = self.tree.attach_script(self.cursor, slot);
203                    self.after_edit(before, c)
204                }
205            }
206            Command::InsertBigOp(op) => {
207                let before = self.snapshot();
208                let c = self.tree.insert_big_op(self.cursor, op);
209                self.after_edit(before, c)
210            }
211            Command::InsertSqrt => {
212                let before = self.snapshot();
213                let sel = self.current_selection();
214                let c = self.tree.insert_sqrt(self.cursor, sel);
215                self.after_edit(before, c)
216            }
217            Command::InsertDelimiters { open, close } => {
218                let before = self.snapshot();
219                let sel = self.current_selection();
220                let c = self.tree.insert_delimiters(self.cursor, open, close, sel);
221                self.after_edit(before, c)
222            }
223            Command::InsertAccent(mark) => {
224                let before = self.snapshot();
225                let sel = self.current_selection();
226                let c = self.tree.insert_accent(self.cursor, mark, sel);
227                self.after_edit(before, c)
228            }
229            Command::InsertUnderOver(spec) => {
230                let before = self.snapshot();
231                let sel = self.current_selection();
232                let c = self.tree.insert_under_over(self.cursor, spec, sel);
233                self.after_edit(before, c)
234            }
235            Command::InsertStyled(variant) | Command::ApplyStyle(variant) => {
236                let before = self.snapshot();
237                let sel = self.current_selection();
238                let c = self.tree.insert_styled(self.cursor, variant, sel);
239                self.after_edit(before, c)
240            }
241            Command::InsertMatrix { env, rows, cols } => {
242                let before = self.snapshot();
243                self.replace_selection();
244                let c = self.tree.insert_matrix(self.cursor, env, rows, cols);
245                self.after_edit(before, c)
246            }
247            Command::InsertFragment(frag) => {
248                let before = self.snapshot();
249                self.replace_selection();
250                let c = self.tree.insert_fragment(self.cursor, &frag);
251                self.after_edit(before, c)
252            }
253
254            Command::DeleteBackward => {
255                if let Some(s) = self.current_selection() {
256                    let before = self.snapshot();
257                    let c = self.tree.delete_selection(s);
258                    self.after_edit(before, c)
259                } else if let Some(node) = nav::adjacent_structure_backward(&self.tree, self.cursor) {
260                    // First Backspace opens a swap or delete menu, or selects for the next Backspace.
261                    self.trigger_structure_backspace(node)
262                } else if self.cursor.seq == self.tree.root() && self.tree.is_empty(self.cursor.seq)
263                {
264                    self.close_result()
265                } else {
266                    let before = self.snapshot();
267                    let c = self.tree.delete_backward(self.cursor);
268                    self.after_edit(before, c)
269                }
270            }
271            Command::DeleteForward => {
272                if let Some(s) = self.current_selection() {
273                    let before = self.snapshot();
274                    let c = self.tree.delete_selection(s);
275                    self.after_edit(before, c)
276                } else if let Some(node) = nav::adjacent_structure_forward(&self.tree, self.cursor) {
277                    self.trigger_structure_backspace(node)
278                } else if self.cursor.seq == self.tree.root() && self.tree.is_empty(self.cursor.seq)
279                {
280                    self.close_result()
281                } else {
282                    let before = self.snapshot();
283                    let c = self.tree.delete_forward(self.cursor);
284                    self.after_edit(before, c)
285                }
286            }
287
288            Command::MatrixInsertRow(side) => {
289                let before = self.snapshot();
290                let c = self.tree.matrix_insert_row(self.cursor, side);
291                self.after_edit(before, c)
292            }
293            Command::MatrixDeleteRow => {
294                let before = self.snapshot();
295                let c = self.tree.matrix_delete_row(self.cursor);
296                self.after_edit(before, c)
297            }
298            Command::MatrixInsertCol(side) => {
299                let before = self.snapshot();
300                let c = self.tree.matrix_insert_col(self.cursor, side);
301                self.after_edit(before, c)
302            }
303            Command::MatrixDeleteCol => {
304                let before = self.snapshot();
305                let c = self.tree.matrix_delete_col(self.cursor);
306                self.after_edit(before, c)
307            }
308        }
309    }
310
311    /// Produce the paintable render output with IR plus caret and selection rects.
312    pub fn render(&self) -> RenderOutput {
313        let (latex, spans) = self.tree.export_latex();
314        let ir = self.host.typeset(&latex);
315        crate::matcher::render(
316            &self.tree,
317            self.cursor,
318            self.current_selection(),
319            &spans,
320            ir,
321            self.pending_menu.as_ref(),
322        )
323    }
324
325    /// Serialize the current selection for the host's clipboard.
326    pub fn selection_latex(&self) -> String {
327        match self.current_selection() {
328            Some(s) => self.tree.selection_latex(s),
329            None => String::new(),
330        }
331    }
332    /// Serialize the current selection as a document fragment.
333    pub fn selection_fragment(&self) -> DocFragment {
334        match self.current_selection() {
335            Some(s) => {
336                let lo = s.anchor.min(s.focus);
337                let hi = s.anchor.max(s.focus);
338                self.tree
339                    .to_doc()
340                    .root()
341                    .get(lo..hi)
342                    .map(<[_]>::to_vec)
343                    .unwrap_or_default()
344            }
345            None => Vec::new(),
346        }
347    }
348
349    /// Apply an undo entry from the host stack and return the inverse redo entry.
350    pub fn apply_undo(&mut self, entry: UndoEntry) -> UndoEntry {
351        let current = self.tree.to_doc();
352        self.tree = crate::model::Tree::from_doc(&entry.0);
353        self.cursor = Cursor {
354            seq: self.tree.root(),
355            index: self.tree.len(self.tree.root()),
356        };
357        self.sel_anchor = None;
358        self.host.on_render();
359        UndoEntry(current)
360    }
361
362    /// Place the caret at the root edge nearest `side` and clear selection.
363    pub fn enter_from(&mut self, side: ExitDir) {
364        let root = self.tree.root();
365        let index = match side {
366            ExitDir::Left | ExitDir::Up => 0,
367            ExitDir::Right | ExitDir::Down => self.tree.len(root),
368        };
369        self.cursor = Cursor { seq: root, index };
370        self.sel_anchor = None;
371        self.pending_menu = None;
372        self.host.on_render();
373    }
374
375    /// Return the whole document for host persistence.
376    pub fn document(&self) -> Document {
377        self.tree.to_doc()
378    }
379
380    /// Return clean LaTeX for display and external copy, dropping empty placeholders.
381    pub fn latex(&self) -> String {
382        self.tree.export_display()
383    }
384
385    fn snapshot(&self) -> Document {
386        self.tree.to_doc()
387    }
388
389    /// The token of the `HostBox` the caret would cross next, right or left of the cursor.
390    fn host_box_neighbor(&self, right: bool) -> Option<u32> {
391        let items = self.tree.items(self.cursor.seq);
392        let node = if right {
393            *items.get(self.cursor.index)?
394        } else {
395            *items.get(self.cursor.index.checked_sub(1)?)?
396        };
397        match self.tree.kind(node) {
398            Some(crate::model::Kind::HostBox { token }) => Some(*token),
399            _ => None,
400        }
401    }
402
403    /// Delete the active selection if any and collapse the cursor to its start.
404    fn replace_selection(&mut self) {
405        if let Some(s) = self.current_selection() {
406            self.cursor = self.tree.delete_selection(s);
407            self.sel_anchor = None;
408        }
409    }
410
411    /// Select a whole structure before Backspace or Delete removes or replaces it.
412    fn select_node(&mut self, node: NodeId) -> CommandResult {
413        let Some((seq, idx)) = self.tree.index_in_parent(node) else {
414            return self.no_op();
415        };
416        self.cursor = Cursor { seq, index: idx + 1 };
417        self.sel_anchor = Some(Cursor { seq, index: idx });
418        self.no_op()
419    }
420
421    /// Open the swap or delete menu for swappable structures, else select the node.
422    fn trigger_structure_backspace(&mut self, node: NodeId) -> CommandResult {
423        match self.tree.swap_variants(node) {
424            Some(variants) => {
425                self.pending_menu = Some(Menu::for_node(node, variants));
426                self.no_op()
427            }
428            None => self.select_node(node),
429        }
430    }
431
432    /// Consume recognized menu commands and let other commands fall through.
433    fn exec_menu(&mut self, cmd: &Command) -> Option<CommandResult> {
434        match cmd {
435            Command::InsertAtom(sym) if sym.latex.chars().count() == 1 => {
436                let menu = self.pending_menu.as_mut()?;
437                menu.query.push_str(&sym.latex);
438                menu.selected = 0;
439                Some(self.no_op())
440            }
441            Command::InsertText(text) => {
442                let menu = self.pending_menu.as_mut()?;
443                menu.query.push_str(text);
444                menu.selected = 0;
445                Some(self.no_op())
446            }
447            Command::DeleteBackward => {
448                let menu = self.pending_menu.as_mut()?;
449                if menu.query.is_empty() {
450                    let selected = menu.selected;
451                    Some(self.commit_menu_row(selected))
452                } else {
453                    menu.query.pop();
454                    menu.selected = 0;
455                    Some(self.no_op())
456                }
457            }
458            Command::Move(Dir::Up) => {
459                let menu = self.pending_menu.as_mut()?;
460                menu.selected = menu.selected.saturating_sub(1);
461                Some(self.no_op())
462            }
463            Command::Move(Dir::Down) => {
464                let menu = self.pending_menu.as_mut()?;
465                let max = menu.visible().len().saturating_sub(1);
466                menu.selected = (menu.selected + 1).min(max);
467                Some(self.no_op())
468            }
469            Command::Confirm => {
470                let selected = self.pending_menu.as_ref()?.selected;
471                Some(self.commit_menu_row(selected))
472            }
473            Command::MenuSelect(i) => Some(self.commit_menu_row(*i)),
474            Command::Collapse => {
475                self.pending_menu = None;
476                Some(self.no_op())
477            }
478            _ => None,
479        }
480    }
481
482    /// Commit the visible menu row by deleting or swapping the anchored structure.
483    fn commit_menu_row(&mut self, visible_idx: usize) -> CommandResult {
484        let Some(menu) = self.pending_menu.take() else {
485            return self.no_op();
486        };
487        let visible = menu.visible();
488        let Some(row) = visible.get(visible_idx) else {
489            return self.no_op();
490        };
491        match row {
492            RowEffect::Delete => {
493                let before = self.snapshot();
494                let Some((seq, idx)) = self.tree.index_in_parent(menu.anchor) else {
495                    return self.no_op();
496                };
497                let c = self.tree.delete_selection(Selection { seq, anchor: idx, focus: idx + 1 });
498                self.after_edit(before, c)
499            }
500            RowEffect::Swap(variant) => {
501                let before = self.snapshot();
502                self.tree.apply_swap(menu.anchor, variant);
503                self.after_edit(before, self.cursor)
504            }
505        }
506    }
507
508    fn extend(&mut self, right: bool) -> CommandResult {
509        let sel = self.current_selection().unwrap_or(Selection {
510            seq: self.cursor.seq,
511            anchor: self.cursor.index,
512            focus: self.cursor.index,
513        });
514        let ns = selection::extend(&self.tree, sel, right);
515        self.cursor = Cursor {
516            seq: ns.seq,
517            index: ns.focus,
518        };
519        self.sel_anchor = Some(Cursor { seq: ns.seq, index: ns.anchor });
520        self.no_op()
521    }
522
523    fn next_fill_target(&self, forward: bool) -> Option<Cursor> {
524        // Tab fills big operator limits in reading order from lower to upper.
525        if forward {
526            // From the lower limit, go to the empty upper limit next.
527            if let Some(parent) = self.tree.seq_parent(self.cursor.seq) {
528                if let Some(crate::model::Kind::BigOp { lower, upper, .. }) =
529                    self.tree.kind(parent)
530                {
531                    if self.cursor.seq == *lower && self.tree.is_empty(*upper) {
532                        return Some(Cursor {
533                            seq: *upper,
534                            index: 0,
535                        });
536                    }
537                }
538            }
539            // Just after a big operator, go to the first empty limit.
540            if self.cursor.index > 0 {
541                let prev = self.tree.items(self.cursor.seq)[self.cursor.index - 1];
542                if let Some(crate::model::Kind::BigOp { lower, upper, .. }) =
543                    self.tree.kind(prev)
544                {
545                    for s in [*lower, *upper] {
546                        if self.tree.is_empty(s) {
547                            return Some(Cursor { seq: s, index: 0 });
548                        }
549                    }
550                }
551            }
552            // From a radical radicand, visit the empty degree before continuing.
553            if let Some(parent) = self.tree.seq_parent(self.cursor.seq) {
554                if let Some(crate::model::Kind::Sqrt { index, radicand }) = self.tree.kind(parent) {
555                    if self.cursor.seq == *radicand && self.tree.is_empty(*index) {
556                        return Some(Cursor {
557                            seq: *index,
558                            index: 0,
559                        });
560                    }
561                }
562            }
563        }
564        let mut c = self.cursor;
565        for _ in 0..100_000 {
566            c = if forward {
567                nav::move_right(&self.tree, c)?
568            } else {
569                nav::move_left(&self.tree, c)?
570            };
571            if self.tree.is_empty(c.seq) {
572                return Some(Cursor {
573                    seq: c.seq,
574                    index: 0,
575                });
576            }
577        }
578        None
579    }
580
581    /// Finish a mutating command by setting the cursor and notifying the host.
582    fn after_edit(&mut self, before: Document, cursor: Cursor) -> CommandResult {
583        self.cursor = cursor;
584        self.sel_anchor = None;
585        let doc = self.tree.to_doc();
586        self.host.on_change(&doc);
587        self.host.on_render();
588        CommandResult {
589            cursor,
590            undo: Some(UndoEntry(before)),
591            exit: None,
592        }
593    }
594
595    /// Extend selection toward `target`, preserving the original drag anchor across seq changes.
596    fn extend_to(&mut self, target: Cursor) -> CommandResult {
597        let anchor = self.sel_anchor.unwrap_or(self.cursor);
598        let sel = selection::extend_to(&self.tree, anchor, target);
599        self.sel_anchor = Some(Cursor { seq: sel.seq, index: sel.anchor });
600        self.cursor = Cursor { seq: sel.seq, index: sel.focus };
601        self.no_op()
602    }
603
604    /// Return a motion result and clear selection.
605    fn moved(&mut self, cursor: Cursor) -> CommandResult {
606        self.cursor = cursor;
607        self.sel_anchor = None;
608        CommandResult {
609            cursor,
610            undo: None,
611            exit: None,
612        }
613    }
614
615    /// Return a result that keeps current cursor and selection state.
616    fn no_op(&self) -> CommandResult {
617        CommandResult {
618            cursor: self.cursor,
619            undo: None,
620            exit: None,
621        }
622    }
623
624    /// Collapse active selection at a boundary, or exit toward `dir` for a plain caret.
625    fn boundary(&mut self, dir: ExitDir) -> CommandResult {
626        if self.current_selection().is_some() {
627            return self.moved(self.cursor);
628        }
629        self.exit_result(dir)
630    }
631
632    /// Exit vertical motion only when no ancestor slot can keep the caret inside math.
633    fn vertical_boundary(&mut self, up: bool) -> CommandResult {
634        if self.current_selection().is_some() {
635            return self.moved(self.cursor);
636        }
637        let dir = if up { ExitDir::Up } else { ExitDir::Down };
638        let mut seq = self.cursor.seq;
639        loop {
640            let Some(parent) = self.tree.seq_parent(seq) else {
641                return self.exit_result(dir);
642            };
643            let Some((pseq, _)) = self.tree.index_in_parent(parent) else {
644                return self.exit_result(dir);
645            };
646            if nav::vertical(&self.tree, Cursor { seq: pseq, index: 0 }, up).is_some() {
647                return self.moved(self.cursor);
648            }
649            seq = pseq;
650        }
651    }
652
653    fn exit_result(&self, dir: ExitDir) -> CommandResult {
654        self.host.on_exit(dir);
655        CommandResult {
656            cursor: self.cursor,
657            undo: None,
658            exit: Some(dir),
659        }
660    }
661
662    /// Return a result that asks the host to end math mode without mutation.
663    fn close_result(&self) -> CommandResult {
664        self.host.request_close();
665        CommandResult {
666            cursor: self.cursor,
667            undo: None,
668            exit: None,
669        }
670    }
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use crate::command::Command;
677    use crate::host::Host;
678    use crate::model::{FracStyle, Kind, MathClass, ScriptSlot, Symbol, Variant};
679
680    struct NullHost;
681    impl Host for NullHost {
682        fn typeset(&self, _latex: &str) -> mathtex_ir::Fragment {
683            mathtex_ir::Fragment::default()
684        }
685        fn on_change(&self, _doc: &Document) {}
686        fn on_exit(&self, _dir: ExitDir) {}
687        fn request_close(&self) {}
688        fn on_render(&self) {}
689    }
690
691    #[derive(Default)]
692    struct HostLog {
693        exits: std::cell::RefCell<Vec<ExitDir>>,
694        closes: std::cell::Cell<usize>,
695        renders: std::cell::Cell<usize>,
696    }
697
698    struct RecordingHost(std::rc::Rc<HostLog>);
699    impl Host for RecordingHost {
700        fn typeset(&self, _latex: &str) -> mathtex_ir::Fragment {
701            mathtex_ir::Fragment::default()
702        }
703        fn on_change(&self, _doc: &Document) {}
704        fn on_exit(&self, dir: ExitDir) {
705            self.0.exits.borrow_mut().push(dir);
706        }
707        fn request_close(&self) {
708            self.0.closes.set(self.0.closes.get() + 1);
709        }
710        fn on_render(&self) {
711            self.0.renders.set(self.0.renders.get() + 1);
712        }
713    }
714
715    fn recording() -> (std::rc::Rc<HostLog>, Editor<RecordingHost>) {
716        let log = std::rc::Rc::new(HostLog::default());
717        let ed = Editor::new(RecordingHost(log.clone()), None);
718        (log, ed)
719    }
720
721    fn sym(c: &str) -> Symbol {
722        Symbol {
723            latex: c.into(),
724            class: MathClass::Ord,
725        }
726    }
727
728    #[test]
729    fn insert_fraction_and_navigate() {
730        let mut ed = Editor::new(NullHost, None);
731        ed.exec(Command::InsertFraction(FracStyle::Bar));
732        // Cursor is in the empty numerator.
733        assert!(ed.tree.is_empty(ed.cursor.seq));
734        ed.exec(Command::InsertAtom(sym("x")));
735        // Numerator now contains "x".
736        assert_eq!(ed.tree.len(ed.cursor.seq), 1);
737    }
738
739    #[test]
740    fn backspace_next_to_structure_selects_then_deletes() {
741        let mut ed = Editor::new(NullHost, None);
742        ed.exec(Command::InsertFraction(FracStyle::Bar));
743        ed.exec(Command::InsertAtom(sym("a")));
744        let root = ed.tree.root();
745        let frac = ed.tree.items(root)[0];
746        let den = ed.tree.child_seqs(frac)[1];
747        ed.cursor = Cursor { seq: den, index: 0 };
748        ed.exec(Command::InsertAtom(sym("b"))); // \frac{a}{b}
749        // Move out to the root, right after the whole fraction.
750        ed.cursor = Cursor { seq: root, index: 1 };
751        ed.sel_anchor = None;
752
753        // First Backspace selects the whole fraction without deleting it.
754        ed.exec(Command::DeleteBackward);
755        assert_eq!(ed.tree.len(root), 1);
756        let sel = ed.current_selection().expect("first backspace should select the structure");
757        assert_eq!((sel.seq, sel.anchor, sel.focus), (root, 0, 1));
758
759        // Second Backspace deletes the selected fraction.
760        ed.exec(Command::DeleteBackward);
761        assert_eq!(ed.tree.len(root), 0);
762    }
763
764    #[test]
765    fn backspace_next_to_delim_opens_swap_menu() {
766        let mut ed = Editor::new(NullHost, None);
767        ed.exec(Command::InsertDelimiters { open: '(', close: ')' });
768        let root = ed.tree.root();
769        ed.cursor = Cursor { seq: root, index: 1 };
770        ed.sel_anchor = None;
771
772        ed.exec(Command::DeleteBackward);
773        // Swappable structures open a menu instead of plain selection.
774        assert!(ed.current_selection().is_none());
775        let out = ed.render();
776        let menu = out.menu.expect("swap menu should be open");
777        assert_eq!(menu.items[0].label, "Delete"); // pinned first row
778        assert!(menu.items.len() > 1); // plus bracket alternatives
779        assert_eq!(ed.tree.len(root), 1); // nothing deleted yet
780    }
781
782    #[test]
783    fn menu_filters_by_typed_query_but_keeps_delete_pinned() {
784        let mut ed = Editor::new(NullHost, None);
785        ed.exec(Command::InsertDelimiters { open: '(', close: ')' });
786        let root = ed.tree.root();
787        ed.cursor = Cursor { seq: root, index: 1 };
788        ed.sel_anchor = None;
789        ed.exec(Command::DeleteBackward); // opens the menu
790
791        ed.exec(Command::InsertText("brace".into()));
792        let out = ed.render();
793        let menu = out.menu.expect("menu should still be open while typing");
794        assert!(menu.items.iter().any(|i| i.label == "Delete")); // still pinned
795        assert!(menu.items.iter().any(|i| i.label.to_lowercase().contains("brace")));
796        assert!(!menu.items.iter().any(|i| i.label.contains('[')));
797    }
798
799    #[test]
800    fn menu_backspace_with_empty_query_confirms_delete() {
801        let mut ed = Editor::new(NullHost, None);
802        ed.exec(Command::InsertDelimiters { open: '(', close: ')' });
803        ed.exec(Command::InsertAtom(sym("x")));
804        let root = ed.tree.root();
805        ed.cursor = Cursor { seq: root, index: 1 };
806        ed.sel_anchor = None;
807        ed.exec(Command::DeleteBackward); // opens the menu, query empty
808        ed.exec(Command::DeleteBackward); // empty query confirms delete
809        assert_eq!(ed.tree.len(root), 0);
810        assert!(ed.render().menu.is_none());
811    }
812
813    #[test]
814    fn menu_select_swaps_the_bracket_in_place() {
815        let mut ed = Editor::new(NullHost, None);
816        ed.exec(Command::InsertDelimiters { open: '(', close: ')' });
817        ed.exec(Command::InsertAtom(sym("x")));
818        let root = ed.tree.root();
819        ed.cursor = Cursor { seq: root, index: 1 };
820        ed.sel_anchor = None;
821        ed.exec(Command::DeleteBackward); // opens the menu
822
823        let out = ed.render();
824        let menu = out.menu.unwrap();
825        let idx = menu
826            .items
827            .iter()
828            .position(|i| i.label.starts_with("[ ]"))
829            .expect("bracket alternative present");
830        ed.exec(Command::MenuSelect(idx));
831
832        assert!(ed.render().menu.is_none()); // menu closed after commit
833        let delim = ed.tree.items(root)[0];
834        match ed.tree.kind(delim) {
835            Some(Kind::Delim { open, close, body }) => {
836                assert_eq!((*open, *close), ('[', ']'));
837                assert_eq!(ed.tree.len(*body), 1); // "x" preserved
838            }
839            _ => panic!("expected Delim"),
840        }
841    }
842
843    #[test]
844    fn menu_escape_cancels_without_changes() {
845        let mut ed = Editor::new(NullHost, None);
846        ed.exec(Command::InsertDelimiters { open: '(', close: ')' });
847        let root = ed.tree.root();
848        ed.cursor = Cursor { seq: root, index: 1 };
849        ed.sel_anchor = None;
850        ed.exec(Command::DeleteBackward); // opens the menu
851        ed.exec(Command::Collapse); // Escape
852        assert!(ed.render().menu.is_none());
853        assert_eq!(ed.tree.len(root), 1); // untouched
854        match ed.tree.kind(ed.tree.items(root)[0]) {
855            Some(Kind::Delim { open, close, .. }) => assert_eq!((*open, *close), ('(', ')')),
856            _ => panic!("expected Delim"),
857        }
858    }
859
860    #[test]
861    fn backspace_next_to_text_block_enters_instead_of_selecting() {
862        let mut ed = Editor::new(NullHost, None);
863        let c = ed.exec(Command::InsertStyled(Variant::Text));
864        let content = c.cursor.seq;
865        ed.cursor = Cursor { seq: content, index: 0 };
866        ed.exec(Command::InsertAtom(sym("x")));
867        let root = ed.tree.root();
868        ed.cursor = Cursor { seq: root, index: 1 };
869        ed.sel_anchor = None;
870
871        ed.exec(Command::DeleteBackward);
872        // The \text{} carve out enters the text node without selecting or deleting it.
873        assert!(ed.current_selection().is_none());
874        assert_eq!(ed.cursor.seq, content);
875        assert_eq!(ed.tree.len(root), 1);
876        assert_eq!(ed.tree.len(content), 1);
877    }
878
879    #[test]
880    fn typing_while_structure_selected_replaces_it() {
881        let mut ed = Editor::new(NullHost, None);
882        ed.exec(Command::InsertFraction(FracStyle::Bar));
883        let root = ed.tree.root();
884        ed.cursor = Cursor { seq: root, index: 1 };
885        ed.sel_anchor = None;
886        ed.exec(Command::DeleteBackward); // selects the empty fraction
887        assert!(ed.current_selection().is_some());
888
889        ed.exec(Command::InsertAtom(sym("z")));
890        assert_eq!(ed.tree.len(root), 1);
891        assert!(matches!(ed.tree.kind(ed.tree.items(root)[0]), Some(Kind::Atom(_))));
892    }
893
894    #[test]
895    fn delete_forward_next_to_structure_selects_then_deletes() {
896        let mut ed = Editor::new(NullHost, None);
897        ed.exec(Command::InsertFraction(FracStyle::Bar));
898        let root = ed.tree.root();
899        ed.cursor = Cursor { seq: root, index: 0 };
900        ed.sel_anchor = None;
901
902        ed.exec(Command::DeleteForward);
903        assert_eq!(ed.tree.len(root), 1);
904        let sel = ed.current_selection().expect("first delete-forward should select the structure");
905        assert_eq!(
906            (sel.seq, sel.anchor.min(sel.focus), sel.anchor.max(sel.focus)),
907            (root, 0, 1)
908        );
909
910        ed.exec(Command::DeleteForward);
911        assert_eq!(ed.tree.len(root), 0);
912    }
913
914    #[test]
915    fn select_all_then_delete_clears() {
916        let mut ed = Editor::new(NullHost, None);
917        ed.exec(Command::InsertText("abc".into()));
918        ed.exec(Command::SelectAll);
919        assert!(ed.current_selection().is_some());
920        ed.exec(Command::DeleteBackward);
921        assert_eq!(ed.document().len(), 0);
922    }
923
924    #[test]
925    fn pointer_drag_anchors_at_cursor_not_first_move_target() {
926        let mut ed = Editor::new(NullHost, None);
927        ed.exec(Command::InsertText("abcd".into()));
928        let root = ed.tree.root();
929        // Simulate `MoveTo` placing the cursor at drag start index 1.
930        ed.cursor = Cursor { seq: root, index: 1 };
931        ed.sel_anchor = None;
932        // First drag move reaches index 3 while the anchor stays at drag start index 1.
933        ed.extend_to(Cursor { seq: root, index: 3 });
934        assert_eq!(ed.sel_anchor, Some(Cursor { seq: root, index: 1 }));
935        let sel = ed.current_selection().unwrap();
936        assert_eq!((sel.anchor, sel.focus), (1, 3));
937        // Further drag moves keep the anchor fixed while focus follows.
938        ed.extend_to(Cursor { seq: root, index: 2 });
939        let sel = ed.current_selection().unwrap();
940        assert_eq!((sel.anchor, sel.focus), (1, 2));
941    }
942
943    #[test]
944    fn pointer_extend_with_existing_selection_keeps_its_anchor() {
945        // Shift click extending an active selection must keep the original anchor.
946        let mut ed = Editor::new(NullHost, None);
947        ed.exec(Command::InsertText("abcd".into()));
948        let root = ed.tree.root();
949        ed.cursor = Cursor { seq: root, index: 1 };
950        ed.sel_anchor = Some(Cursor { seq: root, index: 0 });
951        ed.extend_to(Cursor { seq: root, index: 4 });
952        assert_eq!(ed.sel_anchor, Some(Cursor { seq: root, index: 0 }));
953        let sel = ed.current_selection().unwrap();
954        assert_eq!((sel.anchor, sel.focus), (0, 4));
955    }
956
957    #[test]
958    fn pointer_drag_through_a_child_seq_promotes_to_common_ancestor() {
959        // Dragging through a child seq must promote selection to bracket the whole structure.
960        let mut ed = Editor::new(NullHost, None);
961        ed.exec(Command::InsertFraction(FracStyle::Bar));
962        let root = ed.tree.root();
963        let frac = ed.tree.items(root)[0];
964        let num = ed.tree.child_seqs(frac)[0];
965        // Mousedown before the fraction.
966        ed.cursor = Cursor { seq: root, index: 0 };
967        ed.sel_anchor = None;
968        // Dragging into the numerator promotes selection to bracket the fraction.
969        ed.extend_to(Cursor { seq: num, index: 0 });
970        assert_eq!(ed.sel_anchor, Some(Cursor { seq: root, index: 0 }));
971        let sel = ed.current_selection().unwrap();
972        assert_eq!((sel.seq, sel.anchor, sel.focus), (root, 0, 1));
973        // Dragging past the fraction keeps the exact bracketed selection.
974        ed.extend_to(Cursor { seq: root, index: 1 });
975        let sel = ed.current_selection().unwrap();
976        assert_eq!((sel.seq, sel.anchor, sel.focus), (root, 0, 1));
977    }
978
979    #[test]
980    fn pointer_drag_from_inside_a_script_base_past_the_end_selects_everything() {
981        // Dragging from after "x" in the Script base to after final "2" selects the root sequence.
982        let mut ed = Editor::new(NullHost, None);
983        ed.exec(Command::InsertAtom(sym("x")));
984        ed.exec(Command::InsertScript(ScriptSlot::Sup));
985        ed.exec(Command::InsertAtom(sym("2")));
986        let root = ed.tree.root();
987        // Exit the superscript back to the root, then type "+2".
988        ed.cursor = Cursor { seq: root, index: ed.tree.len(root) };
989        ed.sel_anchor = None;
990        ed.exec(Command::InsertAtom(sym("+")));
991        ed.exec(Command::InsertAtom(sym("2")));
992        assert_eq!(ed.latex(), "x^2+2");
993
994        let script = ed.tree.items(root)[0];
995        let base = match ed.tree.kind(script) {
996            Some(Kind::Script { base, .. }) => *base,
997            _ => panic!("expected a Script"),
998        };
999        // Mousedown right after "x", inside the base.
1000        ed.cursor = Cursor { seq: base, index: 1 };
1001        ed.sel_anchor = None;
1002        // Drag to the very end of the document.
1003        let end = ed.tree.len(root);
1004        ed.extend_to(Cursor { seq: root, index: end });
1005        let sel = ed.current_selection().unwrap();
1006        assert_eq!(sel.seq, root);
1007        assert_eq!((sel.anchor.min(sel.focus), sel.anchor.max(sel.focus)), (0, end));
1008    }
1009
1010    #[test]
1011    fn arrow_left_at_root_start_exits_left() {
1012        let (log, mut ed) = recording();
1013        ed.exec(Command::InsertText("ab".into()));
1014        let root = ed.tree.root();
1015        ed.cursor = Cursor { seq: root, index: 0 };
1016        ed.sel_anchor = None;
1017        let r = ed.exec(Command::Move(Dir::Left));
1018        assert_eq!(r.exit, Some(ExitDir::Left));
1019        assert_eq!(&*log.exits.borrow(), &[ExitDir::Left]);
1020        assert_eq!(ed.cursor, Cursor { seq: root, index: 0 });
1021    }
1022
1023    #[test]
1024    fn arrow_right_at_root_end_exits_right() {
1025        let (log, mut ed) = recording();
1026        ed.exec(Command::InsertText("ab".into()));
1027        let root = ed.tree.root();
1028        let r = ed.exec(Command::Move(Dir::Right));
1029        assert_eq!(r.exit, Some(ExitDir::Right));
1030        assert_eq!(&*log.exits.borrow(), &[ExitDir::Right]);
1031        assert_eq!(ed.cursor, Cursor { seq: root, index: 2 });
1032    }
1033
1034    #[test]
1035    fn arrows_in_flat_sequence_exit_up_and_down() {
1036        let (log, mut ed) = recording();
1037        ed.exec(Command::InsertText("ab".into()));
1038        let root = ed.tree.root();
1039        ed.cursor = Cursor { seq: root, index: 1 };
1040        ed.sel_anchor = None;
1041        let r = ed.exec(Command::Move(Dir::Up));
1042        assert_eq!(r.exit, Some(ExitDir::Up));
1043        let r = ed.exec(Command::Move(Dir::Down));
1044        assert_eq!(r.exit, Some(ExitDir::Down));
1045        assert_eq!(&*log.exits.borrow(), &[ExitDir::Up, ExitDir::Down]);
1046        assert_eq!(ed.cursor, Cursor { seq: root, index: 1 });
1047    }
1048
1049    #[test]
1050    fn arrow_down_in_numerator_enters_denominator_not_exit() {
1051        let (log, mut ed) = recording();
1052        ed.exec(Command::InsertFraction(FracStyle::Bar));
1053        let frac = ed.tree.items(ed.tree.root())[0];
1054        let den = ed.tree.child_seqs(frac)[1];
1055        let r = ed.exec(Command::Move(Dir::Down));
1056        assert_eq!(r.exit, None);
1057        assert_eq!(ed.cursor.seq, den);
1058        assert!(log.exits.borrow().is_empty());
1059    }
1060
1061    #[test]
1062    fn arrow_at_boundary_with_selection_collapses_without_exit() {
1063        let (log, mut ed) = recording();
1064        ed.exec(Command::InsertText("ab".into()));
1065        let root = ed.tree.root();
1066        ed.cursor = Cursor { seq: root, index: 2 };
1067        ed.sel_anchor = Some(Cursor { seq: root, index: 0 });
1068        let r = ed.exec(Command::Move(Dir::Right));
1069        assert_eq!(r.exit, None);
1070        assert!(log.exits.borrow().is_empty());
1071        assert!(ed.current_selection().is_none());
1072        assert_eq!(ed.cursor, Cursor { seq: root, index: 2 });
1073    }
1074
1075    #[test]
1076    fn delete_on_empty_root_requests_close_without_mutation() {
1077        let (log, mut ed) = recording();
1078        let r = ed.exec(Command::DeleteBackward);
1079        assert_eq!(log.closes.get(), 1);
1080        assert!(r.undo.is_none());
1081        assert!(r.exit.is_none());
1082        assert_eq!(ed.document().len(), 0);
1083        assert_eq!(ed.cursor, Cursor { seq: ed.tree.root(), index: 0 });
1084        let r = ed.exec(Command::DeleteForward);
1085        assert_eq!(log.closes.get(), 2);
1086        assert!(r.undo.is_none());
1087        assert_eq!(ed.document().len(), 0);
1088    }
1089
1090    #[test]
1091    fn enter_from_places_caret_at_matching_edge() {
1092        let (log, mut ed) = recording();
1093        ed.exec(Command::InsertText("ab".into()));
1094        let root = ed.tree.root();
1095        ed.cursor = Cursor { seq: root, index: 2 };
1096        ed.sel_anchor = Some(Cursor { seq: root, index: 0 });
1097        let renders = log.renders.get();
1098        ed.enter_from(ExitDir::Left);
1099        assert_eq!(ed.cursor, Cursor { seq: root, index: 0 });
1100        assert!(ed.sel_anchor.is_none());
1101        assert_eq!(log.renders.get(), renders + 1);
1102        ed.enter_from(ExitDir::Right);
1103        assert_eq!(ed.cursor, Cursor { seq: root, index: 2 });
1104        ed.enter_from(ExitDir::Up);
1105        assert_eq!(ed.cursor, Cursor { seq: root, index: 0 });
1106        ed.enter_from(ExitDir::Down);
1107        assert_eq!(ed.cursor, Cursor { seq: root, index: 2 });
1108    }
1109
1110    #[derive(Default)]
1111    struct ObjectLog {
1112        entries: std::cell::RefCell<Vec<(u32, crate::command::Side)>>,
1113        consume: std::cell::Cell<bool>,
1114    }
1115
1116    struct ObjectHost(std::rc::Rc<ObjectLog>);
1117    impl Host for ObjectHost {
1118        fn typeset(&self, _latex: &str) -> mathtex_ir::Fragment {
1119            mathtex_ir::Fragment::default()
1120        }
1121        fn on_change(&self, _doc: &Document) {}
1122        fn on_exit(&self, _dir: ExitDir) {}
1123        fn request_close(&self) {}
1124        fn on_render(&self) {}
1125        fn on_object_enter(&self, token: u32, side: crate::command::Side) -> bool {
1126            self.0.entries.borrow_mut().push((token, side));
1127            self.0.consume.get()
1128        }
1129    }
1130
1131    #[test]
1132    fn motion_into_host_box_routes_to_host_when_consumed() {
1133        use crate::command::Side;
1134        let log = std::rc::Rc::new(ObjectLog::default());
1135        log.consume.set(true);
1136        let mut ed = Editor::new(ObjectHost(log.clone()), None);
1137        ed.exec(Command::InsertHostBox(17));
1138        let root = ed.tree.root();
1139        // Insertion leaves the caret after the atom.
1140        assert_eq!(ed.cursor, Cursor { seq: root, index: 1 });
1141        // Moving left approaches from after, and the consumed event keeps the caret in place.
1142        ed.exec(Command::Move(Dir::Left));
1143        assert_eq!(ed.cursor, Cursor { seq: root, index: 1 });
1144        ed.cursor = Cursor { seq: root, index: 0 };
1145        ed.exec(Command::Move(Dir::Right));
1146        assert_eq!(ed.cursor, Cursor { seq: root, index: 0 });
1147        assert_eq!(&*log.entries.borrow(), &[(17, Side::After), (17, Side::Before)]);
1148    }
1149
1150    #[test]
1151    fn motion_over_host_box_skips_when_defaulted() {
1152        let mut ed = Editor::new(NullHost, None);
1153        ed.exec(Command::InsertText("a".into()));
1154        ed.exec(Command::InsertHostBox(9));
1155        ed.exec(Command::InsertText("b".into()));
1156        let root = ed.tree.root();
1157        ed.cursor = Cursor { seq: root, index: 1 };
1158        ed.sel_anchor = None;
1159        // The default host leaves the event unconsumed, so the caret skips over the whole atom.
1160        let r = ed.exec(Command::Move(Dir::Right));
1161        assert_eq!(r.cursor, Cursor { seq: root, index: 2 });
1162        let r = ed.exec(Command::Move(Dir::Left));
1163        assert_eq!(r.cursor, Cursor { seq: root, index: 1 });
1164    }
1165
1166    #[test]
1167    fn host_box_deletes_whole_and_undo_restores_the_token() {
1168        let mut ed = Editor::new(NullHost, None);
1169        ed.exec(Command::InsertHostBox(17));
1170        assert_eq!(ed.tree.len(ed.tree.root()), 1);
1171        // A single Backspace removes the whole atom without select-then-delete.
1172        let del = ed.exec(Command::DeleteBackward);
1173        assert_eq!(ed.tree.len(ed.tree.root()), 0);
1174        ed.apply_undo(del.undo.unwrap());
1175        let root = ed.tree.root();
1176        assert_eq!(ed.tree.len(root), 1);
1177        match ed.tree.kind(ed.tree.items(root)[0]) {
1178            Some(Kind::HostBox { token }) => assert_eq!(*token, 17),
1179            _ => panic!("expected HostBox"),
1180        }
1181    }
1182
1183    #[test]
1184    fn selection_spanning_host_box_copies_one_entry_with_the_token() {
1185        let mut ed = Editor::new(NullHost, None);
1186        ed.exec(Command::InsertText("a".into()));
1187        ed.exec(Command::InsertHostBox(17));
1188        ed.exec(Command::InsertText("b".into()));
1189        ed.exec(Command::SelectAll);
1190        let frag = ed.selection_fragment();
1191        assert_eq!(frag.len(), 3);
1192        assert!(matches!(frag[1], crate::doc::NodeDoc::HostBox { token: 17 }));
1193        assert_eq!(ed.selection_latex(), "a\\hostbox{17}b");
1194    }
1195
1196    #[test]
1197    fn undo_restores_previous_document() {
1198        let mut ed = Editor::new(NullHost, None);
1199        let r1 = ed.exec(Command::InsertAtom(sym("a")));
1200        let r2 = ed.exec(Command::InsertAtom(sym("b")));
1201        assert_eq!(ed.document().len(), 2);
1202        // host would stack r2.undo, apply it to undo "b"
1203        ed.apply_undo(r2.undo.unwrap());
1204        assert_eq!(ed.document().len(), 1);
1205        ed.apply_undo(r1.undo.unwrap());
1206        assert_eq!(ed.document().len(), 0);
1207    }
1208}