leaf-core 0.1.4

Frontend-neutral core of leaf: a byte-offset caret/selection model over twig, plus an AST→glyph map, styled with a toolkit-agnostic Style.
Documentation

contents:


leaf (Work in progress!!)

A caret-based rich-text editor for documents, built on twig.

Workspace layout

leaf is a Cargo workspace in three tiers. The caret/selection model and the AST→glyph mapping live in a frontend-neutral core; embeddable editor widgets wrap it per toolkit; and thin apps host a widget with a window, clipboard, and file I/O.

crates/ — Rust libraries

crate what it is
leaf-core the document model — a twig::Editor with a byte-offset caret + selection, and the WYSIWYG VisualMap. Glyphs carry a toolkit-agnostic Style; no UI dependency.
leaf-ratatui the embeddable terminal widget (ratatui + crossterm): renders the editing surface into a Rect and turns key/mouse events into Doc edits, returning an Outcome for what the host owns (quit, save, clipboard, dialogs). The terminal peer of leaf-gpui.
leaf-gpui the embeddable GUI widget on gpui: the Editor view plus its input, pixel-wrapping renderer, and register_keybindings. Renders only the editing surface and leaves window chrome, file I/O, and quit to the host.
leaf-ffi the UniFFI Rust binding — wraps the filesystem-free Doc behind a C ABI so a native Apple app can drive it. Paired with the leaf-swift package.
leaf-wasm the wasm-bindgen Rust binding — wraps the Doc for the browser (LeafDoc + a typed DocView). Paired with the leaf-web package.

packages/ — importable non-Rust widget packages

package what it is
leaf-swift the Swift Package (manifest at the repo root, so SwiftPM can resolve it by version) — LeafUI, the AppKit/UIKit editor view, over the committed UniFFI leaf-ffi binding. The Apple peer of leaf-ratatui/leaf-gpui.
leaf-web the npm package (not yet published; private until there is a consumer) — LeafEditor, a framework-agnostic web editor, over the leaf-wasm binding. Tables draw as a real grid from core's structural TableView, not as the box-glyph picture; links and task boxes are clickable; the toolbar dims what the format cannot spell.

apps/ — runnable frontends

app what it is
leaf-tui the terminal editor (binary leaf) — a thin host around leaf-ratatui wiring a terminal, clipboard, and dialogs. The workspace default cargo run.
leaf the standalone gpui application (binary leaf-gui) — a thin host around leaf-gpui. A standalone workspace, like leaf-ios.
leaf-ios the gpui iOS host (a standalone workspace on the gpui-mobile platform).
leaf-editor the cross-platform (macOS + iOS) AppKit/UIKit/UniFFI demo app, consuming packages/leaf-swift.
leaf-web-demo the web demo page, consuming packages/leaf-web.
cargo run -- path/to/document.md            # the TUI (workspace default)

# The GUI is its own workspace (see `exclude` in Cargo.toml), so it is reached
# by manifest rather than by `-p`:
cargo run --manifest-path apps/leaf/Cargo.toml -- path/to/document.md

The other two frontends aren't a cargo run away — the Apple app is a Rust staticlib behind a UniFFI binding behind an Xcode project, and the web demo is a wasm build behind a static server. Both are one word through the task runner in xtask/:

cargo xtask swift        # build + launch apps/leaf-editor on macOS
cargo xtask swift --ios  # …in the iOS Simulator instead (--device to pick one)
cargo xtask web          # build the wasm, serve apps/leaf-web-demo, open it
cargo xtask web --test   # …serve the leaf-web editor tests instead

cargo xtask swift regenerates the UniFFI binding and the Xcode project when they're missing, so a fresh checkout needs no separate bootstrap step; pass --regen to force it after changing the Rust API surface. cargo xtask --help lists every task and flag.

