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