Skip to main content

leaf_ffi/
lib.rs

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