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