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