The runner holds the checks; releasing is its own tool. cargo xtask ci runs everything a release has to pass — rustfmt, clippy with warnings denied, the workspace tests, and a cargo check of each crate alone in each feature shape a host actually builds. release release <patch|minor|major|X.Y.Z> runs those checks, moves the version through every manifest and the lockfile, cuts the generated region of docs/CHANGELOG.md into a released section, and commits and tags — locally. Pushing takes --push, and even a pushed tag publishes nothing: cargo publish --workspace is a separate, deliberate command, and --dry-run shows what it would upload, in dependency order, along with every crate it holds back.

leaf-wasm and leaf-ffi are two projections of the same leaf_core::Doc, and cargo xtask ci holds them level: a method exported by one binding and not the other fails the the_two_bindings_export_the_same_methods test, which exists because that is exactly how the browser ended up a year behind the Apple app. Genuinely host-specific spellings are listed, with reasons, in BINDING_DIVERGENCE in xtask/src/ci.rs.

The web editor's own tests live in a browser, not in cargo test (packages/leaf-web/test): what they cover is a TreeWalker, a Range, a native selection, and text laid out in a proportional font, and a stub DOM would mostly be testing the stub. cargo xtask web --test serves them; the page reports into document.title and window.__results as well as on screen. There is no browser in cargo xtask ci, so this is a task a person runs.

release is the shared tooling in diaryx-org/devtools, which leaf, prov, twig, flower, and the historica repos all cut releases with; what makes leaf leaf is .config/release.toml and nothing else.

