Skip to main content

leaf_ffi/
lib.rs

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