Skip to main content

zorite_editor/
lib.rs

1//! Zorite's **WYSIWYG** (live-preview) markdown editor — and, without a
2//! [`SyntaxStyle`] installed, its **raw**-markdown editor. A from-scratch
3//! multi-line text editor for GPUI. (The third view, the read-only
4//! **reader**, is the separate `zorite-markdown` crate — the two engines share
5//! nothing, so any markdown behavior added here must be checked there and
6//! vice versa. See AGENTS.md "The three views".)
7//!
8//! Host-agnostic — depends only on `gpui` (+ `unicode-segmentation`); no
9//! `gpui-component`. Built directly on gpui's text primitives: an
10//! [`EntityInputHandler`] for keyboard + IME input, `shape_line` for per-line
11//! text shaping, and a custom [`Element`] that lays out + paints the lines,
12//! cursor, and selection. The editor **auto-grows** to its content height (no
13//! inner scrollbar), so a host can stack many editors in one scroll view.
14//! Editing fundamentals: cursor/selection, undo/redo, IME, soft-wrap,
15//! clipboard, spell-check diagnostics (squiggles + suggestion menu).
16//!
17//! WYSIWYG mode is [`EditorState::set_markdown_style`] plus the block
18//! providers (`set_block_image_provider` & co). Comments reference its
19//! feature milestones by code:
20//!
21//! - **W1** — inline styling: bold/italic/strike/code/links/wiki-links/tags,
22//!   markers dimmed in place (`markdown_syntax::scan_line`).
23//! - **W2** — heading font sizes (variable per-line heights).
24//! - **W4** — block widgets: **W4a** inline images, **W4b** fenced code
25//!   blocks, **W4c** tables (Word-style editing); mermaid + `$$math$$`
26//!   rasters ride the same widget path.
27//! - **W6** — marker *hiding* with reveal-on-caret: the painted text drops
28//!   the syntax markers, and per-row offset maps translate display ↔ source.
29//!
30//! Usage: create an [`EditorState`] entity and render it; call [`bind_keys`]
31//! once at startup so the editing actions resolve while it's focused.
32
33use std::ops::Range;
34use std::sync::Arc;
35
36use gpui::{
37    App, AvailableSpace, BorderStyle, Bounds, ClipboardItem, Context, Corners, CursorStyle, Edges,
38    Element, ElementId, ElementInputHandler, Entity, EntityInputHandler, EventEmitter, FocusHandle,
39    Focusable, Font, FontWeight, GlobalElementId, HighlightStyle, Hitbox, HitboxBehavior, Hsla,
40    InspectorElementId, InteractiveElement, IntoElement, KeyBinding, LayoutId, MouseButton,
41    MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, PathBuilder, Pixels,
42    Point, Render, RenderImage, ScrollHandle, SharedString, StatefulInteractiveElement, Style,
43    Styled, TextRun, UTF16Selection, Window, WrappedLine, actions, div, fill, hsla, point, px,
44    relative, rgb, rgba, size,
45};
46use unicode_segmentation::UnicodeSegmentation;
47
48mod markdown_syntax;
49pub use markdown_syntax::{AlertIcons, MathAlign, PropertyIconFn, SyntaxStyle};
50
51mod tables;
52use tables::*;
53
54mod element;
55use element::*;
56
57/// Key context the editing actions are scoped to (so they only fire while an
58/// editor is focused).
59const CONTEXT: &str = "Editor";
60
61actions!(
62    zorite_editor,
63    [
64        Backspace,
65        Delete,
66        Left,
67        Right,
68        Up,
69        Down,
70        Home,
71        End,
72        SelectLeft,
73        SelectRight,
74        SelectUp,
75        SelectDown,
76        SelectAll,
77        Newline,
78        Paste,
79        Copy,
80        Cut,
81        ShowCharacterPalette,
82        Undo,
83        Redo,
84        WordLeft,
85        WordRight,
86        SelectWordLeft,
87        SelectWordRight,
88        Indent,
89        Outdent,
90        Bold,
91        Italic,
92        Underline,
93        Strike,
94        Code,
95        Dismiss,
96    ]
97);
98
99/// Bind the editor's editing keys. Call once at startup. Bindings are scoped to
100/// the editor's key context, so they don't shadow the host's shortcuts.
101pub fn bind_keys(cx: &mut App) {
102    let ctx = Some(CONTEXT);
103    cx.bind_keys([
104        KeyBinding::new("backspace", Backspace, ctx),
105        KeyBinding::new("delete", Delete, ctx),
106        KeyBinding::new("left", Left, ctx),
107        KeyBinding::new("right", Right, ctx),
108        KeyBinding::new("up", Up, ctx),
109        KeyBinding::new("down", Down, ctx),
110        KeyBinding::new("home", Home, ctx),
111        KeyBinding::new("end", End, ctx),
112        KeyBinding::new("shift-left", SelectLeft, ctx),
113        KeyBinding::new("shift-right", SelectRight, ctx),
114        KeyBinding::new("shift-up", SelectUp, ctx),
115        KeyBinding::new("shift-down", SelectDown, ctx),
116        KeyBinding::new("enter", Newline, ctx),
117        KeyBinding::new("tab", Indent, ctx),
118        KeyBinding::new("shift-tab", Outdent, ctx),
119        KeyBinding::new("cmd-a", SelectAll, ctx),
120        KeyBinding::new("ctrl-a", SelectAll, ctx),
121        KeyBinding::new("cmd-c", Copy, ctx),
122        KeyBinding::new("ctrl-c", Copy, ctx),
123        KeyBinding::new("cmd-v", Paste, ctx),
124        KeyBinding::new("ctrl-v", Paste, ctx),
125        KeyBinding::new("cmd-x", Cut, ctx),
126        KeyBinding::new("ctrl-x", Cut, ctx),
127        KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, ctx),
128        KeyBinding::new("cmd-z", Undo, ctx),
129        KeyBinding::new("ctrl-z", Undo, ctx),
130        KeyBinding::new("cmd-shift-z", Redo, ctx),
131        KeyBinding::new("ctrl-shift-z", Redo, ctx),
132        KeyBinding::new("ctrl-y", Redo, ctx),
133        KeyBinding::new("alt-left", WordLeft, ctx),
134        KeyBinding::new("alt-right", WordRight, ctx),
135        KeyBinding::new("alt-shift-left", SelectWordLeft, ctx),
136        KeyBinding::new("alt-shift-right", SelectWordRight, ctx),
137        KeyBinding::new("cmd-b", Bold, ctx),
138        KeyBinding::new("ctrl-b", Bold, ctx),
139        KeyBinding::new("cmd-i", Italic, ctx),
140        KeyBinding::new("ctrl-i", Italic, ctx),
141        KeyBinding::new("cmd-e", Code, ctx),
142        KeyBinding::new("ctrl-e", Code, ctx),
143        KeyBinding::new("cmd-shift-x", Strike, ctx),
144        KeyBinding::new("ctrl-shift-x", Strike, ctx),
145        KeyBinding::new("cmd-u", Underline, ctx),
146        KeyBinding::new("ctrl-u", Underline, ctx),
147        KeyBinding::new("escape", Dismiss, ctx),
148    ]);
149}
150
151/// Cap on undo history (full snapshots) to bound memory.
152const UNDO_LIMIT: usize = 256;
153
154/// Line height as a multiple of the font size. Derived from the editor's own
155/// font (not the ambient `window.line_height()`, which tracks the host's UI text
156/// style and would leave the caret/rows mismatched against differently-sized
157/// editor text). 1.45 for comfortable reading density while typing (1.25 felt
158/// cramped, especially stacking several list rows). Public so a host's scroll
159/// math (e.g. Zorite's click-to-edit caret prediction) can mirror row heights.
160pub const LINE_HEIGHT_RATIO: f32 = 1.45;
161
162/// Extra height under each list/task row in WYSIWYG, matching the reader's
163/// roomier item gap (its list column uses a 4px inter-item gap) — the one
164/// place the reader's look wins over the editor's (see AGENTS.md "Parity
165/// direction"). Detected from the raw line, so it's caret-stable.
166const LIST_ROW_GAP: f32 = 4.;
167/// The gap between a painted bullet/checkbox and its item text — the reader's
168/// 8px marker gap, whose roomier indent wins (AGENTS.md "Parity direction").
169const LIST_TEXT_GAP: f32 = 8.;
170/// Per-space width (px) of one nesting level's indent, matching the reader's
171/// `list_indent` sizing (`spaces × 4.5`). A level therefore advances by
172/// bullet + gap + this — noticeably wider than the raw source spaces, so the
173/// display shifts on reveal-on-caret (the quote inset already set that
174/// precedent, just smaller).
175const LIST_LEVEL_PER_SPACE: f32 = 4.5;
176
177/// Caret thickness (px) — thin like a native text caret, so it doesn't blend into
178/// the first glyph at the start of a line/cell.
179const CARET_WIDTH: f32 = 1.0;
180
181/// Horizontal inset (px) of fenced-code-block text from the box's left edge, so
182/// code sits inside the padded box rather than flush against it. Mirrors the old
183/// renderer's `px(12)` left padding.
184const CODE_INSET: f32 = 12.;
185
186/// Vertical padding (px) above the first / below the last line of a fenced code
187/// block. Reserved as layout space (a gap in the line tops + total height) so the
188/// box doesn't overlap adjacent lines, with no blank line required.
189const CODE_PAD: f32 = 8.;
190
191/// Horizontal inset (px) of blockquote text from the editor's left edge, leaving
192/// room for the left border (2px) + a gap, matching the reading view's `pl(12)`.
193const QUOTE_INSET: f32 = 14.;
194
195/// Vertical padding (px) inside a file chip (e.g. a PDF embed), above + below its
196/// label, so the chip box reads as a button rather than a bare line of text.
197const CHIP_PAD: f32 = 5.;
198
199/// Total vertical breathing room (px) reserved around an inline image — split
200/// above + below — so consecutive images (a bulleted photo list) don't touch.
201const IMG_ROW_PAD: f32 = 12.;
202
203/// Extra height (px) a text row gets beyond its tallest inline `$…$` formula, so a fraction
204/// has a little breathing room above + below instead of touching the neighbouring rows.
205const INLINE_MATH_ROW_PAD: f32 = 6.;
206
207/// Side length (px) of the square drag-to-resize grip painted at an inline
208/// image's bottom-right corner (matching the reading view's 14px handle).
209const IMG_GRIP: f32 = 14.;
210
211/// Smallest width (px) a drag may shrink an inline image to, so it can't vanish.
212const IMG_MIN_W: f32 = 40.;
213
214/// An in-progress drag of an inline image's corner grip: which logical line's
215/// `![](src)` is being resized, its display width when the drag began, the
216/// pointer x at grab, and the live (preview) width the drag has reached. The
217/// image paints at `width` (aspect-preserved) until release writes `{width=N}`.
218#[derive(Clone, Copy)]
219struct ImageResize {
220    line: usize,
221    start_width: f32,
222    start_x: Pixels,
223    width: f32,
224}
225
226/// A restorable editor state, for undo/redo. Stores the caret offset (not a
227/// selection), so undo/redo place the caret rather than re-selecting text.
228#[derive(Clone)]
229struct Snapshot {
230    content: String,
231    caret: usize,
232}
233
234/// The last edit's kind, for coalescing a run of edits into one undo step.
235/// `Insert(end)` is a single-grapheme insert whose caret ends at `end`.
236#[derive(Clone, Copy, PartialEq)]
237enum EditKind {
238    Insert(usize),
239    Delete,
240    Other,
241}
242
243/// A flagged span (e.g. a misspelling) to underline. The host (e.g. a spell
244/// checker) computes these and feeds them in via [`EditorState::set_diagnostics`].
245/// Replacement suggestions are fetched lazily when the user right-clicks the
246/// span, via the provider set with [`EditorState::on_suggest`] — so detection
247/// can stay cheap and run on every edit.
248#[derive(Clone)]
249pub struct Diagnostic {
250    /// Byte range in the document.
251    pub range: Range<usize>,
252}
253
254/// An open right-click suggestions menu for a diagnostic.
255#[derive(Clone)]
256struct DiagMenu {
257    /// Popup top-left, in window space (rendered on a deferred/anchored layer).
258    anchor: Point<Pixels>,
259    /// The diagnostic's byte range, replaced when a suggestion is chosen.
260    range: Range<usize>,
261    suggestions: Vec<SharedString>,
262    /// Scroll state of the (capped-height) list, so a thumb can track it.
263    scroll: ScrollHandle,
264    /// Whether the "Turn into" flyout is open (hover-opened; dies with the menu).
265    turn_into: bool,
266}
267
268/// A block kind the right-click "Turn into" menu converts between —
269/// flat-markdown natural: each conversion is a line-prefix rewrite (fenced
270/// kinds wrap/unwrap the block's lines).
271#[derive(Clone, Copy, PartialEq, Eq)]
272enum TurnKind {
273    Text,
274    H1,
275    H2,
276    H3,
277    Bullet,
278    Numbered,
279    Todo,
280    Quote,
281    Callout,
282    Code,
283    Math,
284}
285
286impl TurnKind {
287    const ALL: [TurnKind; 11] = [
288        TurnKind::Text,
289        TurnKind::H1,
290        TurnKind::H2,
291        TurnKind::H3,
292        TurnKind::Bullet,
293        TurnKind::Numbered,
294        TurnKind::Todo,
295        TurnKind::Quote,
296        TurnKind::Callout,
297        TurnKind::Code,
298        TurnKind::Math,
299    ];
300
301    fn label(self, labels: &Labels) -> SharedString {
302        match self {
303            TurnKind::Text => labels.text.clone(),
304            TurnKind::H1 => labels.heading_1.clone(),
305            TurnKind::H2 => labels.heading_2.clone(),
306            TurnKind::H3 => labels.heading_3.clone(),
307            TurnKind::Bullet => labels.bulleted_list.clone(),
308            TurnKind::Numbered => labels.numbered_list.clone(),
309            TurnKind::Todo => labels.todo.clone(),
310            TurnKind::Quote => labels.quote.clone(),
311            TurnKind::Callout => labels.callout.clone(),
312            TurnKind::Code => labels.code_block.clone(),
313            TurnKind::Math => labels.math_block.clone(),
314        }
315    }
316}
317
318/// Canonicalize freshly loaded content: words-attached `$$` forms (mixed
319/// lines, words-on-fence-lines) normalize onto their own lines — in memory,
320/// persisted on the first edit — so STORED documents render display math
321/// exactly like fresh typing and the reader's pre-parse normalization.
322/// Unpaired `$$` prose/code is untouched. Both load paths (`with_text`,
323/// `set_text`) route through here.
324fn normalize_loaded(content: String) -> String {
325    if !content.contains("$$") {
326        return content;
327    }
328    match zorite_markdown::syntax::normalize_math_fences(&content) {
329        std::borrow::Cow::Owned(n) => n,
330        std::borrow::Cow::Borrowed(_) => content,
331    }
332}
333
334/// If `offset` sits on a collapsed marker line (a table style marker or a
335/// math align marker), the offset of the nearest line that reveals nothing
336/// when the caret rests there: the table's header, or the line after the
337/// math block. Otherwise `offset` unchanged.
338fn caret_off_marker_line(content: &str, offset: usize) -> usize {
339    let row = content[..offset.min(content.len())].matches('\n').count();
340    let line_start = |r: usize| {
341        let mut off = 0;
342        for (i, l) in content.split('\n').enumerate() {
343            if i == r {
344                return off;
345            }
346            off += l.len() + 1;
347        }
348        content.len()
349    };
350    if let Some(t) = markdown_syntax::table_regions(content)
351        .iter()
352        .find(|t| t.marker_line == Some(row))
353    {
354        return line_start(t.lines.start);
355    }
356    if let Some(m) = markdown_syntax::math_regions(content)
357        .iter()
358        .find(|m| m.marker_line == Some(row))
359    {
360        // Anywhere inside a math region reveals it whole — land after it.
361        return line_start(m.range.end);
362    }
363    offset
364}
365
366/// Strip a line's block dressing (heading hashes, list/todo bullet, ordered
367/// number, quote `>`), leaving the text a "Turn into" conversion re-prefixes.
368fn strip_block_prefix(line: &str) -> &str {
369    // Composes the renderer's own recognizers so the strip grammar can't
370    // drift from what WYSIWYG classifies (task/list/heading/quote).
371    if let Some((p, ..)) = markdown_syntax::task_prefix(line) {
372        return &line[p..];
373    }
374    if let Some((p, ..)) = markdown_syntax::list_prefix(line) {
375        return &line[p..];
376    }
377    if let Some(n) = markdown_syntax::heading_level(line) {
378        let after = &line[n as usize..];
379        return after.strip_prefix(' ').unwrap_or(after);
380    }
381    if let Some(p) = markdown_syntax::blockquote_prefix(line) {
382        return &line[p..];
383    }
384    line.trim_start()
385}
386
387/// Join `body` lines each carrying the prefix `p(index)` produces — the
388/// assembly half of a "Turn into" conversion.
389fn prefix_lines(body: &[String], p: impl Fn(usize) -> String) -> String {
390    body.iter()
391        .enumerate()
392        .map(|(i, l)| format!("{}{l}", p(i)))
393        .collect::<Vec<_>>()
394        .join("\n")
395}
396
397/// A column edit applied to every row of a table (insert/delete a cell at index).
398#[derive(Clone, Copy)]
399enum ColEdit {
400    Insert(usize),
401    Delete(usize),
402}
403
404/// An item in the table right-click menu (Word-style table editing).
405#[derive(Clone, Copy)]
406enum TableMenuAction {
407    InsertRowAbove,
408    InsertRowBelow,
409    DuplicateRow,
410    InsertColLeft,
411    InsertColRight,
412    DeleteRow,
413    DeleteColumn,
414    AlignLeft,
415    AlignCenter,
416    AlignRight,
417    /// Rewrite the table's `<!-- table:STYLE -->` marker (`None` = the
418    /// default Grid, which has no marker).
419    SetStyle(Option<&'static str>),
420    CopyTable,
421    DeleteTable,
422}
423
424impl TableMenuAction {
425    fn apply(self, editor: &mut EditorState, cx: &mut Context<EditorState>) {
426        match self {
427            TableMenuAction::InsertRowAbove => editor.insert_table_row(false, cx),
428            TableMenuAction::InsertRowBelow => editor.insert_table_row(true, cx),
429            TableMenuAction::DuplicateRow => editor.duplicate_table_row(cx),
430            TableMenuAction::InsertColLeft => editor.insert_table_column(false, cx),
431            TableMenuAction::InsertColRight => editor.insert_table_column(true, cx),
432            TableMenuAction::DeleteRow => editor.delete_table_row(cx),
433            TableMenuAction::DeleteColumn => editor.delete_table_column(cx),
434            TableMenuAction::AlignLeft => editor.set_caret_table_align(CellAlign::Left, cx),
435            TableMenuAction::AlignCenter => editor.set_caret_table_align(CellAlign::Center, cx),
436            TableMenuAction::AlignRight => editor.set_caret_table_align(CellAlign::Right, cx),
437            TableMenuAction::SetStyle(name) => editor.set_table_style(name, cx),
438            TableMenuAction::CopyTable => editor.copy_table(cx),
439            TableMenuAction::DeleteTable => editor.delete_table(cx),
440        }
441    }
442}
443
444/// Events the editor emits so a host can react. Subscribe with
445/// `cx.subscribe(&editor, …)` — e.g. to re-run spell-check after an edit.
446#[derive(Clone, Debug, PartialEq, Eq)]
447pub enum EditorEvent {
448    /// The document text changed via a user edit (typing, delete, paste, IME,
449    /// applying a suggestion). Not emitted for programmatic `set_text`.
450    Changed,
451    /// A file chip (e.g. a PDF embed) or an inline `[text](url)` link was
452    /// left-clicked — the host opens the `src`/url (http externally, files
453    /// via its own resolution). A navigation hint; the text is untouched.
454    OpenLink(SharedString),
455    /// A `[[wiki-link]]` or `#tag` was left-clicked — the host opens the page
456    /// with this title (Logseq semantics, matching the reading view).
457    OpenWikiLink(SharedString),
458    /// The caret / selection moved without a text change — so a host can update a
459    /// caret-anchored affordance (e.g. the table-alignment toolbar).
460    SelectionChanged,
461    /// The caret entered a `$$…$$` math block (by click, or by arrowing into it): its byte
462    /// `range` in the document (covering both fences) and the LaTeX `source` between them, so
463    /// the host can open a structural editor and replace the block's text on commit. `at_end`
464    /// seats that editor's caret at the formula's end (entered from below/right or by click)
465    /// vs its start (from above/left).
466    EditMath {
467        range: Range<usize>,
468        source: SharedString,
469        at_end: bool,
470        /// `true` for an inline `$…$` span (host splices `$…$` back, seats the editor at the
471        /// formula's spot); `false` for a `$$…$$` block (full-width gap).
472        inline: bool,
473    },
474    /// A `$$…$$` math block was right-clicked: the LaTeX source and the window-space click
475    /// position, so the host can show a context menu (Copy LaTeX / Export).
476    MathMenu {
477        source: SharedString,
478        position: Point<Pixels>,
479    },
480    /// A property panel was clicked or arrowed into: the byte `range` of the whole
481    /// `key:: value` block and its `source`, so the host can seat an in-place
482    /// property editor (via `set_editing_block`) and replace the block's text on
483    /// commit — the same seat/commit pattern as [`EditorEvent::EditMath`] for a
484    /// `$$` block. `at_end` seats focus on the last field (entered by arrowing up
485    /// from below) vs the first (click / arrowing down from above). A click also
486    /// carries `row` — the property line's index within the block — so the host
487    /// focuses the row the user actually clicked; arrows pass `None`.
488    EditProperties {
489        range: Range<usize>,
490        source: SharedString,
491        at_end: bool,
492        row: Option<usize>,
493    },
494    /// An inline `![](src)` image was left-clicked — the host opens a full-size
495    /// preview. The text is untouched.
496    PreviewImage(SharedString),
497}
498
499/// A table column's text alignment, for the host-driven alignment toolbar
500/// ([`EditorState::caret_table_align`] / [`EditorState::set_caret_table_align`]).
501#[derive(Clone, Copy, PartialEq, Eq, Debug)]
502pub enum CellAlign {
503    Left,
504    Center,
505    Right,
506}
507
508/// Provides replacement suggestions for a flagged word (best first); set by the
509/// host via [`EditorState::on_suggest`] and consulted on right-click.
510type SuggestFn = Box<dyn Fn(&str) -> Vec<String>>;
511
512/// Resolves a standalone image line's `src` to a decoded image so the editor can
513/// render it inline (W4). Set by the host via
514/// [`EditorState::set_block_image_provider`]; the host owns loading + caching and
515/// returns `None` while still decoding / on failure (the line shows raw source).
516type BlockImageFn = Box<dyn Fn(&str) -> Option<Arc<RenderImage>>>;
517
518/// Classifies an `![](src)` reference as a file chip (e.g. a PDF) rather than an
519/// image, returning its display label. Set via
520/// [`EditorState::set_block_chip_provider`]; the editor renders such a line as a
521/// clickable chip (left-click emits [`EditorEvent::OpenLink`]).
522type BlockChipFn = Box<dyn Fn(&str) -> Option<SharedString>>;
523
524/// Host-supplied clipboard writer for Copy/Cut — receives the markdown text
525/// the editor would put on the clipboard, so a host can add flavors gpui's
526/// clipboard can't (e.g. rendered HTML beside the plain string). See
527/// [`EditorState::set_clipboard_writer`].
528pub type ClipboardWriter = std::rc::Rc<dyn Fn(&str, &mut App)>;
529
530/// Resolves a standalone `![[target]]` embed line to the host view that renders
531/// the transclusion, plus the row height to reserve for it (the host estimates
532/// and caps it; long content scrolls inside the view). `None` falls back to the
533/// embed chip.
534type EmbedViewFn = Box<dyn Fn(&str) -> Option<(gpui::AnyView, Pixels)>>;
535
536/// Resolves a ` ```mermaid ` block's source to a rendered diagram bitmap plus its
537/// **logical** (display) px size — supplied by the host for the same reason as
538/// [`BlockMathFn`]. Set via [`EditorState::set_block_mermaid_provider`]; the host
539/// renders + caches off-thread (see [`mermaid_sources`] to pre-render).
540type BlockMermaidFn = Box<dyn Fn(&str) -> Option<(Arc<RenderImage>, f32, f32)>>;
541
542/// Resolves a `$$…$$` math block's LaTeX to a typeset bitmap plus its **logical**
543/// (display) px size, so the editor can render the block as the equation (caret
544/// outside) instead of raw source. The host supplies the logical size because it
545/// knows the raster's pixel density (e.g. typeset at a fixed 2× DPR); deriving it
546/// from texture pixels ÷ window scale factor renders 2× too large on a 1× display
547/// (the Linux/X11 bug — the division only cancels on a 2× "Retina" screen). Set
548/// via [`EditorState::set_block_math_provider`]; pre-render with [`math_sources`].
549type BlockMathFn = Box<dyn Fn(&str) -> Option<(Arc<RenderImage>, f32, f32)>>;
550
551/// Colors a fenced code block's tokens in WYSIWYG: `(language tag, block
552/// text) → sorted, non-overlapping styled ranges` (byte offsets into the
553/// block). Host-supplied (e.g. a tree-sitter highlighter) so the crate stays
554/// engine-free; absent it, code renders in `SyntaxStyle::code`. Set via
555/// [`EditorState::set_code_highlighter`].
556type CodeHighlightFn = Box<dyn Fn(&str, &str) -> Vec<(Range<usize>, HighlightStyle)>>;
557
558/// Host auto-replace hook, consulted when a word-boundary character (space,
559/// punctuation, Enter) completes a word: receives the just-finished line's
560/// text up to the boundary and returns the slice range to replace plus its
561/// replacement — e.g. wrapping a completed page title as `[[title]]`. The
562/// edit is one undo step (⌫Z restores the plain word) and the caret keeps its
563/// place after the boundary. Not consulted inside fenced code, and only for
564/// single-character insertions (never pastes or IME commits). Set via
565/// [`EditorState::set_auto_replace`].
566type AutoReplaceFn = Box<dyn Fn(&str) -> Option<(Range<usize>, String)>>;
567
568/// The diagram sources of every ` ```mermaid ` block in `content`, so a host can
569/// pre-render them (the editor's mermaid provider then finds the ready bitmap).
570pub fn mermaid_sources(content: &str) -> Vec<SharedString> {
571    markdown_syntax::mermaid_blocks(content)
572        .into_iter()
573        .map(|(_, source)| source.into())
574        .collect()
575}
576
577/// The LaTeX sources of every `$$…$$` math block in `content`, so a host can
578/// pre-render them (the editor's math provider then finds the ready bitmap).
579pub fn math_sources(content: &str) -> Vec<SharedString> {
580    markdown_syntax::math_blocks(content)
581        .into_iter()
582        .map(|(_, source)| source.into())
583        .collect()
584}
585
586/// The LaTeX sources of every inline `$…$` formula in `content` (the inner LaTeX, no `$`
587/// delimiters), so a host can pre-render them into the same math store the block provider
588/// reads. Skips lines inside fenced code blocks, where `$…$` is literal.
589pub fn inline_math_sources(content: &str) -> Vec<SharedString> {
590    let mut out = Vec::new();
591    let mut in_fence = false;
592    for line in content.split('\n') {
593        if line.trim_start().starts_with("```") {
594            in_fence = !in_fence;
595            continue;
596        }
597        if in_fence {
598            continue;
599        }
600        for span in markdown_syntax::inline_math_spans(line) {
601            out.push(markdown_syntax::inline_math_latex(line, &span).into());
602        }
603    }
604    out
605}
606
607/// The editor: text + cursor/selection state, an undo/redo history, plus a
608/// cached layout (the wrapped lines from the last paint) for hit-testing + IME.
609/// Renders the WYSIWYG view when a markdown [`SyntaxStyle`] is installed, the
610/// raw-markdown view otherwise.
611/// Host-injectable UI labels the editor renders in its context menus and
612/// chrome (right-click menu items, the code-block / math `Copy` chips, the
613/// table / "Turn into" menus). The crate stays host-agnostic so it never
614/// calls `t!()`; the app passes localized strings here via
615/// [`EditorState::set_labels`]. The default is English, keeping the crate
616/// usable standalone (and existing tests' expectations intact).
617#[derive(Clone)]
618pub struct Labels {
619    /// Text-selection right-click menu.
620    pub cut: SharedString,
621    pub copy: SharedString,
622    pub copy_as_markdown: SharedString,
623    pub paste: SharedString,
624    /// Code-block / formula chrome.
625    pub code_copy: SharedString,
626    pub math_copy: SharedString,
627    /// "Turn into" block-conversion menu.
628    pub turn_into: SharedString,
629    pub text: SharedString,
630    pub heading_1: SharedString,
631    pub heading_2: SharedString,
632    pub heading_3: SharedString,
633    pub bulleted_list: SharedString,
634    pub numbered_list: SharedString,
635    pub todo: SharedString,
636    pub quote: SharedString,
637    pub callout: SharedString,
638    pub code_block: SharedString,
639    pub math_block: SharedString,
640    /// Table right-click menu.
641    pub insert_row_above: SharedString,
642    pub insert_row_below: SharedString,
643    pub duplicate_row: SharedString,
644    pub insert_column_left: SharedString,
645    pub insert_column_right: SharedString,
646    pub align_left: SharedString,
647    pub align_center: SharedString,
648    pub align_right: SharedString,
649    pub grid_style: SharedString,
650    pub striped_style: SharedString,
651    pub header_style: SharedString,
652    pub minimal_style: SharedString,
653    pub delete_row: SharedString,
654    pub delete_column: SharedString,
655    pub delete_table: SharedString,
656    /// Property-panel menu.
657    pub edit_properties: SharedString,
658    pub delete_property: SharedString,
659    /// Image menu.
660    pub delete_image: SharedString,
661}
662
663impl Default for Labels {
664    fn default() -> Self {
665        Self {
666            cut: "Cut".into(),
667            copy: "Copy".into(),
668            copy_as_markdown: "Copy as Markdown".into(),
669            paste: "Paste".into(),
670            code_copy: "Copy".into(),
671            math_copy: "Copy".into(),
672            turn_into: "Turn into".into(),
673            text: "Text".into(),
674            heading_1: "Heading 1".into(),
675            heading_2: "Heading 2".into(),
676            heading_3: "Heading 3".into(),
677            bulleted_list: "Bulleted list".into(),
678            numbered_list: "Numbered list".into(),
679            todo: "To-do".into(),
680            quote: "Quote".into(),
681            callout: "Callout".into(),
682            code_block: "Code block".into(),
683            math_block: "Math block".into(),
684            insert_row_above: "Insert row above".into(),
685            insert_row_below: "Insert row below".into(),
686            duplicate_row: "Duplicate row".into(),
687            insert_column_left: "Insert column left".into(),
688            insert_column_right: "Insert column right".into(),
689            align_left: "Align left".into(),
690            align_center: "Align center".into(),
691            align_right: "Align right".into(),
692            grid_style: "Grid style".into(),
693            striped_style: "Striped style".into(),
694            header_style: "Header style".into(),
695            minimal_style: "Minimal style".into(),
696            delete_row: "Delete row".into(),
697            delete_column: "Delete column".into(),
698            delete_table: "Delete table".into(),
699            edit_properties: "Edit properties".into(),
700            delete_property: "Delete property".into(),
701            delete_image: "Delete image".into(),
702        }
703    }
704}
705
706pub struct EditorState {
707    focus_handle: FocusHandle,
708    /// The whole document, newline-separated. Byte offsets index into this.
709    content: String,
710    placeholder: SharedString,
711    /// Selection as a byte range; the caret is one end (see [`Self::cursor_offset`]).
712    selected_range: Range<usize>,
713    selection_reversed: bool,
714    /// IME composition range, if any.
715    marked_range: Option<Range<usize>>,
716    /// Host-supplied clipboard writer for Copy/Cut (e.g. adding an HTML
717    /// flavor beside the plain text). `None` = gpui's plain-string copy.
718    clipboard_writer: Option<ClipboardWriter>,
719    /// Find-in-feed highlights: match byte ranges + the active index, painted
720    /// behind the text like the selection. Host-driven ([`Self::set_search`]).
721    search: Option<(Vec<Range<usize>>, Option<usize>)>,
722    /// Last paint's wrapped lines (one per logical line) and each line's top
723    /// offset relative to the editor's top — both used for hit-testing and
724    /// cursor/IME positioning.
725    wrapped: Vec<WrappedLine>,
726    line_tops: Vec<Pixels>,
727    /// Per-logical-line wrap-row count (from the last paint). Geometry reads
728    /// this, not `wrapped[i].wrap_boundaries()` — a windowed-out line's entry
729    /// in `wrapped` is an empty placeholder.
730    wrap_rows: Vec<usize>,
731    /// Per-logical-line wrap-row height. Variable so a heading (bigger font) gets
732    /// a taller row (W2); `line_height` is the base/fallback for the empty doc
733    /// and any row without a recorded height.
734    line_heights: Vec<Pixels>,
735    /// Per-logical-line table-grid row (from the last paint), so a click /
736    /// Tab / caret hit-tests against cells instead of the raw source line.
737    table_rows: Vec<Option<TableRow>>,
738    /// Hover-revealed "+" add-row / add-column strips for each table (issue #16),
739    /// each paired with the table row to seat the caret in before inserting. From
740    /// the last paint, committed only while the table is hovered; hit-tested on
741    /// mouse-down.
742    table_row_add_rects: Vec<(Bounds<Pixels>, usize)>,
743    table_col_add_rects: Vec<(Bounds<Pixels>, usize, usize)>,
744    /// Each table's hover zone (grid + a thin margin) with its header row,
745    /// committed every paint so `on_mouse_move` can repaint when the pointer's
746    /// table-affordance region changes (the editor otherwise only repaints on
747    /// the caret blink) and `on_scroll_wheel` can hit-test the PAINTED table.
748    table_hover_zones: Vec<(Bounds<Pixels>, usize)>,
749    /// The affordance region the pointer was last in — `(table index, 0 = zone /
750    /// 1 = below strip / 2 = right strip)` — so the repaint fires only on change.
751    table_hover_region: Option<(usize, u8)>,
752    /// Committed delete-handle rects (issue #16): the hovered row's "−" `(bounds,
753    /// row)` and the hovered column's "−" `(bounds, row, col)`, hit-tested on click.
754    table_row_del: Option<(Bounds<Pixels>, usize)>,
755    table_col_del: Option<(Bounds<Pixels>, usize, usize)>,
756    /// The table cell `(row, col)` the pointer was last over, so `on_mouse_move`
757    /// repaints the delete handles when it changes.
758    table_hover_cell: Option<(usize, usize)>,
759    /// Per-logical-line flag: this row is painted as an inline image (W4), so a
760    /// click on it places the caret at the line start instead of hit-testing
761    /// source text. From the last paint.
762    widget_rows: Vec<bool>,
763    /// Per-logical-line display→source byte map for rows with hidden markers
764    /// (W6); `None` when the painted text equals the source. From the last paint.
765    offset_maps: Vec<Option<std::rc::Rc<Vec<usize>>>>,
766    /// Per-logical-line horizontal text inset (and so the caret/selection/hit-test
767    /// inset): non-zero for fenced code blocks and gutter marks (blockquotes,
768    /// lists). From the last paint.
769    line_insets: Vec<Pixels>,
770    /// Per-logical-line right-to-left geometry (#66), `None` on every LTR row
771    /// so an LTR document pays nothing. From the last paint. See [`RtlRow`].
772    rtl_rows: Vec<Option<RtlRow>>,
773    last_bounds: Option<Bounds<Pixels>>,
774    line_height: Pixels,
775    /// Font size from the last paint. Hit-testing that runs during event
776    /// dispatch (e.g. table-cell clicks) must measure at this size — the
777    /// window's text-style stack is unwound there, so `window.text_style()`
778    /// would report the root size, not the host wrapper's.
779    font_size: Pixels,
780    /// The font of the last paint, for the same reason: event-time
781    /// `text_style()` reports the ROOT font, whose family/metrics can differ
782    /// from what the table was painted with (column auto-fit measured with
783    /// the wrong glyph widths and wrapped cells).
784    paint_font: Option<Font>,
785    is_selecting: bool,
786    undo_stack: Vec<Snapshot>,
787    redo_stack: Vec<Snapshot>,
788    last_edit: EditKind,
789    /// Whether the last content edit was a single typed grapheme or a single-char
790    /// backspace — the only edits auto-pairing should react to, so programmatic /
791    /// structural edits (table ops, etc.) don't trip it.
792    last_edit_keystroke: bool,
793    /// Spaces inserted per Tab / one list-nesting level (`Indent`/`Outdent`); set
794    /// by the host via [`Self::set_tab_indent`] to match its list-indent setting.
795    tab_indent: usize,
796    /// The target x for vertical (Up/Down) movement, so the caret keeps its
797    /// column across short lines. `Some` only during a run of Up/Down.
798    goal_x: Option<Pixels>,
799    /// Spans to underline (misspellings, etc.), set by the host via
800    /// [`Self::set_diagnostics`].
801    diagnostics: Vec<Diagnostic>,
802    /// Inline-markdown styling palette; `Some` = the WYSIWYG (live-preview)
803    /// view (W1), `None` = the raw view (plain text). Set by the host via
804    /// [`Self::set_markdown_style`].
805    markdown_style: Option<SyntaxStyle>,
806    /// Host-injectable UI labels for context menus / chrome; English by
807    /// default, localized through [`Self::set_labels`].
808    labels: Labels,
809    /// The open right-click suggestions menu, if any.
810    menu: Option<DiagMenu>,
811    /// The open table right-click menu's anchor (window space), if any. Its actions
812    /// operate on the caret's table cell.
813    table_menu: Option<Point<Pixels>>,
814    /// Scroll state for the table menu, so its overflow scrolls + shows a thumb.
815    table_menu_scroll: ScrollHandle,
816    /// The open image right-click menu, if any: the image's logical line + the
817    /// menu's anchor (window space). Offers Word-style object actions (Delete).
818    image_menu: Option<(usize, Point<Pixels>)>,
819    /// Right-clicked property-panel row: its source line + click position
820    /// (anchors the Edit/Delete property menu).
821    prop_menu: Option<(usize, Point<Pixels>)>,
822    /// Supplies replacement suggestions for a flagged word, fetched lazily when
823    /// the user right-clicks it. Set by the host via [`Self::on_suggest`];
824    /// without it, the right-click menu has nothing to offer.
825    suggest: Option<SuggestFn>,
826    /// Resolves a standalone image line's `src` to a decoded image for inline
827    /// rendering (W4); set by the host via [`Self::set_block_image_provider`].
828    block_image: Option<BlockImageFn>,
829    /// Classifies an `![](src)` as a file chip (e.g. a PDF) + its label; set by
830    /// the host via [`Self::set_block_chip_provider`].
831    block_chip: Option<BlockChipFn>,
832    embed_view: Option<EmbedViewFn>,
833    /// Resolves a ` ```mermaid ` block's source to a rendered diagram; set by the
834    /// host via [`Self::set_block_mermaid_provider`].
835    block_mermaid: Option<BlockMermaidFn>,
836    /// Resolves a `$$…$$` block's LaTeX to a typeset equation; set by the host via
837    /// [`Self::set_block_math_provider`].
838    block_math: Option<BlockMathFn>,
839    /// Fenced-code syntax highlighter, see [`CodeHighlightFn`].
840    code_highlight: Option<CodeHighlightFn>,
841    /// Host auto-replace hook, see [`Self::set_auto_replace`].
842    auto_replace: Option<AutoReplaceFn>,
843    /// What the most recent keystroke edit replaced (the selected text), for
844    /// the host's auto-pair logic — a text diff alone can't distinguish
845    /// "typed `[` over a selection starting with `[`" from "backspaced inside
846    /// a doubled pair". Consumed via [`Self::take_replaced_selection`].
847    last_replaced: Option<String>,
848    /// The em (px/font-size) the `block_math` provider rasterizes at — set via
849    /// [`Self::set_block_math_em`]. Inline `$…$` formulas reuse those rasters scaled by
850    /// `text_em / this`, so they sit at text size. `None` disables inline math rendering.
851    block_math_em: Option<f32>,
852    /// Per-logical-line `src` for rows painted as a file chip (from the last
853    /// paint), so a left-click can open it and a right-click can edit it.
854    chip_rows: Vec<Option<(SharedString, bool)>>,
855    /// Window-space painted bounds of each inline image, with its logical line
856    /// index (from the last paint), so a press near a corner can start a resize
857    /// and know which `![](src)` line to rewrite. One entry per rendered image.
858    image_rects: Vec<(usize, Bounds<Pixels>)>,
859    /// Window-space bounds of each painted task checkbox, with its logical line —
860    /// so a click on the box toggles `[ ]`↔`[x]` instead of placing the caret.
861    checkbox_rects: Vec<(usize, Bounds<Pixels>)>,
862    /// Painted code-card chrome bounds from the last frame (lang tag + Copy per
863    /// code block, keyed by the opening-fence row) — clicks route here before
864    /// caret placement.
865    code_chip_rects: Vec<CodeChipHit>,
866    /// Full card bounds of each code block from the last paint (`(first body
867    /// line, rect)`), for hover tracking — the chrome is hover-revealed.
868    code_card_rects: Vec<(usize, Bounds<Pixels>)>,
869    /// The hovered code block's first body line, if any (chrome shows there).
870    code_chip_hover: Option<usize>,
871    /// Open language picker for a code block: `(opening fence row, anchor)`.
872    code_lang_menu: Option<(usize, Point<Pixels>)>,
873    code_lang_scroll: ScrollHandle,
874    /// Languages the host's highlighter supports, offered in the code block's
875    /// language picker. Empty (the default) disables the picker.
876    code_langs: Vec<SharedString>,
877    /// Painted chevron bounds of foldable callouts (`(line, rect)`, from the
878    /// last paint) — a click flips the marker's `-`/`+` fold char.
879    alert_fold_rects: Vec<(usize, Bounds<Pixels>)>,
880    /// The in-progress corner-grip drag, if any (see [`ImageResize`]). While set,
881    /// that image paints at the live width and other mouse handling is suppressed.
882    image_resize: Option<ImageResize>,
883    /// An in-progress table column-border drag (drag-to-resize, issue #16):
884    /// the column resizes live; release persists `cols=` into the table's
885    /// marker line. `None` = no drag.
886    table_col_resize: Option<TableColResize>,
887    /// Last-paint column-resize grip bands: `(band, header row, column, width)`.
888    table_col_resize_rects: Vec<(Bounds<Pixels>, usize, usize, f32)>,
889    /// The band index the pointer is on (repaint-on-change for its accent line).
890    table_resize_hover: Option<usize>,
891    /// See [`ShapeMemo`] — `RefCell` because the measure closure holds only a
892    /// read borrow of the editor.
893    shape_memo: std::cell::RefCell<Option<ShapeMemo>>,
894    /// See [`ScanData`].
895    scan_cache: std::cell::RefCell<Option<(u64, std::rc::Rc<ScanData>)>>,
896    /// See [`ScrollCompensatorFn`].
897    scroll_compensator: Option<ScrollCompensatorFn>,
898    /// Cross-frame shaping caches — line runs, table column widths, and table
899    /// wrap rows (see [`ShapeCaches`]). Capacity-capped in `shape_document`.
900    shape_caches: ShapeCaches,
901    /// The shaping window (element-local y, quantized), set after each
902    /// prepaint from the painted bounds — one frame stale by design, so the
903    /// measure pass and prepaint always shape with the SAME band and the
904    /// measure→prepaint memo keeps hitting.
905    shape_band: std::cell::Cell<Option<(f32, f32)>>,
906    /// Latch: the scroll compensator fired since the last paint. Measure can
907    /// run several times before a paint commits fresh `line_tops`; without
908    /// this, one async height change compensates once per measure call.
909    compensated: std::cell::Cell<bool>,
910    /// An active gutter block drag (Notion/Cditor-style reorder): the grabbed
911    /// block's first + last rows and the current drop boundary (a row index;
912    /// `== rows` drops at the document end).
913    line_drag: Option<(usize, usize, usize)>,
914    /// The row whose gutter grip the pointer hovers (mirrors prepaint's
915    /// computation) — tracked so hover changes repaint the grip.
916    grip_hover_row: Option<usize>,
917    /// Horizontal scroll of each wide table, keyed by its header row — wide
918    /// tables keep natural column widths and scroll in place. Keys drift on
919    /// edits above a table; entries are clamped at use, so a stale one is a
920    /// harmless partial offset. (ponytail: no eviction, the map stays tiny)
921    table_scroll_x: std::collections::HashMap<usize, f32>,
922    /// Last-paint wide-table scroll thumbs (padded grab rects), so the thumb
923    /// is mouse-draggable, not just an indicator.
924    table_thumbs: Vec<TableThumb>,
925    /// A live thumb drag: `(header row, grab x, scroll offset at grab)`.
926    table_thumb_drag: Option<(usize, Pixels, f32)>,
927    /// Extra left offset for the drag grip — the host sets its line-number
928    /// gutter's width here so the grip sits beside the numbers, not on them.
929    grip_inset: Pixels,
930    /// `content_gen` as of the last paint — a measure with the SAME generation
931    /// but different heights means an async (non-edit) height change, the
932    /// scroll-anchoring trigger.
933    last_paint_gen: u64,
934    /// Bumped on every content mutation — cheap staleness key for caches
935    /// (the UTF-16 conversion anchor below; a shape cache later).
936    content_gen: u64,
937    /// Resume point for UTF-8↔UTF-16 conversion: `(generation, utf8, utf16)`
938    /// of the last converted offset. IME composition fires conversions many
939    /// times per keystroke, clustered near the caret — resuming from the
940    /// anchor makes them O(distance) instead of O(document) (CJK latency
941    /// grew with note size; found auditing against Cditor's per-block IME).
942    utf16_anchor: std::cell::Cell<(u64, usize, usize)>,
943    /// A `$$…$$` block being edited in-line: its byte range + the host-supplied view (the
944    /// structural editor) painted in a reserved gap at the block's spot. `None` = none.
945    editing_block: Option<EditingBlock>,
946    /// Window-space painted bounds of each inline `$…$` formula + its absolute byte range and
947    /// inner LaTeX (from the last paint), so a click can open its structural editor and the
948    /// seated editor can be positioned at the formula's spot.
949    inline_math_rects: Vec<(Range<usize>, SharedString, Bounds<Pixels>)>,
950    /// An inline `$…$` formula under structural edit: its byte range + the host's editor view,
951    /// overlaid at the formula's spot. `None` = none.
952    editing_inline: Option<EditingInline>,
953    /// Painted bounds + target of each property-panel pill (from the last paint),
954    /// so a left-click opens it (`OpenWikiLink` / `OpenLink`).
955    prop_pill_rects: Vec<(Bounds<Pixels>, zorite_markdown::syntax::LinkHit)>,
956    /// Painted bounds of each property-panel row (from the last paint), so
957    /// `on_mouse_move` repaints when the hovered row changes (the panel's hover
958    /// border reads the live pointer during paint).
959    /// Each painted property-panel row's bounds + its source line (for
960    /// hover borders and the right-click property menu).
961    prop_row_rects: Vec<(Bounds<Pixels>, usize)>,
962    /// The property row the pointer was last over — drives `on_mouse_move`'s
963    /// repaint-on-change (like the table hover).
964    prop_hover_row: Option<usize>,
965    /// Collapsed headings, keyed by the heading's trimmed source line
966    /// (`## Goals`). View-local — markdown has no heading-fold syntax (unlike
967    /// callouts' `-`/`+`), so folds live for the editor's lifetime and a key
968    /// self-heals by vanishing when its heading text is edited.
969    folded_headings: std::collections::HashSet<String>,
970    /// Painted chevron bounds of heading folds (`(line, rect)`, from the last
971    /// paint) — a click toggles that heading in `folded_headings`.
972    heading_fold_rects: Vec<(usize, Bounds<Pixels>)>,
973    /// Window-space bounds of every heading's first visual row (from the last
974    /// paint) — `on_mouse_move` hit-tests these for the hover chevron.
975    heading_row_rects: Vec<(usize, Bounds<Pixels>)>,
976    /// The heading line the pointer was last over — its chevron shows on hover
977    /// (a fold chevron on every heading would clutter). Drives
978    /// `on_mouse_move`'s repaint-on-change, like the property-row hover.
979    heading_hover_row: Option<usize>,
980}
981
982/// A math block under in-line structural edit: the byte range to overwrite on commit, and
983/// the host's editor view to render in the reserved gap.
984struct EditingBlock {
985    range: Range<usize>,
986    view: gpui::AnyView,
987    /// The block's displayed height — the gap reserved while editing, so the formula stays
988    /// put instead of jumping to a fixed size.
989    height: Pixels,
990}
991
992/// An inline `$…$` formula under structural edit: the byte range to overwrite on commit, and
993/// the host's editor view, overlaid at the formula's painted spot.
994struct EditingInline {
995    range: Range<usize>,
996    view: gpui::AnyView,
997    /// Where the view's top-left sits relative to the formula raster's top-left. The
998    /// host's editor view pads its raster differently than the display raster (whose
999    /// padding was baked at the block em and scaled down), so a zero offset shifts
1000    /// the glyphs visibly on entering edit.
1001    offset: Point<Pixels>,
1002}
1003
1004impl EditorState {
1005    pub fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self {
1006        Self {
1007            focus_handle: cx.focus_handle(),
1008            content: String::new(),
1009            placeholder: SharedString::default(),
1010            selected_range: 0..0,
1011            selection_reversed: false,
1012            marked_range: None,
1013            clipboard_writer: None,
1014            search: None,
1015            wrapped: Vec::new(),
1016            line_tops: Vec::new(),
1017            line_heights: Vec::new(),
1018            wrap_rows: Vec::new(),
1019            widget_rows: Vec::new(),
1020            offset_maps: Vec::new(),
1021            line_insets: Vec::new(),
1022            rtl_rows: Vec::new(),
1023            table_rows: Vec::new(),
1024            table_row_add_rects: Vec::new(),
1025            table_col_add_rects: Vec::new(),
1026            table_hover_zones: Vec::new(),
1027            table_hover_region: None,
1028            table_row_del: None,
1029            table_col_del: None,
1030            table_hover_cell: None,
1031            last_bounds: None,
1032            line_height: px(20.),
1033            font_size: px(16.),
1034            paint_font: None,
1035            is_selecting: false,
1036            undo_stack: Vec::new(),
1037            redo_stack: Vec::new(),
1038            last_edit: EditKind::Other,
1039            last_edit_keystroke: false,
1040            tab_indent: 4,
1041            goal_x: None,
1042            diagnostics: Vec::new(),
1043            markdown_style: None,
1044            labels: Labels::default(),
1045            menu: None,
1046            table_menu: None,
1047            table_menu_scroll: ScrollHandle::new(),
1048            image_menu: None,
1049            prop_menu: None,
1050            suggest: None,
1051            block_image: None,
1052            block_chip: None,
1053            embed_view: None,
1054            block_mermaid: None,
1055            block_math: None,
1056            block_math_em: None,
1057            code_highlight: None,
1058            auto_replace: None,
1059            last_replaced: None,
1060            chip_rows: Vec::new(),
1061            image_rects: Vec::new(),
1062            checkbox_rects: Vec::new(),
1063            code_chip_rects: Vec::new(),
1064            code_card_rects: Vec::new(),
1065            code_chip_hover: None,
1066            code_lang_menu: None,
1067            code_lang_scroll: ScrollHandle::new(),
1068            code_langs: Vec::new(),
1069            alert_fold_rects: Vec::new(),
1070            image_resize: None,
1071            table_col_resize: None,
1072            table_col_resize_rects: Vec::new(),
1073            table_resize_hover: None,
1074            shape_memo: std::cell::RefCell::new(None),
1075            scan_cache: std::cell::RefCell::new(None),
1076            scroll_compensator: None,
1077            last_paint_gen: 0,
1078            shape_caches: ShapeCaches::default(),
1079            shape_band: std::cell::Cell::new(None),
1080            compensated: std::cell::Cell::new(false),
1081            line_drag: None,
1082            grip_hover_row: None,
1083            table_scroll_x: std::collections::HashMap::new(),
1084            table_thumbs: Vec::new(),
1085            table_thumb_drag: None,
1086            grip_inset: px(0.),
1087            content_gen: 0,
1088            utf16_anchor: std::cell::Cell::new((0, 0, 0)),
1089            editing_block: None,
1090            inline_math_rects: Vec::new(),
1091            editing_inline: None,
1092            prop_pill_rects: Vec::new(),
1093            prop_row_rects: Vec::new(),
1094            prop_hover_row: None,
1095            folded_headings: std::collections::HashSet::new(),
1096            heading_fold_rects: Vec::new(),
1097            heading_row_rects: Vec::new(),
1098            heading_hover_row: None,
1099        }
1100    }
1101
1102    /// Builder: start with the given text (caret at the start).
1103    pub fn with_text(mut self, text: impl Into<String>) -> Self {
1104        self.content = normalize_loaded(text.into());
1105        let caret = caret_off_marker_line(&self.content, 0);
1106        self.selected_range = caret..caret;
1107        self
1108    }
1109
1110    /// Builder: placeholder shown when empty.
1111    pub fn with_placeholder(mut self, text: impl Into<SharedString>) -> Self {
1112        self.placeholder = text.into();
1113        self
1114    }
1115
1116    /// The current document text.
1117    pub fn text(&self) -> &str {
1118        &self.content
1119    }
1120
1121    /// Replace byte `range` with `text` as ONE recorded (undoable) edit, leaving the caret
1122    /// after the inserted text. Unlike [`Self::set_text`] this preserves — and extends — the
1123    /// undo history, so a host writing back a structural edit (e.g. a committed `$$…$$`
1124    /// formula) lands as a normal undo step rather than clobbering the history.
1125    pub fn replace_range(&mut self, range: Range<usize>, text: &str, cx: &mut Context<Self>) {
1126        // Snap to char boundaries (start down, end up) so a stale/shifted range — e.g. one
1127        // captured before a prior formula commit moved the bytes — can't panic mid-UTF-8.
1128        let len = self.content.len();
1129        let mut start = range.start.min(len);
1130        while start > 0 && !self.content.is_char_boundary(start) {
1131            start -= 1;
1132        }
1133        let mut end = range.end.clamp(start, len);
1134        while end < len && !self.content.is_char_boundary(end) {
1135            end += 1;
1136        }
1137        let range = start..end;
1138        self.record_edit(&range, text);
1139        self.content.replace_range(range.clone(), text);
1140        self.remap_diagnostics(&range, text.len());
1141        let caret = range.start + text.len();
1142        self.selected_range = caret..caret;
1143        self.selection_reversed = false;
1144        self.marked_range = None;
1145        // Don't coalesce a following keystroke into this structural replacement.
1146        self.last_edit = EditKind::Other;
1147        cx.notify();
1148    }
1149
1150    /// Replace the whole document; resets the caret to the start.
1151    pub fn set_text(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
1152        self.content_gen += 1;
1153        self.content = normalize_loaded(text.into());
1154        // Never park the loaded caret on a collapsed marker line (`<!-- table/
1155        // math:… -->`): the first focus would reveal it raw mid-interaction.
1156        // This is the ONE passive parking path — every other caret write is a
1157        // deliberate placement (which SHOULD reveal markers for editing).
1158        let caret = caret_off_marker_line(&self.content, 0);
1159        self.selected_range = caret..caret;
1160        self.selection_reversed = false;
1161        self.marked_range = None;
1162        // A programmatic load isn't undoable to the prior document.
1163        self.undo_stack.clear();
1164        self.redo_stack.clear();
1165        self.last_edit = EditKind::Other;
1166        cx.notify();
1167    }
1168
1169    /// Replace the set of diagnostics (underlined spans). The host computes these
1170    /// (e.g. spell-check) and refreshes them as the text changes.
1171    pub fn set_diagnostics(&mut self, diagnostics: Vec<Diagnostic>, cx: &mut Context<Self>) {
1172        self.diagnostics = diagnostics;
1173        // Diagnostics feed the per-row run keys (they underline spans) — drop
1174        // the memo so the next shape re-keys affected lines.
1175        *self.shape_caches.row_keys.borrow_mut() = (None, Vec::new());
1176        cx.notify();
1177    }
1178
1179    /// Turn on WYSIWYG (live-preview) markdown styling with the given
1180    /// color/font palette (call once at setup). Inline bold/italic/code/link/
1181    /// tag formatting then renders as you type — markers stay in the text,
1182    /// dimmed. Without it the editor is the raw view: plain text, spell-check
1183    /// underlines only.
1184    /// Languages offered in a code block's language picker (the host's
1185    /// highlighter set, e.g. its compiled tree-sitter grammars). Empty — the
1186    /// default — leaves the tag click-inert.
1187    pub fn set_code_languages(&mut self, langs: Vec<SharedString>) {
1188        self.code_langs = langs;
1189    }
1190
1191    /// Install the scroll-anchoring hook (see [`ScrollCompensatorFn`]): when
1192    /// an async block render (math/mermaid/image) changes heights above the
1193    /// window viewport, the host receives the delta and shifts its scroll
1194    /// offset so the visible content stays put (Cditor's anchor-restore).
1195    pub fn set_scroll_compensator(&mut self, f: impl Fn(Pixels, &mut Window, &mut App) + 'static) {
1196        self.scroll_compensator = Some(std::rc::Rc::new(f));
1197    }
1198
1199    pub fn set_markdown_style(&mut self, style: SyntaxStyle, cx: &mut Context<Self>) {
1200        self.markdown_style = Some(style);
1201        cx.notify();
1202    }
1203
1204    /// Set the host-localized labels for the context menus / chrome. Replace
1205    /// on every language switch so the current editors pick it up.
1206    pub fn set_labels(&mut self, labels: Labels, cx: &mut Context<Self>) {
1207        self.labels = labels;
1208        cx.notify();
1209    }
1210
1211    /// Turn off live-preview styling — the editor falls back to plain text
1212    /// (spell-check underlines only). Used when the host's WYSIWYG setting is
1213    /// switched off; a no-op if styling was already off.
1214    pub fn clear_markdown_style(&mut self, cx: &mut Context<Self>) {
1215        if self.markdown_style.take().is_some() {
1216            cx.notify();
1217        }
1218    }
1219
1220    /// Install the provider consulted when the user right-clicks a flagged word.
1221    /// It's handed the offending word and returns replacements (best first).
1222    /// Kept lazy by design — the OS suggestion call can be slow, so it runs only
1223    /// on right-click, never in the per-edit detection pass.
1224    pub fn on_suggest(&mut self, provider: impl Fn(&str) -> Vec<String> + 'static) {
1225        self.suggest = Some(Box::new(provider));
1226    }
1227
1228    /// Install the provider that resolves a standalone image line's `src` to a
1229    /// decoded image; with it, such lines render inline (W4) when the caret is
1230    /// elsewhere. Without it (or while an image is still loading), the line shows
1231    /// its raw `![](src)` source.
1232    pub fn set_block_image_provider(
1233        &mut self,
1234        provider: impl Fn(&str) -> Option<Arc<RenderImage>> + 'static,
1235    ) {
1236        self.block_image = Some(Box::new(provider));
1237    }
1238
1239    /// Install the provider that classifies an `![](src)` reference as a file chip
1240    /// (e.g. a PDF) and supplies its label. With it, such lines render as a
1241    /// clickable chip when the caret is elsewhere; a left-click emits
1242    /// [`EditorEvent::OpenLink`] and a right-click places the caret to edit.
1243    pub fn set_block_chip_provider(
1244        &mut self,
1245        provider: impl Fn(&str) -> Option<SharedString> + 'static,
1246    ) {
1247        self.block_chip = Some(Box::new(provider));
1248    }
1249
1250    /// Install the provider that resolves a standalone `![[target]]` line to a
1251    /// host view rendering the transclusion + the height to reserve for it.
1252    /// With it, such lines show the embedded content in place (raw on caret);
1253    /// without (or when it returns `None`) they fall back to a clickable chip.
1254    pub fn set_embed_provider(
1255        &mut self,
1256        provider: impl Fn(&str) -> Option<(gpui::AnyView, Pixels)> + 'static,
1257    ) {
1258        self.embed_view = Some(Box::new(provider));
1259    }
1260
1261    /// Install the provider that resolves a ` ```mermaid ` block's source to a
1262    /// rendered diagram: the bitmap plus its logical (display) px size — see
1263    /// [`BlockMathFn`] for why the host supplies the size. With it, such a block
1264    /// renders as the diagram when the caret is elsewhere; with the caret inside
1265    /// (or while it renders) it shows the raw fenced source. Pre-render with
1266    /// [`mermaid_sources`].
1267    pub fn set_block_mermaid_provider(
1268        &mut self,
1269        provider: impl Fn(&str) -> Option<(Arc<RenderImage>, f32, f32)> + 'static,
1270    ) {
1271        self.block_mermaid = Some(Box::new(provider));
1272    }
1273
1274    /// Install the provider that resolves a `$$…$$` block's LaTeX to a typeset
1275    /// equation: the bitmap plus its logical (display) px size — see
1276    /// [`BlockMathFn`] for why the host supplies the size. With it, such a block
1277    /// renders as the equation when the caret is elsewhere; with the caret inside
1278    /// (or while it renders) it shows the raw `$$…$$` source. Pre-render with
1279    /// [`math_sources`].
1280    /// Route Copy/Cut through `writer` instead of gpui's plain-string copy —
1281    /// the host owns the actual clipboard write (and its extra flavors).
1282    pub fn set_clipboard_writer(&mut self, writer: ClipboardWriter) {
1283        self.clipboard_writer = Some(writer);
1284    }
1285
1286    /// Highlight `matches` (source byte ranges) behind the text — soft yellow,
1287    /// with `active` in the stronger current-match orange (the reader's
1288    /// browser-style find colors). Empty clears. Host-driven: a find bar
1289    /// computes matches (see [`find_in_source`]) and steps `active`.
1290    pub fn set_search(
1291        &mut self,
1292        matches: Vec<Range<usize>>,
1293        active: Option<usize>,
1294        cx: &mut Context<Self>,
1295    ) {
1296        self.search = (!matches.is_empty()).then_some((matches, active));
1297        cx.notify();
1298    }
1299
1300    /// The window-space top of the row containing byte `offset` (from the
1301    /// last layout) — for a host scrolling a find match into view. `None`
1302    /// before first paint or for an out-of-range offset.
1303    pub fn offset_screen_top(&self, offset: usize) -> Option<Pixels> {
1304        let bounds = self.last_bounds?;
1305        let (row, _) = self.row_col(offset.min(self.content.len()));
1306        Some(bounds.top() + self.line_tops.get(row).copied()?)
1307    }
1308
1309    pub fn set_block_math_provider(
1310        &mut self,
1311        provider: impl Fn(&str) -> Option<(Arc<RenderImage>, f32, f32)> + 'static,
1312    ) {
1313        self.block_math = Some(Box::new(provider));
1314    }
1315
1316    /// Declare the em the `block_math` provider rasterizes at (e.g. the host's display-math
1317    /// font size). Turns on inline `$…$` rendering: each inline formula reuses the block
1318    /// raster for the same LaTeX, scaled by `text_em / em` so it sits at text size. Pre-render
1319    /// inline sources too (see [`inline_math_sources`]).
1320    pub fn set_block_math_em(&mut self, em: f32) {
1321        self.block_math_em = (em > 0.).then_some(em);
1322    }
1323
1324    /// Set the fenced-code syntax highlighter (see [`CodeHighlightFn`]).
1325    pub fn set_code_highlighter(
1326        &mut self,
1327        f: impl Fn(&str, &str) -> Vec<(Range<usize>, HighlightStyle)> + 'static,
1328    ) {
1329        self.code_highlight = Some(Box::new(f));
1330    }
1331
1332    /// The text the most recent keystroke edit replaced (its selection), if
1333    /// any — consumed (one read per edit). Lets a host's auto-pair logic tell
1334    /// "opener typed over a selection" from deletions with identical diffs.
1335    pub fn take_replaced_selection(&mut self) -> Option<String> {
1336        self.last_replaced.take()
1337    }
1338
1339    /// Set the word-completion auto-replace hook (see [`AutoReplaceFn`]).
1340    pub fn set_auto_replace(
1341        &mut self,
1342        f: impl Fn(&str) -> Option<(Range<usize>, String)> + 'static,
1343    ) {
1344        self.auto_replace = Some(Box::new(f));
1345    }
1346
1347    /// Run the host's auto-replace hook after a boundary character landed at
1348    /// `boundary` (the byte offset of the char itself). Applies the returned
1349    /// replacement as its own undo step and shifts the caret by the growth.
1350    fn apply_auto_replace(&mut self, boundary: usize) {
1351        let Some(f) = self.auto_replace.as_ref() else {
1352            return;
1353        };
1354        let line_start = self.content[..boundary].rfind('\n').map_or(0, |p| p + 1);
1355        let line = &self.content[line_start..boundary];
1356        if line.is_empty() {
1357            return;
1358        }
1359        // Inside a fenced code block, the text is verbatim — never rewrite it.
1360        // Fence parity comes from the cached scan (this runs on every boundary
1361        // keystroke; the per-line rescan grew with the document).
1362        let (row, _) = self.row_col(boundary);
1363        if *self.scan_data().fence_odd.get(row).unwrap_or(&false)
1364            || line.trim_start().starts_with("```")
1365        {
1366            return;
1367        }
1368        let Some((r, replacement)) = f(line) else {
1369            // No host rule fired — normalize math around the caret instead:
1370            // words-attached `$$` fences and words-mixed `$$…$$` pairs split
1371            // onto their own lines (issue #54: the formula renders display,
1372            // the words stay VISIBLE — nothing is ever hidden). Paragraph-
1373            // bounded, one recorded edit.
1374            self.normalize_math_at(row);
1375            return;
1376        };
1377        if r.start >= r.end || r.end > line.len() {
1378            return;
1379        }
1380        let abs = line_start + r.start..line_start + r.end;
1381        let delta = replacement.len() as isize - abs.len() as isize;
1382        self.record_edit(&abs, &replacement);
1383        self.content =
1384            self.content[..abs.start].to_owned() + &replacement + &self.content[abs.end..];
1385        self.remap_diagnostics(&abs, replacement.len());
1386        let caret = (self.selected_range.start as isize + delta) as usize;
1387        self.selected_range = caret..caret;
1388    }
1389
1390    /// Begin an in-line structural edit of the `$$…$$` block at `range`: reserve a gap at
1391    /// its spot and paint `view` (the host's editor) there. The host focuses `view`.
1392    pub fn set_editing_block(
1393        &mut self,
1394        range: Range<usize>,
1395        view: gpui::AnyView,
1396        height: Pixels,
1397        cx: &mut Context<Self>,
1398    ) {
1399        self.editing_block = Some(EditingBlock {
1400            range,
1401            view,
1402            height,
1403        });
1404        cx.notify();
1405    }
1406
1407    /// The byte range of the block currently being structurally edited (the range handed
1408    /// to [`Self::set_editing_block`] — the source text is untouched while the edit is
1409    /// open, so it stays valid). `None` when no block edit is open.
1410    pub fn editing_block_range(&self) -> Option<Range<usize>> {
1411        self.editing_block.as_ref().map(|eb| eb.range.clone())
1412    }
1413
1414    /// End an in-line math edit (the host has committed / cancelled). Returns the block's
1415    /// byte range, so the host can overwrite it.
1416    pub fn end_editing_block(&mut self, cx: &mut Context<Self>) -> Option<Range<usize>> {
1417        let range = self.editing_block.take().map(|eb| eb.range);
1418        cx.notify();
1419        range
1420    }
1421
1422    /// Begin a structural edit of the inline `$…$` span at `range` (absolute bytes): overlay
1423    /// `view` (the host's editor) at the formula's painted spot. The host focuses `view`.
1424    /// `offset` places the view relative to the raster's top-left, letting the host
1425    /// align the view's glyphs with the displayed formula's (their paddings differ).
1426    pub fn set_editing_inline(
1427        &mut self,
1428        range: Range<usize>,
1429        view: gpui::AnyView,
1430        offset: Point<Pixels>,
1431        cx: &mut Context<Self>,
1432    ) {
1433        self.editing_inline = Some(EditingInline {
1434            range,
1435            view,
1436            offset,
1437        });
1438        cx.notify();
1439    }
1440
1441    /// End an inline math edit. Returns the span's byte range, so the host can overwrite it.
1442    pub fn end_editing_inline(&mut self, cx: &mut Context<Self>) -> Option<Range<usize>> {
1443        let range = self.editing_inline.take().map(|e| e.range);
1444        cx.notify();
1445        range
1446    }
1447
1448    /// Whether `range` still bounds an inline `$…$` span (a `$` at each end, content between, no
1449    /// newline, not a `$$` fence) — guards the inline commit against a stale/shifted range that
1450    /// would otherwise splice text at the wrong spot.
1451    pub fn is_inline_math_range(&self, range: &Range<usize>) -> bool {
1452        range.start < range.end
1453            && range.end <= self.content.len()
1454            && self.content.is_char_boundary(range.start)
1455            && self.content.is_char_boundary(range.end)
1456            && {
1457                let s = &self.content[range.clone()];
1458                s.len() >= 3
1459                    && s.starts_with('$')
1460                    && s.ends_with('$')
1461                    && !s.starts_with("$$")
1462                    && !s.contains('\n')
1463            }
1464    }
1465
1466    /// The horizontal alignment of the `$$…$$` block whose byte range starts at `block_start`
1467    /// (its `<!-- math:ALIGN -->` marker, or `Center` by default) — so the host can seed the
1468    /// in-line editor at the right justification when opening it.
1469    pub fn math_align(&self, block_start: usize) -> MathAlign {
1470        let row = self.row_col(block_start).0;
1471        markdown_syntax::math_regions(&self.content)
1472            .into_iter()
1473            .find(|r| r.range.start == row)
1474            .map_or(MathAlign::default(), |r| r.align)
1475    }
1476
1477    /// Compute the recorded edit that writes `align`'s marker for the `$$` block at byte
1478    /// `block`: the (possibly marker-extended) range to replace, and the marker prefix to
1479    /// prepend to the rewritten block. Center (default) → no marker (drops any existing one);
1480    /// left/right → add or replace it. The host appends the block text to the prefix. Folding
1481    /// the marker into the block's commit edit avoids a separate, range-shifting edit.
1482    pub fn math_marker_edit(
1483        &self,
1484        block: Range<usize>,
1485        align: MathAlign,
1486    ) -> (Range<usize>, String) {
1487        let row = self.row_col(block.start).0;
1488        let prefix = align.marker().map_or(String::new(), |m| format!("{m}\n"));
1489        let has_marker =
1490            row > 0 && markdown_syntax::math_align_marker(self.line_str(row - 1)).is_some();
1491        let start = if has_marker {
1492            self.line_starts()[row - 1]
1493        } else {
1494            block.start
1495        };
1496        (start..block.end, prefix)
1497    }
1498
1499    /// Re-find a `$$…$$` block by its exact LaTeX `source`, returned as a BYTE range (nearest
1500    /// to the now-stale byte `approx` if several match) — so opening/committing one after a
1501    /// prior formula's commit shifted offsets targets the right block. `math_blocks` yields
1502    /// LINE ranges, so convert like `math_block_at` does (else the caret jumps to the top).
1503    pub fn find_math_block(&self, source: &str, approx: usize) -> Option<Range<usize>> {
1504        let starts = self.line_starts();
1505        markdown_syntax::math_blocks(&self.content)
1506            .into_iter()
1507            .filter(|(_, s)| s == source)
1508            .map(|(r, _)| starts[r.start]..self.line_end(r.end - 1))
1509            .min_by_key(|r| r.start.abs_diff(approx))
1510    }
1511
1512    /// Re-find an inline `$…$` span by its exact inner LaTeX, as an absolute byte range (nearest
1513    /// to the now-stale byte `approx` if several match) — the inline counterpart of
1514    /// [`Self::find_math_block`], so opening/committing after a prior edit shifted offsets
1515    /// targets the right span.
1516    pub fn find_inline_math(&self, latex: &str, approx: usize) -> Option<Range<usize>> {
1517        let mut line_start = 0;
1518        let mut best: Option<Range<usize>> = None;
1519        for line in self.content.split('\n') {
1520            for span in markdown_syntax::inline_math_spans(line) {
1521                if markdown_syntax::inline_math_latex(line, &span) == latex {
1522                    let abs = line_start + span.start..line_start + span.end;
1523                    if best
1524                        .as_ref()
1525                        .is_none_or(|b| abs.start.abs_diff(approx) < b.start.abs_diff(approx))
1526                    {
1527                        best = Some(abs);
1528                    }
1529                }
1530            }
1531            line_start += line.len() + 1;
1532        }
1533        best
1534    }
1535
1536    /// Whether byte `range` (half-open) still starts a `$$…$$` block — a commit guard so a
1537    /// stale/shifted range can't splice the block into the wrong place and corrupt the doc.
1538    pub fn is_math_block_range(&self, range: &Range<usize>) -> bool {
1539        range.end <= self.content.len()
1540            && range.start <= range.end
1541            && self.content.is_char_boundary(range.start)
1542            && self.content[range.start..range.end]
1543                .trim_start()
1544                .starts_with("$$")
1545    }
1546
1547    /// The text of logical line `row` (without its trailing newline).
1548    fn line_str(&self, row: usize) -> &str {
1549        let starts = self.line_starts();
1550        match starts.get(row) {
1551            Some(&s) => &self.content[s..self.line_end(row)],
1552            None => "",
1553        }
1554    }
1555
1556    /// The host-supplied embed views, each positioned in the gap its
1557    /// `![[target]]` line reserved (from the last paint's line tops) — the
1558    /// editing-block overlay generalized to N transclusions. Absolute children
1559    /// of the editor's `relative` root, so they scroll with the content; the
1560    /// caret's own line shows raw source instead (its gap wasn't reserved).
1561    fn embed_overlays(&self, window: &Window) -> Vec<gpui::Div> {
1562        let Some(provider) = &self.embed_view else {
1563            return Vec::new();
1564        };
1565        if self.markdown_style.is_none() {
1566            return Vec::new();
1567        }
1568        let caret_row = self
1569            .focus_handle
1570            .is_focused(window)
1571            .then(|| self.row_col(self.cursor_offset()).0);
1572        let mut out = Vec::new();
1573        for (row, line) in self.content.split('\n').enumerate() {
1574            if caret_row == Some(row) {
1575                continue;
1576            }
1577            let Some(inner) = zorite_markdown::syntax::embed_line(line) else {
1578                continue;
1579            };
1580            let (Some(top), Some((view, height))) = (self.line_tops.get(row), provider(inner))
1581            else {
1582                continue;
1583            };
1584            out.push(
1585                div()
1586                    .absolute()
1587                    .top(*top)
1588                    .left(px(0.))
1589                    .w_full()
1590                    .h(height)
1591                    // Clicks/wheel belong to the embed (it may scroll its own
1592                    // content), not the text layer underneath.
1593                    .occlude()
1594                    .child(view),
1595            );
1596        }
1597        out
1598    }
1599
1600    /// The host-supplied editor view for an in-line math edit, positioned in the gap its
1601    /// block reserves (from the last paint's line tops/heights). An absolute child of the
1602    /// editor's `relative` root, so it scrolls with the content.
1603    fn editing_block_overlay(&self) -> Option<gpui::Div> {
1604        let eb = self.editing_block.as_ref()?;
1605        let row = self.row_col(eb.range.start).0;
1606        let top = *self.line_tops.get(row)?;
1607        let height = *self.line_heights.get(row)?;
1608        Some(
1609            div()
1610                .absolute()
1611                .top(top)
1612                .left(px(0.))
1613                .w_full()
1614                .h(height)
1615                // Occlude so clicks inside the hosted math editor don't fall through to the
1616                // text layer below — which would seat the caret on the next line and steal
1617                // focus, blurring (committing + closing) the structural editor.
1618                .occlude()
1619                .child(eb.view.clone()),
1620        )
1621    }
1622
1623    /// The host-supplied editor view for an inline `$…$` edit, overlaid at the formula's last-
1624    /// painted spot (its window rect, made editor-relative via `content_origin`). Unlike a
1625    /// `$$` block it doesn't reserve a full-width gap — it floats over the formula, leaving the
1626    /// surrounding text in place.
1627    fn editing_inline_overlay(&self) -> Option<gpui::Div> {
1628        let ei = self.editing_inline.as_ref()?;
1629        let (_, _, rect) = self
1630            .inline_math_rects
1631            .iter()
1632            .find(|(r, _, _)| *r == ei.range)?;
1633        let origin = self.last_bounds.map_or(Point::default(), |b| b.origin);
1634        Some(
1635            div()
1636                .absolute()
1637                .top(rect.origin.y - origin.y + ei.offset.y)
1638                .left(rect.origin.x - origin.x + ei.offset.x)
1639                .occlude()
1640                .child(ei.view.clone()),
1641        )
1642    }
1643
1644    /// Spaces inserted per Tab / list-nesting level (`Indent`/`Outdent`). The host
1645    /// keeps this in sync with its list-indent setting so nesting is configurable.
1646    pub fn set_tab_indent(&mut self, spaces: usize) {
1647        self.tab_indent = spaces.max(1);
1648    }
1649
1650    /// The caret's byte offset into [`Self::text`] (the moving end of any
1651    /// selection). For hosts that drive a menu/completion off the caret position.
1652    pub fn cursor(&self) -> usize {
1653        self.cursor_offset()
1654    }
1655
1656    /// Whether the last content change was a single typed character or single-char
1657    /// backspace (vs a programmatic / multi-char edit). Hosts gate auto-pairing on
1658    /// this so structural edits (table row/column ops, paste, …) don't trip it.
1659    pub fn last_edit_was_keystroke(&self) -> bool {
1660        self.last_edit_keystroke
1661    }
1662
1663    /// Place the caret at `offset` (a byte offset into the document), collapsing
1664    /// any selection. Clamped to the document and snapped down to a char
1665    /// boundary, so a host can pass a raw click offset safely — e.g. to enter
1666    /// edit mode where rendered text was clicked.
1667    pub fn set_cursor(&mut self, offset: usize, cx: &mut Context<Self>) {
1668        let mut offset = offset.min(self.content.len());
1669        while !self.content.is_char_boundary(offset) {
1670            offset -= 1;
1671        }
1672        self.move_to(offset, cx);
1673    }
1674
1675    /// Per logical line, from the last paint: its top offset within the
1676    /// editor and its first wrap-row's height — enough for a host-drawn
1677    /// gutter (line numbers) to align with rows without re-deriving layout.
1678    /// Empty before the first paint. Rows collapsed by a heading fold show
1679    /// no vertical advance (the next row's top equals theirs) — a gutter
1680    /// should skip those.
1681    pub fn row_layout(&self) -> Vec<(Pixels, Pixels)> {
1682        self.line_tops
1683            .iter()
1684            .enumerate()
1685            .map(|(row, &top)| (top, self.line_h(row)))
1686            .collect()
1687    }
1688
1689    /// The cached [`ScanData`] for the current content, rebuilding on a
1690    /// generation mismatch.
1691    fn scan_data(&self) -> std::rc::Rc<ScanData> {
1692        if let Some((generation, data)) = self.scan_cache.borrow().as_ref()
1693            && *generation == self.content_gen
1694        {
1695            return data.clone();
1696        }
1697        let lines: Vec<&str> = self.content.split('\n').collect();
1698        let mut fence_odd = Vec::with_capacity(lines.len());
1699        let mut odd = false;
1700        for l in &lines {
1701            fence_odd.push(odd);
1702            if l.trim_start().starts_with("```") {
1703                odd = !odd;
1704            }
1705        }
1706        let data = std::rc::Rc::new(ScanData {
1707            generation: self.content_gen,
1708            ordered: markdown_syntax::ordered_numbers(&lines),
1709            tables: markdown_syntax::table_regions(&self.content),
1710            mermaid: markdown_syntax::mermaid_blocks(&self.content),
1711            math: markdown_syntax::math_regions(&self.content),
1712            props: markdown_syntax::property_regions(&self.content),
1713            alert_folds: markdown_syntax::alert_fold_regions(&self.content),
1714            fence_odd,
1715        });
1716        *self.scan_cache.borrow_mut() = Some((self.content_gen, data.clone()));
1717        data
1718    }
1719
1720    /// The wrap-row count of logical line `row` (1 when unrecorded).
1721    fn row_span(&self, row: usize) -> usize {
1722        self.wrap_rows.get(row).copied().unwrap_or(1).max(1)
1723    }
1724
1725    /// The wrap-row height of logical line `row` (a heading is taller). Falls
1726    /// back to the base `line_height` for unrecorded rows / the empty document.
1727    fn line_h(&self, row: usize) -> Pixels {
1728        self.line_heights
1729            .get(row)
1730            .copied()
1731            .unwrap_or(self.line_height)
1732    }
1733
1734    /// Horizontal text inset for logical line `row` (from the last paint): non-zero
1735    /// for fenced code blocks + gutter marks. Applied to the caret, selection,
1736    /// hit-test, and text paint so they all stay aligned.
1737    fn line_inset(&self, row: usize) -> Pixels {
1738        self.line_insets.get(row).copied().unwrap_or(px(0.))
1739    }
1740
1741    /// The right-align shift of an RTL row (zero everywhere else) — see
1742    /// [`RtlRow::shift`].
1743    fn rtl_shift(&self, row: usize) -> Pixels {
1744        self.rtl_rows
1745            .get(row)
1746            .and_then(Option::as_ref)
1747            .and_then(|r| r.shifts.first().copied())
1748            .unwrap_or(px(0.))
1749    }
1750
1751    /// Where logical line `row`'s painted text actually starts: its inset plus
1752    /// the right-align shift of an RTL row (#66). Everything that positions
1753    /// against a row's text — caret, click, selection, link boxes — goes
1754    /// through this, so the two can never drift apart.
1755    fn row_origin_x(&self, row: usize) -> Pixels {
1756        self.line_inset(row) + self.rtl_shift(row)
1757    }
1758
1759    /// Does the caret's TABLE row read right-to-left? Cells step through their
1760    /// own stepper, which walks in logical order — so on an RTL table the
1761    /// visual arrows map to the opposite step.
1762    fn caret_table_is_rtl(&self) -> bool {
1763        let (row, _) = self.row_col(self.cursor_offset());
1764        self.table_rows
1765            .get(row)
1766            .and_then(Option::as_ref)
1767            .is_some_and(|t| t.rtl)
1768    }
1769
1770    /// Does the caret's line read right-to-left?
1771    ///
1772    /// Arrow keys move VISUALLY — Right steps to the character on the right of
1773    /// the screen, which every platform does in bidi text and which readers of
1774    /// Persian expect. On an RTL row that character is the logically PREVIOUS
1775    /// one, so the two step functions swap.
1776    fn caret_row_is_rtl(&self) -> bool {
1777        let (row, _) = self.row_col(self.cursor_offset());
1778        self.rtl_rows
1779            .get(row)
1780            .and_then(Option::as_ref)
1781            .is_some_and(|r| r.base_rtl)
1782    }
1783
1784    /// The offset one step to the visual left/right of the caret, taking the
1785    /// row's direction into account.
1786    fn horizontal_step(&self, visual_right: bool) -> usize {
1787        let off = self.cursor_offset();
1788        let (row, col) = self.row_col(off);
1789        // Inside a bidi row, "one step right" is not "one byte forward, maybe
1790        // flipped". A Latin word or URL embedded in Persian runs the other way,
1791        // and the caret has to flow THROUGH it rather than jump to its far end
1792        // — so the step comes from the glyph order, via the row's map.
1793        if let Some(r) = self.bidi_map(row) {
1794            let dcol = self.display_col(row, col);
1795            let (k, local) = r.row_of(dcol);
1796            if let Some(rr) = r.rows.get(k)
1797                && let Some(next) = rr.map.step_visual(local, visual_right)
1798            {
1799                let target = self.line_starts()[row] + self.source_col(row, rr.start + next);
1800                // A visual step that doesn't move the caret in the DOCUMENT has
1801                // landed inside something atomic — an inline formula's spacer,
1802                // whose every display byte maps back to the span's start. The
1803                // logical stepper knows how to cross those (and how to hand the
1804                // formula to its editor), so defer to it rather than sitting
1805                // still.
1806                if target != off {
1807                    // Landing ON a spacer resolves to the span's START, and the
1808                    // "is the caret inside a formula?" test wants strictly
1809                    // inside — so approaching a formula from its end side, the
1810                    // caret stepped to the start, failed the test, and the next
1811                    // press left the formula behind. Step one byte in so the
1812                    // formula opens from either side.
1813                    let line_start = self.line_starts()[row];
1814                    let here = off.saturating_sub(line_start);
1815                    let lands_on_a_formula = markdown_syntax::inline_math_spans(self.line_str(row))
1816                        .into_iter()
1817                        .any(|s| {
1818                            line_start + s.start == target && !(s.start < here && here < s.end)
1819                        });
1820                    return if lands_on_a_formula {
1821                        target + 1
1822                    } else {
1823                        target
1824                    };
1825                }
1826            }
1827            // Off the end of this row: fall through to the logical neighbour,
1828            // which is what carries the caret onto the next row or line.
1829            return if visual_right != self.caret_row_is_rtl() {
1830                self.next_visible_boundary(off)
1831            } else {
1832                self.prev_visible_boundary(off)
1833            };
1834        }
1835        if visual_right {
1836            self.next_visible_boundary(off)
1837        } else {
1838            let target = self.prev_visible_boundary(off);
1839            // Same nudge as the bidi branch above: a leftward step over a formula's
1840            // spacer resolves to the span's START, which fails `left()`'s strictly-
1841            // inside test — so on plain LTR rows the editor never opened from the
1842            // right and the caret just seated at the formula's start (#77).
1843            let (trow, _) = self.row_col(target);
1844            let line_start = self.line_starts()[trow];
1845            let here = off.saturating_sub(line_start);
1846            let lands_on_a_formula = markdown_syntax::inline_math_spans(self.line_str(trow))
1847                .into_iter()
1848                .any(|s| line_start + s.start == target && !(s.start < here && here < s.end));
1849            if lands_on_a_formula {
1850                target + 1
1851            } else {
1852                target
1853            }
1854        }
1855    }
1856
1857    /// The RTL layout for `row`, if it has one (see [`RtlRow`]).
1858    fn bidi_map(&self, row: usize) -> Option<&RtlRow> {
1859        self.rtl_rows.get(row).and_then(Option::as_ref)
1860    }
1861
1862    /// Window-space bounds of the caret at `offset`, from the last paint's
1863    /// layout — for anchoring a popup (e.g. a slash menu) at a document offset.
1864    /// `None` before the first paint or if `offset`'s row isn't laid out.
1865    pub fn bounds_for_offset(&self, offset: usize) -> Option<Bounds<Pixels>> {
1866        let bounds = self.last_bounds?;
1867        let (row, col) = self.row_col(offset);
1868        let lh = self.line_h(row);
1869        let line = self.wrapped.get(row)?;
1870        let p = line_pos(line, self.bidi_map(row), self.display_col(row, col), lh)?;
1871        let top = bounds.top() + self.line_tops.get(row).copied().unwrap_or(px(0.)) + p.y;
1872        let x = bounds.left() + p.x + self.row_origin_x(row);
1873        Some(Bounds::from_corners(point(x, top), point(x, top + lh)))
1874    }
1875
1876    /// The document text as an owned [`SharedString`]; use [`Self::text`] for a
1877    /// borrowed `&str`.
1878    pub fn value(&self) -> SharedString {
1879        self.content.clone().into()
1880    }
1881
1882    /// Focus the editor so it receives keyboard input. (`set_cursor` only moves
1883    /// the caret; call this to enter edit mode, e.g. on a click into rendered text.)
1884    pub fn focus(&self, window: &mut Window, cx: &mut Context<Self>) {
1885        self.focus_handle.focus(window, cx);
1886    }
1887
1888    /// Keep diagnostics valid across an edit at `edited` (the replaced byte
1889    /// range) that inserted `new_len` bytes: spans before the edit are left
1890    /// alone, spans after it are shifted by the size delta, and spans that
1891    /// overlap the edited text are dropped (that text changed, so they're
1892    /// stale). The host still recomputes the edited region on its own schedule —
1893    /// this just keeps the *other* spans correct so they don't all flicker off
1894    /// on every keystroke.
1895    fn remap_diagnostics(&mut self, edited: &Range<usize>, new_len: usize) {
1896        let delta = new_len as isize - (edited.end - edited.start) as isize;
1897        self.diagnostics.retain_mut(|d| {
1898            if d.range.end <= edited.start {
1899                true
1900            } else if d.range.start >= edited.end {
1901                d.range.start = (d.range.start as isize + delta) as usize;
1902                d.range.end = (d.range.end as isize + delta) as usize;
1903                true
1904            } else {
1905                false
1906            }
1907        });
1908    }
1909
1910    // --- Cursor movement -----------------------------------------------------
1911
1912    fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
1913        if !self.selected_range.is_empty() {
1914            // Collapse to the selection's VISUALLY left edge, which on an RTL
1915            // row is its logical end.
1916            let to = if self.caret_row_is_rtl() {
1917                self.selected_range.end
1918            } else {
1919                self.selected_range.start
1920            };
1921            self.move_to(to, cx);
1922            return;
1923        }
1924        if self.caret_in_table()
1925            && let Some(off) =
1926                self.table_move_horizontal(if self.caret_table_is_rtl() { 1 } else { -1 })
1927        {
1928            self.move_to(off, cx);
1929            return;
1930        }
1931        let off = self.horizontal_step(false);
1932        if let Some((range, source)) = self.inline_math_span_at(off) {
1933            cx.emit(EditorEvent::EditMath {
1934                range,
1935                source,
1936                at_end: true,
1937                inline: true,
1938            });
1939            return;
1940        }
1941        if let Some((range, source)) = self.math_block_at(self.row_col(off).0) {
1942            cx.emit(EditorEvent::EditMath {
1943                range,
1944                source,
1945                at_end: true,
1946                inline: false,
1947            });
1948            return;
1949        }
1950        // Left into a property panel opens its editor at the last field.
1951        if let Some((range, source)) = self.property_block_at(self.row_col(off).0) {
1952            cx.emit(EditorEvent::EditProperties {
1953                range,
1954                source,
1955                at_end: true,
1956                row: None,
1957            });
1958            return;
1959        }
1960        self.move_to(off, cx);
1961    }
1962
1963    fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
1964        if !self.selected_range.is_empty() {
1965            let to = if self.caret_row_is_rtl() {
1966                self.selected_range.start
1967            } else {
1968                self.selected_range.end
1969            };
1970            self.move_to(to, cx);
1971            return;
1972        }
1973        if self.caret_in_table()
1974            && let Some(off) =
1975                self.table_move_horizontal(if self.caret_table_is_rtl() { -1 } else { 1 })
1976        {
1977            self.move_to(off, cx);
1978            return;
1979        }
1980        let off = self.horizontal_step(true);
1981        if let Some((range, source)) = self.inline_math_span_at(off) {
1982            cx.emit(EditorEvent::EditMath {
1983                range,
1984                source,
1985                at_end: false,
1986                inline: true,
1987            });
1988            return;
1989        }
1990        if let Some((range, source)) = self.math_block_at(self.row_col(off).0) {
1991            cx.emit(EditorEvent::EditMath {
1992                range,
1993                source,
1994                at_end: false,
1995                inline: false,
1996            });
1997            return;
1998        }
1999        // Right into a property panel opens its editor at the first field.
2000        if let Some((range, source)) = self.property_block_at(self.row_col(off).0) {
2001            cx.emit(EditorEvent::EditProperties {
2002                range,
2003                source,
2004                at_end: false,
2005                row: None,
2006            });
2007            return;
2008        }
2009        self.move_to(off, cx);
2010    }
2011
2012    fn up(&mut self, _: &Up, _: &mut Window, cx: &mut Context<Self>) {
2013        // In a table, step cell-to-cell keeping the column; at the table's edge
2014        // `table_move_vertical` returns `None` and a normal move exits the table.
2015        if self.caret_in_table()
2016            && let Some(off) = self.table_move_vertical(-1)
2017        {
2018            self.move_to(off, cx);
2019            return;
2020        }
2021        let off = self.move_vertical(-1);
2022        if let Some((range, source)) = self.inline_math_span_at(off) {
2023            cx.emit(EditorEvent::EditMath {
2024                range,
2025                source,
2026                at_end: true,
2027                inline: true,
2028            });
2029            return;
2030        }
2031        if let Some((range, source)) = self.math_block_at(self.row_col(off).0) {
2032            cx.emit(EditorEvent::EditMath {
2033                range,
2034                source,
2035                at_end: true,
2036                inline: false,
2037            });
2038            return;
2039        }
2040        // Arrowing UP into a property panel opens its editor at the LAST field
2041        // (entered from below), not the raw source.
2042        if let Some((range, source)) = self.property_block_at(self.row_col(off).0) {
2043            cx.emit(EditorEvent::EditProperties {
2044                range,
2045                source,
2046                at_end: true,
2047                row: None,
2048            });
2049            return;
2050        }
2051        // Set the caret directly (not via `move_to`) to keep the goal column.
2052        self.selected_range = off..off;
2053        self.last_edit = EditKind::Other;
2054        cx.emit(EditorEvent::SelectionChanged);
2055        cx.notify();
2056    }
2057
2058    fn down(&mut self, _: &Down, _: &mut Window, cx: &mut Context<Self>) {
2059        if self.caret_in_table()
2060            && let Some(off) = self.table_move_vertical(1)
2061        {
2062            self.move_to(off, cx);
2063            return;
2064        }
2065        let off = self.move_vertical(1);
2066        if let Some((range, source)) = self.inline_math_span_at(off) {
2067            cx.emit(EditorEvent::EditMath {
2068                range,
2069                source,
2070                at_end: false,
2071                inline: true,
2072            });
2073            return;
2074        }
2075        if let Some((range, source)) = self.math_block_at(self.row_col(off).0) {
2076            cx.emit(EditorEvent::EditMath {
2077                range,
2078                source,
2079                at_end: false,
2080                inline: false,
2081            });
2082            return;
2083        }
2084        // Arrowing DOWN into a property panel opens its editor at the FIRST field.
2085        if let Some((range, source)) = self.property_block_at(self.row_col(off).0) {
2086            cx.emit(EditorEvent::EditProperties {
2087                range,
2088                source,
2089                at_end: false,
2090                row: None,
2091            });
2092            return;
2093        }
2094        self.selected_range = off..off;
2095        self.last_edit = EditKind::Other;
2096        cx.emit(EditorEvent::SelectionChanged);
2097        cx.notify();
2098    }
2099
2100    fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
2101        self.goal_x = None;
2102        let off = self.horizontal_step(false);
2103        self.select_to(off, cx);
2104    }
2105
2106    fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
2107        self.goal_x = None;
2108        let off = self.horizontal_step(true);
2109        self.select_to(off, cx);
2110    }
2111
2112    fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
2113        let off = self.move_vertical(-1);
2114        self.select_to(off, cx);
2115    }
2116
2117    fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
2118        let off = self.move_vertical(1);
2119        self.select_to(off, cx);
2120    }
2121
2122    fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
2123        self.move_to(0, cx);
2124        self.select_to(self.content.len(), cx);
2125    }
2126
2127    fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
2128        let (row, col) = self.row_col(self.cursor_offset());
2129        let starts = self.line_starts();
2130        // Smart Home on a gutter line (list/task/quote): the marker is hidden
2131        // behind a painted bullet, so land on the first content character —
2132        // the raw line start would reveal the marker and let typing break it.
2133        // A second Home (at or inside the prefix) goes to the true start.
2134        let plen = self.hidden_prefix_len(row);
2135        let target = if plen > 0 && col > plen {
2136            starts[row] + plen
2137        } else {
2138            starts[row]
2139        };
2140        self.move_to(target, cx);
2141    }
2142
2143    /// The hidden marker prefix length of logical `row` — list/task/quote
2144    /// lines draw their marker as a painted gutter and hide the source chars.
2145    /// 0 when the line has no gutter or markdown styling is off.
2146    fn hidden_prefix_len(&self, row: usize) -> usize {
2147        if self.markdown_style.is_none() {
2148            return 0;
2149        }
2150        let Some(&start) = self.line_starts().get(row) else {
2151            return 0;
2152        };
2153        let line = &self.content[start..self.line_end(row)];
2154        markdown_syntax::task_prefix(line)
2155            .map(|(l, ..)| l)
2156            .or_else(|| markdown_syntax::list_prefix(line).map(|(l, ..)| l))
2157            .or_else(|| markdown_syntax::blockquote_prefix(line))
2158            .unwrap_or(0)
2159    }
2160
2161    fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
2162        let (row, _) = self.row_col(self.cursor_offset());
2163        self.move_to(self.line_end(row), cx);
2164    }
2165
2166    // --- Editing -------------------------------------------------------------
2167
2168    fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context<Self>) {
2169        if self.selected_range.is_empty() {
2170            // Word-style image deletion: with the caret on an image row — or at
2171            // the start of the line just below one — remove the whole picture
2172            // (line + newline) as one edit, never stepping into its hidden
2173            // markdown character by character.
2174            let off = self.cursor_offset();
2175            let (row, col) = self.row_col(off);
2176            if let Some(range) = self.image_row_range(row).or_else(|| {
2177                (col == 0 && row > 0)
2178                    .then(|| self.image_row_range(row - 1))
2179                    .flatten()
2180            }) {
2181                self.replace_range(range, "", cx);
2182                cx.emit(EditorEvent::Changed);
2183                return;
2184            }
2185            // The same Word-style treatment for math: backspacing onto an
2186            // inline formula's closing `$` removes the whole formula, and at
2187            // the start of the line below a `$$` block removes the whole
2188            // block — never stripping one hidden delimiter and dumping raw
2189            // LaTeX. A caret strictly INSIDE a span (revealed source) still
2190            // edits character-wise.
2191            if let Some((range, _)) = self
2192                .inline_math_span_at(self.previous_boundary(off))
2193                .filter(|(r, _)| off == r.end)
2194            {
2195                self.replace_range(range, "", cx);
2196                cx.emit(EditorEvent::Changed);
2197                return;
2198            }
2199            if col == 0
2200                && row > 0
2201                && self.math_block_at(row).is_none() // inside = raw editing
2202                && let Some((range, _)) = self.math_block_at(row - 1)
2203            {
2204                let range = self.math_delete_range(range);
2205                self.replace_range(range, "", cx);
2206                cx.emit(EditorEvent::Changed);
2207                return;
2208            }
2209            // Backspacing from the line below a property panel joins as
2210            // usual — but the caret would land inside the panel and reveal
2211            // its raw `key:: value` source. Seat the in-place form after the
2212            // join instead (the same landing as arrowing in from below).
2213            let join_into_props = col == 0
2214                && row > 0
2215                && self.property_block_at(row).is_none()
2216                && self.property_block_at(row - 1).is_some();
2217            // Cditor-style around hidden formatting markers: delete the
2218            // previous VISIBLE character (never a marker byte), and take an
2219            // emptied construct's marker pair with it.
2220            if let Some(range) = self.fmt_delete_range(off, true) {
2221                self.replace_range(range, "", cx);
2222                cx.emit(EditorEvent::Changed);
2223                return;
2224            }
2225            let prev = self.previous_boundary(off);
2226            if off == prev {
2227                return;
2228            }
2229            self.select_to(prev, cx);
2230            self.replace_text_in_range(None, "", window, cx);
2231            if join_into_props {
2232                self.edit_properties_at_caret(true, cx);
2233            }
2234            return;
2235        }
2236        self.replace_text_in_range(None, "", window, cx);
2237    }
2238
2239    fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context<Self>) {
2240        if self.selected_range.is_empty() {
2241            // Word-style, mirroring `backspace`: the caret on an image row — or
2242            // at the end of the line just above one — removes the whole picture.
2243            let off = self.cursor_offset();
2244            let (row, _) = self.row_col(off);
2245            if let Some(range) = self.image_row_range(row).or_else(|| {
2246                (off == self.line_end(row))
2247                    .then(|| self.image_row_range(row + 1))
2248                    .flatten()
2249            }) {
2250                self.replace_range(range, "", cx);
2251                cx.emit(EditorEvent::Changed);
2252                return;
2253            }
2254            // Math mirrors of the backspace guards: deleting onto an inline
2255            // formula's opening `$` removes the whole formula; at the end of
2256            // the line above a `$$` block removes the whole block.
2257            if let Some((range, _)) = self
2258                .inline_math_span_at(self.next_boundary(off))
2259                .filter(|(r, _)| off == r.start)
2260            {
2261                self.replace_range(range, "", cx);
2262                cx.emit(EditorEvent::Changed);
2263                return;
2264            }
2265            if off == self.line_end(row)
2266                && self.math_block_at(row).is_none() // inside = raw editing
2267                && let Some((range, _)) = self.math_block_at(row + 1)
2268            {
2269                let range = self.math_delete_range(range);
2270                self.replace_range(range, "", cx);
2271                cx.emit(EditorEvent::Changed);
2272                return;
2273            }
2274            // Mirroring backspace's property join: pulling the panel's first
2275            // line up would seat a raw caret in the block — open the form.
2276            let join_into_props = off == self.line_end(row)
2277                && self.property_block_at(row).is_none()
2278                && self.property_block_at(row + 1).is_some();
2279            // Cditor-style around hidden formatting markers (see backspace).
2280            if let Some(range) = self.fmt_delete_range(off, false) {
2281                self.replace_range(range, "", cx);
2282                cx.emit(EditorEvent::Changed);
2283                return;
2284            }
2285            let next = self.next_boundary(off);
2286            if off == next {
2287                return;
2288            }
2289            self.select_to(next, cx);
2290            self.replace_text_in_range(None, "", window, cx);
2291            if join_into_props {
2292                self.edit_properties_at_caret(false, cx);
2293            }
2294            return;
2295        }
2296        self.replace_text_in_range(None, "", window, cx);
2297    }
2298
2299    fn newline(&mut self, _: &Newline, window: &mut Window, cx: &mut Context<Self>) {
2300        // Inside a table, a raw newline would split the row's `| … |` markup.
2301        // Enter instead moves to the cell directly below (next row, same column,
2302        // spreadsheet-style); from the last row it exits onto a fresh line below
2303        // the table.
2304        if self.caret_in_table() {
2305            if let Some(off) = self.table_move_vertical(1)
2306                && self
2307                    .table_rows
2308                    .get(self.row_col(off).0)
2309                    .and_then(Option::as_ref)
2310                    .is_some_and(|t| !t.is_separator)
2311            {
2312                self.move_to(off, cx);
2313                return;
2314            }
2315            let (row, _) = self.row_col(self.cursor_offset());
2316            let mut last = row;
2317            while self
2318                .table_rows
2319                .get(last + 1)
2320                .and_then(Option::as_ref)
2321                .is_some()
2322            {
2323                last += 1;
2324            }
2325            let starts = self.line_starts();
2326            let end = starts.get(last + 1).map_or(self.content.len(), |&s| s - 1);
2327            self.selected_range = end..end;
2328            self.replace_text_in_range(None, "\n", window, cx);
2329            return;
2330        }
2331        // Inside a property panel a raw newline would split a `key:: value`
2332        // line. Enter opens the panel's editor instead — the same route as a
2333        // click or arrow-in (the form's own Enter then commits).
2334        if self.selected_range.is_empty() {
2335            let (row, _) = self.row_col(self.cursor_offset());
2336            if self.property_block_at(row).is_some() {
2337                self.edit_properties_at_caret(false, cx);
2338                return;
2339            }
2340        }
2341        // List auto-continuation: Enter on a list/task item opens the next item
2342        // (same marker + indent; ordered numbers increment); Enter on an *empty*
2343        // item removes the marker, exiting the list. Only with a collapsed
2344        // selection — a selection is just replaced by the newline.
2345        if self.selected_range.is_empty() {
2346            let cursor = self.cursor_offset();
2347            let line_start = self.content[..cursor].rfind('\n').map_or(0, |i| i + 1);
2348            let line_end = self.content[line_start..]
2349                .find('\n')
2350                .map_or(self.content.len(), |i| line_start + i);
2351            let line = &self.content[line_start..line_end];
2352            if let Some((prefix_len, indent, ordered, num)) = markdown_syntax::list_prefix(line) {
2353                let task = markdown_syntax::task_prefix(line);
2354                let content_start = task.map_or(prefix_len, |(l, ..)| l);
2355                let empty = line.get(content_start..).unwrap_or("").trim().is_empty();
2356                let cont = if empty {
2357                    None
2358                } else {
2359                    let ws = &line[..indent];
2360                    let bullet = line.as_bytes()[indent] as char;
2361                    Some(if task.is_some() {
2362                        format!("\n{ws}{bullet} [ ] ")
2363                    } else if ordered {
2364                        format!("\n{ws}{}. ", num + 1)
2365                    } else {
2366                        format!("\n{ws}{bullet} ")
2367                    })
2368                };
2369                match cont {
2370                    // Empty item: clear the marker, leaving an empty line.
2371                    None => {
2372                        self.selected_range = line_start..line_end;
2373                        self.replace_text_in_range(None, "", window, cx);
2374                    }
2375                    Some(text) => self.replace_text_in_range(None, &text, window, cx),
2376                }
2377                return;
2378            }
2379        }
2380        self.replace_text_in_range(None, "\n", window, cx);
2381    }
2382
2383    /// Toggle an inline wrapping marker (`**` bold, `*` italic, `` ` `` code)
2384    /// around the selection — the symmetric case of [`Self::toggle_wrap_pair`].
2385    fn toggle_wrap(&mut self, marker: &str, cx: &mut Context<Self>) {
2386        self.toggle_wrap_pair(marker, marker, cx);
2387    }
2388
2389    /// Toggle an open/close marker pair (`<u>`/`</u>`, or a symmetric `**`)
2390    /// around the selection. No-op on an empty selection. Unwraps when the
2391    /// selection is already wrapped (markers just inside or just outside it),
2392    /// otherwise wraps — keeping the same text selected so presses toggle.
2393    fn toggle_wrap_pair(&mut self, open: &str, close: &str, cx: &mut Context<Self>) {
2394        let sel = self.selected_range.clone();
2395        if sel.start >= sel.end {
2396            return;
2397        }
2398        let (ol, cl) = (open.len(), close.len());
2399        let sel_text = &self.content[sel.clone()];
2400        let (range, new, new_sel) =
2401            if sel_text.len() >= ol + cl && sel_text.starts_with(open) && sel_text.ends_with(close)
2402            {
2403                // `**foo**` selected → strip the markers inside the selection.
2404                let inner = self.content[sel.start + ol..sel.end - cl].to_string();
2405                (sel.clone(), inner, sel.start..sel.end - ol - cl)
2406            } else if self.content[..sel.start].ends_with(open)
2407                && self.content[sel.end..].starts_with(close)
2408            {
2409                // `foo` selected with the markers just outside → strip them.
2410                (
2411                    sel.start - ol..sel.end + cl,
2412                    sel_text.to_string(),
2413                    sel.start - ol..sel.end - ol,
2414                )
2415            } else {
2416                // Plain → wrap.
2417                (
2418                    sel.clone(),
2419                    format!("{open}{sel_text}{close}"),
2420                    sel.start + ol..sel.end + ol,
2421                )
2422            };
2423        self.record_edit(&range, &new);
2424        self.content.replace_range(range.clone(), &new);
2425        self.selected_range = new_sel;
2426        self.selection_reversed = false;
2427        self.goal_x = None;
2428        self.remap_diagnostics(&range, new.len());
2429        cx.emit(EditorEvent::Changed);
2430        cx.notify();
2431    }
2432
2433    fn bold(&mut self, _: &Bold, _: &mut Window, cx: &mut Context<Self>) {
2434        self.toggle_wrap("**", cx);
2435    }
2436
2437    fn italic(&mut self, _: &Italic, _: &mut Window, cx: &mut Context<Self>) {
2438        self.toggle_wrap("*", cx);
2439    }
2440
2441    fn code(&mut self, _: &Code, _: &mut Window, cx: &mut Context<Self>) {
2442        self.toggle_wrap("`", cx);
2443    }
2444
2445    fn strike(&mut self, _: &Strike, _: &mut Window, cx: &mut Context<Self>) {
2446        self.toggle_wrap("~~", cx);
2447    }
2448
2449    fn underline(&mut self, _: &Underline, _: &mut Window, cx: &mut Context<Self>) {
2450        // Markdown has no underline — the `<u>` tag, which both views honor.
2451        self.toggle_wrap_pair("<u>", "</u>", cx);
2452    }
2453
2454    /// Tab: on a list/quote item, indent the whole item one level (`tab_indent`
2455    /// spaces at the line start, caret shifts with it); elsewhere insert that many
2456    /// spaces at the caret (replacing any selection).
2457    fn indent(&mut self, _: &Indent, window: &mut Window, cx: &mut Context<Self>) {
2458        // In a table, Tab moves to the next cell rather than indenting.
2459        if self.caret_in_table() {
2460            if let Some(offset) = self.table_cell_nav(true) {
2461                self.move_to(offset, cx);
2462            }
2463            return;
2464        }
2465        let cursor = self.cursor_offset();
2466        let line_start = self.content[..cursor].rfind('\n').map_or(0, |i| i + 1);
2467        let line_end = self.content[line_start..]
2468            .find('\n')
2469            .map_or(self.content.len(), |i| line_start + i);
2470        let line = &self.content[line_start..line_end];
2471        let item = markdown_syntax::list_prefix(line);
2472        let is_item = item.is_some() || markdown_syntax::blockquote_prefix(line).is_some();
2473        let indent = " ".repeat(self.tab_indent);
2474        if !is_item {
2475            self.replace_text_in_range(None, &indent, window, cx);
2476            return;
2477        }
2478        // Indenting an ordered item starts a NESTED list, so its number
2479        // becomes the new list's start: rewrite it to 1. (Both views
2480        // renumber the items after it, so only the start digit matters.)
2481        let (range, new_text) = match item {
2482            Some((_, ws, true, _)) => {
2483                let de = ws + line[ws..].bytes().take_while(u8::is_ascii_digit).count();
2484                (
2485                    line_start..line_start + de,
2486                    format!("{indent}{}1", &line[..ws]),
2487                )
2488            }
2489            _ => (line_start..line_start, indent),
2490        };
2491        self.record_edit(&range, &new_text);
2492        let delta = new_text.len() as isize - (range.end - range.start) as isize;
2493        self.content.replace_range(range.clone(), &new_text);
2494        let caret = (cursor as isize + delta).max(line_start as isize) as usize;
2495        self.selected_range = caret..caret;
2496        self.selection_reversed = false;
2497        self.goal_x = None;
2498        self.remap_diagnostics(&range, new_text.len());
2499        cx.emit(EditorEvent::Changed);
2500        cx.notify();
2501    }
2502
2503    /// Shift+Tab: outdent the caret's line — remove up to `tab_indent` leading
2504    /// spaces (or one leading tab) from the line start. No-op if there's none.
2505    fn outdent(&mut self, _: &Outdent, _: &mut Window, cx: &mut Context<Self>) {
2506        // In a table, Shift+Tab moves to the previous cell rather than outdenting.
2507        if self.caret_in_table() {
2508            if let Some(offset) = self.table_cell_nav(false) {
2509                self.move_to(offset, cx);
2510            }
2511            return;
2512        }
2513        let cursor = self.cursor_offset();
2514        let line_start = self.content[..cursor].rfind('\n').map_or(0, |i| i + 1);
2515        let line = &self.content[line_start..];
2516        let removed = if line.starts_with('\t') {
2517            1
2518        } else {
2519            line.bytes()
2520                .take(self.tab_indent)
2521                .take_while(|b| *b == b' ')
2522                .count()
2523        };
2524        if removed == 0 {
2525            return;
2526        }
2527        let range = line_start..line_start + removed;
2528        self.record_edit(&range, "");
2529        self.content.replace_range(range.clone(), "");
2530        let caret = cursor.saturating_sub(removed).max(line_start);
2531        self.selected_range = caret..caret;
2532        self.selection_reversed = false;
2533        self.goal_x = None;
2534        self.remap_diagnostics(&range, 0);
2535        cx.emit(EditorEvent::Changed);
2536        cx.notify();
2537    }
2538
2539    fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
2540        let item = cx.read_from_clipboard();
2541        // A copied FILE also carries its path as text; inserting that string
2542        // is never what a paste meant. Treat file and image clipboards as
2543        // not-ours: fall through so a host binding on the same keys
2544        // (zorite's image/file paste) can run.
2545        let has_files = item.as_ref().is_some_and(|i| {
2546            i.entries()
2547                .iter()
2548                .any(|e| matches!(e, gpui::ClipboardEntry::ExternalPaths(_)))
2549        });
2550        match item.and_then(|i| i.text()).filter(|_| !has_files) {
2551            Some(text) => {
2552                // Normalize foreign line endings — a Windows/browser copy
2553                // carries \r\n (or bare \r), and a literal \r in the buffer
2554                // garbles rendering + desyncs the \n-based column math.
2555                let mut text = if text.contains('\r') {
2556                    text.replace("\r\n", "\n").replace('\r', "\n")
2557                } else {
2558                    text
2559                };
2560                // Inside a table cell, a raw paste of newlines/pipes would
2561                // break the `| … |` row markup (Enter is guarded the same
2562                // way): flatten newlines and escape pipes so the paste stays
2563                // one cell's content.
2564                if self.caret_in_table() && text.contains(['\n', '|']) {
2565                    // Unescape-then-escape so text already carrying `\|`
2566                    // doesn't double up into `\\|` (an escaped backslash
2567                    // followed by a live separator).
2568                    text = text
2569                        .trim_end_matches('\n')
2570                        .replace('\n', " ")
2571                        .replace("\\|", "|")
2572                        .replace('|', "\\|");
2573                } else if self.markdown_style.is_some() && text.contains("$$") {
2574                    // Words-attached `$$` fences / words-mixed pairs in pasted
2575                    // text split onto their own lines (issue #54) — same
2576                    // normalization typing gets.
2577                    if let std::borrow::Cow::Owned(n) =
2578                        zorite_markdown::syntax::normalize_math_fences(&text)
2579                    {
2580                        text = n;
2581                    }
2582                }
2583                self.replace_text_in_range(None, &text, window, cx);
2584            }
2585            None => cx.propagate(),
2586        }
2587    }
2588
2589    fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
2590        if !self.selected_range.is_empty() {
2591            let range = self.copy_range();
2592            // Ordered markers copy at their DISPLAYED positions (still digit
2593            // markdown), so a paste counts the way the screen did.
2594            let text = if self.markdown_style.is_some() {
2595                markdown_syntax::renumber_copy(&self.content, range)
2596            } else {
2597                self.content[range].to_string()
2598            };
2599            self.write_clipboard(text, cx);
2600        }
2601    }
2602
2603    /// Copy the selection as the raw markdown ONLY — no host clipboard
2604    /// flavors — for pasting literal source into rich surfaces (the context
2605    /// menu's "Copy as Markdown"). Same selection/renumber rules as `copy`.
2606    pub fn copy_plain(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
2607        if !self.selected_range.is_empty() {
2608            let range = self.copy_range();
2609            let text = if self.markdown_style.is_some() {
2610                markdown_syntax::renumber_copy(&self.content, range)
2611            } else {
2612                self.content[range].to_string()
2613            };
2614            cx.write_to_clipboard(ClipboardItem::new_string(text));
2615        }
2616    }
2617
2618    /// Route a Copy/Cut payload through the host's clipboard writer when one
2619    /// is set (see [`Self::set_clipboard_writer`]), else gpui's plain copy.
2620    fn write_clipboard(&self, text: String, cx: &mut Context<Self>) {
2621        match &self.clipboard_writer {
2622            Some(writer) => writer(&text, cx),
2623            None => cx.write_to_clipboard(ClipboardItem::new_string(text)),
2624        }
2625    }
2626
2627    /// What a copy takes: the selection — extended back over the first
2628    /// line's hidden list/task/quote prefix when the selection is multi-line
2629    /// and starts exactly at that line's body start. With markers painted
2630    /// (not text), "select the whole list" visually anchors AFTER the first
2631    /// `1. `, so a verbatim copy dropped the first marker while every other
2632    /// line kept its own. Raw mode (no markdown style) copies verbatim.
2633    fn copy_range(&self) -> std::ops::Range<usize> {
2634        let (start, end) = (
2635            self.selected_range.start.min(self.selected_range.end),
2636            self.selected_range.start.max(self.selected_range.end),
2637        );
2638        if self.markdown_style.is_none() || !self.content[start..end].contains('\n') {
2639            return start..end;
2640        }
2641        let line_start = self.content[..start].rfind('\n').map_or(0, |i| i + 1);
2642        let line_end = self.content[line_start..]
2643            .find('\n')
2644            .map_or(self.content.len(), |i| line_start + i);
2645        let line = &self.content[line_start..line_end];
2646        let prefix_len = markdown_syntax::task_prefix(line)
2647            .map(|(l, ..)| l)
2648            .or_else(|| markdown_syntax::list_prefix(line).map(|(l, ..)| l))
2649            .or_else(|| markdown_syntax::blockquote_prefix(line));
2650        match prefix_len {
2651            Some(plen) if start == line_start + plen => line_start..end,
2652            _ => start..end,
2653        }
2654    }
2655
2656    fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
2657        if !self.selected_range.is_empty() {
2658            self.write_clipboard(self.content[self.selected_range.clone()].to_string(), cx);
2659            self.replace_text_in_range(None, "", window, cx);
2660        }
2661    }
2662
2663    fn show_character_palette(
2664        &mut self,
2665        _: &ShowCharacterPalette,
2666        window: &mut Window,
2667        _: &mut Context<Self>,
2668    ) {
2669        window.show_character_palette();
2670    }
2671
2672    // --- Undo / redo ---------------------------------------------------------
2673
2674    fn undo(&mut self, _: &Undo, _: &mut Window, cx: &mut Context<Self>) {
2675        if let Some(prev) = self.undo_stack.pop() {
2676            self.redo_stack.push(self.snapshot());
2677            self.restore(prev);
2678            self.last_edit = EditKind::Other;
2679            cx.notify();
2680        }
2681    }
2682
2683    fn redo(&mut self, _: &Redo, _: &mut Window, cx: &mut Context<Self>) {
2684        if let Some(next) = self.redo_stack.pop() {
2685            self.undo_stack.push(self.snapshot());
2686            self.restore(next);
2687            self.last_edit = EditKind::Other;
2688            cx.notify();
2689        }
2690    }
2691
2692    fn snapshot(&self) -> Snapshot {
2693        Snapshot {
2694            content: self.content.clone(),
2695            // The forward caret (selection end), so undoing a backspace lands the
2696            // caret after the restored text rather than inside it.
2697            caret: self.selected_range.end,
2698        }
2699    }
2700
2701    fn restore(&mut self, s: Snapshot) {
2702        self.content_gen += 1;
2703        self.content = s.content;
2704        let caret = s.caret.min(self.content.len());
2705        self.selected_range = caret..caret;
2706        self.selection_reversed = false;
2707        self.marked_range = None;
2708    }
2709
2710    /// Snapshot the pre-edit state for undo, coalescing a run of single-grapheme
2711    /// inserts (or a run of deletes) into one undo step so typing isn't undone
2712    /// one character at a time.
2713    fn record_edit(&mut self, range: &Range<usize>, new_text: &str) {
2714        self.content_gen += 1;
2715        let kind = if new_text.is_empty() {
2716            EditKind::Delete
2717        } else if range.start == range.end
2718            && new_text != "\n"
2719            && new_text.graphemes(true).count() == 1
2720        {
2721            EditKind::Insert(range.start + new_text.len())
2722        } else {
2723            EditKind::Other
2724        };
2725        let coalesce = match (self.last_edit, kind) {
2726            (EditKind::Insert(end), EditKind::Insert(_)) => end == range.start,
2727            (EditKind::Delete, EditKind::Delete) => true,
2728            _ => false,
2729        };
2730        if !coalesce {
2731            self.undo_stack.push(self.snapshot());
2732            if self.undo_stack.len() > UNDO_LIMIT {
2733                self.undo_stack.remove(0);
2734            }
2735            self.redo_stack.clear();
2736        }
2737        self.last_edit = kind;
2738        // A keystroke is one typed grapheme (incl. typed over a selection — that's
2739        // an auto-pair "wrap") or a single-char backspace. Multi-char edits (paste,
2740        // table ops, …) are not, so auto-pairing skips them.
2741        self.last_edit_keystroke = (new_text != "\n" && new_text.graphemes(true).count() == 1)
2742            || (new_text.is_empty() && self.content[range.clone()].graphemes(true).count() == 1);
2743    }
2744
2745    // --- Mouse ---------------------------------------------------------------
2746
2747    /// If logical `row` is inside a `$$…$$` block, the block's byte range in the document
2748    /// (both fences) and the LaTeX between them — so a double-click can hand it to the host's
2749    /// structural editor.
2750    fn math_block_at(&self, row: usize) -> Option<(Range<usize>, SharedString)> {
2751        // The structural LaTeX editor is a WYSIWYG affordance (markdown_style is set only in
2752        // live-preview mode). In raw-markdown mode the user edits `$$…$$` as plain text, so
2753        // report no math block here — clicks / arrows / `/math` stay in the text editor.
2754        self.markdown_style.as_ref()?;
2755        let starts = self.line_starts();
2756        let blocks = markdown_syntax::math_blocks(&self.content);
2757        blocks
2758            .iter()
2759            .find(|(r, _)| r.contains(&row))
2760            .or_else(|| {
2761                // A `<!-- math:ALIGN -->` marker row belongs to the block directly
2762                // below it: it's invisible in WYSIWYG, so a caret seated there —
2763                // e.g. arrow-up returns offset 0 when the block opens the document
2764                // (#77) — would reveal the raw rows instead of opening the editor.
2765                markdown_syntax::math_align_marker(self.line_str(row))?;
2766                blocks.iter().find(|(r, _)| r.start == row + 1)
2767            })
2768            .map(|(r, source)| {
2769                (
2770                    starts[r.start]..self.line_end(r.end - 1),
2771                    source.clone().into(),
2772                )
2773            })
2774    }
2775
2776    /// A `$$` block's byte range grown for deletion: takes in the
2777    /// `<!-- math:ALIGN -->` marker line directly above (removing the block
2778    /// alone would orphan it) and the trailing newline.
2779    fn math_delete_range(&self, range: Range<usize>) -> Range<usize> {
2780        let mut start = range.start;
2781        let (row, _) = self.row_col(range.start);
2782        if row > 0 {
2783            let prev_start = self.line_starts()[row - 1];
2784            let prev = &self.content[prev_start..self.line_end(row - 1)];
2785            if markdown_syntax::math_align_marker(prev).is_some() {
2786                start = prev_start;
2787            }
2788        }
2789        start..(range.end + 1).min(self.content.len())
2790    }
2791
2792    /// Route a landing offset into an atomic construct the way the plain
2793    /// arrows do: an inline `$…$` span strictly containing it, or a `$$`
2794    /// block / property-panel row, opens its in-place editor instead of
2795    /// seating a raw caret (which would reveal hidden source). Returns true
2796    /// when handled — word-jumps (⌥←/→) stop there.
2797    fn enter_construct_at(&mut self, off: usize, at_end: bool, cx: &mut Context<Self>) -> bool {
2798        if let Some((range, source)) = self.inline_math_span_at(off) {
2799            cx.emit(EditorEvent::EditMath {
2800                range,
2801                source,
2802                at_end,
2803                inline: true,
2804            });
2805            return true;
2806        }
2807        let (row, _) = self.row_col(off);
2808        if let Some((range, source)) = self.math_block_at(row) {
2809            cx.emit(EditorEvent::EditMath {
2810                range,
2811                source,
2812                at_end,
2813                inline: false,
2814            });
2815            return true;
2816        }
2817        if let Some((range, source)) = self.property_block_at(row) {
2818            let block_row = row - self.row_col(range.start).0;
2819            cx.emit(EditorEvent::EditProperties {
2820                range,
2821                source,
2822                at_end,
2823                row: Some(block_row),
2824            });
2825            return true;
2826        }
2827        false
2828    }
2829
2830    /// If the caret sits inside a property block, ask the host to seat the
2831    /// in-place form there (focused on the caret's row; `at_end` = caret at
2832    /// the value's end) — the recovery for any edit that lands a raw caret in
2833    /// the panel, mirroring what arrows and clicks do on entry.
2834    fn edit_properties_at_caret(&mut self, at_end: bool, cx: &mut Context<Self>) {
2835        let (row, _) = self.row_col(self.cursor_offset());
2836        if let Some((range, source)) = self.property_block_at(row) {
2837            let block_row = row - self.row_col(range.start).0;
2838            cx.emit(EditorEvent::EditProperties {
2839                range,
2840                source,
2841                at_end,
2842                row: Some(block_row),
2843            });
2844        }
2845    }
2846
2847    /// The property block whose lines cover `row`, as an absolute byte range +
2848    /// source — so a click or an arrow into the panel opens the property editor
2849    /// instead of landing in (and revealing) the raw `key:: value` lines.
2850    /// WYSIWYG-only, like [`Self::math_block_at`].
2851    fn property_block_at(&self, row: usize) -> Option<(Range<usize>, SharedString)> {
2852        self.markdown_style.as_ref()?;
2853        let region = markdown_syntax::property_regions(&self.content)
2854            .into_iter()
2855            .find(|r| r.contains(&row))?;
2856        let start = *self.line_starts().get(region.start)?;
2857        let end = self.line_end(region.end - 1);
2858        Some((start..end, self.content[start..end].to_string().into()))
2859    }
2860
2861    /// The inline `$…$` span strictly containing source byte `off` (between the `$` delimiters),
2862    /// as an absolute byte range + inner LaTeX — so arrowing the caret into a formula opens its
2863    /// structural editor instead of landing in (and revealing) the raw source. WYSIWYG-only.
2864    fn inline_math_span_at(&self, off: usize) -> Option<(Range<usize>, SharedString)> {
2865        self.markdown_style.as_ref()?;
2866        let (row, _) = self.row_col(off);
2867        let line_start = *self.line_starts().get(row)?;
2868        let line = self.line_str(row);
2869        let col = off.saturating_sub(line_start);
2870        markdown_syntax::inline_math_spans(line)
2871            .into_iter()
2872            .find(|s| s.start < col && col < s.end)
2873            .map(|s| {
2874                (
2875                    line_start + s.start..line_start + s.end,
2876                    SharedString::from(markdown_syntax::inline_math_latex(line, &s).to_string()),
2877                )
2878            })
2879    }
2880
2881    /// If the caret sits inside a `$$…$$` block, ask the host to open the structural editor
2882    /// for it (caret at the formula's start). Lets the host turn a freshly-inserted, empty
2883    /// math block (the `/math` snippet) straight into a live editor instead of raw source.
2884    pub fn edit_math_at_caret(&mut self, cx: &mut Context<Self>) {
2885        let (row, _) = self.row_col(self.cursor_offset());
2886        if let Some((range, source)) = self.math_block_at(row) {
2887            cx.emit(EditorEvent::EditMath {
2888                range,
2889                source,
2890                at_end: false,
2891                inline: false,
2892            });
2893        }
2894    }
2895
2896    /// The property block covering the caret's line, if any (WYSIWYG-only, like
2897    /// [`Self::edit_math_at_caret`]) — so the host can open the property editor
2898    /// on a freshly-inserted `/property` line instead of leaving raw source.
2899    pub fn property_block_at_caret(&self) -> Option<(Range<usize>, SharedString)> {
2900        let (row, _) = self.row_col(self.cursor_offset());
2901        self.property_block_at(row)
2902    }
2903
2904    fn on_mouse_down(
2905        &mut self,
2906        event: &MouseDownEvent,
2907        window: &mut Window,
2908        cx: &mut Context<Self>,
2909    ) {
2910        // A press on an image's corner grip starts a resize drag — this takes
2911        // precedence over placing the caret on the image row (which the press
2912        // would otherwise do). The image keeps its bounds; the drag previews a new
2913        // width and release writes `{width=N}` (see on_mouse_move / on_mouse_up).
2914        if let Some((line, width)) = self.grip_at(event.position) {
2915            self.image_resize = Some(ImageResize {
2916                line,
2917                start_width: width,
2918                start_x: event.position.x,
2919                width,
2920            });
2921            self.is_selecting = false;
2922            self.menu = None;
2923            self.table_menu = None;
2924            self.image_menu = None;
2925            self.prop_menu = None;
2926            cx.notify();
2927            return;
2928        }
2929        // A press on a task checkbox toggles it (☐↔☑) instead of placing the
2930        // caret — the box sits in the gutter, so this never competes with editing
2931        // the body text. Same length swap, so the caret/selection stay valid.
2932        if let Some(row) = self.checkbox_at(event.position) {
2933            let range = self.line_starts()[row]..self.line_end(row);
2934            if let Some(new_line) =
2935                markdown_syntax::toggle_task_checkbox(&self.content[range.clone()])
2936            {
2937                self.record_edit(&range, &new_line);
2938                self.content =
2939                    self.content[..range.start].to_owned() + &new_line + &self.content[range.end..];
2940                self.remap_diagnostics(&range, new_line.len());
2941                cx.emit(EditorEvent::Changed);
2942                cx.notify();
2943            }
2944            return;
2945        }
2946        // A press on a code card's chrome: Copy writes the block's body to the
2947        // clipboard; the language tag opens the picker — neither places the caret.
2948        if let Some((on_copy, fence_row)) = self.code_chip_at(event.position) {
2949            if on_copy {
2950                if let Some((_, body)) = self.code_block_at(fence_row) {
2951                    let text = self.content[body].to_string();
2952                    self.write_clipboard(text, cx);
2953                }
2954            } else if !self.code_langs.is_empty() {
2955                self.code_lang_menu = Some((fence_row, event.position));
2956                cx.notify();
2957            }
2958            return;
2959        }
2960        // A press on a foldable callout's chevron flips its `-`/`+` fold char
2961        // (folding/unfolding the body) instead of placing the caret — the same
2962        // toggle-in-source model as the task checkbox.
2963        if let Some(row) = self.alert_fold_at(event.position) {
2964            let start = self.line_starts()[row];
2965            let line = &self.content[start..self.line_end(row)];
2966            if let Some((at, folded)) = zorite_markdown::syntax::alert_fold_char(line) {
2967                let range = start + at..start + at + 1;
2968                let repl = if folded { "+" } else { "-" };
2969                self.record_edit(&range, repl);
2970                self.content.replace_range(range.clone(), repl);
2971                self.remap_diagnostics(&range, 1);
2972                cx.emit(EditorEvent::Changed);
2973                cx.notify();
2974            }
2975            return;
2976        }
2977        // A press on a heading's fold chevron toggles its section collapsed —
2978        // view-local state, not an edit (markdown has no heading-fold syntax).
2979        if let Some(row) = self.heading_fold_at(event.position) {
2980            let start = self.line_starts()[row];
2981            let end = self.line_end(row);
2982            let key = self.content[start..end].trim().to_string();
2983            if !self.folded_headings.remove(&key) {
2984                // Folding with the caret inside the section would no-op
2985                // (reveal-on-caret keeps it open) — seat the caret at the
2986                // heading's end first.
2987                let single = std::collections::HashSet::from([key.clone()]);
2988                let crow = self.row_col(self.cursor_offset()).0;
2989                if markdown_syntax::heading_fold_regions(&self.content, &single)
2990                    .iter()
2991                    .any(|r| crow > r.start && crow < r.end)
2992                {
2993                    self.move_to(end, cx);
2994                }
2995                self.folded_headings.insert(key);
2996            }
2997            cx.notify();
2998            return;
2999        }
3000        // Left-click a file chip (e.g. a PDF embed) opens it rather than editing —
3001        // the host handles the link. Right-click edits (see on_right_mouse_down).
3002        if let Some((src, wiki)) = self.chip_at(event.position) {
3003            cx.emit(if wiki {
3004                EditorEvent::OpenWikiLink(src)
3005            } else {
3006                EditorEvent::OpenLink(src)
3007            });
3008            return;
3009        }
3010        // Left-click an inline `$…$` formula opens its structural editor at the formula's spot
3011        // (the host seats it). Shift extends a selection; Control-click is the secondary button.
3012        if !event.modifiers.shift
3013            && !event.modifiers.control
3014            && let Some((range, source)) = self.inline_math_at(event.position)
3015        {
3016            cx.emit(EditorEvent::EditMath {
3017                range,
3018                source,
3019                at_end: true,
3020                inline: true,
3021            });
3022            return;
3023        }
3024        // Left-click a property-panel pill opens its target — the pill is painted
3025        // over a collapsed source line, so hit-test the painted bounds directly
3026        // (not the raw text like `link_at` below).
3027        if event.click_count == 1
3028            && !event.modifiers.shift
3029            && !event.modifiers.control
3030            && let Some((_, hit)) = self
3031                .prop_pill_rects
3032                .iter()
3033                .find(|(b, _)| b.contains(&event.position))
3034        {
3035            match hit {
3036                zorite_markdown::syntax::LinkHit::Page(t) => {
3037                    cx.emit(EditorEvent::OpenWikiLink(t.clone().into()))
3038                }
3039                zorite_markdown::syntax::LinkHit::BlockRef(id) => {
3040                    cx.emit(EditorEvent::OpenWikiLink(format!("#^{id}").into()))
3041                }
3042                zorite_markdown::syntax::LinkHit::Url(u) => {
3043                    cx.emit(EditorEvent::OpenLink(u.clone().into()))
3044                }
3045            }
3046            return;
3047        }
3048        // Left-click on (or beside) a property panel opens the in-place editor
3049        // for its whole block — the panel edits its properties, not the raw
3050        // markdown. Keyed off the ROW the click maps to, not the painted panel
3051        // rects, so a click in the empty space right of the panel opens the
3052        // editor too instead of seating the caret in (and revealing) the source.
3053        if event.click_count == 1 && !event.modifiers.shift && !event.modifiers.control {
3054            let offset = self.index_for_mouse_position(event.position);
3055            let row = self.row_col(offset).0;
3056            if let Some((range, source)) = self.property_block_at(row) {
3057                // Which property line within the block was clicked — the host
3058                // focuses that row's field instead of always the first.
3059                let block_row = row - self.row_col(range.start).0;
3060                cx.emit(EditorEvent::EditProperties {
3061                    range,
3062                    source,
3063                    at_end: false,
3064                    row: Some(block_row),
3065                });
3066                return;
3067            }
3068        }
3069        // Left-click an inline image opens a full-size preview (host-shown).
3070        if !event.modifiers.shift
3071            && !event.modifiers.control
3072            && let Some(src) = self.inline_image_at(event.position)
3073        {
3074            cx.emit(EditorEvent::PreviewImage(src));
3075            return;
3076        }
3077        // Left-click a link navigates, like the reading view: a `[[wiki]]` /
3078        // `#tag` opens that page, a `[text](url)` opens the url — consistent
3079        // with chips and inline math above. Only a plain single click: a
3080        // double-click still selects the word, shift still extends the
3081        // selection, and the caret goes anywhere else as usual (to edit a
3082        // link's own text, click beside it and arrow in — reveal-on-caret).
3083        if event.click_count == 1
3084            && !event.modifiers.shift
3085            && !event.modifiers.control
3086            && self.markdown_style.is_some()
3087        {
3088            let offset = self.index_for_mouse_position(event.position);
3089            let (row, _) = self.row_col(offset);
3090            let start = self.line_starts()[row];
3091            let line = &self.content[start..self.line_end(row)];
3092            match markdown_syntax::link_at(line, offset - start) {
3093                Some(markdown_syntax::LinkHit::Page(title)) => {
3094                    cx.emit(EditorEvent::OpenWikiLink(title.into()));
3095                    return;
3096                }
3097                Some(markdown_syntax::LinkHit::BlockRef(id)) => {
3098                    cx.emit(EditorEvent::OpenWikiLink(format!("#^{id}").into()));
3099                    return;
3100                }
3101                Some(markdown_syntax::LinkHit::Url(url)) => {
3102                    cx.emit(EditorEvent::OpenLink(url.into()));
3103                    return;
3104                }
3105                None => {}
3106            }
3107            // The reference-count badge painted over a hidden ` ^id` anchor:
3108            // a click on its (replaced) range lists the referencers. Only when
3109            // the anchor is hidden — with the caret on the line the raw text
3110            // is revealed for editing and clicks place the caret as usual.
3111            if self.row_col(self.selected_range.start).0 != row
3112                && let Some((at, id)) = zorite_markdown::syntax::block_id(line)
3113                && offset - start >= at
3114                && self
3115                    .markdown_style
3116                    .as_ref()
3117                    .and_then(|st| st.block_ref_count.as_ref().map(|f| f(id)))
3118                    .unwrap_or(0)
3119                    > 0
3120            {
3121                cx.emit(EditorEvent::OpenWikiLink(format!("refs:^{id}").into()));
3122                return;
3123            }
3124        }
3125        // A press on a table's hover "+" strip adds a row (below) or column (right).
3126        // The insert APIs are caret-driven, so seat the caret in the table to target
3127        // them — but capture the user's cell first and restore it after, so the
3128        // caret stays put instead of following the new row/column.
3129        if let Some(row) = self.table_add_row_at(event.position) {
3130            let keep = self.caret_table_cell_pos();
3131            if let Some(off) = self.cell_start_offset(row, 0) {
3132                self.selected_range = off..off;
3133                self.insert_table_row(true, cx);
3134            }
3135            if let Some((r, c, ic)) = keep {
3136                let caret = self.caret_pos_for_cell(r, c, ic);
3137                self.selected_range = caret..caret;
3138                cx.notify();
3139            }
3140            return;
3141        }
3142        if let Some((row, col)) = self.table_add_col_at(event.position) {
3143            let keep = self.caret_table_cell_pos();
3144            if let Some(off) = self.cell_start_offset(row, col) {
3145                self.selected_range = off..off;
3146                self.insert_table_column(true, cx);
3147            }
3148            if let Some((r, c, ic)) = keep {
3149                let caret = self.caret_pos_for_cell(r, c, ic);
3150                self.selected_range = caret..caret;
3151                cx.notify();
3152            }
3153            return;
3154        }
3155        // A press on a row/column delete "−" handle removes that row/column (seat
3156        // the caret in it, then reuse the caret-driven delete APIs).
3157        if let Some((rect, row)) = self.table_row_del
3158            && rect.contains(&event.position)
3159        {
3160            if let Some(off) = self.cell_start_offset(row, 0) {
3161                self.selected_range = off..off;
3162                self.delete_table_row(cx);
3163            }
3164            return;
3165        }
3166        if let Some((rect, row, col)) = self.table_col_del
3167            && rect.contains(&event.position)
3168        {
3169            if let Some(off) = self.cell_start_offset(row, col) {
3170                self.selected_range = off..off;
3171                self.delete_table_column(cx);
3172            }
3173            return;
3174        }
3175        // A press on a wide table's scroll thumb starts a thumb drag — the
3176        // table scrolls with the pointer (see `on_mouse_move`).
3177        if let Some(&TableThumb { header, .. }) = self
3178            .table_thumbs
3179            .iter()
3180            .find(|t| t.grab.contains(&event.position))
3181        {
3182            let sx = self.table_scroll_x.get(&header).copied().unwrap_or(0.);
3183            self.table_thumb_drag = Some((header, event.position.x, sx));
3184            self.is_selecting = false;
3185            cx.notify();
3186            return;
3187        }
3188        // A press on a column border's resize band starts a drag — the column
3189        // resizes live; release persists the width (issue #16). A DOUBLE-click
3190        // auto-fits the column to its content instead (the Excel/Sheets
3191        // convention for a column border).
3192        if let Some(&(_, header_row, col, width)) = self
3193            .table_col_resize_rects
3194            .iter()
3195            .find(|(band, ..)| band.contains(&event.position))
3196        {
3197            if event.click_count == 2 {
3198                self.autofit_table_col(header_row, col, window, cx);
3199                return;
3200            }
3201            self.table_col_resize = Some(TableColResize {
3202                header_row,
3203                col,
3204                start_x: event.position.x,
3205                orig: width,
3206                width,
3207            });
3208            self.is_selecting = false;
3209            cx.notify();
3210            return;
3211        }
3212        // A click on a table cell drops the caret inside the cell, not in the raw
3213        // `| … |` source.
3214        let offset = self
3215            .table_offset_at(event.position, window)
3216            .unwrap_or_else(|| self.index_for_mouse_position(event.position));
3217        self.menu = None;
3218        self.table_menu = None;
3219        self.image_menu = None;
3220        self.prop_menu = None;
3221        self.code_lang_menu = None;
3222        self.goal_x = None;
3223        self.last_edit = EditKind::Other;
3224        match event.click_count {
3225            // Double-click selects the word under the cursor. On a $$…$$ block
3226            // or property panel the FIRST click of the pair already opened the
3227            // in-place editor — word-selecting the hidden source underneath
3228            // would fight the seated editor, so those clicks are swallowed.
3229            2 => {
3230                let (row, _) = self.row_col(offset);
3231                if self.math_block_at(row).is_some() || self.property_block_at(row).is_some() {
3232                    return;
3233                }
3234                self.is_selecting = false;
3235                self.selected_range = self.word_range_at(offset).unwrap_or(offset..offset);
3236                self.selection_reversed = false;
3237                cx.notify();
3238            }
3239            // Triple-click (or more): select the whole logical line — except on
3240            // a block construct, where it would select the raw hidden fences.
3241            n if n >= 3 => {
3242                let (row, _) = self.row_col(offset);
3243                if self.math_block_at(row).is_some() || self.property_block_at(row).is_some() {
3244                    return;
3245                }
3246                self.is_selecting = false;
3247                let start = self.line_starts()[row];
3248                self.selected_range = start..self.line_end(row);
3249                self.selection_reversed = false;
3250                cx.notify();
3251            }
3252            // Single click: place the caret, or extend the selection with Shift.
3253            _ => {
3254                // A single left-click on a $$…$$ block opens the structural editor in
3255                // place; a Control-click (macOS secondary click, which AppKit delivers as
3256                // a left button + control modifier, NOT a right button) shows the formula
3257                // context menu instead. Shift-click still extends the selection.
3258                if !event.modifiers.shift {
3259                    let (row, _) = self.row_col(offset);
3260                    if let Some((range, source)) = self.math_block_at(row) {
3261                        if event.modifiers.control {
3262                            self.focus(window, cx);
3263                            cx.emit(EditorEvent::MathMenu {
3264                                source,
3265                                position: event.position,
3266                            });
3267                        } else {
3268                            cx.emit(EditorEvent::EditMath {
3269                                range,
3270                                source,
3271                                at_end: true,
3272                                inline: false,
3273                            });
3274                        }
3275                        return;
3276                    }
3277                }
3278                self.is_selecting = true;
3279                if event.modifiers.shift {
3280                    self.select_to(offset, cx);
3281                } else {
3282                    self.move_to(offset, cx);
3283                }
3284            }
3285        }
3286    }
3287
3288    fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, cx: &mut Context<Self>) {
3289        // End a gutter block drag: splice the block at the drop boundary.
3290        if let Some((bs, be, t)) = self.line_drag.take() {
3291            self.apply_line_drag(bs, be, t, cx);
3292            cx.notify();
3293            return;
3294        }
3295        // End an image-resize drag by persisting the rounded width as `{width=N}`
3296        // in that image's source line (through the normal mutation path, so it
3297        // joins the undo history + emits Changed); the next paint shows the saved
3298        // size and the live override clears.
3299        if let Some(resize) = self.image_resize.take() {
3300            self.commit_image_resize(resize, cx);
3301            cx.notify();
3302            return;
3303        }
3304        // End a table scroll-thumb drag (the live offsets are already stored).
3305        if self.table_thumb_drag.take().is_some() {
3306            cx.notify();
3307            return;
3308        }
3309        // End a column-border drag by persisting every column's width into the
3310        // table marker's `cols=` list (one undo step, emits Changed).
3311        if let Some(resize) = self.table_col_resize.take() {
3312            self.commit_table_col_widths(resize, cx);
3313            cx.notify();
3314            return;
3315        }
3316        self.is_selecting = false;
3317    }
3318
3319    fn on_mouse_move(
3320        &mut self,
3321        event: &MouseMoveEvent,
3322        window: &mut Window,
3323        cx: &mut Context<Self>,
3324    ) {
3325        // While dragging an image's grip, track the pointer: the new width is the
3326        // grab width plus the horizontal travel, floored at `IMG_MIN_W` and capped
3327        // to the content width left of the image's inset (so a bulleted image's cap
3328        // matches `block_img`, no snap-back on release, and it can't run off the
3329        // page). The paint reads this live width for the dragged image (aspect
3330        // preserved).
3331        // While dragging a block by its gutter grip, track the drop boundary
3332        // (snapped out of rendered regions); paint draws the indicator there.
3333        if let Some((bs, be, t)) = self.line_drag {
3334            let b = self.snap_drop_boundary(self.drop_boundary_at(event.position));
3335            if b != t {
3336                self.line_drag = Some((bs, be, b));
3337                cx.notify();
3338            }
3339            return;
3340        }
3341        // While dragging a wide table's scroll thumb, track the pointer: thumb
3342        // travel maps to content scroll through the committed factor.
3343        if let Some((header, grab_x, start_sx)) = self.table_thumb_drag {
3344            let factor = self
3345                .table_thumbs
3346                .iter()
3347                .find(|t| t.header == header)
3348                .map_or(0., |t| t.factor);
3349            let sx = start_sx + f32::from(event.position.x - grab_x) * factor;
3350            // Clamp at use like the wheel path — the paint clamps too, but a
3351            // sane stored value keeps other consumers simple.
3352            let sx = sx.max(0.);
3353            if self.table_scroll_x.get(&header).copied().unwrap_or(0.) != sx {
3354                self.table_scroll_x.insert(header, sx);
3355                cx.notify();
3356            }
3357            return;
3358        }
3359        // While dragging a table column's border, track the pointer: the new
3360        // width is the grab width plus the travel, floored so the column can't
3361        // vanish. Shaping applies it live (see `table_column_widths`).
3362        if let Some(resize) = self.table_col_resize {
3363            let dx = f32::from(event.position.x - resize.start_x);
3364            let width = (resize.orig + dx).max(24.);
3365            if let Some(r) = self.table_col_resize.as_mut() {
3366                r.width = width;
3367            }
3368            cx.notify();
3369            return;
3370        }
3371        if let Some(resize) = self.image_resize {
3372            let avail = self
3373                .last_bounds
3374                .map_or(f32::MAX, |b| f32::from(b.size.width))
3375                - f32::from(self.line_inset(resize.line));
3376            let max_w = avail.max(IMG_MIN_W);
3377            let dx = f32::from(event.position.x - resize.start_x);
3378            let width = (resize.start_width + dx).clamp(IMG_MIN_W, max_w);
3379            if let Some(r) = self.image_resize.as_mut() {
3380                r.width = width;
3381            }
3382            cx.notify();
3383            return;
3384        }
3385        if self.is_selecting {
3386            let offset = self
3387                .table_offset_at(event.position, window)
3388                .unwrap_or_else(|| self.index_for_mouse_position(event.position));
3389            self.select_to(offset, cx);
3390            return;
3391        }
3392        // While the right-click menu is open it owns the pointer — don't let the
3393        // table hover (highlight/handles) track the mouse behind it.
3394        if self.table_menu.is_some() {
3395            return;
3396        }
3397        // Repaint table "+" affordances when the pointer's region changes, so the
3398        // hover fill + cursor track the mouse live (the editor otherwise only
3399        // repaints on the caret blink).
3400        let region = self.table_hover_region_at(event.position);
3401        let cell = self.hovered_table_cell(event.position);
3402        if region != self.table_hover_region || cell != self.table_hover_cell {
3403            self.table_hover_region = region;
3404            self.table_hover_cell = cell;
3405            cx.notify();
3406        }
3407        // Repaint the column-resize border accent as the pointer crosses a band
3408        // (the cursor comes from the hitbox; the painted line needs a frame).
3409        let on_band = self
3410            .table_col_resize_rects
3411            .iter()
3412            .position(|(b, ..)| b.contains(&event.position));
3413        if on_band != self.table_resize_hover {
3414            self.table_resize_hover = on_band;
3415            cx.notify();
3416        }
3417        // Repaint the property-panel hover border when the pointer moves between
3418        // rows (the border itself reads the live pointer during paint).
3419        let prow = self
3420            .prop_row_rects
3421            .iter()
3422            .position(|(b, _)| b.contains(&event.position));
3423        if prow != self.prop_hover_row {
3424            self.prop_hover_row = prow;
3425            cx.notify();
3426        }
3427        // Repaint the heading fold chevron when the pointer enters/leaves a
3428        // heading row (the chevron is hover-revealed).
3429        let hrow = self
3430            .heading_row_rects
3431            .iter()
3432            .find_map(|(row, b)| b.contains(&event.position).then_some(*row));
3433        if hrow != self.heading_hover_row {
3434            self.heading_hover_row = hrow;
3435            cx.notify();
3436        }
3437        // Repaint the code card's chrome (lang tag + Copy) when the pointer
3438        // enters/leaves a card — it's hover-revealed.
3439        let ccard = self
3440            .code_card_rects
3441            .iter()
3442            .find_map(|(row, b)| b.contains(&event.position).then_some(*row));
3443        if ccard != self.code_chip_hover {
3444            self.code_chip_hover = ccard;
3445            cx.notify();
3446        }
3447    }
3448
3449    /// Persist a finished grip drag: replace the resized image's source line with
3450    /// one carrying the rounded `{width=N}`, going through `record_edit` so it's
3451    /// one undoable edit and emits `Changed`. A no-op if the line vanished or
3452    /// isn't an image any more (it shaped to an image last paint, but guard
3453    /// anyway), or if the width didn't actually change.
3454    fn commit_image_resize(&mut self, resize: ImageResize, cx: &mut Context<Self>) {
3455        let starts = self.line_starts();
3456        let Some(&start) = starts.get(resize.line) else {
3457            return;
3458        };
3459        let end = self.line_end(resize.line);
3460        let line = &self.content[start..end];
3461        let new_line = set_image_width(line, resize.width.round().max(IMG_MIN_W) as u32);
3462        if new_line == line {
3463            return;
3464        }
3465        let range = start..end;
3466        let delta = new_line.len() as isize - (end - start) as isize;
3467        self.record_edit(&range, &new_line);
3468        self.content = self.content[..start].to_owned() + &new_line + &self.content[end..];
3469        self.remap_diagnostics(&range, new_line.len());
3470        // The line just grew/shrank by `delta` — shift a caret at/after its old
3471        // end with the text (the drop path parks it on the line below), else its
3472        // stale offset lands inside the new `{width=N}` tail and reveal-on-caret
3473        // swaps the freshly resized image for raw source. An offset inside the
3474        // line clamps to the new line end.
3475        let remap = |o: usize| {
3476            if o >= end {
3477                o.saturating_add_signed(delta)
3478            } else {
3479                o.min(start + new_line.len())
3480            }
3481        };
3482        self.selected_range = remap(self.selected_range.start)..remap(self.selected_range.end);
3483        cx.emit(EditorEvent::Changed);
3484        cx.notify();
3485    }
3486
3487    /// If logical line `row` renders as an inline image (a standalone `![](src)`
3488    /// or list-item image in markdown mode — not a file chip), the byte range of
3489    /// the whole line plus its trailing newline: the atomic unit Word-style
3490    /// deletion removes. `None` with styling off (raw mode edits as plain text).
3491    fn image_row_range(&self, row: usize) -> Option<Range<usize>> {
3492        self.markdown_style.as_ref()?;
3493        let start = *self.line_starts().get(row)?;
3494        let end = self.line_end(row);
3495        let (src, ..) = markdown_syntax::image_row(&self.content[start..end])?;
3496        if let Some(chip) = &self.block_chip
3497            && chip(src).is_some()
3498        {
3499            return None; // a chip's line edits as text (reveal-on-caret)
3500        }
3501        Some(start..(end + 1).min(self.content.len()))
3502    }
3503
3504    /// Delete the `key:: value` line at `row` (+ its newline) — the panel's
3505    /// right-click "Delete property". One undoable edit; deleting the last
3506    /// property removes the panel.
3507    fn delete_property_row(&mut self, row: usize, cx: &mut Context<Self>) {
3508        let Some(&start) = self.line_starts().get(row) else {
3509            return;
3510        };
3511        let end = (self.line_end(row) + 1).min(self.content.len());
3512        self.replace_range(start..end, "", cx);
3513        cx.emit(EditorEvent::Changed);
3514    }
3515
3516    /// Delete the image occupying logical line `row` — line + trailing newline,
3517    /// one undoable edit. Backs the right-click "Delete image" and the
3518    /// Word-style Backspace/Delete on an image row.
3519    fn delete_image_row(&mut self, row: usize, cx: &mut Context<Self>) {
3520        if let Some(range) = self.image_row_range(row) {
3521            self.replace_range(range, "", cx);
3522            cx.emit(EditorEvent::Changed);
3523        }
3524    }
3525
3526    /// Right-click: if the click lands on a flagged word, fetch its suggestions
3527    /// (lazily, via the provider) and open a menu anchored there; otherwise close
3528    /// any open menu.
3529    fn on_right_mouse_down(
3530        &mut self,
3531        event: &MouseDownEvent,
3532        window: &mut Window,
3533        cx: &mut Context<Self>,
3534    ) {
3535        // Right-click on an inline image: Word-style object menu (Delete) — the
3536        // row renders as a picture, there's no text under the pointer to edit.
3537        // Only for real `![](src)` rows: mermaid/math rasters share the widget
3538        // type but delete as text (their menu would silently no-op).
3539        if let Some(&(line, _)) = self
3540            .image_rects
3541            .iter()
3542            .find(|(_, rect)| rect.contains(&event.position))
3543            .filter(|&&(line, _)| self.image_row_range(line).is_some())
3544        {
3545            self.menu = None;
3546            self.table_menu = None;
3547            self.focus(window, cx);
3548            self.image_menu = Some((line, event.position));
3549            cx.notify();
3550            return;
3551        }
3552        // Right-click a property-panel row: Edit / Delete property menu — the
3553        // panel renders as a widget, there's no text under the pointer.
3554        if let Some(&(_, row)) = self
3555            .prop_row_rects
3556            .iter()
3557            .find(|(rect, _)| rect.contains(&event.position))
3558        {
3559            self.menu = None;
3560            self.table_menu = None;
3561            self.image_menu = None;
3562            self.focus(window, cx);
3563            self.prop_menu = Some((row, event.position));
3564            cx.notify();
3565            return;
3566        }
3567        // Right-click a file chip places the caret to edit its source (the line
3568        // then reveals raw `![](src)`), instead of opening the spell menu.
3569        if self.chip_at(event.position).is_some() {
3570            self.menu = None;
3571            self.focus(window, cx);
3572            let offset = self.index_for_mouse_position(event.position);
3573            self.move_to(offset, cx);
3574            return;
3575        }
3576        // Right-click a $$…$$ block: emit a MathMenu event so the host can show a
3577        // context menu (Copy LaTeX / Export SVG / PNG). Focus the editor (not the caret
3578        // move of old) so it stays live after the menu closes.
3579        {
3580            let offset = self.index_for_mouse_position(event.position);
3581            let (row, _) = self.row_col(offset);
3582            if let Some((_range, source)) = self.math_block_at(row) {
3583                self.focus(window, cx);
3584                cx.emit(EditorEvent::MathMenu {
3585                    source,
3586                    position: event.position,
3587                });
3588                return;
3589            }
3590        }
3591        // Right-click in a table cell: place the caret there + open the table menu
3592        // (insert/delete rows + columns), instead of the spell menu. INSIDE a
3593        // selection, keep the selection and show the clipboard menu instead —
3594        // Cut/Copy act on it, like prose; the structure menu stays a
3595        // selection-free right-click away.
3596        if let Some(offset) = self.table_offset_at(event.position, window) {
3597            self.menu = None;
3598            self.focus(window, cx);
3599            let sel = self.selected_range.clone();
3600            if !sel.is_empty() && offset >= sel.start && offset <= sel.end {
3601                self.menu = Some(DiagMenu {
3602                    anchor: event.position,
3603                    range: offset..offset,
3604                    suggestions: Vec::new(),
3605                    scroll: ScrollHandle::new(),
3606                    turn_into: false,
3607                });
3608            } else {
3609                self.move_to(offset, cx);
3610                self.table_menu = Some(event.position);
3611            }
3612            cx.notify();
3613            return;
3614        }
3615        let offset = self.index_for_mouse_position(event.position);
3616        // Window-space — the popup renders on a `deferred`/`anchored` layer.
3617        let anchor = event.position;
3618        // A right-click outside the selection moves the caret there (so Paste
3619        // lands under the pointer); inside it, the selection stays put — it's
3620        // what Cut/Copy act on.
3621        let sel = self.selected_range.clone();
3622        let in_selection = !sel.is_empty() && offset >= sel.start && offset <= sel.end;
3623        if !in_selection {
3624            self.move_to(offset, cx);
3625        }
3626        self.focus(window, cx);
3627        // Suggestions when the click lands on a flagged word; the clipboard
3628        // verbs (Cut / Copy / Paste) ride along either way.
3629        let (range, suggestions) = match self.diagnostic_at(offset).map(|d| d.range.clone()) {
3630            Some(range) => {
3631                let word = self.content[range.clone()].to_string();
3632                let suggestions = self.suggest.as_ref().map(|f| f(&word)).unwrap_or_default();
3633                (range, suggestions)
3634            }
3635            None => (offset..offset, Vec::new()),
3636        };
3637        self.menu = Some(DiagMenu {
3638            anchor,
3639            range,
3640            suggestions: suggestions.into_iter().map(SharedString::from).collect(),
3641            scroll: ScrollHandle::new(),
3642            turn_into: false,
3643        });
3644        cx.notify();
3645    }
3646
3647    /// The diagnostic whose range contains `offset`, if any.
3648    fn diagnostic_at(&self, offset: usize) -> Option<&Diagnostic> {
3649        self.diagnostics
3650            .iter()
3651            .find(|d| d.range.start <= offset && offset < d.range.end)
3652    }
3653
3654    /// Close the suggestions menu (Escape, or a click elsewhere).
3655    fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
3656        if self.menu.take().is_some()
3657            || self.table_menu.take().is_some()
3658            || self.image_menu.take().is_some()
3659            || self.prop_menu.take().is_some()
3660            || self.code_lang_menu.take().is_some()
3661        {
3662            cx.notify();
3663        }
3664    }
3665
3666    /// Run the shared math-fence normalizer over the paragraph containing
3667    /// `row` (blank-line bounded), splicing only when it changes something.
3668    /// One recorded (undoable) edit; the caret shifts with the insertion.
3669    fn normalize_math_at(&mut self, row: usize) {
3670        if self.markdown_style.is_none() {
3671            return;
3672        }
3673        let starts = self.line_starts();
3674        let last_row = starts.len().saturating_sub(1);
3675        let blank = |r: usize| self.content[starts[r]..self.line_end(r)].trim().is_empty();
3676        let mut first = row;
3677        while first > 0 && !blank(first - 1) {
3678            first -= 1;
3679        }
3680        let mut last = row;
3681        while last < last_row && !blank(last + 1) {
3682            last += 1;
3683        }
3684        let span = starts[first]..self.line_end(last);
3685        let para = &self.content[span.clone()];
3686        if !para.contains("$$") {
3687            return;
3688        }
3689        let normalized = match zorite_markdown::syntax::normalize_math_fences(para) {
3690            std::borrow::Cow::Borrowed(_) => return,
3691            std::borrow::Cow::Owned(s) => s,
3692        };
3693        let old_caret = self.selected_range.start;
3694        let delta = normalized.len() as isize - (span.end - span.start) as isize;
3695        self.record_edit(&span, &normalized);
3696        self.content =
3697            self.content[..span.start].to_owned() + &normalized + &self.content[span.end..];
3698        self.remap_diagnostics(&span, normalized.len());
3699        // Typing happens at/after the paragraph's tail — shifting by the whole
3700        // delta keeps the caret on its text for the common case.
3701        let caret = if old_caret >= span.start {
3702            (old_caret as isize + delta).max(0) as usize
3703        } else {
3704            old_caret
3705        };
3706        let caret = caret.min(self.content.len());
3707        self.selected_range = caret..caret;
3708        self.last_edit = EditKind::Other;
3709    }
3710
3711    /// Replace `range` with a chosen suggestion and close the menu.
3712    fn apply_suggestion(
3713        &mut self,
3714        range: Range<usize>,
3715        text: &str,
3716        window: &mut Window,
3717        cx: &mut Context<Self>,
3718    ) {
3719        self.menu = None;
3720        self.selected_range = range;
3721        self.selection_reversed = false;
3722        self.replace_text_in_range(None, text, window, cx);
3723    }
3724
3725    /// The fenced code block containing `row`: its opening fence row plus the
3726    /// closing fence row (`None` when the block runs unclosed to the end).
3727    /// The single source for turn-into, block drag, and drop snapping.
3728    fn fence_block_rows(&self, row: usize) -> (usize, Option<usize>) {
3729        let scan = self.scan_data();
3730        let starts = self.line_starts();
3731        let last_row = starts.len().saturating_sub(1);
3732        let open = (0..=row)
3733            .rev()
3734            .find(|&r| !scan.fence_odd.get(r).copied().unwrap_or(false))
3735            .unwrap_or(0);
3736        let close = ((open + 1)..=last_row).find(|&r| {
3737            self.content[starts[r]..self.line_end(r)]
3738                .trim_start()
3739                .starts_with("```")
3740        });
3741        (open, close)
3742    }
3743
3744    /// The contiguous blockquote run containing `row` (first row..=last row),
3745    /// by the renderer's `blockquote_prefix` test.
3746    fn quote_run_rows(&self, row: usize) -> (usize, usize) {
3747        let starts = self.line_starts();
3748        let last_row = starts.len().saturating_sub(1);
3749        let is_q = |r: usize| {
3750            markdown_syntax::blockquote_prefix(&self.content[starts[r]..self.line_end(r)]).is_some()
3751        };
3752        let mut first = row;
3753        while first > 0 && is_q(first - 1) {
3754            first -= 1;
3755        }
3756        let mut last = row;
3757        while last < last_row && is_q(last + 1) {
3758            last += 1;
3759        }
3760        (first, last)
3761    }
3762
3763    /// The "Turn into" kind of the block containing `row` — what the flyout
3764    /// shows checked.
3765    fn block_kind_at(&self, row: usize) -> TurnKind {
3766        let scan = self.scan_data();
3767        if scan.math.iter().any(|m| m.range.contains(&row)) {
3768            return TurnKind::Math;
3769        }
3770        let starts = self.line_starts();
3771        let Some(&start) = starts.get(row) else {
3772            return TurnKind::Text;
3773        };
3774        // The renderer's own recognizers (task/list/heading/quote/alert), so
3775        // the checked kind always agrees with what WYSIWYG actually renders.
3776        let line = &self.content[start..self.line_end(row)];
3777        if scan.fence_odd.get(row).copied().unwrap_or(false) || line.trim_start().starts_with("```")
3778        {
3779            return TurnKind::Code;
3780        }
3781        if markdown_syntax::task_prefix(line).is_some() {
3782            return TurnKind::Todo;
3783        }
3784        match markdown_syntax::heading_level(line) {
3785            Some(1) => return TurnKind::H1,
3786            Some(2) => return TurnKind::H2,
3787            Some(3) => return TurnKind::H3,
3788            _ => {}
3789        }
3790        if let Some((_, _, ordered, _)) = markdown_syntax::list_prefix(line) {
3791            return if ordered {
3792                TurnKind::Numbered
3793            } else {
3794                TurnKind::Bullet
3795            };
3796        }
3797        if markdown_syntax::blockquote_prefix(line).is_some() {
3798            // Callout if the caret's contiguous `>`-run carries a VALID
3799            // `[!KIND]` marker anywhere (marker line or body line).
3800            let (first, last) = self.quote_run_rows(row);
3801            for (r, &start) in starts.iter().enumerate().take(last + 1).skip(first) {
3802                let l = &self.content[start..self.line_end(r)];
3803                let p = markdown_syntax::blockquote_prefix(l).unwrap_or(0);
3804                if markdown_syntax::alert_kind(&l[p..]).is_some() {
3805                    return TurnKind::Callout;
3806                }
3807            }
3808            return TurnKind::Quote;
3809        }
3810        TurnKind::Text
3811    }
3812
3813    /// Convert the caret's block to `kind` — the "Turn into" menu action.
3814    /// One undoable edit rewriting the block's lines; fenced kinds (code,
3815    /// math) and quote runs convert as whole blocks.
3816    fn turn_into(&mut self, kind: TurnKind, window: &mut Window, cx: &mut Context<Self>) {
3817        let row = self.row_col(self.selected_range.start).0;
3818        let cur = self.block_kind_at(row);
3819        if cur == kind {
3820            return;
3821        }
3822        let scan = self.scan_data();
3823        let starts = self.line_starts();
3824        let last_row = starts.len().saturating_sub(1);
3825        let line_at = |r: usize| self.content[starts[r]..self.line_end(r)].to_string();
3826        // The block's line span + its content with the current dressing removed.
3827        let (first, last, mut body): (usize, usize, Vec<String>) = match cur {
3828            TurnKind::Code => {
3829                let (open, close) = self.fence_block_rows(row);
3830                let last = close.unwrap_or(last_row);
3831                let body_end = close.map(|c| c.saturating_sub(1)).unwrap_or(last_row);
3832                let body = ((open + 1)..=body_end).map(line_at).collect();
3833                (open, last, body)
3834            }
3835            TurnKind::Math => {
3836                let Some(reg) = scan.math.iter().find(|m| m.range.contains(&row)) else {
3837                    return;
3838                };
3839                let first = reg.range.start;
3840                let last = reg.range.end.saturating_sub(1).max(first);
3841                let body = ((first + 1)..last).map(line_at).collect();
3842                (first, last, body)
3843            }
3844            TurnKind::Quote | TurnKind::Callout => {
3845                let (first, last) = self.quote_run_rows(row);
3846                let body = (first..=last)
3847                    .filter_map(|r| {
3848                        let line = line_at(r);
3849                        let stripped = strip_block_prefix(&line);
3850                        // Drop the `[!KIND]` marker token; keep any title text
3851                        // after it (and skip the line if that leaves nothing).
3852                        if let Some(rest) = stripped.trim_start().strip_prefix("[!") {
3853                            let after = rest.split_once(']').map(|(_, a)| a).unwrap_or("");
3854                            let after = after.trim_start_matches(['-', '+']).trim();
3855                            return (!after.is_empty()).then(|| after.to_string());
3856                        }
3857                        Some(stripped.to_string())
3858                    })
3859                    .collect();
3860                (first, last, body)
3861            }
3862            _ => (
3863                row,
3864                row,
3865                vec![strip_block_prefix(&line_at(row)).to_string()],
3866            ),
3867        };
3868        if body.is_empty() {
3869            body.push(String::new());
3870        }
3871        let text = match kind {
3872            TurnKind::Text => body.join("\n"),
3873            TurnKind::H1 => prefix_lines(&body, |_| "# ".into()),
3874            TurnKind::H2 => prefix_lines(&body, |_| "## ".into()),
3875            TurnKind::H3 => prefix_lines(&body, |_| "### ".into()),
3876            TurnKind::Bullet => prefix_lines(&body, |_| "- ".into()),
3877            TurnKind::Numbered => prefix_lines(&body, |i| format!("{}. ", i + 1)),
3878            TurnKind::Todo => prefix_lines(&body, |_| "- [ ] ".into()),
3879            TurnKind::Quote => prefix_lines(&body, |_| "> ".into()),
3880            TurnKind::Callout => format!("> [!NOTE]\n{}", prefix_lines(&body, |_| "> ".into())),
3881            TurnKind::Code => format!("```\n{}\n```", body.join("\n")),
3882            TurnKind::Math => format!("$$\n{}\n$$", body.join("\n")),
3883        };
3884        let range = starts[first]..self.line_end(last);
3885        let range_start = range.start;
3886        self.selected_range = range;
3887        self.selection_reversed = false;
3888        self.replace_text_in_range(None, &text, window, cx);
3889        // Fenced kinds: the caret parks after the closing fence, revealing the
3890        // raw markers (reveal-on-caret) — seat it on the body's first line
3891        // instead, like `set_code_lang`.
3892        if matches!(kind, TurnKind::Code | TurnKind::Math) {
3893            let caret =
3894                (range_start + text.find('\n').map_or(0, |i| i + 1)).min(self.content.len());
3895            self.selected_range = caret..caret;
3896        }
3897    }
3898
3899    /// Extra left offset for the gutter drag grip (e.g. the host's
3900    /// line-number gutter width), so the grip clears other gutter chrome.
3901    pub fn set_grip_inset(&mut self, inset: Pixels) {
3902        self.grip_inset = inset;
3903    }
3904
3905    /// The line span the gutter grip drags as one unit: whole fenced/rendered
3906    /// regions (code, math, mermaid, tables incl. their style marker,
3907    /// property panels), a quote/callout run, a list item with its
3908    /// deeper-indented children — otherwise the single line.
3909    fn drag_block_rows(&self, row: usize) -> (usize, usize) {
3910        let scan = self.scan_data();
3911        let starts = self.line_starts();
3912        let last_row = starts.len().saturating_sub(1);
3913        let line_at = |r: usize| &self.content[starts[r]..self.line_end(r)];
3914        if let Some(m) = scan
3915            .math
3916            .iter()
3917            .find(|m| m.range.contains(&row) || m.marker_line == Some(row))
3918        {
3919            let first = m.marker_line.unwrap_or(m.range.start).min(m.range.start);
3920            return (first, m.range.end.saturating_sub(1).max(m.range.start));
3921        }
3922        if let Some((r, _)) = scan.mermaid.iter().find(|(r, _)| r.contains(&row)) {
3923            return (r.start, r.end.saturating_sub(1).max(r.start));
3924        }
3925        if let Some(t) = scan
3926            .tables
3927            .iter()
3928            .find(|t| t.lines.contains(&row) || t.marker_line == Some(row))
3929        {
3930            let first = t.marker_line.unwrap_or(t.lines.start).min(t.lines.start);
3931            return (first, t.lines.end.saturating_sub(1).max(t.lines.start));
3932        }
3933        if let Some(p) = scan.props.iter().find(|r| r.contains(&row)) {
3934            return (p.start, p.end.saturating_sub(1).max(p.start));
3935        }
3936        if scan.fence_odd.get(row).copied().unwrap_or(false)
3937            || line_at(row).trim_start().starts_with("```")
3938        {
3939            let (open, close) = self.fence_block_rows(row);
3940            return (open, close.unwrap_or(last_row));
3941        }
3942        if markdown_syntax::blockquote_prefix(line_at(row)).is_some() {
3943            return self.quote_run_rows(row);
3944        }
3945        if markdown_syntax::list_prefix(line_at(row)).is_some() {
3946            let indent = |r: usize| {
3947                let l = line_at(r);
3948                l.len() - l.trim_start().len()
3949            };
3950            let base = indent(row);
3951            let mut last = row;
3952            while last < last_row && !line_at(last + 1).trim().is_empty() && indent(last + 1) > base
3953            {
3954                last += 1;
3955            }
3956            return (row, last);
3957        }
3958        (row, row)
3959    }
3960
3961    /// The row whose gutter grip the pointer would hover (the event-time
3962    /// mirror of prepaint's grip computation, for repaint change-detection).
3963    fn grip_hover_row_at(&self, position: Point<Pixels>) -> Option<usize> {
3964        let bounds = self.last_bounds?;
3965        if self.markdown_style.is_none()
3966            || self.line_drag.is_some()
3967            || position.x < grip_left(bounds.origin.x, self.grip_inset) - px(4.)
3968            || position.x > bounds.origin.x + bounds.size.width
3969            || position.y < bounds.origin.y
3970            || position.y > bounds.origin.y + bounds.size.height
3971        {
3972            return None;
3973        }
3974        let y = position.y - bounds.origin.y;
3975        (0..self.line_tops.len()).find(|&i| {
3976            let h = self.line_h(i) * self.row_span(i) as f32;
3977            h > px(0.5) && y >= self.line_tops[i] && y < self.line_tops[i] + h
3978        })
3979    }
3980
3981    /// The drop boundary (a between-rows index) nearest the pointer, from the
3982    /// last paint's committed geometry.
3983    fn drop_boundary_at(&self, position: Point<Pixels>) -> usize {
3984        let Some(bounds) = self.last_bounds else {
3985            return 0;
3986        };
3987        let y = position.y - bounds.origin.y;
3988        for i in 0..self.line_tops.len() {
3989            let mid = self.line_tops[i] + self.line_h(i) * self.row_span(i) as f32 / 2.;
3990            if y < mid {
3991                return i;
3992            }
3993        }
3994        self.line_tops.len()
3995    }
3996
3997    /// Clamp a drop boundary out of the interior of any rendered region — a
3998    /// block can't land inside a table, fence, math block, or property panel.
3999    fn snap_drop_boundary(&self, mut b: usize) -> usize {
4000        let scan = self.scan_data();
4001        let snap = |b: usize, s: usize, e: usize| {
4002            if b > s && b < e {
4003                if b - s <= e - b { s } else { e }
4004            } else {
4005                b
4006            }
4007        };
4008        for m in scan.math.iter() {
4009            let s = m.marker_line.unwrap_or(m.range.start).min(m.range.start);
4010            b = snap(b, s, m.range.end);
4011        }
4012        for (r, _) in scan.mermaid.iter() {
4013            b = snap(b, r.start, r.end);
4014        }
4015        for t in scan.tables.iter() {
4016            let s = t.marker_line.unwrap_or(t.lines.start).min(t.lines.start);
4017            b = snap(b, s, t.lines.end);
4018        }
4019        for p in scan.props.iter() {
4020            b = snap(b, p.start, p.end);
4021        }
4022        // Inside a code fence: snap to the opening fence or past the close.
4023        if scan.fence_odd.get(b).copied().unwrap_or(false) {
4024            let (open, close) = self.fence_block_rows(b);
4025            let close = close.map(|c| c + 1).unwrap_or(self.line_starts().len());
4026            b = if b - open <= close - b { open } else { close };
4027        }
4028        b
4029    }
4030
4031    /// Land the grabbed block at boundary `t` — one undoable splice of the
4032    /// span between the block and the target, caret seated on the block.
4033    fn apply_line_drag(&mut self, bs: usize, be: usize, t: usize, cx: &mut Context<Self>) {
4034        let starts = self.line_starts();
4035        let n = starts.len();
4036        if bs >= n || be >= n || (t >= bs && t <= be + 1) {
4037            return; // dropped onto itself (or stale rows) — no-op
4038        }
4039        let block_start = starts[bs];
4040        let block_end = self.line_end(be);
4041        let block_text = self.content[block_start..block_end].to_string();
4042        if t > be {
4043            // Down: [block \n between...] → [between... block (\n)]
4044            let target_off = if t >= n {
4045                self.content.len()
4046            } else {
4047                starts[t]
4048            };
4049            let after_block = (block_end + 1).min(self.content.len());
4050            let mut new = self.content[after_block..target_off].to_string();
4051            if !new.is_empty() && !new.ends_with('\n') {
4052                new.push('\n');
4053            }
4054            let rest_len = new.len();
4055            new.push_str(&block_text);
4056            if t < n {
4057                new.push('\n');
4058            }
4059            self.replace_range(block_start..target_off, &new, cx);
4060            let caret = block_start + rest_len;
4061            self.selected_range = caret..caret;
4062        } else {
4063            // Up: [between... block] → [block \n between...]
4064            let target_off = starts[t];
4065            let between = &self.content[target_off..block_start];
4066            let mut new = block_text.clone();
4067            new.push('\n');
4068            new.push_str(&between[..between.len().saturating_sub(1)]);
4069            self.replace_range(target_off..block_end, &new, cx);
4070            self.selected_range = target_off..target_off;
4071        }
4072        cx.emit(EditorEvent::Changed);
4073    }
4074
4075    // --- Selection helpers ---------------------------------------------------
4076
4077    fn move_to(&mut self, offset: usize, cx: &mut Context<Self>) {
4078        self.selected_range = offset..offset;
4079        // A deliberate caret move ends the current typing/deleting run and the
4080        // vertical-movement goal column.
4081        self.last_edit = EditKind::Other;
4082        self.goal_x = None;
4083        cx.emit(EditorEvent::SelectionChanged);
4084        cx.notify();
4085    }
4086
4087    /// Seat the caret on the plain-text line just before (`after = false`) or after
4088    /// (`after = true`) the math `block`, and focus the editor — the keyboard counterpart to
4089    /// clicking away, for when the caret flows out of a `$$…$$` formula's structural editor
4090    /// (so it never lands on the hidden `$$` fence lines, which would reveal raw source).
4091    pub fn exit_math(
4092        &mut self,
4093        block: Range<usize>,
4094        after: bool,
4095        window: &mut Window,
4096        cx: &mut Context<Self>,
4097    ) {
4098        self.focus(window, cx);
4099        let target = if after {
4100            let (end_row, _) = self.row_col(block.end.saturating_sub(1));
4101            match self.line_starts().get(end_row + 1).copied() {
4102                Some(start) => start,
4103                // The block ends the document: give the caret a fresh line
4104                // below. Landing at content-end would park it ON the block's
4105                // last row, revealing the raw source it just committed.
4106                None => {
4107                    let end = self.content.len();
4108                    self.replace_range(end..end, "\n", cx);
4109                    cx.emit(EditorEvent::Changed);
4110                    self.content.len()
4111                }
4112            }
4113        } else {
4114            let (start_row, _) = self.row_col(block.start);
4115            // An alignment marker directly above belongs to the block — resting
4116            // on it reveals the region, so step past it too.
4117            let mut row = start_row;
4118            if row > 0 && markdown_syntax::math_align_marker(self.line_str(row - 1)).is_some() {
4119                row -= 1;
4120            }
4121            if row > 0 { self.line_end(row - 1) } else { 0 }
4122        };
4123        self.move_to(target, cx);
4124    }
4125
4126    /// The caret's bounds in window space (its painted Y range), or `None` before
4127    /// the first paint. Lets a host scroll the caret into view; computed from the
4128    /// layout stored at the last paint, so it's valid for caret moves that don't
4129    /// change the text (arrow keys, click).
4130    pub fn caret_screen_bounds(&self) -> Option<Bounds<Pixels>> {
4131        let bounds = self.last_bounds?;
4132        let (row, col) = self.row_col(self.cursor_offset());
4133        let lh = self.line_h(row);
4134        let p = self
4135            .wrapped
4136            .get(row)?
4137            .position_for_index(self.display_col(row, col), lh)?;
4138        let top = bounds.top() + self.line_tops.get(row).copied().unwrap_or(px(0.)) + p.y;
4139        Some(Bounds::from_corners(
4140            point(bounds.left(), top),
4141            point(bounds.left(), top + lh),
4142        ))
4143    }
4144
4145    fn cursor_offset(&self) -> usize {
4146        if self.selection_reversed {
4147            self.selected_range.start
4148        } else {
4149            self.selected_range.end
4150        }
4151    }
4152
4153    fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) {
4154        if self.selection_reversed {
4155            self.selected_range.start = offset;
4156        } else {
4157            self.selected_range.end = offset;
4158        }
4159        if self.selected_range.end < self.selected_range.start {
4160            self.selection_reversed = !self.selection_reversed;
4161            self.selected_range = self.selected_range.end..self.selected_range.start;
4162        }
4163        cx.emit(EditorEvent::SelectionChanged);
4164        cx.notify();
4165    }
4166
4167    // --- Line / row-col mapping ---------------------------------------------
4168
4169    /// Byte offset where each visual line starts (line 0 starts at 0; each line
4170    /// after a `\n`). Always has at least one entry.
4171    fn line_starts(&self) -> Vec<usize> {
4172        let mut starts = vec![0];
4173        for (i, b) in self.content.bytes().enumerate() {
4174            if b == b'\n' {
4175                starts.push(i + 1);
4176            }
4177        }
4178        starts
4179    }
4180
4181    /// The `(row, byte-column)` of a byte offset.
4182    fn row_col(&self, offset: usize) -> (usize, usize) {
4183        let starts = self.line_starts();
4184        let row = starts.partition_point(|&s| s <= offset).saturating_sub(1);
4185        (row, offset - starts[row])
4186    }
4187
4188    /// Byte offset of the end of a row's text (before its `\n`, or the document
4189    /// end for the last row).
4190    fn line_end(&self, row: usize) -> usize {
4191        let starts = self.line_starts();
4192        starts
4193            .get(row + 1)
4194            .map(|&s| s - 1)
4195            .unwrap_or(self.content.len())
4196    }
4197
4198    /// Offset one row up/down from the caret, preserving the byte column where
4199    /// possible. At the top/bottom edge, jumps to the document start/end.
4200    fn vertical_offset(&self, dir: i32) -> usize {
4201        let cursor = self.cursor_offset();
4202        let starts = self.line_starts();
4203        let (row, col) = self.row_col(cursor);
4204        let target = row as i32 + dir;
4205        if target < 0 {
4206            return 0;
4207        }
4208        if target as usize >= starts.len() {
4209            return self.content.len();
4210        }
4211        let target = target as usize;
4212        let target_start = starts[target];
4213        let target_len = self.line_end(target) - target_start;
4214        let mut new_col = col.min(target_len);
4215        while new_col > 0 && !self.content.is_char_boundary(target_start + new_col) {
4216            new_col -= 1;
4217        }
4218        target_start + new_col
4219    }
4220
4221    /// Offset one *visual* row up/down from the caret, preserving the goal column
4222    /// (x) across the run. Falls back to logical-line movement before the first
4223    /// paint (when no wrapped layout is cached yet).
4224    fn move_vertical(&mut self, dir: i32) -> usize {
4225        if self.wrapped.is_empty() {
4226            return self.vertical_offset(dir);
4227        }
4228        let (row, col) = self.row_col(self.cursor_offset());
4229        let cur_lh = self.line_h(row);
4230        if cur_lh <= px(0.) {
4231            return self.vertical_offset(dir);
4232        }
4233        let Some(cur) = self
4234            .wrapped
4235            .get(row)
4236            .and_then(|l| line_pos(l, self.bidi_map(row), self.display_col(row, col), cur_lh))
4237        else {
4238            return self.vertical_offset(dir);
4239        };
4240        let global_y = self.line_tops[row] + cur.y;
4241        // The goal column is the caret's *visual* x, so it carries an RTL row's
4242        // right-align shift — the target row's shift comes off again below.
4243        // (Row insets stay out of it, as they always have.)
4244        let goal = self.goal_x.unwrap_or(cur.x + self.rtl_shift(row));
4245        self.goal_x = Some(goal);
4246        // Step to the adjacent visual row. Down: to the bottom of the current
4247        // row (= the top of the next one). Up: just above the current row's top
4248        // — robust to the row above having a different height (e.g. a heading),
4249        // since it doesn't depend on the current row's height.
4250        let target_y = if dir >= 0 {
4251            global_y + cur_lh
4252        } else {
4253            global_y - px(1.)
4254        };
4255        if target_y < px(0.) {
4256            return 0;
4257        }
4258        let last = self.wrapped.len() - 1;
4259        let total = self.line_tops[last] + self.line_h(last) * self.row_span(last) as f32;
4260        if target_y >= total {
4261            // Landing at the very end would park the caret on a trailing
4262            // collapsed row (a hidden closing ``` fence) and reveal it — clamp
4263            // to the end of the last VISIBLE row instead.
4264            let mut r = last;
4265            while r > 0 && self.line_h(r) <= px(0.) {
4266                r -= 1;
4267            }
4268            return if r == last {
4269                self.content.len()
4270            } else {
4271                self.line_end(r)
4272            };
4273        }
4274        let mut trow = last;
4275        for i in 0..self.wrapped.len() {
4276            let h = self.line_h(i) * self.row_span(i) as f32;
4277            if target_y < self.line_tops[i] + h {
4278                trow = i;
4279                break;
4280            }
4281        }
4282        // A reserved gutter gap (a table's top/bottom, a code block's pads) belongs
4283        // to no row, and the loop assigns it to the row *after* it — right going
4284        // down, but going up that strands the caret on the far side of the gap (e.g.
4285        // just below a table). Going up, target the row before the gap instead.
4286        if dir < 0 && trow > 0 && target_y < self.line_tops[trow] {
4287            trow -= 1;
4288        }
4289        // A table separator (`|---|`) row isn't editable — skip past it (in the
4290        // direction of travel) so the caret lands on the header/body row rather
4291        // than dropping the whole table to raw source.
4292        if self
4293            .table_rows
4294            .get(trow)
4295            .and_then(Option::as_ref)
4296            .is_some_and(|t| t.is_separator)
4297        {
4298            let skip = if dir >= 0 {
4299                trow + 1
4300            } else {
4301                trow.wrapping_sub(1)
4302            };
4303            if skip < self.wrapped.len() {
4304                trow = skip;
4305            }
4306        }
4307        // A collapsed row (a hidden ``` fence, a folded body line) has no visual
4308        // height — landing there reveals it. Keep stepping in the direction of
4309        // travel so the caret skips over it; if the document runs out that way,
4310        // stay put.
4311        if self.line_h(trow) <= px(0.) {
4312            let step = |r: usize| {
4313                if dir >= 0 {
4314                    (r + 1 < self.wrapped.len()).then_some(r + 1)
4315                } else {
4316                    r.checked_sub(1)
4317                }
4318            };
4319            let mut r = trow;
4320            loop {
4321                match step(r) {
4322                    Some(n) if self.line_h(n) <= px(0.) => r = n,
4323                    Some(n) => {
4324                        trow = n;
4325                        break;
4326                    }
4327                    None => return self.cursor_offset(),
4328                }
4329            }
4330        }
4331        let rel = point(
4332            (goal - self.rtl_shift(trow)).max(px(0.)),
4333            (target_y - self.line_tops[trow]).max(px(0.)),
4334        );
4335        let col = line_index_at(
4336            &self.wrapped[trow],
4337            self.bidi_map(trow),
4338            rel,
4339            self.line_h(trow),
4340        );
4341        self.line_starts()[trow] + self.source_col(trow, col)
4342    }
4343
4344    /// The end of the next word at/after `offset` (⌥→ on macOS).
4345    fn next_word(&self, offset: usize) -> usize {
4346        self.content
4347            .unicode_word_indices()
4348            .map(|(i, w)| i + w.len())
4349            .find(|&end| end > offset)
4350            .unwrap_or(self.content.len())
4351    }
4352
4353    /// The start of the previous word before `offset` (⌥← on macOS).
4354    fn prev_word(&self, offset: usize) -> usize {
4355        self.content
4356            .unicode_word_indices()
4357            .map(|(i, _)| i)
4358            .rfind(|&start| start < offset)
4359            .unwrap_or(0)
4360    }
4361
4362    /// The byte range of the word at `offset` (double-click); `None` in whitespace.
4363    fn word_range_at(&self, offset: usize) -> Option<Range<usize>> {
4364        let mut ends_at = None;
4365        for (i, w) in self.content.unicode_word_indices() {
4366            let range = i..i + w.len();
4367            if range.start <= offset && offset < range.end {
4368                return Some(range);
4369            }
4370            if range.end == offset {
4371                ends_at = Some(range);
4372            }
4373        }
4374        ends_at
4375    }
4376
4377    fn word_left(&mut self, _: &WordLeft, _: &mut Window, cx: &mut Context<Self>) {
4378        let off = self.prev_word(self.cursor_offset());
4379        if self.enter_construct_at(off, true, cx) {
4380            return;
4381        }
4382        self.move_to(off, cx);
4383    }
4384
4385    fn word_right(&mut self, _: &WordRight, _: &mut Window, cx: &mut Context<Self>) {
4386        let off = self.next_word(self.cursor_offset());
4387        if self.enter_construct_at(off, false, cx) {
4388            return;
4389        }
4390        self.move_to(off, cx);
4391    }
4392
4393    fn select_word_left(&mut self, _: &SelectWordLeft, _: &mut Window, cx: &mut Context<Self>) {
4394        self.goal_x = None;
4395        self.select_to(self.prev_word(self.cursor_offset()), cx);
4396    }
4397
4398    fn select_word_right(&mut self, _: &SelectWordRight, _: &mut Window, cx: &mut Context<Self>) {
4399        self.goal_x = None;
4400        self.select_to(self.next_word(self.cursor_offset()), cx);
4401    }
4402
4403    /// The `src` of a file chip on the row at window `position`, if that row is a
4404    /// chip (from the last paint) — left-click opens it, right-click edits.
4405    fn chip_at(&self, position: Point<Pixels>) -> Option<(SharedString, bool)> {
4406        if self.wrapped.is_empty() || self.chip_rows.iter().all(Option::is_none) {
4407            return None;
4408        }
4409        let bounds = self.last_bounds.as_ref()?;
4410        let rel_y = position.y - bounds.top();
4411        let mut row = self.wrapped.len() - 1;
4412        for i in 0..self.wrapped.len() {
4413            let h = self.line_h(i) * self.row_span(i) as f32;
4414            if rel_y < self.line_tops[i] + h {
4415                row = i;
4416                break;
4417            }
4418        }
4419        self.chip_rows.get(row).and_then(Option::clone)
4420    }
4421
4422    /// The inline `$…$` formula under `position` (its absolute byte range + inner LaTeX), from
4423    /// the last paint's window-space `inline_math_rects` — so a click opens its editor.
4424    fn inline_math_at(&self, position: Point<Pixels>) -> Option<(Range<usize>, SharedString)> {
4425        self.inline_math_rects
4426            .iter()
4427            // Empty latex marks an inline IMAGE sharing this machinery — not a
4428            // formula, so a click on it shouldn't open the math editor.
4429            .find(|(_, latex, rect)| !latex.is_empty() && rect.contains(&position))
4430            .map(|(range, latex, _)| (range.clone(), latex.clone()))
4431    }
4432
4433    /// The inline image `src` under `position` (an empty-latex entry in
4434    /// `inline_math_rects`), parsed from its `![alt](src)` source.
4435    fn inline_image_at(&self, position: Point<Pixels>) -> Option<SharedString> {
4436        let (range, _, _) = self
4437            .inline_math_rects
4438            .iter()
4439            .find(|(_, latex, rect)| latex.is_empty() && rect.contains(&position))?;
4440        let text = self.content.get(range.clone())?;
4441        let open = text.rfind('(')?;
4442        let close = text.rfind(')')?;
4443        (open < close).then(|| text[open + 1..close].to_string().into())
4444    }
4445
4446    /// If `position` lands on an inline image's bottom-right resize grip, the
4447    /// `(logical line, current display width)` of that image — so a press can
4448    /// start a corner-grip drag. The grip is the `IMG_GRIP`-side square pinned to
4449    /// each image's painted corner (see [`Self::image_grip`]); checked against the
4450    /// last paint's window-space `image_rects`.
4451    fn grip_at(&self, position: Point<Pixels>) -> Option<(usize, f32)> {
4452        self.image_rects.iter().find_map(|&(line, rect)| {
4453            Self::image_grip(rect)
4454                .contains(&position)
4455                .then_some((line, f32::from(rect.size.width)))
4456        })
4457    }
4458
4459    /// The window-space bounds of an image's corner grip, given the image's
4460    /// painted `rect`. A small square overhanging the bottom-right corner (its
4461    /// center on the corner, like the reading view's), so it's easy to grab
4462    /// without covering much of the image.
4463    fn image_grip(rect: Bounds<Pixels>) -> Bounds<Pixels> {
4464        let s = px(IMG_GRIP);
4465        Bounds::new(
4466            point(rect.right() - s / 2., rect.bottom() - s / 2.),
4467            size(s, s),
4468        )
4469    }
4470
4471    /// If `position` lands on a task checkbox painted last frame, the logical line
4472    /// of that task — so a click can toggle it. The hit area is the box padded a
4473    /// little, to stay easy to tap without swallowing the body text beside it.
4474    /// The code block whose opening fence is `fence_row`: its language token
4475    /// (empty for none) and the byte range of the body between the fences.
4476    fn code_block_at(&self, fence_row: usize) -> Option<(String, Range<usize>)> {
4477        let starts = self.line_starts();
4478        let &start = starts.get(fence_row)?;
4479        let fence_line = &self.content[start..self.line_end(fence_row)];
4480        let trimmed = fence_line.trim_start();
4481        let lang = trimmed.strip_prefix("```")?.trim().to_string();
4482        let body_start = (self.line_end(fence_row) + 1).min(self.content.len());
4483        let mut body_end = self.content.len();
4484        for (row, &row_start) in starts.iter().enumerate().skip(fence_row + 1) {
4485            if self.content[row_start..self.line_end(row)]
4486                .trim_start()
4487                .starts_with("```")
4488            {
4489                body_end = row_start.saturating_sub(1).max(body_start);
4490                break;
4491            }
4492        }
4493        Some((lang, body_start..body_end))
4494    }
4495
4496    /// If `position` lands on a code card's chrome painted last frame:
4497    /// `(on_copy, fence_row)` — `true` = the Copy button, `false` = the
4498    /// language tag.
4499    fn code_chip_at(&self, position: Point<Pixels>) -> Option<(bool, usize)> {
4500        self.code_chip_rects.iter().find_map(|c| {
4501            if c.copy.contains(&position) {
4502                Some((true, c.fence_row))
4503            } else if c.lang.contains(&position) {
4504                Some((false, c.fence_row))
4505            } else {
4506                None
4507            }
4508        })
4509    }
4510
4511    /// Rewrite the block's opening fence to carry `lang` (one undo step).
4512    fn set_code_lang(&mut self, fence_row: usize, lang: &str, cx: &mut Context<Self>) {
4513        let starts = self.line_starts();
4514        let Some(&start) = starts.get(fence_row) else {
4515            return;
4516        };
4517        let end = self.line_end(fence_row);
4518        let line = &self.content[start..end];
4519        let trimmed = line.trim_start();
4520        if !trimmed.starts_with("```") {
4521            return;
4522        }
4523        let indent = &line[..line.len() - trimmed.len()];
4524        let new_line = format!("{indent}```{}", if lang == "text" { "" } else { lang });
4525        self.replace_range(start..end, &new_line, cx);
4526        // replace_range parks the caret at the fence line's end, which reveals
4527        // the raw ``` marker (reveal-on-caret). Step onto the body's first
4528        // line instead so the fence stays hidden.
4529        let caret = (start + new_line.len() + 1).min(self.content.len());
4530        self.selected_range = caret..caret;
4531        cx.emit(EditorEvent::Changed);
4532    }
4533
4534    fn checkbox_at(&self, position: Point<Pixels>) -> Option<usize> {
4535        let pad = px(4.);
4536        self.checkbox_rects.iter().find_map(|&(line, rect)| {
4537            Bounds::new(
4538                point(rect.origin.x - pad, rect.origin.y - pad),
4539                size(rect.size.width + pad * 2., rect.size.height + pad * 2.),
4540            )
4541            .contains(&position)
4542            .then_some(line)
4543        })
4544    }
4545
4546    /// If `position` lands on a foldable callout's chevron painted last frame,
4547    /// that marker's logical line — so a click can flip its fold char. Padded
4548    /// like the task checkbox to stay easy to hit.
4549    fn alert_fold_at(&self, position: Point<Pixels>) -> Option<usize> {
4550        let pad = px(4.);
4551        self.alert_fold_rects.iter().find_map(|&(line, rect)| {
4552            Bounds::new(
4553                point(rect.origin.x - pad, rect.origin.y - pad),
4554                size(rect.size.width + pad * 2., rect.size.height + pad * 2.),
4555            )
4556            .contains(&position)
4557            .then_some(line)
4558        })
4559    }
4560
4561    /// If `position` lands on a heading's fold chevron painted last frame,
4562    /// that heading's logical line — so a click can toggle its fold. Padded
4563    /// like the callout chevron.
4564    fn heading_fold_at(&self, position: Point<Pixels>) -> Option<usize> {
4565        let pad = px(4.);
4566        self.heading_fold_rects.iter().find_map(|&(line, rect)| {
4567            Bounds::new(
4568                point(rect.origin.x - pad, rect.origin.y - pad),
4569                size(rect.size.width + pad * 2., rect.size.height + pad * 2.),
4570            )
4571            .contains(&position)
4572            .then_some(line)
4573        })
4574    }
4575
4576    fn index_for_mouse_position(&self, position: Point<Pixels>) -> usize {
4577        if self.content.is_empty() || self.wrapped.is_empty() {
4578            return 0;
4579        }
4580        let Some(bounds) = self.last_bounds.as_ref() else {
4581            return 0;
4582        };
4583        let rel = point(position.x - bounds.left(), position.y - bounds.top());
4584        // Which logical line, by the vertical band each occupies (variable height).
4585        let mut row = self.wrapped.len() - 1;
4586        for i in 0..self.wrapped.len() {
4587            let height = self.line_h(i) * self.row_span(i) as f32;
4588            if rel.y < self.line_tops[i] + height {
4589                row = i;
4590                break;
4591            }
4592        }
4593        // An inline-image row: clicking it puts the caret at the line start (the
4594        // line then shows its source — "raw on caret"), not a text column.
4595        if self.widget_rows.get(row).copied().unwrap_or(false) {
4596            return self.line_starts()[row];
4597        }
4598        let x = (rel.x - self.row_origin_x(row)).max(px(0.));
4599        let line_rel = point(x, rel.y - self.line_tops[row]);
4600        let col = line_index_at(
4601            &self.wrapped[row],
4602            self.bidi_map(row),
4603            line_rel,
4604            self.line_h(row),
4605        );
4606        self.line_starts()[row] + self.source_col(row, col)
4607    }
4608
4609    /// Map a display byte column on `row` back to its source column. Identity
4610    /// unless the row's markers are hidden (W6), where the painted text is
4611    /// shorter than the source.
4612    fn source_col(&self, row: usize, display_col: usize) -> usize {
4613        match self.offset_maps.get(row).and_then(Option::as_ref) {
4614            Some(map) => map.get(display_col).copied().unwrap_or(display_col),
4615            None => display_col,
4616        }
4617    }
4618
4619    /// Map a source byte column on `row` to its display column — the inverse of
4620    /// [`Self::source_col`], for positioning the caret/selection on a row whose
4621    /// markers are hidden (W6/#5). Uses the last painted map; in-paint code that
4622    /// has this frame's fresh map should call [`display_col_in`] directly.
4623    fn display_col(&self, row: usize, source_col: usize) -> usize {
4624        display_col_in(
4625            self.offset_maps.get(row).and_then(Option::as_ref),
4626            source_col,
4627        )
4628    }
4629
4630    // --- UTF-16 + grapheme boundaries (IME / cursor movement) ----------------
4631
4632    fn offset_from_utf16(&self, offset: usize) -> usize {
4633        let (mut utf8, mut utf16) = self.utf16_resume(offset, false);
4634        for ch in self.content[utf8..].chars() {
4635            if utf16 >= offset {
4636                break;
4637            }
4638            utf16 += ch.len_utf16();
4639            utf8 += ch.len_utf8();
4640        }
4641        self.utf16_anchor.set((self.content_gen, utf8, utf16));
4642        utf8
4643    }
4644
4645    fn offset_to_utf16(&self, offset: usize) -> usize {
4646        let (mut utf8, mut utf16) = self.utf16_resume(offset, true);
4647        for ch in self.content[utf8..].chars() {
4648            if utf8 >= offset {
4649                break;
4650            }
4651            utf8 += ch.len_utf8();
4652            utf16 += ch.len_utf16();
4653        }
4654        self.utf16_anchor.set((self.content_gen, utf8, utf16));
4655        utf16
4656    }
4657
4658    /// Where a conversion can start: the saved anchor when it's from the
4659    /// current content generation and at/before the target (`by_utf8` picks
4660    /// which unit the target is in), else the document start. IME composition
4661    /// converts monotonically-close offsets many times per keystroke, so this
4662    /// turns O(document) scans into O(distance-from-anchor).
4663    fn utf16_resume(&self, target: usize, by_utf8: bool) -> (usize, usize) {
4664        let (generation, utf8, utf16) = self.utf16_anchor.get();
4665        let anchor_pos = if by_utf8 { utf8 } else { utf16 };
4666        if generation == self.content_gen && anchor_pos <= target && utf8 <= self.content.len() {
4667            (utf8, utf16)
4668        } else {
4669            (0, 0)
4670        }
4671    }
4672
4673    fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> {
4674        self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end)
4675    }
4676
4677    fn range_from_utf16(&self, range: &Range<usize>) -> Range<usize> {
4678        self.offset_from_utf16(range.start)..self.offset_from_utf16(range.end)
4679    }
4680
4681    /// One VISIBLE position left of `offset`: a grapheme step, extended while
4682    /// the display column doesn't change — always-hidden formatting markers
4683    /// (`**`, `~~`, …) are zero-width on screen, so crossing one must not cost
4684    /// extra keypresses. Rows without a display map (raw/code) step plainly.
4685    fn prev_visible_boundary(&self, offset: usize) -> usize {
4686        let (row0, col0) = self.row_col(offset);
4687        let d0 = self.display_col(row0, col0);
4688        let mut off = self.previous_boundary(offset);
4689        loop {
4690            if off == 0 {
4691                return off;
4692            }
4693            let (row, col) = self.row_col(off);
4694            if row != row0
4695                || self.offset_maps.get(row).and_then(Option::as_ref).is_none()
4696                || self.display_col(row, col) != d0
4697            {
4698                return off;
4699            }
4700            off = self.previous_boundary(off);
4701        }
4702    }
4703
4704    /// One VISIBLE position right of `offset` — see [`Self::prev_visible_boundary`].
4705    fn next_visible_boundary(&self, offset: usize) -> usize {
4706        let (row0, col0) = self.row_col(offset);
4707        let d0 = self.display_col(row0, col0);
4708        let mut off = self.next_boundary(offset);
4709        loop {
4710            if off >= self.content.len() {
4711                return self.content.len();
4712            }
4713            let (row, col) = self.row_col(off);
4714            if row != row0
4715                || self.offset_maps.get(row).and_then(Option::as_ref).is_none()
4716                || self.display_col(row, col) != d0
4717            {
4718                return off;
4719            }
4720            off = self.next_boundary(off);
4721        }
4722    }
4723
4724    /// Cditor-style deletion planning around hidden formatting markers: the
4725    /// range a backspace (`back`) / forward delete should remove at `off`.
4726    /// Skips the invisible marker bytes to the adjacent VISIBLE character —
4727    /// and when removing it would empty its construct, the now-empty marker
4728    /// pair goes too (deleting bold's last char deletes the bold). `None` =
4729    /// no hidden markers involved (the plain grapheme deletion applies).
4730    fn fmt_delete_range(&self, off: usize, back: bool) -> Option<Range<usize>> {
4731        let st = self.markdown_style.as_ref()?;
4732        let (row, col) = self.row_col(off);
4733        let line_start = self.line_starts()[row];
4734        let line_end = self.line_end(row);
4735        let line = &self.content[line_start..line_end];
4736        // Inside a fenced code block the text is verbatim — no markers there.
4737        if *self.scan_data().fence_odd.get(row).unwrap_or(&false)
4738            || line.trim_start().starts_with("```")
4739        {
4740            return None;
4741        }
4742        let pairs = markdown_syntax::fmt_marker_pairs(line, st);
4743        if pairs.is_empty() {
4744            return None;
4745        }
4746        let markers: Vec<&Range<usize>> = pairs.iter().flat_map(|(o, c)| [o, c]).collect();
4747        // Skip marker bytes in the deletion direction to the visible char.
4748        let mut edge = col;
4749        if back {
4750            while let Some(sp) = markers.iter().find(|sp| sp.end == edge) {
4751                edge = sp.start;
4752            }
4753        } else {
4754            while let Some(sp) = markers.iter().find(|sp| sp.start == edge) {
4755                edge = sp.end;
4756            }
4757        }
4758        // The visible grapheme adjacent to the (possibly skipped-to) edge —
4759        // staying on this line; a line join takes the default path.
4760        let (del_start, del_end) = if back {
4761            if edge == 0 {
4762                return None;
4763            }
4764            let p = self.previous_boundary(line_start + edge) - line_start;
4765            (p, edge)
4766        } else {
4767            if edge >= line.len() {
4768                return None;
4769            }
4770            let n = self.next_boundary(line_start + edge).min(line_end) - line_start;
4771            (edge, n)
4772        };
4773        // Would this empty a construct? Only a real PAIR collapses: ITS opener
4774        // ending at the deletion's start and ITS closer starting at the end
4775        // (adjacent different constructs must not fuse).
4776        let emptied = pairs
4777            .iter()
4778            .find(|(o, c)| o.end == del_start && c.start == del_end);
4779        let range = match emptied {
4780            Some((o, c)) => o.start..c.end,
4781            None if edge == col => return None, // no markers were involved
4782            None => del_start..del_end,
4783        };
4784        Some(line_start + range.start..line_start + range.end)
4785    }
4786
4787    fn previous_boundary(&self, offset: usize) -> usize {
4788        self.content
4789            .grapheme_indices(true)
4790            .rev()
4791            .find_map(|(idx, _)| (idx < offset).then_some(idx))
4792            .unwrap_or(0)
4793    }
4794
4795    fn next_boundary(&self, offset: usize) -> usize {
4796        self.content
4797            .grapheme_indices(true)
4798            .find_map(|(idx, _)| (idx > offset).then_some(idx))
4799            .unwrap_or(self.content.len())
4800    }
4801}
4802
4803impl EntityInputHandler for EditorState {
4804    fn text_for_range(
4805        &mut self,
4806        range_utf16: Range<usize>,
4807        actual_range: &mut Option<Range<usize>>,
4808        _: &mut Window,
4809        _: &mut Context<Self>,
4810    ) -> Option<String> {
4811        let range = self.range_from_utf16(&range_utf16);
4812        actual_range.replace(self.range_to_utf16(&range));
4813        Some(self.content[range].to_string())
4814    }
4815
4816    fn selected_text_range(
4817        &mut self,
4818        _: bool,
4819        _: &mut Window,
4820        _: &mut Context<Self>,
4821    ) -> Option<UTF16Selection> {
4822        Some(UTF16Selection {
4823            range: self.range_to_utf16(&self.selected_range),
4824            reversed: self.selection_reversed,
4825        })
4826    }
4827
4828    fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
4829        self.marked_range.as_ref().map(|r| self.range_to_utf16(r))
4830    }
4831
4832    fn unmark_text(&mut self, _: &mut Window, _: &mut Context<Self>) {
4833        self.marked_range = None;
4834    }
4835
4836    fn replace_text_in_range(
4837        &mut self,
4838        range_utf16: Option<Range<usize>>,
4839        new_text: &str,
4840        _: &mut Window,
4841        cx: &mut Context<Self>,
4842    ) {
4843        let range = range_utf16
4844            .as_ref()
4845            .map(|r| self.range_from_utf16(r))
4846            .or(self.marked_range.clone())
4847            .unwrap_or(self.selected_range.clone());
4848        // Report what this edit replaced (see `last_replaced`) — the fact a
4849        // diff can't reconstruct when the selection starts with the typed char.
4850        self.last_replaced =
4851            (range.start < range.end).then(|| self.content[range.clone()].to_string());
4852        self.record_edit(&range, new_text);
4853        self.content =
4854            self.content[0..range.start].to_owned() + new_text + &self.content[range.end..];
4855        let caret = range.start + new_text.len();
4856        self.selected_range = caret..caret;
4857        self.selection_reversed = false;
4858        self.marked_range = None;
4859        self.goal_x = None;
4860        // Keep unaffected diagnostics valid across the edit (shift those after
4861        // it, drop those it overlapped); the host recomputes the edited region.
4862        self.remap_diagnostics(&range, new_text.len());
4863        if word_boundary_input(new_text) {
4864            self.apply_auto_replace(range.start);
4865        }
4866        cx.emit(EditorEvent::Changed);
4867        cx.notify();
4868    }
4869
4870    fn replace_and_mark_text_in_range(
4871        &mut self,
4872        range_utf16: Option<Range<usize>>,
4873        new_text: &str,
4874        new_selected_range_utf16: Option<Range<usize>>,
4875        _: &mut Window,
4876        cx: &mut Context<Self>,
4877    ) {
4878        let range = range_utf16
4879            .as_ref()
4880            .map(|r| self.range_from_utf16(r))
4881            .or(self.marked_range.clone())
4882            .unwrap_or(self.selected_range.clone());
4883        self.content_gen += 1;
4884        self.content =
4885            self.content[0..range.start].to_owned() + new_text + &self.content[range.end..];
4886        self.marked_range =
4887            (!new_text.is_empty()).then(|| range.start..range.start + new_text.len());
4888        self.selected_range = new_selected_range_utf16
4889            .as_ref()
4890            .map(|r| self.range_from_utf16(r))
4891            .map(|r| r.start + range.start..r.end + range.start)
4892            .unwrap_or_else(|| {
4893                let caret = range.start + new_text.len();
4894                caret..caret
4895            });
4896        self.remap_diagnostics(&range, new_text.len());
4897        cx.emit(EditorEvent::Changed);
4898        cx.notify();
4899    }
4900
4901    fn bounds_for_range(
4902        &mut self,
4903        range_utf16: Range<usize>,
4904        bounds: Bounds<Pixels>,
4905        _: &mut Window,
4906        _: &mut Context<Self>,
4907    ) -> Option<Bounds<Pixels>> {
4908        let range = self.range_from_utf16(&range_utf16);
4909        let (row, col) = self.row_col(range.start);
4910        let lh = self.line_h(row);
4911        let line = self.wrapped.get(row)?;
4912        let map = self.bidi_map(row);
4913        let p = line_pos(line, map, self.display_col(row, col), lh)?;
4914        let top = bounds.top() + self.line_tops.get(row).copied().unwrap_or(px(0.)) + p.y;
4915        let x = bounds.left() + p.x + self.row_origin_x(row);
4916        // Span the whole range when it stays on one wrap row (the common IME
4917        // composition), so the candidate window anchors under the marked TEXT
4918        // rather than a zero-width bar at its start. Multi-row ranges keep the
4919        // start-anchored bar.
4920        let (erow, ecol) = self.row_col(range.end);
4921        let x2 = if erow == row && range.end > range.start {
4922            line_pos(line, map, self.display_col(row, ecol), lh)
4923                .filter(|e| e.y == p.y)
4924                .map(|e| bounds.left() + e.x + self.row_origin_x(row))
4925                .unwrap_or(x)
4926        } else {
4927            x
4928        };
4929        Some(Bounds::from_corners(
4930            point(x, top),
4931            point(x2.max(x), top + lh),
4932        ))
4933    }
4934
4935    fn character_index_for_point(
4936        &mut self,
4937        point: Point<Pixels>,
4938        _: &mut Window,
4939        _: &mut Context<Self>,
4940    ) -> Option<usize> {
4941        Some(self.offset_to_utf16(self.index_for_mouse_position(point)))
4942    }
4943}
4944
4945impl Focusable for EditorState {
4946    fn focus_handle(&self, _: &App) -> FocusHandle {
4947        self.focus_handle.clone()
4948    }
4949}
4950
4951impl EventEmitter<EditorEvent> for EditorState {}
4952
4953impl Render for EditorState {
4954    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4955        div()
4956            .relative()
4957            // While a `$$` block OR an inline `$…$` formula is being edited, the hosted math
4958            // editor is focused but lives *inside* this element — so the editor's own
4959            // keybindings (arrows, typing, …) would capture keys before they reach it. Drop the
4960            // key context for the duration so raw keys flow to the math editor's on_key_down.
4961            .key_context(
4962                if self.editing_block.is_some() || self.editing_inline.is_some() {
4963                    ""
4964                } else {
4965                    CONTEXT
4966                },
4967            )
4968            .track_focus(&self.focus_handle)
4969            .cursor(CursorStyle::IBeam)
4970            .on_action(cx.listener(Self::backspace))
4971            .on_action(cx.listener(Self::delete))
4972            .on_action(cx.listener(Self::left))
4973            .on_action(cx.listener(Self::right))
4974            .on_action(cx.listener(Self::up))
4975            .on_action(cx.listener(Self::down))
4976            .on_action(cx.listener(Self::home))
4977            .on_action(cx.listener(Self::end))
4978            .on_action(cx.listener(Self::select_left))
4979            .on_action(cx.listener(Self::select_right))
4980            .on_action(cx.listener(Self::select_up))
4981            .on_action(cx.listener(Self::select_down))
4982            .on_action(cx.listener(Self::word_left))
4983            .on_action(cx.listener(Self::word_right))
4984            .on_action(cx.listener(Self::select_word_left))
4985            .on_action(cx.listener(Self::select_word_right))
4986            .on_action(cx.listener(Self::select_all))
4987            .on_action(cx.listener(Self::newline))
4988            .on_action(cx.listener(Self::bold))
4989            .on_action(cx.listener(Self::italic))
4990            .on_action(cx.listener(Self::underline))
4991            .on_action(cx.listener(Self::strike))
4992            .on_action(cx.listener(Self::code))
4993            .on_action(cx.listener(Self::indent))
4994            .on_action(cx.listener(Self::outdent))
4995            .on_action(cx.listener(Self::paste))
4996            .on_action(cx.listener(Self::copy))
4997            .on_action(cx.listener(Self::cut))
4998            .on_action(cx.listener(Self::show_character_palette))
4999            .on_action(cx.listener(Self::undo))
5000            .on_action(cx.listener(Self::redo))
5001            .on_action(cx.listener(Self::dismiss))
5002            .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
5003            .on_mouse_down(MouseButton::Right, cx.listener(Self::on_right_mouse_down))
5004            .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up))
5005            .on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up))
5006            .on_mouse_move(cx.listener(Self::on_mouse_move))
5007            .on_scroll_wheel(cx.listener(Self::on_scroll_wheel))
5008            .child(EditorElement {
5009                editor: cx.entity(),
5010            })
5011            .children(self.embed_overlays(window))
5012            .children(self.editing_block_overlay())
5013            .children(self.editing_inline_overlay())
5014            // Right-click suggestions menu, absolutely positioned over the
5015            // editor (anchored at the click). `Option`'s `IntoIterator` renders
5016            // zero or one popup; clicking a row replaces the misspelled span.
5017            .children(self.menu.clone().map(|menu| {
5018                let DiagMenu {
5019                    anchor,
5020                    range,
5021                    suggestions,
5022                    scroll,
5023                    turn_into: menu_turn_into,
5024                } = menu;
5025                let count = suggestions.len();
5026                // Menu chrome from the host's theme (fallbacks match the former
5027                // hardcoded dark menu when no markdown style is set).
5028                let st = self.markdown_style.as_ref();
5029                let menu_bg = st.map_or(rgb(0x26262b).into(), |s| s.popover_bg);
5030                let menu_border = st.map_or(rgb(0x45454c).into(), |s| s.popover_border);
5031                let menu_fg = st.map_or(rgb(0xe6e6e6).into(), |s| s.popover_fg);
5032                let hover = st.map_or(rgba(0x2f6fd628).into(), |s| s.popover_hover);
5033                let mut thumb_c = st.map_or(rgba(0xffffff66).into(), |s| s.marker);
5034                thumb_c.a = 0.5;
5035                // Collected eagerly (not a lazy iterator) so `cx` is only
5036                // borrowed here and stays free for the menu's own listeners below.
5037                let rows: Vec<_> = suggestions
5038                    .into_iter()
5039                    .enumerate()
5040                    .map(|(i, sugg)| {
5041                        let range = range.clone();
5042                        let replacement = sugg.to_string();
5043                        div()
5044                            // A stable per-row id so gpui tracks hover state and
5045                            // repaints as the pointer moves between rows. Without
5046                            // an id, the hover style only shows on a forced
5047                            // repaint (e.g. while scrolling).
5048                            .id(("suggestion-row", i))
5049                            // Don't let the scroll container's max-height squeeze
5050                            // the rows; they keep their height and overflow.
5051                            .flex_shrink_0()
5052                            .px(px(10.))
5053                            .py(px(3.))
5054                            // Highlight the row under the pointer.
5055                            .hover(move |s| s.bg(hover))
5056                            .child(sugg)
5057                            .on_mouse_down(
5058                                MouseButton::Left,
5059                                cx.listener(move |editor, _: &MouseDownEvent, window, cx| {
5060                                    // Keep the editor's own mouse-down from clearing
5061                                    // the menu / moving the caret out from under us.
5062                                    cx.stop_propagation();
5063                                    editor.apply_suggestion(
5064                                        range.clone(),
5065                                        &replacement,
5066                                        window,
5067                                        cx,
5068                                    );
5069                                }),
5070                            )
5071                    })
5072                    .collect();
5073                // A thin scrollbar thumb, shown when the list overflows ~6 rows
5074                // so the scroll affordance is visible. Sized from the row count
5075                // (known now) and positioned from the live scroll offset — a
5076                // wheel scroll calls window.refresh(), which re-renders this.
5077                const ROW_H: f32 = 24.0;
5078                const PAD: f32 = 4.0;
5079                const MAX_H: f32 = 180.0;
5080                let rows_h = count as f32 * ROW_H;
5081                let view_h = MAX_H - 2.0 * PAD;
5082                let thumb = (rows_h > view_h).then(|| {
5083                    let scrolled = (-f32::from(scroll.offset().y)).clamp(0.0, rows_h - view_h);
5084                    let thumb_h = (view_h * view_h / rows_h).max(24.0);
5085                    let thumb_top = PAD + scrolled / (rows_h - view_h) * (view_h - thumb_h);
5086                    div()
5087                        .absolute()
5088                        .top(px(thumb_top))
5089                        .right(px(2.))
5090                        .w(px(6.))
5091                        .h(px(thumb_h))
5092                        .rounded(px(3.))
5093                        .bg(thumb_c)
5094                });
5095
5096                // Cut / Copy need a selection; Paste always applies (the caret
5097                // was seated at the click when it landed outside the selection).
5098                let has_sel = !self.selected_range.is_empty();
5099                let clip_item = |id: &'static str, label: SharedString| {
5100                    div()
5101                        .id(id)
5102                        .flex_shrink_0()
5103                        .px(px(10.))
5104                        .py(px(3.))
5105                        .hover(move |s| s.bg(hover))
5106                        .child(label)
5107                };
5108                let mut clipboard = div().flex().flex_col().py(px(4.));
5109                if has_sel {
5110                    clipboard = clipboard
5111                        .child(
5112                            clip_item("menu-cut", self.labels.cut.clone()).on_mouse_down(
5113                                MouseButton::Left,
5114                                cx.listener(|editor, _: &MouseDownEvent, window, cx| {
5115                                    cx.stop_propagation();
5116                                    editor.menu = None;
5117                                    editor.cut(&Cut, window, cx);
5118                                }),
5119                            ),
5120                        )
5121                        .child(
5122                            clip_item("menu-copy", self.labels.copy.clone()).on_mouse_down(
5123                                MouseButton::Left,
5124                                cx.listener(|editor, _: &MouseDownEvent, window, cx| {
5125                                    cx.stop_propagation();
5126                                    editor.menu = None;
5127                                    editor.copy(&Copy, window, cx);
5128                                }),
5129                            ),
5130                        )
5131                        // Plain-only: the raw markdown with no host flavors —
5132                        // for pasting literal source into rich surfaces
5133                        // (email, chat) where Copy's HTML flavor would win.
5134                        .child(
5135                            clip_item("menu-copy-md", self.labels.copy_as_markdown.clone())
5136                                .on_mouse_down(
5137                                    MouseButton::Left,
5138                                    cx.listener(|editor, _: &MouseDownEvent, window, cx| {
5139                                        cx.stop_propagation();
5140                                        editor.menu = None;
5141                                        editor.copy_plain(window, cx);
5142                                    }),
5143                                ),
5144                        );
5145                }
5146                let clipboard = clipboard.child(
5147                    clip_item("menu-paste", self.labels.paste.clone()).on_mouse_down(
5148                        MouseButton::Left,
5149                        cx.listener(|editor, _: &MouseDownEvent, window, cx| {
5150                            cx.stop_propagation();
5151                            editor.menu = None;
5152                            editor.paste(&Paste, window, cx);
5153                        }),
5154                    ),
5155                );
5156
5157                // Inline-format bar (Cditor-style): with a selection, a strip of
5158                // B / I / S / <> buttons across the menu's top — each toggles its
5159                // markdown wrap on the selection and closes the menu.
5160                let fmt_btn = |id: &'static str| {
5161                    div()
5162                        .id(id)
5163                        .w(px(28.))
5164                        .h(px(24.))
5165                        .rounded(px(4.))
5166                        .flex()
5167                        .items_center()
5168                        .justify_center()
5169                        .hover(move |s| s.bg(hover))
5170                };
5171                let format_bar = has_sel.then(|| {
5172                    div()
5173                        .flex()
5174                        .flex_row()
5175                        .items_center()
5176                        .gap(px(2.))
5177                        .px(px(6.))
5178                        .py(px(4.))
5179                        .child(
5180                            fmt_btn("menu-fmt-bold")
5181                                .font_weight(FontWeight::BOLD)
5182                                .child("B")
5183                                .on_mouse_down(
5184                                    MouseButton::Left,
5185                                    cx.listener(|editor, _: &MouseDownEvent, window, cx| {
5186                                        cx.stop_propagation();
5187                                        editor.menu = None;
5188                                        editor.bold(&Bold, window, cx);
5189                                    }),
5190                                ),
5191                        )
5192                        .child(
5193                            fmt_btn("menu-fmt-italic")
5194                                .italic()
5195                                .child("I")
5196                                .on_mouse_down(
5197                                    MouseButton::Left,
5198                                    cx.listener(|editor, _: &MouseDownEvent, window, cx| {
5199                                        cx.stop_propagation();
5200                                        editor.menu = None;
5201                                        editor.italic(&Italic, window, cx);
5202                                    }),
5203                                ),
5204                        )
5205                        .child(
5206                            fmt_btn("menu-fmt-underline")
5207                                .underline()
5208                                .child("U")
5209                                .on_mouse_down(
5210                                    MouseButton::Left,
5211                                    cx.listener(|editor, _: &MouseDownEvent, window, cx| {
5212                                        cx.stop_propagation();
5213                                        editor.menu = None;
5214                                        editor.underline(&Underline, window, cx);
5215                                    }),
5216                                ),
5217                        )
5218                        .child(
5219                            fmt_btn("menu-fmt-strike")
5220                                .line_through()
5221                                .child("S")
5222                                .on_mouse_down(
5223                                    MouseButton::Left,
5224                                    cx.listener(|editor, _: &MouseDownEvent, window, cx| {
5225                                        cx.stop_propagation();
5226                                        editor.menu = None;
5227                                        editor.strike(&Strike, window, cx);
5228                                    }),
5229                                ),
5230                        )
5231                        .child(
5232                            fmt_btn("menu-fmt-code")
5233                                .font_family(
5234                                    self.markdown_style
5235                                        .as_ref()
5236                                        .map(|s| s.mono.family.clone())
5237                                        .unwrap_or_else(|| "monospace".into()),
5238                                )
5239                                .text_size(px(12.))
5240                                .child("<>")
5241                                .on_mouse_down(
5242                                    MouseButton::Left,
5243                                    cx.listener(|editor, _: &MouseDownEvent, window, cx| {
5244                                        cx.stop_propagation();
5245                                        editor.menu = None;
5246                                        editor.code(&Code, window, cx);
5247                                    }),
5248                                ),
5249                        )
5250                });
5251
5252                // "Turn into" block conversion (Cditor-style): a row whose
5253                // hover opens a kind-list flyout beside the menu, the caret
5254                // block's current kind checked.
5255                let cur_kind = self.block_kind_at(self.row_col(self.selected_range.start).0);
5256                let turn_row = div()
5257                    .id("menu-turn-into")
5258                    .flex_shrink_0()
5259                    .px(px(10.))
5260                    .py(px(3.))
5261                    .hover(move |s| s.bg(hover))
5262                    .flex()
5263                    .flex_row()
5264                    .items_center()
5265                    .justify_between()
5266                    .gap(px(16.))
5267                    .child(self.labels.turn_into.clone())
5268                    .child(div().text_size(px(10.)).child("\u{25b8}"))
5269                    .on_hover(cx.listener(|editor, hovered: &bool, _, cx| {
5270                        if *hovered
5271                            && let Some(m) = editor.menu.as_mut()
5272                            && !m.turn_into
5273                        {
5274                            m.turn_into = true;
5275                            cx.notify();
5276                        }
5277                    }));
5278                let turn_labels = self.labels.clone();
5279                let turn_flyout = menu_turn_into.then(|| {
5280                    let rows: Vec<_> = TurnKind::ALL
5281                        .iter()
5282                        .enumerate()
5283                        .map(|(i, &k)| {
5284                            let checked = k == cur_kind;
5285                            div()
5286                                .id(("turn-kind", i))
5287                                .flex_shrink_0()
5288                                .px(px(10.))
5289                                .py(px(3.))
5290                                .hover(move |s| s.bg(hover))
5291                                .flex()
5292                                .flex_row()
5293                                .items_center()
5294                                .gap(px(6.))
5295                                .child(div().w(px(12.)).flex_shrink_0().child(if checked {
5296                                    "\u{2713}"
5297                                } else {
5298                                    ""
5299                                }))
5300                                .child(k.label(&turn_labels))
5301                                .on_mouse_down(
5302                                    MouseButton::Left,
5303                                    cx.listener(move |editor, _: &MouseDownEvent, window, cx| {
5304                                        cx.stop_propagation();
5305                                        editor.menu = None;
5306                                        editor.turn_into(k, window, cx);
5307                                    }),
5308                                )
5309                        })
5310                        .collect();
5311                    // An absolute sibling of the (overflow-clipped) menu box —
5312                    // out of flow, so it can't inflate the anchored bounds and
5313                    // re-trigger the window snap (the slash-flyout lesson).
5314                    div()
5315                        .absolute()
5316                        .left(gpui::relative(1.))
5317                        .bottom(px(0.))
5318                        .ml(px(2.))
5319                        .occlude()
5320                        .min_w(px(140.))
5321                        .cursor(CursorStyle::Arrow)
5322                        .bg(menu_bg)
5323                        .border_1()
5324                        .border_color(menu_border)
5325                        .rounded(px(6.))
5326                        .shadow_md()
5327                        .text_color(menu_fg)
5328                        .text_size(px(13.))
5329                        .flex()
5330                        .flex_col()
5331                        .py(px(4.))
5332                        .children(rows)
5333                });
5334
5335                // Deferred + anchored to a window-space top layer with `.occlude()`,
5336                // so it renders above the page chrome and captures the wheel — else a
5337                // scroll over the popup scrolls the page behind it.
5338                gpui::deferred(
5339                    gpui::anchored().position(anchor).snap_to_window().child(
5340                        div().relative().children(turn_flyout).child(
5341                            div()
5342                                .relative()
5343                                .occlude()
5344                                .min_w(px(150.))
5345                                // Override the editor's I-beam — the menu is a normal
5346                                // pointer surface (children inherit this hitbox's cursor).
5347                                .cursor(CursorStyle::Arrow)
5348                                .bg(menu_bg)
5349                                .border_1()
5350                                .border_color(menu_border)
5351                                .rounded(px(6.))
5352                                .shadow_md()
5353                                // Clip rows + thumb to the rounded box.
5354                                .overflow_hidden()
5355                                .text_color(menu_fg)
5356                                .text_size(px(13.))
5357                                // A click anywhere outside the menu dismisses it.
5358                                .on_mouse_down_out(cx.listener(
5359                                    |editor, _: &MouseDownEvent, _, cx| {
5360                                        editor.menu = None;
5361                                        cx.notify();
5362                                    },
5363                                ))
5364                                .children(format_bar)
5365                                .children(has_sel.then(|| div().h(px(1.)).bg(menu_border)))
5366                                .children((count > 0).then(|| {
5367                                    // The scroll viewport: shows ~6 rows, the rest scroll.
5368                                    div()
5369                                        .id("suggestion-menu")
5370                                        .max_h(px(MAX_H))
5371                                        .overflow_y_scroll()
5372                                        .track_scroll(&scroll)
5373                                        .flex()
5374                                        .flex_col()
5375                                        .py(px(PAD))
5376                                        .children(rows)
5377                                }))
5378                                .children((count > 0).then(|| div().h(px(1.)).bg(menu_border)))
5379                                .child(clipboard)
5380                                .child(div().h(px(1.)).bg(menu_border))
5381                                .child(div().flex().flex_col().py(px(4.)).child(turn_row))
5382                                .children(thumb),
5383                        ),
5384                    ),
5385                )
5386            }))
5387            // The table right-click menu (Word-style row/column editing), anchored
5388            // at the click; each row runs its action on the caret's table cell.
5389            .children(self.table_menu.map(|anchor| {
5390                // Menu chrome from the host's theme (fallbacks match the former
5391                // hardcoded dark menu when no markdown style is set).
5392                let st = self.markdown_style.as_ref();
5393                let menu_bg = st.map_or(rgb(0x26262b).into(), |s| s.popover_bg);
5394                let menu_border = st.map_or(rgb(0x45454c).into(), |s| s.popover_border);
5395                let menu_fg = st.map_or(rgb(0xe6e6e6).into(), |s| s.popover_fg);
5396                let hover = st.map_or(rgba(0x2f6fd628).into(), |s| s.popover_hover);
5397                let divider = st.map_or(rgba(0xffffff2e).into(), |s| s.popover_divider);
5398                let mut thumb_c = st.map_or(rgba(0xffffff66).into(), |s| s.marker);
5399                thumb_c.a = 0.5;
5400                const ROW_H: f32 = 24.0;
5401                const DIV_H: f32 = 9.0;
5402                const PAD: f32 = 4.0;
5403                const MAX_H: f32 = 480.0;
5404                // Cditor-style grouped rows: a glyph column, a checkmark on the
5405                // current align/style, and the destructive group in red.
5406                let danger = st.map_or(rgb(0xE5484D).into(), |s| s.popover_danger);
5407                let cur_align = self.caret_table_align();
5408                let cur_style = self
5409                    .caret_table_region()
5410                    .map(|r| r.style)
5411                    .unwrap_or_default();
5412                use markdown_syntax::TableStyle as TS;
5413                enum Row {
5414                    Div,
5415                    Item {
5416                        glyph: &'static str,
5417                        label: SharedString,
5418                        action: TableMenuAction,
5419                        red: bool,
5420                        checked: bool,
5421                    },
5422                }
5423                let item = |glyph, label, action| Row::Item {
5424                    glyph,
5425                    label,
5426                    action,
5427                    red: false,
5428                    checked: false,
5429                };
5430                let specs = [
5431                    item(
5432                        "↑",
5433                        self.labels.insert_row_above.clone(),
5434                        TableMenuAction::InsertRowAbove,
5435                    ),
5436                    item(
5437                        "↓",
5438                        self.labels.insert_row_below.clone(),
5439                        TableMenuAction::InsertRowBelow,
5440                    ),
5441                    item(
5442                        "⧉",
5443                        self.labels.duplicate_row.clone(),
5444                        TableMenuAction::DuplicateRow,
5445                    ),
5446                    Row::Div,
5447                    item(
5448                        "←",
5449                        self.labels.insert_column_left.clone(),
5450                        TableMenuAction::InsertColLeft,
5451                    ),
5452                    item(
5453                        "→",
5454                        self.labels.insert_column_right.clone(),
5455                        TableMenuAction::InsertColRight,
5456                    ),
5457                    Row::Div,
5458                    Row::Item {
5459                        glyph: "",
5460                        label: self.labels.align_left.clone(),
5461                        action: TableMenuAction::AlignLeft,
5462                        red: false,
5463                        checked: cur_align == Some(CellAlign::Left),
5464                    },
5465                    Row::Item {
5466                        glyph: "",
5467                        label: self.labels.align_center.clone(),
5468                        action: TableMenuAction::AlignCenter,
5469                        red: false,
5470                        checked: cur_align == Some(CellAlign::Center),
5471                    },
5472                    Row::Item {
5473                        glyph: "",
5474                        label: self.labels.align_right.clone(),
5475                        action: TableMenuAction::AlignRight,
5476                        red: false,
5477                        checked: cur_align == Some(CellAlign::Right),
5478                    },
5479                    Row::Div,
5480                    Row::Item {
5481                        glyph: "▦",
5482                        label: self.labels.grid_style.clone(),
5483                        action: TableMenuAction::SetStyle(None),
5484                        red: false,
5485                        checked: cur_style == TS::Grid,
5486                    },
5487                    Row::Item {
5488                        glyph: "▤",
5489                        label: self.labels.striped_style.clone(),
5490                        action: TableMenuAction::SetStyle(Some("striped")),
5491                        red: false,
5492                        checked: cur_style == TS::Striped,
5493                    },
5494                    Row::Item {
5495                        glyph: "▥",
5496                        label: self.labels.header_style.clone(),
5497                        action: TableMenuAction::SetStyle(Some("header")),
5498                        red: false,
5499                        checked: cur_style == TS::Header,
5500                    },
5501                    Row::Item {
5502                        glyph: "─",
5503                        label: self.labels.minimal_style.clone(),
5504                        action: TableMenuAction::SetStyle(Some("minimal")),
5505                        red: false,
5506                        checked: cur_style == TS::Minimal,
5507                    },
5508                    Row::Div,
5509                    item(
5510                        "⊞",
5511                        self.labels.copy_as_markdown.clone(),
5512                        TableMenuAction::CopyTable,
5513                    ),
5514                    Row::Div,
5515                    Row::Item {
5516                        glyph: "✕",
5517                        label: self.labels.delete_row.clone(),
5518                        action: TableMenuAction::DeleteRow,
5519                        red: true,
5520                        checked: false,
5521                    },
5522                    Row::Item {
5523                        glyph: "✕",
5524                        label: self.labels.delete_column.clone(),
5525                        action: TableMenuAction::DeleteColumn,
5526                        red: true,
5527                        checked: false,
5528                    },
5529                    Row::Item {
5530                        glyph: "✕",
5531                        label: self.labels.delete_table.clone(),
5532                        action: TableMenuAction::DeleteTable,
5533                        red: true,
5534                        checked: false,
5535                    },
5536                ];
5537                let n_items = specs
5538                    .iter()
5539                    .filter(|r| matches!(r, Row::Item { .. }))
5540                    .count();
5541                let n_divs = specs.len() - n_items;
5542                let mut rows: Vec<gpui::AnyElement> = Vec::new();
5543                for (i, spec) in specs.into_iter().enumerate() {
5544                    match spec {
5545                        Row::Div => rows.push(
5546                            div()
5547                                .flex_shrink_0()
5548                                .h(px(1.))
5549                                .my(px(4.))
5550                                .mx(px(8.))
5551                                .bg(divider)
5552                                .into_any_element(),
5553                        ),
5554                        Row::Item {
5555                            glyph,
5556                            label,
5557                            action,
5558                            red,
5559                            checked,
5560                        } => {
5561                            let fg = if red { danger } else { menu_fg };
5562                            let mut glyph_c = fg;
5563                            glyph_c.a *= 0.7;
5564                            rows.push(
5565                                div()
5566                                    .id(("table-menu-row", i))
5567                                    .flex_shrink_0()
5568                                    .px(px(10.))
5569                                    .py(px(3.))
5570                                    .flex()
5571                                    .flex_row()
5572                                    .items_center()
5573                                    .gap(px(6.))
5574                                    .text_color(fg)
5575                                    .hover(move |s| s.bg(hover))
5576                                    .child(
5577                                        div()
5578                                            .w(px(16.))
5579                                            .flex_none()
5580                                            .text_color(glyph_c)
5581                                            .child(glyph),
5582                                    )
5583                                    .child(div().flex_1().child(label))
5584                                    .children(checked.then(|| div().text_color(glyph_c).child("✓")))
5585                                    .on_mouse_down(
5586                                        MouseButton::Left,
5587                                        cx.listener(move |editor, _: &MouseDownEvent, _, cx| {
5588                                            cx.stop_propagation();
5589                                            action.apply(editor, cx);
5590                                        }),
5591                                    )
5592                                    .into_any_element(),
5593                            );
5594                        }
5595                    }
5596                }
5597                // Scrollbar thumb, shown when the items overflow the cap — sized from
5598                // the content height + positioned from the live scroll offset.
5599                let rows_h = n_items as f32 * ROW_H + n_divs as f32 * DIV_H;
5600                let view_h = MAX_H - 2.0 * PAD;
5601                let thumb = (rows_h > view_h).then(|| {
5602                    let scrolled =
5603                        (-f32::from(self.table_menu_scroll.offset().y)).clamp(0.0, rows_h - view_h);
5604                    let thumb_h = (view_h * view_h / rows_h).max(24.0);
5605                    let thumb_top = PAD + scrolled / (rows_h - view_h) * (view_h - thumb_h);
5606                    div()
5607                        .absolute()
5608                        .top(px(thumb_top))
5609                        .right(px(2.))
5610                        .w(px(6.))
5611                        .h(px(thumb_h))
5612                        .rounded(px(3.))
5613                        .bg(thumb_c)
5614                });
5615                gpui::deferred(
5616                    gpui::anchored().position(anchor).snap_to_window().child(
5617                        div()
5618                            .relative()
5619                            .occlude()
5620                            .min_w(px(190.))
5621                            .cursor(CursorStyle::Arrow)
5622                            .bg(menu_bg)
5623                            .border_1()
5624                            .border_color(menu_border)
5625                            .rounded(px(6.))
5626                            .shadow_md()
5627                            .overflow_hidden()
5628                            .text_color(menu_fg)
5629                            .text_size(px(13.))
5630                            .on_mouse_down_out(cx.listener(|editor, _: &MouseDownEvent, _, cx| {
5631                                editor.table_menu = None;
5632                                cx.notify();
5633                            }))
5634                            .child(
5635                                // Inner scroll viewport: caps the height + scrolls the
5636                                // overflow (max_h on a separate flex-col div, like the
5637                                // suggestion menu — combining it with the styled box
5638                                // above doesn't cap).
5639                                div()
5640                                    .id("table-menu")
5641                                    .max_h(px(MAX_H))
5642                                    .overflow_y_scroll()
5643                                    .track_scroll(&self.table_menu_scroll)
5644                                    .flex()
5645                                    .flex_col()
5646                                    .py(px(PAD))
5647                                    .children(rows),
5648                            )
5649                            .children(thumb),
5650                    ),
5651                )
5652            }))
5653            // The image right-click menu: Word-style object actions on an inline
5654            // image (Delete), anchored at the click. Chrome matches the table menu.
5655            .children(self.prop_menu.map(|(row, anchor)| {
5656                let st = self.markdown_style.as_ref();
5657                let menu_bg = st.map_or(rgb(0x26262b).into(), |s| s.popover_bg);
5658                let menu_border = st.map_or(rgb(0x45454c).into(), |s| s.popover_border);
5659                let menu_fg = st.map_or(rgb(0xe6e6e6).into(), |s| s.popover_fg);
5660                let hover = st.map_or(rgba(0x2f6fd628).into(), |s| s.popover_hover);
5661                let item = |id: &'static str, label: SharedString| {
5662                    div()
5663                        .id(id)
5664                        .px(px(10.))
5665                        .py(px(3.))
5666                        .hover(move |s| s.bg(hover))
5667                        .child(label)
5668                };
5669                gpui::deferred(
5670                    gpui::anchored().position(anchor).snap_to_window().child(
5671                        div()
5672                            .occlude()
5673                            .min_w(px(160.))
5674                            .cursor(CursorStyle::Arrow)
5675                            .bg(menu_bg)
5676                            .border_1()
5677                            .border_color(menu_border)
5678                            .rounded(px(6.))
5679                            .shadow_md()
5680                            .overflow_hidden()
5681                            .text_color(menu_fg)
5682                            .text_size(px(13.))
5683                            .py(px(4.))
5684                            .on_mouse_down_out(cx.listener(|editor, _: &MouseDownEvent, _, cx| {
5685                                editor.prop_menu = None;
5686                                cx.notify();
5687                            }))
5688                            .child(
5689                                item("prop-menu-edit", self.labels.edit_properties.clone())
5690                                    .on_mouse_down(
5691                                        MouseButton::Left,
5692                                        cx.listener(move |editor, _: &MouseDownEvent, _, cx| {
5693                                            cx.stop_propagation();
5694                                            editor.prop_menu = None;
5695                                            if let Some((range, source)) =
5696                                                editor.property_block_at(row)
5697                                            {
5698                                                let block_row = row - editor.row_col(range.start).0;
5699                                                cx.emit(EditorEvent::EditProperties {
5700                                                    range,
5701                                                    source,
5702                                                    at_end: false,
5703                                                    row: Some(block_row),
5704                                                });
5705                                            }
5706                                        }),
5707                                    ),
5708                            )
5709                            .child(
5710                                item("prop-menu-delete", self.labels.delete_property.clone())
5711                                    .on_mouse_down(
5712                                        MouseButton::Left,
5713                                        cx.listener(move |editor, _: &MouseDownEvent, _, cx| {
5714                                            cx.stop_propagation();
5715                                            editor.prop_menu = None;
5716                                            editor.delete_property_row(row, cx);
5717                                        }),
5718                                    ),
5719                            ),
5720                    ),
5721                )
5722            }))
5723            .children(self.image_menu.map(|(line, anchor)| {
5724                let st = self.markdown_style.as_ref();
5725                let menu_bg = st.map_or(rgb(0x26262b).into(), |s| s.popover_bg);
5726                let menu_border = st.map_or(rgb(0x45454c).into(), |s| s.popover_border);
5727                let menu_fg = st.map_or(rgb(0xe6e6e6).into(), |s| s.popover_fg);
5728                let hover = st.map_or(rgba(0x2f6fd628).into(), |s| s.popover_hover);
5729                gpui::deferred(
5730                    gpui::anchored().position(anchor).snap_to_window().child(
5731                        div()
5732                            .occlude()
5733                            .min_w(px(140.))
5734                            .cursor(CursorStyle::Arrow)
5735                            .bg(menu_bg)
5736                            .border_1()
5737                            .border_color(menu_border)
5738                            .rounded(px(6.))
5739                            .shadow_md()
5740                            .overflow_hidden()
5741                            .text_color(menu_fg)
5742                            .text_size(px(13.))
5743                            .py(px(4.))
5744                            .on_mouse_down_out(cx.listener(|editor, _: &MouseDownEvent, _, cx| {
5745                                editor.image_menu = None;
5746                                cx.notify();
5747                            }))
5748                            .child(
5749                                div()
5750                                    .id("image-menu-delete")
5751                                    .px(px(10.))
5752                                    .py(px(3.))
5753                                    .hover(move |s| s.bg(hover))
5754                                    .child(self.labels.delete_image.clone())
5755                                    .on_mouse_down(
5756                                        MouseButton::Left,
5757                                        cx.listener(move |editor, _: &MouseDownEvent, _, cx| {
5758                                            cx.stop_propagation();
5759                                            editor.image_menu = None;
5760                                            editor.delete_image_row(line, cx);
5761                                        }),
5762                                    ),
5763                            ),
5764                    ),
5765                )
5766            }))
5767            .children(self.code_lang_menu.map(|(row, anchor)| {
5768                // The code block's language picker (Cditor-inspired): the host's
5769                // highlighter languages, scrollable past the cap, current one
5770                // checked. Selecting rewrites the opening fence (one undo step).
5771                let st = self.markdown_style.as_ref();
5772                let menu_bg = st.map_or(rgb(0x26262b).into(), |s| s.popover_bg);
5773                let menu_border = st.map_or(rgb(0x45454c).into(), |s| s.popover_border);
5774                let menu_fg = st.map_or(rgb(0xe6e6e6).into(), |s| s.popover_fg);
5775                let hover = st.map_or(rgba(0x2f6fd628).into(), |s| s.popover_hover);
5776                let mut thumb_c = st.map_or(rgba(0xffffff66).into(), |s| s.marker);
5777                thumb_c.a = 0.5;
5778                let current = self.code_block_at(row).map(|(l, _)| l).unwrap_or_default();
5779                const ROW_H: f32 = 22.0;
5780                const MAX_H: f32 = 260.0;
5781                const PAD: f32 = 4.0;
5782                let langs = self.code_langs.clone();
5783                let rows_h = langs.len() as f32 * ROW_H;
5784                let view_h = MAX_H - 2.0 * PAD;
5785                let thumb = (rows_h > view_h).then(|| {
5786                    let scrolled =
5787                        (-f32::from(self.code_lang_scroll.offset().y)).clamp(0.0, rows_h - view_h);
5788                    let thumb_h = (view_h * view_h / rows_h).max(24.0);
5789                    let thumb_top = PAD + scrolled / (rows_h - view_h) * (view_h - thumb_h);
5790                    div()
5791                        .absolute()
5792                        .top(px(thumb_top))
5793                        .right(px(2.))
5794                        .w(px(6.))
5795                        .h(px(thumb_h))
5796                        .rounded(px(3.))
5797                        .bg(thumb_c)
5798                });
5799                gpui::deferred(
5800                    gpui::anchored().position(anchor).snap_to_window().child(
5801                        div()
5802                            .relative()
5803                            .occlude()
5804                            .min_w(px(140.))
5805                            .cursor(CursorStyle::Arrow)
5806                            .bg(menu_bg)
5807                            .border_1()
5808                            .border_color(menu_border)
5809                            .rounded(px(6.))
5810                            .shadow_md()
5811                            .overflow_hidden()
5812                            .text_color(menu_fg)
5813                            .text_size(px(13.))
5814                            .py(px(PAD))
5815                            .on_mouse_down_out(cx.listener(|editor, _: &MouseDownEvent, _, cx| {
5816                                editor.code_lang_menu = None;
5817                                cx.notify();
5818                            }))
5819                            .child(
5820                                div()
5821                                    .id("code-lang-list")
5822                                    .max_h(px(MAX_H - 2.0 * PAD))
5823                                    .overflow_y_scroll()
5824                                    .track_scroll(&self.code_lang_scroll)
5825                                    .children(langs.into_iter().enumerate().map(|(i, lang)| {
5826                                        let is_current = *lang == current
5827                                            || (current.is_empty() && *lang == *"text");
5828                                        let label: SharedString = if is_current {
5829                                            format!("{lang} ✓").into()
5830                                        } else {
5831                                            lang.clone()
5832                                        };
5833                                        div()
5834                                            .id(("code-lang-row", i))
5835                                            .flex_shrink_0()
5836                                            .h(px(ROW_H))
5837                                            .px(px(10.))
5838                                            .py(px(2.))
5839                                            .hover(move |s| s.bg(hover))
5840                                            .child(label)
5841                                            .on_mouse_down(
5842                                                MouseButton::Left,
5843                                                cx.listener(
5844                                                    move |editor, _: &MouseDownEvent, _, cx| {
5845                                                        cx.stop_propagation();
5846                                                        editor.code_lang_menu = None;
5847                                                        editor.set_code_lang(row, &lang, cx);
5848                                                    },
5849                                                ),
5850                                            )
5851                                            .into_any_element()
5852                                    })),
5853                            )
5854                            .children(thumb),
5855                    ),
5856                )
5857            }))
5858    }
5859}
5860
5861/// The shaped width of `text` at `font_size` — used to inset a gutter line's body
5862/// to exactly where its (hidden) source prefix ends, so the rendered + raw views
5863/// line up (and tab/space nesting matches the actual whitespace width).
5864fn measure_width(window: &mut Window, text: &str, font: &Font, font_size: Pixels) -> Pixels {
5865    if text.is_empty() {
5866        return px(0.);
5867    }
5868    let run = TextRun {
5869        len: text.len(),
5870        font: font.clone(),
5871        color: Hsla::default(),
5872        background_color: None,
5873        underline: None,
5874        strikethrough: None,
5875    };
5876    window
5877        .text_system()
5878        .shape_line(
5879            SharedString::from(text.to_string()),
5880            font_size,
5881            &[run],
5882            None,
5883        )
5884        .width()
5885}
5886
5887/// Shape `text` with pre-built `runs`, so diagnostics can underline specific
5888/// spans. The plain-run [`shape_all`] is used for the placeholder + measurement.
5889fn shape_runs(
5890    window: &mut Window,
5891    text: &SharedString,
5892    font_size: Pixels,
5893    runs: &[TextRun],
5894    wrap_width: Option<Pixels>,
5895) -> Vec<WrappedLine> {
5896    window
5897        .text_system()
5898        .shape_text(text.clone(), font_size, runs, wrap_width, None)
5899        .map(|lines| lines.into_vec())
5900        .unwrap_or_default()
5901}
5902
5903/// A line currently rendered as an inline image (W4) instead of its source text:
5904/// the decoded image plus its fit-to-width display size (logical px).
5905#[derive(Clone)]
5906struct BlockImg {
5907    img: Arc<RenderImage>,
5908    width: Pixels,
5909    height: Pixels,
5910    /// Whether to show a corner resize grip. `false` for math (nothing to persist a
5911    /// `{width=N}` to, and it renders at its natural typeset size); `true` for images.
5912    resizable: bool,
5913    /// Horizontal alignment in the content width. `Left` for images; display math sets its
5914    /// own (centered by default).
5915    align: MathAlign,
5916}
5917
5918/// One inline `$…$` formula painted within a text line. `display_off` is the byte offset of its
5919/// invisible spacer in the shaped DISPLAY string (resolved to an x via the wrapped line at
5920/// paint); `source` is the formula's byte range within the *source line* (to hit-test a click
5921/// back to its edit range); `img`/`width`/`height` are the typeset raster scaled to text size.
5922#[derive(Clone)]
5923struct InlineMath {
5924    display_off: usize,
5925    /// Byte length of the spacer this raster sits over. On an RTL row
5926    /// `display_off` is its RIGHT edge, so the whole span is needed to find the
5927    /// left one — see where it is painted.
5928    len: usize,
5929    /// ABSOLUTE byte range of the `$…$` span in the document — to hit-test a click on the
5930    /// formula back to its edit range and to position the seated editor.
5931    source: Range<usize>,
5932    /// The inner LaTeX (no `$` delimiters), to seed the structural editor on click.
5933    latex: SharedString,
5934    img: Arc<RenderImage>,
5935    width: Pixels,
5936    height: Pixels,
5937}
5938
5939/// A line rendered as a block widget instead of its source text: a standalone
5940/// image, or a clickable file chip (e.g. a PDF — left-click opens it, right-click
5941/// edits). Shown only while the caret is off the line ("raw on caret").
5942#[derive(Clone)]
5943enum Block {
5944    Image(BlockImg),
5945    Chip {
5946        src: SharedString,
5947        label: SharedString,
5948        /// Label color (accent, signalling clickable), box fill, box border.
5949        link: Hsla,
5950        bg: Hsla,
5951        border: Hsla,
5952        height: Pixels,
5953        /// `src` is a wiki target (an `![[embed]]` chip → OpenWikiLink, which
5954        /// navigates + jumps to any anchor) vs a file path (→ OpenLink).
5955        wiki: bool,
5956    },
5957    /// A run of `key:: value` properties as a two-column panel (the reader's
5958    /// `render_property_table` twin). Painted on the region's first line; the
5959    /// rest of the region's lines collapse. The caret entering the region
5960    /// reveals the raw source (like a math block).
5961    Properties(PropPanel),
5962}
5963
5964/// A rendered piece of a property value in the panel: plain text, or a colored
5965/// pill (tag / wiki-link / URL).
5966#[derive(Clone)]
5967enum PanelSeg {
5968    Plain(SharedString),
5969    Pill {
5970        text: SharedString,
5971        color: Hsla,
5972        target: zorite_markdown::syntax::LinkHit,
5973    },
5974}
5975
5976/// Layout for a WYSIWYG property panel: the measured rows (key + value
5977/// segments), the column widths + per-row height, and the key/value colors. No
5978/// grid lines — rows read clean (Obsidian-style); the value's tags and
5979/// wiki-links render as pills.
5980#[derive(Clone)]
5981struct PropPanel {
5982    /// `(key, icon asset path, value segments)` per property line, in order.
5983    rows: Vec<(SharedString, Option<SharedString>, Vec<PanelSeg>)>,
5984    key_w: Pixels,
5985    /// Panel width (shared by every row) so hover borders align.
5986    width: Pixels,
5987    row_h: Pixels,
5988    height: Pixels,
5989    /// Icon draw size (0 when the host resolves no icons); the key text is inset
5990    /// by `key_indent` to leave room for it.
5991    icon_sz: Pixels,
5992    key_indent: Pixels,
5993    key_color: Hsla,
5994    value_color: Hsla,
5995    /// The rounded border drawn around the row under the pointer (Obsidian-style
5996    /// whole-row hover).
5997    hover_border: Hsla,
5998}
5999
6000impl Block {
6001    fn height(&self) -> Pixels {
6002        match self {
6003            Block::Image(i) => i.height,
6004            Block::Chip { height, .. } => *height,
6005            Block::Properties(p) => p.height,
6006        }
6007    }
6008}
6009
6010/// A fenced-code-block line's background (W4b/refinement): the block reads as one
6011/// rounded, content-fit box (sized to its widest line, like a table — not the
6012/// full editor width). Each line carries the block color, the shared box width
6013/// (back-patched once the block's extent is known), and whether it's the
6014/// first/last visible line (to round the box's top/bottom corners).
6015/// Last-frame hit rects for one code card's chrome (see `code_chip_rects`).
6016#[derive(Clone)]
6017struct CodeChipHit {
6018    lang: Bounds<Pixels>,
6019    copy: Bounds<Pixels>,
6020    fence_row: usize,
6021}
6022
6023/// A code card's top-right chrome, laid out in prepaint: the language tag and
6024/// Copy button (Cditor-inspired, issue #16). Geometry is window-space; paint
6025/// draws at these bounds and the hitboxes flip the cursor.
6026struct CodeChip {
6027    lang_text: SharedString,
6028    copy_text: SharedString,
6029    lang_bounds: Bounds<Pixels>,
6030    copy_bounds: Bounds<Pixels>,
6031    fence_row: usize,
6032    /// Card background — the labels sit on an opaque pill of it so they stay
6033    /// readable over a long first line.
6034    bg: Hsla,
6035    fg: Hsla,
6036    lang_hb: Hitbox,
6037    copy_hb: Hitbox,
6038}
6039
6040#[derive(Clone, Copy)]
6041struct CodeBg {
6042    color: Hsla,
6043    width: Pixels,
6044    top: bool,
6045    bottom: bool,
6046}
6047
6048/// A table row rendered as a grid (W4c): its cells, per-column alignment, the
6049/// content-fit per-column widths (shared across the table), header/separator/
6050/// last-row flags, and the border color. Built only when the caret is outside
6051/// the table — the caret's table shows source instead ("raw on caret").
6052#[derive(Clone)]
6053struct TableRow {
6054    cells: Vec<SharedString>,
6055    /// Byte range of each cell's trimmed content within its source line — for
6056    /// placing the caret inside a cell + hit-testing a click back to a source
6057    /// offset (in-cell editing).
6058    cell_ranges: Vec<Range<usize>>,
6059    aligns: Vec<markdown_syntax::Align>,
6060    col_widths: Vec<Pixels>,
6061    is_header: bool,
6062    is_separator: bool,
6063    is_last: bool,
6064    /// 0-based position among the body rows (`None` for header/separator) — drives
6065    /// striping (shade odd indices) + the rule-under-header (index 0).
6066    body_index: Option<usize>,
6067    /// The table's visual style (from its `<!-- table:STYLE -->` marker).
6068    style: markdown_syntax::TableStyle,
6069    border: Hsla,
6070    /// Row-shade color for striped / header-shaded styles (a faint tint).
6071    shade: Hsla,
6072    /// The table reads right-to-left (#66) — from its region's
6073    /// [`markdown_syntax::TableRegion::rtl`], so every row of one table agrees.
6074    rtl: bool,
6075}
6076
6077/// A per-line "gutter" decoration: a left-margin treatment that hides its source
6078/// marker and renders something in its place, with the body text inset to make
6079/// Task checkbox edge, as a fraction of the line's font size — shared by the
6080/// shaping (body inset), the pointer hitbox, and the paint so they agree.
6081const CHECKBOX_SCALE: f32 = 0.9;
6082
6083/// room. Covers blockquotes now; list bullets + task checkboxes reuse it.
6084#[derive(Clone, Copy)]
6085enum LineMark {
6086    /// Blockquote: a left border (`bar`); the `>` markers are hidden. `text`
6087    /// colors the body — `Some` = muted quote tone, `None` = the editor's
6088    /// normal text color (alert bodies).
6089    Quote { bar: Hsla, text: Option<Hsla> },
6090    /// A GitHub alert's marker line (`> [!NOTE]` …): the marker is hidden and
6091    /// a bold `label` paints in the alert color; any same-line body insets to
6092    /// `text_inset` (QUOTE_INSET + the label's measured width + a gap) — the
6093    /// list-bullet pattern. Continuation lines are `Quote` marks with the
6094    /// alert's bar color.
6095    Alert {
6096        bar: Hsla,
6097        label: &'static str,
6098        kind: markdown_syntax::AlertKind,
6099        text_inset: Pixels,
6100        /// Foldable callout (`[!NOTE]-`/`+`): `Some(true)` = folded. A chevron
6101        /// paints at `chevron_x` (after the label) and clicking it flips the
6102        /// fold char in the source.
6103        fold: Option<bool>,
6104        chevron_x: Pixels,
6105    },
6106    /// List item: a painted bullet (`•`) or number (`N.`) at `bullet_x` (where the
6107    /// hidden source marker began), muted; the body sits at `text_inset` — the
6108    /// measured width of the whole source prefix, so the rendered + raw views
6109    /// line up exactly and tab/space nesting stays in sync.
6110    List {
6111        bullet_x: Pixels,
6112        text_inset: Pixels,
6113        ordered: bool,
6114        num: u32,
6115        /// Structural nesting level (0 = top), for the Word-style marker
6116        /// scheme (`1.` -> `a.` -> `i.`).
6117        level: usize,
6118        color: Hsla,
6119    },
6120    /// GFM task item: a painted ☐/☑ box at `bullet_x`, muted; the body sits at
6121    /// `text_inset` (measured prefix width) like a list item.
6122    Check {
6123        bullet_x: Pixels,
6124        text_inset: Pixels,
6125        checked: bool,
6126        color: Hsla,
6127        /// Fill for a done box (the host's link/accent color; white check on top).
6128        accent: Hsla,
6129    },
6130    /// Thematic break (`---`): a full-width muted divider painted in place of the
6131    /// source; the line has no body text (reveal-on-caret shows the raw `---`).
6132    Rule(Hsla),
6133}
6134
6135impl LineMark {
6136    /// Horizontal inset (px) applied to the body text + caret for this mark.
6137    fn inset(self) -> Pixels {
6138        match self {
6139            LineMark::Quote { .. } => px(QUOTE_INSET),
6140            LineMark::Alert { text_inset, .. } => text_inset,
6141            LineMark::List { text_inset, .. } | LineMark::Check { text_inset, .. } => text_inset,
6142            LineMark::Rule(_) => px(0.),
6143        }
6144    }
6145}
6146
6147/// Per-logical-line shaping output — parallel vecs of equal length: the shaped
6148/// source line, its row height, an optional inline-image widget, an optional
6149/// fenced-code-block background, an optional table-row grid, the display→source
6150/// map, and an optional gutter decoration (blockquote / list / checkbox).
6151/// One shaped document: per-line parallel channels, all the same length —
6152/// the per-line loop's normal push and [`ShapedDoc::push_placeholder`] are
6153/// the only writers, so the lockstep invariant lives here.
6154#[derive(Default)]
6155struct ShapedDoc {
6156    wrapped: Vec<WrappedLine>,
6157    heights: Vec<Pixels>,
6158    widgets: Vec<Option<Block>>,
6159    backgrounds: Vec<Option<CodeBg>>,
6160    tables: Vec<Option<TableRow>>,
6161    /// Per-line display→source byte map for lines with markers hidden (W6);
6162    /// `None` when the displayed text equals the source. Shared with the
6163    /// line-run cache (a hit re-uses the same allocation across frames).
6164    maps: Vec<Option<std::rc::Rc<Vec<usize>>>>,
6165    marks: Vec<Option<LineMark>>,
6166    /// Per-line inline `$…$` formulas painted over spacers (empty when none).
6167    inline_maths: Vec<Vec<InlineMath>>,
6168    /// Per-line wrap-row count. Geometry (line tops, total height) reads THIS,
6169    /// not `wrap_boundaries()` — a windowed-out line's `WrappedLine` is an
6170    /// empty placeholder, but its cached count keeps the layout exact. For an
6171    /// RTL line it is OUR row count, which is not gpui's (#66).
6172    wrap_rows: Vec<usize>,
6173    /// Per-line bidi layout: whether the line READS right-to-left, plus the
6174    /// rows we broke in logical order. `None` for lines with no RTL at all,
6175    /// which keep gpui's own wrapping. Drives that line's paint, caret,
6176    /// selection and hit-testing.
6177    rtl_rows: Vec<Option<(bool, Vec<gpui_bidi::paragraph::Row>)>>,
6178}
6179
6180impl ShapedDoc {
6181    /// Push one line that renders as something other than shaped text — a
6182    /// widget/collapsed/windowed-out line: an empty placeholder `WrappedLine`,
6183    /// the given height/widget/mark, and `rows` wrap rows.
6184    #[allow(clippy::too_many_arguments)]
6185    fn push_placeholder(
6186        &mut self,
6187        window: &mut Window,
6188        base_font_size: Pixels,
6189        wrap_width: Option<Pixels>,
6190        h: Pixels,
6191        widget: Option<Block>,
6192        mark: Option<LineMark>,
6193        rows: usize,
6194    ) {
6195        let wl = shape_runs(
6196            window,
6197            &SharedString::default(),
6198            base_font_size,
6199            &[],
6200            wrap_width,
6201        )
6202        .into_iter()
6203        .next()
6204        .expect("a line always shapes to one wrapped line");
6205        self.wrapped.push(wl);
6206        self.heights.push(h);
6207        self.widgets.push(widget);
6208        self.backgrounds.push(None);
6209        self.tables.push(None);
6210        self.maps.push(None);
6211        self.marks.push(mark);
6212        self.inline_maths.push(Vec::new());
6213        self.wrap_rows.push(rows);
6214        self.rtl_rows.push(None);
6215    }
6216}
6217
6218/// Rewrite an image source `line` to carry an explicit `{width=N}` after the
6219/// `![alt](src)` (replacing any existing `{width=...}`), preserving a leading
6220/// list marker and any trailing whitespace. Used to persist a corner-grip resize
6221/// back into the document. Returns `line` unchanged if it isn't an image row.
6222fn set_image_width(line: &str, width: u32) -> String {
6223    let Some((_, _, marker_len)) = markdown_syntax::image_row(line) else {
6224        return line.to_string();
6225    };
6226    // Split off any trailing whitespace so the attr lands right after `)` (or the
6227    // existing `{width=…}`), with the original trailing run re-appended.
6228    let trimmed_end = line.trim_end_matches([' ', '\t']);
6229    let trailing_ws = &line[trimmed_end.len()..];
6230    // The image body always ends at the first `)` after the list marker; an
6231    // existing `{width=…}` (only valid right after it) is dropped.
6232    let close = marker_len + line[marker_len..].find(')').map_or(0, |i| i + 1);
6233    let body = trimmed_end[..close.min(trimmed_end.len())].trim_end();
6234    format!("{body}{{width={width}}}{trailing_ws}")
6235}
6236
6237/// Invert a display→source offset map: the display column for `source_col`. The
6238/// map is ascending, so a source column that is hidden (a collapsed marker)
6239/// snaps to the next visible display column. `None` map → identity (a row shown
6240/// as full source). The prepaint cursor/selection pass this frame's fresh map
6241/// (the committed `EditorState::offset_maps` lags a frame); event handlers go
6242/// through [`EditorState::display_col`], which uses the committed map.
6243fn display_col_in(map: Option<&std::rc::Rc<Vec<usize>>>, source_col: usize) -> usize {
6244    match map {
6245        // The first display byte whose source ≥ `source_col` (a leftmost lower-bound). Unlike
6246        // `binary_search`, this is deterministic when several display bytes share one source
6247        // offset — an inline `$…$` spacer maps its whole width to the span start, so the caret
6248        // just before the formula must land at the spacer's LEFT edge, not somewhere inside it.
6249        Some(m) => m.partition_point(|&s| s < source_col),
6250        None => source_col,
6251    }
6252}
6253
6254/// The painted position of display column `dcol` on a shaped line: gpui's own
6255/// lookup, with the x taken from the row's bidi map when it has one (#66).
6256///
6257/// gpui's `x_for_index` returns the first glyph whose `index >= dcol`, and the
6258/// first glyph of an RTL line carries the HIGHEST index — so every offset in
6259/// the line collapses onto x = 0. The y (which wrap row) stays gpui's; a row
6260/// only gets a map while it is ONE visual row, so it is always 0 there.
6261/// Excludes the row's insets — callers add [`EditorState::row_origin_x`].
6262fn line_pos(
6263    line: &WrappedLine,
6264    rtl: Option<&RtlRow>,
6265    dcol: usize,
6266    lh: Pixels,
6267) -> Option<Point<Pixels>> {
6268    match rtl {
6269        // Our own rows: which one holds the offset decides the y, and the
6270        // row's map the x. gpui's layout is not consulted at all — its rows
6271        // are not ours.
6272        Some(r) => {
6273            let (row, local) = r.row_of(dcol);
6274            let x = px(r.rows.get(row)?.map.x_for_index(local)) + r.shift_delta(row);
6275            Some(point(x, lh * row))
6276        }
6277        None => line.position_for_index(dcol, lh),
6278    }
6279}
6280
6281/// The display column a click at row-local `p` names — the inverse of
6282/// [`line_pos`], through the bidi map when the row has one (gpui's
6283/// `closest_index_for_x` fails on RTL the same way `x_for_index` does).
6284fn line_index_at(line: &WrappedLine, rtl: Option<&RtlRow>, p: Point<Pixels>, lh: Pixels) -> usize {
6285    match rtl {
6286        Some(r) if !r.rows.is_empty() => {
6287            let row = ((f32::from(p.y) / f32::from(lh).max(1.0)).floor().max(0.) as usize)
6288                .min(r.rows.len() - 1);
6289            let x = p.x - r.shift_delta(row);
6290            let Some(rr) = r.rows.get(row) else {
6291                return 0;
6292            };
6293            rr.start + rr.map.index_for_x(f32::from(x))
6294        }
6295        _ => match line.closest_index_for_position(p, lh) {
6296            Ok(i) | Err(i) => i,
6297        },
6298    }
6299}
6300
6301/// A right-to-left row's editor-side geometry, built in prepaint (#66).
6302///
6303/// Only rows whose source reads RTL ([`zorite_markdown::syntax::base_direction`])
6304/// get one — the flag *is* `Option::is_some`, so an LTR document allocates
6305/// nothing and keeps taking gpui's own (cheaper) lookups.
6306pub(crate) struct RtlRow {
6307    /// The line's visual rows, broken in LOGICAL order (gpui's own wrapping
6308    /// slices the reordered glyph run and gets them backwards). Each carries
6309    /// the map that turns an offset inside it into an x and back.
6310    rows: Vec<gpui_bidi::paragraph::Row>,
6311    /// Each row's right-align shift, parallel to `rows`: added to the text
6312    /// origin so the row right-aligns in the content width (see [`rtl_shift`]).
6313    /// Per ROW, not per line — a short last row shifts further than a full one.
6314    /// Kept OUT of `line_insets` on purpose: the list-marker + gutter math
6315    /// reads that, and must not move with the text.
6316    shifts: Vec<Pixels>,
6317    /// Does the line READ right-to-left? A left-to-right line containing a
6318    /// Persian phrase gets rows and maps too, but stays left-aligned and keeps
6319    /// left-to-right arrow keys.
6320    base_rtl: bool,
6321}
6322
6323impl RtlRow {
6324    /// The row holding display column `dcol`, and the column's offset within
6325    /// it. Rows are in reading order and contiguous, so the last row wins for
6326    /// an offset at the very end of the line.
6327    fn row_of(&self, dcol: usize) -> (usize, usize) {
6328        row_of_spans(self.rows.iter().map(|r| (r.start, r.len)), dcol)
6329    }
6330
6331    /// A row's horizontal extent (left, right) relative to the FIRST row's
6332    /// origin — the coordinate space callers already work in, since they add
6333    /// `row_origin_x`. Used to band a selection across a row: an RTL row does
6334    /// not span the content width, so "the whole row" is this, not 0..width.
6335    pub(crate) fn row_extent(&self, row: usize) -> (Pixels, Pixels) {
6336        let d = self.shift_delta(row);
6337        (d, d + self.rows.get(row).map_or(px(0.), |r| r.width))
6338    }
6339
6340    /// How many visual rows this line broke into.
6341    pub(crate) fn row_count(&self) -> usize {
6342        self.rows.len()
6343    }
6344
6345    /// A row's shift relative to the FIRST row's. Callers already add
6346    /// `row_origin_x`, which carries row 0's shift, so this is the remainder —
6347    /// that keeps every existing caller correct without threading a wrap-row
6348    /// index through all of them.
6349    fn shift_delta(&self, row: usize) -> Pixels {
6350        self.shifts.get(row).copied().unwrap_or(px(0.))
6351            - self.shifts.first().copied().unwrap_or(px(0.))
6352    }
6353}
6354
6355/// Which row holds display column `dcol`, and its offset within that row.
6356///
6357/// Split out from [`RtlRow::row_of`] so it can be tested without a window: a
6358/// `Row` carries a shaped line, which needs one. Rows are contiguous and in
6359/// reading order, so an offset at the very end of the line lands on the last
6360/// row rather than falling off the end — that is where the caret sits after
6361/// typing at the end of a paragraph.
6362fn row_of_spans(rows: impl Iterator<Item = (usize, usize)>, dcol: usize) -> (usize, usize) {
6363    let mut last = (0, dcol);
6364    let mut seen = false;
6365    for (i, (start, len)) in rows.enumerate() {
6366        seen = true;
6367        last = (i, dcol.saturating_sub(start));
6368        if dcol < start + len {
6369            return (i, dcol - start);
6370        }
6371    }
6372    if seen { last } else { (0, dcol) }
6373}
6374
6375/// The x a right-to-left row's text starts at within `content_width`, so its
6376/// *trailing* edge sits `inset` in from the right — the mirror of the leading
6377/// `inset` an LTR row gets. Zero once the text no longer fits (it wraps, and
6378/// every wrap row starts at the left edge).
6379///
6380/// Callers add this to the origin they already inset, hence the doubled
6381/// `inset`: `origin + inset + shift` lands the text `inset` from the right.
6382fn rtl_shift(content_width: Pixels, inset: Pixels, line_width: Pixels) -> Pixels {
6383    (content_width - inset * 2. - line_width).max(px(0.))
6384}
6385
6386/// Mirror a gutter marker's x (a bullet, a number, a checkbox) to the right
6387/// edge for an RTL row, so it sits on the side the text now starts at —
6388/// matching the reader's `flex_row_reverse` list items. Nesting is preserved:
6389/// a deeper level's larger `marker_x` indents further from the right.
6390fn rtl_marker_x(content_width: Pixels, marker_x: Pixels, marker_width: Pixels) -> Pixels {
6391    (content_width - marker_x - marker_width).max(px(0.))
6392}
6393
6394/// A task row's checkbox x within the content width, mirrored on an RTL row.
6395/// One function so the prepaint hitbox (the hand cursor) and the paint (the
6396/// box, and the rects a click hit-tests) can never land on different sides.
6397fn checkbox_x(bullet_x: Pixels, size: Pixels, content_width: Pixels, rtl: bool) -> Pixels {
6398    if rtl {
6399        rtl_marker_x(content_width, bullet_x, size)
6400    } else {
6401        bullet_x
6402    }
6403}
6404
6405/// Case-insensitive occurrences of `query` in `content`, as source byte
6406/// ranges — the match list a find bar feeds to [`EditorState::set_search`].
6407/// Unicode-aware (comparison happens on lowercased text through an index map
6408/// back to original byte offsets). An empty query matches nothing.
6409pub fn find_in_source(content: &str, query: &str) -> Vec<Range<usize>> {
6410    let query: String = query.chars().flat_map(char::to_lowercase).collect();
6411    if query.is_empty() {
6412        return Vec::new();
6413    }
6414    // Lowercased haystack + per-byte map back to original offsets. Boundaries
6415    // survive: each original char lowercases to >= 1 chars, all of whose bytes
6416    // map to the original char's start.
6417    let mut lower = String::with_capacity(content.len());
6418    let mut map = Vec::with_capacity(content.len() + 1);
6419    for (off, ch) in content.char_indices() {
6420        for lc in ch.to_lowercase() {
6421            lower.push(lc);
6422            map.resize(lower.len(), off);
6423        }
6424    }
6425    lower
6426        .match_indices(&query)
6427        .map(|(i, m)| map[i]..map.get(i + m.len()).copied().unwrap_or(content.len()))
6428        .collect()
6429}
6430
6431/// Paint a flat, line-art document glyph (a page with a folded top-right corner +
6432/// two text lines) in `color`, the chip's file icon. Drawn with strokes — not a
6433/// font emoji — so it reads flat and on-theme at the text's size. Public so a
6434/// host's read-only view can draw the identical icon on its own file chips
6435/// (cross-view parity).
6436pub fn paint_doc_icon(
6437    x: Pixels,
6438    y: Pixels,
6439    w: Pixels,
6440    h: Pixels,
6441    color: Hsla,
6442    window: &mut Window,
6443) {
6444    let f = w * 0.33; // folded-corner size
6445    // Page silhouette, with the top-right corner cut away for the fold.
6446    let mut outline = PathBuilder::stroke(px(1.3));
6447    outline.move_to(point(x, y));
6448    outline.line_to(point(x + w - f, y));
6449    outline.line_to(point(x + w, y + f));
6450    outline.line_to(point(x + w, y + h));
6451    outline.line_to(point(x, y + h));
6452    outline.line_to(point(x, y));
6453    if let Ok(p) = outline.build() {
6454        window.paint_path(p, color);
6455    }
6456    // The folded corner (dog-ear).
6457    let mut fold = PathBuilder::stroke(px(1.3));
6458    fold.move_to(point(x + w - f, y));
6459    fold.line_to(point(x + w - f, y + f));
6460    fold.line_to(point(x + w, y + f));
6461    if let Ok(p) = fold.build() {
6462        window.paint_path(p, color);
6463    }
6464    // Two short text lines below the fold.
6465    for fy in [0.6_f32, 0.78] {
6466        let mut ln = PathBuilder::stroke(px(1.));
6467        ln.move_to(point(x + w * 0.26, y + h * fy));
6468        ln.line_to(point(x + w * 0.74, y + h * fy));
6469        if let Ok(p) = ln.build() {
6470            window.paint_path(p, color);
6471        }
6472    }
6473}
6474
6475/// How many wrap rows table row `cells` need at `col_widths` — 1 for content
6476/// that fits; more once a drag-narrowed column forces its text to wrap.
6477/// The gutter grip's left edge for an editor whose content starts at
6478/// `bounds_left` — THE grip x formula, shared by prepaint (fresh geometry)
6479/// and the event-time hover mirror so the two can't drift.
6480fn grip_left(bounds_left: Pixels, inset: Pixels) -> Pixels {
6481    bounds_left - px(22.) - inset
6482}
6483
6484/// One markdown line's built display + runs, cached across frames (see
6485/// `EditorState::line_run_cache`). `src` and `line_base` verify a hash hit —
6486/// the rest of the inputs are folded into the key's hash.
6487struct CachedLineRuns {
6488    src: String,
6489    line_base: Hsla,
6490    /// Shared payloads: a cache HIT is three refcount bumps, not three deep
6491    /// clones (this runs per visible line per frame).
6492    disp: SharedString,
6493    runs: std::rc::Rc<Vec<TextRun>>,
6494    map: std::rc::Rc<Vec<usize>>,
6495}
6496
6497/// The validity marker + per-row content keys of the run-key memo.
6498type RowKeys = (Option<u64>, Vec<Option<u64>>);
6499
6500/// The measured table column widths cache: one keyed entry for the doc.
6501type RegionCols = Option<(u64, std::rc::Rc<Vec<Vec<Pixels>>>)>;
6502
6503/// The editor-owned caches `shape_document` reads and writes (interior-
6504/// mutable — shaping runs under a read borrow of the editor):
6505/// - `line_runs`: each markdown line's built display + runs (cross-frame).
6506/// - `region_cols`: the measured table column widths for the WHOLE document,
6507///   one keyed entry — rebuilt when the tables' source, the wrap width, the
6508///   font epoch, or a live column drag changes.
6509/// - `cell_rows`: per table row, how many wrap rows its tallest cell needs.
6510#[derive(Default)]
6511struct ShapeCaches {
6512    line_runs: std::cell::RefCell<std::collections::HashMap<u64, CachedLineRuns>>,
6513    region_cols: std::cell::RefCell<RegionCols>,
6514    cell_rows: std::cell::RefCell<std::collections::HashMap<u64, usize>>,
6515    /// Per-line (row height, wrap rows), keyed by the line-run key ⊕ font
6516    /// size ⊕ wrap width — the shaping window's exact heights for skipped
6517    /// offscreen lines.
6518    line_heights: std::cell::RefCell<std::collections::HashMap<u64, (Pixels, usize)>>,
6519    /// Per-row CONTENT-part run keys (line bytes + line_base + diags + epoch),
6520    /// valid for one (scan generation, epoch) pair — so a steady-state frame
6521    /// hashes three u64s per line instead of every line's bytes. Diagnostics
6522    /// changes invalidate explicitly (see `set_diagnostics`).
6523    row_keys: std::cell::RefCell<RowKeys>,
6524}
6525
6526/// A hash of the inputs shared by every line's run build (font + palette) —
6527/// part of the per-line cache key, so a theme or font change misses cleanly.
6528fn line_run_epoch(font: &Font, st: Option<&SyntaxStyle>) -> u64 {
6529    use std::hash::{Hash, Hasher};
6530    let mut h = std::collections::hash_map::DefaultHasher::new();
6531    font.family.hash(&mut h);
6532    font.weight.0.to_bits().hash(&mut h);
6533    let hash_hsla = |c: Hsla, h: &mut std::collections::hash_map::DefaultHasher| {
6534        c.h.to_bits().hash(h);
6535        c.s.to_bits().hash(h);
6536        c.l.to_bits().hash(h);
6537        c.a.to_bits().hash(h);
6538    };
6539    if let Some(st) = st {
6540        for c in [
6541            st.marker, st.code, st.code_bg, st.link, st.tag, st.quote, st.mark_bg,
6542        ] {
6543            hash_hsla(c, &mut h);
6544        }
6545        st.mono.family.hash(&mut h);
6546        st.block_label_gen.hash(&mut h);
6547    }
6548    h.finish()
6549}
6550
6551/// Host hook for scroll anchoring: called from the measure pass when an
6552/// ASYNC height change (a math/mermaid/image raster arriving) lands ABOVE
6553/// the window's viewport, with the height delta — the host shifts its scroll
6554/// container's offset by it so the content being read doesn't jump.
6555pub type ScrollCompensatorFn = std::rc::Rc<dyn Fn(Pixels, &mut Window, &mut App)>;
6556
6557/// Content-derived structural scans — tables, ordered-list numbering,
6558/// mermaid/math regions, property runs, foldable callouts, and per-line
6559/// fence parity — cached per [`EditorState::content_gen`]. `shape_document`
6560/// recomputed all of these on every call (twice a frame before the shape
6561/// memo); now they rebuild only when the content actually changes, and the
6562/// caret-driven table ops + auto-replace reuse the same scan.
6563pub(crate) struct ScanData {
6564    /// The `content_gen` this scan was built for — cache keys use this
6565    /// instead of rehashing content.
6566    generation: u64,
6567    ordered: Vec<(u32, usize)>,
6568    tables: Vec<markdown_syntax::TableRegion>,
6569    mermaid: Vec<(Range<usize>, String)>,
6570    math: Vec<markdown_syntax::MathRegion>,
6571    props: Vec<Range<usize>>,
6572    alert_folds: Vec<(Range<usize>, bool)>,
6573    /// Whether each line STARTS inside a fenced code block (odd count of ```
6574    /// fences above it).
6575    fence_odd: Vec<bool>,
6576}
6577
6578/// One frame's shaping, memoized between the measure pass and prepaint —
6579/// both shape the IDENTICAL inputs, so the measure's result is handed to
6580/// prepaint instead of shaping the whole document twice per frame. Consumed
6581/// (taken) by prepaint, so it can never go stale across frames; a key
6582/// mismatch (e.g. the resolved width differs from the available width)
6583/// falls back to shaping.
6584struct ShapeMemo {
6585    wrap_width: Option<Pixels>,
6586    caret_row: Option<usize>,
6587    selection: (usize, usize),
6588    font_size: Pixels,
6589    shaped: ShapedDoc,
6590}
6591
6592/// Whether a just-typed input completes a word: a single boundary character
6593/// (space / punctuation / Enter, incl. a list continuation's leading newline)
6594/// — the moment the host's auto-replace hook (page-title auto-linking) is
6595/// offered the line. `:` is NOT a boundary, so a `key:: value` property can be
6596/// typed on a key that happens to name a page — the first `:` must not wrap
6597/// the key into `[[key]]`.
6598fn word_boundary_input(new_text: &str) -> bool {
6599    match new_text.as_bytes() {
6600        [c] => !c.is_ascii_alphanumeric() && !matches!(c, b'_' | b'-' | b'/' | b'#' | b'[' | b':'),
6601        [b'\n', ..] => true,
6602        _ => false,
6603    }
6604}
6605
6606#[cfg(test)]
6607mod tests {
6608    #[test]
6609    fn caret_off_marker_line_cases() {
6610        use super::caret_off_marker_line;
6611        // Table marker at the top: the caret steps to the header line.
6612        let doc = "<!-- table:grid cols=40,40 -->\n| a | b |\n| --- | --- |\n| 1 | 2 |";
6613        assert_eq!(caret_off_marker_line(doc, 0), 31);
6614        // Math align marker: the caret lands after the block.
6615        let doc = "<!-- math:center -->\n$$\nx^2\n$$\nafter";
6616        assert_eq!(caret_off_marker_line(doc, 0), 31);
6617        assert_eq!(&doc[31..], "after");
6618        // Plain lines pass through untouched.
6619        assert_eq!(caret_off_marker_line("hello\nworld", 0), 0);
6620        assert_eq!(caret_off_marker_line("", 0), 0);
6621    }
6622
6623    #[test]
6624    fn strip_block_prefix_cases() {
6625        use super::strip_block_prefix;
6626        assert_eq!(strip_block_prefix("# Title"), "Title");
6627        assert_eq!(strip_block_prefix("### Deep"), "Deep");
6628        assert_eq!(strip_block_prefix("- [x] done"), "done");
6629        assert_eq!(strip_block_prefix("- item"), "item");
6630        assert_eq!(strip_block_prefix("12. nth"), "nth");
6631        assert_eq!(strip_block_prefix("> quoted"), "quoted");
6632        assert_eq!(strip_block_prefix(">bare"), "bare");
6633        assert_eq!(strip_block_prefix("plain"), "plain");
6634        // Renderer-grammar forms the old hand-rolled version missed.
6635        assert_eq!(strip_block_prefix("* [ ] star task"), "star task");
6636        assert_eq!(strip_block_prefix("+ [x] plus task"), "plus task");
6637        assert_eq!(strip_block_prefix("3) paren"), "paren");
6638        assert_eq!(strip_block_prefix("#### H4"), "H4");
6639        // Not block prefixes: mid-word hash runs, `#tag`, a lone dash.
6640        assert_eq!(strip_block_prefix("#tag"), "#tag");
6641        assert_eq!(strip_block_prefix("-dash"), "-dash");
6642    }
6643
6644    #[test]
6645    fn find_in_source_cases() {
6646        use super::find_in_source;
6647        assert_eq!(find_in_source("aa bb aa", "aa"), vec![0..2, 6..8]);
6648        // Case-insensitive, unicode-aware.
6649        assert_eq!(
6650            find_in_source("Grüße hier", "grüsse"),
6651            Vec::<std::ops::Range<usize>>::new()
6652        );
6653        assert_eq!(find_in_source("Grüße", "grüße"), vec![0..7]);
6654        assert_eq!(find_in_source("İstanbul", "i̇stanbul"), vec![0..9]);
6655        assert_eq!(
6656            find_in_source("abc", ""),
6657            Vec::<std::ops::Range<usize>>::new()
6658        );
6659    }
6660
6661    use super::{display_col_in, set_image_width, word_boundary_input};
6662
6663    #[test]
6664    fn word_boundaries_offer_auto_replace_but_colon_never_does() {
6665        // Space / punctuation / Enter (incl. a list continuation) complete a word.
6666        assert!(word_boundary_input(" "));
6667        assert!(word_boundary_input("."));
6668        assert!(word_boundary_input("\n"));
6669        assert!(word_boundary_input("\n- "));
6670        // Word characters and syntax openers don't.
6671        assert!(!word_boundary_input("a"));
6672        assert!(!word_boundary_input("["));
6673        assert!(!word_boundary_input("#"));
6674        // `:` must not — typing `key::` on a page-title key would otherwise
6675        // auto-link the key before the property can form.
6676        assert!(!word_boundary_input(":"));
6677    }
6678
6679    #[test]
6680    fn display_col_leftmost_for_inline_math_spacer() {
6681        // An inline `$…$` spacer maps its whole width to the span's start offset (here source 2,
6682        // repeated across display 2..5). The caret at source 2 must land at the spacer's LEFT
6683        // edge (display 2), not an arbitrary spot inside it; source 5 (just past the formula)
6684        // lands at display 5.
6685        let map = std::rc::Rc::new(vec![0, 1, 2, 2, 2, 5, 6, 7]);
6686        assert_eq!(display_col_in(Some(&map), 2), 2);
6687        assert_eq!(display_col_in(Some(&map), 5), 5);
6688        // A strictly-increasing map (hidden markers) is unaffected.
6689        let plain = std::rc::Rc::new(vec![0, 1, 2, 3]);
6690        assert_eq!(display_col_in(Some(&plain), 2), 2);
6691        assert_eq!(display_col_in(None, 4), 4);
6692    }
6693
6694    #[test]
6695    fn image_width_splice() {
6696        // No existing attr: append `{width=N}` right after `)`.
6697        assert_eq!(
6698            set_image_width("![a](b.png)", 200),
6699            "![a](b.png){width=200}"
6700        );
6701        // Existing `{width=N}` is replaced (not duplicated).
6702        assert_eq!(
6703            set_image_width("![a](b.png){width=320}", 200),
6704            "![a](b.png){width=200}"
6705        );
6706        // The `px` unit form is replaced too.
6707        assert_eq!(
6708            set_image_width("![a](b.png){width=320px}", 200),
6709            "![a](b.png){width=200}"
6710        );
6711        // List-item image: the leading marker is preserved, attr lands after `)`.
6712        assert_eq!(
6713            set_image_width("- ![](x){width=10}", 50),
6714            "- ![](x){width=50}"
6715        );
6716        // Trailing whitespace is preserved (attr lands before it).
6717        assert_eq!(
6718            set_image_width("![a](b.png)  ", 80),
6719            "![a](b.png){width=80}  "
6720        );
6721        // Not an image row: returned unchanged.
6722        assert_eq!(set_image_width("just text", 100), "just text");
6723    }
6724
6725    // --- RTL row geometry (#66) ---------------------------------------------
6726
6727    use super::{checkbox_x, px, row_of_spans, rtl_marker_x, rtl_shift};
6728
6729    #[test]
6730    fn a_caret_offset_lands_on_the_row_that_holds_it() {
6731        // Three rows: "0..5", "5..11", "11..14" — contiguous, reading order.
6732        let rows = || [(0usize, 5usize), (5, 6), (11, 3)].into_iter();
6733        assert_eq!(row_of_spans(rows(), 0), (0, 0));
6734        assert_eq!(row_of_spans(rows(), 4), (0, 4));
6735        // A boundary belongs to the row that STARTS there, not the one ending.
6736        assert_eq!(row_of_spans(rows(), 5), (1, 0));
6737        assert_eq!(row_of_spans(rows(), 12), (2, 1));
6738        // The caret sits one past the last character after typing at the end
6739        // of a paragraph: it must stay on the last row, not fall off.
6740        assert_eq!(row_of_spans(rows(), 14), (2, 3));
6741        assert_eq!(row_of_spans(rows(), 99), (2, 88));
6742        // No rows at all (an empty line) is row 0.
6743        assert_eq!(row_of_spans([].into_iter(), 0), (0, 0));
6744    }
6745
6746    #[test]
6747    fn rtl_shift_mirrors_the_row_inset() {
6748        // Plain paragraph (no inset): the text's right edge meets the content
6749        // edge, so the shift is all the slack.
6750        assert_eq!(rtl_shift(px(500.), px(0.), px(200.)), px(300.));
6751        // Inset row (a list item / blockquote body): callers add the shift to
6752        // an origin they already inset, so the doubled inset leaves the SAME
6753        // gap on the right that an LTR row gets on the left.
6754        assert_eq!(rtl_shift(px(500.), px(24.), px(200.)), px(252.));
6755        assert_eq!(px(24.) + rtl_shift(px(500.), px(24.), px(200.)), px(276.));
6756        // …i.e. text spans 276..476, exactly 24 in from the right edge.
6757        // Text that fills or overflows the row wraps, and every wrap row starts
6758        // at the left edge — no shift, never a negative one.
6759        assert_eq!(rtl_shift(px(500.), px(0.), px(500.)), px(0.));
6760        assert_eq!(rtl_shift(px(500.), px(0.), px(900.)), px(0.));
6761        assert_eq!(rtl_shift(px(100.), px(60.), px(50.)), px(0.));
6762    }
6763
6764    #[test]
6765    fn rtl_markers_mirror_to_the_right_edge() {
6766        // A bullet 8px wide at x=10 lands 10 in from the right edge instead.
6767        assert_eq!(rtl_marker_x(px(500.), px(10.), px(8.)), px(482.));
6768        // Nesting is preserved: a deeper level (larger x) indents further FROM
6769        // THE RIGHT, so the levels keep their order.
6770        let l1 = rtl_marker_x(px(500.), px(10.), px(8.));
6771        let l2 = rtl_marker_x(px(500.), px(34.), px(8.));
6772        assert!(l2 < l1, "level 2 must sit further in from the right");
6773        assert_eq!(l1 - l2, px(24.), "the indent step survives the mirror");
6774        // Never off the left edge, however wide the marker.
6775        assert_eq!(rtl_marker_x(px(20.), px(10.), px(40.)), px(0.));
6776        // The checkbox shares that math, and an LTR row is untouched.
6777        assert_eq!(checkbox_x(px(10.), px(12.), px(500.), true), px(478.));
6778        assert_eq!(checkbox_x(px(10.), px(12.), px(500.), false), px(10.));
6779    }
6780
6781    // --- RTL table placement (#66) ------------------------------------------
6782
6783    use super::tables::{TABLE_GUTTER, table_left_x, table_visible_band};
6784
6785    /// The note column used throughout: origin 100, width 500 → the LTR band
6786    /// is 122..600 and the RTL band 100..578, both `TABLE_GUTTER` wide.
6787    const O: f32 = 100.;
6788    const W: f32 = 500.;
6789
6790    #[test]
6791    fn an_rtl_table_hugs_the_right_edge_with_the_gutter_mirrored() {
6792        let g = TABLE_GUTTER;
6793        // A table narrower than the column: LTR starts a gutter in from the
6794        // left, RTL *ends* a gutter in from the right.
6795        let ltr = table_left_x(px(O), px(W), px(300.), px(0.), false);
6796        let rtl = table_left_x(px(O), px(W), px(300.), px(0.), true);
6797        assert_eq!(ltr, px(O + g));
6798        assert_eq!(rtl + px(300.), px(O + W - g), "right edge, one gutter in");
6799        // The two are mirror images about the column's centre.
6800        assert_eq!(
6801            f32::from(ltr - px(O)),
6802            f32::from(px(O + W) - (rtl + px(300.)))
6803        );
6804        // Column widths don't move an LTR table but do move an RTL one — it is
6805        // anchored at its trailing edge.
6806        assert_eq!(
6807            table_left_x(px(O), px(W), px(120.), px(0.), false),
6808            px(O + g)
6809        );
6810        assert_eq!(
6811            table_left_x(px(O), px(W), px(120.), px(0.), true),
6812            px(O + W - g - 120.)
6813        );
6814    }
6815
6816    #[test]
6817    fn a_wide_table_scrolls_to_its_own_far_edge_either_way() {
6818        let g = TABLE_GUTTER;
6819        let total = px(900.);
6820        let avail = px(W - g); // what `table_sx` clamps against
6821        let max = total - avail;
6822        // Unscrolled, each direction shows its own leading edge at the gutter.
6823        assert_eq!(table_left_x(px(O), px(W), total, px(0.), false), px(O + g));
6824        assert_eq!(
6825            table_left_x(px(O), px(W), total, px(0.), true) + total,
6826            px(O + W - g)
6827        );
6828        // Fully scrolled, each shows its trailing edge at the band's far side:
6829        // LTR's right edge reaches the column's right, RTL's left edge the left.
6830        assert_eq!(
6831            table_left_x(px(O), px(W), total, max, false) + total,
6832            px(O + W)
6833        );
6834        assert_eq!(table_left_x(px(O), px(W), total, max, true), px(O));
6835        // Scroll moves the content in OPPOSITE directions — why the wheel and
6836        // the thumb's `factor` invert their sign on an RTL table.
6837        let step = px(50.);
6838        assert!(table_left_x(px(O), px(W), total, step, false) < px(O + g));
6839        assert!(
6840            table_left_x(px(O), px(W), total, step, true)
6841                > table_left_x(px(O), px(W), total, px(0.), true)
6842        );
6843    }
6844
6845    #[test]
6846    fn rtl_mirrors_the_column_order() {
6847        use super::tables::{cell_span_width, col_offset};
6848        // Three columns, 10/20/30 wide. Left to right they start at 0/10/30;
6849        // mirrored, column 0 is the RIGHTMOST, so it starts at 50.
6850        let w = [px(10.), px(20.), px(30.)];
6851        assert_eq!(col_offset(&w, 3, 0, false), px(0.));
6852        assert_eq!(col_offset(&w, 3, 1, false), px(10.));
6853        assert_eq!(col_offset(&w, 3, 2, false), px(30.));
6854        assert_eq!(col_offset(&w, 3, 0, true), px(50.));
6855        assert_eq!(col_offset(&w, 3, 1, true), px(30.));
6856        assert_eq!(col_offset(&w, 3, 2, true), px(0.));
6857        // Either way the columns tile the table with no gap or overlap — paint,
6858        // caret and hit-testing all read this, so a gap is a mis-click.
6859        for rtl in [false, true] {
6860            let mut spans: Vec<(f32, f32)> = (0..3)
6861                .map(|c| {
6862                    let x = f32::from(col_offset(&w, 3, c, rtl));
6863                    (x, x + f32::from(cell_span_width(&w, 3, c)))
6864                })
6865                .collect();
6866            spans.sort_by(|a, b| a.0.total_cmp(&b.0));
6867            assert_eq!(spans[0].0, 0.0);
6868            assert_eq!(spans[2].1, 60.0);
6869            assert!(spans.windows(2).all(|s| s[0].1 == s[1].0), "{spans:?}");
6870        }
6871    }
6872
6873    #[test]
6874    fn the_visible_band_is_the_column_less_its_gutter() {
6875        let g = TABLE_GUTTER;
6876        assert_eq!(
6877            table_visible_band(px(O), px(W), false),
6878            (px(O + g), px(O + W))
6879        );
6880        assert_eq!(
6881            table_visible_band(px(O), px(W), true),
6882            (px(O), px(O + W - g))
6883        );
6884        // Both bands are the `avail` width the scroll clamp assumes.
6885        for rtl in [false, true] {
6886            let (l, r) = table_visible_band(px(O), px(W), rtl);
6887            assert_eq!(r - l, px(W - g));
6888        }
6889    }
6890}