leaf and leaf-gpui pin gpui to a specific Zed commit (gpui isn't published to crates.io); the first build fetches and compiles the gpui tree, so it is slow. It has both views, toggled with ⌘e, just like the TUI's ⌥w:

  • source — the raw document, caret in source bytes.
  • wysiwygleaf-core's VisualMap resolved: **bold** painted bold, _italic_ italic, # / ` / ** delimiters hidden, headings coloured, list markers as bullets. Each rendered glyph still maps back to its source byte, so the caret, selection, and clicks ride the visible text and step over hidden delimiters — the identical VisualMap the TUI renders, here with real proportional bold/italic via the per-glyph to_gpui styling in leaf-gpui/src/style.rs.

Both views share one rendering path (a RowLayout per visual row carrying each character's source offset), so caret, selection, and mouse hit-testing are written once and work in either view. Keys: arrows/Home/End (+ to select), type to edit, ⌘b/⌘i bold/italic, ⌘e toggle view, ⌘s save, ⌘q quit.

cargo run --manifest-path apps/leaf/Cargo.toml -- document.md
cargo run --manifest-path apps/leaf/Cargo.toml -- document.md wysiwyg

gpui gotchas (macOS), learned the hard way:

  • The gpui_platform dependency must enable the font-kit feature. Without it, gpui's macOS backend uses a placeholder text system that lays text out but rasterizes no glyphs — the window, caret, and selection all render, but every character is invisible. This is not a version issue; it's a feature flag.
  • gpui uses library features stabilized in Rust 1.95, and its macOS backend compiles Metal shaders at build time — so a full Xcode with the Metal Toolchain component is required (xcodebuild -downloadComponent MetalToolchain). The pinned toolchain lives in rust-toolchain.toml.

Sibling to bough: same backend, opposite model. Where bough moves a selection through the document's AST and edits the tree, leaf gives you an ordinary text caret, mouse, selection, and a formatting toolbar — and turns every keystroke into one of twig's offset-addressed edits. The document stays a live, round-trippable AST the whole time you type into it, so a Markdown file and a Djot file are edited through the exact same operations.

Two views, toggled with ⌥w:

  • source — the raw document with the caret in source bytes.
  • wysiwyg — the markup resolved: headings coloured, **bold** as real bold, the # / ** / ` delimiters hidden. The caret still works because every rendered glyph is tied back to the source byte it came from, so cursor motion, clicks, and selection ride the visible text and step right over the hidden delimiters. Because it reads the AST, Markdown and Djot that parse alike render identically — the mdfried idea, made editable.

Every action maps onto twig's editor surface:

action twig op
type / delete edit_range(start, end, text)
re-anchor the caret after an edit the returned Change
breadcrumb / cursor context ancestors_at(offset)
click to place the caret node_at + the flat nodes() snapshot
bold / italic / code / mark toggle_inline(range, kind)
heading / body set_block(offset, kind)

Usage

cargo run -- path/to/document.md

Formats are detected by extension: .md/.markdown, .dj/.djot, .html/.htm, .xml. The formatting toolbar targets the lightweight-markup formats (Markdown, Djot).

The palette follows the terminal. On startup leaf asks it whether it is light or dark (an OSC 11 query, the same moment it probes for a graphics protocol) and picks the matching colors, so the tint behind a code block is a lift off your background rather than a fixed dark slab. Terminals that won't answer are detected as such quickly and fall back to COLORFGBG, then to dark. To pin it:

LEAF_THEME=light cargo run -- path/to/document.md

Keys

key action
(printable) insert at the caret (replacing any selection)
Enter / Backspace / Delete the usual — Enter in a table drops to the cell below, growing the table when there isn't one
Tab / ⇧Tab indent / outdent — in a table, walk the cells, appending a row off the last one
⌥Enter an in-cell line break (the terminal's spelling of ⇧Enter, which a terminal can't tell from Enter)
arrows / Home / End move the caret
Shift+move extend the selection
click / drag place / drag the caret
⌥b / ⌥i / ⌥c toggle bold / italic / code on the selection
⌥m / ⌥d / ⌥u toggle mark/highlight (Djot) / strikethrough / underline
⌥1⌥6 make the block at the caret a heading of that level
⌥0 make it a paragraph
⌥7 / ⌥8 / ⌥9 numbered list / bulleted list / quote
⌥t / ⌥x give the item at the caret a checkbox / tick the box
⌥k / ⌥l set the link destination / the code block's language
⌥e / ⌥f / ⌥r insert an image / a footnote / a horizontal rule
⌥g follow what the caret is on — a footnote to its note, a note back to its reference, a #fragment to the heading it names
⌥w switch between the source and wysiwyg views
⌥⇧w / ⌥⇧f cycle the markup mode / flip line flow
^p or ⌥p the command palette
F1 or ⌥h the key reference
^s / ⌥s / ⌥n / ^q save / save as / new / quit
right-click the context menu: Format, Insert, Table and View flyouts
hover peek at what's under the pointer — a footnote's note, a link's destination — without moving the caret

Everything the keyboard doesn't reach is in the command palette — the three media kinds, all fourteen table operations, and the markup/line-flow modes named outright rather than cycled. Type a few letters of a command's name and press Return; table finds the whole grid family, ir finds Insert Row Above.

The palette, the context menu, and the key reference are all generated from one command table (apps/leaf-tui/src/commands.rs), so a command cannot gain a key without gaining a menu row and a line in the help. Every one of those surfaces also dims what the document's format cannot spell==highlight== in a Markdown file, a footnote in an HTML one — rather than hiding it, so what a format can't do stays legible instead of merely absent.

Status

Both views work: caret editing, mouse, selection, the format-aware toolbar, undo/ redo, and live AST awareness, in either source or wysiwyg. Tables are editable as a grid; footnotes can be written and walked in both directions; images, video and audio embed; and the markup and line-flow dials are reachable.

Known rough edges (next steps): no soft-wrap-aware width for wide/emoji glyphs (columns are counted in chars); code blocks render read-styled but map coarsely, so edit code in the source view; no inline-image rendering yet (kitty/sixel is the natural follow-up now that the glyph map exists); and ⌥g names an external link's destination rather than opening it — launching a browser out of a text editor is a decision for the person at the keyboard, and most terminals already make a printed URL clickable.