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