Skip to main content

ratex_gpui/editor/
view.rs

1//! The interactive gpui view — renders the formula + caret and turns keystrokes into
2//! structural edits. This is the visual seam: it owns the `render` raster and the gpui
3//! input, while all editing logic stays in the gpui-free `editor::{model, cursor, input,
4//! geometry}`.
5
6use std::cell::Cell;
7use std::rc::Rc;
8
9use crate::editor::cursor::Cursor;
10use crate::editor::geometry;
11use crate::editor::input;
12use crate::editor::model::Row;
13use crate::render::{self, PAD, Rendered};
14use gpui::prelude::FluentBuilder;
15use gpui::*;
16
17/// Autocomplete dropdown geometry (px): row height + the scrollable height cap. Shared by
18/// the dropdown render and `scroll_match_into_view` so the thumb + scroll-into-view agree.
19const DROP_ITEM_H: f32 = 26.0;
20const DROP_MAX_H: f32 = 240.0;
21
22/// The host's theme colors for the editor chrome (palette, toolbar, caret, autocomplete) and
23/// the formula glyphs, so the editor matches the surrounding app. Filled by the host from its
24/// palette; `Default` is a light scheme for the standalone example.
25#[derive(Clone, Copy)]
26pub struct MathTheme {
27    /// Formula glyphs + primary text (button labels).
28    pub fg: Hsla,
29    /// Secondary text — grips, dropdown rows.
30    pub muted: Hsla,
31    /// Panel + button surfaces (palette, toolbar, dropdown).
32    pub panel: Hsla,
33    /// Panel + button borders.
34    pub border: Hsla,
35    /// The caret and active/selected highlights.
36    pub accent: Hsla,
37    /// A subtle accent fill — hover, selected row, command preview.
38    pub accent_bg: Hsla,
39}
40
41impl Default for MathTheme {
42    fn default() -> Self {
43        Self {
44            fg: rgb(0x334155).into(),
45            muted: rgb(0x64748b).into(),
46            panel: rgb(0xf8fafc).into(),
47            border: rgb(0xcbd5e1).into(),
48            accent: rgb(0x2563eb).into(),
49            accent_bg: rgb(0xeff6ff).into(),
50        }
51    }
52}
53
54/// A structural math editor view: the model, the caret, the cached raster, an in-progress
55/// `\command` buffer with autocomplete, and the draggable palette's position.
56pub struct MathEditor {
57    root: Row,
58    cursor: Cursor,
59    /// The fixed end of a selection (the moving end is `cursor`); `None` = no selection.
60    /// Always shares `cursor`'s row — selection is single-row for now.
61    anchor: Option<Cursor>,
62    focus: FocusHandle,
63    font_size: f32,
64    dpr: f32,
65    /// The host's theme colors for the chrome + formula glyphs.
66    theme: MathTheme,
67    rendered: Option<Rendered>,
68    /// The letters of a `\command` being typed (without the leading backslash), or `None`
69    /// in normal mode.
70    pending: Option<String>,
71    /// Highlighted autocomplete match (index into the visible matches).
72    selected: usize,
73    /// Scroll offset of the open autocomplete dropdown, so a long match list scrolls to keep
74    /// the highlight in view (keyboard nav can run past the height cap).
75    match_scroll: ScrollHandle,
76    /// The palette panel's top-left, in window px (draggable by its grip).
77    palette_pos: (f32, f32),
78    /// In-line only: where the user dragged the palette, in formula-container
79    /// px. `None` = the automatic dock (below the formula, flipping above
80    /// near the window bottom). Per-editor, so each edit starts docked.
81    inline_palette_off: Option<(f32, f32)>,
82    /// While dragging the palette: the (cursor − panel-origin) offset, kept for 1:1
83    /// tracking with no jump on grab.
84    palette_drag: Option<(f32, f32)>,
85    /// The matrix toolbar's offset from the matrix's bottom-left, so it tracks the grid as
86    /// the formula reflows; adjustable by dragging the toolbar's grip.
87    toolbar_off: (f32, f32),
88    /// During a toolbar drag: the previous cursor position, for delta-based movement.
89    toolbar_drag: Option<(f32, f32)>,
90    /// During an autocomplete-scrollbar drag: (grab mouse y, scrolled px at grab).
91    thumb_drag: Option<(f32, f32)>,
92    /// During a mouse drag over the formula: the cursor at the press, i.e. the
93    /// selection's fixed end. Selection is single-row, like shift-arrows.
94    select_drag: Option<Cursor>,
95    /// In-line edit mode (hosted in a note's text flow): left-align the formula at its spot
96    /// and hide the floating palette + white background, vs the centered standalone editor.
97    inline: bool,
98    /// Horizontal justification of the formula in in-line mode — matches the display block's
99    /// alignment so entering edit doesn't shift it.
100    align: MathAlign,
101    /// Undo / redo history: `(model, caret)` snapshots taken before each edit. The host
102    /// editor's Cmd+Z is inert while we're hosted (its key context is dropped), so the
103    /// formula owns its own in-place undo. A committed formula is one step in the document's
104    /// history (the host records it), so the two levels compose.
105    undo_stack: Vec<(Row, Cursor)>,
106    redo_stack: Vec<(Row, Cursor)>,
107    /// The formula image's window-space bounds, captured each paint by a `canvas` overlay so a
108    /// click can be mapped to a position in the formula. `Cell` since the capture closure has
109    /// no `&mut self`.
110    img_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
111}
112
113/// Palette panel width (px) — used to dock a right-aligned formula's palette to its right edge.
114const PALETTE_W: f32 = 200.0;
115
116/// Drag payload for the palette / toolbar grips. gpui's `on_drag` +
117/// `on_drag_move` (not a bounds-gated `on_mouse_move`) keeps events flowing
118/// when the pointer leaves the editor's thin reserved gap mid-drag — a plain
119/// mouse-move listener froze vertical palette drags at the gap edge.
120struct GripDrag;
121
122impl Render for GripDrag {
123    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
124        gpui::Empty
125    }
126}
127
128/// Drag payload for mouse selection over the formula — same on_drag/on_drag_move
129/// machinery as [`GripDrag`], so events keep flowing when the pointer leaves the
130/// formula's bounds mid-drag.
131struct SelectDrag;
132
133impl Render for SelectDrag {
134    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
135        gpui::Empty
136    }
137}
138/// The palette panel's full height: 45 buttons wrap to 9 rows at this width
139/// (32px + 4px gap), plus padding and the drag handle. Used to flip the
140/// panel above the formula when the below-dock would clip at the window
141/// bottom — an estimate is fine, it only steers the flip.
142const PALETTE_H: f32 = 366.0;
143
144/// Cap on the formula's in-place undo history, to bound memory.
145const UNDO_CAP: usize = 200;
146
147/// Horizontal alignment of the in-line formula, so it matches the centered (or left/right)
148/// display block and doesn't jump when entered. The host maps its own marker to this.
149#[derive(Clone, Copy, PartialEq, Eq, Default)]
150pub enum MathAlign {
151    Left,
152    #[default]
153    Center,
154    Right,
155}
156
157/// A signal the host listens for from the hosted editor.
158pub enum MathNav {
159    /// The caret tried to move past a boundary of the formula (`after` = past the end → seat
160    /// the text caret after the block; else before it), so focus should flow back out to the
161    /// surrounding text editor — the way arrowing past a table cell's edge exits the table.
162    Exit { after: bool },
163    /// The formula was right-clicked while being edited — the host shows its formula context
164    /// menu (copy LaTeX / export) at `position` (window-space). The hosted editor occludes the
165    /// formula, so the host's own right-click handler can't fire; this routes it back out.
166    ContextMenu { position: Point<Pixels> },
167}
168
169impl EventEmitter<MathNav> for MathEditor {}
170
171impl MathEditor {
172    pub fn new(cx: &mut Context<Self>) -> Self {
173        Self::with_root(
174            Row::new(),
175            48.0,
176            false,
177            MathAlign::Center,
178            MathTheme::default(),
179            cx,
180        )
181    }
182
183    /// Build an editor seeded with the formula parsed from `latex`, rendered at `font_size`
184    /// px/em — for editing an existing `$$…$$` block in-line at its displayed size. The caret
185    /// lands at the end of the top row when `at_end`, else at the start — so arrowing *into*
186    /// the block from below/right enters at the end, and from above/left enters at the start.
187    /// `align` matches the display block's justification so entering edit doesn't shift it.
188    pub fn from_latex(
189        latex: &str,
190        font_size: f32,
191        at_end: bool,
192        align: MathAlign,
193        theme: MathTheme,
194        cx: &mut Context<Self>,
195    ) -> Self {
196        let mut this = Self::with_root(
197            crate::editor::latex::parse_latex(latex),
198            font_size,
199            true,
200            align,
201            theme,
202            cx,
203        );
204        if !at_end {
205            this.cursor = Cursor::start();
206        }
207        this
208    }
209
210    /// The current formula as LaTeX, to write back into the `$$…$$` block.
211    pub fn to_latex(&self) -> String {
212        self.root.to_latex()
213    }
214
215    /// The current horizontal alignment, so the host writes the matching marker on commit.
216    pub fn align(&self) -> MathAlign {
217        self.align
218    }
219
220    /// Re-justify the in-line formula (from the right-click "Align" menu) — immediate visual
221    /// feedback; the host persists the marker on commit.
222    pub fn set_align(&mut self, align: MathAlign, cx: &mut Context<Self>) {
223        self.align = align;
224        cx.notify();
225    }
226
227    fn with_root(
228        root: Row,
229        font_size: f32,
230        inline: bool,
231        align: MathAlign,
232        theme: MathTheme,
233        cx: &mut Context<Self>,
234    ) -> Self {
235        let index = root.atoms.len();
236        let mut this = Self {
237            root,
238            cursor: Cursor {
239                path: vec![],
240                index,
241            },
242            anchor: None,
243            focus: cx.focus_handle(),
244            font_size,
245            // Corrected to the window's scale factor on first render.
246            dpr: 2.0,
247            align,
248            theme,
249            rendered: None,
250            pending: None,
251            selected: 0,
252            match_scroll: ScrollHandle::new(),
253            palette_pos: (16.0, 16.0),
254            inline_palette_off: None,
255            palette_drag: None,
256            toolbar_off: (0.0, 8.0),
257            toolbar_drag: None,
258            thumb_drag: None,
259            select_drag: None,
260            inline,
261            undo_stack: Vec::new(),
262            redo_stack: Vec::new(),
263            img_bounds: Rc::new(Cell::new(None)),
264        };
265        this.rendered = render::render_row(&this.root, this.font_size, this.dpr, this.theme.fg);
266        this
267    }
268
269    /// The focus handle, so the host can focus the editor on open.
270    pub fn focus_handle(&self) -> FocusHandle {
271        self.focus.clone()
272    }
273
274    /// Re-rasterize the formula, freeing the previous image's GPU texture, and notify.
275    fn rerender(&mut self, window: &mut Window, cx: &mut Context<Self>) {
276        let old = self.rendered.take();
277        self.rendered = render::render_row(&self.root, self.font_size, self.dpr, self.theme.fg);
278        if let Some(old) = old {
279            cx.drop_image(old.image, Some(window));
280        }
281        cx.notify();
282    }
283
284    /// Finish a possibly-mutating action: if it changed the model, record `before` for undo
285    /// (clearing the redo branch), then re-rasterize. Caret-only moves don't record a step.
286    fn commit_edit(&mut self, before: (Row, Cursor), window: &mut Window, cx: &mut Context<Self>) {
287        if self.root != before.0 {
288            self.undo_stack.push(before);
289            self.redo_stack.clear();
290            if self.undo_stack.len() > UNDO_CAP {
291                self.undo_stack.remove(0);
292            }
293        }
294        self.rerender(window, cx);
295    }
296
297    /// Restore the previous snapshot (pushing the current one onto the redo branch). `false`
298    /// if there's nothing to undo.
299    fn undo(&mut self) -> bool {
300        let Some((root, cursor)) = self.undo_stack.pop() else {
301            return false;
302        };
303        let prev = (
304            std::mem::replace(&mut self.root, root),
305            std::mem::replace(&mut self.cursor, cursor),
306        );
307        self.redo_stack.push(prev);
308        self.anchor = None;
309        self.pending = None;
310        true
311    }
312
313    /// Re-apply the last undone snapshot. `false` if there's nothing to redo.
314    fn redo(&mut self) -> bool {
315        let Some((root, cursor)) = self.redo_stack.pop() else {
316            return false;
317        };
318        let prev = (
319            std::mem::replace(&mut self.root, root),
320            std::mem::replace(&mut self.cursor, cursor),
321        );
322        self.undo_stack.push(prev);
323        self.anchor = None;
324        self.pending = None;
325        true
326    }
327
328    fn on_key(&mut self, ev: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
329        let ks = &ev.keystroke;
330        if ks.modifiers.platform || ks.modifiers.control {
331            // Undo / redo within the formula (Cmd/Ctrl+Z, Cmd+Shift+Z, Cmd/Ctrl+Y). The host
332            // editor's binding is inert while we're hosted (its key context is dropped), so
333            // the formula handles it. Other modified keys are left for the host.
334            if !ks.modifiers.alt && !ks.modifiers.function {
335                let did = match ks.key.as_str() {
336                    "z" if ks.modifiers.shift => self.redo(),
337                    "z" => self.undo(),
338                    "y" => self.redo(),
339                    _ => false,
340                };
341                if did {
342                    self.rerender(window, cx);
343                }
344            }
345            return;
346        }
347        // Escape backs out one layer at a time: cancel a pending `\command`, else clear a
348        // selection, else exit the formula entirely (commit + flow the caret out, like
349        // arrowing past the end).
350        if ks.key == "escape" {
351            if self.pending.is_some() {
352                self.pending = None;
353                cx.notify();
354            } else if self.anchor.is_some() {
355                self.anchor = None;
356                cx.notify();
357            } else {
358                cx.emit(MathNav::Exit { after: true });
359            }
360            return;
361        }
362        let was_normal = self.pending.is_none();
363        let root_before = self.root.clone();
364        let cursor_before = self.cursor.clone();
365        let consumed = if self.pending.is_some() {
366            self.handle_pending(ks)
367        } else {
368            self.handle_normal(ks)
369        };
370        // An arrow that left the caret unmoved in normal mode is a boundary: hand focus back
371        // to the host so the text caret flows out of the formula (left/up → before the block,
372        // right/down → after it), the way arrowing past a table cell's edge exits the table.
373        // An up that can't move seats the caret at the formula's start (text-editor
374        // convention for up on the first line); only a second up at the start exits.
375        // Keeps a stray up from dumping the caret out of the formula — worst where
376        // the block opens the document and "before the block" reveals the raw source.
377        if was_normal
378            && !ks.modifiers.shift
379            && ks.key == "up"
380            && self.cursor == cursor_before
381            && self.cursor != Cursor::start()
382        {
383            self.anchor = None;
384            self.cursor = Cursor::start();
385            cx.notify();
386            return;
387        }
388        if was_normal
389            && !ks.modifiers.shift
390            && self.cursor == cursor_before
391            && let Some(after) = match ks.key.as_str() {
392                "left" | "up" => Some(false),
393                "right" | "down" => Some(true),
394                _ => None,
395            }
396        {
397            cx.emit(MathNav::Exit { after });
398            return;
399        }
400        if !consumed {
401            return;
402        }
403        // Record an undo step iff the model actually changed (so navigation / selection
404        // don't), then re-rasterize.
405        self.commit_edit((root_before, cursor_before), window, cx);
406    }
407
408    /// Normal-mode keys: navigation, selection (Shift+←/→), editing, typing, `\` to start a
409    /// command, and wrapping a selection — `(` → parens, `/` → fraction.
410    fn handle_normal(&mut self, ks: &Keystroke) -> bool {
411        let shift = ks.modifiers.shift;
412        let sel = self.selection_range();
413        match ks.key.as_str() {
414            "left" if shift => self.select_step(false),
415            "right" if shift => self.select_step(true),
416            "left" => {
417                self.anchor = None;
418                self.cursor.move_left(&self.root);
419            }
420            "right" => {
421                self.anchor = None;
422                self.cursor.move_right(&self.root);
423            }
424            "up" => {
425                self.anchor = None;
426                self.cursor.move_up(&self.root);
427            }
428            "down" => {
429                self.anchor = None;
430                self.cursor.move_down(&self.root);
431            }
432            "backspace" => match sel {
433                Some((lo, hi)) => {
434                    self.cursor.delete_range(&mut self.root, lo, hi);
435                    self.anchor = None;
436                }
437                None => self.cursor.backspace(&mut self.root),
438            },
439            _ => match ks.key_char.as_ref().and_then(|s| s.chars().next()) {
440                Some('\\') => {
441                    // Keep the anchor: a selection survives into `\command` mode so a
442                    // wrap-capable command (frac, sqrt, delimiters, accents) typed over
443                    // it wraps it, MathQuill-style — commit_pending passes it along.
444                    self.pending = Some(String::new());
445                    self.selected = 0;
446                    self.scroll_match_into_view();
447                }
448                Some('/') if sel.is_some() => {
449                    let (lo, hi) = sel.unwrap();
450                    self.cursor.wrap_fraction(&mut self.root, lo, hi);
451                    self.anchor = None;
452                }
453                // A bracket / brace / bar typed over a selection wraps it in that delimiter.
454                Some(c) if sel.is_some() && input::delim_pair(c).is_some() => {
455                    let (open, close) = input::delim_pair(c).unwrap();
456                    let (lo, hi) = sel.unwrap();
457                    self.cursor.wrap_delim(&mut self.root, lo, hi, open, close);
458                    self.anchor = None;
459                }
460                // Any other character collapses the selection (non-destructively — there's
461                // no undo) and inserts at the caret.
462                Some(c) => {
463                    self.anchor = None;
464                    input::type_char(&mut self.root, &mut self.cursor, c);
465                }
466                None => return false,
467            },
468        }
469        true
470    }
471
472    /// Extend (or begin) the selection by one atom within the current row, in `right` /
473    /// left direction. Single-row: the moving end stays in `cursor`'s row, never descending.
474    fn select_step(&mut self, right: bool) {
475        if self.anchor.is_none() {
476            self.anchor = Some(self.cursor.clone());
477        }
478        let len = self.cursor.row(&self.root).atoms.len();
479        if right {
480            if self.cursor.index < len {
481                self.cursor.index += 1;
482            }
483        } else if self.cursor.index > 0 {
484            self.cursor.index -= 1;
485        }
486    }
487
488    /// The selected atom range `lo..hi` in the current row, or `None` when there's no
489    /// (non-empty) selection. Guards that the anchor shares the cursor's row.
490    fn selection_range(&self) -> Option<(usize, usize)> {
491        let a = self.anchor.as_ref()?;
492        if a.path != self.cursor.path {
493            return None;
494        }
495        let lo = a.index.min(self.cursor.index);
496        let hi = a.index.max(self.cursor.index);
497        (lo < hi).then_some((lo, hi))
498    }
499
500    /// `\command`-mode keys: build the buffer, move the highlight, commit, or cancel. (Escape
501    /// cancels, but `on_key` intercepts it before this is reached.)
502    fn handle_pending(&mut self, ks: &Keystroke) -> bool {
503        match ks.key.as_str() {
504            "enter" | "tab" | "space" => self.commit_pending(),
505            "up" => self.selected = self.selected.saturating_sub(1),
506            "down" => {
507                let n = self
508                    .pending
509                    .as_deref()
510                    .map_or(0, |p| input::command_matches(p).len());
511                self.selected = (self.selected + 1).min(n.saturating_sub(1));
512            }
513            "backspace" => {
514                if self.pending.as_deref().is_some_and(|b| !b.is_empty()) {
515                    self.pending.as_mut().unwrap().pop();
516                } else {
517                    self.pending = None; // backspaced past the '\'
518                }
519                self.selected = 0;
520            }
521            _ => match ks.key_char.as_ref().and_then(|s| s.chars().next()) {
522                Some(c) if c.is_ascii_alphabetic() => {
523                    self.pending.as_mut().unwrap().push(c);
524                    self.selected = 0;
525                }
526                // `{` commits like enter — LaTeX muscle memory types `\hat{` and expects
527                // the structure's slot to open (#77); the brace itself is consumed.
528                Some('{') => self.commit_pending(),
529                _ => return false,
530            },
531        }
532        self.scroll_match_into_view();
533        true
534    }
535
536    /// Scroll the autocomplete dropdown so the highlighted match stays visible — the list can
537    /// run past the height cap (the full `\` menu is ~75 entries). gpui's `scroll_to_item`
538    /// uses the MEASURED child + viewport bounds at prepaint; the previous hand-rolled
539    /// `DROP_ITEM_H`/`DROP_MAX_H` math drifted whenever they disagreed with the paint.
540    fn scroll_match_into_view(&self) {
541        self.match_scroll.scroll_to_item(self.selected);
542    }
543
544    /// Resolve the pending `\name`: the highlighted match, else the literal letters.
545    fn commit_pending(&mut self) {
546        let name = self.pending.take().unwrap_or_default();
547        let sel = self.selection_range();
548        self.anchor = None;
549        let matches = input::command_matches(&name);
550        match matches.get(self.selected).or_else(|| matches.first()) {
551            Some(&chosen) => {
552                // A wrap-capable command typed over a selection wraps it (the
553                // selection becomes the numerator / radicand / accent base).
554                input::commit_command_selecting(&mut self.root, &mut self.cursor, chosen, sel);
555            }
556            None => {
557                for c in name.chars() {
558                    input::type_char(&mut self.root, &mut self.cursor, c);
559                }
560            }
561        }
562    }
563
564    /// Caret rect (left x, top y, height) in logical px for `cursor`, from the geometry walk
565    /// (top row + fraction/script slots). `None` for slots the walk doesn't handle yet.
566    fn caret_px_of(&self, cursor: &Cursor) -> Option<(f32, f32, f32)> {
567        let r = geometry::caret_rect(&self.root, cursor)?;
568        let fs = self.font_size;
569        let h = (r.h as f32 * fs).max(fs * 0.3);
570        Some((PAD + r.x as f32 * fs, PAD + r.y as f32 * fs, h))
571    }
572
573    /// Caret rect for the live cursor.
574    fn caret_px(&self) -> Option<(f32, f32, f32)> {
575        self.caret_px_of(&self.cursor)
576    }
577
578    /// A window-space click in formula em-coords (the geometry walk's units): undo the render
579    /// padding + layout font size, mirroring `caret_px_of`. `None` until the first paint has
580    /// captured the formula's bounds.
581    fn em_at(&self, pos: Point<Pixels>) -> Option<(f64, f64)> {
582        let b = self.img_bounds.get()?;
583        let ex = ((f32::from(pos.x - b.origin.x) - PAD) / self.font_size) as f64;
584        let ey = ((f32::from(pos.y - b.origin.y) - PAD) / self.font_size) as f64;
585        Some((ex, ey))
586    }
587
588    /// Single click: place the caret at the click, collapsing any selection / pending command.
589    fn click_to_caret(&mut self, pos: Point<Pixels>, cx: &mut Context<Self>) {
590        let Some((ex, ey)) = self.em_at(pos) else {
591            return;
592        };
593        self.anchor = None;
594        self.pending = None;
595        self.cursor = geometry::cursor_at(&self.root, ex, ey);
596        cx.notify();
597    }
598
599    /// Double click: select the atom (or structure) under the click.
600    fn select_cell_at(&mut self, pos: Point<Pixels>, cx: &mut Context<Self>) {
601        let Some((ex, ey)) = self.em_at(pos) else {
602            return;
603        };
604        let (path, lo, hi) = geometry::span_at(&self.root, ex, ey);
605        if lo == hi {
606            self.click_to_caret(pos, cx); // empty row → just place the caret
607            return;
608        }
609        self.pending = None;
610        self.anchor = Some(Cursor {
611            path: path.clone(),
612            index: lo,
613        });
614        self.cursor = Cursor { path, index: hi };
615        cx.notify();
616    }
617
618    /// Triple click: select the whole row / slot under the click.
619    fn select_row_at(&mut self, pos: Point<Pixels>, cx: &mut Context<Self>) {
620        let Some((ex, ey)) = self.em_at(pos) else {
621            return;
622        };
623        let (path, len) = geometry::row_len_at(&self.root, ex, ey);
624        if len == 0 {
625            self.click_to_caret(pos, cx);
626            return;
627        }
628        self.pending = None;
629        self.anchor = Some(Cursor {
630            path: path.clone(),
631            index: 0,
632        });
633        self.cursor = Cursor { path, index: len };
634        cx.notify();
635    }
636
637    /// The click-to-insert symbol palette (a floating, draggable panel). Shares the command
638    /// table with `\command` typing, so a click is just a keyboard-free `commit_command`.
639    /// The in-line palette's top, in image-container px: just below the formula normally, but
640    /// below the matrix toolbar when the caret is in a matrix (else the two panels overlap).
641    fn inline_palette_top(&self, window: &Window) -> f32 {
642        // A user-dragged position overrides every automatic dock.
643        if let Some((_, y)) = self.inline_palette_off {
644            return y;
645        }
646        if let Some(m) = geometry::matrix_rect(&self.root, &self.cursor) {
647            // Clear the toolbar, which docks at the matrix's bottom (see `matrix_toolbar`):
648            // its dock top + the toolbar's own height (24px row + padding) + a small gap.
649            return PAD + (m.y + m.h) as f32 * self.font_size + self.toolbar_off.1 + 40.0;
650        }
651        let below = self.rendered.as_ref().map_or(0.0, |r| r.height) + 4.0;
652        // Near the window bottom the below-the-formula dock would clip — flip
653        // the panel above the formula when it fully fits there instead.
654        if let Some(b) = self.img_bounds.get() {
655            let win_h = f32::from(window.viewport_size().height);
656            let formula_top = f32::from(b.top());
657            if formula_top + below + PALETTE_H > win_h && formula_top - PALETTE_H - 4.0 >= 0.0 {
658                return -(PALETTE_H + 4.0);
659            }
660        }
661        below
662    }
663
664    /// The in-line palette's left, in formula-container px. The palette is a child of the
665    /// (flex-justified) formula container, so this is formula-relative and the panel tracks the
666    /// formula automatically. For a RIGHT-aligned formula it docks at the formula's RIGHT edge
667    /// (extends left), so its right edge sits at the formula's right (≈ the editor's right) and
668    /// can't run off-screen — computed from the formula width, no painted-position measurement
669    /// (which would lag a frame). Left/center dock at the formula's left; a matrix docks beside
670    /// the grid.
671    fn inline_palette_left(&self) -> f32 {
672        if let Some((x, _)) = self.inline_palette_off {
673            return x;
674        }
675        if let Some(m) = geometry::matrix_rect(&self.root, &self.cursor) {
676            return PAD + m.x as f32 * self.font_size + self.toolbar_off.0;
677        }
678        match self.align {
679            MathAlign::Right => self.rendered.as_ref().map_or(0.0, |r| r.width) - PALETTE_W,
680            _ => 0.0,
681        }
682    }
683
684    fn palette(&self, window: &Window, cx: &mut Context<Self>) -> Div {
685        let theme = self.theme;
686        // The grip "ear": press and hold here to move the panel.
687        let handle = div()
688            .id("palette-handle")
689            .flex()
690            .items_center()
691            .justify_center()
692            .w_full()
693            .h(px(16.0))
694            .bg(theme.border)
695            .cursor_pointer()
696            .text_size(px(11.0))
697            .text_color(theme.muted)
698            .on_mouse_down(
699                MouseButton::Left,
700                cx.listener(|this, ev: &MouseDownEvent, window, cx| {
701                    // Grab offset against the panel's current window position
702                    // (in-line, that's the dock or a previous drag).
703                    let (px_, py_) = if this.inline {
704                        let (ox, oy) = this
705                            .img_bounds
706                            .get()
707                            .map_or((0.0, 0.0), |b| (f32::from(b.left()), f32::from(b.top())));
708                        (
709                            ox + this.inline_palette_left(),
710                            oy + this.inline_palette_top(window),
711                        )
712                    } else {
713                        this.palette_pos
714                    };
715                    this.palette_drag = Some((
716                        f32::from(ev.position.x) - px_,
717                        f32::from(ev.position.y) - py_,
718                    ));
719                    cx.notify();
720                }),
721            )
722            .on_drag(GripDrag, |_, _, _, cx| {
723                cx.stop_propagation();
724                cx.new(|_| GripDrag)
725            })
726            .child("⠿ ⠿ ⠿");
727
728        let buttons = div()
729            .flex()
730            .flex_wrap()
731            .gap_1()
732            .p_2()
733            .children(input::PALETTE.iter().map(|(label, cmd)| {
734                let cmd = *cmd;
735                div()
736                    .id(cmd)
737                    .flex()
738                    .items_center()
739                    .justify_center()
740                    .size(px(32.0))
741                    .bg(theme.panel)
742                    .border_1()
743                    .border_color(theme.border)
744                    .rounded_md()
745                    .text_size(px(17.0))
746                    .cursor_pointer()
747                    .hover(|s| s.bg(theme.accent_bg))
748                    .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
749                        // With a selection active, the fraction / root buttons WRAP it (it
750                        // becomes the numerator / radicand); other buttons just insert.
751                        let before = (this.root.clone(), this.cursor.clone());
752                        let sel = this.selection_range();
753                        input::commit_command_selecting(&mut this.root, &mut this.cursor, cmd, sel);
754                        this.anchor = None;
755                        this.focus.focus(window, cx);
756                        this.commit_edit(before, window, cx);
757                    }))
758                    .child(*label)
759            }));
760
761        div()
762            .absolute()
763            .left(px(if self.inline {
764                self.inline_palette_left()
765            } else {
766                self.palette_pos.0
767            }))
768            .top(px(if self.inline {
769                self.inline_palette_top(window)
770            } else {
771                self.palette_pos.1
772            }))
773            .flex()
774            .flex_col()
775            .w(px(PALETTE_W))
776            .bg(theme.panel)
777            .border_1()
778            .border_color(theme.border)
779            .rounded_md()
780            // Occlude: in-line the palette floats below the formula, outside the host's
781            // reserved gap — without this, glyph clicks fall through to the text editor,
782            // which seats the caret on the next line and closes this editor (insert lost).
783            .occlude()
784            .child(handle)
785            .child(buttons)
786    }
787
788    /// A small contextual toolbar — shown only when the caret is in a matrix — docked just
789    /// below the grid so it stays near the matrix (vital once a formula is embedded in a
790    /// doc). Draggable by its grip; columns have no natural keyboard gesture, so it's also
791    /// the discoverable way to grow/shrink width, and it doubles as row/column removal.
792    fn matrix_toolbar(&self, cx: &mut Context<Self>) -> Option<Div> {
793        let theme = self.theme;
794        let m = geometry::matrix_rect(&self.root, &self.cursor)?;
795        let fs = self.font_size;
796        // Dock at the matrix's bottom-left (formula-container px) plus the draggable offset.
797        let left = PAD + m.x as f32 * fs + self.toolbar_off.0;
798        let top = PAD + (m.y + m.h) as f32 * fs + self.toolbar_off.1;
799
800        // The grip "ear": press and hold to move the toolbar.
801        let grip = div()
802            .id("matrix-toolbar-handle")
803            .flex()
804            .items_center()
805            .justify_center()
806            .px_1()
807            .h(px(24.0))
808            .bg(theme.border)
809            .cursor_pointer()
810            .text_size(px(11.0))
811            .text_color(theme.muted)
812            .on_mouse_down(
813                MouseButton::Left,
814                cx.listener(|this, ev: &MouseDownEvent, _window, cx| {
815                    this.toolbar_drag = Some((f32::from(ev.position.x), f32::from(ev.position.y)));
816                    cx.notify();
817                }),
818            )
819            .on_drag(GripDrag, |_, _, _, cx| {
820                cx.stop_propagation();
821                cx.new(|_| GripDrag)
822            })
823            .child("⠿");
824
825        let btn = |label: &'static str, op: fn(&mut Cursor, &mut Row)| {
826            div()
827                .id(label)
828                .flex()
829                .items_center()
830                .justify_center()
831                .px_2()
832                .h(px(24.0))
833                .bg(theme.panel)
834                .border_1()
835                .border_color(theme.border)
836                .rounded_md()
837                .text_size(px(13.0))
838                .text_color(theme.fg)
839                .cursor_pointer()
840                .hover(|s| s.bg(theme.accent_bg))
841                .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
842                    let before = (this.root.clone(), this.cursor.clone());
843                    op(&mut this.cursor, &mut this.root);
844                    this.focus.focus(window, cx);
845                    this.commit_edit(before, window, cx);
846                }))
847                .child(label)
848        };
849        Some(
850            div()
851                .absolute()
852                .left(px(left))
853                .top(px(top))
854                .flex()
855                .gap_1()
856                .p_1()
857                .bg(theme.panel)
858                .border_1()
859                .border_color(theme.border)
860                .rounded_md()
861                // Occlude — same overflow as the palette: it floats below the matrix.
862                .occlude()
863                .child(grip)
864                .child(btn("+ row", Cursor::matrix_add_row))
865                .child(btn("− row", Cursor::matrix_remove_row))
866                .child(btn("+ col", Cursor::matrix_add_col))
867                .child(btn("− col", Cursor::matrix_remove_col)),
868        )
869    }
870}
871
872impl Render for MathEditor {
873    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
874        // Rasterize at the window's real pixel density — the construction
875        // default (2.0) is only right on a 2× display; a 1× screen rendered
876        // soft and a 3× wasted texture. Guarded, so this settles in one
877        // extra frame after a display change.
878        let dpr = window.scale_factor().max(1.0);
879        if (dpr - self.dpr).abs() > f32::EPSILON {
880            self.dpr = dpr;
881            self.rerender(window, cx);
882        }
883        let theme = self.theme;
884        let (w, h) = self
885            .rendered
886            .as_ref()
887            .map_or((40.0, 40.0), |r| (r.width, r.height));
888        let image = self
889            .rendered
890            .as_ref()
891            .map(|r| img(r.image.clone()).w(px(w)).h(px(h)));
892
893        // In normal mode show the caret bar; while typing a \command show the pending text
894        // (and an autocomplete dropdown) at the caret instead.
895        let caret = self
896            .pending
897            .is_none()
898            .then(|| self.caret_px())
899            .flatten()
900            .map(|(x, top, ch)| {
901                div()
902                    .absolute()
903                    .left(px(x))
904                    .top(px(top))
905                    .w(px(2.0))
906                    .h(px(ch))
907                    .bg(theme.accent)
908            });
909
910        // Selection highlight: a translucent band from the anchor caret to the live caret
911        // (same row), painted behind the formula glyphs.
912        let selection = self.selection_range().is_some().then(|| {
913            let a = self.anchor.as_ref()?;
914            let (ax, _, _) = self.caret_px_of(a)?;
915            let (fx, top, ch) = self.caret_px()?;
916            let left = ax.min(fx);
917            let w = (ax - fx).abs();
918            let mut fill = theme.accent;
919            fill.a = 0.22;
920            (w > 0.5).then(|| {
921                div()
922                    .absolute()
923                    .left(px(left))
924                    .top(px(top))
925                    .w(px(w))
926                    .h(px(ch))
927                    .rounded(px(2.0))
928                    .bg(fill)
929            })
930        });
931        let selection = selection.flatten();
932
933        let pending = self.pending.as_ref().map(|p| {
934            let (x, top, _) = self.caret_px().unwrap_or((PAD, PAD, 0.0));
935            div()
936                .absolute()
937                .left(px(x))
938                .top(px(top))
939                .px_1()
940                .text_size(px(self.font_size * 0.42))
941                .text_color(theme.accent)
942                .bg(theme.accent_bg)
943                .child(format!("\\{p}"))
944        });
945        let dropdown = self.pending.as_ref().and_then(|p| {
946            let matches = input::command_matches(p);
947            if matches.is_empty() {
948                return None;
949            }
950            let (x, top, ch) = self.caret_px().unwrap_or((PAD, PAD, self.font_size));
951            let selected = self.selected;
952
953            // Inner scroll viewport: the full match list (no cap) scrolls within DROP_MAX_H.
954            let viewport = div()
955                .id("ratex-cmd-menu")
956                .max_h(px(DROP_MAX_H))
957                .overflow_y_scroll()
958                .track_scroll(&self.match_scroll)
959                .flex()
960                .flex_col()
961                .children(matches.iter().enumerate().map(|(i, name)| {
962                    // Pinned to DROP_ITEM_H so the scroll-into-view + thumb math (which
963                    // multiply by it) match the painted rows — padding-derived heights
964                    // drifted ~3px/row and the highlight walked off before scrolling.
965                    let row = div()
966                        .h(px(DROP_ITEM_H))
967                        .flex_shrink_0()
968                        .px_2()
969                        .flex()
970                        .items_center()
971                        .child(format!("\\{name}"));
972                    if i == selected {
973                        row.bg(theme.accent_bg).text_color(theme.accent)
974                    } else {
975                        row.text_color(theme.fg)
976                    }
977                }));
978
979            // Scrollbar thumb, shown only when the rows overflow the cap — sized from the
980            // content height + positioned from the live offset (mirrors the host slash menu).
981            // All MEASURED (last frame's bounds/content): estimating content height
982            // as rows × DROP_ITEM_H drifted from the paint, leaving the thumb short
983            // of the bottom on a fully-scrolled list. `max_offset` is content − view.
984            let vh = f32::from(self.match_scroll.bounds().size.height);
985            let max_scroll = f32::from(self.match_scroll.max_offset().y);
986            let thumb = (vh > 0.0 && max_scroll > 0.0).then(|| {
987                let scrolled = (-f32::from(self.match_scroll.offset().y)).clamp(0.0, max_scroll);
988                let thumb_h = (vh * vh / (vh + max_scroll)).max(24.0);
989                let thumb_top = scrolled / max_scroll * (vh - thumb_h);
990                let mut thumb_c = theme.muted;
991                thumb_c.a = 0.5;
992                div()
993                    .id("ratex-cmd-thumb")
994                    .absolute()
995                    .top(px(thumb_top))
996                    .right(px(2.0))
997                    .w(px(5.0))
998                    .h(px(thumb_h))
999                    .rounded(px(3.0))
1000                    .bg(thumb_c)
1001                    .cursor_pointer()
1002                    // Draggable: grab records (mouse y, scrolled-at-grab); the root's
1003                    // on_drag_move maps the delta back to a scroll offset (same
1004                    // GripDrag machinery as the palette).
1005                    .on_mouse_down(
1006                        MouseButton::Left,
1007                        cx.listener(move |this, ev: &MouseDownEvent, _window, cx| {
1008                            this.thumb_drag = Some((f32::from(ev.position.y), scrolled));
1009                            cx.stop_propagation();
1010                            cx.notify();
1011                        }),
1012                    )
1013                    .on_drag(GripDrag, |_, _, _, cx| {
1014                        cx.stop_propagation();
1015                        cx.new(|_| GripDrag)
1016                    })
1017            });
1018
1019            Some(
1020                div()
1021                    .absolute()
1022                    .left(px(x))
1023                    .top(px(top + ch + 4.0))
1024                    .occlude()
1025                    .bg(theme.panel)
1026                    .border_1()
1027                    .border_color(theme.border)
1028                    .rounded_md()
1029                    .overflow_hidden()
1030                    .text_size(px(14.0))
1031                    // An absolute container offers min-content width, which wraps a
1032                    // command name to one glyph per line — keep rows on one line so
1033                    // the panel sizes to its longest match.
1034                    .whitespace_nowrap()
1035                    .child(viewport)
1036                    .children(thumb),
1037            )
1038        });
1039
1040        // Records the formula's window-space bounds each paint, so a click can be mapped to a
1041        // caret position (the closure has no `&mut self`, hence the shared `Cell`).
1042        let bounds_cell = self.img_bounds.clone();
1043        let bounds_probe = canvas(
1044            move |bounds: Bounds<Pixels>, _window, _cx| bounds_cell.set(Some(bounds)),
1045            |_, _, _, _| {},
1046        )
1047        .absolute()
1048        .inset_0();
1049        // In-line: the palette lives inside the (flex-justified) formula container so it tracks
1050        // a centered / right-aligned formula. Standalone: it's a draggable root child.
1051        let palette = self.palette(window, cx);
1052        let (root_palette, inner_palette) = if self.inline {
1053            (None, Some(palette))
1054        } else {
1055            (Some(palette), None)
1056        };
1057
1058        div()
1059            .track_focus(&self.focus)
1060            .on_key_down(cx.listener(Self::on_key))
1061            .on_drag_move(
1062                cx.listener(|this, e: &gpui::DragMoveEvent<GripDrag>, _window, cx| {
1063                    let (mx, my) = (f32::from(e.event.position.x), f32::from(e.event.position.y));
1064                    if let Some((gx, gy)) = this.palette_drag {
1065                        if this.inline {
1066                            if let Some(b) = this.img_bounds.get() {
1067                                this.inline_palette_off = Some((
1068                                    mx - gx - f32::from(b.left()),
1069                                    my - gy - f32::from(b.top()),
1070                                ));
1071                            }
1072                        } else {
1073                            this.palette_pos = (mx - gx, my - gy);
1074                        }
1075                        cx.notify();
1076                    } else if let Some((lx, ly)) = this.toolbar_drag {
1077                        this.toolbar_off.0 += mx - lx;
1078                        this.toolbar_off.1 += my - ly;
1079                        this.toolbar_drag = Some((mx, my));
1080                        cx.notify();
1081                    } else if let Some((gy, scrolled_at_grab)) = this.thumb_drag {
1082                        // Thumb-drag → scroll offset: a thumb pixel covers
1083                        // max_scroll / (vh − thumb_h) content pixels. Same MEASURED
1084                        // quantities the thumb render derives, so they stay in step.
1085                        let vh = f32::from(this.match_scroll.bounds().size.height);
1086                        let max_scroll = f32::from(this.match_scroll.max_offset().y);
1087                        let thumb_h = (vh * vh / (vh + max_scroll)).max(24.0);
1088                        if max_scroll > 0.0 && vh > thumb_h {
1089                            let scrolled = (scrolled_at_grab
1090                                + (my - gy) * max_scroll / (vh - thumb_h))
1091                                .clamp(0.0, max_scroll);
1092                            this.match_scroll.set_offset(point(px(0.0), px(-scrolled)));
1093                            cx.notify();
1094                        }
1095                    }
1096                }),
1097            )
1098            .on_drag_move(
1099                cx.listener(|this, e: &gpui::DragMoveEvent<SelectDrag>, _window, cx| {
1100                    let Some(from) = this.select_drag.clone() else {
1101                        return;
1102                    };
1103                    let Some((ex, ey)) = this.em_at(e.event.position) else {
1104                        return;
1105                    };
1106                    let to = geometry::cursor_at(&this.root, ex, ey);
1107                    // Selection is single-row: both ends climb to their deepest COMMON
1108                    // row, taking each structure crossed in whole (a press inside one
1109                    // accent's base dragged into another's must not bail).
1110                    let (path, lo, hi) = crate::editor::cursor::common_row_selection(&from, &to);
1111                    if lo == hi {
1112                        this.anchor = None;
1113                        this.cursor = Cursor { path, index: lo };
1114                    } else {
1115                        // The moving end follows the pointer's side of the range: compare
1116                        // the two ends' positions at the common depth (the same measure
1117                        // common_row_selection orders by).
1118                        let depth = path.len();
1119                        let pos = |c: &Cursor| c.path.get(depth).map(|s| s.atom).unwrap_or(c.index);
1120                        let (fixed, moving) = if pos(&from) <= pos(&to) {
1121                            (lo, hi)
1122                        } else {
1123                            (hi, lo)
1124                        };
1125                        this.anchor = Some(Cursor {
1126                            path: path.clone(),
1127                            index: fixed,
1128                        });
1129                        this.cursor = Cursor {
1130                            path,
1131                            index: moving,
1132                        };
1133                    }
1134                    cx.notify();
1135                }),
1136            )
1137            .on_mouse_up(
1138                MouseButton::Left,
1139                cx.listener(|this, _: &MouseUpEvent, _window, cx| {
1140                    let dragged = this.palette_drag.take().is_some();
1141                    let dragged = this.toolbar_drag.take().is_some() || dragged;
1142                    let dragged = this.thumb_drag.take().is_some() || dragged;
1143                    this.select_drag = None;
1144                    if dragged {
1145                        cx.notify();
1146                    }
1147                }),
1148            )
1149            .relative()
1150            .size_full()
1151            .flex()
1152            // In-line: top-aligned, justified to match the display block so entering edit
1153            // doesn't shift the formula. Standalone: fully centered.
1154            .when(self.inline, |el| {
1155                let el = el.items_start();
1156                match self.align {
1157                    MathAlign::Left => el.justify_start(),
1158                    MathAlign::Center => el.justify_center(),
1159                    MathAlign::Right => el.justify_end(),
1160                }
1161            })
1162            .when(!self.inline, |el| {
1163                el.items_center().justify_center().bg(theme.panel)
1164            })
1165            .children(root_palette)
1166            .child(
1167                div()
1168                    .id("ratex-formula")
1169                    .relative()
1170                    .w(px(w))
1171                    .h(px(h))
1172                    .on_drag(SelectDrag, |_, _, _, cx| {
1173                        cx.stop_propagation();
1174                        cx.new(|_| SelectDrag)
1175                    })
1176                    .on_mouse_down(
1177                        MouseButton::Left,
1178                        cx.listener(|this, ev: &MouseDownEvent, window, cx| {
1179                            // Capture clicks on the formula so they don't fall through to the
1180                            // host text editor — which would blur + close this in-line editor
1181                            // and drop the caret to the next line. Keep focus on the formula.
1182                            this.focus.focus(window, cx);
1183                            cx.stop_propagation();
1184                            // macOS Control-click is a secondary click (delivered as left +
1185                            // control, not a right button) → the formula menu, like right-click.
1186                            if ev.modifiers.control {
1187                                cx.emit(MathNav::ContextMenu {
1188                                    position: ev.position,
1189                                });
1190                                return;
1191                            }
1192                            // 1 click → caret, 2 → select the atom, 3+ → select the row/slot.
1193                            match ev.click_count {
1194                                1 => {
1195                                    this.click_to_caret(ev.position, cx);
1196                                    // Arm drag-selection from the pressed caret; the root's
1197                                    // SelectDrag on_drag_move extends it as the mouse moves.
1198                                    this.select_drag = Some(this.cursor.clone());
1199                                }
1200                                2 => this.select_cell_at(ev.position, cx),
1201                                _ => this.select_row_at(ev.position, cx),
1202                            }
1203                        }),
1204                    )
1205                    .on_mouse_down(
1206                        MouseButton::Right,
1207                        cx.listener(|this, ev: &MouseDownEvent, window, cx| {
1208                            // Right-click while editing → ask the host for the formula menu
1209                            // (copy LaTeX / export). Keep focus + swallow the press so it
1210                            // doesn't blur the editor.
1211                            this.focus.focus(window, cx);
1212                            cx.stop_propagation();
1213                            cx.emit(MathNav::ContextMenu {
1214                                position: ev.position,
1215                            });
1216                        }),
1217                    )
1218                    // Capture the formula's window-space bounds for click-to-caret mapping.
1219                    .child(bounds_probe)
1220                    // Selection band first, so it paints behind the formula glyphs.
1221                    .children(selection)
1222                    .children(image)
1223                    .children(caret)
1224                    .children(pending)
1225                    // In-line palette + matrix toolbar live in the container, so they track the
1226                    // formula's centered / right-aligned position automatically.
1227                    // Deferred: both panels float outside the reserved gap (palette
1228                    // below the formula, toolbar under a matrix) and must paint above
1229                    // the host's later content.
1230                    .children(inner_palette.map(deferred))
1231                    .children(self.matrix_toolbar(cx).map(deferred))
1232                    // Deferred like the panels, and painted LAST: the dropdown floats
1233                    // below the caret, often past the reserved gap and over the
1234                    // palette (#77) — it must win both overlaps.
1235                    .children(dropdown.map(deferred)),
1236            )
1237    }
1238}