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