---
contents:
- '[Sample](sample.md)'
---
# leaf (Work in progress!!)
A caret-based rich-text editor for documents, built on [`twig`](../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
| [`leaf-core`](crates/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-raster`](crates/leaf-raster) | the **shared CPU rasterization layer**: image decode (SVG included), path resolution and aspect-fit policy, and oversized-heading rasters with the caret and selection painted into the pixels — how a terminal heading stays editable when cells can't draw over a graphics-protocol image. |
| [`leaf-ratatui`](crates/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`](crates/leaf-gpui) | the **embeddable GUI widget** on [gpui](https://github.com/zed-industries/zed): 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`](crates/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`](crates/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
| [`leaf-swift`](packages/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`](packages/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
| [`leaf-tui`](apps/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`](apps/leaf) | the standalone gpui **application** (binary `leaf-gui`) — a thin host around `leaf-gpui`. A standalone workspace, like `leaf-ios`. |
| [`leaf-ios`](apps/leaf-ios) | the gpui iOS host (a standalone workspace on the gpui-mobile platform). |
| [`leaf-editor`](apps/leaf-editor) | the cross-platform (macOS + iOS) AppKit/UIKit/UniFFI demo app, consuming `packages/leaf-swift`. |
| [`leaf-web-demo`](apps/leaf-web-demo) | the web demo page, consuming `packages/leaf-web`. |
```sh
cargo run -- path/to/document.md # the TUI (workspace default)
cargo run -- --width 100 path/to/document.md # centered 100-column document
# 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/`](xtask):
```sh
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`](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`](xtask/src/ci.rs).
The web editor's own tests live in a browser, not in `cargo test`
([`packages/leaf-web/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][devtools], which leaf,
prov, twig, flower, and the historica repos all cut releases with; what makes
leaf leaf is [`.config/release.toml`](.config/release.toml) and nothing else.
[devtools]: https://github.com/diaryx-org/devtools
`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.
- **wysiwyg** — `leaf-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.
```sh
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`](../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`](https://github.com/benjajaja/mdfried)
idea, made editable.
Every action maps onto twig's editor surface:
| 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
```sh
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).
A path that doesn't exist yet opens an empty buffer under that name, the way any
other terminal editor does — `^S` creates the file. The extension still has to be
one of the above, since it is what the buffer is parsed as, and nothing is
written until you save, so opening a file and quitting leaves the disk untouched.
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:
```sh
LEAF_THEME=light cargo run -- path/to/document.md
```
`leaf -r <file>` (or `--read-only`) opens the document as a **reading surface**
rather than an editing one. Caret motion, selection, copy, `⌥g` follow, the
view/markup/line-flow dials, the palette and the key reference all work exactly
as they do anywhere else; everything that would change the document or write a
file is refused, says so, and is dimmed in the palette, the context menu and the
key reference. The flag goes on either side of the path. The gate itself is
`leaf-core`'s — every mutation funnels through three doors and all three are
shut — so this is a guarantee rather than a suppressed keyboard.
## Keys
| *(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 |
| `^f` / `^h` | **find** / find and replace — a bar in the footer, matching case-insensitively over the source |
| `^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 |
| terminal paste | arrives whole (bracketed paste) and lands as one undo step — into the prompt or the palette when one is open, otherwise into the document |
leaf **watches the file** it has open. Twice a second it stats the path, and on
the rare tick where that has moved it asks `Doc::disk_state` — the hash against
leaf's watermark — what actually happened. A document with no unsaved changes
reloads itself silently, keeping the caret and the scroll, so a `git checkout`
or a formatter run shows up in the buffer instead of waiting to become a
conflict — and the reload is **one undo step**, so `^z` gives you back what you
were looking at. Never on the tick that notices: a write is a truncate and then
a write, so the reload waits for a tick where the file has stopped moving rather
than reading somebody's half-finished one. A document *with* unsaved changes is
never reloaded behind your back: it says so once, and the Overwrite/Reload/Cancel
prompt at save time is where the choice gets made.
Inside the find bar: `Enter` steps to the next match (and `^g` repeats it),
`↑`/`↓` walk back and forward, `Esc` closes and leaves the caret on the match it
landed on. `⇧Enter` and `^⇧g` are *not* the "previous" half, because a terminal
sends the identical bytes for `Enter` and `⇧Enter` — the same reason the in-cell
line break is spelled `⌥Enter` here. With a replacement field up (`^h`, or `^h`
again on an open find bar, which keeps the query), `Tab` swaps fields and `^r`
replaces all — as a single undo step, because replace-all is one thing you did.
`Enter` does whichever field the keyboard is in: in the replacement it replaces
the current match and moves on, in the query it steps, as it always does. `^h`
over a selection, or over a bar that already has a query, starts in the
replacement field, so the common path is one key. The bar hands the host's own
chords back to the host — `^q`, `^s`, `^c` (which copies the match), `^p`, `⌥h`,
`⌥w` — rather than swallowing them; it is a strip with two fields in it, not a
modal dialog.
Matches are painted with `leaf-core`'s `Doc::set_highlights`, so a hit inside
`**bold**` selects the rendered word: the offsets are source bytes, which is the
coordinate the caret, the selection, and the wash are all already in. Searching
the source is also what makes a hit possible in text the *rich* view doesn't
draw — inside hidden frontmatter, or in a link's destination — and those don't
count as matches in that view. They are counted and said out loud ("2 of 3, 1
hidden"), and never stepped to, washed, or replaced; `⌥w` into the source view,
where every byte is on screen, is where the whole count is.
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.