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/// `` 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 `` 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 `` 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 `` 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 `` 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 `` 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 `` 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 ``
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 `` 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 ``), 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 `` 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/// `` (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("", 200),
6699 "{width=200}"
6700 );
6701 // Existing `{width=N}` is replaced (not duplicated).
6702 assert_eq!(
6703 set_image_width("{width=320}", 200),
6704 "{width=200}"
6705 );
6706 // The `px` unit form is replaced too.
6707 assert_eq!(
6708 set_image_width("{width=320px}", 200),
6709 "{width=200}"
6710 );
6711 // List-item image: the leading marker is preserved, attr lands after `)`.
6712 assert_eq!(
6713 set_image_width("- {width=10}", 50),
6714 "- {width=50}"
6715 );
6716 // Trailing whitespace is preserved (attr lands before it).
6717 assert_eq!(
6718 set_image_width(" ", 80),
6719 "{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}