Skip to main content

leaf_ffi/
lib.rs

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