Skip to main content

leaf_ffi/
lib.rs

1//! leaf-ffi — the Swift / C-ABI frontend binding for leaf.
2//!
3//! This is the native-Apple analogue of `leaf-wasm`: it takes `leaf-core`'s
4//! frontend-neutral [`Doc`] — the byte-offset caret model and the AST→glyph
5//! [`VisualMap`] — and exposes it across a C ABI (via UniFFI) in the shape an
6//! AppKit/SwiftUI renderer wants. Core stays the single source of truth for the
7//! text, the caret math, and the offset⇄position mapping; the Swift side only
8//! paints glyphs and forwards key/mouse events back in, exactly as the TUI, gpui,
9//! and wasm frontends do.
10//!
11//! ## The boundary is style *runs*, not glyphs
12//!
13//! [`Doc::build_visual`] resolves the document to rows of per-character glyphs,
14//! each tagged with a semantic [`Role`] and the author's emphasis. Sending one
15//! object per character would make every keystroke O(document) in boundary
16//! crossings. Instead [`LeafDoc::view`] coalesces each row's glyphs into maximal
17//! **runs** of identical style and ships those — a handful of records per line.
18//! The Swift renderer maps each run's `role` to a font/size/weight and its
19//! emphasis flags to traits, the native counterpart of the TUI's `to_ratatui`
20//! and the web's CSS class.
21//!
22//! ## Core owns the grid; Swift owns the pixels
23//!
24//! Core lays a row out in whole character *columns* (a terminal-cell measure),
25//! and every offset⇄position method speaks that grid. It deliberately does *not*
26//! dictate presentation. So a native renderer is *proportional* — body text in a
27//! real family, headings by **size** and weight, code in a monospace panel — and
28//! never multiplies `col × cell_width`. It lets `NSLayoutManager` / Core Text
29//! shape each row, places the caret at [`DocView::caret_ch`] (a UTF-16 offset,
30//! which is exactly what `NSAttributedString` and `NSTextView` count in), and
31//! hit-tests a click through `characterIndex(for:)`, feeding the resulting
32//! row + UTF-16 offset back through [`LeafDoc::click_ch`]. Core measures nothing
33//! in pixels; Swift positions nothing in the model.
34//!
35//! ## Threading
36//!
37//! A UniFFI object is handed to Swift as a reference-counted handle whose methods
38//! take `&self`, so the [`Doc`] lives behind a [`Mutex`]. Every call locks, edits
39//! or reads, and returns a fresh [`DocView`] — one boundary crossing both mutates
40//! and repaints, same as the wasm frontend. Drive it from the main thread.
41
42use std::sync::{Arc, Mutex};
43
44use leaf_core::style::{Baseline, Role, Style as LStyle};
45use leaf_core::wysiwyg::text_width;
46use leaf_core::{
47    Alignment, BlockKind, Capabilities as CoreCapabilities, ColorScheme, Doc, Format, InlineKind,
48    LineFlow as CoreLineFlow, MarkColor as CoreMarkColor, MarkupMode as CoreMarkupMode,
49    MediaKind as CoreMediaKind, View, VisualMap,
50};
51use unicode_segmentation::UnicodeSegmentation;
52
53// Linked, not used: see the dependency's note in Cargo.toml. The `as _` is
54// what makes rustc treat the crate as referenced and carry its objects into
55// the staticlib.
56use resvg_uniffi as _;
57
58uniffi::setup_scaffolding!();
59
60/// A parse failure constructing a document — the only fallible entry point. Every
61/// other method is infallible (it operates on an already-parsed model), so they
62/// return a [`DocView`] directly.
63#[derive(Debug, thiserror::Error, uniffi::Error)]
64pub enum LeafError {
65    /// The `format` string handed to [`LeafDoc::new`] wasn't one leaf understands.
66    #[error("unknown format: {name}")]
67    UnknownFormat { name: String },
68    /// `leaf-core` failed to parse `source` as the requested format.
69    #[error("parse error: {message}")]
70    Parse { message: String },
71}
72
73/// A selection cited out of the source — the text, a little of what
74/// surrounded it, and the byte range it came from. The FFI shape of
75/// `leaf_core::Quote`; see [`LeafDoc::selection_quote`].
76#[derive(uniffi::Record)]
77pub struct SelectionQuote {
78    /// The selected source, verbatim.
79    pub exact: String,
80    /// What immediately preceded it — empty at the document's start.
81    pub prefix: String,
82    /// What immediately followed it — empty at the document's end.
83    pub suffix: String,
84    /// Byte offset in the source where the selection begins.
85    pub start: u64,
86    /// Byte offset where it ends (exclusive).
87    pub end: u64,
88}
89
90/// A host-painted range of the source — an annotation's footprint, a search
91/// hit. The FFI shape of `leaf_core::Highlight`; see
92/// [`LeafDoc::set_highlights`].
93#[derive(uniffi::Record)]
94pub struct Highlight {
95    /// Byte offset in the source where the wash begins.
96    pub start: u64,
97    /// Byte offset where it ends (exclusive).
98    pub end: u64,
99    /// The host's name for it, handed back on activation. Opaque to leaf.
100    pub id: String,
101    /// A rendering hint (`#RRGGBB`), or `None` for the theme's default wash.
102    pub color: Option<String>,
103    /// A margin glyph's name (an SF Symbol, for this binding's frontends), or
104    /// `None` for wash-only ink. The marker — not the wash — is what
105    /// activates a highlight; see `leaf_core::Highlight::marker`.
106    pub marker: Option<String>,
107}
108
109/// One maximal span of same-styled glyphs on a visual row — the unit the Swift
110/// renderer turns into a single styled attributed-string run.
111#[derive(uniffi::Record)]
112pub struct Run {
113    /// The run's text, glyphs concatenated in column order.
114    pub text: String,
115    /// The glyph's semantic role as a renderer class id: `body`, `h1`…`h6`,
116    /// `code`, `link`, `mark`, `list`, `quote`, `rule`.
117    pub role: String,
118    pub bold: bool,
119    pub italic: bool,
120    pub underline: bool,
121    pub strike: bool,
122    /// Raised off the baseline and drawn smaller — a footnote reference's `[1]`,
123    /// or an author's `^x^`. Mutually exclusive with [`sub`](Self::sub); core's
124    /// `Baseline` is one value, and these are its two non-default cases flattened
125    /// to the flag shape the rest of this record is spelled in.
126    pub sup: bool,
127    /// Lowered off the baseline and drawn smaller — an author's `~x~`.
128    pub sub: bool,
129    /// The byte offset in the source this run's first glyph came from.
130    ///
131    /// What a run *means*, as opposed to how it looks: a `link` role says a span
132    /// is drawn as a link but not where it points, and the only way back to that
133    /// is the source. A frontend drawing part of the document somewhere the caret
134    /// isn't — a footnote's text in a popover — pairs this with
135    /// [`LeafDoc::link_destination_at`] or [`LeafDoc::footnote_at`] to make those
136    /// runs followable.
137    ///
138    /// The alternative was for a frontend to count its way along the row's text
139    /// and ask [`LeafDoc::offset_for_pos`], which means converting between three
140    /// units that only agree on ASCII: this is a byte offset, the run's text is
141    /// characters, and a row's column is a *display* cell (a wide CJK glyph is
142    /// two). Handing the offset over is exact, O(1), and needs none of that.
143    ///
144    /// `0` for the runs of the source view, whose rows are split from raw text
145    /// rather than laid out from glyphs.
146    pub src: u32,
147    /// Whether this run lies inside the active selection — so the renderer can
148    /// paint a selection background without re-deriving it from offsets.
149    pub sel: bool,
150    /// The id of the host highlight covering this run, if one does — see
151    /// [`LeafDoc::set_highlights`]. A highlight splits a run the way the
152    /// selection does, so a wash begins and ends exactly on its bytes.
153    pub hl: Option<String>,
154    /// That highlight's rendering hint (`#RRGGBB`, or `None` for the theme's
155    /// default wash), carried beside the id so a renderer needs no lookup.
156    pub hl_color: Option<String>,
157    /// The colour the author named on a `mark` run — `"red"`, `"orange"`,
158    /// `"yellow"`, `"green"`, `"blue"`, `"purple"`, `"brown"` — or absent for a
159    /// plain `==highlight==` and for every other role.
160    ///
161    /// A *name*, unlike [`hl_color`](Self::hl_color)'s `#RRGGBB`, and that is
162    /// the difference between the two: a host highlight's colour is the host's
163    /// own choice and arrives as a value to paint, while this one is the
164    /// document's word for it and the renderer picks the wash. It rides beside
165    /// `role` rather than folding into it (`"mark-red"`) so a renderer that
166    /// knows nothing about colours still draws the run as the highlight it is.
167    pub mark_color: Option<String>,
168    /// What a `code` run is to the language its fenced block is written in —
169    /// `"punctuation"`, `"keyword"`, `"entity"`, `"support"`, `"constant"`,
170    /// `"string"`, `"comment"`, `"invalid"` — or absent for a run the grammar
171    /// left plain, for every run of a block in a language no grammar covers,
172    /// for inline code, and for every other role.
173    ///
174    /// A class id like `role`, and beside it for the reason `mark_color` is: a
175    /// renderer that knows nothing about tokens still draws the run as the code
176    /// it is, and one that does keys a palette on the name.
177    pub token: Option<String>,
178}
179
180/// Where a locator lands — what [`LeafDoc::locate`] answers with, and the FFI
181/// mirror of [`leaf_core::Landing`].
182///
183/// A span rather than an offset because the two things a host does with a
184/// locator want different halves of it: following one puts a caret at `start`,
185/// while peeking at one draws the rows between `start` and `end`. Only the first
186/// can be recovered from an offset alone.
187#[derive(uniffi::Record)]
188pub struct LandingView {
189    /// The first byte of the block the locator names — where a caret goes.
190    pub start: u32,
191    /// One past its last byte, so the pair maps through `pos_for_offset` to the
192    /// rendered rows the block occupies, the way [`FootnoteView`]'s pair does.
193    pub end: u32,
194}
195
196impl From<leaf_core::Landing> for LandingView {
197    fn from(l: leaf_core::Landing) -> Self {
198        LandingView {
199            start: l.start as u32,
200            end: l.end as u32,
201        }
202    }
203}
204
205/// A footnote reference and the note it names — what [`LeafDoc::footnote_at`]
206/// answers with. The FFI mirror of [`leaf_core::FootnoteRef`].
207///
208/// A reference whose definition the document is missing still comes back, with
209/// its `label` and no `text`: that a `[^99]` names nothing is a thing to tell
210/// the reader, and it is not the same as the caret standing on no reference at
211/// all (which is `None`).
212#[derive(uniffi::Record)]
213pub struct FootnoteView {
214    /// The reference's label — the `1` of `[^1]`, without the `^` or brackets.
215    pub label: String,
216    /// The note's body as source text, or `None` when nothing defines it.
217    pub text: Option<String>,
218    /// The byte offset the note's body starts at, for a "go to note" that moves
219    /// the caret there. `None` alongside a `None` `text`.
220    pub offset: Option<u32>,
221    /// Where the body ends, exclusive. With `offset` this bounds the note, so a
222    /// frontend can map the pair through `pos_for_offset` to the *rendered rows*
223    /// it occupies and draw those — the note with its markup resolved, rather
224    /// than the asterisks and backticks `text` carries. `None` alongside a
225    /// `None` `offset`.
226    pub end: Option<u32>,
227}
228
229impl From<leaf_core::FootnoteRef> for FootnoteView {
230    fn from(f: leaf_core::FootnoteRef) -> Self {
231        FootnoteView {
232            label: f.label,
233            text: f.text,
234            offset: f.offset.map(|o| o as u32),
235            end: f.end.map(|o| o as u32),
236        }
237    }
238}
239
240/// A footnote definition and the reference that sends a reader to it — what
241/// [`LeafDoc::footnote_definition_at_caret`] answers with, and the FFI mirror of
242/// [`leaf_core::FootnoteDef`].
243///
244/// The other half of [`FootnoteView`]'s round trip: that one carries a reader
245/// down to the note, this one carries them back up. A definition nothing cites
246/// still comes back, with its `label` and no `offset`, for the reason an
247/// undefined reference does — "nothing refers to this note" is worth saying.
248#[derive(uniffi::Record)]
249pub struct FootnoteDefView {
250    /// The definition's label — the `1` of `[^1]: …`, spelled exactly as
251    /// [`FootnoteView::label`] spells the same footnote's.
252    pub label: String,
253    /// The byte offset the first reference starts at, for a "back to reference"
254    /// that moves the caret there. `None` for a note nothing refers to.
255    pub offset: Option<u32>,
256}
257
258impl From<leaf_core::FootnoteDef> for FootnoteDefView {
259    fn from(f: leaf_core::FootnoteDef) -> Self {
260        FootnoteDefView {
261            label: f.label,
262            offset: f.offset.map(|o| o as u32),
263        }
264    }
265}
266
267/// One visual line: its styled runs plus the row-level flags a frontend draws
268/// chrome from.
269#[derive(uniffi::Record)]
270pub struct Row {
271    pub runs: Vec<Run>,
272    /// Drawn but holds no caret (a table rule, a block-gap blank line): the
273    /// renderer skips it for click/caret math. See [`leaf_core::VRow`].
274    pub decoration: bool,
275    /// A fenced/indented code-block line — the renderer draws a tinted, bordered
276    /// panel around each maximal run of these.
277    pub code: bool,
278    /// A fenced block's language, carried on the block's first code row only.
279    pub code_lang: Option<String>,
280    /// A `:::name{.class}` directive-container line — the renderer draws a
281    /// tinted panel around each maximal run of these, the `code` recipe. See
282    /// [`leaf_core::VRow::directive`].
283    pub directive: bool,
284    /// A directive container's space-joined `.class` attrs, carried on the
285    /// block's first row only. See [`leaf_core::VRow::directive_label`].
286    pub directive_label: Option<String>,
287    /// The heading level (1–6) if this row belongs to a heading block, else
288    /// `None`. A proportional renderer sizes the *whole* row from this so an
289    /// inline `` `code` `` run inside a heading still reads at the heading's size.
290    pub heading: Option<u8>,
291    /// What this row divides, on the blank rows a block boundary is drawn with
292    /// and `None` everywhere else — so `boundary != nil` is exactly "this row is
293    /// a drawn block boundary". A frontend spaces a boundary by the pair it
294    /// falls between (the margin above a heading is wider than the one between
295    /// two paragraphs); the *height* is the frontend's, the *kind* is core's.
296    /// See [`leaf_core::Boundary`].
297    pub boundary: Option<Boundary>,
298}
299
300/// What a drawn block boundary separates. The FFI mirror of
301/// [`leaf_core::Boundary`].
302#[derive(uniffi::Record)]
303pub struct Boundary {
304    pub above: BlockClass,
305    pub below: BlockClass,
306}
307
308/// The block kinds core tells apart — the vocabulary a [`Boundary`] is spelled
309/// in. The FFI mirror of [`leaf_core::BlockClass`]; `Other` covers every kind
310/// core doesn't separate out, so a frontend's `match` stays exhaustive as the
311/// list grows.
312#[derive(uniffi::Enum)]
313pub enum BlockClass {
314    Paragraph,
315    Heading,
316    /// A whole list. Core draws no boundary row *between* two items of one list,
317    /// tight or loose, so an `ListItem`↔`ListItem` pair never reaches a frontend.
318    List,
319    ListItem,
320    Quote,
321    Code,
322    Table,
323    Media,
324    Directive,
325    Rule,
326    Footnote,
327    Other,
328}
329
330impl From<leaf_core::BlockClass> for BlockClass {
331    fn from(k: leaf_core::BlockClass) -> Self {
332        use leaf_core::BlockClass as K;
333        match k {
334            K::Paragraph => BlockClass::Paragraph,
335            K::Heading => BlockClass::Heading,
336            K::List => BlockClass::List,
337            K::ListItem => BlockClass::ListItem,
338            K::Quote => BlockClass::Quote,
339            K::Code => BlockClass::Code,
340            K::Table => BlockClass::Table,
341            K::Media => BlockClass::Media,
342            K::Directive => BlockClass::Directive,
343            K::Rule => BlockClass::Rule,
344            K::Footnote => BlockClass::Footnote,
345            K::Other => BlockClass::Other,
346        }
347    }
348}
349
350/// One *visual line* of a table cell: its styled runs and the source offsets
351/// bounding it. A cell is usually one line, but an in-cell hard break (an inline
352/// `<br>`) splits it into several — each its own line here, so the frontend
353/// shapes and caret-maps them independently (the byte↔UTF-16 offset math a cell
354/// needs holds within a line, which carries no break). The runs are *unwrapped*:
355/// column width — and any soft wrap within it — is the frontend's to decide.
356#[derive(uniffi::Record)]
357pub struct TableCellLineView {
358    pub runs: Vec<Run>,
359    /// The source offsets bounding this line's content — the caret home at its
360    /// start and the stop just past its end.
361    pub start: u32,
362    pub end: u32,
363}
364
365/// One cell of a table's structural grid: its content as one or more visual
366/// lines, the column alignment its text honours, and the source range the whole
367/// cell occupies (where a click or the caret lands).
368#[derive(uniffi::Record)]
369pub struct TableCellView {
370    /// The cell's lines, in order — one unless an in-cell `<br>` splits it.
371    pub lines: Vec<TableCellLineView>,
372    /// `"left"`, `"right"`, `"center"`, or `"default"`.
373    pub align: String,
374    /// The source offsets bounding the cell's content — the caret anchors a
375    /// click in the cell resolves to.
376    pub start: u32,
377    pub end: u32,
378}
379
380/// One row of a table's structural grid; a header row draws bold and is ruled
381/// off from the body below it.
382#[derive(uniffi::Record)]
383pub struct TableRowView {
384    pub head: bool,
385    pub cells: Vec<TableCellView>,
386}
387
388/// A table described *structurally* rather than as the monospace box-glyph
389/// picture that spells it in [`DocView::rows`]. A proportional renderer draws its
390/// own grid from this — columns sized to content, real borders — and SKIPS the
391/// picture rows in `[start_row, end_row)`. The two describe the same cells at the
392/// same source offsets, so the caret lands identically either way. See
393/// [`leaf_core::TableInfo`].
394#[derive(uniffi::Record)]
395pub struct TableView {
396    /// The [`DocView::rows`] indices the box-drawn picture occupies — the rows a
397    /// grid-drawing frontend skips.
398    pub start_row: u32,
399    pub end_row: u32,
400    pub grid: Vec<TableRowView>,
401}
402
403/// A leaf directive (`::name{…}`) — a standalone block with no body, drawn in
404/// [`DocView::rows`] as a one-row `⧉ name` placeholder. A frontend that knows
405/// the host app's vocabulary reads this and paints the real thing over the rows
406/// in `[start_row, end_row)` — a web view for diaryx's `::embed{src=…}`, say —
407/// exactly as a grid-drawing one replaces a [`TableView`]'s picture rows. One
408/// that doesn't just paints the placeholder, which is already framed by the
409/// directive panel chrome.
410///
411/// Core resolves nothing here and neither does this layer: the vocabulary
412/// belongs to the app. See [`leaf_core::DirectiveInfo`].
413#[derive(uniffi::Record)]
414pub struct DirectiveView {
415    /// The [`DocView::rows`] indices the placeholder occupies.
416    pub start_row: u32,
417    pub end_row: u32,
418    /// The directive's type (`embed`, `toc`, `vis`), no leading colons.
419    pub name: String,
420    /// Its `[label]` text, or empty — what the placeholder row shows.
421    pub label: String,
422    /// Its `{…}` attributes in source order. A bare attribute (`{public}`) has an
423    /// empty value, which a consumer reads as a flag.
424    pub attrs: Vec<DirectiveAttr>,
425}
426
427/// One `{key=value}` attribute of a [`DirectiveView`]. A record rather than a
428/// tuple because UniFFI has no tuple type; an absent value flattens to `""`,
429/// since a bare attribute is a flag and the distinction from `key=""` has no
430/// consumer on this side.
431#[derive(uniffi::Record)]
432pub struct DirectiveAttr {
433    pub key: String,
434    pub value: String,
435}
436
437/// What a block-level media placeholder is, so Swift knows which view to build
438/// over the rows core reserved: an `NSImageView`/`UIImageView`, or an
439/// `AVPlayerView` with or without a picture to show. The peer of
440/// [`leaf_core::MediaKind`].
441#[derive(uniffi::Enum)]
442pub enum MediaKind {
443    Image,
444    Video,
445    Audio,
446}
447
448/// One `<source>` alternative of a block media element — a candidate URL plus
449/// whichever of the two things HTML picks a `<source>` by: a media query
450/// (`<picture>`) or a MIME type (`<video>`/`<audio>`).
451///
452/// Unlike the web frontend, which hands the whole list to the browser and lets
453/// it choose, a native renderer usually wants [`MediaView::src`] — already
454/// resolved for the current appearance — and reaches in here only to pick a
455/// codec `AVFoundation` can actually play.
456#[derive(uniffi::Record)]
457pub struct MediaSourceView {
458    /// The `media="…"` query, or empty for an unconditional source.
459    pub media: String,
460    /// The candidate URL (a `<picture>` `srcset` or a `<video>`/`<audio>` `src`).
461    pub src: String,
462    /// The `type="…"` MIME (`"video/webm"`), or empty when none is declared.
463    pub mime: String,
464}
465
466/// One block-level image, video, or audio: which rows core reserved for it and
467/// what to build there. The peer of [`leaf_core::MediaInfo`], and the media
468/// analogue of [`DirectiveView`] — a frontend **skips the rows in
469/// `start_row..end_row`** and lays its own view over them, rather than painting
470/// the `🖼`/`🎬`/`🔊` placeholder glyphs core put there for a surface that can't.
471#[derive(uniffi::Record)]
472pub struct MediaView {
473    /// The [`DocView::rows`] indices the placeholder occupies.
474    pub start_row: u32,
475    pub end_row: u32,
476    /// Which of the three this is — the view to build.
477    pub kind: MediaKind,
478    /// The URL to load, already resolved against the current appearance (see
479    /// [`LeafDoc::set_dark_appearance`]). A relative path resolves against the
480    /// document's own directory, which core does not know — the host does.
481    /// Empty only when a `<video>`/`<audio>` named neither a `src` nor a
482    /// `<source>`, which is a broken document.
483    pub src: String,
484    /// A `<video>`'s poster frame URL, or empty. An image destination, so it
485    /// loads exactly as an image `src` does — worth showing before the movie is
486    /// ready, or in place of one that won't play.
487    pub poster: String,
488    /// The alt / fallback text, for the view's accessibility label.
489    pub alt: String,
490    /// The `<source>` alternatives in document order; empty for a plain image.
491    pub sources: Vec<MediaSourceView>,
492}
493
494/// A per-destination measured height, the way Swift reports one back — the input
495/// half of the loop [`LeafDoc::set_media_rows`] closes.
496#[derive(uniffi::Record)]
497pub struct MediaHeight {
498    /// The media's `src` as it appeared in the document, keying it to a
499    /// [`MediaView`].
500    pub destination: String,
501    /// How many visual rows the laid-out view needs.
502    pub rows: u32,
503}
504
505/// A whole rendered frame: the rows to paint, where the caret sits, and the
506/// toolbar state — everything the Swift side needs for one repaint, in one value.
507/// Returned by every view-producing method.
508#[derive(uniffi::Record)]
509pub struct DocView {
510    pub rows: Vec<Row>,
511    /// Tables described structurally, for a frontend that draws its own grid
512    /// instead of painting the box-glyph rows. Empty in the source view. Each
513    /// names the `rows` span its picture occupies, to be skipped.
514    pub tables: Vec<TableView>,
515    /// Leaf directives (`::name{…}`) described structurally, for a frontend that
516    /// paints what the host app's vocabulary makes of them instead of the `⧉`
517    /// placeholder row. Empty in the source view, where the directive is the
518    /// literal text the caret is editing.
519    pub directives: Vec<DirectiveView>,
520    /// Block-level images, videos, and audio described structurally, for a
521    /// frontend that lays real views over the rows core reserved instead of
522    /// painting the placeholder glyphs. Empty in the source view, where the
523    /// `![](…)` or `<video>` markup is the literal text being edited.
524    pub media: Vec<MediaView>,
525    /// The caret's row: an index into [`Self::rows`].
526    pub caret_row: u32,
527    /// The caret's display *column* within its row — core's grid position. Kept
528    /// for callers reasoning in columns; a proportional renderer wants
529    /// [`Self::caret_ch`] instead.
530    pub caret_col: u32,
531    /// The caret's offset within its row's text in **UTF-16 code units** — what
532    /// `NSAttributedString`/`NSTextView` count to. This is `caret_col` mapped
533    /// through the row's grapheme widths, so it lands the caret correctly past
534    /// wide glyphs (CJK, emoji) where a column and a character index diverge.
535    pub caret_ch: u32,
536    /// The caret's **source byte offset** — the coordinate a table cell is keyed
537    /// by (`TableCellView::start`/`end`), so a frontend drawing its own grid can
538    /// find which cell the caret sits in without the picture-row indices.
539    pub caret_src: u32,
540    /// Whether a (non-empty) selection is active.
541    pub has_selection: bool,
542    /// The selection's *fixed* end (the caret is the moving end), as a row and a
543    /// UTF-16 offset — so the renderer can restore a native selection with the
544    /// same direction the model has. Equal to the caret when `has_selection` is
545    /// false.
546    pub anchor_row: u32,
547    pub anchor_ch: u32,
548    /// Whether the buffer differs from the last saved bytes — for a "● modified"
549    /// affordance.
550    pub dirty: bool,
551    /// Whether there is a step to undo, and one to redo — what a native Edit
552    /// menu or an undo manager enables its items by. Both false on a read-only
553    /// document. See [`leaf_core::Doc::can_undo`] for the bound this is.
554    pub can_undo: bool,
555    pub can_redo: bool,
556    /// `"wysiwyg"` or `"source"`, for a view-toggle affordance.
557    pub view: String,
558    /// The heading level at the caret, if any — a toolbar lights H1…H6 from it.
559    pub heading: Option<u32>,
560    /// The inline marks active at the caret (`bold`, `italic`, `code`, …) — the
561    /// toolbar lights the matching buttons.
562    pub active: Vec<String>,
563    /// The destination of the link the caret stands in, or `None` — the toolbar
564    /// lights its Link button from it and seeds an edit of that link with it.
565    ///
566    /// It rides the frame rather than being a query a toolbar makes for itself
567    /// because a toolbar only redraws when the *state* changes: walking the caret
568    /// out of a link changes no mark, no heading, and no dirty flag, so a Link
569    /// button reading this by a call of its own would keep a stale light on. Same
570    /// reason `heading` is here and not asked for.
571    ///
572    /// Only a *parsed* link answers ([`LeafDoc::link_destination_at_caret`]);
573    /// a wikilink is literal text with no node behind it, and has nothing to
574    /// repoint — see `LinkTarget.swift`.
575    pub link: Option<String>,
576    /// The colour of the highlight the caret stands in — which swatch a colour
577    /// palette draws as the current one, and `None` both outside a highlight and
578    /// inside an uncoloured one.
579    ///
580    /// It rides the frame for `link`'s reason, and more sharply: walking from a
581    /// red highlight into a blue one changes no mark, no heading, no dirty flag
582    /// and no link, so a palette asking for itself would never be told to move
583    /// its checkmark.
584    ///
585    /// An enum rather than the name string [`Run::mark_color`] carries, because
586    /// the two are different questions. A run's colour is a *rendering* hint that
587    /// has to survive a name this build has never heard of (a newer twig's
588    /// eighth colour draws as a plain highlight rather than not at all); this is
589    /// the closed palette a control offers, and a name outside it is not a
590    /// swatch anyone can press.
591    pub mark_color: Option<MarkColor>,
592}
593
594/// The colour of a highlight — the closed palette [`LeafDoc::set_mark_color`]
595/// writes and [`DocView::mark_color`] reports.
596///
597/// Obsidian's spelling, which is twig's: a circle emoji straight after the
598/// opening `==`, so `==🔴 text==` is a highlight of `text` in [`MarkColor::Red`].
599/// The emoji is *spelling* rather than content — it never appears in the text a
600/// reader sees, and never in a [`Run`].
601#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
602pub enum MarkColor {
603    Red,
604    Orange,
605    Yellow,
606    Green,
607    Blue,
608    Purple,
609    Brown,
610}
611
612impl From<CoreMarkColor> for MarkColor {
613    fn from(c: CoreMarkColor) -> Self {
614        match c {
615            CoreMarkColor::Red => MarkColor::Red,
616            CoreMarkColor::Orange => MarkColor::Orange,
617            CoreMarkColor::Yellow => MarkColor::Yellow,
618            CoreMarkColor::Green => MarkColor::Green,
619            CoreMarkColor::Blue => MarkColor::Blue,
620            CoreMarkColor::Purple => MarkColor::Purple,
621            CoreMarkColor::Brown => MarkColor::Brown,
622        }
623    }
624}
625
626impl From<MarkColor> for CoreMarkColor {
627    fn from(c: MarkColor) -> Self {
628        match c {
629            MarkColor::Red => CoreMarkColor::Red,
630            MarkColor::Orange => CoreMarkColor::Orange,
631            MarkColor::Yellow => CoreMarkColor::Yellow,
632            MarkColor::Green => CoreMarkColor::Green,
633            MarkColor::Blue => CoreMarkColor::Blue,
634            MarkColor::Purple => CoreMarkColor::Purple,
635            MarkColor::Brown => CoreMarkColor::Brown,
636        }
637    }
638}
639
640/// A visual position: a row index plus a UTF-16 offset within that row's text —
641/// the coordinate the geometry side (Core Text) draws from. Returned by
642/// [`LeafDoc::pos_for_offset`], the bridge from a source offset (what a
643/// `UITextPosition` wraps) to where it sits on screen.
644#[derive(uniffi::Record)]
645pub struct RowCol {
646    pub row: u32,
647    pub ch: u32,
648}
649
650/// The rows a source range covers, both ends **inclusive** — what a frontend
651/// slices out of a frame to draw a block somewhere other than where it sits: a
652/// footnote peek, a link peek, a landing flash. Returned by
653/// [`LeafDoc::row_range_for`].
654///
655/// Inclusive rather than half-open because the answer is "these rows", not "up
656/// to here": every caller wants `rows[first...last]`, and a `last` one past the
657/// end would be a second thing to get wrong at each of them. `last >= first`
658/// always, so the pair is never empty — a range with no visible byte still
659/// covers the row it opened on.
660#[derive(uniffi::Record)]
661pub struct RowRange {
662    pub first: u32,
663    pub last: u32,
664}
665
666/// Which formatting controls this document's format can spell — the toolbar's
667/// enabled state, one flag per button, from [`LeafDoc::capabilities`]. Mirrors
668/// [`leaf_core::Capabilities`], where the reasoning lives.
669///
670/// Its shape is a flat record of `Bool`s rather than a query taking a gesture
671/// because the Swift side wants exactly one crossing and a value it can hold in
672/// an `@Observable`: `let caps = doc.capabilities()`, then
673/// `.disabled(!caps.bold)` on each control.
674#[derive(uniffi::Record)]
675pub struct Capabilities {
676    pub bold: bool,
677    pub italic: bool,
678    pub code: bool,
679    pub mark: bool,
680    pub underline: bool,
681    pub strike: bool,
682    /// [`LeafDoc::set_mark_color`] — the highlight *palette*, which Markdown
683    /// spells and djot does not, though both spell the highlight itself. Gate
684    /// the swatches on this *and* on [`LeafDoc::caret_in_mark`], the way the grid
685    /// controls take `table` and `caret_in_table`: one is a fact about the
686    /// format, the other about where the caret is standing.
687    pub mark_color: bool,
688    pub superscript: bool,
689    pub subscript: bool,
690    /// Both [`LeafDoc::set_heading`] and [`LeafDoc::set_paragraph`] — they are
691    /// the same gesture in core and stand or fall together.
692    pub heading: bool,
693    pub blockquote: bool,
694    pub bullet_list: bool,
695    pub ordered_list: bool,
696    /// [`LeafDoc::toggle_task_item`], [`LeafDoc::toggle_task_checked`] and
697    /// [`LeafDoc::toggle_task_at`] — including a *tap* on a rendered checkbox,
698    /// which should not be live where the box cannot be spelled.
699    pub task: bool,
700    pub link: bool,
701    /// [`LeafDoc::insert_image`] and [`LeafDoc::insert_media`] both.
702    pub image: bool,
703    pub thematic_break: bool,
704    /// [`LeafDoc::insert_footnote`]. Markdown and djot spell the pair; HTML does
705    /// not, so the button goes rather than dims into a refusal.
706    pub footnote: bool,
707    pub code_language: bool,
708    /// The grid controls. Gate them on this *and* [`LeafDoc::caret_in_table`]:
709    /// this asks whether the format's tables are editable, that whether the
710    /// caret is in one.
711    pub table: bool,
712    /// Shift+Return inside a cell — [`LeafDoc::cell_line_break`].
713    pub cell_line_break: bool,
714}
715
716impl From<CoreCapabilities> for Capabilities {
717    fn from(c: CoreCapabilities) -> Self {
718        Self {
719            bold: c.bold,
720            italic: c.italic,
721            code: c.code,
722            mark: c.mark,
723            underline: c.underline,
724            strike: c.strike,
725            mark_color: c.mark_color,
726            superscript: c.superscript,
727            // `subscript` is a Swift keyword; uniffi escapes it in the generated
728            // binding (`caps.`subscript``), so the field keeps its real name
729            // here rather than wearing a suffix on both sides of the boundary.
730            subscript: c.subscript,
731            heading: c.heading,
732            blockquote: c.blockquote,
733            bullet_list: c.bullet_list,
734            ordered_list: c.ordered_list,
735            task: c.task,
736            link: c.link,
737            image: c.image,
738            thematic_break: c.thematic_break,
739            footnote: c.footnote,
740            code_language: c.code_language,
741            table: c.table,
742            cell_line_break: c.cell_line_break,
743        }
744    }
745}
746
747/// A table column's text alignment — the argument to
748/// [`LeafDoc::table_set_alignment`]. Mirrors twig's `Alignment`.
749#[derive(uniffi::Enum)]
750pub enum TableAlignment {
751    Default,
752    Left,
753    Right,
754    Center,
755}
756
757impl TableAlignment {
758    fn into_core(self) -> Alignment {
759        match self {
760            TableAlignment::Default => Alignment::Default,
761            TableAlignment::Left => Alignment::Left,
762            TableAlignment::Right => Alignment::Right,
763            TableAlignment::Center => Alignment::Center,
764        }
765    }
766}
767
768/// How much of the source markup the rich view exposes — the argument to
769/// [`LeafDoc::set_markup_mode`]. Mirrors [`leaf_core::MarkupMode`]; `None`
770/// is the default (the clean surface Diaryx ships, with typed syntax kept
771/// literal).
772///
773/// A single three-way ladder rather than a pair of toggles, because only three
774/// of the four combinations of its two axes — reveal the caret's delimiters,
775/// author markup from typing — are coherent. See [`leaf_core::MarkupMode`]
776/// for which one is left out and why.
777#[derive(uniffi::Enum)]
778pub enum MarkupMode {
779    None,
780    Shortcuts,
781    Full,
782}
783
784impl MarkupMode {
785    fn into_core(self) -> CoreMarkupMode {
786        match self {
787            MarkupMode::None => CoreMarkupMode::None,
788            MarkupMode::Shortcuts => CoreMarkupMode::Shortcuts,
789            MarkupMode::Full => CoreMarkupMode::Full,
790        }
791    }
792
793    fn from_core(mode: CoreMarkupMode) -> Self {
794        match mode {
795            CoreMarkupMode::None => MarkupMode::None,
796            CoreMarkupMode::Shortcuts => MarkupMode::Shortcuts,
797            CoreMarkupMode::Full => MarkupMode::Full,
798        }
799    }
800}
801
802/// How the rich view treats a soft break (a bare newline inside a paragraph) —
803/// the argument to [`LeafDoc::set_line_flow`]. Mirrors [`leaf_core::LineFlow`];
804/// `Fold` is the default (soft breaks reflow into the paragraph, as before).
805#[derive(uniffi::Enum)]
806pub enum LineFlow {
807    Fold,
808    Preserve,
809}
810
811impl LineFlow {
812    fn into_core(self) -> CoreLineFlow {
813        match self {
814            LineFlow::Fold => CoreLineFlow::Fold,
815            LineFlow::Preserve => CoreLineFlow::Preserve,
816        }
817    }
818
819    fn from_core(mode: CoreLineFlow) -> Self {
820        match mode {
821            CoreLineFlow::Fold => LineFlow::Fold,
822            CoreLineFlow::Preserve => LineFlow::Preserve,
823        }
824    }
825}
826
827/// A live leaf document bound for a native Apple frontend: `leaf_core::Doc` plus
828/// the wrap width the current viewport implies, behind a mutex. Constructed from
829/// an in-memory string and driven entirely through method calls — there is no
830/// filesystem behind it.
831#[derive(uniffi::Object)]
832pub struct LeafDoc {
833    inner: Mutex<Inner>,
834}
835
836/// The guarded state. Its methods assume the lock is held (they take `&mut
837/// self`); the [`LeafDoc`] exported wrappers acquire it, delegate, and return the
838/// resulting frame.
839struct Inner {
840    doc: Doc,
841    /// The wrap mode. `Some(cols)` wraps the map at that column budget (a terminal,
842    /// or a fixed-cell frontend); `None` builds it **unwrapped** — one row per block —
843    /// for a proportional GUI that wraps at its own pixel width. `build_visual`
844    /// caches on `(revision, width)`, so re-syncing when neither moved is free.
845    width: Option<usize>,
846    /// The host's current appearance, which a `<picture>`'s `prefers-color-scheme`
847    /// `<source>`s are matched against when resolving a block image's URL. Core
848    /// has no theme of its own, so this is AppKit/UIKit answering on its behalf;
849    /// defaults to light until the host calls
850    /// [`LeafDoc::set_dark_appearance`].
851    scheme: ColorScheme,
852}
853
854// SAFETY: `Doc` embeds a `twig::Editor`, which holds a `NonNull<TwigEditor>` and
855// is therefore `!Send`. UniFFI hands `LeafDoc` to Swift as a reference-counted
856// handle that must be `Send + Sync`, so `Inner` must be `Send`. This is sound
857// because:
858//   1. Every access goes through `LeafDoc::lock()` — the `Mutex` serializes all
859//      reads and mutations, so there is never concurrent access to the handle.
860//   2. twig's editor handle owns a plain heap allocation with no thread-affinity
861//      (no thread-locals, no per-thread state) — moving the pointer between
862//      threads is fine as long as use is serialized, which (1) guarantees.
863// The intended usage is still main-thread-driven; this impl only permits the
864// handle to cross threads safely, it does not invite concurrent use.
865unsafe impl Send for Inner {}
866
867impl Inner {
868    /// Rebuild the visual map at the current width. Cheap (cached) when nothing
869    /// changed; the guard that lets every movement/click method assume a fresh
870    /// grid regardless of call order.
871    fn sync(&mut self) {
872        match self.width {
873            Some(w) => self.doc.build_visual(w),
874            None => self.doc.build_visual_unwrapped(),
875        }
876    }
877
878    /// The plain text of visual row `row` in the active view — the string the
879    /// renderer concatenates its runs into. Backs the column⇄UTF-16 mapping.
880    fn row_text(&self, row: usize) -> String {
881        match self.doc.view {
882            View::Wysiwyg => self
883                .doc
884                .vmap
885                .rows
886                .get(row)
887                .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
888                .unwrap_or_default(),
889            View::Source => self
890                .doc
891                .source
892                .split('\n')
893                .nth(row)
894                .unwrap_or("")
895                .to_string(),
896        }
897    }
898
899    /// The `(row, display-column)` a source offset sits at in the active view.
900    fn pos_of_offset(&self, off: usize) -> (usize, usize) {
901        match self.doc.view {
902            View::Wysiwyg => self.doc.vmap.pos_of_offset(off),
903            View::Source => {
904                let s = &self.doc.source;
905                // Walk back to a character boundary, not just into range. Every
906                // offset here arrives from a UI toolkit counting in its own
907                // units, so one landing mid-character is ordinary input — and
908                // slicing on it aborts the process across an FFI boundary that
909                // has no unwinding. `snap_stop` and `text_in_range` already do
910                // this; this was the one door left open.
911                let mut off = off.min(s.len());
912                while off > 0 && !s.is_char_boundary(off) {
913                    off -= 1;
914                }
915                let row = s[..off].bytes().filter(|&b| b == b'\n').count();
916                let line_start = s[..off].rfind('\n').map_or(0, |i| i + 1);
917                (row, text_width(&s[line_start..off]))
918            }
919        }
920    }
921
922    /// The inclusive row span a source range occupies in the active view.
923    ///
924    /// The rich view defers to [`leaf_core::wysiwyg::VisualMap::row_range_for`],
925    /// where the reasoning lives. The source view has no hidden bytes at all —
926    /// every byte is drawn on the line it is written on — so counting newlines
927    /// is the whole answer, and the last row is the one holding the range's last
928    /// byte rather than the one past it.
929    fn row_range_for(&self, start: usize, end: usize) -> (usize, usize) {
930        match self.doc.view {
931            View::Wysiwyg => self.doc.vmap.row_range_for(start..end),
932            View::Source => {
933                let first = self.pos_of_offset(start).0;
934                let last = self.pos_of_offset(end.max(start.saturating_add(1)) - 1).0;
935                (first, last.max(first))
936            }
937        }
938    }
939
940    /// The source offset under a click at row `row`, `ch` UTF-16 units in.
941    fn offset_at(&mut self, row: usize, ch: usize) -> usize {
942        self.sync();
943        let col = utf16_to_col(&self.row_text(row), ch);
944        self.doc.click(row, col, false);
945        self.doc.caret
946    }
947
948    // ── position mapping for UITextInput (non-mutating; caret untouched) ───────
949    // These branch by view exactly as `pos_of_offset` does, so the WYSIWYG map and
950    // the raw-source grid each answer in their own coordinates.
951
952    /// The source offset of display column `col` on visual `row` — the inverse of
953    /// [`Self::pos_of_offset`] in column space.
954    fn offset_of_col(&self, row: usize, col: usize) -> usize {
955        match self.doc.view {
956            View::Wysiwyg => self.doc.vmap.offset_of_pos(row, col),
957            View::Source => {
958                let line = self.row_text(row);
959                let (mut c, mut b) = (0usize, 0usize);
960                for g in line.graphemes(true) {
961                    if c >= col {
962                        break;
963                    }
964                    c += text_width(g);
965                    b += g.len();
966                }
967                self.source_line_start(row) + b
968            }
969        }
970    }
971
972    /// The byte offset where visual `row` begins in the source view.
973    fn source_line_start(&self, row: usize) -> usize {
974        self.doc
975            .source
976            .split('\n')
977            .take(row)
978            .map(|l| l.len() + 1)
979            .sum()
980    }
981
982    /// The next caret stop after `off`, or `None` at the end.
983    fn stop_after(&self, off: usize) -> Option<usize> {
984        match self.doc.view {
985            View::Wysiwyg => self.doc.vmap.stop_after(off),
986            View::Source => {
987                let s = &self.doc.source;
988                if off >= s.len() {
989                    None
990                } else {
991                    Some(
992                        s[off..]
993                            .grapheme_indices(true)
994                            .nth(1)
995                            .map_or(s.len(), |(i, _)| off + i),
996                    )
997                }
998            }
999        }
1000    }
1001
1002    /// The previous caret stop before `off`, or `None` at the start.
1003    fn stop_before(&self, off: usize) -> Option<usize> {
1004        match self.doc.view {
1005            View::Wysiwyg => self.doc.vmap.stop_before(off),
1006            View::Source => {
1007                let s = &self.doc.source;
1008                let off = off.min(s.len());
1009                if off == 0 {
1010                    None
1011                } else {
1012                    s[..off].grapheme_indices(true).next_back().map(|(i, _)| i)
1013                }
1014            }
1015        }
1016    }
1017
1018    /// Snap `off` to a valid caret stop (WYSIWYG) / char boundary (source).
1019    fn snap_stop(&self, off: usize) -> usize {
1020        let s = &self.doc.source;
1021        let mut off = off.min(s.len());
1022        match self.doc.view {
1023            View::Wysiwyg => self.doc.vmap.snap_to_stop(off),
1024            View::Source => {
1025                while off > 0 && !s.is_char_boundary(off) {
1026                    off -= 1;
1027                }
1028                off
1029            }
1030        }
1031    }
1032
1033    /// The navigable visual row above `row`, if any.
1034    fn nav_above(&self, row: usize) -> Option<usize> {
1035        match self.doc.view {
1036            View::Wysiwyg => self.doc.vmap.navigable_above(row),
1037            View::Source => (row > 0).then(|| row - 1),
1038        }
1039    }
1040
1041    /// The navigable visual row below `row`, if any.
1042    fn nav_below(&self, row: usize) -> Option<usize> {
1043        match self.doc.view {
1044            View::Wysiwyg => self.doc.vmap.navigable_below(row),
1045            View::Source => {
1046                let n = self.doc.source.split('\n').count();
1047                (row + 1 < n).then_some(row + 1)
1048            }
1049        }
1050    }
1051
1052    /// Resolve the current document to a renderable frame of style runs. Called
1053    /// for the first paint, on resize, and by every mutating wrapper so one
1054    /// boundary crossing both edits and repaints.
1055    fn view(&mut self) -> DocView {
1056        self.sync();
1057
1058        let (ss, se) = self.doc.selection().unwrap_or((usize::MAX, usize::MAX));
1059
1060        // The two views speak different grids — the WYSIWYG map's resolved glyphs
1061        // vs the raw source split on newlines — and `caret_pos` branches to match,
1062        // so the rows must too or the caret lands on the wrong text.
1063        let rows = match self.doc.view {
1064            View::Wysiwyg => wysiwyg_rows(&self.doc.vmap, ss, se, self.doc.highlights()),
1065            View::Source => source_rows(&self.doc.source, ss, se),
1066        };
1067        // Structural tables, for a proportional renderer that draws its own grid;
1068        // none in the source view (the caret rides raw pipe text there).
1069        let tables = match self.doc.view {
1070            View::Wysiwyg => wysiwyg_tables(&self.doc.vmap, ss, se, self.doc.highlights()),
1071            View::Source => Vec::new(),
1072        };
1073
1074        // Leaf directives, on the same terms as the tables above: structural in
1075        // the rich view, absent in the source view.
1076        let directives = match self.doc.view {
1077            View::Wysiwyg => wysiwyg_directives(&self.doc.vmap),
1078            View::Source => Vec::new(),
1079        };
1080
1081        // Block media, on the same terms again: only the rich view has
1082        // placeholder rows to lay a view over.
1083        let media = match self.doc.view {
1084            View::Wysiwyg => wysiwyg_media(&self.doc.vmap, self.scheme),
1085            View::Source => Vec::new(),
1086        };
1087
1088        let (caret_row, caret_col) = self.doc.caret_pos();
1089        // Map the caret's display column to a UTF-16 text offset so a native
1090        // renderer can place it past wide glyphs (see [`DocView::caret_ch`]).
1091        let caret_ch = col_to_utf16(&self.row_text(caret_row), caret_col);
1092        // The selection's fixed (anchor) end, in the same row/UTF-16 terms.
1093        let (has_selection, anchor_row, anchor_ch) = match self.doc.selection() {
1094            Some(_) => {
1095                let a = self.doc.anchor.unwrap_or(self.doc.caret);
1096                let (ar, ac) = self.pos_of_offset(a);
1097                (true, ar, col_to_utf16(&self.row_text(ar), ac))
1098            }
1099            None => (false, caret_row, caret_ch),
1100        };
1101        let heading = self.doc.current_heading_level();
1102        let active = self
1103            .doc
1104            .active_inline_marks()
1105            .iter()
1106            .map(|k| mark_id(k).to_string())
1107            .collect();
1108        let link = self.doc.link_destination_at_caret();
1109        let mark_color = self.doc.mark_color_at_caret().map(MarkColor::from);
1110
1111        DocView {
1112            rows,
1113            tables,
1114            directives,
1115            media,
1116            caret_row: caret_row as u32,
1117            caret_col: caret_col as u32,
1118            caret_ch: caret_ch as u32,
1119            caret_src: self.doc.caret.min(self.doc.source.len()) as u32,
1120            has_selection,
1121            anchor_row: anchor_row as u32,
1122            anchor_ch: anchor_ch as u32,
1123            dirty: self.doc.dirty,
1124            can_undo: self.doc.can_undo(),
1125            can_redo: self.doc.can_redo(),
1126            view: self.doc.view_name().to_string(),
1127            heading,
1128            active,
1129            link,
1130            mark_color,
1131        }
1132    }
1133}
1134
1135#[uniffi::export]
1136impl LeafDoc {
1137    /// Parse `source` as `format` (`"markdown"`/`"md"`, `"djot"`/`"dj"`,
1138    /// `"html"`, `"xml"`) into a live, untitled document.
1139    #[uniffi::constructor]
1140    pub fn new(source: String, format: String) -> Result<Arc<Self>, LeafError> {
1141        let format = match format.to_ascii_lowercase().as_str() {
1142            "markdown" | "md" => Format::Markdown,
1143            "djot" | "dj" => Format::Djot,
1144            "html" | "htm" => Format::Html,
1145            "xml" => Format::Xml,
1146            other => {
1147                return Err(LeafError::UnknownFormat {
1148                    name: other.to_string(),
1149                });
1150            }
1151        };
1152        let doc = Doc::from_source(source, format).map_err(|e| LeafError::Parse {
1153            message: e.to_string(),
1154        })?;
1155        Ok(Arc::new(LeafDoc {
1156            inner: Mutex::new(Inner {
1157                doc,
1158                width: Some(80),
1159                scheme: ColorScheme::Light,
1160            }),
1161        }))
1162    }
1163
1164    /// Resolve the current document to a renderable frame — the first paint.
1165    pub fn view(&self) -> DocView {
1166        self.lock().view()
1167    }
1168
1169    /// Set the wrap width (in columns) the viewport implies and repaint. For a
1170    /// fixed-cell frontend (a terminal); a proportional GUI uses [`set_unwrapped`].
1171    pub fn set_width(&self, cols: u32) -> DocView {
1172        let mut g = self.lock();
1173        g.width = Some((cols as usize).max(1));
1174        g.view()
1175    }
1176
1177    /// Switch to **unwrapped** layout — one visual row per block, no column wrapping —
1178    /// and repaint. A proportional GUI calls this once at start-up, then wraps each
1179    /// row at its own pixel width (the caret/hit/selection geometry it derives from
1180    /// the pixel wrap; core still owns the caret model, in byte offsets). Idempotent
1181    /// and cheap to leave in place across edits.
1182    pub fn set_unwrapped(&self) -> DocView {
1183        let mut g = self.lock();
1184        g.width = None;
1185        g.view()
1186    }
1187
1188    /// Tell core whether the host is in a dark appearance, so a `<picture>`'s
1189    /// `prefers-color-scheme` `<source>`s resolve to the right banner. Call it
1190    /// from `viewDidChangeEffectiveAppearance` (AppKit) or
1191    /// `traitCollectionDidChange` (UIKit).
1192    ///
1193    /// Cheap to call repeatedly: resolving at the same appearance yields the same
1194    /// URLs, and a renderer keying its views by `src` tears nothing down.
1195    pub fn set_dark_appearance(&self, dark: bool) -> DocView {
1196        let mut g = self.lock();
1197        g.scheme = if dark {
1198            ColorScheme::Dark
1199        } else {
1200            ColorScheme::Light
1201        };
1202        g.view()
1203    }
1204
1205    /// Report how many visual rows each block media actually needs, measured from
1206    /// the views the renderer laid out, keyed by the media's `src`.
1207    ///
1208    /// Core does no I/O and can't know how tall a picture or a player is, so this
1209    /// is the only way a placeholder grows past its default single row. The loop
1210    /// is: lay out at the current reservation → measure → call this → repaint if
1211    /// it changed. Handing over the same measurements again is a no-op, so a
1212    /// renderer can report its current state each frame without diffing first.
1213    ///
1214    /// A frontend that lays media out in its own units and simply reserves the
1215    /// vertical space itself (the way the gpui GUI does with images) never needs
1216    /// to call this at all.
1217    pub fn set_media_rows(&self, heights: Vec<MediaHeight>) -> DocView {
1218        let mut g = self.lock();
1219        g.doc.set_media_rows(
1220            heights
1221                .into_iter()
1222                .map(|h| (h.destination, h.rows.max(1) as usize))
1223                .collect(),
1224        );
1225        g.view()
1226    }
1227
1228    /// Insert a block-level image, video, or audio at the caret. Any selection
1229    /// becomes the alt / fallback text. See [`leaf_core::Doc::insert_media`] for
1230    /// the markup each kind spells.
1231    pub fn insert_media(&self, kind: MediaKind, destination: String, alt: String) -> DocView {
1232        let mut g = self.lock();
1233        let kind = match kind {
1234            MediaKind::Image => CoreMediaKind::Image,
1235            MediaKind::Video => CoreMediaKind::Video,
1236            MediaKind::Audio => CoreMediaKind::Audio,
1237        };
1238        g.doc.insert_media(kind, &destination, &alt);
1239        g.view()
1240    }
1241
1242    /// Insert a thematic break (`---`) at the caret — the toolbar's Horizontal
1243    /// Rule button. See [`leaf_core::Doc::insert_thematic_break`] for how it
1244    /// handles a selection, a blank line, and the caret sitting mid-paragraph,
1245    /// mid-list, or inside a quote.
1246    pub fn insert_thematic_break(&self) -> DocView {
1247        let mut g = self.lock();
1248        g.doc.insert_thematic_break();
1249        g.view()
1250    }
1251
1252    /// The current source text — for a save (write to disk / iCloud / a document
1253    /// wrapper) or a source-view display.
1254    pub fn source(&self) -> String {
1255        self.lock().doc.source.clone()
1256    }
1257
1258    /// The selected text, if any — for a clipboard copy/cut.
1259    pub fn selected_text(&self) -> Option<String> {
1260        self.lock().doc.selected_text().map(str::to_string)
1261    }
1262
1263    /// The selection as a quote with up to `context` characters of what
1264    /// surrounded it, cut from the **source** — the shape a host that cites or
1265    /// annotates a passage wants, findable in the document again by plain
1266    /// string search. `None` when nothing is selected. See
1267    /// `leaf_core::Doc::selection_quote`.
1268    pub fn selection_quote(&self, context: u32) -> Option<SelectionQuote> {
1269        let g = self.lock();
1270        g.doc
1271            .selection_quote(context as usize)
1272            .map(|q| SelectionQuote {
1273                exact: q.exact,
1274                prefix: q.prefix,
1275                suffix: q.suffix,
1276                start: q.start as u64,
1277                end: q.end as u64,
1278            })
1279    }
1280
1281    /// Whether the document refuses to change — see `set_read_only`.
1282    pub fn read_only(&self) -> bool {
1283        self.lock().doc.read_only()
1284    }
1285
1286    /// Turn the read-only gate on or off — a *reading* surface over the same
1287    /// rendering, selection and navigation the editor has. Enforced in core at
1288    /// the three doors every mutation goes through, so a host that also quiets
1289    /// its input chrome is polishing, not protecting.
1290    pub fn set_read_only(&self, on: bool) -> DocView {
1291        let mut g = self.lock();
1292        g.doc.set_read_only(on);
1293        g.view()
1294    }
1295
1296    /// Replace the host-painted source ranges wholesale and repaint — see
1297    /// `leaf_core::Doc::set_highlights` for why it is a replace, and
1298    /// [`Highlight`] for what one is.
1299    pub fn set_highlights(&self, highlights: Vec<Highlight>) -> DocView {
1300        let mut g = self.lock();
1301        let hls = highlights
1302            .into_iter()
1303            .map(|h| leaf_core::Highlight {
1304                start: h.start as usize,
1305                end: h.end as usize,
1306                id: h.id,
1307                color: h.color,
1308                marker: h.marker,
1309            })
1310            .collect();
1311        g.doc.set_highlights(hls);
1312        g.view()
1313    }
1314
1315    /// The id of the highlight covering source `offset`, if one does — what a
1316    /// frontend asks when the reader activates a spot on the page.
1317    pub fn highlight_at(&self, offset: u32) -> Option<String> {
1318        self.lock()
1319            .doc
1320            .highlight_at(offset as usize)
1321            .map(|h| h.id.clone())
1322    }
1323
1324    /// The host-painted ranges as last set, sorted by start — what a frontend
1325    /// walks to lay out margin markers.
1326    pub fn highlights(&self) -> Vec<Highlight> {
1327        self.lock()
1328            .doc
1329            .highlights()
1330            .iter()
1331            .map(|h| Highlight {
1332                start: h.start as u64,
1333                end: h.end as u64,
1334                id: h.id.clone(),
1335                color: h.color.clone(),
1336                marker: h.marker.clone(),
1337            })
1338            .collect()
1339    }
1340
1341    /// Mark the buffer saved after the host persisted [`LeafDoc::source`] its own
1342    /// way — clears the dirty flag without touching a filesystem.
1343    pub fn mark_saved(&self) -> DocView {
1344        let mut g = self.lock();
1345        g.doc.mark_saved();
1346        g.view()
1347    }
1348
1349    // ── text input ───────────────────────────────────────────────────────────
1350
1351    pub fn insert(&self, text: String) -> DocView {
1352        let mut g = self.lock();
1353        g.doc.insert(&text);
1354        g.view()
1355    }
1356
1357    pub fn paste(&self, text: String) -> DocView {
1358        let mut g = self.lock();
1359        g.doc.paste(&text);
1360        g.view()
1361    }
1362
1363    pub fn newline(&self) -> DocView {
1364        let mut g = self.lock();
1365        g.doc.newline();
1366        g.view()
1367    }
1368
1369    /// Tab away from a table: indent the caret's line (or the selected lines) one
1370    /// level, nesting a list item under its sibling. The frontend calls this when
1371    /// [`LeafDoc::cell_tab`] declined because the caret isn't in a table.
1372    pub fn indent(&self) -> DocView {
1373        let mut g = self.lock();
1374        g.doc.indent();
1375        g.view()
1376    }
1377
1378    /// Shift+Tab away from a table: take one indent level back off the caret's
1379    /// line (or the selected lines), unnesting a list item. The mirror of
1380    /// [`LeafDoc::indent`].
1381    pub fn outdent(&self) -> DocView {
1382        let mut g = self.lock();
1383        g.doc.outdent();
1384        g.view()
1385    }
1386
1387    // ── table keys ────────────────────────────────────────────────────────────
1388    // Tab, Return, and Shift+Return take on table meanings when the caret is in
1389    // one. Each returns `Some(view)` when it acted as a table key and `None` when
1390    // the caret isn't in a table — the frontend then does the key's ordinary job
1391    // (indent, newline), so these keep their meaning everywhere else.
1392
1393    /// Tab (`forward`) / Shift+Tab hops to the next/previous cell; Tab past the
1394    /// last cell appends a fresh row and enters it.
1395    pub fn cell_tab(&self, forward: bool) -> Option<DocView> {
1396        let mut g = self.lock();
1397        g.sync();
1398        g.doc.cell_tab(forward).then(|| g.view())
1399    }
1400
1401    /// Return drops to the cell below in the same column, appending a row at the
1402    /// table's bottom.
1403    pub fn cell_return(&self) -> Option<DocView> {
1404        let mut g = self.lock();
1405        g.sync();
1406        g.doc.cell_return().then(|| g.view())
1407    }
1408
1409    /// Shift+Return inserts a hard line break *within* the current cell.
1410    pub fn cell_line_break(&self) -> Option<DocView> {
1411        let mut g = self.lock();
1412        g.sync();
1413        g.doc.cell_line_break().then(|| g.view())
1414    }
1415
1416    pub fn backspace(&self) -> DocView {
1417        let mut g = self.lock();
1418        g.doc.backspace();
1419        g.view()
1420    }
1421
1422    pub fn delete_forward(&self) -> DocView {
1423        let mut g = self.lock();
1424        g.doc.delete_forward();
1425        g.view()
1426    }
1427
1428    pub fn delete_word_back(&self) -> DocView {
1429        let mut g = self.lock();
1430        g.doc.delete_word_back();
1431        g.view()
1432    }
1433
1434    pub fn delete_word_forward(&self) -> DocView {
1435        let mut g = self.lock();
1436        g.doc.delete_word_forward();
1437        g.view()
1438    }
1439
1440    // ── caret movement ───────────────────────────────────────────────────────
1441    // Each syncs the grid first (movement reads the stop table / column layout),
1442    // moves, then repaints — `Inner::view` re-syncs but that's the cached no-op.
1443
1444    pub fn move_left(&self, extend: bool) -> DocView {
1445        let mut g = self.lock();
1446        g.sync();
1447        g.doc.move_left(extend);
1448        g.view()
1449    }
1450
1451    pub fn move_right(&self, extend: bool) -> DocView {
1452        let mut g = self.lock();
1453        g.sync();
1454        g.doc.move_right(extend);
1455        g.view()
1456    }
1457
1458    pub fn move_up(&self, extend: bool) -> DocView {
1459        let mut g = self.lock();
1460        g.sync();
1461        g.doc.move_up(extend);
1462        g.view()
1463    }
1464
1465    pub fn move_down(&self, extend: bool) -> DocView {
1466        let mut g = self.lock();
1467        g.sync();
1468        g.doc.move_down(extend);
1469        g.view()
1470    }
1471
1472    pub fn move_word_left(&self, extend: bool) -> DocView {
1473        let mut g = self.lock();
1474        g.sync();
1475        g.doc.move_word_left(extend);
1476        g.view()
1477    }
1478
1479    pub fn move_word_right(&self, extend: bool) -> DocView {
1480        let mut g = self.lock();
1481        g.sync();
1482        g.doc.move_word_right(extend);
1483        g.view()
1484    }
1485
1486    pub fn move_home(&self, extend: bool) -> DocView {
1487        let mut g = self.lock();
1488        g.sync();
1489        g.doc.move_home(extend);
1490        g.view()
1491    }
1492
1493    pub fn move_end(&self, extend: bool) -> DocView {
1494        let mut g = self.lock();
1495        g.sync();
1496        g.doc.move_end(extend);
1497        g.view()
1498    }
1499
1500    pub fn move_doc_start(&self, extend: bool) -> DocView {
1501        let mut g = self.lock();
1502        g.sync();
1503        g.doc.move_doc_start(extend);
1504        g.view()
1505    }
1506
1507    pub fn move_doc_end(&self, extend: bool) -> DocView {
1508        let mut g = self.lock();
1509        g.sync();
1510        g.doc.move_doc_end(extend);
1511        g.view()
1512    }
1513
1514    pub fn select_all(&self) -> DocView {
1515        let mut g = self.lock();
1516        g.doc.select_all();
1517        g.view()
1518    }
1519
1520    /// Place the caret from a click, in core's column grid: `row` indexes the
1521    /// visual [`Row`]s and `col` is the glyph column within it. Core clamps both
1522    /// to real caret stops. Prefer [`LeafDoc::click_ch`] from a proportional
1523    /// renderer.
1524    pub fn click(&self, row: u32, col: u32, extend: bool) -> DocView {
1525        let mut g = self.lock();
1526        g.sync();
1527        g.doc.click(row as usize, col as usize, extend);
1528        g.view()
1529    }
1530
1531    /// Place the caret from a click whose horizontal position is a **UTF-16
1532    /// offset** into the visual row's text — what `characterIndex(for:)` hands
1533    /// back. Converted to core's display column before clicking, so a proportional
1534    /// renderer never reasons about column widths itself.
1535    pub fn click_ch(&self, row: u32, ch: u32, extend: bool) -> DocView {
1536        let mut g = self.lock();
1537        g.sync();
1538        let col = utf16_to_col(&g.row_text(row as usize), ch as usize);
1539        g.doc.click(row as usize, col, extend);
1540        g.view()
1541    }
1542
1543    /// Select the word under a click (row, `ch`) — the double-click gesture.
1544    pub fn select_word_ch(&self, row: u32, ch: u32) -> DocView {
1545        let mut g = self.lock();
1546        let off = g.offset_at(row as usize, ch as usize);
1547        g.doc.select_word_at(off);
1548        g.view()
1549    }
1550
1551    /// Select the whole logical text block under a click (row, `ch`) — the
1552    /// triple-click gesture. Grabs the entire block even where it soft-wraps.
1553    pub fn select_block_ch(&self, row: u32, ch: u32) -> DocView {
1554        let mut g = self.lock();
1555        let off = g.offset_at(row as usize, ch as usize);
1556        g.doc.select_block_at(off);
1557        g.view()
1558    }
1559
1560    /// Mirror a native selection into the model: `[anchor, focus]` given as
1561    /// row + UTF-16 offset pairs. Each is resolved to a source offset the way a
1562    /// click is, then set as the selection's fixed and moving ends. A collapsed
1563    /// range (`anchor == focus`) just places the caret.
1564    pub fn set_selection(
1565        &self,
1566        anchor_row: u32,
1567        anchor_ch: u32,
1568        focus_row: u32,
1569        focus_ch: u32,
1570    ) -> DocView {
1571        let mut g = self.lock();
1572        let anchor = g.offset_at(anchor_row as usize, anchor_ch as usize);
1573        let focus = g.offset_at(focus_row as usize, focus_ch as usize);
1574        g.doc.place_caret(anchor, false);
1575        if anchor != focus {
1576            g.doc.place_caret(focus, true);
1577        }
1578        g.view()
1579    }
1580
1581    // ── rich clipboard (mirrors leaf-tui / leaf-gpui / leaf-wasm) ─────────────
1582
1583    /// The current selection rendered to HTML by twig — the rich flavor a copy
1584    /// writes alongside the plain [`LeafDoc::selected_text`]. `None` when nothing
1585    /// is selected.
1586    pub fn selection_html(&self) -> Option<String> {
1587        self.lock().doc.selection_html()
1588    }
1589
1590    /// Paste, preferring the clipboard's rich (`text/html`) flavor: twig parses
1591    /// `html` into the document's own markup and inserts it. Falls back to the
1592    /// plain `text` when there's no HTML or it doesn't parse.
1593    pub fn paste_rich(&self, html: Option<String>, text: String) -> DocView {
1594        let mut g = self.lock();
1595        let took = html.as_deref().is_some_and(|h| g.doc.paste_html(h));
1596        if !took {
1597            g.doc.paste(&text);
1598        }
1599        g.view()
1600    }
1601
1602    // ── formatting commands (mirror leaf-gpui's EditorCommand) ────────────────
1603
1604    pub fn toggle_bold(&self) -> DocView {
1605        let mut g = self.lock();
1606        g.doc.toggle(InlineKind::Strong);
1607        g.view()
1608    }
1609
1610    pub fn toggle_italic(&self) -> DocView {
1611        let mut g = self.lock();
1612        g.doc.toggle(InlineKind::Emph);
1613        g.view()
1614    }
1615
1616    pub fn toggle_code(&self) -> DocView {
1617        let mut g = self.lock();
1618        g.doc.toggle(InlineKind::Verbatim);
1619        g.view()
1620    }
1621
1622    pub fn toggle_mark(&self) -> DocView {
1623        let mut g = self.lock();
1624        g.doc.toggle(InlineKind::Mark);
1625        g.view()
1626    }
1627
1628    /// Whether the caret stands in a highlight — what a colour palette enables
1629    /// itself by, since a colour is a property of a highlight that already
1630    /// exists. The caret-side half of [`Capabilities::mark_color`].
1631    pub fn caret_in_mark(&self) -> bool {
1632        self.lock().doc.caret_in_mark()
1633    }
1634
1635    /// Colour the highlight at the caret, or clear its colour with `None`.
1636    ///
1637    /// Markdown only — `==🔴 text==` is its spelling and djot has none — and
1638    /// only where there is a highlight to colour: this does not make one, so a
1639    /// coloured highlight from bare text is [`LeafDoc::toggle_mark`] and then
1640    /// this, which is the order the button and its palette already sit in. Both
1641    /// refusals leave the document alone and say so in the status line.
1642    pub fn set_mark_color(&self, color: Option<MarkColor>) -> DocView {
1643        let mut g = self.lock();
1644        g.doc.set_mark_color(color.map(CoreMarkColor::from));
1645        g.view()
1646    }
1647
1648    /// One press of a colour swatch: colour the highlight at the caret, or —
1649    /// over a selection that isn't highlighted yet — highlight it and colour it,
1650    /// as **one** undo step.
1651    ///
1652    /// [`LeafDoc::set_mark_color`] is the exact gesture; this is the compound a
1653    /// toolbar presses, and it lives in core so that every frontend answers
1654    /// "what does a swatch mean over plain text" the same way. `None` clears the
1655    /// colour, and over an unhighlighted selection means simply "highlight
1656    /// this". A bare caret in no highlight is left alone.
1657    pub fn highlight(&self, color: Option<MarkColor>) -> DocView {
1658        let mut g = self.lock();
1659        g.doc.highlight(color.map(CoreMarkColor::from));
1660        g.view()
1661    }
1662
1663    pub fn toggle_underline(&self) -> DocView {
1664        let mut g = self.lock();
1665        g.doc.toggle(InlineKind::Insert);
1666        g.view()
1667    }
1668
1669    pub fn toggle_strike(&self) -> DocView {
1670        let mut g = self.lock();
1671        g.doc.toggle(InlineKind::Delete);
1672        g.view()
1673    }
1674
1675    pub fn set_paragraph(&self) -> DocView {
1676        let mut g = self.lock();
1677        g.doc.set_block(BlockKind::Paragraph);
1678        g.view()
1679    }
1680
1681    /// Toggle the current block to a heading of `level` (1–6); toggling the
1682    /// active level off returns it to a paragraph, per core.
1683    pub fn set_heading(&self, level: u32) -> DocView {
1684        let mut g = self.lock();
1685        g.doc.toggle_heading(level);
1686        g.view()
1687    }
1688
1689    pub fn toggle_blockquote(&self) -> DocView {
1690        let mut g = self.lock();
1691        g.doc.toggle_blockquote();
1692        g.view()
1693    }
1694
1695    pub fn toggle_list(&self, ordered: bool) -> DocView {
1696        let mut g = self.lock();
1697        g.doc.toggle_list(ordered);
1698        g.view()
1699    }
1700
1701    /// Tick or untick the task item at the caret. See
1702    /// [`leaf_core::Doc::toggle_task_checked`].
1703    pub fn toggle_task_checked(&self) -> DocView {
1704        let mut g = self.lock();
1705        g.doc.toggle_task_checked();
1706        g.view()
1707    }
1708
1709    /// Tick or untick the task item covering `offset` — a tap on a rendered
1710    /// checkbox, which must not drag the caret across the document to get there.
1711    pub fn toggle_task_at(&self, offset: u64) -> DocView {
1712        let mut g = self.lock();
1713        g.doc.toggle_task_at(offset as usize);
1714        g.view()
1715    }
1716
1717    /// Give the list item at the caret a checkbox, or take its checkbox away.
1718    pub fn toggle_task_item(&self) -> DocView {
1719        let mut g = self.lock();
1720        g.doc.toggle_task_item();
1721        g.view()
1722    }
1723
1724    /// Whether the item at the caret has a box and which way it faces — `None`
1725    /// for a plain list item or no item at all. Drives a toolbar's checked state.
1726    pub fn task_checked_at_caret(&self) -> Option<bool> {
1727        let mut g = self.lock();
1728        g.doc.task_checked_at_caret()
1729    }
1730
1731    /// Which of the formatting commands above this document's format can
1732    /// actually spell — one flag per control, for building the toolbar.
1733    ///
1734    /// Read once when a document opens: the answer depends only on the format,
1735    /// so it cannot change under an edit. Every command refuses on its own
1736    /// regardless — the model is the authority, not the toolbar — so a frontend
1737    /// that ignores this stays correct, it just offers buttons whose only effect
1738    /// is a line in the status bar.
1739    ///
1740    /// Don't collapse it to one flag. An HTML document takes ⌘B, ⌘I and inline
1741    /// code (its marks are a tag pair) while refusing every heading, list, quote
1742    /// and link, and Markdown refuses the underline djot spells — so a toolbar
1743    /// driven by [`Self::authorable`] alone would be wrong in both directions.
1744    pub fn capabilities(&self) -> Capabilities {
1745        self.lock().doc.capabilities().into()
1746    }
1747
1748    /// Whether this document's format offers *any* door in — `false` only for a
1749    /// wholly parse-only one (XML), where an app may as well open the file
1750    /// read-only and hide the formatting section outright. For anything finer,
1751    /// including whether to dim an individual button, use [`Self::capabilities`].
1752    pub fn authorable(&self) -> bool {
1753        self.lock().doc.authorable()
1754    }
1755
1756    // ── table editing ─────────────────────────────────────────────────────────
1757
1758    /// Whether the caret is inside a table — for enabling the table controls.
1759    /// Pair it with [`Capabilities::table`]: the caret is genuinely inside an
1760    /// HTML `<table>`, and the grid controls still cannot edit one.
1761    pub fn caret_in_table(&self) -> bool {
1762        self.lock().doc.caret_in_table()
1763    }
1764
1765    /// Insert an empty row below (`below`) or above the caret's row.
1766    pub fn table_insert_row(&self, below: bool) -> DocView {
1767        let mut g = self.lock();
1768        g.doc.table_insert_row(below);
1769        g.view()
1770    }
1771
1772    /// Delete the caret's row (not the header or the last body row).
1773    pub fn table_delete_row(&self) -> DocView {
1774        let mut g = self.lock();
1775        g.doc.table_delete_row();
1776        g.view()
1777    }
1778
1779    /// Insert an empty column right (`right`) or left of the caret's column.
1780    pub fn table_insert_column(&self, right: bool) -> DocView {
1781        let mut g = self.lock();
1782        g.doc.table_insert_column(right);
1783        g.view()
1784    }
1785
1786    /// Delete the caret's column (unless it is the only one).
1787    pub fn table_delete_column(&self) -> DocView {
1788        let mut g = self.lock();
1789        g.doc.table_delete_column();
1790        g.view()
1791    }
1792
1793    /// Set the caret's column to `alignment`.
1794    pub fn table_set_alignment(&self, alignment: TableAlignment) -> DocView {
1795        let mut g = self.lock();
1796        g.doc.table_set_alignment(alignment.into_core());
1797        g.view()
1798    }
1799
1800    /// Move the caret's row one place down (`down`) or up.
1801    pub fn table_move_row(&self, down: bool) -> DocView {
1802        let mut g = self.lock();
1803        g.doc.table_move_row(down);
1804        g.view()
1805    }
1806
1807    /// Move the caret's column one place right (`right`) or left.
1808    pub fn table_move_column(&self, right: bool) -> DocView {
1809        let mut g = self.lock();
1810        g.doc.table_move_column(right);
1811        g.view()
1812    }
1813
1814    pub fn insert_link(&self, destination: String) -> DocView {
1815        let mut g = self.lock();
1816        g.doc.insert_link(&destination);
1817        g.view()
1818    }
1819
1820    /// The destination of the link under the caret, if the caret is inside one —
1821    /// so a frontend can open it (⌘-click / "Open Link") or show it. `None` when the
1822    /// caret isn't on a link.
1823    pub fn link_destination_at_caret(&self) -> Option<String> {
1824        self.lock().doc.link_destination_at_caret()
1825    }
1826
1827    /// The source of the image the caret stands in — the `src` of an
1828    /// `![](cat.png)` or a `<img>`, exactly as the document spells it. `None`
1829    /// when the caret is in no image.
1830    ///
1831    /// Two hosts ask. An image prompt seeds from it, so editing an existing
1832    /// picture starts from its current URL rather than blank; and a host that
1833    /// gives an attachment a place of its own — a node, a page, a file
1834    /// inspector — asks it to answer "show me *this* one" from a menu raised
1835    /// over the body. See `LeafEditorModel.onShowMedia` in the Swift package.
1836    ///
1837    /// A caret resting just after a block image (its trailing stop) is already
1838    /// past it and gets `None`, which is the same half-open rule
1839    /// [`link_destination_at_caret`](Self::link_destination_at_caret) follows.
1840    pub fn image_destination_at_caret(&self) -> Option<String> {
1841        self.lock().doc.image_destination_at_caret()
1842    }
1843
1844    /// The destination of the link at byte offset `off` —
1845    /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
1846    /// the caret isn't.
1847    ///
1848    /// What a frontend drawing part of the document *outside* the document asks:
1849    /// a footnote's text in a popover has link runs in it, and this is how those
1850    /// runs learn where they point, since a `Run` carries how a span looks and
1851    /// not what it means.
1852    pub fn link_destination_at(&self, off: u32) -> Option<String> {
1853        self.lock().doc.link_destination_at(off as usize)
1854    }
1855
1856    /// Where the locator `id` lands in this document — the `#v2` half of a
1857    /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
1858    /// answers to it, which is a host's cue to open the document at its top
1859    /// rather than refuse to go.
1860    ///
1861    /// The query that gives a link finer granularity than the file. It reads an
1862    /// explicit `{#v1}`, a djot heading's minted id, or (for Markdown, which
1863    /// mints none) a heading's own words slugged — see [`leaf_core::Doc::locate`].
1864    ///
1865    /// Asked of *any* document, not only the open one: a host peeking at a
1866    /// citation builds a [`LeafDoc`] over the other file's bytes and asks this,
1867    /// which is what lets a hover show the verse instead of the filename.
1868    pub fn locate(&self, id: String) -> Option<LandingView> {
1869        self.lock().doc.locate(&id).map(LandingView::from)
1870    }
1871
1872    /// Write a footnote at the caret — the toolbar's Footnote button. Both the
1873    /// `[^1]` and the definition it needs go in as one edit (one undo takes both
1874    /// back), the label is the lowest number the document has free, and the caret
1875    /// is left **in the empty note** ready to type it. Gate the button on
1876    /// [`Capabilities::footnote`]; see [`leaf_core::Doc::insert_footnote`].
1877    pub fn insert_footnote(&self) -> DocView {
1878        let mut g = self.lock();
1879        g.doc.insert_footnote();
1880        g.view()
1881    }
1882
1883    /// The footnote reference under the caret, resolved to the note it names —
1884    /// so a frontend can show the note when a reader activates a `[1]`, instead
1885    /// of the nothing a reference click used to do. `None` when the caret isn't
1886    /// on a reference; see [`FootnoteView`] for the reference that resolved to
1887    /// no definition.
1888    pub fn footnote_at_caret(&self) -> Option<FootnoteView> {
1889        self.lock().doc.footnote_at_caret().map(FootnoteView::from)
1890    }
1891
1892    /// The footnote reference at byte offset `off`, resolved to the note it
1893    /// names — [`footnote_at_caret`](Self::footnote_at_caret) for a place the
1894    /// caret isn't.
1895    ///
1896    /// This is what a hover asks: a pointer resting on a `[1]` wants the note's
1897    /// text in a popover, and moving the caret to find out would yank the reader
1898    /// out of wherever they were typing.
1899    pub fn footnote_at(&self, off: u32) -> Option<FootnoteView> {
1900        self.lock()
1901            .doc
1902            .footnote_at(off as usize)
1903            .map(FootnoteView::from)
1904    }
1905
1906    /// The footnote definition the caret stands in, and where the reference that
1907    /// names it is — the return leg of [`footnote_at_caret`](Self::footnote_at_caret),
1908    /// so following a footnote is a round trip rather than a fall.
1909    ///
1910    /// `None` when the caret isn't in a definition, which is also how a frontend
1911    /// tells the two directions apart: the reference query answers up top, this
1912    /// one answers down in the notes, and never both at once.
1913    pub fn footnote_definition_at_caret(&self) -> Option<FootnoteDefView> {
1914        self.lock()
1915            .doc
1916            .footnote_definition_at_caret()
1917            .map(FootnoteDefView::from)
1918    }
1919
1920    pub fn undo(&self) -> DocView {
1921        let mut g = self.lock();
1922        g.doc.undo();
1923        g.view()
1924    }
1925
1926    pub fn redo(&self) -> DocView {
1927        let mut g = self.lock();
1928        g.doc.redo();
1929        g.view()
1930    }
1931
1932    /// Switch between the rendered WYSIWYG surface and the raw source.
1933    pub fn toggle_view(&self) -> DocView {
1934        let mut g = self.lock();
1935        g.doc.toggle_view();
1936        g.view()
1937    }
1938
1939    /// The current markup-exposure preference (see [`MarkupMode`]).
1940    pub fn markup_mode(&self) -> MarkupMode {
1941        MarkupMode::from_core(self.lock().doc.markup_mode())
1942    }
1943
1944    /// Set the markup-exposure preference. Returns a fresh view so a frontend
1945    /// can repaint — and under `Full` it must, because the returned view is the
1946    /// first one showing the caret's line raw. Diaryx leaves it at the `None`
1947    /// default.
1948    pub fn set_markup_mode(&self, mode: MarkupMode) -> DocView {
1949        let mut g = self.lock();
1950        g.doc.set_markup_mode(mode.into_core());
1951        g.view()
1952    }
1953
1954    /// The current soft-break flow preference (see [`LineFlow`]).
1955    pub fn line_flow(&self) -> LineFlow {
1956        LineFlow::from_core(self.lock().doc.line_flow())
1957    }
1958
1959    /// Set the soft-break flow preference. Returns a fresh view so a frontend
1960    /// can repaint: like the markup-exposure preference this one changes rendering
1961    /// immediately, laying preserved soft breaks out as their own rows.
1962    pub fn set_line_flow(&self, mode: LineFlow) -> DocView {
1963        let mut g = self.lock();
1964        g.doc.set_line_flow(mode.into_core());
1965        g.view()
1966    }
1967}
1968
1969// ── UITextInput support ──────────────────────────────────────────────────────
1970// A `UITextPosition` on the Swift side wraps a source byte offset; these are the
1971// offset↔geometry, stepping, and range-editing primitives the protocol needs.
1972// Queries never move the caret — they only read the (synced) visual map — so the
1973// system can probe positions freely while the model's selection stays put.
1974#[uniffi::export]
1975impl LeafDoc {
1976    /// The caret's source offset (the selection's moving end).
1977    pub fn caret_offset(&self) -> u32 {
1978        self.lock().doc.caret as u32
1979    }
1980
1981    /// The selection's fixed end (equals the caret when there's no selection).
1982    pub fn anchor_offset(&self) -> u32 {
1983        let g = self.lock();
1984        g.doc.anchor.unwrap_or(g.doc.caret) as u32
1985    }
1986
1987    /// The last caret stop in the document — `UITextInput.endOfDocument`.
1988    pub fn doc_end_offset(&self) -> u32 {
1989        let mut g = self.lock();
1990        g.sync();
1991        let end = g.doc.source.len();
1992        g.snap_stop(end) as u32
1993    }
1994
1995    /// Snap an arbitrary offset to the nearest valid caret stop.
1996    pub fn snap_offset(&self, off: u32) -> u32 {
1997        let mut g = self.lock();
1998        g.sync();
1999        g.snap_stop(off as usize) as u32
2000    }
2001
2002    /// Where a source offset sits on screen: its visual `(row, ch)`.
2003    pub fn pos_for_offset(&self, off: u32) -> RowCol {
2004        let mut g = self.lock();
2005        g.sync();
2006        let (row, col) = g.pos_of_offset(off as usize);
2007        let ch = col_to_utf16(&g.row_text(row), col);
2008        RowCol {
2009            row: row as u32,
2010            ch: ch as u32,
2011        }
2012    }
2013
2014    /// The rows a source range covers, inclusive — for drawing a block away
2015    /// from where it sits (a footnote peek, a link peek, a landing flash).
2016    ///
2017    /// Ask this rather than mapping `start` and `end - 1` through
2018    /// [`Self::pos_for_offset`]. That pair reads correctly and is wrong: a
2019    /// block's last byte is often *hidden* — a note or a paragraph ending in a
2020    /// link ends inside the link's destination — and `pos_for_offset` snaps a
2021    /// hidden offset forward to the next visible glyph, which for a trailing
2022    /// one is on the next block's row. A peek slicing that span drew the block
2023    /// after it too. `pos_for_offset`'s snap is right for a caret and wrong for
2024    /// a span; this is the question spans should be asking.
2025    pub fn row_range_for(&self, start: u32, end: u32) -> RowRange {
2026        let mut g = self.lock();
2027        g.sync();
2028        let (first, last) = g.row_range_for(start as usize, end as usize);
2029        RowRange {
2030            first: first as u32,
2031            last: last as u32,
2032        }
2033    }
2034
2035    /// The source offset at visual `(row, ch)` — the inverse of
2036    /// [`Self::pos_for_offset`], for hit-testing a point to a position.
2037    pub fn offset_for_pos(&self, row: u32, ch: u32) -> u32 {
2038        let mut g = self.lock();
2039        g.sync();
2040        let col = utf16_to_col(&g.row_text(row as usize), ch as usize);
2041        g.offset_of_col(row as usize, col) as u32
2042    }
2043
2044    /// Move `off` by `delta` caret stops (negative = left) — `position(from:offset:)`.
2045    pub fn step_offset(&self, off: u32, delta: i32) -> u32 {
2046        let mut g = self.lock();
2047        g.sync();
2048        let mut o = g.snap_stop(off as usize);
2049        if delta >= 0 {
2050            for _ in 0..delta {
2051                match g.stop_after(o) {
2052                    Some(n) => o = n,
2053                    None => break,
2054                }
2055            }
2056        } else {
2057            for _ in 0..(-delta) {
2058                match g.stop_before(o) {
2059                    Some(p) => o = p,
2060                    None => break,
2061                }
2062            }
2063        }
2064        o as u32
2065    }
2066
2067    /// The count of caret stops between two offsets (signed) — `offset(from:to:)`.
2068    pub fn distance_offset(&self, from: u32, to: u32) -> i32 {
2069        let mut g = self.lock();
2070        g.sync();
2071        let (from, to) = (from as usize, to as usize);
2072        let (mut a, b, sign) = if from <= to {
2073            (from, to, 1i32)
2074        } else {
2075            (to, from, -1i32)
2076        };
2077        a = g.snap_stop(a);
2078        let mut n = 0i32;
2079        while a < b {
2080            match g.stop_after(a) {
2081                Some(x) => {
2082                    a = x;
2083                    n += 1;
2084                }
2085                None => break,
2086            }
2087        }
2088        n * sign
2089    }
2090
2091    /// The UTF-16 index at which source offset `off` sits in the visible text —
2092    /// the string `text_in_range(0, doc_end_offset())` returns — which is the
2093    /// character space AppKit's `NSTextInputClient` and `NSAccessibility` speak.
2094    ///
2095    /// leaf's own handle is the source byte offset, and the two are not one
2096    /// scale apart: WYSIWYG hides delimiters, a block gap is spelled as one
2097    /// `\n`, and a character outside the BMP is two UTF-16 units. A frontend
2098    /// hands the system an `NSRange` converted with this and turns the ranges
2099    /// it gets back through `offset_for_utf16_index`, so Look Up, dictation, and
2100    /// VoiceOver all index the same text the frontend drew.
2101    pub fn utf16_index_for_offset(&self, off: u32) -> u32 {
2102        let mut g = self.lock();
2103        g.sync();
2104        let off = (off as usize).min(g.doc.source.len());
2105        match g.doc.view {
2106            View::Wysiwyg => g.doc.vmap.visible_utf16_len(0, off) as u32,
2107            View::Source => {
2108                let off = g.snap_stop(off);
2109                g.doc.source[..off].encode_utf16().count() as u32
2110            }
2111        }
2112    }
2113
2114    /// The inverse of `utf16_index_for_offset`: the source offset of the
2115    /// visible character at UTF-16 `index`, or the document's end stop at or
2116    /// past the end of the text. An index inside a surrogate pair resolves to
2117    /// the character that owns it, and one on the `\n` a block gap is spelled
2118    /// with to the stop at the end of the block before it. Always a caret stop.
2119    pub fn offset_for_utf16_index(&self, index: u32) -> u32 {
2120        let mut g = self.lock();
2121        g.sync();
2122        let len = g.doc.source.len();
2123        let end = g.snap_stop(len);
2124        let index = index as usize;
2125        match g.doc.view {
2126            // A block separator resolves to the gap offset, which is no stop;
2127            // snapping lands it on the row end before it, the stop a caret
2128            // standing "after the last character" already means.
2129            View::Wysiwyg => g
2130                .doc
2131                .vmap
2132                .offset_at_visible_utf16(end, index)
2133                .map_or(end, |o| g.snap_stop(o)) as u32,
2134            View::Source => {
2135                let mut seen = 0usize;
2136                for (i, ch) in g.doc.source.char_indices() {
2137                    let n = ch.len_utf16();
2138                    if index < seen + n {
2139                        return i as u32;
2140                    }
2141                    seen += n;
2142                }
2143                end as u32
2144            }
2145        }
2146    }
2147
2148    /// The offset one navigable row up/down from `off`, keeping its column —
2149    /// `position(from:in: .up/.down)`. `None` at the top/bottom edge.
2150    pub fn vertical_offset(&self, off: u32, down: bool) -> Option<u32> {
2151        let mut g = self.lock();
2152        g.sync();
2153        let (row, col) = g.pos_of_offset(off as usize);
2154        let target = if down {
2155            g.nav_below(row)
2156        } else {
2157            g.nav_above(row)
2158        };
2159        target.map(|r| g.offset_of_col(r, col) as u32)
2160    }
2161
2162    /// The visible text between two offsets — `text(in:)`. In the WYSIWYG
2163    /// view this is *not* the raw source slice: a hidden inline-mark
2164    /// delimiter (`**`, `` ` ``, `_`) contributes nothing, matching what
2165    /// `distance_offset`/`step_offset` already count in this same offset
2166    /// space — while a genuine gap between blocks the range spans contributes
2167    /// one inserted `'\n'` that
2168    /// `distance_offset`/`step_offset` do *not* count (a block boundary costs
2169    /// caret motion zero stops there, by design — see
2170    /// `the_caret_skips_the_gap_between_two_paragraphs` in `leaf-core`'s
2171    /// `doc.rs`). So the relationship is
2172    /// `text_in_range(a, b).chars().count() >= distance_offset(a, b)`, not
2173    /// strict equality: the two agree exactly when `(a, b)` spans no block
2174    /// boundary, and `text_in_range` is never shorter, only ever as long or
2175    /// longer, when it does. That inequality is still what
2176    /// `UITextInput`'s own word/line tokenizer needs (see
2177    /// [`leaf_core::wysiwyg::VisualMap::visible_text`] for why): it only reads
2178    /// this string to find a boundary and converts the result back to a
2179    /// position via `position(from:offset:)`, which walks stops — the
2180    /// inserted character is never hit as one, it only keeps the tokenizer
2181    /// from reading two paragraphs' last/first words as a single run of
2182    /// letters. The source view has nothing hidden to begin with, so there
2183    /// this is still exactly the raw slice.
2184    pub fn text_in_range(&self, from: u32, to: u32) -> String {
2185        let mut g = self.lock();
2186        g.sync();
2187        let len = g.doc.source.len();
2188        let (mut a, mut b) = ((from as usize).min(len), (to as usize).min(len));
2189        if a > b {
2190            std::mem::swap(&mut a, &mut b);
2191        }
2192        match g.doc.view {
2193            View::Wysiwyg => g.doc.vmap.visible_text(a, b),
2194            View::Source => {
2195                let s = &g.doc.source;
2196                while a > 0 && !s.is_char_boundary(a) {
2197                    a -= 1;
2198                }
2199                while b < s.len() && !s.is_char_boundary(b) {
2200                    b += 1;
2201                }
2202                s[a..b].to_string()
2203            }
2204        }
2205    }
2206
2207    /// Set the selection to `[anchor, focus]` by source offsets — the setter behind
2208    /// `UITextInput.selectedTextRange` and handle dragging.
2209    pub fn set_selection_offsets(&self, anchor: u32, focus: u32) -> DocView {
2210        let mut g = self.lock();
2211        g.doc.place_caret(anchor as usize, false);
2212        if focus != anchor {
2213            g.doc.place_caret(focus as usize, true);
2214        }
2215        g.view()
2216    }
2217
2218    /// Select the exact source range `[start, end)`, snapping neither end to a
2219    /// visible caret stop — for a host painting a range it already knows the
2220    /// bytes of (a search hit, an annotation) rather than hit-testing a touch.
2221    ///
2222    /// `set_selection_offsets` above is the *other* verb: it goes through
2223    /// `place_caret`, which snaps, and is what a drag handle wants. This one
2224    /// takes the range as given, so a selection over `**needle**`'s inner word
2225    /// is the word and not one byte short of it.
2226    pub fn select_range(&self, start: u32, end: u32) -> DocView {
2227        let mut g = self.lock();
2228        g.doc.select_range(start as usize, end as usize);
2229        g.view()
2230    }
2231
2232    /// Replace the source range `[from, to]` with `text` — `replace(_:withText:)`.
2233    pub fn replace_range(&self, from: u32, to: u32, text: String) -> DocView {
2234        let mut g = self.lock();
2235        g.doc.place_caret(from as usize, false);
2236        if to != from {
2237            g.doc.place_caret(to as usize, true);
2238        }
2239        g.doc.insert(&text);
2240        g.view()
2241    }
2242}
2243
2244impl LeafDoc {
2245    /// Acquire the guard, recovering from a poisoned lock: a panic in `leaf-core`
2246    /// under one call shouldn't wedge the whole document handle for the app.
2247    fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
2248        self.inner.lock().unwrap_or_else(|p| p.into_inner())
2249    }
2250}
2251
2252/// The UTF-16 offset into `text` of display column `col`. Walks grapheme clusters
2253/// exactly as core measures columns ([`text_width`] per cluster), so a wide
2254/// cluster advances the column by its cells while the offset advances by its
2255/// UTF-16 length; the two coincide only on plain ASCII.
2256fn col_to_utf16(text: &str, col: usize) -> usize {
2257    let mut c = 0usize;
2258    let mut u = 0usize;
2259    for g in text.graphemes(true) {
2260        if c >= col {
2261            break;
2262        }
2263        c += text_width(g);
2264        u += g.chars().map(char::len_utf16).sum::<usize>();
2265    }
2266    u
2267}
2268
2269/// The display column of the grapheme boundary at or before UTF-16 offset `off`
2270/// — the inverse of [`col_to_utf16`], turning a native click position back into
2271/// core's column. Core then clamps the column to a real caret stop.
2272fn utf16_to_col(text: &str, off: usize) -> usize {
2273    let mut c = 0usize;
2274    let mut u = 0usize;
2275    for g in text.graphemes(true) {
2276        if u >= off {
2277            break;
2278        }
2279        u += g.chars().map(char::len_utf16).sum::<usize>();
2280        c += text_width(g);
2281    }
2282    c
2283}
2284
2285/// The renderer class id for a semantic role. Heading level is folded into the
2286/// id (`h1`…`h6`) so a single style rule per level applies.
2287fn role_name(r: Role) -> String {
2288    match r {
2289        Role::Body => "body".into(),
2290        Role::Heading(level) => format!("h{}", level.clamp(1, 6)),
2291        Role::Code => "code".into(),
2292        Role::Link => "link".into(),
2293        // The colour rides `Run::mark_color`, not the class id: a renderer that
2294        // styles `mark` and nothing else still draws a coloured highlight.
2295        Role::Mark(_) => "mark".into(),
2296        Role::ListMarker => "list".into(),
2297        Role::QuoteGutter => "quote".into(),
2298        Role::Rule => "rule".into(),
2299        Role::Image => "image".into(),
2300        Role::Delimiter => "delimiter".into(),
2301    }
2302}
2303
2304/// The toolbar id for an inline mark — kept in sync with the Swift button ids.
2305fn mark_id(kind: InlineKind) -> &'static str {
2306    match kind {
2307        InlineKind::Strong => "bold",
2308        InlineKind::Emph => "italic",
2309        InlineKind::Verbatim => "code",
2310        InlineKind::Mark => "mark",
2311        InlineKind::Insert => "underline",
2312        InlineKind::Delete => "strike",
2313        InlineKind::Superscript => "superscript",
2314        InlineKind::Subscript => "subscript",
2315    }
2316}
2317
2318/// The WYSIWYG rows: each visual row's glyphs coalesced into maximal runs of
2319/// identical `(style, selected)`. A glyph is selected when its source byte lies
2320/// in `[ss, se)`.
2321fn wysiwyg_rows(vmap: &VisualMap, ss: usize, se: usize, hls: &[leaf_core::Highlight]) -> Vec<Row> {
2322    vmap.rows
2323        .iter()
2324        .map(|vrow| {
2325            Row {
2326                runs: runs_of(&vrow.glyphs, ss, se, hls),
2327                decoration: vrow.decoration,
2328                code: vrow.code,
2329                code_lang: vrow.code_lang.clone(),
2330                directive: vrow.directive,
2331                directive_label: vrow.directive_label.clone(),
2332                // Straight off the row, not scanned out of its glyphs: an empty
2333                // heading has none to scan, and a renderer sizing the line by a
2334                // glyph's role drew `# ` at body height until it had text.
2335                heading: vrow.heading,
2336                boundary: vrow.boundary.map(|b| Boundary {
2337                    above: b.above.into(),
2338                    below: b.below.into(),
2339                }),
2340            }
2341        })
2342        .collect()
2343}
2344
2345/// Coalesce `glyphs` into maximal runs of identical `(style, selected)` — the
2346/// shared body of a row's runs and a table cell's runs. A glyph is selected when
2347/// its source byte lies in `[ss, se)`.
2348/// Split a cell's flat glyphs into its visual lines at the in-cell break glyphs
2349/// (`\n`, from a `<br>`), each with the source range it spans. A line runs from
2350/// its first glyph's offset to the break that ends it (`cell_end` for the last);
2351/// an empty line — a leading/trailing break, or an empty cell — collapses to a
2352/// single caret home. The break glyphs themselves are dropped (they hold no
2353/// caret), exactly as the monospace picture drops them.
2354fn cell_lines(
2355    glyphs: &[leaf_core::Glyph],
2356    cell_start: usize,
2357    cell_end: usize,
2358    ss: usize,
2359    se: usize,
2360    hls: &[leaf_core::Highlight],
2361) -> Vec<TableCellLineView> {
2362    let mut lines = Vec::new();
2363    let mut seg: Vec<leaf_core::Glyph> = Vec::new();
2364    // The current line's start offset: the cell's for the first line, then the
2365    // first real glyph after each break (`None` until that glyph is seen).
2366    let mut line_start: Option<usize> = Some(cell_start);
2367    for g in glyphs {
2368        if g.ch == '\n' {
2369            let start = line_start.unwrap_or(g.src);
2370            lines.push(TableCellLineView {
2371                runs: runs_of(&seg, ss, se, hls),
2372                start: start as u32,
2373                end: g.src as u32,
2374            });
2375            seg.clear();
2376            line_start = None;
2377        } else {
2378            if line_start.is_none() {
2379                line_start = Some(g.src);
2380            }
2381            seg.push(g.clone());
2382        }
2383    }
2384    lines.push(TableCellLineView {
2385        runs: runs_of(&seg, ss, se, hls),
2386        start: line_start.unwrap_or(cell_end) as u32,
2387        end: cell_end as u32,
2388    });
2389    lines
2390}
2391
2392fn runs_of(
2393    glyphs: &[leaf_core::Glyph],
2394    ss: usize,
2395    se: usize,
2396    hls: &[leaf_core::Highlight],
2397) -> Vec<Run> {
2398    // Which highlight (by index) covers a glyph — first by start when several
2399    // overlap, matching `Doc::highlight_at`. Part of the run key: a highlight
2400    // splits a run exactly the way the selection does, so its wash begins and
2401    // ends on its own bytes.
2402    let hl_of = |src: usize| hls.iter().position(|h| h.start <= src && src < h.end);
2403    let mut runs: Vec<Run> = Vec::new();
2404    let mut buf = String::new();
2405    // The style/selection/highlight key the run is accumulating, and the source
2406    // offset its first glyph came from — carried alongside rather than
2407    // re-derived, since a run's glyphs are contiguous but its *text* has no
2408    // offsets in it.
2409    let mut cur: Option<(LStyle, bool, Option<usize>, usize)> = None;
2410    for g in glyphs {
2411        let key = (g.style, g.src >= ss && g.src < se, hl_of(g.src));
2412        match cur {
2413            Some((style, sel, hl, _)) if (style, sel, hl) == key => buf.push(g.ch),
2414            _ => {
2415                if let Some((style, was_sel, hl, src)) = cur.take() {
2416                    runs.push(make_run(
2417                        std::mem::take(&mut buf),
2418                        style,
2419                        was_sel,
2420                        hl.map(|i| &hls[i]),
2421                        src,
2422                    ));
2423                }
2424                cur = Some((key.0, key.1, key.2, g.src));
2425                buf.push(g.ch);
2426            }
2427        }
2428    }
2429    if let Some((style, was_sel, hl, src)) = cur {
2430        runs.push(make_run(buf, style, was_sel, hl.map(|i| &hls[i]), src));
2431    }
2432    runs
2433}
2434
2435/// The leaf directives of a WYSIWYG frame — each with the `rows` span its
2436/// placeholder occupies (to be painted over) and the name/attributes a frontend
2437/// resolves it by. The peer of [`wysiwyg_tables`] for a block that renders as a
2438/// thing rather than as text.
2439fn wysiwyg_directives(vmap: &VisualMap) -> Vec<DirectiveView> {
2440    vmap.directives
2441        .iter()
2442        .map(|d| DirectiveView {
2443            start_row: d.rows_span.start as u32,
2444            end_row: d.rows_span.end as u32,
2445            name: d.name.clone(),
2446            label: d.label.clone(),
2447            attrs: d
2448                .attrs
2449                .iter()
2450                .map(|(k, v)| DirectiveAttr {
2451                    key: k.clone(),
2452                    value: v.clone().unwrap_or_default(),
2453                })
2454                .collect(),
2455        })
2456        .collect()
2457}
2458
2459/// The block media of a WYSIWYG frame — each with the `rows` span its
2460/// placeholder occupies (to be laid over) and what to build there. The peer of
2461/// [`wysiwyg_directives`], with each URL already resolved under `scheme`.
2462///
2463/// Resolving here rather than in Swift keeps the one piece of `<picture>` logic
2464/// core owns (`prefers-color-scheme` matching) in core. The `<source>` list
2465/// still crosses untouched, so a renderer can additionally pick by MIME — which
2466/// codecs AVFoundation has is not something core can know.
2467fn wysiwyg_media(vmap: &VisualMap, scheme: ColorScheme) -> Vec<MediaView> {
2468    vmap.media
2469        .iter()
2470        .map(|m| MediaView {
2471            start_row: m.rows_span.start as u32,
2472            end_row: m.rows_span.end as u32,
2473            kind: match m.kind {
2474                CoreMediaKind::Image => MediaKind::Image,
2475                CoreMediaKind::Video => MediaKind::Video,
2476                CoreMediaKind::Audio => MediaKind::Audio,
2477            },
2478            src: m.resolve(scheme).to_string(),
2479            poster: m.poster.clone(),
2480            alt: m.alt.clone(),
2481            sources: m
2482                .sources
2483                .iter()
2484                .map(|s| MediaSourceView {
2485                    media: s.media.clone(),
2486                    src: s.srcset.clone(),
2487                    mime: s.mime.clone(),
2488                })
2489                .collect(),
2490        })
2491        .collect()
2492}
2493
2494/// The structural tables of a WYSIWYG frame — each with the `rows` span its
2495/// box-glyph picture occupies (to be skipped) and its grid of styled cells.
2496fn wysiwyg_tables(
2497    vmap: &VisualMap,
2498    ss: usize,
2499    se: usize,
2500    hls: &[leaf_core::Highlight],
2501) -> Vec<TableView> {
2502    vmap.tables
2503        .iter()
2504        .map(|t| TableView {
2505            start_row: t.rows_span.start as u32,
2506            end_row: t.rows_span.end as u32,
2507            grid: t
2508                .grid
2509                .iter()
2510                .map(|row| TableRowView {
2511                    head: row.head,
2512                    cells: row
2513                        .cells
2514                        .iter()
2515                        .map(|cell| TableCellView {
2516                            lines: cell_lines(&cell.glyphs, cell.start, cell.end, ss, se, hls),
2517                            align: align_name(cell.align),
2518                            start: cell.start as u32,
2519                            end: cell.end as u32,
2520                        })
2521                        .collect(),
2522                })
2523                .collect(),
2524        })
2525        .collect()
2526}
2527
2528/// The wire name for a cell's column alignment.
2529fn align_name(a: Alignment) -> String {
2530    match a {
2531        Alignment::Left => "left",
2532        Alignment::Right => "right",
2533        Alignment::Center => "center",
2534        Alignment::Default => "default",
2535    }
2536    .to_string()
2537}
2538
2539/// The source rows: the raw document split on `'\n'`, every line plain body text
2540/// with the `[ss, se)` selection carved out as its own run. Backs the source
2541/// view, whose caret rides raw byte offsets.
2542fn source_rows(source: &str, ss: usize, se: usize) -> Vec<Row> {
2543    let body = LStyle::default();
2544    let mut rows = Vec::new();
2545    let mut byte = 0usize;
2546
2547    for raw in source.split('\n') {
2548        let start = byte;
2549        let end = start + raw.len();
2550        // Selection overlap with this line, in line-local byte coordinates.
2551        let a = ss.clamp(start, end) - start;
2552        let b = se.clamp(start, end) - start;
2553
2554        // The source view's rows are split from raw text, so a run's offset is
2555        // simply where its slice starts — no glyphs to read one off.
2556        let mut runs = Vec::new();
2557        if a < b {
2558            if a > 0 {
2559                runs.push(make_run(raw[..a].to_string(), body, false, None, start));
2560            }
2561            runs.push(make_run(raw[a..b].to_string(), body, true, None, start + a));
2562            if b < raw.len() {
2563                runs.push(make_run(raw[b..].to_string(), body, false, None, start + b));
2564            }
2565        } else if !raw.is_empty() {
2566            runs.push(make_run(raw.to_string(), body, false, None, start));
2567        }
2568
2569        rows.push(Row {
2570            runs,
2571            decoration: false,
2572            code: false,
2573            code_lang: None,
2574            directive: false,
2575            directive_label: None,
2576            heading: None,  // source view is raw text — no resolved heading rows
2577            boundary: None, // …and no resolved block structure to divide
2578        });
2579        byte = end + 1; // skip the '\n' that `split` consumed
2580    }
2581    rows
2582}
2583
2584/// Build a [`Run`] from an accumulated string and the core style it was drawn
2585/// with — the one place role and emphasis flags cross into the view shape.
2586fn make_run(
2587    text: String,
2588    style: LStyle,
2589    sel: bool,
2590    hl: Option<&leaf_core::Highlight>,
2591    src: usize,
2592) -> Run {
2593    Run {
2594        text,
2595        role: role_name(style.role),
2596        bold: style.bold,
2597        italic: style.italic,
2598        underline: style.underline,
2599        strike: style.strikethrough,
2600        sup: style.baseline == Baseline::Super,
2601        sub: style.baseline == Baseline::Sub,
2602        src: src as u32,
2603        sel,
2604        hl: hl.map(|h| h.id.clone()),
2605        hl_color: hl.and_then(|h| h.color.clone()),
2606        mark_color: mark_color_name(style.role),
2607        token: style.token.map(|t| t.name().to_string()),
2608    }
2609}
2610
2611/// The name of a `mark` role's colour, for [`Run::mark_color`]. `None` for a
2612/// plain highlight and for every other role — the same answer, because neither
2613/// has a colour to name.
2614fn mark_color_name(role: Role) -> Option<String> {
2615    match role {
2616        Role::Mark(c) => c.map(|c| c.name().to_string()),
2617        _ => None,
2618    }
2619}
2620
2621#[cfg(test)]
2622mod tests {
2623    use super::*;
2624
2625    fn doc(src: &str) -> Arc<LeafDoc> {
2626        LeafDoc::new(src.to_string(), "markdown".to_string()).unwrap()
2627    }
2628
2629    /// A token splits a run the way a style does — it *is* part of the style
2630    /// — and rides across as its class id. A block in a language no grammar
2631    /// covers is one plain `code` run with no token, as it always was.
2632    #[cfg(feature = "syntax")]
2633    #[test]
2634    fn a_highlighted_block_splits_its_runs_by_token() {
2635        let v = doc("```rust\nlet x = 1;\n```\n").set_unwrapped();
2636        let row = v.rows.iter().find(|r| r.code).expect("a code row");
2637        let classed: Vec<(&str, Option<&str>)> = row
2638            .runs
2639            .iter()
2640            .map(|r| (r.text.as_str(), r.token.as_deref()))
2641            .collect();
2642        assert_eq!(classed[0], ("let", Some("keyword")));
2643        assert!(row.runs.iter().all(|r| r.role == "code"), "{classed:?}");
2644        assert!(
2645            classed
2646                .iter()
2647                .any(|(t, k)| *t == "1" && *k == Some("constant")),
2648            "{classed:?}"
2649        );
2650
2651        let v = doc("```text\nlet x = 1;\n```\n").set_unwrapped();
2652        let row = v.rows.iter().find(|r| r.code).unwrap();
2653        assert_eq!(row.runs.len(), 1);
2654        assert_eq!(row.runs[0].token, None);
2655    }
2656
2657    #[test]
2658    fn a_footnote_definition_ending_the_file_is_itself_not_a_copy() {
2659        // No trailing newline: twig closes the last block on the virtual newline
2660        // it supplies at EOF, so the block's `span.end` is one past the source.
2661        // The definition and the `section` whose bytes contain it then both
2662        // overran, both keyed the block cache as *empty*, and the definition was
2663        // served the section's rows — this rendered the heading a second time.
2664        let src = "A claim[^1] worth checking.\n\n# A heading with a reference[^1] in it\n\n[^1]: The first note.\n[^note]: A note with a word for a label.";
2665        let d = LeafDoc::new(src.to_string(), "djot".to_string()).unwrap();
2666        let text: Vec<String> = d
2667            .view()
2668            .rows
2669            .iter()
2670            .map(|r| r.runs.iter().map(|x| x.text.as_str()).collect())
2671            .collect();
2672        assert_eq!(
2673            text.last().map(String::as_str),
2674            Some("[note] A note with a word for a label."),
2675            "the last definition should render itself: {text:?}"
2676        );
2677        assert_eq!(
2678            text.iter()
2679                .filter(|t| t.contains("A heading with a reference"))
2680                .count(),
2681            1,
2682            "the heading should render exactly once: {text:?}"
2683        );
2684    }
2685
2686    #[test]
2687    fn an_empty_heading_crosses_the_boundary_carrying_its_level() {
2688        // What the toolbar's H1 leaves on a blank line: a heading with no text
2689        // yet. The renderer sizes a row by this field, so a `nil` here is a line
2690        // (and a caret) drawn at body height that jumps to heading height on the
2691        // first keystroke — the level can't be scanned out of the runs, because
2692        // an empty heading has none.
2693        let d = doc("body\n\n# \n");
2694        let v = d.view();
2695        let head = v.rows.last().expect("the heading's row");
2696        assert!(
2697            head.runs.iter().all(|r| r.text.is_empty()),
2698            "the `# ` marker is hidden"
2699        );
2700        assert_eq!(head.heading, Some(1));
2701        assert_eq!(
2702            v.rows[0].heading, None,
2703            "the paragraph above is not a heading"
2704        );
2705    }
2706
2707    #[test]
2708    fn typing_into_a_heading_made_on_a_blank_line_keeps_the_caret_on_its_row() {
2709        // The reported bug at the boundary the Swift renderer reads: with a blank
2710        // line under it, the caret came back on a row two below the heading it
2711        // was actually in, and the view drew it there.
2712        let d = doc("one\n\ntwo\n\n\n\n");
2713        let _ = d.click(4, 0, false); // the first of the two blank lines
2714        let _ = d.set_heading(1);
2715        let mut v = d.view();
2716        for c in "title".chars() {
2717            v = d.insert(c.to_string());
2718        }
2719        assert_eq!(d.source(), "one\n\ntwo\n\n# title\n\n");
2720        assert_eq!(
2721            (v.caret_row, v.caret_ch),
2722            (4, 5),
2723            "the caret is on the heading's row"
2724        );
2725        assert_eq!(v.rows[4].heading, Some(1));
2726    }
2727
2728    #[test]
2729    fn a_video_crosses_the_boundary_as_media_with_the_rows_to_lay_it_over() {
2730        // What the Swift renderer actually consumes: a row span to cover, a kind
2731        // to build a view from, and a URL to load. A frontend that skipped the
2732        // span would paint core's `🎬` placeholder underneath its own player.
2733        let d = doc("<video src=\"clip.mp4\" poster=\"still.png\" controls></video>\n");
2734        let v = d.view();
2735        assert_eq!(v.media.len(), 1);
2736        let m = &v.media[0];
2737        assert!(matches!(m.kind, MediaKind::Video));
2738        assert_eq!(m.src, "clip.mp4");
2739        assert_eq!(m.poster, "still.png");
2740        assert!(
2741            m.end_row > m.start_row,
2742            "the span must cover at least its label row"
2743        );
2744    }
2745
2746    #[test]
2747    fn a_pictures_dark_source_resolves_by_appearance() {
2748        // The one piece of `<picture>` logic core owns, exercised across the
2749        // boundary: the same document resolves to a different URL depending on
2750        // what the host said its appearance was.
2751        let d = doc(
2752            "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\
2753             <img src=\"l.svg\" alt=\"banner\"></picture>\n",
2754        );
2755        assert_eq!(d.view().media[0].src, "l.svg", "light by default");
2756        assert_eq!(d.set_dark_appearance(true).media[0].src, "d.svg");
2757        assert_eq!(d.set_dark_appearance(false).media[0].src, "l.svg");
2758    }
2759
2760    #[test]
2761    fn tapping_below_a_trailing_picture_and_typing_keeps_it_a_picture() {
2762        // The whole gesture, across the boundary, in the order the Apple frontend
2763        // performs it: the layout clamps a point below the last row onto the
2764        // picture's row and asks for the position past its label glyphs; that
2765        // offset becomes the selection; then a character arrives. Before the two
2766        // halves of this fix, the offset was the stop *in front of* the picture
2767        // and the character dissolved it into a paragraph with an inline image —
2768        // the photo stopped being drawn, and nothing said so.
2769        let d = doc("hi\n\n![](p.png)\n");
2770        let v = d.set_unwrapped();
2771        let row = v.media[0].start_row;
2772        let label: u32 = v.rows[row as usize]
2773            .runs
2774            .iter()
2775            .map(|r| r.text.encode_utf16().count() as u32)
2776            .sum();
2777
2778        let off = d.offset_for_pos(row, label);
2779        assert_eq!(
2780            off,
2781            "hi\n\n![](p.png)".len() as u32,
2782            "the stop past the picture"
2783        );
2784
2785        d.set_selection_offsets(off, off);
2786        let after = d.insert("x".to_string());
2787        assert_eq!(d.source(), "hi\n\n![](p.png)\n\nx\n");
2788        assert_eq!(after.media.len(), 1, "still a picture, one paragraph up");
2789    }
2790
2791    #[test]
2792    fn backspace_from_that_same_tap_takes_the_picture_whole() {
2793        // The other half of the same gesture, and the one that cost this project's
2794        // own test vault a photo: tap under the picture, press Backspace. That
2795        // offset is the stop past the markup, so a byte-step deleted the closing
2796        // paren and left the literal text `![](p.png` where a photo had been.
2797        let d = doc("hi\n\n![](p.png)\n");
2798        d.set_unwrapped();
2799        let off = "hi\n\n![](p.png)".len() as u32;
2800        d.set_selection_offsets(off, off);
2801        let after = d.backspace();
2802        assert_eq!(d.source(), "hi\n");
2803        assert_eq!(after.media.len(), 0, "gone as a picture, not as bytes");
2804        let undone = d.undo();
2805        assert_eq!(d.source(), "hi\n\n![](p.png)\n");
2806        assert_eq!(
2807            undone.media.len(),
2808            1,
2809            "and one undo brings the picture back"
2810        );
2811    }
2812
2813    #[test]
2814    fn measured_heights_grow_the_reserved_span() {
2815        // The height loop: core reserves one row until the renderer measures the
2816        // real view and reports back, because core does no I/O and cannot know.
2817        let d = doc("![a cat](cat.png)\n");
2818        let before = &d.view().media[0];
2819        assert_eq!(
2820            before.end_row - before.start_row,
2821            1,
2822            "one row until measured"
2823        );
2824
2825        let after = d.set_media_rows(vec![MediaHeight {
2826            destination: "cat.png".to_string(),
2827            rows: 6,
2828        }]);
2829        let m = &after.media[0];
2830        assert_eq!(
2831            m.end_row - m.start_row,
2832            6,
2833            "the span grew to what was measured"
2834        );
2835    }
2836
2837    #[test]
2838    fn inserted_media_comes_straight_back_out_as_media() {
2839        // Round trip across the boundary, the pair that matters: what Swift asks
2840        // to insert, Swift sees on the very next frame.
2841        let d = doc("\n");
2842        let v = d.insert_media(
2843            MediaKind::Audio,
2844            "take.mp3".to_string(),
2845            "a take".to_string(),
2846        );
2847        assert_eq!(v.media.len(), 1);
2848        assert!(matches!(v.media[0].kind, MediaKind::Audio));
2849        assert_eq!(v.media[0].src, "take.mp3");
2850        assert_eq!(v.media[0].alt, "a take");
2851    }
2852
2853    #[test]
2854    fn the_source_view_publishes_no_media() {
2855        // In the source view the `<video>` markup is the literal text the caret
2856        // is editing — laying a player over it would cover what's being typed.
2857        let d = doc("<video src=\"clip.mp4\" controls></video>\n");
2858        assert_eq!(d.view().media.len(), 1);
2859        assert!(
2860            d.toggle_view().media.is_empty(),
2861            "no placeholders in the source view"
2862        );
2863    }
2864
2865    /// **A foreign caller's offset must never panic.** Every offset entering
2866    /// leaf comes from a UI toolkit that counts in its own units — UIKit hands
2867    /// back UTF-16 positions — so an offset landing mid-character is a normal
2868    /// thing to be handed, not a bug in the caller. Slicing on it aborts the
2869    /// process across the FFI boundary, where there is no unwinding to catch.
2870    ///
2871    /// Reproduces a real crash: `byte index 1236 is not a char boundary; it is
2872    /// inside '…'`.
2873    #[test]
2874    fn an_offset_inside_a_multibyte_char_does_not_panic() {
2875        let d = doc(
2876            "# April 02, 2026\n\nAn interesting thing AI said to me:\n\n> a person… who journals\n",
2877        );
2878        d.toggle_view(); // to the raw source view, where offsets index bytes directly
2879        let src = d.source();
2880        // The interior byte of the `…` — exactly the shape of the crash.
2881        let mid = src.find('…').expect("the ellipsis is in the fixture") + 1;
2882        assert!(
2883            !src.is_char_boundary(mid),
2884            "the fixture must be mid-character"
2885        );
2886
2887        // Every entry point that takes a raw source offset.
2888        let _ = d.pos_for_offset(mid as u32);
2889        let _ = d.vertical_offset(mid as u32, true);
2890        let _ = d.vertical_offset(mid as u32, false);
2891        let _ = d.snap_offset(mid as u32);
2892        let _ = d.step_offset(mid as u32, 1);
2893        let _ = d.step_offset(mid as u32, -1);
2894        let _ = d.distance_offset(0, mid as u32);
2895        let _ = d.text_in_range(0, mid as u32);
2896        let _ = d.set_selection_offsets(mid as u32, mid as u32);
2897        // And the caret must not come to rest inside the character either — a
2898        // mid-character caret is a later panic waiting for the next edit.
2899        let _ = d.replace_range(mid as u32, mid as u32, "x".to_string());
2900        assert!(
2901            d.source().is_char_boundary(d.caret_offset() as usize),
2902            "the caret must sit on a character boundary"
2903        );
2904    }
2905
2906    #[test]
2907    fn cell_lines_split_on_the_break_glyph_carrying_each_lines_source_range() {
2908        use leaf_core::Glyph;
2909        let g = |ch, src| Glyph {
2910            ch,
2911            style: LStyle::default(),
2912            src,
2913            stop: true,
2914        };
2915        // "a" at 10, a `<br>` at 11..15 (the break glyph), "b" at 15; cell 10..16.
2916        let glyphs = [g('a', 10), g('\n', 11), g('b', 15)];
2917        let lines = cell_lines(&glyphs, 10, 16, 0, 0, &[]);
2918        assert_eq!(lines.len(), 2, "one break makes two lines");
2919        assert_eq!(
2920            (lines[0].start, lines[0].end),
2921            (10, 11),
2922            "line 1 ends at the break"
2923        );
2924        assert_eq!(
2925            (lines[1].start, lines[1].end),
2926            (15, 16),
2927            "line 2 begins past it"
2928        );
2929        let text =
2930            |l: &TableCellLineView| l.runs.iter().map(|r| r.text.clone()).collect::<String>();
2931        assert_eq!(text(&lines[0]), "a");
2932        assert_eq!(text(&lines[1]), "b");
2933
2934        // A trailing break leaves an empty last line homed at the cell's end.
2935        let trailing = [g('a', 10), g('\n', 11)];
2936        let lines = cell_lines(&trailing, 10, 15, 0, 0, &[]);
2937        assert_eq!(lines.len(), 2);
2938        assert!(lines[1].runs.is_empty());
2939        assert_eq!((lines[1].start, lines[1].end), (15, 15));
2940
2941        // No break: one line spanning the whole cell.
2942        let plain = [g('P', 10), g('e', 11)];
2943        let lines = cell_lines(&plain, 10, 12, 0, 0, &[]);
2944        assert_eq!(lines.len(), 1);
2945        assert_eq!((lines[0].start, lines[0].end), (10, 12));
2946    }
2947
2948    fn row_text(v: &DocView, row: usize) -> String {
2949        v.rows[row].runs.iter().map(|r| r.text.clone()).collect()
2950    }
2951
2952    #[test]
2953    fn unwrapped_collapses_a_paragraph_to_one_row() {
2954        let d = doc("one two three four five six seven eight\n");
2955        let wrapped = d.set_width(10);
2956        let unwrapped = d.set_unwrapped();
2957        assert!(
2958            unwrapped.rows.len() < wrapped.rows.len(),
2959            "a narrow column wrap splits the paragraph; unwrapped keeps it whole"
2960        );
2961        assert!(
2962            (0..unwrapped.rows.len()).any(|i| row_text(&unwrapped, i).contains("eight")),
2963            "the whole paragraph, including its last word, sits on a single unwrapped row"
2964        );
2965    }
2966
2967    #[test]
2968    fn offsets_round_trip_when_unwrapped() {
2969        let d = doc("hello world\n");
2970        d.set_unwrapped();
2971        // offset -> (row, ch) -> offset is stable, so the pixel-wrapping frontend can
2972        // map between its visual lines and core's byte-offset caret model.
2973        let rc = d.pos_for_offset(6); // the 'w' of "world"
2974        assert_eq!(d.offset_for_pos(rc.row, rc.ch), 6);
2975    }
2976
2977    #[test]
2978    fn set_unwrapped_is_idempotent() {
2979        let d = doc("a paragraph of some length here\n");
2980        let first = d.set_unwrapped();
2981        let second = d.set_unwrapped();
2982        assert_eq!(first.rows.len(), second.rows.len());
2983    }
2984
2985    #[test]
2986    fn newline_on_last_list_item_before_a_blockquote_starts_a_new_item() {
2987        let src = "- one\n- two\n- three\n\n> quote\n";
2988        let d = doc(src);
2989        let off = (src.find("three").unwrap() + "three".len()) as u32; // end of "three" = 19
2990        d.set_selection_offsets(off, off);
2991        d.newline();
2992        let after = d.source();
2993        assert!(
2994            after.contains("- three\n- ") && after.contains("> quote"),
2995            "expected a new empty list item with the blockquote intact, got: {after:?}"
2996        );
2997    }
2998
2999    #[test]
3000    fn enter_on_an_empty_line_adds_one_newline_and_one_backspace_undoes_it() {
3001        let d = doc("hello\n");
3002        d.set_selection_offsets(5, 5);
3003        d.newline(); // paragraph "hello" → a paragraph break, caret on the empty line
3004        let after_para = d.source();
3005        let caret_para = d.caret_offset();
3006        d.newline(); // Enter on the empty line
3007        assert_eq!(
3008            d.source().len(),
3009            after_para.len() + 1,
3010            "an empty-line Enter adds a single newline, not another paragraph break"
3011        );
3012        d.backspace(); // a single Backspace restores the previous state
3013        assert_eq!(d.source(), after_para);
3014        assert_eq!(d.caret_offset(), caret_para);
3015    }
3016
3017    #[test]
3018    fn enter_in_a_nonempty_paragraph_still_opens_a_new_paragraph() {
3019        let d = doc("hello\n");
3020        d.set_selection_offsets(5, 5);
3021        let before = d.source().len();
3022        d.newline();
3023        assert_eq!(
3024            d.source().len(),
3025            before + 2,
3026            "a paragraph break is still \\n\\n"
3027        );
3028    }
3029
3030    #[test]
3031    fn link_destination_at_caret_reads_the_caret_link() {
3032        let d = doc("see [t](https://x.dev) ok\n");
3033        d.set_selection_offsets(5, 5); // caret on the link text "t"
3034        assert_eq!(
3035            d.link_destination_at_caret().as_deref(),
3036            Some("https://x.dev")
3037        );
3038        d.set_selection_offsets(0, 0); // caret on plain text
3039        assert_eq!(d.link_destination_at_caret(), None);
3040    }
3041
3042    #[test]
3043    fn image_destination_at_caret_reads_the_image_under_the_caret() {
3044        let d = doc("![a](cat.png) after\n");
3045        d.set_selection_offsets(3, 3); // caret on the alt text
3046        assert_eq!(d.image_destination_at_caret().as_deref(), Some("cat.png"));
3047        d.set_selection_offsets(15, 15); // caret past the image, in the prose
3048        assert_eq!(d.image_destination_at_caret(), None);
3049    }
3050
3051    #[test]
3052    fn the_frame_carries_the_caret_link_so_a_toolbar_can_light_and_seed_from_it() {
3053        // The reason it rides `DocView` rather than being asked for: stepping the
3054        // caret out of the link changes no other chrome fact on the frame, so a
3055        // toolbar that only redraws on a *changed* state would keep a stale light.
3056        let d = doc("see [t](https://x.dev) ok\n");
3057        d.set_selection_offsets(5, 5);
3058        let inside = d.view();
3059        assert_eq!(inside.link.as_deref(), Some("https://x.dev"));
3060        assert_eq!(inside.heading, None);
3061        assert!(inside.active.is_empty());
3062
3063        d.set_selection_offsets(0, 0);
3064        let outside = d.view();
3065        assert_eq!(outside.link, None);
3066        // Nothing else the frame reports moved with it.
3067        assert_eq!(outside.heading, inside.heading);
3068        assert_eq!(outside.active, inside.active);
3069    }
3070
3071    #[test]
3072    fn insert_footnote_crosses_and_leaves_the_caret_in_the_new_note() {
3073        // The button's round trip through the boundary: both halves written, and
3074        // a caret offset a host can type into without asking anything else.
3075        let d = doc("A claim and more.\n");
3076        d.set_selection_offsets(7, 7); // just past "A claim"
3077        d.insert_footnote();
3078        assert!(
3079            d.source().starts_with("A claim[^1] and more."),
3080            "{:?}",
3081            d.source()
3082        );
3083        assert!(d.source().contains("[^1]:"), "{:?}", d.source());
3084
3085        let note = d.footnote_at(9).expect("the reference just written");
3086        assert_eq!(note.label, "1");
3087        assert_eq!(
3088            d.caret_offset(),
3089            note.offset.expect("an empty note is still a place")
3090        );
3091        // …and the way back out is the same one a reader uses.
3092        assert_eq!(
3093            d.footnote_definition_at_caret().expect("in the note").label,
3094            "1"
3095        );
3096    }
3097
3098    #[test]
3099    fn a_highlight_is_coloured_at_the_caret_and_the_frame_says_which() {
3100        // The whole crossing a colour palette makes: press the swatch, and the
3101        // frame that comes back names the colour so the swatch can light.
3102        let d = doc("a word b\n");
3103        d.set_selection_offsets(2, 6);
3104        d.toggle_mark();
3105        assert_eq!(d.source(), "a ==word== b\n");
3106
3107        d.set_selection_offsets(5, 5); // inside the highlight
3108        assert!(d.caret_in_mark());
3109        let v = d.set_mark_color(Some(MarkColor::Red));
3110        assert_eq!(d.source(), "a ==🔴 word== b\n");
3111        assert_eq!(v.mark_color, Some(MarkColor::Red));
3112
3113        // And the way back: no colour, still a highlight.
3114        let v = d.set_mark_color(None);
3115        assert_eq!(d.source(), "a ==word== b\n");
3116        assert_eq!(v.mark_color, None);
3117        assert!(d.caret_in_mark());
3118    }
3119
3120    #[test]
3121    fn one_press_highlights_and_colours_and_one_undo_takes_it_back() {
3122        // What a toolbar swatch calls. The fold is core's, and it is what makes
3123        // the press reversible in one step rather than leaving an uncoloured
3124        // highlight behind.
3125        let d = doc("a word b\n");
3126        d.set_selection_offsets(2, 6);
3127        let v = d.highlight(Some(MarkColor::Purple));
3128        assert_eq!(d.source(), "a ==\u{1F7E3} word== b\n");
3129        assert_eq!(v.mark_color, Some(MarkColor::Purple));
3130
3131        d.undo();
3132        assert_eq!(d.source(), "a word b\n");
3133    }
3134
3135    #[test]
3136    fn the_palette_has_two_gates_and_they_ask_different_questions() {
3137        // `mark_color` is the format's answer and `caret_in_mark` the caret's.
3138        // A djot document spells the highlight and no colour for it, so the two
3139        // disagree there — which is the case a toolbar gating on either one
3140        // alone gets wrong.
3141        assert!(doc("x\n").capabilities().mark_color);
3142        let dj = LeafDoc::new("a {=word=} b\n".to_string(), "djot".to_string()).unwrap();
3143        assert!(dj.capabilities().mark, "djot writes the highlight");
3144        assert!(!dj.capabilities().mark_color, "and no colour on it");
3145        dj.set_selection_offsets(5, 5);
3146        assert!(dj.caret_in_mark(), "the caret is in one all the same");
3147
3148        let d = doc("a word b\n");
3149        d.set_selection_offsets(3, 3);
3150        assert!(!d.caret_in_mark(), "no highlight to colour here");
3151        assert_eq!(d.set_mark_color(Some(MarkColor::Blue)).mark_color, None);
3152        assert_eq!(d.source(), "a word b\n", "and nothing written");
3153    }
3154
3155    #[test]
3156    fn capabilities_answer_for_footnotes_the_way_the_format_does() {
3157        assert!(
3158            doc("x\n").capabilities().footnote,
3159            "markdown spells the pair"
3160        );
3161        let html = LeafDoc::new("<p>x</p>\n".to_string(), "html".to_string()).unwrap();
3162        assert!(
3163            !html.capabilities().footnote,
3164            "html has no footnote of its own"
3165        );
3166    }
3167
3168    #[test]
3169    fn footnote_at_caret_crosses_with_its_note_and_its_offset() {
3170        let d = doc("A claim[^1] and more.\n\n[^1]: the note\n");
3171        d.set_selection_offsets(9, 9); // caret on the reference's label
3172        let f = d
3173            .footnote_at_caret()
3174            .expect("the caret stands in a reference");
3175        assert_eq!(f.label, "1");
3176        assert_eq!(f.text.as_deref(), Some("the note"));
3177        // The note's first word — a byte the caret can actually rest on. The
3178        // definition's `[^1]:` marker is decoration with no stop of its own.
3179        assert_eq!(f.offset, Some(29));
3180        assert_eq!(f.end, Some(37));
3181
3182        d.set_selection_offsets(0, 0); // caret on plain text
3183        assert!(d.footnote_at_caret().is_none());
3184    }
3185
3186    #[test]
3187    fn footnote_at_crosses_for_an_offset_without_moving_the_caret() {
3188        // What a hover needs: the note under the pointer, and the caret left
3189        // exactly where the reader put it.
3190        let d = doc("A claim[^1] and more.\n\n[^1]: the note\n");
3191        d.set_selection_offsets(0, 0);
3192        let f = d.footnote_at(9).expect("offset 9 stands in the reference");
3193        assert_eq!(f.label, "1");
3194        assert_eq!(f.text.as_deref(), Some("the note"));
3195        assert_eq!(d.caret_offset(), 0, "asking must not move the caret");
3196        assert!(d.footnote_at(2).is_none(), "offset 2 is prose");
3197    }
3198
3199    #[test]
3200    fn footnote_definition_at_caret_crosses_with_the_way_back() {
3201        let d = doc("A claim[^1] and more.\n\n[^1]: the note\n");
3202        d.set_selection_offsets(30, 30); // caret inside the note's body
3203        let f = d
3204            .footnote_definition_at_caret()
3205            .expect("the caret stands in a definition");
3206        assert_eq!(f.label, "1");
3207        assert_eq!(f.offset, Some(9), "the reference's label");
3208
3209        // Disjoint from the reference query, which is what lets one gesture mean
3210        // "down" up top and "back up" down here.
3211        d.set_selection_offsets(9, 9);
3212        assert!(d.footnote_definition_at_caret().is_none());
3213        assert!(d.footnote_at_caret().is_some());
3214    }
3215
3216    /// The contract a peek is built on: a note's offsets map to rows whose runs
3217    /// are the note *rendered* — emphasis as an italic run, `` `code` `` as a
3218    /// code run, a link as a link run — so a frontend draws it the way the
3219    /// document draws it instead of showing the reader raw asterisks.
3220    #[test]
3221    fn a_notes_offsets_map_to_its_rendered_rows() {
3222        let src = "Claim[^a].\n\n[^a]: see *emphasis* and `code` and [a link](https://x.dev).\n";
3223        let d = doc(src);
3224        let view = d.set_unwrapped();
3225        d.set_selection_offsets(6, 6); // the reference's label
3226
3227        let f = d.footnote_at_caret().expect("a reference");
3228        let start = d.pos_for_offset(f.offset.expect("a note"));
3229        let end = d.pos_for_offset(f.end.expect("a note") - 1);
3230        assert_eq!(
3231            start.row, end.row,
3232            "a one-paragraph note is one unwrapped row"
3233        );
3234
3235        let row = &view.rows[start.row as usize];
3236        let runs: Vec<(&str, &str, bool)> = row
3237            .runs
3238            .iter()
3239            .map(|r| (r.role.as_str(), r.text.as_str(), r.italic))
3240            .collect();
3241        assert!(runs.contains(&("body", "emphasis", true)), "got {runs:?}");
3242        assert!(
3243            runs.iter()
3244                .any(|(role, text, _)| *role == "code" && *text == "code"),
3245            "got {runs:?}"
3246        );
3247        assert!(
3248            runs.iter()
3249                .any(|(role, text, _)| *role == "link" && *text == "a link"),
3250            "got {runs:?}"
3251        );
3252
3253        // The rendered row carries no markup characters at all — which is the
3254        // whole point, and what `text` (source bytes) deliberately still does.
3255        let rendered: String = row.runs.iter().map(|r| r.text.as_str()).collect();
3256        assert!(
3257            !rendered.contains('*') && !rendered.contains('`'),
3258            "got {rendered:?}"
3259        );
3260        assert!(
3261            f.text.as_deref().unwrap().contains('*'),
3262            "the source answer keeps them"
3263        );
3264
3265        // `ch` is where the body starts within the row — past the `[a] ` marker,
3266        // so a frontend that wants the note without its label can slice there.
3267        assert_eq!(row.runs[0].role, "list");
3268        assert_eq!(start.ch as usize, row.runs[0].text.chars().count());
3269
3270        // And each run says where it came from, which is how a link run drawn in
3271        // a popover learns where it points. `Run` otherwise says how a span
3272        // looks, never what it means.
3273        let link = row
3274            .runs
3275            .iter()
3276            .find(|r| r.role == "link")
3277            .expect("a link run");
3278        assert_eq!(
3279            d.link_destination_at(link.src).as_deref(),
3280            Some("https://x.dev"),
3281            "the run at {} is the link",
3282            link.src
3283        );
3284    }
3285
3286    /// The peek bug, in the shape it was actually found in: three notes, each
3287    /// ending in a link, which is what a real citation block looks like.
3288    ///
3289    /// `a_notes_offsets_map_to_its_rendered_rows` above uses a note ending in a
3290    /// visible `.`, so its last byte has a row of its own and `end - 1` reads
3291    /// right. Take the full stop away — end the note *with* the link, as a
3292    /// citation does — and the last byte falls inside the hidden destination,
3293    /// where `pos_for_offset` snaps forward onto the next note's row. Hovering
3294    /// `[^2]` peeked notes 2 *and* 3.
3295    #[test]
3296    fn a_note_ending_in_a_link_covers_its_own_row_and_no_other() {
3297        let src = "A[^1] B[^2] C[^3].\n\n\
3298                   [^1]: https://en.wikipedia.org/wiki/Moravec%27s_paradox\n\n\
3299                   [^2]: [\"How to Get Startup Ideas,\" Nov 2012](https://www.paulgraham.com/startupideas.html)\n\n\
3300                   [^3]: [Alma 37:46](https://www.churchofjesuschrist.org/study/scriptures/bofm/alma/37?lang=eng&id=p46#p46)\n";
3301        let d = doc(src);
3302        let view = d.set_unwrapped();
3303
3304        // The caret in the [^2] reference, exactly as a hover resolves it.
3305        let off2 = src.find("[^2] C").unwrap() as u32 + 2;
3306        d.set_selection_offsets(off2, off2);
3307        let f = d.footnote_at_caret().expect("a reference");
3308        let (start, end) = (f.offset.expect("a note"), f.end.expect("a note"));
3309
3310        let span = d.row_range_for(start, end);
3311        assert_eq!(span.first, span.last, "one note is one unwrapped row");
3312
3313        // And what it draws is note 2 alone — the assertion the popover failed.
3314        let drawn: String = view.rows[span.first as usize]
3315            .runs
3316            .iter()
3317            .map(|r| r.text.as_str())
3318            .collect();
3319        assert!(drawn.contains("How to Get Startup Ideas"), "got {drawn:?}");
3320        assert!(
3321            !drawn.contains("Alma"),
3322            "note 3 leaked into the peek: {drawn:?}"
3323        );
3324
3325        // The old arithmetic, pinned as still wrong so nobody quietly restores
3326        // it: this is the failure `row_range_for` exists instead of.
3327        assert_ne!(
3328            d.pos_for_offset(end - 1).row,
3329            span.last,
3330            "the forward snap still leaves the note's row — that is the point",
3331        );
3332
3333        // Note 1 is a bare autolink, whose visible text *is* its URL, so it was
3334        // never affected and must not change.
3335        let off1 = src.find("[^1] B").unwrap() as u32 + 2;
3336        d.set_selection_offsets(off1, off1);
3337        let f1 = d.footnote_at_caret().expect("a reference");
3338        let one = d.row_range_for(f1.offset.unwrap(), f1.end.unwrap());
3339        assert_eq!(one.first, one.last);
3340        assert_ne!(one.first, span.first, "and it is a different note");
3341    }
3342
3343    /// A run's `src` is a byte offset core handed over, not something a frontend
3344    /// counted its way to — so multi-byte prose ahead of a link inside a note
3345    /// can't slide it.
3346    ///
3347    /// The offset is a *byte* offset while the run's text is characters and the
3348    /// row's columns are display cells; `src` is the only one of the three a
3349    /// frontend can use without converting between the other two.
3350    #[test]
3351    fn a_runs_source_offset_survives_multibyte_prose_ahead_of_it() {
3352        let src = "Claim[^a].\n\n[^a]: 日記 café [a link](https://x.dev).\n";
3353        let d = doc(src);
3354        let view = d.set_unwrapped();
3355        d.set_selection_offsets(6, 6);
3356
3357        let f = d.footnote_at_caret().expect("a reference");
3358        let start = d.pos_for_offset(f.offset.expect("a note"));
3359        let row = &view.rows[start.row as usize];
3360        let link = row
3361            .runs
3362            .iter()
3363            .find(|r| r.role == "link")
3364            .expect("a link run");
3365
3366        assert_eq!(
3367            d.link_destination_at(link.src).as_deref(),
3368            Some("https://x.dev")
3369        );
3370        assert_eq!(
3371            &src[link.src as usize..][.."a link".len()],
3372            "a link",
3373            "and it is a byte offset, not a character or column index"
3374        );
3375        // Which the character count is not: `日記 café ` is 9 characters and 13
3376        // bytes, so anything derived from the run text lands in the wrong place.
3377        let counted: usize = row
3378            .runs
3379            .iter()
3380            .take_while(|r| r.role != "link")
3381            .map(|r| r.text.chars().count())
3382            .sum();
3383        assert_ne!(counted, link.src as usize);
3384    }
3385
3386    /// The round trip through the API a frontend actually calls — which places
3387    /// carets, and so snaps them to real stops. Offsets that named the `[^`
3388    /// markers passed every test that assigned the caret directly and still
3389    /// dumped the reader in the paragraph above the note.
3390    #[test]
3391    fn following_a_footnote_and_coming_back_lands_on_real_caret_stops() {
3392        let d = doc("A claim[^1] and more.\n\n[^1]: the note\n");
3393        d.set_selection_offsets(9, 9);
3394
3395        let down = d
3396            .footnote_at_caret()
3397            .expect("a reference")
3398            .offset
3399            .expect("a note");
3400        d.set_selection_offsets(down, down);
3401        assert_eq!(
3402            d.caret_offset(),
3403            down,
3404            "the note is somewhere the caret fits"
3405        );
3406
3407        let up = d
3408            .footnote_definition_at_caret()
3409            .expect("arrived inside the definition")
3410            .offset
3411            .expect("a reference to return to");
3412        d.set_selection_offsets(up, up);
3413        assert_eq!(d.caret_offset(), up, "and so is the reference");
3414        assert_eq!(
3415            d.footnote_at_caret().expect("back on the reference").label,
3416            "1"
3417        );
3418    }
3419
3420    #[test]
3421    fn a_footnote_reference_crosses_the_ffi_raised() {
3422        // The whole point of the `sup` flag: without it a reference reaches
3423        // Swift as a run indistinguishable from a hyperlink's, which is why it
3424        // used to draw at body size.
3425        let d = doc("A claim[^1] and more.\n");
3426        let view = d.view();
3427        let runs: Vec<&Run> = view.rows.iter().flat_map(|r| &r.runs).collect();
3428        let chip = runs
3429            .iter()
3430            .find(|r| r.text.contains('1'))
3431            .expect("the reference's chip");
3432        assert!(chip.sup, "the reference should cross raised");
3433        assert!(!chip.sub);
3434        assert_eq!(
3435            chip.role, "link",
3436            "and still carrying the role every frontend paints"
3437        );
3438        // The prose it interrupts is a run of its own, on the normal baseline —
3439        // which is what proves the flag splits runs rather than bleeding.
3440        let prose = runs
3441            .iter()
3442            .find(|r| r.text.contains("claim"))
3443            .expect("the prose");
3444        assert!(!prose.sup && !prose.sub);
3445    }
3446
3447    #[test]
3448    fn utf16_indices_round_trip_through_the_visible_text_in_both_views() {
3449        // Hidden delimiters, an emoji outside the BMP, and a block gap: the
3450        // three ways an `NSRange` into the visible text and a source byte
3451        // offset part company.
3452        let d = doc("a **b\u{1F600}** c\n\nd\n");
3453        let end = d.doc_end_offset();
3454        let text = d.text_in_range(0, end);
3455        assert_eq!(text, "a b\u{1F600} c\nd");
3456        let total = text.encode_utf16().count() as u32;
3457        assert_eq!(d.utf16_index_for_offset(end), total);
3458        assert_eq!(d.offset_for_utf16_index(total), end);
3459        // Walk every stop: index it, and come back to the same stop.
3460        let mut off = 0u32;
3461        loop {
3462            let idx = d.utf16_index_for_offset(off);
3463            assert_eq!(
3464                d.offset_for_utf16_index(idx),
3465                off,
3466                "stop {off} via index {idx}"
3467            );
3468            let next = d.step_offset(off, 1);
3469            if next == off {
3470                break;
3471            }
3472            off = next;
3473        }
3474        // The 'c' comes after the hidden `**` and the two-unit emoji.
3475        let c = "a **b\u{1F600}** c".find(" c").unwrap() as u32 + 1;
3476        assert_eq!(
3477            d.utf16_index_for_offset(c),
3478            "a b\u{1F600} ".encode_utf16().count() as u32
3479        );
3480
3481        // Source view: the text is the raw source, so the index is the plain
3482        // UTF-16 count of the bytes before the offset — delimiters included.
3483        d.toggle_view();
3484        assert_eq!(
3485            d.text_in_range(0, d.doc_end_offset()),
3486            "a **b\u{1F600}** c\n\nd\n"
3487        );
3488        assert_eq!(
3489            d.utf16_index_for_offset(c),
3490            "a **b\u{1F600}** ".encode_utf16().count() as u32
3491        );
3492        assert_eq!(d.offset_for_utf16_index(d.utf16_index_for_offset(c)), c);
3493        // Inside the emoji's surrogate pair resolves to the emoji itself.
3494        let emoji = "a **b".len() as u32;
3495        assert_eq!(
3496            d.offset_for_utf16_index(d.utf16_index_for_offset(emoji) + 1),
3497            emoji
3498        );
3499    }
3500
3501    #[test]
3502    fn text_in_range_hides_delimiters_like_the_screen_does() {
3503        // "a **bold** c\n": 0:'a' 1:' ' 2:'*' 3:'*' 4:'b' 5:'o' 6:'l' 7:'d'
3504        // 8:'*' 9:'*' 10:' ' 11:'c' 12:'\n'. Bytes 8..10 are the closing `**`
3505        // — hidden, no glyph — and bytes 2..4 the opening `**`, likewise
3506        // hidden. `caret_steps_over_hidden_delimiters` in leaf-core already
3507        // pins that one Right from 7 (just past the 'd') lands on 10 (the
3508        // space before 'c'), skipping 8/9 entirely — so the *visible* text
3509        // transiting [7, 10) is exactly "d": the closing `**` contributes
3510        // nothing, matching what's drawn on screen.
3511        let d = doc("a **bold** c\n");
3512        assert_eq!(d.text_in_range(7, 10), "d");
3513        assert_eq!(
3514            d.text_in_range(7, 10).chars().count() as i32,
3515            d.distance_offset(7, 10),
3516            "text(in:).count() must equal offset(from:to:) — the UITextInput invariant this bug broke"
3517        );
3518
3519        // Plain text with no hidden delimiter in range: unchanged, still the
3520        // raw slice, proving the fix doesn't regress the common case.
3521        assert_eq!(d.text_in_range(0, 1), "a");
3522        assert_eq!(d.text_in_range(11, 12), "c");
3523        assert_eq!(
3524            d.text_in_range(0, 1).chars().count() as i32,
3525            d.distance_offset(0, 1)
3526        );
3527    }
3528
3529    #[test]
3530    fn text_in_range_matches_distance_offset_across_marked_up_and_plain_spans() {
3531        // The general invariant, straddling bold/italic/code spans and not:
3532        // for any pair of offsets, the visible text `text_in_range` returns
3533        // must have exactly as many `chars()` as `distance_offset` reports
3534        // stops between them — otherwise iOS's word tokenizer (which fetches
3535        // a text window, finds a boundary by indexing into *that string*, and
3536        // converts the index back to a position via `position(from:offset:)`)
3537        // resolves the boundary at the wrong offset.
3538        let d = doc("a **bold** _em_ and `code` here\n");
3539        let len = d.source().len() as u32;
3540        let mut pairs = Vec::new();
3541        let mut a = 0u32;
3542        while a < len {
3543            let mut b = a + 1;
3544            while b <= len {
3545                pairs.push((a, b));
3546                b += 3; // sample rather than an O(n^2) sweep
3547            }
3548            a += 1;
3549        }
3550        for (a, b) in pairs {
3551            let text = d.text_in_range(a, b);
3552            let dist = d.distance_offset(a, b).abs();
3553            assert_eq!(
3554                text.chars().count() as i32,
3555                dist,
3556                "text_in_range({a}, {b}) = {text:?} has {} chars, but distance_offset says {dist}",
3557                text.chars().count()
3558            );
3559        }
3560    }
3561
3562    #[test]
3563    fn text_in_range_separates_paragraphs_so_words_dont_merge_across_the_gap() {
3564        // Regression: double-tapping the last word on a line immediately
3565        // followed by a paragraph break selected past the break into the
3566        // next paragraph — and kept compounding across further trivial
3567        // paragraphs in a row — because `text_in_range` returned the two
3568        // paragraphs' text with nothing between them: "hello" then "hello"
3569        // read back as one merged "hellohello" run of letters, no different
3570        // from the raw source concatenation, and iOS's word tokenizer duly
3571        // selected the whole run as a single word.
3572        let d = doc("hello\n\nhello\n\nhello\n");
3573        let src = d.source();
3574        assert_eq!(
3575            src.find("hello").unwrap(),
3576            0,
3577            "paragraph 1 at the very start"
3578        );
3579        let p2 = src[5..].find("hello").unwrap() + 5; // 7: paragraph 2's "hello"
3580
3581        // A window straddling the tail of paragraph 1 ("lo") and the head of
3582        // paragraph 2 ("he").
3583        let text = d.text_in_range(3, p2 as u32 + 2);
3584        assert_ne!(
3585            text, "lohe",
3586            "the two paragraphs' words must not read as merged"
3587        );
3588        assert!(
3589            text.chars().any(|c| !c.is_alphanumeric()),
3590            "a non-letter must separate the two paragraphs' words: got {text:?}"
3591        );
3592        assert_eq!(
3593            text, "lo\nhe",
3594            "exactly one separator opens the second paragraph's head"
3595        );
3596
3597        // A window that is nothing but the bare gap itself (no glyph on the
3598        // left, since it starts exactly at the end of paragraph 1's own last
3599        // row) must still carry the break — this is the case a naive
3600        // "insert a separator only between two real hits" fix undercounts,
3601        // since there is no earlier hit to anchor it to.
3602        let gap_only = d.text_in_range(5, p2 as u32);
3603        assert!(
3604            gap_only.chars().count() as i32 >= d.distance_offset(5, p2 as u32),
3605            "text_in_range must never be shorter than distance_offset: {gap_only:?}"
3606        );
3607
3608        // The invariant the two existing tests above assert (strict
3609        // equality) no longer holds once the range spans a paragraph
3610        // boundary — see `text_in_range`'s doc comment — but it must never
3611        // *undercount* relative to `distance_offset`, which is what would let
3612        // a tokenizer's `position(from:offset:)` walk past where the text it
3613        // was handed actually put a boundary.
3614        for (a, b) in [(0u32, src.len() as u32), (3, p2 as u32 + 2), (5, p2 as u32)] {
3615            let text = d.text_in_range(a, b);
3616            let dist = d.distance_offset(a, b);
3617            assert!(
3618                text.chars().count() as i32 >= dist,
3619                "text_in_range({a}, {b}) = {text:?} ({} chars) is shorter than distance_offset {dist}",
3620                text.chars().count()
3621            );
3622        }
3623
3624        // Caret motion itself is untouched by any of this: from the very end
3625        // of paragraph 1's row, a paragraph gap still costs exactly one
3626        // Right press to reach the start of paragraph 2 — matching
3627        // leaf-core's `the_caret_skips_the_gap_between_two_paragraphs`.
3628        assert_eq!(
3629            d.distance_offset(5, p2 as u32),
3630            1,
3631            "one Right crosses the whole gap"
3632        );
3633    }
3634
3635    #[test]
3636    fn text_in_range_does_not_split_table_cells_at_decoration_rows() {
3637        let d = doc("| Feature | Status |\n| --- | --- |\n| Tables | editable |\n");
3638        assert_eq!(
3639            d.text_in_range(0, d.doc_end_offset()),
3640            "Feature Status Tables editable"
3641        );
3642    }
3643
3644    #[test]
3645    fn a_coloured_highlight_rides_out_as_a_name_beside_the_mark_role() {
3646        // The host draws the wash, so the colour has to reach it. It rides
3647        // `mark_color` rather than folding into `role` (`"mark-red"`) on
3648        // purpose: a renderer that only knows `"mark"` — every version of the
3649        // Swift one before this field existed — still draws the highlight.
3650        let d = doc("a ==\u{1F534} red== and ==plain== b\n");
3651        let runs = &d.view().rows[0].runs;
3652        let marks: Vec<(&str, Option<&str>)> = runs
3653            .iter()
3654            .filter(|r| r.role == "mark")
3655            .map(|r| (r.text.as_str(), r.mark_color.as_deref()))
3656            .collect();
3657        assert_eq!(marks, [("red", Some("red")), ("plain", None)]);
3658        assert!(
3659            runs.iter()
3660                .all(|r| r.role == "mark" || r.mark_color.is_none()),
3661            "nothing but a mark names a colour"
3662        );
3663    }
3664
3665    #[test]
3666    fn a_highlight_splits_runs_on_its_own_bytes_and_carries_its_id() {
3667        let d = doc("one two three\n");
3668        let view = d.set_highlights(vec![Highlight {
3669            start: 4,
3670            end: 7,
3671            id: "remark-1".into(),
3672            color: Some("#ffe066".into()),
3673            marker: Some("text.bubble".into()),
3674        }]);
3675        let row = &view.rows[0];
3676        let texts: Vec<(&str, Option<&str>)> = row
3677            .runs
3678            .iter()
3679            .map(|r| (r.text.as_str(), r.hl.as_deref()))
3680            .collect();
3681        assert_eq!(
3682            texts,
3683            [("one ", None), ("two", Some("remark-1")), (" three", None)],
3684            "the wash begins and ends exactly on the highlight's bytes"
3685        );
3686        assert_eq!(row.runs[1].hl_color.as_deref(), Some("#ffe066"));
3687        assert_eq!(d.highlight_at(5).as_deref(), Some("remark-1"));
3688        assert_eq!(
3689            d.highlights()
3690                .first()
3691                .and_then(|h| h.marker.clone())
3692                .as_deref(),
3693            Some("text.bubble"),
3694            "the marker rides back out for the frontend's margin pass"
3695        );
3696        assert_eq!(d.highlight_at(7), None, "end is exclusive");
3697        // A replace with nothing clears the wash.
3698        let view = d.set_highlights(Vec::new());
3699        assert!(view.rows[0].runs.iter().all(|r| r.hl.is_none()));
3700    }
3701}