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