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}