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