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::borrow::Cow;
43use std::sync::{Arc, Mutex};
44
45use leaf_core::style::{Baseline, Role, Style as LStyle};
46use leaf_core::wysiwyg::text_width;
47use leaf_core::{
48 Align as CoreAlign, Alignment, BlockKind, Capabilities as CoreCapabilities, ColorScheme, Doc,
49 FaceTable as CoreFaceTable, FontFace as CoreFontFace, FontFamily as CoreFontFamily,
50 FontSize as CoreFontSize, Format, Hundredths as CoreHundredths, InlineKind,
51 LineFlow as CoreLineFlow, LineHeight as CoreLineHeight, LineSpacing as CoreLineSpacing,
52 MarkColor as CoreMarkColor, MarkupMode as CoreMarkupMode, MediaKind as CoreMediaKind,
53 SizeStep as CoreSizeStep, SourceMap, TextColor as CoreTextColor, TextCounts as CoreTextCounts,
54 View, VisualMap,
55};
56use unicode_segmentation::UnicodeSegmentation;
57
58// Linked, not used: see the dependency's note in Cargo.toml. The `as _` is
59// what makes rustc treat the crate as referenced and carry its objects into
60// the staticlib.
61use resvg_uniffi as _;
62
63uniffi::setup_scaffolding!();
64
65/// A parse failure constructing a document — the only fallible entry point. Every
66/// other method is infallible (it operates on an already-parsed model), so they
67/// return a [`DocView`] directly.
68#[derive(Debug, thiserror::Error, uniffi::Error)]
69pub enum LeafError {
70 /// The `format` string handed to [`LeafDoc::new`] wasn't one leaf understands.
71 #[error("unknown format: {name}")]
72 UnknownFormat { name: String },
73 /// `leaf-core` failed to parse `source` as the requested format.
74 #[error("parse error: {message}")]
75 Parse { message: String },
76 /// [`typeset_math`] could not read its TeX. `position` is a byte offset
77 /// into the formula's text where the parser gave up, when it can say.
78 #[error("math error: {message}")]
79 Math {
80 message: String,
81 position: Option<u32>,
82 },
83}
84
85/// A selection cited out of the source — the text, a little of what
86/// surrounded it, and the byte range it came from. The FFI shape of
87/// `leaf_core::Quote`; see [`LeafDoc::selection_quote`].
88#[derive(uniffi::Record)]
89pub struct SelectionQuote {
90 /// The selected source, verbatim.
91 pub exact: String,
92 /// What immediately preceded it — empty at the document's start.
93 pub prefix: String,
94 /// What immediately followed it — empty at the document's end.
95 pub suffix: String,
96 /// Byte offset in the source where the selection begins.
97 pub start: u64,
98 /// Byte offset where it ends (exclusive).
99 pub end: u64,
100}
101
102/// How much writing there is — over the whole document, or over the
103/// selection. The FFI shape of `leaf_core::TextCounts`; see
104/// [`LeafDoc::counts`] for what is counted and what isn't.
105#[derive(uniffi::Record)]
106pub struct TextCounts {
107 /// Words, by UAX#29 word segmentation: a segment holding at least one
108 /// letter or digit, so `don't` is one and a lone dash is none.
109 /// A hyphenated compound is two, which is what the algorithm says.
110 pub words: u64,
111 /// Characters as a reader counts them — grapheme clusters, spaces
112 /// included. An emoji family and an accented letter are each one.
113 pub characters: u64,
114 /// The same, less every whitespace grapheme.
115 pub characters_without_spaces: u64,
116 /// Block-level containers holding at least one non-whitespace character:
117 /// a paragraph, a heading, each list item, each paragraph inside a
118 /// blockquote, a whole code block, a whole table.
119 pub paragraphs: u64,
120}
121
122impl From<CoreTextCounts> for TextCounts {
123 fn from(c: CoreTextCounts) -> Self {
124 TextCounts {
125 words: c.words as u64,
126 characters: c.characters as u64,
127 characters_without_spaces: c.characters_without_spaces as u64,
128 paragraphs: c.paragraphs as u64,
129 }
130 }
131}
132
133/// A host-painted range of the source — an annotation's footprint, a search
134/// hit. The FFI shape of `leaf_core::Highlight`; see
135/// [`LeafDoc::set_highlights`].
136#[derive(uniffi::Record)]
137pub struct Highlight {
138 /// Byte offset in the source where the wash begins.
139 pub start: u64,
140 /// Byte offset where it ends (exclusive).
141 pub end: u64,
142 /// The host's name for it, handed back on activation. Opaque to leaf.
143 pub id: String,
144 /// A rendering hint (`#RRGGBB`), or `None` for the theme's default wash.
145 pub color: Option<String>,
146 /// A margin glyph's name (an SF Symbol, for this binding's frontends), or
147 /// `None` for wash-only ink. The marker — not the wash — is what
148 /// activates a highlight; see `leaf_core::Highlight::marker`.
149 pub marker: Option<String>,
150}
151
152/// One maximal span of same-styled glyphs on a visual row — the unit the Swift
153/// renderer turns into a single styled attributed-string run.
154#[derive(Clone, Debug, PartialEq, uniffi::Record)]
155pub struct Run {
156 /// The run's text, glyphs concatenated in column order.
157 pub text: String,
158 /// The glyph's semantic role as a renderer class id: `body`, `h1`…`h6`,
159 /// `code`, `link`, `mark`, `list`, `quote`, `rule`.
160 pub role: String,
161 pub bold: bool,
162 pub italic: bool,
163 pub underline: bool,
164 pub strike: bool,
165 /// Raised off the baseline and drawn smaller — a footnote reference's `[1]`,
166 /// or an author's `^x^`. Mutually exclusive with [`sub`](Self::sub); core's
167 /// `Baseline` is one value, and these are its two non-default cases flattened
168 /// to the flag shape the rest of this record is spelled in.
169 pub sup: bool,
170 /// Lowered off the baseline and drawn smaller — an author's `~x~`.
171 pub sub: bool,
172 /// The byte offset in the source this run's first glyph came from.
173 ///
174 /// What a run *means*, as opposed to how it looks: a `link` role says a span
175 /// is drawn as a link but not where it points, and the only way back to that
176 /// is the source. A frontend drawing part of the document somewhere the caret
177 /// isn't — a footnote's text in a popover — pairs this with
178 /// [`LeafDoc::link_destination_at`] or [`LeafDoc::footnote_at`] to make those
179 /// runs followable.
180 ///
181 /// The alternative was for a frontend to count its way along the row's text
182 /// and ask [`LeafDoc::offset_for_pos`], which means converting between three
183 /// units that only agree on ASCII: this is a byte offset, the run's text is
184 /// characters, and a row's column is a *display* cell (a wide CJK glyph is
185 /// two). Handing the offset over is exact, O(1), and needs none of that.
186 ///
187 /// `0` for the runs of the source view, whose rows are split from raw text
188 /// rather than laid out from glyphs.
189 pub src: u32,
190 /// Whether this run lies inside the active selection — so the renderer can
191 /// paint a selection background without re-deriving it from offsets.
192 pub sel: bool,
193 /// The id of the host highlight covering this run, if one does — see
194 /// [`LeafDoc::set_highlights`]. A highlight splits a run the way the
195 /// selection does, so a wash begins and ends exactly on its bytes.
196 pub hl: Option<String>,
197 /// That highlight's rendering hint (`#RRGGBB`, or `None` for the theme's
198 /// default wash), carried beside the id so a renderer needs no lookup.
199 pub hl_color: Option<String>,
200 /// The colour the author named on a `mark` run — `"red"`, `"orange"`,
201 /// `"yellow"`, `"green"`, `"blue"`, `"purple"`, `"brown"` — or absent for a
202 /// plain `==highlight==` and for every other role.
203 ///
204 /// A *name*, unlike [`hl_color`](Self::hl_color)'s `#RRGGBB`, and that is
205 /// the difference between the two: a host highlight's colour is the host's
206 /// own choice and arrives as a value to paint, while this one is the
207 /// document's word for it and the renderer picks the wash. It rides beside
208 /// `role` rather than folding into it (`"mark-red"`) so a renderer that
209 /// knows nothing about colours still draws the run as the highlight it is.
210 pub mark_color: Option<String>,
211 /// What a `code` run is to the language its fenced block is written in —
212 /// `"punctuation"`, `"keyword"`, `"entity"`, `"support"`, `"constant"`,
213 /// `"string"`, `"comment"`, `"invalid"` — or absent for a run the grammar
214 /// left plain, for every run of a block in a language no grammar covers,
215 /// for inline code, and for every other role.
216 ///
217 /// A class id like `role`, and beside it for the reason `mark_color` is: a
218 /// renderer that knows nothing about tokens still draws the run as the code
219 /// it is, and one that does keys a palette on the name.
220 pub token: Option<String>,
221 /// How large this run is set — one of CSS's seven keywords (`"xx-small"`,
222 /// `"x-small"`, `"small"`, `"large"`, `"x-large"`, `"xx-large"`,
223 /// `"xxx-large"`), or the exact size the author asked for (`"14pt"`,
224 /// `"13.5pt"`) — and absent for the theme's own size, which is every run
225 /// there was before the presentation vocabulary.
226 ///
227 /// A *token* rather than an enum, for [`mark_color`](Self::mark_color)'s
228 /// reason and one more: a keyword says how much bigger and leaves how big
229 /// to the theme (`leaf_core::SizeStep::scale` has CSS's own ratios for a
230 /// renderer that wants a default ramp), while a `pt` size is exactly that
231 /// many points of the sheet. A renderer reads its table first and parses
232 /// the suffix when the table has no entry.
233 pub size: Option<String>,
234 /// The face this run is set in — one of CSS's four generics (`"serif"`,
235 /// `"sans-serif"`, `"monospace"`, `"cursive"`), or a family the author
236 /// named (`"Garamond"`) — and absent for the theme's body face.
237 ///
238 /// A generic is the theme's to name, so `serif` is whichever serif this
239 /// platform's theme has and opens everywhere. A family name is resolved
240 /// through the platform's font registry and falls back to the body face
241 /// where it is not installed, which is the portability the author traded
242 /// away knowingly.
243 pub font: Option<String>,
244 /// The run's *foreground* colour — one of the seven names
245 /// [`mark_color`](Self::mark_color) carries, or six lowercase hex digits
246 /// behind a `#` (`"#c03030"`) — and absent for the theme's text colour.
247 ///
248 /// A name is two inks, one per appearance, and the theme owns both; a
249 /// triple is painted as written in the light appearance and in the dark
250 /// one alike, which is what "exact" means.
251 ///
252 /// Not [`mark_color`](Self::mark_color), though they share a vocabulary on
253 /// purpose: that is a highlight's *background* and reaches a run through its
254 /// `mark` role, this is what the letters themselves are painted. A renderer
255 /// with a red for a highlight has a red for text, and both should be that
256 /// red.
257 pub text_color: Option<String>,
258}
259
260/// Where a locator lands — what [`LeafDoc::locate`] answers with, and the FFI
261/// mirror of [`leaf_core::Landing`].
262///
263/// A span rather than an offset because the two things a host does with a
264/// locator want different halves of it: following one puts a caret at `start`,
265/// while peeking at one draws the rows between `start` and `end`. Only the first
266/// can be recovered from an offset alone.
267#[derive(uniffi::Record)]
268pub struct LandingView {
269 /// The first byte of the block the locator names — where a caret goes.
270 pub start: u32,
271 /// One past its last byte, so the pair maps through `pos_for_offset` to the
272 /// rendered rows the block occupies, the way [`FootnoteView`]'s pair does.
273 pub end: u32,
274}
275
276impl From<leaf_core::Landing> for LandingView {
277 fn from(l: leaf_core::Landing) -> Self {
278 LandingView {
279 start: l.start as u32,
280 end: l.end as u32,
281 }
282 }
283}
284
285/// A footnote reference and the note it names — what [`LeafDoc::footnote_at`]
286/// answers with. The FFI mirror of [`leaf_core::FootnoteRef`].
287///
288/// A reference whose definition the document is missing still comes back, with
289/// its `label` and no `text`: that a `[^99]` names nothing is a thing to tell
290/// the reader, and it is not the same as the caret standing on no reference at
291/// all (which is `None`).
292#[derive(uniffi::Record)]
293pub struct FootnoteView {
294 /// The reference's label — the `1` of `[^1]`, without the `^` or brackets.
295 pub label: String,
296 /// The note's body as source text, or `None` when nothing defines it.
297 pub text: Option<String>,
298 /// The byte offset the note's body starts at, for a "go to note" that moves
299 /// the caret there. `None` alongside a `None` `text`.
300 pub offset: Option<u32>,
301 /// Where the body ends, exclusive. With `offset` this bounds the note, so a
302 /// frontend can map the pair through `pos_for_offset` to the *rendered rows*
303 /// it occupies and draw those — the note with its markup resolved, rather
304 /// than the asterisks and backticks `text` carries. `None` alongside a
305 /// `None` `offset`.
306 pub end: Option<u32>,
307}
308
309impl From<leaf_core::FootnoteRef> for FootnoteView {
310 fn from(f: leaf_core::FootnoteRef) -> Self {
311 FootnoteView {
312 label: f.label,
313 text: f.text,
314 offset: f.offset.map(|o| o as u32),
315 end: f.end.map(|o| o as u32),
316 }
317 }
318}
319
320/// A footnote definition and the reference that sends a reader to it — what
321/// [`LeafDoc::footnote_definition_at_caret`] answers with, and the FFI mirror of
322/// [`leaf_core::FootnoteDef`].
323///
324/// The other half of [`FootnoteView`]'s round trip: that one carries a reader
325/// down to the note, this one carries them back up. A definition nothing cites
326/// still comes back, with its `label` and no `offset`, for the reason an
327/// undefined reference does — "nothing refers to this note" is worth saying.
328#[derive(uniffi::Record)]
329pub struct FootnoteDefView {
330 /// The definition's label — the `1` of `[^1]: …`, spelled exactly as
331 /// [`FootnoteView::label`] spells the same footnote's.
332 pub label: String,
333 /// The byte offset the first reference starts at, for a "back to reference"
334 /// that moves the caret there. `None` for a note nothing refers to.
335 pub offset: Option<u32>,
336}
337
338impl From<leaf_core::FootnoteDef> for FootnoteDefView {
339 fn from(f: leaf_core::FootnoteDef) -> Self {
340 FootnoteDefView {
341 label: f.label,
342 offset: f.offset.map(|o| o as u32),
343 }
344 }
345}
346
347/// One visual line: its styled runs plus the row-level flags a frontend draws
348/// chrome from.
349#[derive(Clone, Debug, PartialEq, uniffi::Record)]
350pub struct Row {
351 pub runs: Vec<Run>,
352 /// Drawn but holds no caret (a table rule, a block-gap blank line): the
353 /// renderer skips it for click/caret math. See [`leaf_core::VRow`].
354 pub decoration: bool,
355 /// A fenced/indented code-block line — the renderer draws a tinted, bordered
356 /// panel around each maximal run of these.
357 pub code: bool,
358 /// A fenced block's language, carried on the block's first code row only.
359 pub code_lang: Option<String>,
360 /// A `:::name{.class}` directive-container line — the renderer draws a
361 /// tinted panel around each maximal run of these, the `code` recipe. See
362 /// [`leaf_core::VRow::directive`].
363 pub directive: bool,
364 /// A directive container's space-joined `.class` attrs, carried on the
365 /// block's first row only. See [`leaf_core::VRow::directive_label`].
366 pub directive_label: Option<String>,
367 /// The heading level (1–6) if this row belongs to a heading block, else
368 /// `None`. A proportional renderer sizes the *whole* row from this so an
369 /// inline `` `code` `` run inside a heading still reads at the heading's size.
370 pub heading: Option<u8>,
371 /// How this row's block is aligned across the measure — `"center"`,
372 /// `"right"`, `"justify"` — and `None` for the theme's default, which is
373 /// left. On every row the block emits.
374 ///
375 /// A *row* fact and not a run one for [`heading`](Self::heading)'s reason,
376 /// and more sharply: alignment is a property of the line, not of the letters
377 /// on it, so an empty paragraph the author has just centred carries it with
378 /// no run to hang it on. A renderer sets its paragraph style's alignment
379 /// from it. See [`leaf_core::VRow::align`].
380 pub align: Option<String>,
381 /// How far apart this row's block sets its lines, as a multiple of the
382 /// theme's own line height — the menu's three (`"1.15"`, `"1.5"`, `"2"`)
383 /// or any other positive decimal the author asked for (`"1.3"`) — and
384 /// `None` for the theme's spacing, which is what `"1"` would mean and is
385 /// why it is never written. On every row the block emits.
386 ///
387 /// The token *is* the ratio, so a renderer laying rows out in points
388 /// multiplies its line height by it whether or not the menu has a row for
389 /// it; one drawing a row per terminal line ignores it, the way it ignores a
390 /// heading's size. See [`leaf_core::VRow::line_height`].
391 pub line_height: Option<String>,
392 /// What this row divides, on the blank rows a block boundary is drawn with
393 /// and `None` everywhere else — so `boundary != nil` is exactly "this row is
394 /// a drawn block boundary". A frontend spaces a boundary by the pair it
395 /// falls between (the margin above a heading is wider than the one between
396 /// two paragraphs); the *height* is the frontend's, the *kind* is core's.
397 /// See [`leaf_core::Boundary`].
398 pub boundary: Option<Boundary>,
399}
400
401/// What a drawn block boundary separates. The FFI mirror of
402/// [`leaf_core::Boundary`].
403#[derive(Clone, Debug, PartialEq, uniffi::Record)]
404pub struct Boundary {
405 pub above: BlockClass,
406 pub below: BlockClass,
407}
408
409/// The block kinds core tells apart — the vocabulary a [`Boundary`] is spelled
410/// in. The FFI mirror of [`leaf_core::BlockClass`]; `Other` covers every kind
411/// core doesn't separate out, so a frontend's `match` stays exhaustive as the
412/// list grows.
413#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)]
414pub enum BlockClass {
415 Paragraph,
416 Heading,
417 /// A whole list. Core draws no boundary row *between* two items of one list,
418 /// tight or loose, so an `ListItem`↔`ListItem` pair never reaches a frontend.
419 List,
420 ListItem,
421 Quote,
422 Code,
423 Table,
424 Media,
425 /// A display formula on lines of its own — a `$$…$$` block.
426 Math,
427 Directive,
428 Rule,
429 Footnote,
430 Other,
431}
432
433impl From<leaf_core::BlockClass> for BlockClass {
434 fn from(k: leaf_core::BlockClass) -> Self {
435 use leaf_core::BlockClass as K;
436 match k {
437 K::Paragraph => BlockClass::Paragraph,
438 K::Heading => BlockClass::Heading,
439 K::List => BlockClass::List,
440 K::ListItem => BlockClass::ListItem,
441 K::Quote => BlockClass::Quote,
442 K::Code => BlockClass::Code,
443 K::Table => BlockClass::Table,
444 K::Media => BlockClass::Media,
445 K::Math => BlockClass::Math,
446 K::Directive => BlockClass::Directive,
447 K::Rule => BlockClass::Rule,
448 K::Footnote => BlockClass::Footnote,
449 K::Other => BlockClass::Other,
450 }
451 }
452}
453
454/// One *visual line* of a table cell: its styled runs and the source offsets
455/// bounding it. A cell is usually one line, but an in-cell hard break (an inline
456/// `<br>`) splits it into several — each its own line here, so the frontend
457/// shapes and caret-maps them independently (the byte↔UTF-16 offset math a cell
458/// needs holds within a line, which carries no break). The runs are *unwrapped*:
459/// column width — and any soft wrap within it — is the frontend's to decide.
460#[derive(uniffi::Record)]
461pub struct TableCellLineView {
462 pub runs: Vec<Run>,
463 /// The source offsets bounding this line's content — the caret home at its
464 /// start and the stop just past its end.
465 pub start: u32,
466 pub end: u32,
467}
468
469/// One cell of a table's structural grid: its content as one or more visual
470/// lines, the column alignment its text honours, and the source range the whole
471/// cell occupies (where a click or the caret lands).
472#[derive(uniffi::Record)]
473pub struct TableCellView {
474 /// The cell's lines, in order — one unless an in-cell `<br>` splits it.
475 pub lines: Vec<TableCellLineView>,
476 /// `"left"`, `"right"`, `"center"`, or `"default"`.
477 pub align: String,
478 /// The source offsets bounding the cell's content — the caret anchors a
479 /// click in the cell resolves to.
480 pub start: u32,
481 pub end: u32,
482}
483
484/// One row of a table's structural grid; a header row draws bold and is ruled
485/// off from the body below it.
486#[derive(uniffi::Record)]
487pub struct TableRowView {
488 pub head: bool,
489 pub cells: Vec<TableCellView>,
490}
491
492/// A table described *structurally* rather than as the monospace box-glyph
493/// picture that spells it in [`DocView::rows`]. A proportional renderer draws its
494/// own grid from this — columns sized to content, real borders — and SKIPS the
495/// picture rows in `[start_row, end_row)`. The two describe the same cells at the
496/// same source offsets, so the caret lands identically either way. See
497/// [`leaf_core::TableInfo`].
498#[derive(uniffi::Record)]
499pub struct TableView {
500 /// The [`DocView::rows`] indices the box-drawn picture occupies — the rows a
501 /// grid-drawing frontend skips.
502 pub start_row: u32,
503 pub end_row: u32,
504 pub grid: Vec<TableRowView>,
505}
506
507/// A leaf directive (`::name{…}`) — a standalone block with no body, drawn in
508/// [`DocView::rows`] as a one-row `⧉ name` placeholder. A frontend that knows
509/// the host app's vocabulary reads this and paints the real thing over the rows
510/// in `[start_row, end_row)` — a web view for diaryx's `::embed{src=…}`, say —
511/// exactly as a grid-drawing one replaces a [`TableView`]'s picture rows. One
512/// that doesn't just paints the placeholder, which is already framed by the
513/// directive panel chrome.
514///
515/// Core resolves nothing here and neither does this layer: the vocabulary
516/// belongs to the app. See [`leaf_core::DirectiveInfo`].
517#[derive(uniffi::Record)]
518pub struct DirectiveView {
519 /// The [`DocView::rows`] indices the placeholder occupies.
520 pub start_row: u32,
521 pub end_row: u32,
522 /// The directive's type (`embed`, `toc`, `vis`), no leading colons.
523 pub name: String,
524 /// Its `[label]` text, or empty — what the placeholder row shows.
525 pub label: String,
526 /// Its `{…}` attributes in source order. A bare attribute (`{public}`) has an
527 /// empty value, which a consumer reads as a flag.
528 pub attrs: Vec<DirectiveAttr>,
529}
530
531/// One `{key=value}` attribute of a [`DirectiveView`]. A record rather than a
532/// tuple because UniFFI has no tuple type; an absent value flattens to `""`,
533/// since a bare attribute is a flag and the distinction from `key=""` has no
534/// consumer on this side.
535#[derive(uniffi::Record)]
536pub struct DirectiveAttr {
537 pub key: String,
538 pub value: String,
539}
540
541/// What a block-level media placeholder is, so Swift knows which view to build
542/// over the rows core reserved: an `NSImageView`/`UIImageView`, or an
543/// `AVPlayerView` with or without a picture to show. The peer of
544/// [`leaf_core::MediaKind`].
545#[derive(uniffi::Enum)]
546pub enum MediaKind {
547 Image,
548 Video,
549 Audio,
550}
551
552/// One `<source>` alternative of a block media element — a candidate URL plus
553/// whichever of the two things HTML picks a `<source>` by: a media query
554/// (`<picture>`) or a MIME type (`<video>`/`<audio>`).
555///
556/// Unlike the web frontend, which hands the whole list to the browser and lets
557/// it choose, a native renderer usually wants [`MediaView::src`] — already
558/// resolved for the current appearance — and reaches in here only to pick a
559/// codec `AVFoundation` can actually play.
560#[derive(uniffi::Record)]
561pub struct MediaSourceView {
562 /// The `media="…"` query, or empty for an unconditional source.
563 pub media: String,
564 /// The candidate URL (a `<picture>` `srcset` or a `<video>`/`<audio>` `src`).
565 pub src: String,
566 /// The `type="…"` MIME (`"video/webm"`), or empty when none is declared.
567 pub mime: String,
568}
569
570/// One block-level image, video, or audio: which rows core reserved for it and
571/// what to build there. The peer of [`leaf_core::MediaInfo`], and the media
572/// analogue of [`DirectiveView`] — a frontend **skips the rows in
573/// `start_row..end_row`** and lays its own view over them, rather than painting
574/// the `🖼`/`🎬`/`🔊` placeholder glyphs core put there for a surface that can't.
575#[derive(uniffi::Record)]
576pub struct MediaView {
577 /// The [`DocView::rows`] indices the placeholder occupies.
578 pub start_row: u32,
579 pub end_row: u32,
580 /// Which of the three this is — the view to build.
581 pub kind: MediaKind,
582 /// The URL to load, already resolved against the current appearance (see
583 /// [`LeafDoc::set_dark_appearance`]). A relative path resolves against the
584 /// document's own directory, which core does not know — the host does.
585 /// Empty only when a `<video>`/`<audio>` named neither a `src` nor a
586 /// `<source>`, which is a broken document.
587 pub src: String,
588 /// A `<video>`'s poster frame URL, or empty. An image destination, so it
589 /// loads exactly as an image `src` does — worth showing before the movie is
590 /// ready, or in place of one that won't play.
591 pub poster: String,
592 /// The alt / fallback text, for the view's accessibility label.
593 pub alt: String,
594 /// The `<source>` alternatives in document order; empty for a plain image.
595 pub sources: Vec<MediaSourceView>,
596}
597
598/// A per-destination measured height, the way Swift reports one back — the input
599/// half of the loop [`LeafDoc::set_media_rows`] closes.
600#[derive(uniffi::Record)]
601pub struct MediaHeight {
602 /// The media's `src` as it appeared in the document, keying it to a
603 /// [`MediaView`].
604 pub destination: String,
605 /// How many visual rows the laid-out view needs.
606 pub rows: u32,
607}
608
609/// One formula standing as a picture: what to typeset and where its picture
610/// goes. The peer of [`leaf_core::MathInfo`]. Two shapes:
611///
612/// - **Inline** (`inline == true`): one row, and on it exactly one run with
613/// role `math` whose `src` equals this `src` — a single `∑` standing for the
614/// whole formula. The renderer typesets the TeX at the run's font size
615/// ([`typeset_math`]) and draws the picture in the run's place with the
616/// text baseline through it at the picture's height: a run delegate on
617/// Apple. The run is a caret stop at the formula's start; the one after it
618/// is the next run's first character.
619/// - **Block** (`inline == false`): the rows in `start_row..end_row` are the
620/// placeholder, exactly a [`MediaView`]'s shape — the renderer **skips
621/// them** and lays the typeset picture over them, centred on the measure.
622///
623/// A formula on the caret's line is not here: there it is its TeX, drawn as
624/// `code` runs between `delimiter` runs, in every markup mode.
625#[derive(uniffi::Record)]
626pub struct MathView {
627 /// The [`DocView::rows`] indices the formula occupies — its own row for an
628 /// inline one, the placeholder and its fillers for a block.
629 pub start_row: u32,
630 pub end_row: u32,
631 /// Whether this is an atom in a line of text, or a block of its own.
632 pub inline: bool,
633 /// The TeX between the delimiters, verbatim. What [`typeset_math`] takes.
634 pub tex: String,
635 /// Display style (limits above and below, full-height fractions) rather
636 /// than text style — a `$$…$$`, inline or not.
637 pub display: bool,
638 /// The formula's source start: what the `math` run's `src` carries, and
639 /// where a click on the picture lands the caret.
640 pub src: u32,
641}
642
643/// A per-formula measured height, the way a renderer that reserves rows
644/// (rather than laying pictures out in its own units) reports one back — the
645/// input half of the loop [`LeafDoc::set_math_rows`] closes. The Swift views
646/// lay a formula out in points and never need this.
647#[derive(uniffi::Record)]
648pub struct MathHeight {
649 /// The formula's `tex` as [`MathView`] handed it over.
650 pub tex: String,
651 /// How many visual rows the picture needs.
652 pub rows: u32,
653}
654
655/// A typeset formula: a standalone SVG document and where its baseline is.
656/// The peer of `leaf_math::MathPicture`; see [`typeset_math`].
657#[derive(uniffi::Record)]
658pub struct MathPicture {
659 /// A self-contained SVG — every glyph an outline, no font to find. Its
660 /// `viewBox`, `width` and `height` are in pixels at the size it was
661 /// typeset at, so drawn at its intrinsic size the glyphs land at that
662 /// font size.
663 pub svg: String,
664 /// The picture's advance width, in em of the size it was typeset at.
665 pub width: f64,
666 /// How far it rises above its baseline, in em — the ascent a run
667 /// delegate reports, so the text baseline passes through the picture
668 /// here.
669 pub height: f64,
670 /// How far it reaches below its baseline, in em — the descent.
671 pub depth: f64,
672}
673
674/// A rendered frame: the rows to paint, where the caret sits, and the
675/// toolbar state — everything the Swift side needs for one repaint, in one value.
676/// Returned by every view-producing method.
677///
678/// ## Whole or a change
679///
680/// By default every frame is whole: `rows` is every row of the document, and
681/// the frame before it is forgotten. After [`LeafDoc::set_incremental_frames`]
682/// the same methods answer with a frame whose `rows` are only the rows that
683/// changed since the frame before — a caret move lifts none, a keystroke lifts
684/// the row it landed on — and the five fields after `rows` say where they go.
685/// Which kind a frame is, it says itself: `basis` is `0` on a whole frame and
686/// the frame before's number on a change. A caller applies a change to the
687/// frame it holds (see `rows`), and one that holds a frame other than `basis`
688/// has lost step and asks [`LeafDoc::view`] for a whole one, which every
689/// change after that is against. The rest of the frame — the caret, the
690/// selection, the toolbar state, `tables`, `directives`, `media` and `math` —
691/// is complete on every frame of either kind.
692#[derive(uniffi::Record)]
693pub struct DocView {
694 /// The rows to paint: every row of the document on a whole frame, and on
695 /// a change (`basis != 0`) the rows that replace `replaced` of the frame
696 /// before's from `row_start`, after which the rows that follow are the
697 /// frame before's with `src_shift` added to every `Run::src` they carry.
698 /// So a frame is applied as: splice `rows` over `row_start..row_start +
699 /// replaced`, then move the offsets of the rows after the splice. An
700 /// empty `rows` with `replaced == 0` is a frame that changed no row.
701 pub rows: Vec<Row>,
702 /// This frame's number: one more than the frame before's, from `1` at
703 /// the first. What a change names as its `basis`.
704 pub frame: u32,
705 /// The number of the frame these `rows` are a change against, or `0` when
706 /// they are the whole document. A change is applied only to a copy of
707 /// exactly that frame; see the type's docs.
708 pub basis: u32,
709 /// Where `rows` begin, as an index into the document's rows — the
710 /// frame before's and, since a change never moves the rows above it, this
711 /// one's. `0` on a whole frame.
712 pub row_start: u32,
713 /// How many of the frame before's rows, from `row_start`, `rows` replace.
714 /// `0` on a whole frame.
715 pub replaced: u32,
716 /// How many rows the document has once this frame is applied — what
717 /// `rows.len()` is on a whole frame, so a caller sizing a scroll view
718 /// reads this on either kind.
719 pub row_count: u32,
720 /// The byte offset every `Run::src` in a row *after* the replaced span
721 /// moved by — an edit shifts the source of everything below it, and
722 /// those rows are otherwise the frame before's, so they are kept and
723 /// moved rather than lifted. `0` on a whole frame, and on a change that
724 /// moved nothing.
725 pub src_shift: i32,
726 /// Tables described structurally, for a frontend that draws its own grid
727 /// instead of painting the box-glyph rows. Empty in the source view. Each
728 /// names the span of the *document's* rows its picture occupies, to be
729 /// skipped — an index into a whole frame's `rows`, and into the rows a
730 /// change has been applied to. These lists are small and ride every
731 /// frame complete, so no frame has to say whether they changed.
732 pub tables: Vec<TableView>,
733 /// Leaf directives (`::name{…}`) described structurally, for a frontend that
734 /// paints what the host app's vocabulary makes of them instead of the `⧉`
735 /// placeholder row. Empty in the source view, where the directive is the
736 /// literal text the caret is editing.
737 pub directives: Vec<DirectiveView>,
738 /// Block-level images, videos, and audio described structurally, for a
739 /// frontend that lays real views over the rows core reserved instead of
740 /// painting the placeholder glyphs. Empty in the source view, where the
741 /// `` or `<video>` markup is the literal text being edited.
742 pub media: Vec<MediaView>,
743 /// Formulas standing as pictures — each inline atom and each display
744 /// block — for a frontend that typesets and draws them in place of the
745 /// `math` run or the placeholder rows. Empty in the source view, and
746 /// empty of any formula on the caret's line, which is its TeX there.
747 pub math: Vec<MathView>,
748 /// The caret's row: an index into [`Self::rows`].
749 pub caret_row: u32,
750 /// The caret's display *column* within its row — core's grid position. Kept
751 /// for callers reasoning in columns; a proportional renderer wants
752 /// [`Self::caret_ch`] instead.
753 pub caret_col: u32,
754 /// The caret's offset within its row's text in **UTF-16 code units** — what
755 /// `NSAttributedString`/`NSTextView` count to. This is `caret_col` mapped
756 /// through the row's grapheme widths, so it lands the caret correctly past
757 /// wide glyphs (CJK, emoji) where a column and a character index diverge.
758 pub caret_ch: u32,
759 /// The caret's **source byte offset** — the coordinate a table cell is keyed
760 /// by (`TableCellView::start`/`end`), so a frontend drawing its own grid can
761 /// find which cell the caret sits in without the picture-row indices.
762 pub caret_src: u32,
763 /// Whether a (non-empty) selection is active.
764 pub has_selection: bool,
765 /// The selection's *fixed* end (the caret is the moving end), as a row and a
766 /// UTF-16 offset — so the renderer can restore a native selection with the
767 /// same direction the model has. Equal to the caret when `has_selection` is
768 /// false.
769 pub anchor_row: u32,
770 pub anchor_ch: u32,
771 /// Whether the buffer differs from the last saved bytes — for a "● modified"
772 /// affordance.
773 pub dirty: bool,
774 /// Whether there is a step to undo, and one to redo — what a native Edit
775 /// menu or an undo manager enables its items by. Both false on a read-only
776 /// document. See [`leaf_core::Doc::can_undo`] for the bound this is.
777 pub can_undo: bool,
778 pub can_redo: bool,
779 /// `"wysiwyg"` or `"source"`, for a view-toggle affordance.
780 pub view: String,
781 /// The heading level at the caret, if any — a toolbar lights H1…H6 from it.
782 pub heading: Option<u32>,
783 /// Whether the caret stands in a code block — the toolbar lights its Code
784 /// Block button from it. Rides the frame for `heading`'s reason: walking
785 /// the caret into a fence changes no mark, and a button asking for itself
786 /// would never be told.
787 pub code_block: bool,
788 /// Whether the list item at the caret carries a checkbox, and which way it
789 /// faces — `Some(true)` ticked, `Some(false)` empty, `None` for a plain
790 /// item or no item at all. A toolbar lights its Checklist button from the
791 /// first question and ticks its Checked item from the second. Rides the
792 /// frame for `heading`'s reason: stepping the caret from a bullet into a
793 /// task item changes no mark, and a button asking for itself would never
794 /// be told.
795 pub task: Option<bool>,
796 /// The inline marks active at the caret (`bold`, `italic`, `code`, …) — the
797 /// toolbar lights the matching buttons.
798 pub active: Vec<String>,
799 /// The destination of the link the caret stands in, or `None` — the toolbar
800 /// lights its Link button from it and seeds an edit of that link with it.
801 ///
802 /// It rides the frame rather than being a query a toolbar makes for itself
803 /// because a toolbar only redraws when the *state* changes: walking the caret
804 /// out of a link changes no mark, no heading, and no dirty flag, so a Link
805 /// button reading this by a call of its own would keep a stale light on. Same
806 /// reason `heading` is here and not asked for.
807 ///
808 /// Only a *parsed* link answers ([`LeafDoc::link_destination_at_caret`]);
809 /// a wikilink is literal text with no node behind it, and has nothing to
810 /// repoint — see `LinkTarget.swift`.
811 pub link: Option<String>,
812 /// The colour of the highlight the caret stands in — which swatch a colour
813 /// palette draws as the current one, and `None` both outside a highlight and
814 /// inside an uncoloured one.
815 ///
816 /// It rides the frame for `link`'s reason, and more sharply: walking from a
817 /// red highlight into a blue one changes no mark, no heading, no dirty flag
818 /// and no link, so a palette asking for itself would never be told to move
819 /// its checkmark.
820 ///
821 /// An enum rather than the name string [`Run::mark_color`] carries, because
822 /// the two are different questions. A run's colour is a *rendering* hint that
823 /// has to survive a name this build has never heard of (a newer twig's
824 /// eighth colour draws as a plain highlight rather than not at all); this is
825 /// the closed palette a control offers, and a name outside it is not a
826 /// swatch anyone can press.
827 pub mark_color: Option<MarkColor>,
828}
829
830/// The colour of a highlight — the closed palette [`LeafDoc::set_mark_color`]
831/// writes and [`DocView::mark_color`] reports.
832///
833/// Obsidian's spelling, which is twig's: a circle emoji straight after the
834/// opening `==`, so `==🔴 text==` is a highlight of `text` in [`MarkColor::Red`].
835/// The emoji is *spelling* rather than content — it never appears in the text a
836/// reader sees, and never in a [`Run`].
837#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
838pub enum MarkColor {
839 Red,
840 Orange,
841 Yellow,
842 Green,
843 Blue,
844 Purple,
845 Brown,
846}
847
848impl From<CoreMarkColor> for MarkColor {
849 fn from(c: CoreMarkColor) -> Self {
850 match c {
851 CoreMarkColor::Red => MarkColor::Red,
852 CoreMarkColor::Orange => MarkColor::Orange,
853 CoreMarkColor::Yellow => MarkColor::Yellow,
854 CoreMarkColor::Green => MarkColor::Green,
855 CoreMarkColor::Blue => MarkColor::Blue,
856 CoreMarkColor::Purple => MarkColor::Purple,
857 CoreMarkColor::Brown => MarkColor::Brown,
858 }
859 }
860}
861
862impl From<MarkColor> for CoreMarkColor {
863 fn from(c: MarkColor) -> Self {
864 match c {
865 MarkColor::Red => CoreMarkColor::Red,
866 MarkColor::Orange => CoreMarkColor::Orange,
867 MarkColor::Yellow => CoreMarkColor::Yellow,
868 MarkColor::Green => CoreMarkColor::Green,
869 MarkColor::Blue => CoreMarkColor::Blue,
870 MarkColor::Purple => CoreMarkColor::Purple,
871 MarkColor::Brown => CoreMarkColor::Brown,
872 }
873 }
874}
875
876/// How a block's lines are set across the measure — the closed vocabulary
877/// [`LeafDoc::set_alignment`] writes and [`LeafDoc::alignment_at_caret`]
878/// reports.
879///
880/// There is no `left`, because absence is left: the default alignment is the
881/// theme's, and a document that agrees with it has no reason to say so. A
882/// segmented control draws a fourth segment for it and calls `setAlignment(nil)`.
883///
884/// An enum rather than the name string [`Row::align`] carries, for
885/// [`MarkColor`]'s reason: a row's alignment is a *rendering* fact that has to
886/// survive a token this build has never heard of, while this is the closed set a
887/// control offers.
888#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
889pub enum Align {
890 Center,
891 Right,
892 Justify,
893}
894
895impl From<CoreAlign> for Align {
896 fn from(a: CoreAlign) -> Self {
897 match a {
898 CoreAlign::Center => Align::Center,
899 CoreAlign::Right => Align::Right,
900 CoreAlign::Justify => Align::Justify,
901 }
902 }
903}
904
905impl From<Align> for CoreAlign {
906 fn from(a: Align) -> Self {
907 match a {
908 Align::Center => CoreAlign::Center,
909 Align::Right => CoreAlign::Right,
910 Align::Justify => CoreAlign::Justify,
911 }
912 }
913}
914
915/// How far apart a block's lines are set, as a multiple of the theme's own line
916/// height — the vocabulary [`LeafDoc::set_line_spacing`] writes.
917///
918/// Single spacing is absent for the reason `left` is absent from [`Align`]: it
919/// is the theme's, and the menu entry for it is `setLineSpacing(nil)`.
920#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
921pub enum LineSpacing {
922 /// `1.15` — the word processor's default "a little more air".
923 OneFifteen,
924 /// `1.5`.
925 OneHalf,
926 /// `2` — double spacing.
927 Double,
928}
929
930impl From<CoreLineSpacing> for LineSpacing {
931 fn from(l: CoreLineSpacing) -> Self {
932 match l {
933 CoreLineSpacing::OneFifteen => LineSpacing::OneFifteen,
934 CoreLineSpacing::OneHalf => LineSpacing::OneHalf,
935 CoreLineSpacing::Double => LineSpacing::Double,
936 }
937 }
938}
939
940impl From<LineSpacing> for CoreLineSpacing {
941 fn from(l: LineSpacing) -> Self {
942 match l {
943 LineSpacing::OneFifteen => CoreLineSpacing::OneFifteen,
944 LineSpacing::OneHalf => CoreLineSpacing::OneHalf,
945 LineSpacing::Double => CoreLineSpacing::Double,
946 }
947 }
948}
949
950/// How large a run is set relative to the text around it — CSS's
951/// `<absolute-size>` keywords with `medium` removed, because `medium` is
952/// absence. What [`LeafDoc::set_font_size`] writes.
953///
954/// A *step*, never a measurement: a run set to `14pt` in a 12pt theme is a step
955/// up and the same run under a 16pt theme is a step *down*, the author's intent
956/// inverted by a change they never made. `Large` is a step up under every theme,
957/// and `leaf_core::SizeStep::scale` has CSS's own ratio for each if the theme
958/// wants a default ramp.
959#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
960pub enum SizeStep {
961 XxSmall,
962 XSmall,
963 Small,
964 Large,
965 XLarge,
966 XxLarge,
967 XxxLarge,
968}
969
970impl From<CoreSizeStep> for SizeStep {
971 fn from(s: CoreSizeStep) -> Self {
972 match s {
973 CoreSizeStep::XxSmall => SizeStep::XxSmall,
974 CoreSizeStep::XSmall => SizeStep::XSmall,
975 CoreSizeStep::Small => SizeStep::Small,
976 CoreSizeStep::Large => SizeStep::Large,
977 CoreSizeStep::XLarge => SizeStep::XLarge,
978 CoreSizeStep::XxLarge => SizeStep::XxLarge,
979 CoreSizeStep::XxxLarge => SizeStep::XxxLarge,
980 }
981 }
982}
983
984impl From<SizeStep> for CoreSizeStep {
985 fn from(s: SizeStep) -> Self {
986 match s {
987 SizeStep::XxSmall => CoreSizeStep::XxSmall,
988 SizeStep::XSmall => CoreSizeStep::XSmall,
989 SizeStep::Small => CoreSizeStep::Small,
990 SizeStep::Large => CoreSizeStep::Large,
991 SizeStep::XLarge => CoreSizeStep::XLarge,
992 SizeStep::XxLarge => CoreSizeStep::XxLarge,
993 SizeStep::XxxLarge => CoreSizeStep::XxxLarge,
994 }
995 }
996}
997
998/// The face a run is set in — CSS's generic families, less `fantasy` and
999/// `system-ui`, neither of which an author asks for. What
1000/// [`LeafDoc::set_font_family`] writes.
1001///
1002/// A generic, never a font name, for [`SizeStep`]'s reason: a document naming
1003/// `Georgia` renders in the fallback everywhere Georgia is not installed. The
1004/// theme names the concrete face for each — `Serif` is whichever serif this
1005/// platform's theme has, and `Monospace` is the face inline code already uses.
1006/// The theme's own body face is absent because it is absence; the menu entry for
1007/// it is `setFontFamily(nil)`.
1008#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
1009pub enum FontFamily {
1010 Serif,
1011 SansSerif,
1012 Monospace,
1013 Cursive,
1014}
1015
1016impl From<CoreFontFamily> for FontFamily {
1017 fn from(f: CoreFontFamily) -> Self {
1018 match f {
1019 CoreFontFamily::Serif => FontFamily::Serif,
1020 CoreFontFamily::SansSerif => FontFamily::SansSerif,
1021 CoreFontFamily::Monospace => FontFamily::Monospace,
1022 CoreFontFamily::Cursive => FontFamily::Cursive,
1023 }
1024 }
1025}
1026
1027impl From<FontFamily> for CoreFontFamily {
1028 fn from(f: FontFamily) -> Self {
1029 match f {
1030 FontFamily::Serif => CoreFontFamily::Serif,
1031 FontFamily::SansSerif => CoreFontFamily::SansSerif,
1032 FontFamily::Monospace => CoreFontFamily::Monospace,
1033 FontFamily::Cursive => CoreFontFamily::Cursive,
1034 }
1035 }
1036}
1037
1038// ── the exact forms: a value where a name will not do ───────────────────────
1039//
1040// Each of the four closed enums above gets an *open* one beside it, which is a
1041// Swift enum with associated values: `.step(.large)` or `.points(14)`,
1042// `.generic(.serif)` or `.named("Garamond")`, `.named(.red)` or `.rgb(…)`,
1043// `.step(.oneHalf)` or `.ratio(1.3)`. The four gestures and four queries take
1044// and answer these; the closed enums stay because they are what a menu offers
1045// *first* — a name is portable under every theme, and the value under the
1046// divider is the author's to take knowingly. See
1047// `docs/proposals/exact-presentation-values.md`.
1048//
1049// **A value this vocabulary cannot carry is refused, and the gesture writes
1050// nothing at all.** A size of `0pt` or `700pt`, a ratio of `0`, a face named
1051// `" "` — an author who typed one of those asked for something the document
1052// cannot hold, and the answer to that is to leave the document as it is. It is
1053// emphatically *not* to clear the key: the size that was already on the run is
1054// not the author's mistake, and a field that refuses by throwing away what was
1055// there is a field that punishes a typo. Validate before calling anyway — the
1056// range is 0.01 to 655.35 — because from here a refusal is silent.
1057//
1058// **One value does mean absence**, and it is the one core already states: a
1059// line height of `1` is single spacing, which is the theme's own and has no
1060// token, so `.ratio(1)` *clears* the key. That is the author asking for the
1061// default, not failing to ask for anything. [`Meant`] is the three answers
1062// written down once.
1063
1064/// What a presentation value a caller handed in comes to — the three answers
1065/// the note above states, written down once.
1066///
1067/// A gesture asks for this and writes `Some(value)`, writes `None`, or writes
1068/// nothing at all. The middle one is a *clearing* the author asked for and the
1069/// last is a refusal; from the other side of the binding they look alike, which
1070/// is why a field that offers a number validates before it calls.
1071enum Meant<T> {
1072 /// A value core carries. The gesture writes it.
1073 Value(T),
1074 /// A value that *means* the theme's own. The gesture clears the key — only
1075 /// a line height of 1 is one of these.
1076 Absence,
1077 /// A value outside what the vocabulary carries. The gesture writes nothing,
1078 /// and what the run already said stands.
1079 Refused,
1080}
1081
1082impl<T> Meant<T> {
1083 /// A core constructor's `Option` read as a refusal — the shape three of the
1084 /// four conversions below have, since only a spacing has an absence.
1085 fn of(value: Option<T>) -> Self {
1086 match value {
1087 Some(value) => Self::Value(value),
1088 None => Self::Refused,
1089 }
1090 }
1091}
1092
1093/// What a gesture handed `value` writes: `Some(Some(v))` for a value,
1094/// `Some(None)` for the clearing both a `nil` argument and an absence mean, and
1095/// `None` for a value the vocabulary refuses — which the gesture spells by not
1096/// calling core at all.
1097fn written<T, C>(value: Option<T>, into_core: impl FnOnce(T) -> Meant<C>) -> Option<Option<C>> {
1098 match value.map(into_core) {
1099 None | Some(Meant::Absence) => Some(None),
1100 Some(Meant::Value(value)) => Some(Some(value)),
1101 Some(Meant::Refused) => None,
1102 }
1103}
1104
1105/// How large a run is set: a [`SizeStep`] relative to the text around it, or
1106/// the point size the author asked for. What [`LeafDoc::set_font_size`] writes
1107/// and [`LeafDoc::font_size_at_caret`] answers.
1108///
1109/// The step is what a menu offers first and what a document should say where a
1110/// name will do — it reads as a step up under every theme. The point size is
1111/// what the author typed and is all it is: 14 points of the sheet on paper, and
1112/// 14 points before the zoom on screen. A heading set to an exact size is that
1113/// size and not its ramp scaled.
1114#[derive(Clone, Copy, Debug, PartialEq, uniffi::Enum)]
1115pub enum FontSize {
1116 Step(SizeStep),
1117 /// Points. 0.01 to 655.35; anything else is refused — see the note above.
1118 Points(f64),
1119}
1120
1121impl FontSize {
1122 /// The core size this names — [`Meant::Refused`] for a number of points
1123 /// core cannot carry, which leaves the run's size alone.
1124 fn into_core(self) -> Meant<CoreFontSize> {
1125 match self {
1126 FontSize::Step(step) => Meant::Value(CoreFontSize::Step(step.into())),
1127 FontSize::Points(points) => Meant::of(CoreFontSize::points(points as f32)),
1128 }
1129 }
1130}
1131
1132impl From<CoreFontSize> for FontSize {
1133 fn from(s: CoreFontSize) -> Self {
1134 match s {
1135 CoreFontSize::Step(step) => FontSize::Step(step.into()),
1136 // Divided in `f64` rather than widened from the `f32` `as_f32`
1137 // answers: 1.3 as an `f32` widens to 1.2999999523, and a caller
1138 // that passed 1.3 in has to get 1.3 back or no menu row ticks.
1139 CoreFontSize::Points(pt) => FontSize::Points(f64::from(pt.hundredths()) / 100.0),
1140 }
1141 }
1142}
1143
1144/// How far apart a block's lines are set: a [`LineSpacing`] from the menu's
1145/// three, or the ratio the author asked for. [`FontSize`]'s peer one property
1146/// along, and with no unit at all — a line height is a multiple.
1147///
1148/// A ratio that spells one of the three names *is* that name, so
1149/// `.ratio(1.5)` comes back as `.step(.oneHalf)` and a menu has a row to tick.
1150/// A ratio of 1 is single spacing, which is absence: it clears the key.
1151#[derive(Clone, Copy, Debug, PartialEq, uniffi::Enum)]
1152pub enum LineHeight {
1153 Step(LineSpacing),
1154 /// A multiple of the theme's line height. 1 is absence, and clears; a
1155 /// number outside 0.01 to 655.35 is refused — see the note above.
1156 Ratio(f64),
1157}
1158
1159impl LineHeight {
1160 /// The core spacing this names — the one [`into_core`](FontSize::into_core)
1161 /// here whose answer can be [`Meant::Absence`], because a ratio of 1 is
1162 /// single spacing and single spacing has no token.
1163 fn into_core(self) -> Meant<CoreLineHeight> {
1164 match self {
1165 LineHeight::Step(step) => Meant::Value(CoreLineHeight::Step(step.into())),
1166 // A number the vocabulary carries at all is asked first, because
1167 // `ratio` answers `None` both for a 1 — which *means* the theme's
1168 // own — and for a 0 or a NaN, which mean nothing at all, and the
1169 // two have opposite answers.
1170 LineHeight::Ratio(ratio) => match CoreHundredths::from_f32(ratio as f32) {
1171 None => Meant::Refused,
1172 Some(_) => match CoreLineHeight::ratio(ratio as f32) {
1173 Some(height) => Meant::Value(height),
1174 None => Meant::Absence,
1175 },
1176 },
1177 }
1178 }
1179}
1180
1181impl From<CoreLineHeight> for LineHeight {
1182 fn from(l: CoreLineHeight) -> Self {
1183 match l {
1184 CoreLineHeight::Step(step) => LineHeight::Step(step.into()),
1185 // In `f64` throughout, for [`FontSize`]'s reason.
1186 CoreLineHeight::Ratio(r) => LineHeight::Ratio(f64::from(r.hundredths()) / 100.0),
1187 }
1188 }
1189}
1190
1191/// A run's *foreground* colour: one of the seven [`MarkColor`] names, or the
1192/// RGB triple the author asked for.
1193///
1194/// A name is two inks, one per appearance, and the theme owns both. A triple is
1195/// painted as written in the light appearance and in the dark one alike — that
1196/// is what "exact" means, and the theme does not soften it.
1197#[derive(Clone, Copy, Debug, PartialEq, uniffi::Enum)]
1198pub enum TextColor {
1199 Named(MarkColor),
1200 Rgb { r: u8, g: u8, b: u8 },
1201}
1202
1203impl TextColor {
1204 /// The core colour this names. A plain conversion and not a [`Meant`],
1205 /// because every triple of bytes is a colour: this is the one of the four
1206 /// with nothing to refuse.
1207 fn into_core(self) -> CoreTextColor {
1208 match self {
1209 TextColor::Named(color) => CoreTextColor::Named(color.into()),
1210 TextColor::Rgb { r, g, b } => CoreTextColor::Rgb { r, g, b },
1211 }
1212 }
1213}
1214
1215impl From<CoreTextColor> for TextColor {
1216 fn from(c: CoreTextColor) -> Self {
1217 match c {
1218 CoreTextColor::Named(color) => TextColor::Named(color.into()),
1219 CoreTextColor::Rgb { r, g, b } => TextColor::Rgb { r, g, b },
1220 }
1221 }
1222}
1223
1224/// The face a run is set in: one of CSS's four generics, or the family the
1225/// author named.
1226///
1227/// A generic opens on every machine and a family name does not, which is the
1228/// trade [`FontFamily`]'s note states once. A named family is resolved through
1229/// the platform's font registry and falls back to the theme's body face where
1230/// it is not installed.
1231#[derive(Clone, Debug, PartialEq, uniffi::Enum)]
1232pub enum FontFace {
1233 Generic(FontFamily),
1234 /// A family name, as the font panel spells it. Trimmed on the way in, and
1235 /// one that spells a generic (`"Serif"`) is read as that generic.
1236 Named(String),
1237}
1238
1239impl FontFace {
1240 /// The core face this names — [`Meant::Refused`] for a name that names
1241 /// nothing, which leaves the run's face alone.
1242 fn into_core(self) -> Meant<CoreFontFace> {
1243 match self {
1244 FontFace::Generic(family) => Meant::Value(CoreFontFace::Generic(family.into())),
1245 // Through the parser rather than straight into `Named`, so a name
1246 // gets the trim and the generic-keyword reading a document's own
1247 // `data-font` gets, and an empty one names nothing.
1248 FontFace::Named(name) => Meant::of(CoreFontFace::from_attr(&name)),
1249 }
1250 }
1251}
1252
1253impl From<CoreFontFace> for FontFace {
1254 fn from(f: CoreFontFace) -> Self {
1255 match f {
1256 CoreFontFace::Generic(family) => FontFace::Generic(family.into()),
1257 CoreFontFace::Named(name) => FontFace::Named(name),
1258 }
1259 }
1260}
1261
1262/// A visual position: a row index plus a UTF-16 offset within that row's text —
1263/// the coordinate the geometry side (Core Text) draws from. Returned by
1264/// [`LeafDoc::pos_for_offset`], the bridge from a source offset (what a
1265/// `UITextPosition` wraps) to where it sits on screen.
1266#[derive(uniffi::Record)]
1267pub struct RowCol {
1268 pub row: u32,
1269 pub ch: u32,
1270}
1271
1272/// The rows a source range covers, both ends **inclusive** — what a frontend
1273/// slices out of a frame to draw a block somewhere other than where it sits: a
1274/// footnote peek, a link peek, a landing flash. Returned by
1275/// [`LeafDoc::row_range_for`].
1276///
1277/// Inclusive rather than half-open because the answer is "these rows", not "up
1278/// to here": every caller wants `rows[first...last]`, and a `last` one past the
1279/// end would be a second thing to get wrong at each of them. `last >= first`
1280/// always, so the pair is never empty — a range with no visible byte still
1281/// covers the row it opened on.
1282#[derive(uniffi::Record)]
1283pub struct RowRange {
1284 pub first: u32,
1285 pub last: u32,
1286}
1287
1288/// Which formatting controls this document's format can spell — the toolbar's
1289/// enabled state, one flag per button, from [`LeafDoc::capabilities`]. Mirrors
1290/// [`leaf_core::Capabilities`], where the reasoning lives.
1291///
1292/// Its shape is a flat record of `Bool`s rather than a query taking a gesture
1293/// because the Swift side wants exactly one crossing and a value it can hold in
1294/// an `@Observable`: `let caps = doc.capabilities()`, then
1295/// `.disabled(!caps.bold)` on each control.
1296#[derive(uniffi::Record)]
1297pub struct Capabilities {
1298 pub bold: bool,
1299 pub italic: bool,
1300 pub code: bool,
1301 pub mark: bool,
1302 pub underline: bool,
1303 pub strike: bool,
1304 /// [`LeafDoc::set_mark_color`] — the highlight *palette*, which Markdown
1305 /// spells and djot does not, though both spell the highlight itself. Gate
1306 /// the swatches on this *and* on [`LeafDoc::caret_in_mark`], the way the grid
1307 /// controls take `table` and `caret_in_table`: one is a fact about the
1308 /// format, the other about where the caret is standing.
1309 pub mark_color: bool,
1310 pub superscript: bool,
1311 pub subscript: bool,
1312 /// Both [`LeafDoc::set_heading`] and [`LeafDoc::set_paragraph`] — they are
1313 /// the same gesture in core and stand or fall together.
1314 pub heading: bool,
1315 pub blockquote: bool,
1316 pub bullet_list: bool,
1317 pub ordered_list: bool,
1318 /// [`LeafDoc::toggle_task_item`], [`LeafDoc::toggle_task_checked`] and
1319 /// [`LeafDoc::toggle_task_at`] — including a *tap* on a rendered checkbox,
1320 /// which should not be live where the box cannot be spelled.
1321 pub task: bool,
1322 pub link: bool,
1323 /// [`LeafDoc::insert_image`] and [`LeafDoc::insert_media`] both.
1324 pub image: bool,
1325 pub thematic_break: bool,
1326 /// [`LeafDoc::insert_footnote`]. Markdown and djot spell the pair; HTML does
1327 /// not, so the button goes rather than dims into a refusal.
1328 pub footnote: bool,
1329 /// [`LeafDoc::toggle_code_block`]. Markdown and djot spell the fence; HTML
1330 /// rebuilds the block as `<pre><code>`. Pair with [`DocView::code_block`]
1331 /// for the button's lit state.
1332 pub code_block: bool,
1333 pub code_language: bool,
1334 /// The grid controls. Gate them on this *and* [`LeafDoc::caret_in_table`]:
1335 /// this asks whether the format's tables are editable, that whether the
1336 /// caret is in one.
1337 pub table: bool,
1338 /// Shift+Return inside a cell — [`LeafDoc::cell_line_break`].
1339 pub cell_line_break: bool,
1340 /// The alignment control — [`LeafDoc::set_alignment`]. Every format leaf
1341 /// opens but XML spells a block's attributes.
1342 pub alignment: bool,
1343 /// The line-spacing menu — [`LeafDoc::set_line_spacing`]. The same gesture
1344 /// as [`alignment`](Self::alignment) and so the same answer, and its own
1345 /// flag because a toolbar dims controls one at a time.
1346 pub line_spacing: bool,
1347 /// The size menu — [`LeafDoc::set_font_size`]. **Narrower than the block
1348 /// pair**: it wraps a selection in an attributed span, which AsciiDoc has no
1349 /// slot for, so this is `false` there while [`alignment`](Self::alignment) is
1350 /// `true`. The block-level form of the same property — the caret in a
1351 /// paragraph, nothing selected — still works, which is why the flag
1352 /// describes the control rather than the caret.
1353 pub font_size: bool,
1354 /// The face menu — [`LeafDoc::set_font_family`]. A span, as
1355 /// [`font_size`](Self::font_size) is.
1356 pub font_family: bool,
1357 /// The text-colour swatches — [`LeafDoc::set_text_color`]. A span again, and
1358 /// not to be confused with [`mark_color`](Self::mark_color): that is a
1359 /// highlight's background and rides the `mark` node, this is a run's
1360 /// foreground and rides an attributed span.
1361 pub text_color: bool,
1362 /// The page-break button — [`LeafDoc::insert_page_break`]. Markdown and djot
1363 /// and no others, though twig spells the gesture in HTML and AsciiDoc too:
1364 /// the flag describes what leaf can *show*, and the walker draws neither of
1365 /// those spellings yet.
1366 pub page_break: bool,
1367}
1368
1369impl From<CoreCapabilities> for Capabilities {
1370 fn from(c: CoreCapabilities) -> Self {
1371 Self {
1372 bold: c.bold,
1373 italic: c.italic,
1374 code: c.code,
1375 mark: c.mark,
1376 underline: c.underline,
1377 strike: c.strike,
1378 mark_color: c.mark_color,
1379 superscript: c.superscript,
1380 // `subscript` is a Swift keyword; uniffi escapes it in the generated
1381 // binding (`caps.`subscript``), so the field keeps its real name
1382 // here rather than wearing a suffix on both sides of the boundary.
1383 subscript: c.subscript,
1384 heading: c.heading,
1385 blockquote: c.blockquote,
1386 bullet_list: c.bullet_list,
1387 ordered_list: c.ordered_list,
1388 task: c.task,
1389 link: c.link,
1390 image: c.image,
1391 thematic_break: c.thematic_break,
1392 footnote: c.footnote,
1393 code_block: c.code_block,
1394 code_language: c.code_language,
1395 table: c.table,
1396 cell_line_break: c.cell_line_break,
1397 alignment: c.alignment,
1398 line_spacing: c.line_spacing,
1399 font_size: c.font_size,
1400 font_family: c.font_family,
1401 text_color: c.text_color,
1402 page_break: c.page_break,
1403 }
1404 }
1405}
1406
1407/// A table column's text alignment — the argument to
1408/// [`LeafDoc::table_set_alignment`]. Mirrors twig's `Alignment`.
1409#[derive(uniffi::Enum)]
1410pub enum TableAlignment {
1411 Default,
1412 Left,
1413 Right,
1414 Center,
1415}
1416
1417impl TableAlignment {
1418 fn into_core(self) -> Alignment {
1419 match self {
1420 TableAlignment::Default => Alignment::Default,
1421 TableAlignment::Left => Alignment::Left,
1422 TableAlignment::Right => Alignment::Right,
1423 TableAlignment::Center => Alignment::Center,
1424 }
1425 }
1426}
1427
1428/// How much of the source markup the rich view exposes — the argument to
1429/// [`LeafDoc::set_markup_mode`]. Mirrors [`leaf_core::MarkupMode`]; `None`
1430/// is the default (the clean surface Diaryx ships, with typed syntax kept
1431/// literal).
1432///
1433/// A single three-way ladder rather than a pair of toggles, because only three
1434/// of the four combinations of its two axes — reveal the caret's delimiters,
1435/// author markup from typing — are coherent. See [`leaf_core::MarkupMode`]
1436/// for which one is left out and why.
1437#[derive(uniffi::Enum)]
1438pub enum MarkupMode {
1439 None,
1440 Shortcuts,
1441 Full,
1442}
1443
1444impl MarkupMode {
1445 fn into_core(self) -> CoreMarkupMode {
1446 match self {
1447 MarkupMode::None => CoreMarkupMode::None,
1448 MarkupMode::Shortcuts => CoreMarkupMode::Shortcuts,
1449 MarkupMode::Full => CoreMarkupMode::Full,
1450 }
1451 }
1452
1453 fn from_core(mode: CoreMarkupMode) -> Self {
1454 match mode {
1455 CoreMarkupMode::None => MarkupMode::None,
1456 CoreMarkupMode::Shortcuts => MarkupMode::Shortcuts,
1457 CoreMarkupMode::Full => MarkupMode::Full,
1458 }
1459 }
1460}
1461
1462/// How the rich view treats a soft break (a bare newline inside a paragraph) —
1463/// the argument to [`LeafDoc::set_line_flow`]. Mirrors [`leaf_core::LineFlow`];
1464/// `Fold` is the default (soft breaks reflow into the paragraph, as before).
1465#[derive(uniffi::Enum)]
1466pub enum LineFlow {
1467 Fold,
1468 Preserve,
1469}
1470
1471impl LineFlow {
1472 fn into_core(self) -> CoreLineFlow {
1473 match self {
1474 LineFlow::Fold => CoreLineFlow::Fold,
1475 LineFlow::Preserve => CoreLineFlow::Preserve,
1476 }
1477 }
1478
1479 fn from_core(mode: CoreLineFlow) -> Self {
1480 match mode {
1481 CoreLineFlow::Fold => LineFlow::Fold,
1482 CoreLineFlow::Preserve => LineFlow::Preserve,
1483 }
1484 }
1485}
1486
1487/// A live leaf document bound for a native Apple frontend: `leaf_core::Doc` plus
1488/// the wrap width the current viewport implies, behind a mutex. Constructed from
1489/// an in-memory string and driven entirely through method calls — there is no
1490/// filesystem behind it.
1491#[derive(uniffi::Object)]
1492pub struct LeafDoc {
1493 inner: Mutex<Inner>,
1494}
1495
1496/// The guarded state. Its methods assume the lock is held (they take `&mut
1497/// self`); the [`LeafDoc`] exported wrappers acquire it, delegate, and return the
1498/// resulting frame.
1499struct Inner {
1500 doc: Doc,
1501 /// The wrap mode. `Some(cols)` wraps the map at that column budget (a terminal,
1502 /// or a fixed-cell frontend); `None` builds it **unwrapped** — one row per block —
1503 /// for a proportional GUI that wraps at its own pixel width. `build_visual`
1504 /// caches on `(revision, width)`, so re-syncing when neither moved is free.
1505 width: Option<usize>,
1506 /// The host's current appearance, which a `<picture>`'s `prefers-color-scheme`
1507 /// `<source>`s are matched against when resolving a block image's URL. Core
1508 /// has no theme of its own, so this is AppKit/UIKit answering on its behalf;
1509 /// defaults to light until the host calls
1510 /// [`LeafDoc::set_dark_appearance`].
1511 scheme: ColorScheme,
1512 /// Whether a frame is the change since the frame before rather than the
1513 /// whole document — see [`LeafDoc::set_incremental_frames`].
1514 incremental: bool,
1515 /// The rows of the last frame handed out, kept only while `incremental`
1516 /// so the next frame can be the difference from them; `None` makes the
1517 /// next frame whole. Whenever it is `Some`, it is frame `frame`'s rows.
1518 last_rows: Option<Vec<Row>>,
1519 /// The number of the last frame handed out — `0` before the first.
1520 frame: u32,
1521}
1522
1523// SAFETY: `Doc` embeds a `twig::Editor`, which holds a `NonNull<TwigEditor>` and
1524// is therefore `!Send`. UniFFI hands `LeafDoc` to Swift as a reference-counted
1525// handle that must be `Send + Sync`, so `Inner` must be `Send`. This is sound
1526// because:
1527// 1. Every access goes through `LeafDoc::lock()` — the `Mutex` serializes all
1528// reads and mutations, so there is never concurrent access to the handle.
1529// 2. twig's editor handle owns a plain heap allocation with no thread-affinity
1530// (no thread-locals, no per-thread state) — moving the pointer between
1531// threads is fine as long as use is serialized, which (1) guarantees.
1532// The intended usage is still main-thread-driven; this impl only permits the
1533// handle to cross threads safely, it does not invite concurrent use.
1534unsafe impl Send for Inner {}
1535
1536impl Inner {
1537 /// Rebuild the visual map at the current width. Cheap (cached) when nothing
1538 /// changed; the guard that lets every movement/click method assume a fresh
1539 /// grid regardless of call order.
1540 fn sync(&mut self) {
1541 match self.width {
1542 Some(w) => self.doc.build_visual(w),
1543 None => self.doc.build_visual_unwrapped(),
1544 }
1545 // The source view's styling, keyed on the revision alone — a no-op on
1546 // every call that isn't the first after an edit, like the map above.
1547 if self.doc.view == View::Source {
1548 self.doc.build_source();
1549 }
1550 }
1551
1552 /// The plain text of visual row `row` in the active view — the string the
1553 /// renderer concatenates its runs into. Backs the column⇄UTF-16 mapping.
1554 fn row_text(&self, row: usize) -> String {
1555 match self.doc.view {
1556 View::Wysiwyg => self
1557 .doc
1558 .vmap
1559 .rows
1560 .get(row)
1561 .map(|r| r.glyphs.iter().map(|g| g.ch).collect())
1562 .unwrap_or_default(),
1563 View::Source => self
1564 .doc
1565 .source
1566 .split('\n')
1567 .nth(row)
1568 .unwrap_or("")
1569 .to_string(),
1570 }
1571 }
1572
1573 /// The `(row, display-column)` a source offset sits at in the active view.
1574 fn pos_of_offset(&self, off: usize) -> (usize, usize) {
1575 match self.doc.view {
1576 View::Wysiwyg => self.doc.vmap.pos_of_offset(off),
1577 View::Source => {
1578 let s = &self.doc.source;
1579 // Walk back to a character boundary, not just into range. Every
1580 // offset here arrives from a UI toolkit counting in its own
1581 // units, so one landing mid-character is ordinary input — and
1582 // slicing on it aborts the process across an FFI boundary that
1583 // has no unwinding. `snap_stop` and `text_in_range` already do
1584 // this; this was the one door left open.
1585 let mut off = off.min(s.len());
1586 while off > 0 && !s.is_char_boundary(off) {
1587 off -= 1;
1588 }
1589 let row = s[..off].bytes().filter(|&b| b == b'\n').count();
1590 let line_start = s[..off].rfind('\n').map_or(0, |i| i + 1);
1591 (row, text_width(&s[line_start..off]))
1592 }
1593 }
1594 }
1595
1596 /// The inclusive row span a source range occupies in the active view.
1597 ///
1598 /// The rich view defers to [`leaf_core::wysiwyg::VisualMap::row_range_for`],
1599 /// where the reasoning lives. The source view has no hidden bytes at all —
1600 /// every byte is drawn on the line it is written on — so counting newlines
1601 /// is the whole answer, and the last row is the one holding the range's last
1602 /// byte rather than the one past it.
1603 fn row_range_for(&self, start: usize, end: usize) -> (usize, usize) {
1604 match self.doc.view {
1605 View::Wysiwyg => self.doc.vmap.row_range_for(start..end),
1606 View::Source => {
1607 let first = self.pos_of_offset(start).0;
1608 let last = self.pos_of_offset(end.max(start.saturating_add(1)) - 1).0;
1609 (first, last.max(first))
1610 }
1611 }
1612 }
1613
1614 /// The source offset under a click at row `row`, `ch` UTF-16 units in.
1615 fn offset_at(&mut self, row: usize, ch: usize) -> usize {
1616 self.sync();
1617 let col = utf16_to_col(&self.row_text(row), ch);
1618 self.doc.click(row, col, false);
1619 self.doc.caret
1620 }
1621
1622 // ── position mapping for UITextInput (non-mutating; caret untouched) ───────
1623 // These branch by view exactly as `pos_of_offset` does, so the WYSIWYG map and
1624 // the raw-source grid each answer in their own coordinates.
1625
1626 /// The source offset of display column `col` on visual `row` — the inverse of
1627 /// [`Self::pos_of_offset`] in column space.
1628 fn offset_of_col(&self, row: usize, col: usize) -> usize {
1629 match self.doc.view {
1630 View::Wysiwyg => self.doc.vmap.offset_of_pos(row, col),
1631 View::Source => {
1632 let line = self.row_text(row);
1633 let (mut c, mut b) = (0usize, 0usize);
1634 for g in line.graphemes(true) {
1635 if c >= col {
1636 break;
1637 }
1638 c += text_width(g);
1639 b += g.len();
1640 }
1641 self.source_line_start(row) + b
1642 }
1643 }
1644 }
1645
1646 /// The byte offset where visual `row` begins in the source view.
1647 fn source_line_start(&self, row: usize) -> usize {
1648 self.doc
1649 .source
1650 .split('\n')
1651 .take(row)
1652 .map(|l| l.len() + 1)
1653 .sum()
1654 }
1655
1656 /// The next caret stop after `off`, or `None` at the end.
1657 fn stop_after(&self, off: usize) -> Option<usize> {
1658 match self.doc.view {
1659 View::Wysiwyg => self.doc.vmap.stop_after(off),
1660 View::Source => {
1661 let s = &self.doc.source;
1662 if off >= s.len() {
1663 None
1664 } else {
1665 Some(
1666 s[off..]
1667 .grapheme_indices(true)
1668 .nth(1)
1669 .map_or(s.len(), |(i, _)| off + i),
1670 )
1671 }
1672 }
1673 }
1674 }
1675
1676 /// The previous caret stop before `off`, or `None` at the start.
1677 fn stop_before(&self, off: usize) -> Option<usize> {
1678 match self.doc.view {
1679 View::Wysiwyg => self.doc.vmap.stop_before(off),
1680 View::Source => {
1681 let s = &self.doc.source;
1682 let off = off.min(s.len());
1683 if off == 0 {
1684 None
1685 } else {
1686 s[..off].grapheme_indices(true).next_back().map(|(i, _)| i)
1687 }
1688 }
1689 }
1690 }
1691
1692 /// Snap `off` to a valid caret stop (WYSIWYG) / char boundary (source).
1693 fn snap_stop(&self, off: usize) -> usize {
1694 let s = &self.doc.source;
1695 let mut off = off.min(s.len());
1696 match self.doc.view {
1697 View::Wysiwyg => self.doc.vmap.snap_to_stop(off),
1698 View::Source => {
1699 while off > 0 && !s.is_char_boundary(off) {
1700 off -= 1;
1701 }
1702 off
1703 }
1704 }
1705 }
1706
1707 /// The UTF-16 index of source offset `off` in the visible text — the
1708 /// body of [`LeafDoc::utf16_index_for_offset`], for the one call and for
1709 /// the many.
1710 fn utf16_index_of(&self, off: u32) -> u32 {
1711 let off = (off as usize).min(self.doc.source.len());
1712 match self.doc.view {
1713 View::Wysiwyg => self.doc.vmap.visible_utf16_len(0, off) as u32,
1714 View::Source => {
1715 let off = self.snap_stop(off);
1716 self.doc.source[..off].encode_utf16().count() as u32
1717 }
1718 }
1719 }
1720
1721 /// [`snap_stop`](Self::snap_stop) for the walks that pair stops with
1722 /// characters — `step_offset` and `distance_offset`, which a system text
1723 /// input counts against the text it was shown. The caret's home at the
1724 /// end of a hidden mark (`VisualMap::mark_ends`) has no character of its
1725 /// own, so those walks start from the glyph stop drawn at the same spot.
1726 fn snap_glyph_stop(&self, off: usize) -> usize {
1727 match self.doc.view {
1728 View::Wysiwyg => self
1729 .doc
1730 .vmap
1731 .snap_to_glyph_stop(off.min(self.doc.source.len())),
1732 View::Source => self.snap_stop(off),
1733 }
1734 }
1735
1736 /// The navigable visual row above `row`, if any.
1737 fn nav_above(&self, row: usize) -> Option<usize> {
1738 match self.doc.view {
1739 View::Wysiwyg => self.doc.vmap.navigable_above(row),
1740 View::Source => (row > 0).then(|| row - 1),
1741 }
1742 }
1743
1744 /// The navigable visual row below `row`, if any.
1745 fn nav_below(&self, row: usize) -> Option<usize> {
1746 match self.doc.view {
1747 View::Wysiwyg => self.doc.vmap.navigable_below(row),
1748 View::Source => {
1749 let n = self.doc.source.split('\n').count();
1750 (row + 1 < n).then_some(row + 1)
1751 }
1752 }
1753 }
1754
1755 /// Resolve the current document to a whole frame of style runs, numbered
1756 /// as the next one. What both [`view`](Self::view) and
1757 /// [`frame`](Self::frame) start from; neither the frame count nor the
1758 /// rows kept for the next change are touched here.
1759 fn whole(&mut self) -> DocView {
1760 self.sync();
1761
1762 let (ss, se) = self.doc.selection().unwrap_or((usize::MAX, usize::MAX));
1763
1764 // The two views speak different grids — the WYSIWYG map's resolved glyphs
1765 // vs the raw source split on newlines — and `caret_pos` branches to match,
1766 // so the rows must too or the caret lands on the wrong text.
1767 let rows = match self.doc.view {
1768 View::Wysiwyg => wysiwyg_rows(&self.doc.vmap, ss, se, self.doc.highlights()),
1769 View::Source => source_rows(
1770 &self.doc.source,
1771 &self.doc.smap,
1772 ss,
1773 se,
1774 self.doc.highlights(),
1775 ),
1776 };
1777 // Structural tables, for a proportional renderer that draws its own grid;
1778 // none in the source view (the caret rides raw pipe text there).
1779 let tables = match self.doc.view {
1780 View::Wysiwyg => wysiwyg_tables(&self.doc.vmap, ss, se, self.doc.highlights()),
1781 View::Source => Vec::new(),
1782 };
1783
1784 // Leaf directives, on the same terms as the tables above: structural in
1785 // the rich view, absent in the source view.
1786 let directives = match self.doc.view {
1787 View::Wysiwyg => wysiwyg_directives(&self.doc.vmap),
1788 View::Source => Vec::new(),
1789 };
1790
1791 // Block media, on the same terms again: only the rich view has
1792 // placeholder rows to lay a view over.
1793 let media = match self.doc.view {
1794 View::Wysiwyg => wysiwyg_media(&self.doc.vmap, self.scheme),
1795 View::Source => Vec::new(),
1796 };
1797
1798 // Formulas, likewise: pictures stand in only in the rich view.
1799 let math = match self.doc.view {
1800 View::Wysiwyg => wysiwyg_math(&self.doc.vmap),
1801 View::Source => Vec::new(),
1802 };
1803
1804 let (caret_row, caret_col) = self.doc.caret_pos();
1805 // Map the caret's display column to a UTF-16 text offset so a native
1806 // renderer can place it past wide glyphs (see [`DocView::caret_ch`]).
1807 let caret_ch = col_to_utf16(&self.row_text(caret_row), caret_col);
1808 // The selection's fixed (anchor) end, in the same row/UTF-16 terms.
1809 let (has_selection, anchor_row, anchor_ch) = match self.doc.selection() {
1810 Some(_) => {
1811 let a = self.doc.anchor.unwrap_or(self.doc.caret);
1812 let (ar, ac) = self.pos_of_offset(a);
1813 (true, ar, col_to_utf16(&self.row_text(ar), ac))
1814 }
1815 None => (false, caret_row, caret_ch),
1816 };
1817 let heading = self.doc.current_heading_level();
1818 let code_block = self.doc.caret_in_code_block();
1819 let task = self.doc.task_checked_at_caret();
1820 let active = self
1821 .doc
1822 .active_inline_marks()
1823 .iter()
1824 .map(|k| mark_id(k).to_string())
1825 .collect();
1826 let link = self.doc.link_destination_at_caret();
1827 let mark_color = self.doc.mark_color_at_caret().map(MarkColor::from);
1828
1829 let row_count = rows.len() as u32;
1830 DocView {
1831 rows,
1832 frame: self.frame + 1,
1833 basis: 0,
1834 row_start: 0,
1835 replaced: 0,
1836 row_count,
1837 src_shift: 0,
1838 tables,
1839 directives,
1840 media,
1841 math,
1842 caret_row: caret_row as u32,
1843 caret_col: caret_col as u32,
1844 caret_ch: caret_ch as u32,
1845 caret_src: self.doc.caret.min(self.doc.source.len()) as u32,
1846 has_selection,
1847 anchor_row: anchor_row as u32,
1848 anchor_ch: anchor_ch as u32,
1849 dirty: self.doc.dirty,
1850 can_undo: self.doc.can_undo(),
1851 can_redo: self.doc.can_redo(),
1852 view: self.doc.view_name().to_string(),
1853 heading,
1854 code_block,
1855 task,
1856 active,
1857 link,
1858 mark_color,
1859 }
1860 }
1861
1862 /// The whole frame, and the one every later change is against. Called
1863 /// for the first paint, on resize, and by a frontend that has lost step
1864 /// with the changes — see [`DocView`].
1865 fn view(&mut self) -> DocView {
1866 let v = self.whole();
1867 self.frame = v.frame;
1868 self.last_rows = self.incremental.then(|| v.rows.clone());
1869 v
1870 }
1871
1872 /// The frame every mutating wrapper answers with, so one boundary
1873 /// crossing both mutates and repaints: whole unless the frontend asked
1874 /// for changes, and then the rows that differ from the frame before's —
1875 /// found by comparing them in memory, which costs microseconds and lifts
1876 /// nothing — with the frame before's rows kept for the next one.
1877 fn frame(&mut self) -> DocView {
1878 let mut v = self.whole();
1879 self.frame = v.frame;
1880 if !self.incremental {
1881 return v;
1882 }
1883 let Some(old) = self.last_rows.take() else {
1884 self.last_rows = Some(v.rows.clone());
1885 return v;
1886 };
1887 let rows = std::mem::take(&mut v.rows);
1888 let d = leaf_core::row_delta(&old, &rows, same_row_shifted, shift_between_rows);
1889 v.basis = v.frame - 1;
1890 v.row_start = d.start as u32;
1891 v.replaced = d.replaced as u32;
1892 v.src_shift = d.src_shift as i32;
1893 v.rows = rows[d.start..d.start + d.len].to_vec();
1894 self.last_rows = Some(rows);
1895 v
1896 }
1897}
1898
1899/// Whether `b` is `a` with every `Run::src` moved by `shift` — the row
1900/// equality [`leaf_core::row_delta`] matches the frame before's suffix on. Every
1901/// field is named so that a field added to [`Row`] or [`Run`] is a compile
1902/// error here rather than a row the delta silently ignores.
1903fn same_row_shifted(a: &Row, b: &Row, shift: i64) -> bool {
1904 let Row {
1905 runs,
1906 decoration,
1907 code,
1908 code_lang,
1909 directive,
1910 directive_label,
1911 heading,
1912 align,
1913 line_height,
1914 boundary,
1915 } = a;
1916 *decoration == b.decoration
1917 && *code == b.code
1918 && *code_lang == b.code_lang
1919 && *directive == b.directive
1920 && *directive_label == b.directive_label
1921 && *heading == b.heading
1922 && *align == b.align
1923 && *line_height == b.line_height
1924 && *boundary == b.boundary
1925 && runs.len() == b.runs.len()
1926 && runs
1927 .iter()
1928 .zip(&b.runs)
1929 .all(|(x, y)| same_run_shifted(x, y, shift))
1930}
1931
1932fn same_run_shifted(a: &Run, b: &Run, shift: i64) -> bool {
1933 let Run {
1934 text,
1935 role,
1936 bold,
1937 italic,
1938 underline,
1939 strike,
1940 sup,
1941 sub,
1942 src,
1943 sel,
1944 hl,
1945 hl_color,
1946 mark_color,
1947 token,
1948 size,
1949 font,
1950 text_color,
1951 } = a;
1952 *src as i64 + shift == b.src as i64
1953 && *text == b.text
1954 && *role == b.role
1955 && *bold == b.bold
1956 && *italic == b.italic
1957 && *underline == b.underline
1958 && *strike == b.strike
1959 && *sup == b.sup
1960 && *sub == b.sub
1961 && *sel == b.sel
1962 && *hl == b.hl
1963 && *hl_color == b.hl_color
1964 && *mark_color == b.mark_color
1965 && *token == b.token
1966 && *size == b.size
1967 && *font == b.font
1968 && *text_color == b.text_color
1969}
1970
1971/// The shift `b`'s offsets stand at from `a`'s, read off the first run — or
1972/// `None` for a row with no run to read it off, which matches at any shift.
1973fn shift_between_rows(a: &Row, b: &Row) -> Option<i64> {
1974 Some(b.runs.first()?.src as i64 - a.runs.first()?.src as i64)
1975}
1976
1977/// Move every offset `row` carries by `by` — what a caller does to the rows
1978/// after a change's span, and what the tests do to check one.
1979#[cfg(test)]
1980fn shift_row(row: &mut Row, by: i64) {
1981 for run in &mut row.runs {
1982 run.src = (run.src as i64 + by) as u32;
1983 }
1984}
1985
1986/// Typeset `tex` — the text between a formula's delimiters, as a [`MathView`]
1987/// hands it over — to a picture. `display` is the view's `display`; `size` is
1988/// the font size in points the formula is set at (an inline formula takes the
1989/// run's, a block the body's); `r`, `g`, `b`, `a` are the ink, as bytes. A
1990/// theme change is a re-render with a new colour.
1991///
1992/// Pure layout over fonts embedded in the binary — no I/O, fast enough to
1993/// call from a layout pass — so a renderer caches by `(tex, display, size,
1994/// colour)` and nothing more. TeX the typesetter cannot read is a
1995/// [`LeafError::Math`]; the renderer shows the revealed source in its place.
1996#[uniffi::export]
1997pub fn typeset_math(
1998 tex: String,
1999 display: bool,
2000 size: f64,
2001 r: u8,
2002 g: u8,
2003 b: u8,
2004 a: u8,
2005) -> Result<MathPicture, LeafError> {
2006 let p = leaf_math::typeset(&tex, display, size, [r, g, b, a]).map_err(|e| LeafError::Math {
2007 message: e.message,
2008 position: e.position.map(|p| p as u32),
2009 })?;
2010 Ok(MathPicture {
2011 svg: p.svg,
2012 width: p.width,
2013 height: p.height,
2014 depth: p.depth,
2015 })
2016}
2017
2018#[uniffi::export]
2019impl LeafDoc {
2020 /// Parse `source` as `format` (`"markdown"`/`"md"`, `"djot"`/`"dj"`,
2021 /// `"html"`, `"xml"`) into a live, untitled document.
2022 #[uniffi::constructor]
2023 pub fn new(source: String, format: String) -> Result<Arc<Self>, LeafError> {
2024 let format = match format.to_ascii_lowercase().as_str() {
2025 "markdown" | "md" => Format::Markdown,
2026 "djot" | "dj" => Format::Djot,
2027 "html" | "htm" => Format::Html,
2028 "xml" => Format::Xml,
2029 other => {
2030 return Err(LeafError::UnknownFormat {
2031 name: other.to_string(),
2032 });
2033 }
2034 };
2035 let doc = Doc::from_source(source, format).map_err(|e| LeafError::Parse {
2036 message: e.to_string(),
2037 })?;
2038 Ok(Arc::new(LeafDoc {
2039 inner: Mutex::new(Inner {
2040 doc,
2041 width: Some(80),
2042 scheme: ColorScheme::Light,
2043 incremental: false,
2044 last_rows: None,
2045 frame: 0,
2046 }),
2047 }))
2048 }
2049
2050 /// Resolve the current document to a whole frame — the first paint, and
2051 /// the frame a frontend taking changes ([`set_incremental_frames`]) is
2052 /// brought back into step by: every change after this is against it.
2053 ///
2054 /// [`set_incremental_frames`]: Self::set_incremental_frames
2055 pub fn view(&self) -> DocView {
2056 self.lock().view()
2057 }
2058
2059 /// Whether the frame every method answers with is the change since the
2060 /// frame before rather than the whole document — see [`DocView`] for the
2061 /// shape, and for what a frontend does with one. Off by default, so a
2062 /// frontend that reads `rows` as the document goes on getting it; a
2063 /// frontend that keeps its own copy of the rows turns it on once and
2064 /// splices each frame into that copy, which makes a caret move lift no
2065 /// row across the binding and a keystroke lift the row it changed.
2066 ///
2067 /// The first frame after turning it on is whole (there is no frame before
2068 /// to be a change from), and [`view`](Self::view) is whole at any time.
2069 pub fn set_incremental_frames(&self, on: bool) {
2070 let mut g = self.lock();
2071 g.incremental = on;
2072 g.last_rows = None;
2073 }
2074
2075 /// The document's rows `from..to`, as the last frame had them — for a
2076 /// frontend that wants a window of rows back without a whole frame,
2077 /// having applied every change so far or not. Clamped to the document;
2078 /// empty when `from >= to`. Costs the rows of the document to build and
2079 /// the window to lift, so it is the occasional resynchronisation, not the
2080 /// per-gesture path.
2081 pub fn rows(&self, from: u32, to: u32) -> Vec<Row> {
2082 let mut g = self.lock();
2083 let mut rows = g.whole().rows;
2084 let to = (to as usize).min(rows.len());
2085 let from = (from as usize).min(to);
2086 rows.truncate(to);
2087 rows.drain(..from);
2088 rows
2089 }
2090
2091 /// Set the wrap width (in columns) the viewport implies and repaint. For a
2092 /// fixed-cell frontend (a terminal); a proportional GUI uses [`set_unwrapped`].
2093 pub fn set_width(&self, cols: u32) -> DocView {
2094 let mut g = self.lock();
2095 g.width = Some((cols as usize).max(1));
2096 g.frame()
2097 }
2098
2099 /// Switch to **unwrapped** layout — one visual row per block, no column wrapping —
2100 /// and repaint. A proportional GUI calls this once at start-up, then wraps each
2101 /// row at its own pixel width (the caret/hit/selection geometry it derives from
2102 /// the pixel wrap; core still owns the caret model, in byte offsets). Idempotent
2103 /// and cheap to leave in place across edits.
2104 pub fn set_unwrapped(&self) -> DocView {
2105 let mut g = self.lock();
2106 g.width = None;
2107 g.frame()
2108 }
2109
2110 /// Tell core whether the host is in a dark appearance, so a `<picture>`'s
2111 /// `prefers-color-scheme` `<source>`s resolve to the right banner. Call it
2112 /// from `viewDidChangeEffectiveAppearance` (AppKit) or
2113 /// `traitCollectionDidChange` (UIKit).
2114 ///
2115 /// Cheap to call repeatedly: resolving at the same appearance yields the same
2116 /// URLs, and a renderer keying its views by `src` tears nothing down.
2117 pub fn set_dark_appearance(&self, dark: bool) -> DocView {
2118 let mut g = self.lock();
2119 g.scheme = if dark {
2120 ColorScheme::Dark
2121 } else {
2122 ColorScheme::Light
2123 };
2124 g.frame()
2125 }
2126
2127 /// Report how many visual rows each block media actually needs, measured from
2128 /// the views the renderer laid out, keyed by the media's `src`.
2129 ///
2130 /// Core does no I/O and can't know how tall a picture or a player is, so this
2131 /// is the only way a placeholder grows past its default single row. The loop
2132 /// is: lay out at the current reservation → measure → call this → repaint if
2133 /// it changed. Handing over the same measurements again is a no-op, so a
2134 /// renderer can report its current state each frame without diffing first.
2135 ///
2136 /// A frontend that lays media out in its own units and simply reserves the
2137 /// vertical space itself (the way the gpui GUI does with images) never needs
2138 /// to call this at all.
2139 pub fn set_media_rows(&self, heights: Vec<MediaHeight>) -> DocView {
2140 let mut g = self.lock();
2141 g.doc.set_media_rows(
2142 heights
2143 .into_iter()
2144 .map(|h| (h.destination, h.rows.max(1) as usize))
2145 .collect(),
2146 );
2147 g.frame()
2148 }
2149
2150 /// Report how many visual rows each display formula needs, keyed by its
2151 /// TeX as [`MathView`] handed it over — [`set_media_rows`]'s peer for a
2152 /// renderer that reserves rows. One that lays a formula out in its own
2153 /// units, as the Swift views do, never calls this.
2154 ///
2155 /// [`set_media_rows`]: Self::set_media_rows
2156 pub fn set_math_rows(&self, heights: Vec<MathHeight>) -> DocView {
2157 let mut g = self.lock();
2158 g.doc.set_math_rows(
2159 heights
2160 .into_iter()
2161 .map(|h| (h.tex, h.rows.max(1) as usize))
2162 .collect(),
2163 );
2164 g.frame()
2165 }
2166
2167 /// Say whether the renderer can paint a picture *inside* a line of text.
2168 /// When it can, an inline formula arrives as one `math` run and a
2169 /// [`MathView`] with `inline` set, for the renderer to draw its typeset
2170 /// picture over; when it cannot, as the code-styled TeX it always was.
2171 /// Off until called, so a host that has not caught up sees what it saw.
2172 pub fn set_inline_pictures(&self, on: bool) -> DocView {
2173 let mut g = self.lock();
2174 g.doc.set_inline_pictures(on);
2175 g.frame()
2176 }
2177
2178 /// Insert a block-level image, video, or audio at the caret. Any selection
2179 /// becomes the alt / fallback text. See [`leaf_core::Doc::insert_media`] for
2180 /// the markup each kind spells.
2181 pub fn insert_media(&self, kind: MediaKind, destination: String, alt: String) -> DocView {
2182 let mut g = self.lock();
2183 let kind = match kind {
2184 MediaKind::Image => CoreMediaKind::Image,
2185 MediaKind::Video => CoreMediaKind::Video,
2186 MediaKind::Audio => CoreMediaKind::Audio,
2187 };
2188 g.doc.insert_media(kind, &destination, &alt);
2189 g.frame()
2190 }
2191
2192 /// Insert a thematic break (`---`) at the caret — the toolbar's Horizontal
2193 /// Rule button. See [`leaf_core::Doc::insert_thematic_break`] for how it
2194 /// handles a selection, a blank line, and the caret sitting mid-paragraph,
2195 /// mid-list, or inside a quote.
2196 pub fn insert_thematic_break(&self) -> DocView {
2197 let mut g = self.lock();
2198 g.doc.insert_thematic_break();
2199 g.frame()
2200 }
2201
2202 /// The current source text — for a save (write to disk / iCloud / a document
2203 /// wrapper) or a source-view display.
2204 pub fn source(&self) -> String {
2205 self.lock().doc.source.clone()
2206 }
2207
2208 /// The selected text, if any — for a clipboard copy/cut.
2209 pub fn selected_text(&self) -> Option<String> {
2210 self.lock().doc.selected_text().map(str::to_string)
2211 }
2212
2213 /// The selection as a quote with up to `context` characters of what
2214 /// surrounded it, cut from the **source** — the shape a host that cites or
2215 /// annotates a passage wants, findable in the document again by plain
2216 /// string search. `None` when nothing is selected. See
2217 /// `leaf_core::Doc::selection_quote`.
2218 pub fn selection_quote(&self, context: u32) -> Option<SelectionQuote> {
2219 let g = self.lock();
2220 g.doc
2221 .selection_quote(context as usize)
2222 .map(|q| SelectionQuote {
2223 exact: q.exact,
2224 prefix: q.prefix,
2225 suffix: q.suffix,
2226 start: q.start as u64,
2227 end: q.end as u64,
2228 })
2229 }
2230
2231 /// Words, characters, and paragraphs over the whole document — the numbers
2232 /// a status bar or an inspector puts next to a piece of writing.
2233 ///
2234 /// Counted over the text a reader sees rather than the markup that spells
2235 /// it: `**bold**` is one word and four characters, a link is its label and
2236 /// not its destination, a picture counts nothing, and frontmatter is not
2237 /// writing. The same in both views — the count reads neither the view nor
2238 /// the map the host last built. See `leaf_core::Doc::counts`.
2239 ///
2240 /// It is O(document) and not free (about 4 ms on a 45 KB file), so ask
2241 /// when the typing settles rather than on every keystroke; there is no
2242 /// `DocView` in it, because nothing about the document changes by being
2243 /// counted.
2244 pub fn counts(&self) -> TextCounts {
2245 self.lock().doc.counts().into()
2246 }
2247
2248 /// The same statistics over the selection alone — `None` when nothing is
2249 /// selected. See `leaf_core::Doc::selection_counts`.
2250 pub fn selection_counts(&self) -> Option<TextCounts> {
2251 self.lock().doc.selection_counts().map(Into::into)
2252 }
2253
2254 /// Whether the document refuses to change — see `set_read_only`.
2255 pub fn read_only(&self) -> bool {
2256 self.lock().doc.read_only()
2257 }
2258
2259 /// Turn the read-only gate on or off — a *reading* surface over the same
2260 /// rendering, selection and navigation the editor has. Enforced in core at
2261 /// the three doors every mutation goes through, so a host that also quiets
2262 /// its input chrome is polishing, not protecting.
2263 pub fn set_read_only(&self, on: bool) -> DocView {
2264 let mut g = self.lock();
2265 g.doc.set_read_only(on);
2266 g.frame()
2267 }
2268
2269 /// Replace the host-painted source ranges wholesale and repaint — see
2270 /// `leaf_core::Doc::set_highlights` for why it is a replace, and
2271 /// [`Highlight`] for what one is.
2272 pub fn set_highlights(&self, highlights: Vec<Highlight>) -> DocView {
2273 let mut g = self.lock();
2274 let hls = highlights
2275 .into_iter()
2276 .map(|h| leaf_core::Highlight {
2277 start: h.start as usize,
2278 end: h.end as usize,
2279 id: h.id,
2280 color: h.color,
2281 marker: h.marker,
2282 })
2283 .collect();
2284 g.doc.set_highlights(hls);
2285 g.frame()
2286 }
2287
2288 /// The id of the highlight covering source `offset`, if one does — what a
2289 /// frontend asks when the reader activates a spot on the page.
2290 pub fn highlight_at(&self, offset: u32) -> Option<String> {
2291 self.lock()
2292 .doc
2293 .highlight_at(offset as usize)
2294 .map(|h| h.id.clone())
2295 }
2296
2297 /// The host-painted ranges as last set, sorted by start — what a frontend
2298 /// walks to lay out margin markers.
2299 pub fn highlights(&self) -> Vec<Highlight> {
2300 self.lock()
2301 .doc
2302 .highlights()
2303 .iter()
2304 .map(|h| Highlight {
2305 start: h.start as u64,
2306 end: h.end as u64,
2307 id: h.id.clone(),
2308 color: h.color.clone(),
2309 marker: h.marker.clone(),
2310 })
2311 .collect()
2312 }
2313
2314 /// Mark the buffer saved after the host persisted [`LeafDoc::source`] its own
2315 /// way — clears the dirty flag without touching a filesystem.
2316 pub fn mark_saved(&self) -> DocView {
2317 let mut g = self.lock();
2318 g.doc.mark_saved();
2319 g.frame()
2320 }
2321
2322 // ── text input ───────────────────────────────────────────────────────────
2323
2324 pub fn insert(&self, text: String) -> DocView {
2325 let mut g = self.lock();
2326 g.doc.insert(&text);
2327 g.frame()
2328 }
2329
2330 pub fn paste(&self, text: String) -> DocView {
2331 let mut g = self.lock();
2332 g.doc.paste(&text);
2333 g.frame()
2334 }
2335
2336 pub fn newline(&self) -> DocView {
2337 let mut g = self.lock();
2338 g.doc.newline();
2339 g.frame()
2340 }
2341
2342 /// Tab away from a table: indent the caret's line (or the selected lines) one
2343 /// level, nesting a list item under its sibling. The frontend calls this when
2344 /// [`LeafDoc::cell_tab`] declined because the caret isn't in a table.
2345 pub fn indent(&self) -> DocView {
2346 let mut g = self.lock();
2347 g.doc.indent();
2348 g.frame()
2349 }
2350
2351 /// Shift+Tab away from a table: take one indent level back off the caret's
2352 /// line (or the selected lines), unnesting a list item. The mirror of
2353 /// [`LeafDoc::indent`].
2354 pub fn outdent(&self) -> DocView {
2355 let mut g = self.lock();
2356 g.doc.outdent();
2357 g.frame()
2358 }
2359
2360 // ── table keys ────────────────────────────────────────────────────────────
2361 // Tab, Return, and Shift+Return take on table meanings when the caret is in
2362 // one. Each returns `Some(view)` when it acted as a table key and `None` when
2363 // the caret isn't in a table — the frontend then does the key's ordinary job
2364 // (indent, newline), so these keep their meaning everywhere else.
2365
2366 /// Tab (`forward`) / Shift+Tab hops to the next/previous cell; Tab past the
2367 /// last cell appends a fresh row and enters it.
2368 pub fn cell_tab(&self, forward: bool) -> Option<DocView> {
2369 let mut g = self.lock();
2370 g.sync();
2371 g.doc.cell_tab(forward).then(|| g.frame())
2372 }
2373
2374 /// Return drops to the cell below in the same column, appending a row at the
2375 /// table's bottom.
2376 pub fn cell_return(&self) -> Option<DocView> {
2377 let mut g = self.lock();
2378 g.sync();
2379 g.doc.cell_return().then(|| g.frame())
2380 }
2381
2382 /// Shift+Return inserts a hard line break *within* the current cell.
2383 pub fn cell_line_break(&self) -> Option<DocView> {
2384 let mut g = self.lock();
2385 g.sync();
2386 g.doc.cell_line_break().then(|| g.frame())
2387 }
2388
2389 pub fn backspace(&self) -> DocView {
2390 let mut g = self.lock();
2391 g.doc.backspace();
2392 g.frame()
2393 }
2394
2395 pub fn delete_forward(&self) -> DocView {
2396 let mut g = self.lock();
2397 g.doc.delete_forward();
2398 g.frame()
2399 }
2400
2401 pub fn delete_word_back(&self) -> DocView {
2402 let mut g = self.lock();
2403 g.doc.delete_word_back();
2404 g.frame()
2405 }
2406
2407 pub fn delete_word_forward(&self) -> DocView {
2408 let mut g = self.lock();
2409 g.doc.delete_word_forward();
2410 g.frame()
2411 }
2412
2413 // ── caret movement ───────────────────────────────────────────────────────
2414 // Each syncs the grid first (movement reads the stop table / column layout),
2415 // moves, then repaints — `Inner::view` re-syncs but that's the cached no-op.
2416
2417 pub fn move_left(&self, extend: bool) -> DocView {
2418 let mut g = self.lock();
2419 g.sync();
2420 g.doc.move_left(extend);
2421 g.frame()
2422 }
2423
2424 pub fn move_right(&self, extend: bool) -> DocView {
2425 let mut g = self.lock();
2426 g.sync();
2427 g.doc.move_right(extend);
2428 g.frame()
2429 }
2430
2431 pub fn move_up(&self, extend: bool) -> DocView {
2432 let mut g = self.lock();
2433 g.sync();
2434 g.doc.move_up(extend);
2435 g.frame()
2436 }
2437
2438 pub fn move_down(&self, extend: bool) -> DocView {
2439 let mut g = self.lock();
2440 g.sync();
2441 g.doc.move_down(extend);
2442 g.frame()
2443 }
2444
2445 pub fn move_word_left(&self, extend: bool) -> DocView {
2446 let mut g = self.lock();
2447 g.sync();
2448 g.doc.move_word_left(extend);
2449 g.frame()
2450 }
2451
2452 pub fn move_word_right(&self, extend: bool) -> DocView {
2453 let mut g = self.lock();
2454 g.sync();
2455 g.doc.move_word_right(extend);
2456 g.frame()
2457 }
2458
2459 pub fn move_home(&self, extend: bool) -> DocView {
2460 let mut g = self.lock();
2461 g.sync();
2462 g.doc.move_home(extend);
2463 g.frame()
2464 }
2465
2466 pub fn move_end(&self, extend: bool) -> DocView {
2467 let mut g = self.lock();
2468 g.sync();
2469 g.doc.move_end(extend);
2470 g.frame()
2471 }
2472
2473 pub fn move_doc_start(&self, extend: bool) -> DocView {
2474 let mut g = self.lock();
2475 g.sync();
2476 g.doc.move_doc_start(extend);
2477 g.frame()
2478 }
2479
2480 pub fn move_doc_end(&self, extend: bool) -> DocView {
2481 let mut g = self.lock();
2482 g.sync();
2483 g.doc.move_doc_end(extend);
2484 g.frame()
2485 }
2486
2487 pub fn select_all(&self) -> DocView {
2488 let mut g = self.lock();
2489 g.doc.select_all();
2490 g.frame()
2491 }
2492
2493 /// Place the caret from a click, in core's column grid: `row` indexes the
2494 /// visual [`Row`]s and `col` is the glyph column within it. Core clamps both
2495 /// to real caret stops. Prefer [`LeafDoc::click_ch`] from a proportional
2496 /// renderer.
2497 pub fn click(&self, row: u32, col: u32, extend: bool) -> DocView {
2498 let mut g = self.lock();
2499 g.sync();
2500 g.doc.click(row as usize, col as usize, extend);
2501 g.frame()
2502 }
2503
2504 /// Place the caret from a click whose horizontal position is a **UTF-16
2505 /// offset** into the visual row's text — what `characterIndex(for:)` hands
2506 /// back. Converted to core's display column before clicking, so a proportional
2507 /// renderer never reasons about column widths itself.
2508 pub fn click_ch(&self, row: u32, ch: u32, extend: bool) -> DocView {
2509 let mut g = self.lock();
2510 g.sync();
2511 let col = utf16_to_col(&g.row_text(row as usize), ch as usize);
2512 g.doc.click(row as usize, col, extend);
2513 g.frame()
2514 }
2515
2516 /// Select the word under a click (row, `ch`) — the double-click gesture.
2517 pub fn select_word_ch(&self, row: u32, ch: u32) -> DocView {
2518 let mut g = self.lock();
2519 let off = g.offset_at(row as usize, ch as usize);
2520 g.doc.select_word_at(off);
2521 g.frame()
2522 }
2523
2524 /// Select the whole logical text block under a click (row, `ch`) — the
2525 /// triple-click gesture. Grabs the entire block even where it soft-wraps.
2526 pub fn select_block_ch(&self, row: u32, ch: u32) -> DocView {
2527 let mut g = self.lock();
2528 let off = g.offset_at(row as usize, ch as usize);
2529 g.doc.select_block_at(off);
2530 g.frame()
2531 }
2532
2533 /// A click in the blank space under the last row — the caret goes onto an
2534 /// empty paragraph under the last block, opening one if the document does
2535 /// not end with one, wherever the pointer was horizontally. See
2536 /// [`leaf_core::Doc::click_past_end`]. The frontend decides "under": the
2537 /// point is below every line box it laid out.
2538 pub fn click_past_end(&self) -> DocView {
2539 let mut g = self.lock();
2540 g.sync();
2541 g.doc.click_past_end();
2542 g.frame()
2543 }
2544
2545 /// Mirror a native selection into the model: `[anchor, focus]` given as
2546 /// row + UTF-16 offset pairs. Each is resolved to a source offset the way a
2547 /// click is, then set as the selection's fixed and moving ends. A collapsed
2548 /// range (`anchor == focus`) just places the caret.
2549 pub fn set_selection(
2550 &self,
2551 anchor_row: u32,
2552 anchor_ch: u32,
2553 focus_row: u32,
2554 focus_ch: u32,
2555 ) -> DocView {
2556 let mut g = self.lock();
2557 let anchor = g.offset_at(anchor_row as usize, anchor_ch as usize);
2558 let focus = g.offset_at(focus_row as usize, focus_ch as usize);
2559 g.doc.place_caret(anchor, false);
2560 if anchor != focus {
2561 g.doc.place_caret(focus, true);
2562 }
2563 g.frame()
2564 }
2565
2566 // ── rich clipboard (mirrors leaf-tui / leaf-gpui / leaf-wasm) ─────────────
2567
2568 /// The current selection rendered to HTML by twig — the rich flavor a copy
2569 /// writes alongside the plain [`LeafDoc::selected_text`]. `None` when nothing
2570 /// is selected.
2571 pub fn selection_html(&self) -> Option<String> {
2572 self.lock().doc.selection_html()
2573 }
2574
2575 /// Paste, preferring the clipboard's rich (`text/html`) flavor: twig parses
2576 /// `html` into the document's own markup and inserts it. Falls back to the
2577 /// plain `text` when there's no HTML or it doesn't parse.
2578 pub fn paste_rich(&self, html: Option<String>, text: String) -> DocView {
2579 let mut g = self.lock();
2580 let took = html.as_deref().is_some_and(|h| g.doc.paste_html(h));
2581 if !took {
2582 g.doc.paste(&text);
2583 }
2584 g.frame()
2585 }
2586
2587 // ── formatting commands (mirror leaf-gpui's EditorCommand) ────────────────
2588
2589 pub fn toggle_bold(&self) -> DocView {
2590 let mut g = self.lock();
2591 g.doc.toggle(InlineKind::Strong);
2592 g.frame()
2593 }
2594
2595 pub fn toggle_italic(&self) -> DocView {
2596 let mut g = self.lock();
2597 g.doc.toggle(InlineKind::Emph);
2598 g.frame()
2599 }
2600
2601 pub fn toggle_code(&self) -> DocView {
2602 let mut g = self.lock();
2603 g.doc.toggle(InlineKind::Verbatim);
2604 g.frame()
2605 }
2606
2607 pub fn toggle_mark(&self) -> DocView {
2608 let mut g = self.lock();
2609 g.doc.toggle(InlineKind::Mark);
2610 g.frame()
2611 }
2612
2613 /// Whether the caret stands in a highlight — what a colour palette enables
2614 /// itself by, since a colour is a property of a highlight that already
2615 /// exists. The caret-side half of [`Capabilities::mark_color`].
2616 pub fn caret_in_mark(&self) -> bool {
2617 self.lock().doc.caret_in_mark()
2618 }
2619
2620 /// Colour the highlight at the caret, or clear its colour with `None`.
2621 ///
2622 /// Markdown only — `==🔴 text==` is its spelling and djot has none — and
2623 /// only where there is a highlight to colour: this does not make one, so a
2624 /// coloured highlight from bare text is [`LeafDoc::toggle_mark`] and then
2625 /// this, which is the order the button and its palette already sit in. Both
2626 /// refusals leave the document alone and say so in the status line.
2627 pub fn set_mark_color(&self, color: Option<MarkColor>) -> DocView {
2628 let mut g = self.lock();
2629 g.doc.set_mark_color(color.map(CoreMarkColor::from));
2630 g.frame()
2631 }
2632
2633 /// One press of a colour swatch: colour the highlight at the caret, or —
2634 /// over a selection that isn't highlighted yet — highlight it and colour it,
2635 /// as **one** undo step.
2636 ///
2637 /// [`LeafDoc::set_mark_color`] is the exact gesture; this is the compound a
2638 /// toolbar presses, and it lives in core so that every frontend answers
2639 /// "what does a swatch mean over plain text" the same way. `None` clears the
2640 /// colour, and over an unhighlighted selection means simply "highlight
2641 /// this". A bare caret in no highlight is left alone.
2642 pub fn highlight(&self, color: Option<MarkColor>) -> DocView {
2643 let mut g = self.lock();
2644 g.doc.highlight(color.map(CoreMarkColor::from));
2645 g.frame()
2646 }
2647
2648 pub fn toggle_underline(&self) -> DocView {
2649 let mut g = self.lock();
2650 g.doc.toggle(InlineKind::Insert);
2651 g.frame()
2652 }
2653
2654 pub fn toggle_strike(&self) -> DocView {
2655 let mut g = self.lock();
2656 g.doc.toggle(InlineKind::Delete);
2657 g.frame()
2658 }
2659
2660 pub fn set_paragraph(&self) -> DocView {
2661 let mut g = self.lock();
2662 g.doc.set_block(BlockKind::Paragraph);
2663 g.frame()
2664 }
2665
2666 /// Toggle the current block to a heading of `level` (1–6); toggling the
2667 /// active level off returns it to a paragraph, per core.
2668 pub fn set_heading(&self, level: u32) -> DocView {
2669 let mut g = self.lock();
2670 g.doc.toggle_heading(level);
2671 g.frame()
2672 }
2673
2674 pub fn toggle_blockquote(&self) -> DocView {
2675 let mut g = self.lock();
2676 g.doc.toggle_blockquote();
2677 g.frame()
2678 }
2679
2680 pub fn toggle_list(&self, ordered: bool) -> DocView {
2681 let mut g = self.lock();
2682 g.doc.toggle_list(ordered);
2683 g.frame()
2684 }
2685
2686 /// Toggle a fenced code block over the selection or the block at the caret;
2687 /// on a blank line, open an empty one with the caret inside. See
2688 /// [`leaf_core::Doc::toggle_code_block`]. Gate on
2689 /// [`Capabilities::code_block`]; light from [`DocView::code_block`].
2690 pub fn toggle_code_block(&self) -> DocView {
2691 let mut g = self.lock();
2692 g.doc.toggle_code_block();
2693 g.frame()
2694 }
2695
2696 /// Tick or untick the task item at the caret. See
2697 /// [`leaf_core::Doc::toggle_task_checked`].
2698 pub fn toggle_task_checked(&self) -> DocView {
2699 let mut g = self.lock();
2700 g.doc.toggle_task_checked();
2701 g.frame()
2702 }
2703
2704 /// Tick or untick the task item covering `offset` — a tap on a rendered
2705 /// checkbox, which must not drag the caret across the document to get there.
2706 pub fn toggle_task_at(&self, offset: u64) -> DocView {
2707 let mut g = self.lock();
2708 g.doc.toggle_task_at(offset as usize);
2709 g.frame()
2710 }
2711
2712 /// Give the list item at the caret a checkbox, or take its checkbox away.
2713 pub fn toggle_task_item(&self) -> DocView {
2714 let mut g = self.lock();
2715 g.doc.toggle_task_item();
2716 g.frame()
2717 }
2718
2719 /// Whether the item at the caret has a box and which way it faces — `None`
2720 /// for a plain list item or no item at all. Drives a toolbar's checked state.
2721 pub fn task_checked_at_caret(&self) -> Option<bool> {
2722 let mut g = self.lock();
2723 g.doc.task_checked_at_caret()
2724 }
2725
2726 // ── the presentation vocabulary ───────────────────────────────────────────
2727 //
2728 // Six gestures and five queries over a document's *presentation*: alignment
2729 // and line spacing, which are the block's, and size, face and colour, which
2730 // are the run's. Each value is a **name** the renderer's theme resolves —
2731 // `center`, `1.5`, `large`, `serif`, `red` — or, for the four properties
2732 // whose type is open, the exact value the author asked for: `14pt`,
2733 // `Garamond`, `#c03030`, `1.3`. A name outlives the theme it was written
2734 // under and a value does not, which is the trade the *Other…* row makes
2735 // visible; see [`FontSize`] and its three peers.
2736 //
2737 // Each gesture edits one attribute key and keeps the rest, so a document
2738 // from elsewhere passes through the editor unharmed, and `nil` clears the
2739 // key. Each takes its enabled state from the matching [`Capabilities`] flag,
2740 // and its *lit* state from the query beside it — the queries read the
2741 // nearest node that names the property, so a control follows the caret into
2742 // a centred `<div>` the way the H1 light follows it into a heading.
2743
2744 /// Align the caret's block, or return it to the theme's default with `nil`.
2745 ///
2746 /// A block property, so it is the caret's *block* whatever is selected: a
2747 /// line belongs to a block, and "centre this" with three words selected
2748 /// means the paragraph, not the words. Other `class` tokens on the block are
2749 /// kept. Gate on [`Capabilities::alignment`].
2750 pub fn set_alignment(&self, align: Option<Align>) -> DocView {
2751 let mut g = self.lock();
2752 g.doc.set_alignment(align.map(CoreAlign::from));
2753 g.frame()
2754 }
2755
2756 /// Set the line spacing of the caret's block, or return it to the theme's
2757 /// with `nil`. [`set_alignment`](Self::set_alignment)'s peer in every
2758 /// respect but the key. Gate on [`Capabilities::line_spacing`].
2759 ///
2760 /// `.step(.oneHalf)` from the menu's three, or `.ratio(1.3)` from an
2761 /// *Other…* field. A ratio that spells one of the three *is* that name, and
2762 /// a ratio of 1 is single spacing, which is absence and clears the key. A
2763 /// ratio the vocabulary cannot carry at all — `0`, `700`, a NaN — writes
2764 /// nothing, and the block's own spacing stands.
2765 pub fn set_line_spacing(&self, spacing: Option<LineHeight>) -> DocView {
2766 let mut g = self.lock();
2767 if let Some(spacing) = written(spacing, LineHeight::into_core) {
2768 g.doc.set_line_spacing(spacing);
2769 }
2770 g.frame()
2771 }
2772
2773 /// Set the size of the selected run, or of the caret's whole block when
2774 /// nothing is selected; `nil` returns it to the theme's own size.
2775 ///
2776 /// Size, face and colour are the *run's*, and the block's when no run is
2777 /// chosen — so "make this paragraph larger" is a press with the caret in it
2778 /// rather than a select-all first. With a selection the range is wrapped in
2779 /// an attributed span, or the span it already lies in is re-styled, never
2780 /// nested. Gate on [`Capabilities::font_size`].
2781 ///
2782 /// `.step(.large)` from the menu's seven, or `.points(14)` from an
2783 /// *Other…* field — a size in points, which is what the paper will show. A
2784 /// number of points the vocabulary cannot carry (0.01 to 655.35 is the
2785 /// range) writes nothing at all, and the run's own size stands: validate
2786 /// the field before calling, because from here the refusal is silent.
2787 pub fn set_font_size(&self, size: Option<FontSize>) -> DocView {
2788 let mut g = self.lock();
2789 if let Some(size) = written(size, FontSize::into_core) {
2790 g.doc.set_font_size(size);
2791 }
2792 g.frame()
2793 }
2794
2795 /// Set the face of the selected run, or of the caret's whole block.
2796 /// [`set_font_size`](Self::set_font_size)'s peer. Gate on
2797 /// [`Capabilities::font_family`].
2798 ///
2799 /// `.generic(.serif)` from the menu's four, or `.named("Garamond")` from
2800 /// the platform's font picker — which draws in that family where it is
2801 /// installed and in the theme's body face where it is not. A name that
2802 /// names nothing (`" "`) writes nothing, and the run's own face stands.
2803 pub fn set_font_family(&self, font: Option<FontFace>) -> DocView {
2804 let mut g = self.lock();
2805 if let Some(font) = written(font, FontFace::into_core) {
2806 g.doc.set_font_family(font);
2807 }
2808 g.frame()
2809 }
2810
2811 /// Set the *text* colour of the selected run, or of the caret's whole block.
2812 /// [`set_font_size`](Self::set_font_size)'s peer, and **not**
2813 /// [`set_mark_color`](Self::set_mark_color): that one colours a highlight's
2814 /// background and needs a highlight to colour, this one paints the letters
2815 /// and needs nothing. They share the seven names on purpose. Gate on
2816 /// [`Capabilities::text_color`].
2817 ///
2818 /// `.named(.red)` from the seven swatches, or `.rgb(r:g:b:)` from the
2819 /// system colour picker — painted as written in both appearances, which is
2820 /// what "exact" costs.
2821 pub fn set_text_color(&self, color: Option<TextColor>) -> DocView {
2822 let mut g = self.lock();
2823 g.doc.set_text_color(color.map(TextColor::into_core));
2824 g.frame()
2825 }
2826
2827 /// Insert a page break at the caret — a leaf directive with no label, placed
2828 /// exactly as [`insert_thematic_break`](Self::insert_thematic_break) places a
2829 /// rule, a selection replaced by it and a bare paragraph parted at the caret
2830 /// first.
2831 ///
2832 /// A frontend that paginates opens a page at the row's directive mark and
2833 /// gives the row no height; one that does not draws the `⧉ page-break`
2834 /// placeholder every leaf directive gets. Gate on
2835 /// [`Capabilities::page_break`].
2836 pub fn insert_page_break(&self) -> DocView {
2837 let mut g = self.lock();
2838 g.doc.insert_page_break();
2839 g.frame()
2840 }
2841
2842 /// The alignment in force at the caret, or `nil` for the theme's default —
2843 /// which segment of an alignment control is lit.
2844 ///
2845 /// Read off the nearest node that names one: the caret's block, and the
2846 /// `div`s around it after that, so the control follows the caret into a
2847 /// centred `<div>`.
2848 pub fn alignment_at_caret(&self) -> Option<Align> {
2849 let mut g = self.lock();
2850 g.doc.alignment_at_caret().map(Align::from)
2851 }
2852
2853 /// The line spacing in force at the caret, or `nil` for the theme's own —
2854 /// which entry a spacing menu shows ticked.
2855 /// [`alignment_at_caret`](Self::alignment_at_caret)'s peer.
2856 ///
2857 /// A `.ratio` is a spacing no row of the menu's three can tick, and is the
2858 /// author's own: the menu shows it as a row of its own above *Other…*.
2859 pub fn line_spacing_at_caret(&self) -> Option<LineHeight> {
2860 let mut g = self.lock();
2861 g.doc.line_spacing_at_caret().map(LineHeight::from)
2862 }
2863
2864 /// The size in force at the caret, or `nil` for the theme's own — which
2865 /// entry a size menu shows ticked. Run-level, so the chain starts one node
2866 /// deeper: the attributed span the caret stands in, then its block, then the
2867 /// `div`s around it, the nearest winning.
2868 ///
2869 /// A `.points` is a size no row of the menu's seven can tick, and the menu
2870 /// shows it as a row of its own — "14 pt" — above *Other…*.
2871 pub fn font_size_at_caret(&self) -> Option<FontSize> {
2872 let mut g = self.lock();
2873 g.doc.font_size_at_caret().map(FontSize::from)
2874 }
2875
2876 /// The face in force at the caret, or `nil` for the theme's body face.
2877 /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer, and a `.named`
2878 /// is the family the author picked, shown as its own ticked row.
2879 pub fn font_family_at_caret(&self) -> Option<FontFace> {
2880 let mut g = self.lock();
2881 g.doc.font_family_at_caret().map(FontFace::from)
2882 }
2883
2884 /// The *text* colour in force at the caret, or `nil` for the theme's — which
2885 /// swatch a text-colour control marks as the current one.
2886 /// [`font_size_at_caret`](Self::font_size_at_caret)'s peer, and not
2887 /// [`DocView::mark_color`], which reads a highlight's background off a
2888 /// `mark` node the caret is standing in. A `.rgb` is the author's own
2889 /// triple, which the palette shows as a swatch of its own.
2890 pub fn text_color_at_caret(&self) -> Option<TextColor> {
2891 let mut g = self.lock();
2892 g.doc.text_color_at_caret().map(TextColor::from)
2893 }
2894
2895 /// Which of the formatting commands above this document's format can
2896 /// actually spell — one flag per control, for building the toolbar.
2897 ///
2898 /// Read once when a document opens: the answer depends only on the format,
2899 /// so it cannot change under an edit. Every command refuses on its own
2900 /// regardless — the model is the authority, not the toolbar — so a frontend
2901 /// that ignores this stays correct, it just offers buttons whose only effect
2902 /// is a line in the status bar.
2903 ///
2904 /// Don't collapse it to one flag. An HTML document takes ⌘B, ⌘I and inline
2905 /// code (its marks are a tag pair) while refusing every heading, list, quote
2906 /// and link, and Markdown refuses the underline djot spells — so a toolbar
2907 /// driven by [`Self::authorable`] alone would be wrong in both directions.
2908 pub fn capabilities(&self) -> Capabilities {
2909 self.lock().doc.capabilities().into()
2910 }
2911
2912 /// Whether this document's format offers *any* door in — `false` only for a
2913 /// wholly parse-only one (XML), where an app may as well open the file
2914 /// read-only and hide the formatting section outright. For anything finer,
2915 /// including whether to dim an individual button, use [`Self::capabilities`].
2916 pub fn authorable(&self) -> bool {
2917 self.lock().doc.authorable()
2918 }
2919
2920 // ── table editing ─────────────────────────────────────────────────────────
2921
2922 /// Whether the caret is inside a table — for enabling the table controls.
2923 /// Pair it with [`Capabilities::table`]: the caret is genuinely inside an
2924 /// HTML `<table>`, and the grid controls still cannot edit one.
2925 pub fn caret_in_table(&self) -> bool {
2926 self.lock().doc.caret_in_table()
2927 }
2928
2929 /// Insert an empty row below (`below`) or above the caret's row.
2930 pub fn table_insert_row(&self, below: bool) -> DocView {
2931 let mut g = self.lock();
2932 g.doc.table_insert_row(below);
2933 g.frame()
2934 }
2935
2936 /// Delete the caret's row (not the header or the last body row).
2937 pub fn table_delete_row(&self) -> DocView {
2938 let mut g = self.lock();
2939 g.doc.table_delete_row();
2940 g.frame()
2941 }
2942
2943 /// Insert an empty column right (`right`) or left of the caret's column.
2944 pub fn table_insert_column(&self, right: bool) -> DocView {
2945 let mut g = self.lock();
2946 g.doc.table_insert_column(right);
2947 g.frame()
2948 }
2949
2950 /// Delete the caret's column (unless it is the only one).
2951 pub fn table_delete_column(&self) -> DocView {
2952 let mut g = self.lock();
2953 g.doc.table_delete_column();
2954 g.frame()
2955 }
2956
2957 /// Set the caret's column to `alignment`.
2958 pub fn table_set_alignment(&self, alignment: TableAlignment) -> DocView {
2959 let mut g = self.lock();
2960 g.doc.table_set_alignment(alignment.into_core());
2961 g.frame()
2962 }
2963
2964 /// Move the caret's row one place down (`down`) or up.
2965 pub fn table_move_row(&self, down: bool) -> DocView {
2966 let mut g = self.lock();
2967 g.doc.table_move_row(down);
2968 g.frame()
2969 }
2970
2971 /// Move the caret's column one place right (`right`) or left.
2972 pub fn table_move_column(&self, right: bool) -> DocView {
2973 let mut g = self.lock();
2974 g.doc.table_move_column(right);
2975 g.frame()
2976 }
2977
2978 /// Insert a fresh table at the caret — one header row, `rows` empty body
2979 /// rows, `cols` columns — and leave the caret in its first header cell.
2980 /// The one table verb that needs no table under the caret; gate it on
2981 /// [`Capabilities::table`] alone. See [`leaf_core::Doc::insert_table`] for
2982 /// the placement (a paragraph is parted around the caret, as for the rule)
2983 /// and for what a zero shape does.
2984 pub fn insert_table(&self, rows: u32, cols: u32) -> DocView {
2985 let mut g = self.lock();
2986 g.doc.insert_table(rows as usize, cols as usize);
2987 g.frame()
2988 }
2989
2990 pub fn insert_link(&self, destination: String) -> DocView {
2991 let mut g = self.lock();
2992 g.doc.insert_link(&destination);
2993 g.frame()
2994 }
2995
2996 /// The destination of the link under the caret, if the caret is inside one —
2997 /// so a frontend can open it (⌘-click / "Open Link") or show it. `None` when the
2998 /// caret isn't on a link.
2999 pub fn link_destination_at_caret(&self) -> Option<String> {
3000 self.lock().doc.link_destination_at_caret()
3001 }
3002
3003 /// The source of the image the caret stands in — the `src` of an
3004 /// `` or a `<img>`, exactly as the document spells it. `None`
3005 /// when the caret is in no image.
3006 ///
3007 /// Two hosts ask. An image prompt seeds from it, so editing an existing
3008 /// picture starts from its current URL rather than blank; and a host that
3009 /// gives an attachment a place of its own — a node, a page, a file
3010 /// inspector — asks it to answer "show me *this* one" from a menu raised
3011 /// over the body. See `LeafEditorModel.onShowMedia` in the Swift package.
3012 ///
3013 /// A caret resting just after a block image (its trailing stop) is already
3014 /// past it and gets `None`, which is the same half-open rule
3015 /// [`link_destination_at_caret`](Self::link_destination_at_caret) follows.
3016 pub fn image_destination_at_caret(&self) -> Option<String> {
3017 self.lock().doc.image_destination_at_caret()
3018 }
3019
3020 /// The destination of the link at byte offset `off` —
3021 /// [`link_destination_at_caret`](Self::link_destination_at_caret) for a place
3022 /// the caret isn't.
3023 ///
3024 /// What a frontend drawing part of the document *outside* the document asks:
3025 /// a footnote's text in a popover has link runs in it, and this is how those
3026 /// runs learn where they point, since a `Run` carries how a span looks and
3027 /// not what it means.
3028 pub fn link_destination_at(&self, off: u32) -> Option<String> {
3029 self.lock().doc.link_destination_at(off as usize)
3030 }
3031
3032 /// Where the locator `id` lands in this document — the `#v2` half of a
3033 /// `chapter.dj#v2`, resolved to the block it names. `None` when nothing here
3034 /// answers to it, which is a host's cue to open the document at its top
3035 /// rather than refuse to go.
3036 ///
3037 /// The query that gives a link finer granularity than the file. It reads an
3038 /// explicit `{#v1}`, a djot heading's minted id, or (for Markdown, which
3039 /// mints none) a heading's own words slugged — see [`leaf_core::Doc::locate`].
3040 ///
3041 /// Asked of *any* document, not only the open one: a host peeking at a
3042 /// citation builds a [`LeafDoc`] over the other file's bytes and asks this,
3043 /// which is what lets a hover show the verse instead of the filename.
3044 pub fn locate(&self, id: String) -> Option<LandingView> {
3045 self.lock().doc.locate(&id).map(LandingView::from)
3046 }
3047
3048 /// Write a footnote at the caret — the toolbar's Footnote button. Both the
3049 /// `[^1]` and the definition it needs go in as one edit (one undo takes both
3050 /// back), the label is the lowest number the document has free, and the caret
3051 /// is left **in the empty note** ready to type it. Gate the button on
3052 /// [`Capabilities::footnote`]; see [`leaf_core::Doc::insert_footnote`].
3053 pub fn insert_footnote(&self) -> DocView {
3054 let mut g = self.lock();
3055 g.doc.insert_footnote();
3056 g.frame()
3057 }
3058
3059 /// The footnote reference under the caret, resolved to the note it names —
3060 /// so a frontend can show the note when a reader activates a `[1]`, instead
3061 /// of the nothing a reference click used to do. `None` when the caret isn't
3062 /// on a reference; see [`FootnoteView`] for the reference that resolved to
3063 /// no definition.
3064 pub fn footnote_at_caret(&self) -> Option<FootnoteView> {
3065 self.lock().doc.footnote_at_caret().map(FootnoteView::from)
3066 }
3067
3068 /// The footnote reference at byte offset `off`, resolved to the note it
3069 /// names — [`footnote_at_caret`](Self::footnote_at_caret) for a place the
3070 /// caret isn't.
3071 ///
3072 /// This is what a hover asks: a pointer resting on a `[1]` wants the note's
3073 /// text in a popover, and moving the caret to find out would yank the reader
3074 /// out of wherever they were typing.
3075 pub fn footnote_at(&self, off: u32) -> Option<FootnoteView> {
3076 self.lock()
3077 .doc
3078 .footnote_at(off as usize)
3079 .map(FootnoteView::from)
3080 }
3081
3082 /// The footnote definition the caret stands in, and where the reference that
3083 /// names it is — the return leg of [`footnote_at_caret`](Self::footnote_at_caret),
3084 /// so following a footnote is a round trip rather than a fall.
3085 ///
3086 /// `None` when the caret isn't in a definition, which is also how a frontend
3087 /// tells the two directions apart: the reference query answers up top, this
3088 /// one answers down in the notes, and never both at once.
3089 pub fn footnote_definition_at_caret(&self) -> Option<FootnoteDefView> {
3090 self.lock()
3091 .doc
3092 .footnote_definition_at_caret()
3093 .map(FootnoteDefView::from)
3094 }
3095
3096 pub fn undo(&self) -> DocView {
3097 let mut g = self.lock();
3098 g.doc.undo();
3099 g.frame()
3100 }
3101
3102 pub fn redo(&self) -> DocView {
3103 let mut g = self.lock();
3104 g.doc.redo();
3105 g.frame()
3106 }
3107
3108 /// Switch between the rendered WYSIWYG surface and the raw source.
3109 pub fn toggle_view(&self) -> DocView {
3110 let mut g = self.lock();
3111 g.doc.toggle_view();
3112 g.frame()
3113 }
3114
3115 /// The current markup-exposure preference (see [`MarkupMode`]).
3116 pub fn markup_mode(&self) -> MarkupMode {
3117 MarkupMode::from_core(self.lock().doc.markup_mode())
3118 }
3119
3120 /// Set the markup-exposure preference. Returns a fresh view so a frontend
3121 /// can repaint — and under `Full` it must, because the returned view is the
3122 /// first one showing the caret's line raw. Diaryx leaves it at the `None`
3123 /// default.
3124 pub fn set_markup_mode(&self, mode: MarkupMode) -> DocView {
3125 let mut g = self.lock();
3126 g.doc.set_markup_mode(mode.into_core());
3127 g.frame()
3128 }
3129
3130 /// The current soft-break flow preference (see [`LineFlow`]).
3131 pub fn line_flow(&self) -> LineFlow {
3132 LineFlow::from_core(self.lock().doc.line_flow())
3133 }
3134
3135 /// Set the soft-break flow preference. Returns a fresh view so a frontend
3136 /// can repaint: like the markup-exposure preference this one changes rendering
3137 /// immediately, laying preserved soft breaks out as their own rows.
3138 pub fn set_line_flow(&self, mode: LineFlow) -> DocView {
3139 let mut g = self.lock();
3140 g.doc.set_line_flow(mode.into_core());
3141 g.frame()
3142 }
3143}
3144
3145// ── UITextInput support ──────────────────────────────────────────────────────
3146// A `UITextPosition` on the Swift side wraps a source byte offset; these are the
3147// offset↔geometry, stepping, and range-editing primitives the protocol needs.
3148// Queries never move the caret — they only read the (synced) visual map — so the
3149// system can probe positions freely while the model's selection stays put.
3150#[uniffi::export]
3151impl LeafDoc {
3152 /// The caret's source offset (the selection's moving end).
3153 pub fn caret_offset(&self) -> u32 {
3154 self.lock().doc.caret as u32
3155 }
3156
3157 /// The selection's fixed end (equals the caret when there's no selection).
3158 pub fn anchor_offset(&self) -> u32 {
3159 let g = self.lock();
3160 g.doc.anchor.unwrap_or(g.doc.caret) as u32
3161 }
3162
3163 /// The last caret stop in the document — `UITextInput.endOfDocument`.
3164 pub fn doc_end_offset(&self) -> u32 {
3165 let mut g = self.lock();
3166 g.sync();
3167 let end = g.doc.source.len();
3168 g.snap_stop(end) as u32
3169 }
3170
3171 /// Snap an arbitrary offset to the nearest valid caret stop.
3172 pub fn snap_offset(&self, off: u32) -> u32 {
3173 let mut g = self.lock();
3174 g.sync();
3175 g.snap_stop(off as usize) as u32
3176 }
3177
3178 /// Where a source offset sits on screen: its visual `(row, ch)`.
3179 pub fn pos_for_offset(&self, off: u32) -> RowCol {
3180 let mut g = self.lock();
3181 g.sync();
3182 let (row, col) = g.pos_of_offset(off as usize);
3183 let ch = col_to_utf16(&g.row_text(row), col);
3184 RowCol {
3185 row: row as u32,
3186 ch: ch as u32,
3187 }
3188 }
3189
3190 /// The rows a source range covers, inclusive — for drawing a block away
3191 /// from where it sits (a footnote peek, a link peek, a landing flash).
3192 ///
3193 /// Ask this rather than mapping `start` and `end - 1` through
3194 /// [`Self::pos_for_offset`]. That pair reads correctly and is wrong: a
3195 /// block's last byte is often *hidden* — a note or a paragraph ending in a
3196 /// link ends inside the link's destination — and `pos_for_offset` snaps a
3197 /// hidden offset forward to the next visible glyph, which for a trailing
3198 /// one is on the next block's row. A peek slicing that span drew the block
3199 /// after it too. `pos_for_offset`'s snap is right for a caret and wrong for
3200 /// a span; this is the question spans should be asking.
3201 pub fn row_range_for(&self, start: u32, end: u32) -> RowRange {
3202 let mut g = self.lock();
3203 g.sync();
3204 let (first, last) = g.row_range_for(start as usize, end as usize);
3205 RowRange {
3206 first: first as u32,
3207 last: last as u32,
3208 }
3209 }
3210
3211 /// The source offset at visual `(row, ch)` — the inverse of
3212 /// [`Self::pos_for_offset`], for hit-testing a point to a position.
3213 pub fn offset_for_pos(&self, row: u32, ch: u32) -> u32 {
3214 let mut g = self.lock();
3215 g.sync();
3216 let col = utf16_to_col(&g.row_text(row as usize), ch as usize);
3217 g.offset_of_col(row as usize, col) as u32
3218 }
3219
3220 /// Move `off` by `delta` caret stops (negative = left) — `position(from:offset:)`.
3221 pub fn step_offset(&self, off: u32, delta: i32) -> u32 {
3222 let mut g = self.lock();
3223 g.sync();
3224 let mut o = g.snap_glyph_stop(off as usize);
3225 if delta >= 0 {
3226 for _ in 0..delta {
3227 match g.stop_after(o) {
3228 Some(n) => o = n,
3229 None => break,
3230 }
3231 }
3232 } else {
3233 for _ in 0..(-delta) {
3234 match g.stop_before(o) {
3235 Some(p) => o = p,
3236 None => break,
3237 }
3238 }
3239 }
3240 o as u32
3241 }
3242
3243 /// The count of caret stops between two offsets (signed) — `offset(from:to:)`.
3244 pub fn distance_offset(&self, from: u32, to: u32) -> i32 {
3245 let mut g = self.lock();
3246 g.sync();
3247 let (from, to) = (from as usize, to as usize);
3248 let (mut a, b, sign) = if from <= to {
3249 (from, to, 1i32)
3250 } else {
3251 (to, from, -1i32)
3252 };
3253 a = g.snap_glyph_stop(a);
3254 let mut n = 0i32;
3255 while a < b {
3256 match g.stop_after(a) {
3257 Some(x) => {
3258 a = x;
3259 n += 1;
3260 }
3261 None => break,
3262 }
3263 }
3264 n * sign
3265 }
3266
3267 /// The UTF-16 index at which source offset `off` sits in the visible text —
3268 /// the string `text_in_range(0, doc_end_offset())` returns — which is the
3269 /// character space AppKit's `NSTextInputClient` and `NSAccessibility` speak.
3270 ///
3271 /// leaf's own handle is the source byte offset, and the two are not one
3272 /// scale apart: WYSIWYG hides delimiters, a block gap is spelled as one
3273 /// `\n`, and a character outside the BMP is two UTF-16 units. A frontend
3274 /// hands the system an `NSRange` converted with this and turns the ranges
3275 /// it gets back through `offset_for_utf16_index`, so Look Up, dictation, and
3276 /// VoiceOver all index the same text the frontend drew.
3277 pub fn utf16_index_for_offset(&self, off: u32) -> u32 {
3278 let mut g = self.lock();
3279 g.sync();
3280 g.utf16_index_of(off)
3281 }
3282
3283 /// `utf16_index_for_offset` over many offsets in one crossing — the index
3284 /// of each, in the order given. For a caller that converts every run of
3285 /// the frame at once, as a spell checker masking the visible text does:
3286 /// thousands of runs, and a call across the binding for each was the cost.
3287 pub fn utf16_indices_for_offsets(&self, offs: Vec<u32>) -> Vec<u32> {
3288 let mut g = self.lock();
3289 g.sync();
3290 offs.into_iter().map(|off| g.utf16_index_of(off)).collect()
3291 }
3292
3293 /// The inverse of `utf16_index_for_offset`: the source offset of the
3294 /// visible character at UTF-16 `index`, or the document's end stop at or
3295 /// past the end of the text. An index inside a surrogate pair resolves to
3296 /// the character that owns it, and one on the `\n` a block gap is spelled
3297 /// with to the stop at the end of the block before it. Always a caret stop.
3298 pub fn offset_for_utf16_index(&self, index: u32) -> u32 {
3299 let mut g = self.lock();
3300 g.sync();
3301 let len = g.doc.source.len();
3302 let end = g.snap_stop(len);
3303 let index = index as usize;
3304 match g.doc.view {
3305 // A block separator resolves to the gap offset, which is no stop;
3306 // snapping lands it on the row end before it, the stop a caret
3307 // standing "after the last character" already means.
3308 View::Wysiwyg => g
3309 .doc
3310 .vmap
3311 .offset_at_visible_utf16(end, index)
3312 .map_or(end, |o| g.snap_stop(o)) as u32,
3313 View::Source => {
3314 let mut seen = 0usize;
3315 for (i, ch) in g.doc.source.char_indices() {
3316 let n = ch.len_utf16();
3317 if index < seen + n {
3318 return i as u32;
3319 }
3320 seen += n;
3321 }
3322 end as u32
3323 }
3324 }
3325 }
3326
3327 /// The offset one navigable row up/down from `off`, keeping its column —
3328 /// `position(from:in: .up/.down)`. `None` at the top/bottom edge.
3329 pub fn vertical_offset(&self, off: u32, down: bool) -> Option<u32> {
3330 let mut g = self.lock();
3331 g.sync();
3332 let (row, col) = g.pos_of_offset(off as usize);
3333 let target = if down {
3334 g.nav_below(row)
3335 } else {
3336 g.nav_above(row)
3337 };
3338 target.map(|r| g.offset_of_col(r, col) as u32)
3339 }
3340
3341 /// The visible text between two offsets — `text(in:)`. In the WYSIWYG
3342 /// view this is *not* the raw source slice: a hidden inline-mark
3343 /// delimiter (`**`, `` ` ``, `_`) contributes nothing, and a stop that
3344 /// draws no glyph — a row's end, a table cell's end — is spelled `'\n'`.
3345 /// Exactly one character per caret stop, so that for any two stops
3346 /// `text_in_range(a, b).chars().count() == distance_offset(a, b)`. That
3347 /// equality is what `UITextInput`'s word tokenizer relies on: it reads a
3348 /// window of this text, indexes into it by `offset(from:to:)`, and hands
3349 /// a character delta back through `position(from:offset:)` — see
3350 /// [`leaf_core::wysiwyg::VisualMap::visible_text`] for the rule and what
3351 /// a one-character drift did to a double-tapped word. The source view has
3352 /// nothing hidden to begin with, so there this is exactly the raw slice.
3353 pub fn text_in_range(&self, from: u32, to: u32) -> String {
3354 let mut g = self.lock();
3355 g.sync();
3356 let len = g.doc.source.len();
3357 let (mut a, mut b) = ((from as usize).min(len), (to as usize).min(len));
3358 if a > b {
3359 std::mem::swap(&mut a, &mut b);
3360 }
3361 match g.doc.view {
3362 View::Wysiwyg => g.doc.vmap.visible_text(a, b),
3363 View::Source => {
3364 let s = &g.doc.source;
3365 while a > 0 && !s.is_char_boundary(a) {
3366 a -= 1;
3367 }
3368 while b < s.len() && !s.is_char_boundary(b) {
3369 b += 1;
3370 }
3371 s[a..b].to_string()
3372 }
3373 }
3374 }
3375
3376 /// Set the selection to `[anchor, focus]` by source offsets — the setter behind
3377 /// `UITextInput.selectedTextRange` and handle dragging.
3378 pub fn set_selection_offsets(&self, anchor: u32, focus: u32) -> DocView {
3379 let mut g = self.lock();
3380 g.doc.place_caret(anchor as usize, false);
3381 if focus != anchor {
3382 g.doc.place_caret(focus as usize, true);
3383 }
3384 g.frame()
3385 }
3386
3387 /// Select the exact source range `[start, end)`, snapping neither end to a
3388 /// visible caret stop — for a host painting a range it already knows the
3389 /// bytes of (a search hit, an annotation) rather than hit-testing a touch.
3390 ///
3391 /// `set_selection_offsets` above is the *other* verb: it goes through
3392 /// `place_caret`, which snaps, and is what a drag handle wants. This one
3393 /// takes the range as given, so a selection over `**needle**`'s inner word
3394 /// is the word and not one byte short of it.
3395 pub fn select_range(&self, start: u32, end: u32) -> DocView {
3396 let mut g = self.lock();
3397 g.doc.select_range(start as usize, end as usize);
3398 g.frame()
3399 }
3400
3401 /// Replace the source range `[from, to]` with `text` — `replace(_:withText:)`.
3402 pub fn replace_range(&self, from: u32, to: u32, text: String) -> DocView {
3403 let mut g = self.lock();
3404 g.doc.place_caret(from as usize, false);
3405 if to != from {
3406 g.doc.place_caret(to as usize, true);
3407 }
3408 g.doc.insert(&text);
3409 g.frame()
3410 }
3411}
3412
3413impl LeafDoc {
3414 /// Acquire the guard, recovering from a poisoned lock: a panic in `leaf-core`
3415 /// under one call shouldn't wedge the whole document handle for the app.
3416 fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
3417 self.inner.lock().unwrap_or_else(|p| p.into_inner())
3418 }
3419}
3420
3421/// The UTF-16 offset into `text` of display column `col`. Walks grapheme clusters
3422/// exactly as core measures columns ([`text_width`] per cluster), so a wide
3423/// cluster advances the column by its cells while the offset advances by its
3424/// UTF-16 length; the two coincide only on plain ASCII.
3425fn col_to_utf16(text: &str, col: usize) -> usize {
3426 let mut c = 0usize;
3427 let mut u = 0usize;
3428 for g in text.graphemes(true) {
3429 if c >= col {
3430 break;
3431 }
3432 c += text_width(g);
3433 u += g.chars().map(char::len_utf16).sum::<usize>();
3434 }
3435 u
3436}
3437
3438/// The display column of the grapheme boundary at or before UTF-16 offset `off`
3439/// — the inverse of [`col_to_utf16`], turning a native click position back into
3440/// core's column. Core then clamps the column to a real caret stop.
3441fn utf16_to_col(text: &str, off: usize) -> usize {
3442 let mut c = 0usize;
3443 let mut u = 0usize;
3444 for g in text.graphemes(true) {
3445 if u >= off {
3446 break;
3447 }
3448 u += g.chars().map(char::len_utf16).sum::<usize>();
3449 c += text_width(g);
3450 }
3451 c
3452}
3453
3454/// The renderer class id for a semantic role. Heading level is folded into the
3455/// id (`h1`…`h6`) so a single style rule per level applies.
3456fn role_name(r: Role) -> String {
3457 match r {
3458 Role::Body => "body".into(),
3459 Role::Heading(level) => format!("h{}", level.clamp(1, 6)),
3460 Role::Code => "code".into(),
3461 Role::Link => "link".into(),
3462 // The colour rides `Run::mark_color`, not the class id: a renderer that
3463 // styles `mark` and nothing else still draws a coloured highlight.
3464 Role::Mark(_) => "mark".into(),
3465 Role::ListMarker => "list".into(),
3466 Role::QuoteGutter => "quote".into(),
3467 Role::Rule => "rule".into(),
3468 Role::Image => "image".into(),
3469 // A formula's stand-in: the one-character atom run an inline formula
3470 // renders to, or a display block's placeholder label. A renderer pairs
3471 // a `math` run with its [`MathView`] by `src` and draws the picture in
3472 // its place — see [`DocView::math`].
3473 Role::Math => "math".into(),
3474 Role::Delimiter => "delimiter".into(),
3475 }
3476}
3477
3478/// The toolbar id for an inline mark — kept in sync with the Swift button ids.
3479fn mark_id(kind: InlineKind) -> &'static str {
3480 match kind {
3481 InlineKind::Strong => "bold",
3482 InlineKind::Emph => "italic",
3483 InlineKind::Verbatim => "code",
3484 InlineKind::Mark => "mark",
3485 InlineKind::Insert => "underline",
3486 InlineKind::Delete => "strike",
3487 InlineKind::Superscript => "superscript",
3488 InlineKind::Subscript => "subscript",
3489 }
3490}
3491
3492/// The WYSIWYG rows: each visual row's glyphs coalesced into maximal runs of
3493/// identical `(style, selected)`. A glyph is selected when its source byte lies
3494/// in `[ss, se)`.
3495fn wysiwyg_rows(vmap: &VisualMap, ss: usize, se: usize, hls: &[leaf_core::Highlight]) -> Vec<Row> {
3496 // The map's own face table, for the family name a glyph carries only an id
3497 // for — threaded down beside `hls`, which travels the same road.
3498 let faces = vmap.faces();
3499 vmap.rows
3500 .iter()
3501 .map(|vrow| {
3502 Row {
3503 runs: runs_of(&vrow.glyphs, ss, se, hls, faces),
3504 decoration: vrow.decoration,
3505 code: vrow.code,
3506 code_lang: vrow.code_lang.clone(),
3507 directive: vrow.directive,
3508 directive_label: vrow.directive_label.clone(),
3509 // Straight off the row, not scanned out of its glyphs: an empty
3510 // heading has none to scan, and a renderer sizing the line by a
3511 // glyph's role drew `# ` at body height until it had text.
3512 heading: vrow.heading,
3513 // Off the row for the same reason, and more sharply: alignment
3514 // and spacing are properties of the *line*, so an empty
3515 // paragraph just centred has no run to carry them.
3516 align: vrow.align.map(|a| a.name().to_string()),
3517 line_height: vrow.line_height.map(|l| l.name().to_string()),
3518 boundary: vrow.boundary.map(|b| Boundary {
3519 above: b.above.into(),
3520 below: b.below.into(),
3521 }),
3522 }
3523 })
3524 .collect()
3525}
3526
3527/// Coalesce `glyphs` into maximal runs of identical `(style, selected)` — the
3528/// shared body of a row's runs and a table cell's runs. A glyph is selected when
3529/// its source byte lies in `[ss, se)`.
3530/// Split a cell's flat glyphs into its visual lines at the in-cell break glyphs
3531/// (`\n`, from a `<br>`), each with the source range it spans. A line runs from
3532/// its first glyph's offset to the break that ends it (`cell_end` for the last);
3533/// an empty line — a leading/trailing break, or an empty cell — collapses to a
3534/// single caret home. The break glyphs themselves are dropped (they hold no
3535/// caret), exactly as the monospace picture drops them.
3536fn cell_lines(
3537 glyphs: &[leaf_core::Glyph],
3538 cell_start: usize,
3539 cell_end: usize,
3540 ss: usize,
3541 se: usize,
3542 hls: &[leaf_core::Highlight],
3543 faces: &CoreFaceTable,
3544) -> Vec<TableCellLineView> {
3545 let mut lines = Vec::new();
3546 let mut seg: Vec<leaf_core::Glyph> = Vec::new();
3547 // The current line's start offset: the cell's for the first line, then the
3548 // first real glyph after each break (`None` until that glyph is seen).
3549 let mut line_start: Option<usize> = Some(cell_start);
3550 for g in glyphs {
3551 if g.ch == '\n' {
3552 let start = line_start.unwrap_or(g.src);
3553 lines.push(TableCellLineView {
3554 runs: runs_of(&seg, ss, se, hls, faces),
3555 start: start as u32,
3556 end: g.src as u32,
3557 });
3558 seg.clear();
3559 line_start = None;
3560 } else {
3561 if line_start.is_none() {
3562 line_start = Some(g.src);
3563 }
3564 seg.push(g.clone());
3565 }
3566 }
3567 lines.push(TableCellLineView {
3568 runs: runs_of(&seg, ss, se, hls, faces),
3569 start: line_start.unwrap_or(cell_end) as u32,
3570 end: cell_end as u32,
3571 });
3572 lines
3573}
3574
3575fn runs_of(
3576 glyphs: &[leaf_core::Glyph],
3577 ss: usize,
3578 se: usize,
3579 hls: &[leaf_core::Highlight],
3580 faces: &CoreFaceTable,
3581) -> Vec<Run> {
3582 // Which highlight (by index) covers a glyph — first by start when several
3583 // overlap, matching `Doc::highlight_at`. Part of the run key: a highlight
3584 // splits a run exactly the way the selection does, so its wash begins and
3585 // ends on its own bytes.
3586 let hl_of = |src: usize| hls.iter().position(|h| h.start <= src && src < h.end);
3587 let mut runs: Vec<Run> = Vec::new();
3588 let mut buf = String::new();
3589 // The style/selection/highlight key the run is accumulating, and the source
3590 // offset its first glyph came from — carried alongside rather than
3591 // re-derived, since a run's glyphs are contiguous but its *text* has no
3592 // offsets in it.
3593 let mut cur: Option<(LStyle, bool, Option<usize>, usize)> = None;
3594 for g in glyphs {
3595 let key = (g.style, g.src >= ss && g.src < se, hl_of(g.src));
3596 match cur {
3597 Some((style, sel, hl, _)) if (style, sel, hl) == key => buf.push(g.ch),
3598 _ => {
3599 if let Some((style, was_sel, hl, src)) = cur.take() {
3600 runs.push(make_run(
3601 std::mem::take(&mut buf),
3602 style,
3603 was_sel,
3604 hl.map(|i| &hls[i]),
3605 src,
3606 faces,
3607 ));
3608 }
3609 cur = Some((key.0, key.1, key.2, g.src));
3610 buf.push(g.ch);
3611 }
3612 }
3613 }
3614 if let Some((style, was_sel, hl, src)) = cur {
3615 runs.push(make_run(
3616 buf,
3617 style,
3618 was_sel,
3619 hl.map(|i| &hls[i]),
3620 src,
3621 faces,
3622 ));
3623 }
3624 runs
3625}
3626
3627/// The leaf directives of a WYSIWYG frame — each with the `rows` span its
3628/// placeholder occupies (to be painted over) and the name/attributes a frontend
3629/// resolves it by. The peer of [`wysiwyg_tables`] for a block that renders as a
3630/// thing rather than as text.
3631fn wysiwyg_directives(vmap: &VisualMap) -> Vec<DirectiveView> {
3632 vmap.directives
3633 .iter()
3634 .map(|d| DirectiveView {
3635 start_row: d.rows_span.start as u32,
3636 end_row: d.rows_span.end as u32,
3637 name: d.name.clone(),
3638 label: d.label.clone(),
3639 attrs: d
3640 .attrs
3641 .iter()
3642 .map(|(k, v)| DirectiveAttr {
3643 key: k.clone(),
3644 value: v.clone().unwrap_or_default(),
3645 })
3646 .collect(),
3647 })
3648 .collect()
3649}
3650
3651/// The block media of a WYSIWYG frame — each with the `rows` span its
3652/// placeholder occupies (to be laid over) and what to build there. The peer of
3653/// [`wysiwyg_directives`], with each URL already resolved under `scheme`.
3654///
3655/// Resolving here rather than in Swift keeps the one piece of `<picture>` logic
3656/// core owns (`prefers-color-scheme` matching) in core. The `<source>` list
3657/// still crosses untouched, so a renderer can additionally pick by MIME — which
3658/// codecs AVFoundation has is not something core can know.
3659fn wysiwyg_media(vmap: &VisualMap, scheme: ColorScheme) -> Vec<MediaView> {
3660 vmap.media
3661 .iter()
3662 .map(|m| MediaView {
3663 start_row: m.rows_span.start as u32,
3664 end_row: m.rows_span.end as u32,
3665 kind: match m.kind {
3666 CoreMediaKind::Image => MediaKind::Image,
3667 CoreMediaKind::Video => MediaKind::Video,
3668 CoreMediaKind::Audio => MediaKind::Audio,
3669 },
3670 src: m.resolve(scheme).to_string(),
3671 poster: m.poster.clone(),
3672 alt: m.alt.clone(),
3673 sources: m
3674 .sources
3675 .iter()
3676 .map(|s| MediaSourceView {
3677 media: s.media.clone(),
3678 src: s.srcset.clone(),
3679 mime: s.mime.clone(),
3680 })
3681 .collect(),
3682 })
3683 .collect()
3684}
3685
3686/// The formulas of a WYSIWYG frame — each atom and each display block, with
3687/// the `rows` span it occupies and the TeX to typeset. The peer of
3688/// [`wysiwyg_media`].
3689fn wysiwyg_math(vmap: &VisualMap) -> Vec<MathView> {
3690 vmap.math
3691 .iter()
3692 .map(|m| MathView {
3693 start_row: m.rows_span.start as u32,
3694 end_row: m.rows_span.end as u32,
3695 inline: m.glyph.is_some(),
3696 tex: m.tex.clone(),
3697 display: m.display,
3698 src: m.src as u32,
3699 })
3700 .collect()
3701}
3702
3703/// The structural tables of a WYSIWYG frame — each with the `rows` span its
3704/// box-glyph picture occupies (to be skipped) and its grid of styled cells.
3705fn wysiwyg_tables(
3706 vmap: &VisualMap,
3707 ss: usize,
3708 se: usize,
3709 hls: &[leaf_core::Highlight],
3710) -> Vec<TableView> {
3711 let faces = vmap.faces();
3712 vmap.tables
3713 .iter()
3714 .map(|t| TableView {
3715 start_row: t.rows_span.start as u32,
3716 end_row: t.rows_span.end as u32,
3717 grid: t
3718 .grid
3719 .iter()
3720 .map(|row| TableRowView {
3721 head: row.head,
3722 cells: row
3723 .cells
3724 .iter()
3725 .map(|cell| TableCellView {
3726 lines: cell_lines(
3727 &cell.glyphs,
3728 cell.start,
3729 cell.end,
3730 ss,
3731 se,
3732 hls,
3733 faces,
3734 ),
3735 align: align_name(cell.align),
3736 start: cell.start as u32,
3737 end: cell.end as u32,
3738 })
3739 .collect(),
3740 })
3741 .collect(),
3742 })
3743 .collect()
3744}
3745
3746/// The wire name for a cell's column alignment.
3747fn align_name(a: Alignment) -> String {
3748 match a {
3749 Alignment::Left => "left",
3750 Alignment::Right => "right",
3751 Alignment::Center => "center",
3752 Alignment::Default => "default",
3753 }
3754 .to_string()
3755}
3756
3757/// The source rows: the raw document split on `'\n'`, each line cut into runs
3758/// wherever its styling changes — the markup `smap` colours, the `[ss, se)`
3759/// selection, and the host's highlights — the native counterpart of the TUI's
3760/// `build_lines`. Backs the source view, whose caret rides raw byte offsets.
3761///
3762/// An empty `smap` — a frontend that never built one, a document with no
3763/// markup — paints every line as plain text, which is what this did before
3764/// the map reached it.
3765fn source_rows(
3766 source: &str,
3767 smap: &SourceMap,
3768 ss: usize,
3769 se: usize,
3770 hls: &[leaf_core::Highlight],
3771) -> Vec<Row> {
3772 // Raw text carries no attributed span, so no run of it names a family and
3773 // the table it would be read out of is empty.
3774 let faces = CoreFaceTable::default();
3775 let mut rows = Vec::new();
3776 let mut byte = 0usize;
3777 // Reused across lines rather than allocated per line: a document is a few
3778 // thousand of them and this is rebuilt on every whole frame.
3779 let mut cuts: Vec<usize> = Vec::new();
3780 // Which highlight covers a byte — first by start when several overlap,
3781 // matching `Doc::highlight_at` and `runs_of` above.
3782 let hl_of = |src: usize| hls.iter().position(|h| h.start <= src && src < h.end);
3783
3784 for raw in source.split('\n') {
3785 let start = byte;
3786 let end = start + raw.len();
3787
3788 // Where the styling can change within this line, in document offsets:
3789 // its two ends, every selection and highlight edge inside it, and every
3790 // edge of the syntax map's runs. No style edge falls strictly inside a
3791 // run by construction, so one probe at each run's first byte answers
3792 // for all of it.
3793 cuts.clear();
3794 cuts.push(start);
3795 cuts.push(end);
3796 for at in [ss, se]
3797 .into_iter()
3798 .chain(hls.iter().flat_map(|h| [h.start, h.end]))
3799 {
3800 if at > start && at < end {
3801 cuts.push(at);
3802 }
3803 }
3804 smap.edges_in(start..end, &mut cuts);
3805 cuts.sort_unstable();
3806 cuts.dedup();
3807
3808 let mut runs = Vec::new();
3809 for pair in cuts.windows(2) {
3810 let (a, b) = (pair[0], pair[1]);
3811 runs.push(make_run(
3812 raw[a - start..b - start].to_string(),
3813 smap.style_at(a),
3814 a >= ss && a < se,
3815 hl_of(a).map(|i| &hls[i]),
3816 a,
3817 &faces,
3818 ));
3819 }
3820
3821 rows.push(Row {
3822 runs,
3823 decoration: false,
3824 code: false,
3825 code_lang: None,
3826 directive: false,
3827 directive_label: None,
3828 heading: None, // source view is raw text — no resolved heading rows
3829 align: None, // …no attributes resolved onto a block…
3830 line_height: None,
3831 boundary: None, // …and no resolved block structure to divide
3832 });
3833 byte = end + 1; // skip the '\n' that `split` consumed
3834 }
3835 rows
3836}
3837
3838/// Build a [`Run`] from an accumulated string and the core style it was drawn
3839/// with — the one place role and emphasis flags cross into the view shape.
3840fn make_run(
3841 text: String,
3842 style: LStyle,
3843 sel: bool,
3844 hl: Option<&leaf_core::Highlight>,
3845 src: usize,
3846 faces: &CoreFaceTable,
3847) -> Run {
3848 Run {
3849 text,
3850 role: role_name(style.role),
3851 bold: style.bold,
3852 italic: style.italic,
3853 underline: style.underline,
3854 strike: style.strikethrough,
3855 sup: style.baseline == Baseline::Super,
3856 sub: style.baseline == Baseline::Sub,
3857 src: src as u32,
3858 sel,
3859 hl: hl.map(|h| h.id.clone()),
3860 hl_color: hl.and_then(|h| h.color.clone()),
3861 mark_color: mark_color_name(style.role),
3862 token: style.token.map(|t| t.name().to_string()),
3863 size: style.size.map(|s| s.name().to_string()),
3864 font: style.font.and_then(|f| faces.spell(f).map(Cow::into_owned)),
3865 text_color: style.color.map(|c| c.name().to_string()),
3866 }
3867}
3868
3869/// The name of a `mark` role's colour, for [`Run::mark_color`]. `None` for a
3870/// plain highlight and for every other role — the same answer, because neither
3871/// has a colour to name.
3872fn mark_color_name(role: Role) -> Option<String> {
3873 match role {
3874 Role::Mark(c) => c.map(|c| c.name().to_string()),
3875 _ => None,
3876 }
3877}
3878
3879#[cfg(test)]
3880mod tests {
3881 use super::*;
3882
3883 fn doc(src: &str) -> Arc<LeafDoc> {
3884 LeafDoc::new(src.to_string(), "markdown".to_string()).unwrap()
3885 }
3886
3887 /// The source view's rows carry the markup's styling: a heading's `# ` is
3888 /// a delimiter run, its text a heading run, and the rows split exactly at
3889 /// the map's edges — the same runs the TUI paints from the same map.
3890 #[test]
3891 fn source_rows_carry_the_source_maps_styling() {
3892 let d = doc("# Title\n\nsee [here](https://x.dev) now\n");
3893 let v = d.toggle_view();
3894 assert_eq!(v.view, "source");
3895 fn roles(row: &Row) -> Vec<(&str, &str)> {
3896 row.runs
3897 .iter()
3898 .map(|r| (r.text.as_str(), r.role.as_str()))
3899 .collect()
3900 }
3901 assert_eq!(
3902 roles(&v.rows[0]),
3903 vec![("# ", "delimiter"), ("Title", "h1")]
3904 );
3905 assert_eq!(
3906 roles(&v.rows[2]),
3907 vec", "delimiter"),
3912 (" now", "body"),
3913 ]
3914 );
3915 // Every run knows where it came from, as the rendered rows' do.
3916 let src = d.source();
3917 assert_eq!(v.rows[2].runs[2].src as usize, src.find("here").unwrap());
3918 // The text is whole: the runs concatenate back to the line.
3919 let joined: String = v.rows[2].runs.iter().map(|r| r.text.as_str()).collect();
3920 assert_eq!(joined, "see [here](https://x.dev) now");
3921 }
3922
3923 /// The selection and a host highlight split a source row on their own
3924 /// edges, on top of the map's, and neither loses the styling under it.
3925 #[test]
3926 fn source_rows_split_on_selection_and_highlight_edges_too() {
3927 let d = doc("a **bold** b\n");
3928 d.toggle_view();
3929 let src = d.source();
3930 let b = src.find("bold").unwrap() as u32;
3931 // Select "ol" — inside the bold run.
3932 d.set_selection_offsets(b + 1, b + 3);
3933 let v = d.set_highlights(vec![Highlight {
3934 start: 0,
3935 end: 3, // "a *"
3936 id: "h".into(),
3937 color: None,
3938 marker: None,
3939 }]);
3940 let runs: Vec<(String, String, bool, bool, Option<String>)> = v.rows[0]
3941 .runs
3942 .iter()
3943 .map(|r| (r.text.clone(), r.role.clone(), r.bold, r.sel, r.hl.clone()))
3944 .collect();
3945 let expect = |t: &str, role: &str, bold: bool, sel: bool, hl: Option<&str>| {
3946 (
3947 t.to_string(),
3948 role.to_string(),
3949 bold,
3950 sel,
3951 hl.map(str::to_string),
3952 )
3953 };
3954 assert_eq!(
3955 runs,
3956 vec![
3957 expect("a ", "body", false, false, Some("h")),
3958 expect("*", "delimiter", true, false, Some("h")),
3959 expect("*", "delimiter", true, false, None),
3960 expect("b", "body", true, false, None),
3961 expect("ol", "body", true, true, None),
3962 expect("d", "body", true, false, None),
3963 expect("**", "delimiter", true, false, None),
3964 expect(" b", "body", false, false, None),
3965 ]
3966 );
3967 }
3968
3969 /// A fenced block's body in the source view carries the grammar's tokens,
3970 /// as the rendered view's does — the same `let` is a keyword in both.
3971 #[cfg(feature = "syntax")]
3972 #[test]
3973 fn source_rows_carry_fence_tokens() {
3974 let d = doc("```rust\nlet x = 1;\n```\n");
3975 let v = d.toggle_view();
3976 let kw = v.rows[1]
3977 .runs
3978 .iter()
3979 .find(|r| r.text == "let")
3980 .expect("a run for the keyword");
3981 assert_eq!(kw.role, "code");
3982 assert_eq!(kw.token.as_deref(), Some("keyword"));
3983 assert_eq!(v.rows[0].runs[0].role, "delimiter", "the fence is markup");
3984 }
3985
3986 /// A token splits a run the way a style does — it *is* part of the style
3987 /// — and rides across as its class id. A block in a language no grammar
3988 /// covers is one plain `code` run with no token, as it always was.
3989 #[cfg(feature = "syntax")]
3990 #[test]
3991 fn a_highlighted_block_splits_its_runs_by_token() {
3992 let v = doc("```rust\nlet x = 1;\n```\n").set_unwrapped();
3993 let row = v.rows.iter().find(|r| r.code).expect("a code row");
3994 let classed: Vec<(&str, Option<&str>)> = row
3995 .runs
3996 .iter()
3997 .map(|r| (r.text.as_str(), r.token.as_deref()))
3998 .collect();
3999 assert_eq!(classed[0], ("let", Some("keyword")));
4000 assert!(row.runs.iter().all(|r| r.role == "code"), "{classed:?}");
4001 assert!(
4002 classed
4003 .iter()
4004 .any(|(t, k)| *t == "1" && *k == Some("constant")),
4005 "{classed:?}"
4006 );
4007
4008 let v = doc("```text\nlet x = 1;\n```\n").set_unwrapped();
4009 let row = v.rows.iter().find(|r| r.code).unwrap();
4010 assert_eq!(row.runs.len(), 1);
4011 assert_eq!(row.runs[0].token, None);
4012 }
4013
4014 #[test]
4015 fn a_footnote_definition_ending_the_file_is_itself_not_a_copy() {
4016 // No trailing newline: twig closes the last block on the virtual newline
4017 // it supplies at EOF, so the block's `span.end` is one past the source.
4018 // The definition and the `section` whose bytes contain it then both
4019 // overran, both keyed the block cache as *empty*, and the definition was
4020 // served the section's rows — this rendered the heading a second time.
4021 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.";
4022 let d = LeafDoc::new(src.to_string(), "djot".to_string()).unwrap();
4023 let text: Vec<String> = d
4024 .view()
4025 .rows
4026 .iter()
4027 .map(|r| r.runs.iter().map(|x| x.text.as_str()).collect())
4028 .collect();
4029 assert_eq!(
4030 text.last().map(String::as_str),
4031 Some("[note] A note with a word for a label."),
4032 "the last definition should render itself: {text:?}"
4033 );
4034 assert_eq!(
4035 text.iter()
4036 .filter(|t| t.contains("A heading with a reference"))
4037 .count(),
4038 1,
4039 "the heading should render exactly once: {text:?}"
4040 );
4041 }
4042
4043 #[test]
4044 fn an_empty_heading_crosses_the_boundary_carrying_its_level() {
4045 // What the toolbar's H1 leaves on a blank line: a heading with no text
4046 // yet. The renderer sizes a row by this field, so a `nil` here is a line
4047 // (and a caret) drawn at body height that jumps to heading height on the
4048 // first keystroke — the level can't be scanned out of the runs, because
4049 // an empty heading has none.
4050 let d = doc("body\n\n# \n");
4051 let v = d.view();
4052 let head = v.rows.last().expect("the heading's row");
4053 assert!(
4054 head.runs.iter().all(|r| r.text.is_empty()),
4055 "the `# ` marker is hidden"
4056 );
4057 assert_eq!(head.heading, Some(1));
4058 assert_eq!(
4059 v.rows[0].heading, None,
4060 "the paragraph above is not a heading"
4061 );
4062 }
4063
4064 #[test]
4065 fn typing_into_a_heading_made_on_a_blank_line_keeps_the_caret_on_its_row() {
4066 // The reported bug at the boundary the Swift renderer reads: with a blank
4067 // line under it, the caret came back on a row two below the heading it
4068 // was actually in, and the view drew it there.
4069 let d = doc("one\n\ntwo\n\n\n\n");
4070 let _ = d.click(4, 0, false); // the first of the two blank lines
4071 let _ = d.set_heading(1);
4072 let mut v = d.view();
4073 for c in "title".chars() {
4074 v = d.insert(c.to_string());
4075 }
4076 assert_eq!(d.source(), "one\n\ntwo\n\n# title\n\n");
4077 assert_eq!(
4078 (v.caret_row, v.caret_ch),
4079 (4, 5),
4080 "the caret is on the heading's row"
4081 );
4082 assert_eq!(v.rows[4].heading, Some(1));
4083 }
4084
4085 #[test]
4086 fn a_video_crosses_the_boundary_as_media_with_the_rows_to_lay_it_over() {
4087 // What the Swift renderer actually consumes: a row span to cover, a kind
4088 // to build a view from, and a URL to load. A frontend that skipped the
4089 // span would paint core's `🎬` placeholder underneath its own player.
4090 let d = doc("<video src=\"clip.mp4\" poster=\"still.png\" controls></video>\n");
4091 let v = d.view();
4092 assert_eq!(v.media.len(), 1);
4093 let m = &v.media[0];
4094 assert!(matches!(m.kind, MediaKind::Video));
4095 assert_eq!(m.src, "clip.mp4");
4096 assert_eq!(m.poster, "still.png");
4097 assert!(
4098 m.end_row > m.start_row,
4099 "the span must cover at least its label row"
4100 );
4101 }
4102
4103 #[test]
4104 fn a_pictures_dark_source_resolves_by_appearance() {
4105 // The one piece of `<picture>` logic core owns, exercised across the
4106 // boundary: the same document resolves to a different URL depending on
4107 // what the host said its appearance was.
4108 let d = doc(
4109 "<picture><source media=\"(prefers-color-scheme: dark)\" srcset=\"d.svg\">\
4110 <img src=\"l.svg\" alt=\"banner\"></picture>\n",
4111 );
4112 assert_eq!(d.view().media[0].src, "l.svg", "light by default");
4113 assert_eq!(d.set_dark_appearance(true).media[0].src, "d.svg");
4114 assert_eq!(d.set_dark_appearance(false).media[0].src, "l.svg");
4115 }
4116
4117 #[test]
4118 fn tapping_below_a_trailing_picture_and_typing_keeps_it_a_picture() {
4119 // The whole gesture, across the boundary, in the order the Apple frontend
4120 // performs it: the layout clamps a point below the last row onto the
4121 // picture's row and asks for the position past its label glyphs; that
4122 // offset becomes the selection; then a character arrives. Before the two
4123 // halves of this fix, the offset was the stop *in front of* the picture
4124 // and the character dissolved it into a paragraph with an inline image —
4125 // the photo stopped being drawn, and nothing said so.
4126 let d = doc("hi\n\n\n");
4127 let v = d.set_unwrapped();
4128 let row = v.media[0].start_row;
4129 let label: u32 = v.rows[row as usize]
4130 .runs
4131 .iter()
4132 .map(|r| r.text.encode_utf16().count() as u32)
4133 .sum();
4134
4135 let off = d.offset_for_pos(row, label);
4136 assert_eq!(
4137 off,
4138 "hi\n\n".len() as u32,
4139 "the stop past the picture"
4140 );
4141
4142 d.set_selection_offsets(off, off);
4143 let after = d.insert("x".to_string());
4144 assert_eq!(d.source(), "hi\n\n\n\nx\n");
4145 assert_eq!(after.media.len(), 1, "still a picture, one paragraph up");
4146 }
4147
4148 #[test]
4149 fn backspace_from_that_same_tap_takes_the_picture_whole() {
4150 // The other half of the same gesture, and the one that cost this project's
4151 // own test vault a photo: tap under the picture, press Backspace. That
4152 // offset is the stop past the markup, so a byte-step deleted the closing
4153 // paren and left the literal text `\n");
4155 d.set_unwrapped();
4156 let off = "hi\n\n".len() as u32;
4157 d.set_selection_offsets(off, off);
4158 let after = d.backspace();
4159 assert_eq!(d.source(), "hi\n");
4160 assert_eq!(after.media.len(), 0, "gone as a picture, not as bytes");
4161 let undone = d.undo();
4162 assert_eq!(d.source(), "hi\n\n\n");
4163 assert_eq!(
4164 undone.media.len(),
4165 1,
4166 "and one undo brings the picture back"
4167 );
4168 }
4169
4170 #[test]
4171 fn measured_heights_grow_the_reserved_span() {
4172 // The height loop: core reserves one row until the renderer measures the
4173 // real view and reports back, because core does no I/O and cannot know.
4174 let d = doc("\n");
4175 let before = &d.view().media[0];
4176 assert_eq!(
4177 before.end_row - before.start_row,
4178 1,
4179 "one row until measured"
4180 );
4181
4182 let after = d.set_media_rows(vec![MediaHeight {
4183 destination: "cat.png".to_string(),
4184 rows: 6,
4185 }]);
4186 let m = &after.media[0];
4187 assert_eq!(
4188 m.end_row - m.start_row,
4189 6,
4190 "the span grew to what was measured"
4191 );
4192 }
4193
4194 #[test]
4195 fn inserted_media_comes_straight_back_out_as_media() {
4196 // Round trip across the boundary, the pair that matters: what Swift asks
4197 // to insert, Swift sees on the very next frame.
4198 let d = doc("\n");
4199 let v = d.insert_media(
4200 MediaKind::Audio,
4201 "take.mp3".to_string(),
4202 "a take".to_string(),
4203 );
4204 assert_eq!(v.media.len(), 1);
4205 assert!(matches!(v.media[0].kind, MediaKind::Audio));
4206 assert_eq!(v.media[0].src, "take.mp3");
4207 assert_eq!(v.media[0].alt, "a take");
4208 }
4209
4210 /// The block half of the presentation vocabulary, the whole way round:
4211 /// press, and the fact comes back on **every** row of the block as the name
4212 /// the document carries, with the query lighting the control that wrote it.
4213 ///
4214 /// The row rather than a run because an empty paragraph has no run — and a
4215 /// name rather than an index because the renderer's theme owns the ramp,
4216 /// which is the same division `mark_color` makes.
4217 #[test]
4218 fn the_block_vocabulary_crosses_on_the_row_and_comes_back_at_the_caret() {
4219 let d = doc("a centred paragraph\n");
4220 let v = d.set_alignment(Some(Align::Center));
4221 let aligned: Vec<&str> = v
4222 .rows
4223 .iter()
4224 .filter_map(|r| r.align.as_deref())
4225 .collect::<Vec<_>>();
4226 assert_eq!(aligned, ["center"], "the token, on the paragraph's row");
4227 assert_eq!(d.alignment_at_caret(), Some(Align::Center));
4228
4229 // A second property on the same block keeps the first: each gesture
4230 // edits one key and passes the rest back whole. (The caret is put back
4231 // in the paragraph first — in Markdown the attributes went onto a `div`
4232 // the press wrote *around* it, so the offset the caret kept is now that
4233 // opening line, which is no block of the document's.)
4234 let at = d.source().find("centred").unwrap() as u32;
4235 d.set_selection_offsets(at, at);
4236 let v = d.set_line_spacing(Some(LineHeight::Step(LineSpacing::OneHalf)));
4237 let row = v
4238 .rows
4239 .iter()
4240 .find(|r| r.align.is_some())
4241 .expect("the block");
4242 assert_eq!(row.align.as_deref(), Some("center"));
4243 assert_eq!(row.line_height.as_deref(), Some("1.5"));
4244 assert_eq!(
4245 d.line_spacing_at_caret(),
4246 Some(LineHeight::Step(LineSpacing::OneHalf))
4247 );
4248
4249 // `nil` clears, and absence is the theme's default rather than a token
4250 // meaning "left".
4251 let at = d.source().find("centred").unwrap() as u32;
4252 d.set_selection_offsets(at, at);
4253 let v = d.set_alignment(None);
4254 assert!(v.rows.iter().all(|r| r.align.is_none()));
4255 assert_eq!(d.alignment_at_caret(), None);
4256 assert_eq!(
4257 d.line_spacing_at_caret(),
4258 Some(LineHeight::Step(LineSpacing::OneHalf)),
4259 "clearing one key leaves the other standing"
4260 );
4261 }
4262
4263 /// The run half: size, face and colour ride the run beside `role`, so a
4264 /// renderer picks a font and a foreground without re-reading the document.
4265 /// With nothing selected the caret's whole block takes them, which is what
4266 /// makes "make this paragraph larger" one press.
4267 #[test]
4268 fn the_run_vocabulary_crosses_on_the_run_and_comes_back_at_the_caret() {
4269 let d = doc("big serif blue\n");
4270 // Each press with the caret back in the paragraph, as a frontend's is —
4271 // the first wrapped the block in a `div`, and the offset the caret kept
4272 // is that opening line rather than the text.
4273 let in_text = |d: &Arc<LeafDoc>| {
4274 let at = d.source().find("serif").unwrap() as u32;
4275 d.set_selection_offsets(at, at);
4276 };
4277 d.set_font_size(Some(FontSize::Step(SizeStep::Large)));
4278 in_text(&d);
4279 d.set_font_family(Some(FontFace::Generic(FontFamily::Serif)));
4280 in_text(&d);
4281 let v = d.set_text_color(Some(TextColor::Named(MarkColor::Blue)));
4282
4283 let styled: Vec<(Option<&str>, Option<&str>, Option<&str>)> = v
4284 .rows
4285 .iter()
4286 .flat_map(|r| r.runs.iter())
4287 .filter(|r| !r.text.trim().is_empty())
4288 .map(|r| {
4289 (
4290 r.size.as_deref(),
4291 r.font.as_deref(),
4292 r.text_color.as_deref(),
4293 )
4294 })
4295 .collect();
4296 assert_eq!(styled, [(Some("large"), Some("serif"), Some("blue"))]);
4297
4298 assert_eq!(
4299 d.font_size_at_caret(),
4300 Some(FontSize::Step(SizeStep::Large))
4301 );
4302 assert_eq!(
4303 d.font_family_at_caret(),
4304 Some(FontFace::Generic(FontFamily::Serif))
4305 );
4306 assert_eq!(
4307 d.text_color_at_caret(),
4308 Some(TextColor::Named(MarkColor::Blue))
4309 );
4310
4311 // A run's text colour is not a highlight's wash: nothing here is a
4312 // `mark`, so the palette that colours one reports nothing.
4313 assert!(!d.caret_in_mark());
4314 assert!(
4315 v.rows
4316 .iter()
4317 .flat_map(|r| r.runs.iter())
4318 .all(|r| r.mark_color.is_none())
4319 );
4320 }
4321
4322 /// The other half of each open type: the value an *Other…* row writes, out
4323 /// through the gesture and back through both the query and the run view.
4324 /// The view's token is the canonical spelling, because a renderer's theme
4325 /// table is keyed by string and parses what it does not find.
4326 #[test]
4327 fn an_exact_value_crosses_as_its_own_token_and_comes_back_whole() {
4328 let d = doc("exact\n");
4329 let in_text = |d: &Arc<LeafDoc>| {
4330 let at = d.source().find("exact").unwrap() as u32;
4331 d.set_selection_offsets(at, at);
4332 };
4333 d.set_font_size(Some(FontSize::Points(14.0)));
4334 in_text(&d);
4335 d.set_font_family(Some(FontFace::Named("Garamond".to_string())));
4336 in_text(&d);
4337 d.set_text_color(Some(TextColor::Rgb {
4338 r: 0xc0,
4339 g: 0x30,
4340 b: 0x30,
4341 }));
4342 in_text(&d);
4343 let v = d.set_line_spacing(Some(LineHeight::Ratio(1.3)));
4344
4345 let styled: Vec<(Option<&str>, Option<&str>, Option<&str>)> = v
4346 .rows
4347 .iter()
4348 .flat_map(|r| r.runs.iter())
4349 .filter(|r| !r.text.trim().is_empty())
4350 .map(|r| {
4351 (
4352 r.size.as_deref(),
4353 r.font.as_deref(),
4354 r.text_color.as_deref(),
4355 )
4356 })
4357 .collect();
4358 assert_eq!(
4359 styled,
4360 [(Some("14pt"), Some("Garamond"), Some("#c03030"))],
4361 "the value's own spelling, where a name stood before"
4362 );
4363 let spaced: Vec<&str> = v
4364 .rows
4365 .iter()
4366 .filter_map(|r| r.line_height.as_deref())
4367 .collect();
4368 assert_eq!(spaced, ["1.3"]);
4369
4370 assert_eq!(d.font_size_at_caret(), Some(FontSize::Points(14.0)));
4371 assert_eq!(
4372 d.font_family_at_caret(),
4373 Some(FontFace::Named("Garamond".to_string()))
4374 );
4375 assert_eq!(
4376 d.text_color_at_caret(),
4377 Some(TextColor::Rgb {
4378 r: 0xc0,
4379 g: 0x30,
4380 b: 0x30
4381 })
4382 );
4383 assert_eq!(d.line_spacing_at_caret(), Some(LineHeight::Ratio(1.3)));
4384 }
4385
4386 /// A named face reaching a run **inside a table cell** — the other road a
4387 /// run takes to a frontend, and the one an id resolved against the wrong
4388 /// table would quietly ruin: a cell's runs come through [`cell_lines`] and
4389 /// not through [`wysiwyg_rows`], so the map's [`CoreFaceTable`] has to be
4390 /// threaded down both. A grid whose faces all named nothing would draw in
4391 /// the body face and look like a theme that simply had no Garamond.
4392 #[test]
4393 fn a_named_face_reaches_a_run_inside_a_table_cell() {
4394 let d = doc("| a | b |\n|---|---|\n| one | two |\n");
4395 let at = d.source().find("one").unwrap() as u32;
4396 d.set_selection_offsets(at, at + 3);
4397 let v = d.set_font_family(Some(FontFace::Named("Garamond".to_string())));
4398
4399 let faced: Vec<(&str, &str)> = v
4400 .tables
4401 .iter()
4402 .flat_map(|t| t.grid.iter())
4403 .flat_map(|r| r.cells.iter())
4404 .flat_map(|c| c.lines.iter())
4405 .flat_map(|l| l.runs.iter())
4406 .filter_map(|r| Some((r.text.trim(), r.font.as_deref()?)))
4407 .collect();
4408 assert_eq!(faced, [("one", "Garamond")]);
4409 }
4410
4411 /// A value the vocabulary cannot carry writes **nothing at all**, and what
4412 /// the run already said stands — a typo in an *Other…* field is not a
4413 /// reason to throw away the size the author set a minute ago. The one
4414 /// value that clears is the one that *means* the theme's own: a ratio of
4415 /// 1, which is single spacing.
4416 #[test]
4417 fn a_value_outside_the_vocabulary_leaves_the_key_alone() {
4418 let d = doc("plain\n");
4419 let in_text = |d: &Arc<LeafDoc>| {
4420 let at = d.source().find("plain").unwrap() as u32;
4421 d.set_selection_offsets(at, at);
4422 };
4423 d.set_font_size(Some(FontSize::Points(14.0)));
4424 in_text(&d);
4425 assert_eq!(d.font_size_at_caret(), Some(FontSize::Points(14.0)));
4426 for refused in [700.0, 0.0, -3.0, f64::NAN] {
4427 d.set_font_size(Some(FontSize::Points(refused)));
4428 in_text(&d);
4429 assert_eq!(
4430 d.font_size_at_caret(),
4431 Some(FontSize::Points(14.0)),
4432 "{refused} is not a size, and the author's 14pt is not its casualty"
4433 );
4434 }
4435
4436 // A ratio of 1 is single spacing, which is the theme's and has no
4437 // token: that one *is* absence, and clears. A ratio of 0 is not.
4438 d.set_line_spacing(Some(LineHeight::Ratio(1.5)));
4439 in_text(&d);
4440 assert_eq!(
4441 d.line_spacing_at_caret(),
4442 Some(LineHeight::Step(LineSpacing::OneHalf)),
4443 "a ratio that spells a name is that name"
4444 );
4445 d.set_line_spacing(Some(LineHeight::Ratio(0.0)));
4446 in_text(&d);
4447 assert_eq!(
4448 d.line_spacing_at_caret(),
4449 Some(LineHeight::Step(LineSpacing::OneHalf)),
4450 "nought is not a spacing, and refusing it keeps the block's own"
4451 );
4452 d.set_line_spacing(Some(LineHeight::Ratio(1.0)));
4453 in_text(&d);
4454 assert_eq!(d.line_spacing_at_caret(), None, "single is the theme's own");
4455
4456 // And a name that spells a generic is that generic, whatever its case
4457 // — the reading a document's own `data-font` gets. A name that names
4458 // nothing is refused, and the face the run had stands.
4459 d.set_font_family(Some(FontFace::Named(" Serif ".to_string())));
4460 in_text(&d);
4461 assert_eq!(
4462 d.font_family_at_caret(),
4463 Some(FontFace::Generic(FontFamily::Serif))
4464 );
4465 d.set_font_family(Some(FontFace::Named(" ".to_string())));
4466 in_text(&d);
4467 assert_eq!(
4468 d.font_family_at_caret(),
4469 Some(FontFace::Generic(FontFamily::Serif))
4470 );
4471
4472 // `nil` is the argument that clears, and it is the only one.
4473 d.set_font_size(None);
4474 d.set_font_family(None);
4475 in_text(&d);
4476 assert_eq!(d.font_size_at_caret(), None);
4477 assert_eq!(d.font_family_at_caret(), None);
4478 }
4479
4480 /// A page break crosses as the leaf directive it is — the row a paginating
4481 /// frontend opens a page at.
4482 #[test]
4483 fn a_page_break_crosses_as_a_directive_of_its_own() {
4484 let d = doc("before\n\nafter\n");
4485 let v = d.insert_page_break();
4486 let names: Vec<&str> = v.directives.iter().map(|x| x.name.as_str()).collect();
4487 assert_eq!(names, ["page-break"]);
4488 assert!(d.source().contains("page-break"));
4489 }
4490
4491 /// One flag per new control, answered by the format — the toolbar builds
4492 /// itself from these rather than discovering each refusal on a press.
4493 /// Markdown spells all six; XML spells none of them, being parse-only.
4494 #[test]
4495 fn capabilities_answer_for_the_presentation_controls_too() {
4496 let md = doc("x\n").capabilities();
4497 assert!(md.alignment && md.line_spacing);
4498 assert!(md.font_size && md.font_family && md.text_color);
4499 assert!(md.page_break);
4500
4501 let xml = LeafDoc::new("<a>x</a>".to_string(), "xml".to_string())
4502 .unwrap()
4503 .capabilities();
4504 assert!(!xml.alignment && !xml.line_spacing);
4505 assert!(!xml.font_size && !xml.font_family && !xml.text_color);
4506 assert!(!xml.page_break);
4507 }
4508
4509 #[test]
4510 fn the_source_view_publishes_no_media() {
4511 // In the source view the `<video>` markup is the literal text the caret
4512 // is editing — laying a player over it would cover what's being typed.
4513 let d = doc("<video src=\"clip.mp4\" controls></video>\n");
4514 assert_eq!(d.view().media.len(), 1);
4515 assert!(
4516 d.toggle_view().media.is_empty(),
4517 "no placeholders in the source view"
4518 );
4519 }
4520
4521 /// **A foreign caller's offset must never panic.** Every offset entering
4522 /// leaf comes from a UI toolkit that counts in its own units — UIKit hands
4523 /// back UTF-16 positions — so an offset landing mid-character is a normal
4524 /// thing to be handed, not a bug in the caller. Slicing on it aborts the
4525 /// process across the FFI boundary, where there is no unwinding to catch.
4526 ///
4527 /// Reproduces a real crash: `byte index 1236 is not a char boundary; it is
4528 /// inside '…'`.
4529 #[test]
4530 fn an_offset_inside_a_multibyte_char_does_not_panic() {
4531 let d = doc(
4532 "# April 02, 2026\n\nAn interesting thing AI said to me:\n\n> a person… who journals\n",
4533 );
4534 d.toggle_view(); // to the raw source view, where offsets index bytes directly
4535 let src = d.source();
4536 // The interior byte of the `…` — exactly the shape of the crash.
4537 let mid = src.find('…').expect("the ellipsis is in the fixture") + 1;
4538 assert!(
4539 !src.is_char_boundary(mid),
4540 "the fixture must be mid-character"
4541 );
4542
4543 // Every entry point that takes a raw source offset.
4544 let _ = d.pos_for_offset(mid as u32);
4545 let _ = d.vertical_offset(mid as u32, true);
4546 let _ = d.vertical_offset(mid as u32, false);
4547 let _ = d.snap_offset(mid as u32);
4548 let _ = d.step_offset(mid as u32, 1);
4549 let _ = d.step_offset(mid as u32, -1);
4550 let _ = d.distance_offset(0, mid as u32);
4551 let _ = d.text_in_range(0, mid as u32);
4552 let _ = d.set_selection_offsets(mid as u32, mid as u32);
4553 // And the caret must not come to rest inside the character either — a
4554 // mid-character caret is a later panic waiting for the next edit.
4555 let _ = d.replace_range(mid as u32, mid as u32, "x".to_string());
4556 assert!(
4557 d.source().is_char_boundary(d.caret_offset() as usize),
4558 "the caret must sit on a character boundary"
4559 );
4560 }
4561
4562 #[test]
4563 fn cell_lines_split_on_the_break_glyph_carrying_each_lines_source_range() {
4564 use leaf_core::Glyph;
4565 let g = |ch, src| Glyph {
4566 ch,
4567 style: LStyle::default(),
4568 src,
4569 stop: true,
4570 };
4571 // "a" at 10, a `<br>` at 11..15 (the break glyph), "b" at 15; cell 10..16.
4572 let glyphs = [g('a', 10), g('\n', 11), g('b', 15)];
4573 let lines = cell_lines(&glyphs, 10, 16, 0, 0, &[], &CoreFaceTable::default());
4574 assert_eq!(lines.len(), 2, "one break makes two lines");
4575 assert_eq!(
4576 (lines[0].start, lines[0].end),
4577 (10, 11),
4578 "line 1 ends at the break"
4579 );
4580 assert_eq!(
4581 (lines[1].start, lines[1].end),
4582 (15, 16),
4583 "line 2 begins past it"
4584 );
4585 let text =
4586 |l: &TableCellLineView| l.runs.iter().map(|r| r.text.clone()).collect::<String>();
4587 assert_eq!(text(&lines[0]), "a");
4588 assert_eq!(text(&lines[1]), "b");
4589
4590 // A trailing break leaves an empty last line homed at the cell's end.
4591 let trailing = [g('a', 10), g('\n', 11)];
4592 let lines = cell_lines(&trailing, 10, 15, 0, 0, &[], &CoreFaceTable::default());
4593 assert_eq!(lines.len(), 2);
4594 assert!(lines[1].runs.is_empty());
4595 assert_eq!((lines[1].start, lines[1].end), (15, 15));
4596
4597 // No break: one line spanning the whole cell.
4598 let plain = [g('P', 10), g('e', 11)];
4599 let lines = cell_lines(&plain, 10, 12, 0, 0, &[], &CoreFaceTable::default());
4600 assert_eq!(lines.len(), 1);
4601 assert_eq!((lines[0].start, lines[0].end), (10, 12));
4602 }
4603
4604 fn row_text(v: &DocView, row: usize) -> String {
4605 v.rows[row].runs.iter().map(|r| r.text.clone()).collect()
4606 }
4607
4608 #[test]
4609 fn unwrapped_collapses_a_paragraph_to_one_row() {
4610 let d = doc("one two three four five six seven eight\n");
4611 let wrapped = d.set_width(10);
4612 let unwrapped = d.set_unwrapped();
4613 assert!(
4614 unwrapped.rows.len() < wrapped.rows.len(),
4615 "a narrow column wrap splits the paragraph; unwrapped keeps it whole"
4616 );
4617 assert!(
4618 (0..unwrapped.rows.len()).any(|i| row_text(&unwrapped, i).contains("eight")),
4619 "the whole paragraph, including its last word, sits on a single unwrapped row"
4620 );
4621 }
4622
4623 #[test]
4624 fn offsets_round_trip_when_unwrapped() {
4625 let d = doc("hello world\n");
4626 d.set_unwrapped();
4627 // offset -> (row, ch) -> offset is stable, so the pixel-wrapping frontend can
4628 // map between its visual lines and core's byte-offset caret model.
4629 let rc = d.pos_for_offset(6); // the 'w' of "world"
4630 assert_eq!(d.offset_for_pos(rc.row, rc.ch), 6);
4631 }
4632
4633 #[test]
4634 fn set_unwrapped_is_idempotent() {
4635 let d = doc("a paragraph of some length here\n");
4636 let first = d.set_unwrapped();
4637 let second = d.set_unwrapped();
4638 assert_eq!(first.rows.len(), second.rows.len());
4639 }
4640
4641 #[test]
4642 fn newline_on_last_list_item_before_a_blockquote_starts_a_new_item() {
4643 let src = "- one\n- two\n- three\n\n> quote\n";
4644 let d = doc(src);
4645 let off = (src.find("three").unwrap() + "three".len()) as u32; // end of "three" = 19
4646 d.set_selection_offsets(off, off);
4647 d.newline();
4648 let after = d.source();
4649 assert!(
4650 after.contains("- three\n- ") && after.contains("> quote"),
4651 "expected a new empty list item with the blockquote intact, got: {after:?}"
4652 );
4653 }
4654
4655 #[test]
4656 fn enter_on_an_empty_line_adds_one_newline_and_one_backspace_undoes_it() {
4657 let d = doc("hello\n");
4658 d.set_selection_offsets(5, 5);
4659 d.newline(); // paragraph "hello" → a paragraph break, caret on the empty line
4660 let after_para = d.source();
4661 let caret_para = d.caret_offset();
4662 d.newline(); // Enter on the empty line
4663 assert_eq!(
4664 d.source().len(),
4665 after_para.len() + 1,
4666 "an empty-line Enter adds a single newline, not another paragraph break"
4667 );
4668 d.backspace(); // a single Backspace restores the previous state
4669 assert_eq!(d.source(), after_para);
4670 assert_eq!(d.caret_offset(), caret_para);
4671 }
4672
4673 #[test]
4674 fn enter_in_a_nonempty_paragraph_still_opens_a_new_paragraph() {
4675 let d = doc("hello\n");
4676 d.set_selection_offsets(5, 5);
4677 let before = d.source().len();
4678 d.newline();
4679 assert_eq!(
4680 d.source().len(),
4681 before + 2,
4682 "a paragraph break is still \\n\\n"
4683 );
4684 }
4685
4686 #[test]
4687 fn link_destination_at_caret_reads_the_caret_link() {
4688 let d = doc("see [t](https://x.dev) ok\n");
4689 d.set_selection_offsets(5, 5); // caret on the link text "t"
4690 assert_eq!(
4691 d.link_destination_at_caret().as_deref(),
4692 Some("https://x.dev")
4693 );
4694 d.set_selection_offsets(0, 0); // caret on plain text
4695 assert_eq!(d.link_destination_at_caret(), None);
4696 }
4697
4698 #[test]
4699 fn image_destination_at_caret_reads_the_image_under_the_caret() {
4700 let d = doc(" after\n");
4701 d.set_selection_offsets(3, 3); // caret on the alt text
4702 assert_eq!(d.image_destination_at_caret().as_deref(), Some("cat.png"));
4703 d.set_selection_offsets(15, 15); // caret past the image, in the prose
4704 assert_eq!(d.image_destination_at_caret(), None);
4705 }
4706
4707 #[test]
4708 fn the_frame_carries_the_caret_link_so_a_toolbar_can_light_and_seed_from_it() {
4709 // The reason it rides `DocView` rather than being asked for: stepping the
4710 // caret out of the link changes no other chrome fact on the frame, so a
4711 // toolbar that only redraws on a *changed* state would keep a stale light.
4712 let d = doc("see [t](https://x.dev) ok\n");
4713 d.set_selection_offsets(5, 5);
4714 let inside = d.view();
4715 assert_eq!(inside.link.as_deref(), Some("https://x.dev"));
4716 assert_eq!(inside.heading, None);
4717 assert!(inside.active.is_empty());
4718
4719 d.set_selection_offsets(0, 0);
4720 let outside = d.view();
4721 assert_eq!(outside.link, None);
4722 // Nothing else the frame reports moved with it.
4723 assert_eq!(outside.heading, inside.heading);
4724 assert_eq!(outside.active, inside.active);
4725 }
4726
4727 #[test]
4728 fn insert_footnote_crosses_and_leaves_the_caret_in_the_new_note() {
4729 // The button's round trip through the boundary: both halves written, and
4730 // a caret offset a host can type into without asking anything else.
4731 let d = doc("A claim and more.\n");
4732 d.set_selection_offsets(7, 7); // just past "A claim"
4733 d.insert_footnote();
4734 assert!(
4735 d.source().starts_with("A claim[^1] and more."),
4736 "{:?}",
4737 d.source()
4738 );
4739 assert!(d.source().contains("[^1]:"), "{:?}", d.source());
4740
4741 let note = d.footnote_at(9).expect("the reference just written");
4742 assert_eq!(note.label, "1");
4743 assert_eq!(
4744 d.caret_offset(),
4745 note.offset.expect("an empty note is still a place")
4746 );
4747 // …and the way back out is the same one a reader uses.
4748 assert_eq!(
4749 d.footnote_definition_at_caret().expect("in the note").label,
4750 "1"
4751 );
4752 }
4753
4754 #[test]
4755 fn a_highlight_is_coloured_at_the_caret_and_the_frame_says_which() {
4756 // The whole crossing a colour palette makes: press the swatch, and the
4757 // frame that comes back names the colour so the swatch can light.
4758 let d = doc("a word b\n");
4759 d.set_selection_offsets(2, 6);
4760 d.toggle_mark();
4761 assert_eq!(d.source(), "a ==word== b\n");
4762
4763 d.set_selection_offsets(5, 5); // inside the highlight
4764 assert!(d.caret_in_mark());
4765 let v = d.set_mark_color(Some(MarkColor::Red));
4766 assert_eq!(d.source(), "a ==🔴 word== b\n");
4767 assert_eq!(v.mark_color, Some(MarkColor::Red));
4768
4769 // And the way back: no colour, still a highlight.
4770 let v = d.set_mark_color(None);
4771 assert_eq!(d.source(), "a ==word== b\n");
4772 assert_eq!(v.mark_color, None);
4773 assert!(d.caret_in_mark());
4774 }
4775
4776 #[test]
4777 fn one_press_highlights_and_colours_and_one_undo_takes_it_back() {
4778 // What a toolbar swatch calls. The fold is core's, and it is what makes
4779 // the press reversible in one step rather than leaving an uncoloured
4780 // highlight behind.
4781 let d = doc("a word b\n");
4782 d.set_selection_offsets(2, 6);
4783 let v = d.highlight(Some(MarkColor::Purple));
4784 assert_eq!(d.source(), "a ==\u{1F7E3} word== b\n");
4785 assert_eq!(v.mark_color, Some(MarkColor::Purple));
4786
4787 d.undo();
4788 assert_eq!(d.source(), "a word b\n");
4789 }
4790
4791 #[test]
4792 fn the_palette_has_two_gates_and_they_ask_different_questions() {
4793 // `mark_color` is the format's answer and `caret_in_mark` the caret's.
4794 // A djot document spells the highlight and no colour for it, so the two
4795 // disagree there — which is the case a toolbar gating on either one
4796 // alone gets wrong.
4797 assert!(doc("x\n").capabilities().mark_color);
4798 let dj = LeafDoc::new("a {=word=} b\n".to_string(), "djot".to_string()).unwrap();
4799 assert!(dj.capabilities().mark, "djot writes the highlight");
4800 assert!(!dj.capabilities().mark_color, "and no colour on it");
4801 dj.set_selection_offsets(5, 5);
4802 assert!(dj.caret_in_mark(), "the caret is in one all the same");
4803
4804 let d = doc("a word b\n");
4805 d.set_selection_offsets(3, 3);
4806 assert!(!d.caret_in_mark(), "no highlight to colour here");
4807 assert_eq!(d.set_mark_color(Some(MarkColor::Blue)).mark_color, None);
4808 assert_eq!(d.source(), "a word b\n", "and nothing written");
4809 }
4810
4811 #[test]
4812 fn capabilities_answer_for_footnotes_the_way_the_format_does() {
4813 assert!(
4814 doc("x\n").capabilities().footnote,
4815 "markdown spells the pair"
4816 );
4817 let html = LeafDoc::new("<p>x</p>\n".to_string(), "html".to_string()).unwrap();
4818 assert!(
4819 !html.capabilities().footnote,
4820 "html has no footnote of its own"
4821 );
4822 }
4823
4824 #[test]
4825 fn footnote_at_caret_crosses_with_its_note_and_its_offset() {
4826 let d = doc("A claim[^1] and more.\n\n[^1]: the note\n");
4827 d.set_selection_offsets(9, 9); // caret on the reference's label
4828 let f = d
4829 .footnote_at_caret()
4830 .expect("the caret stands in a reference");
4831 assert_eq!(f.label, "1");
4832 assert_eq!(f.text.as_deref(), Some("the note"));
4833 // The note's first word — a byte the caret can actually rest on. The
4834 // definition's `[^1]:` marker is decoration with no stop of its own.
4835 assert_eq!(f.offset, Some(29));
4836 assert_eq!(f.end, Some(37));
4837
4838 d.set_selection_offsets(0, 0); // caret on plain text
4839 assert!(d.footnote_at_caret().is_none());
4840 }
4841
4842 #[test]
4843 fn footnote_at_crosses_for_an_offset_without_moving_the_caret() {
4844 // What a hover needs: the note under the pointer, and the caret left
4845 // exactly where the reader put it.
4846 let d = doc("A claim[^1] and more.\n\n[^1]: the note\n");
4847 d.set_selection_offsets(0, 0);
4848 let f = d.footnote_at(9).expect("offset 9 stands in the reference");
4849 assert_eq!(f.label, "1");
4850 assert_eq!(f.text.as_deref(), Some("the note"));
4851 assert_eq!(d.caret_offset(), 0, "asking must not move the caret");
4852 assert!(d.footnote_at(2).is_none(), "offset 2 is prose");
4853 }
4854
4855 #[test]
4856 fn footnote_definition_at_caret_crosses_with_the_way_back() {
4857 let d = doc("A claim[^1] and more.\n\n[^1]: the note\n");
4858 d.set_selection_offsets(30, 30); // caret inside the note's body
4859 let f = d
4860 .footnote_definition_at_caret()
4861 .expect("the caret stands in a definition");
4862 assert_eq!(f.label, "1");
4863 assert_eq!(f.offset, Some(9), "the reference's label");
4864
4865 // Disjoint from the reference query, which is what lets one gesture mean
4866 // "down" up top and "back up" down here.
4867 d.set_selection_offsets(9, 9);
4868 assert!(d.footnote_definition_at_caret().is_none());
4869 assert!(d.footnote_at_caret().is_some());
4870 }
4871
4872 /// The contract a peek is built on: a note's offsets map to rows whose runs
4873 /// are the note *rendered* — emphasis as an italic run, `` `code` `` as a
4874 /// code run, a link as a link run — so a frontend draws it the way the
4875 /// document draws it instead of showing the reader raw asterisks.
4876 #[test]
4877 fn a_notes_offsets_map_to_its_rendered_rows() {
4878 let src = "Claim[^a].\n\n[^a]: see *emphasis* and `code` and [a link](https://x.dev).\n";
4879 let d = doc(src);
4880 let view = d.set_unwrapped();
4881 d.set_selection_offsets(6, 6); // the reference's label
4882
4883 let f = d.footnote_at_caret().expect("a reference");
4884 let start = d.pos_for_offset(f.offset.expect("a note"));
4885 let end = d.pos_for_offset(f.end.expect("a note") - 1);
4886 assert_eq!(
4887 start.row, end.row,
4888 "a one-paragraph note is one unwrapped row"
4889 );
4890
4891 let row = &view.rows[start.row as usize];
4892 let runs: Vec<(&str, &str, bool)> = row
4893 .runs
4894 .iter()
4895 .map(|r| (r.role.as_str(), r.text.as_str(), r.italic))
4896 .collect();
4897 assert!(runs.contains(&("body", "emphasis", true)), "got {runs:?}");
4898 assert!(
4899 runs.iter()
4900 .any(|(role, text, _)| *role == "code" && *text == "code"),
4901 "got {runs:?}"
4902 );
4903 assert!(
4904 runs.iter()
4905 .any(|(role, text, _)| *role == "link" && *text == "a link"),
4906 "got {runs:?}"
4907 );
4908
4909 // The rendered row carries no markup characters at all — which is the
4910 // whole point, and what `text` (source bytes) deliberately still does.
4911 let rendered: String = row.runs.iter().map(|r| r.text.as_str()).collect();
4912 assert!(
4913 !rendered.contains('*') && !rendered.contains('`'),
4914 "got {rendered:?}"
4915 );
4916 assert!(
4917 f.text.as_deref().unwrap().contains('*'),
4918 "the source answer keeps them"
4919 );
4920
4921 // `ch` is where the body starts within the row — past the `[a] ` marker,
4922 // so a frontend that wants the note without its label can slice there.
4923 assert_eq!(row.runs[0].role, "list");
4924 assert_eq!(start.ch as usize, row.runs[0].text.chars().count());
4925
4926 // And each run says where it came from, which is how a link run drawn in
4927 // a popover learns where it points. `Run` otherwise says how a span
4928 // looks, never what it means.
4929 let link = row
4930 .runs
4931 .iter()
4932 .find(|r| r.role == "link")
4933 .expect("a link run");
4934 assert_eq!(
4935 d.link_destination_at(link.src).as_deref(),
4936 Some("https://x.dev"),
4937 "the run at {} is the link",
4938 link.src
4939 );
4940 }
4941
4942 /// The peek bug, in the shape it was actually found in: three notes, each
4943 /// ending in a link, which is what a real citation block looks like.
4944 ///
4945 /// `a_notes_offsets_map_to_its_rendered_rows` above uses a note ending in a
4946 /// visible `.`, so its last byte has a row of its own and `end - 1` reads
4947 /// right. Take the full stop away — end the note *with* the link, as a
4948 /// citation does — and the last byte falls inside the hidden destination,
4949 /// where `pos_for_offset` snaps forward onto the next note's row. Hovering
4950 /// `[^2]` peeked notes 2 *and* 3.
4951 #[test]
4952 fn a_note_ending_in_a_link_covers_its_own_row_and_no_other() {
4953 let src = "A[^1] B[^2] C[^3].\n\n\
4954 [^1]: https://en.wikipedia.org/wiki/Moravec%27s_paradox\n\n\
4955 [^2]: [\"How to Get Startup Ideas,\" Nov 2012](https://www.paulgraham.com/startupideas.html)\n\n\
4956 [^3]: [Alma 37:46](https://www.churchofjesuschrist.org/study/scriptures/bofm/alma/37?lang=eng&id=p46#p46)\n";
4957 let d = doc(src);
4958 let view = d.set_unwrapped();
4959
4960 // The caret in the [^2] reference, exactly as a hover resolves it.
4961 let off2 = src.find("[^2] C").unwrap() as u32 + 2;
4962 d.set_selection_offsets(off2, off2);
4963 let f = d.footnote_at_caret().expect("a reference");
4964 let (start, end) = (f.offset.expect("a note"), f.end.expect("a note"));
4965
4966 let span = d.row_range_for(start, end);
4967 assert_eq!(span.first, span.last, "one note is one unwrapped row");
4968
4969 // And what it draws is note 2 alone — the assertion the popover failed.
4970 let drawn: String = view.rows[span.first as usize]
4971 .runs
4972 .iter()
4973 .map(|r| r.text.as_str())
4974 .collect();
4975 assert!(drawn.contains("How to Get Startup Ideas"), "got {drawn:?}");
4976 assert!(
4977 !drawn.contains("Alma"),
4978 "note 3 leaked into the peek: {drawn:?}"
4979 );
4980
4981 // The old arithmetic, pinned as still wrong so nobody quietly restores
4982 // it: this is the failure `row_range_for` exists instead of.
4983 assert_ne!(
4984 d.pos_for_offset(end - 1).row,
4985 span.last,
4986 "the forward snap still leaves the note's row — that is the point",
4987 );
4988
4989 // Note 1 is a bare autolink, whose visible text *is* its URL, so it was
4990 // never affected and must not change.
4991 let off1 = src.find("[^1] B").unwrap() as u32 + 2;
4992 d.set_selection_offsets(off1, off1);
4993 let f1 = d.footnote_at_caret().expect("a reference");
4994 let one = d.row_range_for(f1.offset.unwrap(), f1.end.unwrap());
4995 assert_eq!(one.first, one.last);
4996 assert_ne!(one.first, span.first, "and it is a different note");
4997 }
4998
4999 /// A run's `src` is a byte offset core handed over, not something a frontend
5000 /// counted its way to — so multi-byte prose ahead of a link inside a note
5001 /// can't slide it.
5002 ///
5003 /// The offset is a *byte* offset while the run's text is characters and the
5004 /// row's columns are display cells; `src` is the only one of the three a
5005 /// frontend can use without converting between the other two.
5006 #[test]
5007 fn a_runs_source_offset_survives_multibyte_prose_ahead_of_it() {
5008 let src = "Claim[^a].\n\n[^a]: 日記 café [a link](https://x.dev).\n";
5009 let d = doc(src);
5010 let view = d.set_unwrapped();
5011 d.set_selection_offsets(6, 6);
5012
5013 let f = d.footnote_at_caret().expect("a reference");
5014 let start = d.pos_for_offset(f.offset.expect("a note"));
5015 let row = &view.rows[start.row as usize];
5016 let link = row
5017 .runs
5018 .iter()
5019 .find(|r| r.role == "link")
5020 .expect("a link run");
5021
5022 assert_eq!(
5023 d.link_destination_at(link.src).as_deref(),
5024 Some("https://x.dev")
5025 );
5026 assert_eq!(
5027 &src[link.src as usize..][.."a link".len()],
5028 "a link",
5029 "and it is a byte offset, not a character or column index"
5030 );
5031 // Which the character count is not: `日記 café ` is 9 characters and 13
5032 // bytes, so anything derived from the run text lands in the wrong place.
5033 let counted: usize = row
5034 .runs
5035 .iter()
5036 .take_while(|r| r.role != "link")
5037 .map(|r| r.text.chars().count())
5038 .sum();
5039 assert_ne!(counted, link.src as usize);
5040 }
5041
5042 /// The round trip through the API a frontend actually calls — which places
5043 /// carets, and so snaps them to real stops. Offsets that named the `[^`
5044 /// markers passed every test that assigned the caret directly and still
5045 /// dumped the reader in the paragraph above the note.
5046 #[test]
5047 fn following_a_footnote_and_coming_back_lands_on_real_caret_stops() {
5048 let d = doc("A claim[^1] and more.\n\n[^1]: the note\n");
5049 d.set_selection_offsets(9, 9);
5050
5051 let down = d
5052 .footnote_at_caret()
5053 .expect("a reference")
5054 .offset
5055 .expect("a note");
5056 d.set_selection_offsets(down, down);
5057 assert_eq!(
5058 d.caret_offset(),
5059 down,
5060 "the note is somewhere the caret fits"
5061 );
5062
5063 let up = d
5064 .footnote_definition_at_caret()
5065 .expect("arrived inside the definition")
5066 .offset
5067 .expect("a reference to return to");
5068 d.set_selection_offsets(up, up);
5069 assert_eq!(d.caret_offset(), up, "and so is the reference");
5070 assert_eq!(
5071 d.footnote_at_caret().expect("back on the reference").label,
5072 "1"
5073 );
5074 }
5075
5076 #[test]
5077 fn a_footnote_reference_crosses_the_ffi_raised() {
5078 // The whole point of the `sup` flag: without it a reference reaches
5079 // Swift as a run indistinguishable from a hyperlink's, which is why it
5080 // used to draw at body size.
5081 let d = doc("A claim[^1] and more.\n");
5082 let view = d.view();
5083 let runs: Vec<&Run> = view.rows.iter().flat_map(|r| &r.runs).collect();
5084 let chip = runs
5085 .iter()
5086 .find(|r| r.text.contains('1'))
5087 .expect("the reference's chip");
5088 assert!(chip.sup, "the reference should cross raised");
5089 assert!(!chip.sub);
5090 assert_eq!(
5091 chip.role, "link",
5092 "and still carrying the role every frontend paints"
5093 );
5094 // The prose it interrupts is a run of its own, on the normal baseline —
5095 // which is what proves the flag splits runs rather than bleeding.
5096 let prose = runs
5097 .iter()
5098 .find(|r| r.text.contains("claim"))
5099 .expect("the prose");
5100 assert!(!prose.sup && !prose.sub);
5101 }
5102
5103 #[test]
5104 fn utf16_indices_round_trip_through_the_visible_text_in_both_views() {
5105 // Hidden delimiters, an emoji outside the BMP, and a block gap: the
5106 // three ways an `NSRange` into the visible text and a source byte
5107 // offset part company.
5108 let d = doc("a **b\u{1F600}** c\n\nd\n");
5109 let end = d.doc_end_offset();
5110 let text = d.text_in_range(0, end);
5111 assert_eq!(text, "a b\u{1F600} c\nd");
5112 let total = text.encode_utf16().count() as u32;
5113 assert_eq!(d.utf16_index_for_offset(end), total);
5114 assert_eq!(d.offset_for_utf16_index(total), end);
5115 // Walk every stop: index it, and come back to the same stop.
5116 let mut off = 0u32;
5117 loop {
5118 let idx = d.utf16_index_for_offset(off);
5119 assert_eq!(
5120 d.offset_for_utf16_index(idx),
5121 off,
5122 "stop {off} via index {idx}"
5123 );
5124 let next = d.step_offset(off, 1);
5125 if next == off {
5126 break;
5127 }
5128 off = next;
5129 }
5130 // The 'c' comes after the hidden `**` and the two-unit emoji.
5131 let c = "a **b\u{1F600}** c".find(" c").unwrap() as u32 + 1;
5132 assert_eq!(
5133 d.utf16_index_for_offset(c),
5134 "a b\u{1F600} ".encode_utf16().count() as u32
5135 );
5136
5137 // Source view: the text is the raw source, so the index is the plain
5138 // UTF-16 count of the bytes before the offset — delimiters included.
5139 d.toggle_view();
5140 assert_eq!(
5141 d.text_in_range(0, d.doc_end_offset()),
5142 "a **b\u{1F600}** c\n\nd\n"
5143 );
5144 assert_eq!(
5145 d.utf16_index_for_offset(c),
5146 "a **b\u{1F600}** ".encode_utf16().count() as u32
5147 );
5148 assert_eq!(d.offset_for_utf16_index(d.utf16_index_for_offset(c)), c);
5149 // The batch form is the one call, many times, in the order asked.
5150 assert_eq!(
5151 d.utf16_indices_for_offsets(vec![c, 0, end]),
5152 vec![
5153 d.utf16_index_for_offset(c),
5154 d.utf16_index_for_offset(0),
5155 d.utf16_index_for_offset(end)
5156 ]
5157 );
5158 // Inside the emoji's surrogate pair resolves to the emoji itself.
5159 let emoji = "a **b".len() as u32;
5160 assert_eq!(
5161 d.offset_for_utf16_index(d.utf16_index_for_offset(emoji) + 1),
5162 emoji
5163 );
5164 }
5165
5166 #[test]
5167 fn text_in_range_hides_delimiters_like_the_screen_does() {
5168 // "a **bold** c\n": 0:'a' 1:' ' 2:'*' 3:'*' 4:'b' 5:'o' 6:'l' 7:'d'
5169 // 8:'*' 9:'*' 10:' ' 11:'c' 12:'\n'. Bytes 8..10 are the closing `**`
5170 // — hidden, no glyph — and bytes 2..4 the opening `**`, likewise
5171 // hidden. `caret_steps_over_hidden_delimiters` in leaf-core already
5172 // pins that one Right from 7 (just past the 'd') lands on 10 (the
5173 // space before 'c'), skipping 8/9 entirely — so the *visible* text
5174 // transiting [7, 10) is exactly "d": the closing `**` contributes
5175 // nothing, matching what's drawn on screen.
5176 let d = doc("a **bold** c\n");
5177 assert_eq!(d.text_in_range(7, 10), "d");
5178 assert_eq!(
5179 d.text_in_range(7, 10).chars().count() as i32,
5180 d.distance_offset(7, 10),
5181 "text(in:).count() must equal offset(from:to:) — the UITextInput invariant this bug broke"
5182 );
5183
5184 // Plain text with no hidden delimiter in range: unchanged, still the
5185 // raw slice, proving the fix doesn't regress the common case.
5186 assert_eq!(d.text_in_range(0, 1), "a");
5187 assert_eq!(d.text_in_range(11, 12), "c");
5188 assert_eq!(
5189 d.text_in_range(0, 1).chars().count() as i32,
5190 d.distance_offset(0, 1)
5191 );
5192 }
5193
5194 #[test]
5195 fn text_in_range_matches_distance_offset_across_marked_up_and_plain_spans() {
5196 // The general invariant, straddling bold/italic/code spans and not:
5197 // for any pair of offsets, the visible text `text_in_range` returns
5198 // must have exactly as many `chars()` as `distance_offset` reports
5199 // stops between them — otherwise iOS's word tokenizer (which fetches
5200 // a text window, finds a boundary by indexing into *that string*, and
5201 // converts the index back to a position via `position(from:offset:)`)
5202 // resolves the boundary at the wrong offset.
5203 let d = doc("a **bold** _em_ and `code` here\n");
5204 let len = d.source().len() as u32;
5205 let mut pairs = Vec::new();
5206 let mut a = 0u32;
5207 while a < len {
5208 let mut b = a + 1;
5209 while b <= len {
5210 pairs.push((a, b));
5211 b += 3; // sample rather than an O(n^2) sweep
5212 }
5213 a += 1;
5214 }
5215 for (a, b) in pairs {
5216 let text = d.text_in_range(a, b);
5217 let dist = d.distance_offset(a, b).abs();
5218 assert_eq!(
5219 text.chars().count() as i32,
5220 dist,
5221 "text_in_range({a}, {b}) = {text:?} has {} chars, but distance_offset says {dist}",
5222 text.chars().count()
5223 );
5224 }
5225 }
5226
5227 #[test]
5228 fn text_in_range_separates_paragraphs_so_words_dont_merge_across_the_gap() {
5229 // Regression: double-tapping the last word on a line immediately
5230 // followed by a paragraph break selected past the break into the
5231 // next paragraph — and kept compounding across further trivial
5232 // paragraphs in a row — because `text_in_range` returned the two
5233 // paragraphs' text with nothing between them: "hello" then "hello"
5234 // read back as one merged "hellohello" run of letters, no different
5235 // from the raw source concatenation, and iOS's word tokenizer duly
5236 // selected the whole run as a single word.
5237 let d = doc("hello\n\nhello\n\nhello\n");
5238 let src = d.source();
5239 assert_eq!(
5240 src.find("hello").unwrap(),
5241 0,
5242 "paragraph 1 at the very start"
5243 );
5244 let p2 = src[5..].find("hello").unwrap() + 5; // 7: paragraph 2's "hello"
5245
5246 // A window straddling the tail of paragraph 1 ("lo") and the head of
5247 // paragraph 2 ("he").
5248 let text = d.text_in_range(3, p2 as u32 + 2);
5249 assert_ne!(
5250 text, "lohe",
5251 "the two paragraphs' words must not read as merged"
5252 );
5253 assert!(
5254 text.chars().any(|c| !c.is_alphanumeric()),
5255 "a non-letter must separate the two paragraphs' words: got {text:?}"
5256 );
5257 assert_eq!(
5258 text, "lo\nhe",
5259 "exactly one separator opens the second paragraph's head"
5260 );
5261
5262 // A window that is nothing but paragraph 1's end stop (no glyph in
5263 // it: it starts exactly at the end of paragraph 1's own last row)
5264 // is that stop's one character, the break itself.
5265 let gap_only = d.text_in_range(5, p2 as u32);
5266 assert_eq!(gap_only, "\n");
5267
5268 // The break is the end stop's own character, not one inserted beside
5269 // it, so the equality the test above asserts holds across a
5270 // paragraph boundary too — the tokenizer's `position(from:offset:)`
5271 // walk lands exactly where the text it was handed put a boundary.
5272 for (a, b) in [(0u32, src.len() as u32), (3, p2 as u32 + 2), (5, p2 as u32)] {
5273 let text = d.text_in_range(a, b);
5274 let dist = d.distance_offset(a, b);
5275 assert_eq!(
5276 text.chars().count() as i32,
5277 dist,
5278 "text_in_range({a}, {b}) = {text:?} ({} chars) vs distance_offset {dist}",
5279 text.chars().count()
5280 );
5281 }
5282
5283 // Caret motion itself is untouched by any of this: from the very end
5284 // of paragraph 1's row, a paragraph gap still costs exactly one
5285 // Right press to reach the start of paragraph 2 — matching
5286 // leaf-core's `the_caret_skips_the_gap_between_two_paragraphs`.
5287 assert_eq!(
5288 d.distance_offset(5, p2 as u32),
5289 1,
5290 "one Right crosses the whole gap"
5291 );
5292 }
5293
5294 #[test]
5295 fn text_in_range_ends_a_line_at_each_table_cell_and_splits_none_inside() {
5296 // A cell's end reads as a line end — the tokenizer keeps `Status` and
5297 // `Tables` apart, and a tap past `Feature`'s last letter has no space
5298 // to step over into `Status` — and a table's rule rows, decoration
5299 // *inside* the one block, put nothing inside a cell (`Feature` once
5300 // came back as `F\neature`). The table's trailing stop — the caret
5301 // home past the last cell — draws no glyph either, so the document's
5302 // end is one more line end, the blank line under the table where the
5303 // caret past it stands.
5304 let d = doc("| Feature | Status |\n| --- | --- |\n| Tables | editable |\n");
5305 assert_eq!(
5306 d.text_in_range(0, d.doc_end_offset()),
5307 "Feature\nStatus\nTables\neditable\n"
5308 );
5309 }
5310
5311 #[test]
5312 fn a_coloured_highlight_rides_out_as_a_name_beside_the_mark_role() {
5313 // The host draws the wash, so the colour has to reach it. It rides
5314 // `mark_color` rather than folding into `role` (`"mark-red"`) on
5315 // purpose: a renderer that only knows `"mark"` — every version of the
5316 // Swift one before this field existed — still draws the highlight.
5317 let d = doc("a ==\u{1F534} red== and ==plain== b\n");
5318 let runs = &d.view().rows[0].runs;
5319 let marks: Vec<(&str, Option<&str>)> = runs
5320 .iter()
5321 .filter(|r| r.role == "mark")
5322 .map(|r| (r.text.as_str(), r.mark_color.as_deref()))
5323 .collect();
5324 assert_eq!(marks, [("red", Some("red")), ("plain", None)]);
5325 assert!(
5326 runs.iter()
5327 .all(|r| r.role == "mark" || r.mark_color.is_none()),
5328 "nothing but a mark names a colour"
5329 );
5330 }
5331
5332 #[test]
5333 fn a_highlight_splits_runs_on_its_own_bytes_and_carries_its_id() {
5334 let d = doc("one two three\n");
5335 let view = d.set_highlights(vec![Highlight {
5336 start: 4,
5337 end: 7,
5338 id: "remark-1".into(),
5339 color: Some("#ffe066".into()),
5340 marker: Some("text.bubble".into()),
5341 }]);
5342 let row = &view.rows[0];
5343 let texts: Vec<(&str, Option<&str>)> = row
5344 .runs
5345 .iter()
5346 .map(|r| (r.text.as_str(), r.hl.as_deref()))
5347 .collect();
5348 assert_eq!(
5349 texts,
5350 [("one ", None), ("two", Some("remark-1")), (" three", None)],
5351 "the wash begins and ends exactly on the highlight's bytes"
5352 );
5353 assert_eq!(row.runs[1].hl_color.as_deref(), Some("#ffe066"));
5354 assert_eq!(d.highlight_at(5).as_deref(), Some("remark-1"));
5355 assert_eq!(
5356 d.highlights()
5357 .first()
5358 .and_then(|h| h.marker.clone())
5359 .as_deref(),
5360 Some("text.bubble"),
5361 "the marker rides back out for the frontend's margin pass"
5362 );
5363 assert_eq!(d.highlight_at(7), None, "end is exclusive");
5364 // A replace with nothing clears the wash.
5365 let view = d.set_highlights(Vec::new());
5366 assert!(view.rows[0].runs.iter().all(|r| r.hl.is_none()));
5367 }
5368
5369 /// The whole point of the record is that the numbers cross the boundary,
5370 /// so this checks the ones a host would show — and that a selection
5371 /// narrows them and no selection answers nothing at all.
5372 #[test]
5373 fn counts_cross_the_boundary_whole_and_selected() {
5374 let d = doc("a **bold** word\n\n- item\n");
5375 let c = d.counts();
5376 assert_eq!(
5377 (
5378 c.words,
5379 c.characters,
5380 c.characters_without_spaces,
5381 c.paragraphs
5382 ),
5383 (4, 15, 13, 2)
5384 );
5385
5386 assert!(d.selection_counts().is_none(), "no selection, no counts");
5387 d.select_range(0, 10);
5388 let s = d.selection_counts().expect("a selection");
5389 assert_eq!((s.words, s.characters, s.paragraphs), (2, 6, 1));
5390 }
5391
5392 #[test]
5393 fn an_inline_formula_is_a_math_run_paired_with_its_view_by_src() {
5394 // Off until the renderer says it paints in a line: the TeX as code.
5395 let d = doc("say $x+y$ here\n\nnext\n");
5396 d.set_selection_offsets(16, 16);
5397 let v = d.view();
5398 assert!(v.math.is_empty());
5399 assert!(
5400 v.rows[0]
5401 .runs
5402 .iter()
5403 .any(|r| r.role == "code" && r.text == "x+y")
5404 );
5405 // On: one `math` run, one character, and a view whose `src` is its.
5406 let v = d.set_inline_pictures(true);
5407 assert_eq!(v.math.len(), 1);
5408 let m = &v.math[0];
5409 assert!(m.inline);
5410 assert_eq!((m.start_row, m.end_row), (0, 1));
5411 assert_eq!(m.tex, "x+y");
5412 assert!(!m.display);
5413 assert_eq!(m.src, 4);
5414 let run = v.rows[0]
5415 .runs
5416 .iter()
5417 .find(|r| r.role == "math")
5418 .expect("a math run");
5419 assert_eq!(run.text, "∑");
5420 assert_eq!(run.src, m.src);
5421 }
5422
5423 #[test]
5424 fn a_formula_on_the_caret_line_is_its_tex_and_no_view() {
5425 let d = doc("say $x+y$ here\n\nnext\n");
5426 d.set_inline_pictures(true);
5427 let v = d.set_selection_offsets(0, 0);
5428 assert!(v.math.is_empty());
5429 let text: String = v.rows[0].runs.iter().map(|r| r.text.as_str()).collect();
5430 assert_eq!(text, "say $x+y$ here");
5431 assert!(
5432 v.rows[0]
5433 .runs
5434 .iter()
5435 .any(|r| r.role == "delimiter" && r.text == "$")
5436 );
5437 }
5438
5439 #[test]
5440 fn a_display_block_is_rows_to_lay_over_and_measured_heights_grow_them() {
5441 let d = doc("$$\n\\int_0^1 x\n$$\n\nend\n");
5442 let v = d.set_selection_offsets(17, 17);
5443 assert_eq!(v.math.len(), 1);
5444 let m = &v.math[0];
5445 assert!(!m.inline);
5446 assert!(m.display);
5447 assert_eq!((m.start_row, m.end_row), (0, 1));
5448 assert_eq!(m.tex, "\n\\int_0^1 x\n");
5449 assert_eq!(m.src, 0);
5450 let after = d.set_math_rows(vec![MathHeight {
5451 tex: m.tex.clone(),
5452 rows: 4,
5453 }]);
5454 assert_eq!((after.math[0].start_row, after.math[0].end_row), (0, 4));
5455 }
5456
5457 #[test]
5458 fn typeset_math_hands_back_a_picture_and_names_a_fault() {
5459 let p = typeset_math("E = mc^2".into(), false, 16.0, 0, 0, 0, 255).unwrap();
5460 assert!(p.svg.starts_with("<svg"));
5461 assert!(p.width > 3.0);
5462 assert!(p.height > 0.5);
5463 assert_eq!(p.depth, 0.0);
5464 match typeset_math("\\frac{".into(), true, 16.0, 0, 0, 0, 255) {
5465 Err(LeafError::Math { message, position }) => {
5466 assert!(!message.is_empty());
5467 assert!(position.is_some());
5468 }
5469 Err(other) => panic!("expected a math error, got {other}"),
5470 Ok(_) => panic!("expected a math error, got a picture"),
5471 }
5472 }
5473
5474 // ── frames as changes ────────────────────────────────────────────────
5475
5476 /// Apply `frame`, a change, to `rows`, the frame it names as its basis —
5477 /// what a frontend does with one; see [`DocView::rows`].
5478 fn apply(rows: &mut Vec<Row>, frame: &DocView) {
5479 let d = leaf_core::RowDelta {
5480 start: frame.row_start as usize,
5481 replaced: frame.replaced as usize,
5482 len: frame.rows.len(),
5483 src_shift: frame.src_shift as i64,
5484 };
5485 leaf_core::apply_row_delta(rows, d, &frame.rows, shift_row);
5486 assert_eq!(rows.len(), frame.row_count as usize);
5487 }
5488
5489 /// A long document with the blocks a design document has, deterministic
5490 /// so a failure reproduces: prose with inline markup, a list, a table,
5491 /// a fence, a formula.
5492 fn long_document() -> String {
5493 use std::fmt::Write as _;
5494 let words = [
5495 "the",
5496 "document",
5497 "carries",
5498 "its",
5499 "own",
5500 "identity",
5501 "and",
5502 "a",
5503 "reference",
5504 "resolves",
5505 "against",
5506 "whatever",
5507 "archive",
5508 "holds",
5509 "it",
5510 ];
5511 let mut out = String::from("# A long document\n\n");
5512 for para in 0..60usize {
5513 if para % 9 == 0 {
5514 let _ = writeln!(out, "## Section {}\n", para / 9 + 1);
5515 }
5516 match para % 11 {
5517 7 => {
5518 for i in 0..4 {
5519 let _ = writeln!(out, "- item {i} with `code_{i}` and *emphasis*");
5520 }
5521 out.push('\n');
5522 }
5523 9 => {
5524 out.push_str("| key | value |\n|---|---|\n| `k` | v |\n| k2 | v2 |\n\n");
5525 }
5526 10 => {
5527 out.push_str("```rust\nfn example() -> u32 {\n 42\n}\n```\n\nA formula $x^2$ inline.\n\n");
5528 }
5529 _ => {
5530 let n = 12 + (para * 7) % 30;
5531 for i in 0..n {
5532 let w = words[(para * 3 + i * 5) % words.len()];
5533 match (para + i) % 13 {
5534 0 => {
5535 let _ = write!(out, "`{w}` ");
5536 }
5537 4 => {
5538 let _ = write!(out, "**{w}** ");
5539 }
5540 8 => {
5541 let _ = write!(out, "[{w}](https://example.org/{para}) ");
5542 }
5543 _ => {
5544 out.push_str(w);
5545 out.push(' ');
5546 }
5547 }
5548 }
5549 out.push_str("\n\n");
5550 }
5551 }
5552 }
5553 out
5554 }
5555
5556 #[test]
5557 fn a_hundred_frames_applied_in_order_are_the_whole_view_after_each() {
5558 // Two documents driven identically: one answering with changes, one
5559 // with whole frames. After every gesture the changes applied so far
5560 // must be the whole frame the other document is at — and, at the
5561 // end, the whole frame the first one gives when asked outright.
5562 let src = long_document();
5563 let a = doc(&src);
5564 let b = doc(&src);
5565 a.set_incremental_frames(true);
5566 let first = a.set_unwrapped();
5567 let _ = b.set_unwrapped();
5568 assert_eq!(first.basis, 0, "the first frame after opting in is whole");
5569 let mut rows = first.rows.clone();
5570 assert!(rows.len() > 100, "{} rows", rows.len());
5571 let mut last_frame = first.frame;
5572
5573 // A small linear congruential generator: enough to spread the
5574 // gestures over the document, and reproducible.
5575 let mut seed: u64 = 0x2545_F491_4F6C_DD1D;
5576 let mut next = move |n: u64| {
5577 seed = seed
5578 .wrapping_mul(6364136223846793005)
5579 .wrapping_add(1442695040888963407);
5580 (seed >> 33) % n
5581 };
5582 for step in 0..100 {
5583 let extend = next(4) == 0;
5584 let (fa, fb) = match next(14) {
5585 0 => (a.insert("x".into()), b.insert("x".into())),
5586 1 => (a.insert("word ".into()), b.insert("word ".into())),
5587 2 => (a.backspace(), b.backspace()),
5588 3 => (a.newline(), b.newline()),
5589 4 => (a.move_left(extend), b.move_left(extend)),
5590 5 => (a.move_right(extend), b.move_right(extend)),
5591 6 => (a.move_up(extend), b.move_up(extend)),
5592 7 => (a.move_down(extend), b.move_down(extend)),
5593 8 => {
5594 let row = next(rows.len() as u64) as u32;
5595 (a.click_ch(row, 2, extend), b.click_ch(row, 2, extend))
5596 }
5597 9 => (a.toggle_bold(), b.toggle_bold()),
5598 10 => (a.undo(), b.undo()),
5599 11 => (a.move_word_right(extend), b.move_word_right(extend)),
5600 12 => (a.delete_word_back(), b.delete_word_back()),
5601 _ => (a.set_heading(2), b.set_heading(2)),
5602 };
5603 assert_eq!(
5604 fa.basis, last_frame,
5605 "step {step}: a change names the frame before"
5606 );
5607 assert_eq!(fa.frame, last_frame + 1);
5608 last_frame = fa.frame;
5609 apply(&mut rows, &fa);
5610 assert!(
5611 rows == fb.rows,
5612 "step {step}: the applied change is not the whole frame"
5613 );
5614 assert_eq!(fa.row_count as usize, fb.rows.len());
5615 assert_eq!((fa.caret_row, fa.caret_ch), (fb.caret_row, fb.caret_ch));
5616 assert_eq!(
5617 (fa.tables.len(), fa.math.len()),
5618 (fb.tables.len(), fb.math.len())
5619 );
5620 }
5621 let whole = a.view();
5622 assert_eq!(whole.basis, 0);
5623 assert!(whole.rows == rows, "view() is the frame the changes built");
5624 // And the next change is against it.
5625 let after = a.move_right(false);
5626 assert_eq!(after.basis, whole.frame);
5627 assert_eq!(a.source(), b.source());
5628 }
5629
5630 #[test]
5631 fn a_caret_move_lifts_no_row_and_a_keystroke_lifts_its_own() {
5632 let d = doc(&long_document());
5633 d.set_incremental_frames(true);
5634 let first = d.set_unwrapped();
5635 let n = first.rows.len();
5636 let mid = (n / 2) as u32;
5637 let moved = d.click_ch(mid, 3, false);
5638 assert_eq!(moved.rows.len(), 0, "a click changed no row");
5639 assert_eq!(moved.replaced, 0);
5640 assert_eq!(moved.row_count as usize, n);
5641 let typed = d.insert("x".into());
5642 assert_eq!(typed.rows.len(), 1, "a keystroke changed its row");
5643 assert_eq!(typed.replaced, 1);
5644 assert_eq!(typed.row_start, moved.caret_row);
5645 assert_eq!(typed.src_shift, 1, "every row below moved one byte");
5646 assert_eq!(typed.row_count as usize, n);
5647 let split = d.newline();
5648 assert_eq!(
5649 split.row_count as usize,
5650 n + 2,
5651 "a paragraph split adds a row and a gap"
5652 );
5653 assert!(split.rows.len() <= 4, "{} rows lifted", split.rows.len());
5654 // A selection over two rows changes those two, and the gap between.
5655 let dragged = d.click_ch(mid - 4, 0, true);
5656 assert!(
5657 dragged.rows.len() <= 6,
5658 "{} rows lifted",
5659 dragged.rows.len()
5660 );
5661 assert_eq!(dragged.src_shift, 0);
5662 }
5663
5664 #[test]
5665 fn frames_are_whole_unless_asked_for_and_view_is_whole_regardless() {
5666 let d = doc("one\n\ntwo\n\nthree\n");
5667 let v = d.set_unwrapped();
5668 assert_eq!((v.frame, v.basis, v.row_start, v.replaced), (1, 0, 0, 0));
5669 assert_eq!(v.row_count as usize, v.rows.len());
5670 let v = d.move_right(false);
5671 assert_eq!(
5672 (v.frame, v.basis),
5673 (2, 0),
5674 "not opted in: whole, and numbered"
5675 );
5676 assert_eq!(v.rows.len(), 5);
5677 d.set_incremental_frames(true);
5678 let v = d.move_right(false);
5679 assert_eq!(v.basis, 0, "the first frame after opting in is whole");
5680 assert_eq!(v.rows.len(), 5);
5681 let v = d.move_right(false);
5682 assert_eq!(v.basis, 3);
5683 assert!(v.rows.is_empty());
5684 let v = d.view();
5685 assert_eq!(v.basis, 0, "view() is whole");
5686 assert_eq!(v.rows.len(), 5);
5687 d.set_incremental_frames(false);
5688 let v = d.move_right(false);
5689 assert_eq!(v.basis, 0);
5690 assert_eq!(v.rows.len(), 5);
5691 }
5692
5693 #[test]
5694 fn a_window_of_rows_is_the_frame_s_rows_clamped() {
5695 let d = doc("one\n\ntwo\n\nthree\n");
5696 let v = d.set_unwrapped();
5697 let window = d.rows(1, 3);
5698 assert_eq!(window, v.rows[1..3].to_vec());
5699 assert_eq!(
5700 d.rows(4, 40),
5701 v.rows[4..].to_vec(),
5702 "clamped to the document"
5703 );
5704 assert!(d.rows(3, 3).is_empty());
5705 assert!(d.rows(3, 1).is_empty());
5706 assert_eq!(d.view().frame, v.frame + 1, "a window is not a frame");
5707 }
5708}