Skip to content

feat(rust): desktop playgrounds on Rune and GPUI - #121

Open
tmm wants to merge 10 commits into
mainfrom
rust-playgrounds
Open

tmm wants to merge 10 commits into
mainfrom
rust-playgrounds

Conversation

@tmm

@tmm tmm commented Nov 14, 2026

Copy link
Copy Markdown
Member

Stacked on #120 (rust-selection); review that one first. This adds the desktop playgrounds tmm asked for in place of a web one, on Rune and on GPUI, as wevm/zero has zbugs on both. It also adds the Selection.modify editor hook that #120 said was coming next.

What changes

  • Selection.modify as an editor hook (lexical-editor): SelectionMovement::landed_point, Editor::set_selection_movement and SelectionRef::modify. With a movement set, deleteCharacter/deleteWord/deleteLine and modify continue as JS does after domSelection.modify. Without one, behavior is unchanged: headless deletion inside text throws window object not found, as in JS. 288 cases recorded from JS (jsdom with Selection.prototype.modify stubbed by the same rules as the test's movement) match.
  • rust/apps/lexical-playground-core: the binding both apps share. Every edit is a step of the conformance script language, run by the same Session that replays scripts/*, so a session exports as a vector file JS replays (packages/lexical-conformance/src/replay.test.ts). Surface is the input-client side (UTF-16 ranges, an input method's composition kept out of Lexical until it commits).
  • rust/apps/lexical-playground-rune: Rune's text_input draws a mirror of the document; edits run through Lexical first.
  • rust/apps/lexical-playground-gpui: an editor element of its own, the window's EntityInputHandler, with macOS key bindings. It also has an end-to-end mode (--features e2e), as zbugs-gpui does.
  • Both apps: a format toolbar, Tree/JSON/Parity panels, and a status bar. Under Parity, Replay vectors runs the conformance report in the app, and Export session writes the session for JS to replay. cargo run --release in an app's directory launches it as a .app (bundle.sh).
  • CI (.github/workflows/rust-playground.yml): Rune builds, tests and renders headless on Linux and macOS, including the Parity panel. GPUI runs its end-to-end session in a real macOS window, then the job replays that session in JS.
  • rust/DESIGN.md: the Playgrounds section and the milestone table.

Test plan

On a macOS 27 Mac, with real keystrokes and clicks:

  • A 59-step GPUI session and a 37-step Rune session (typing, Return, ⌘B, Backspace, ⌥⌫, click to place the caret) replay in JS with every step equal.
  • The GPUI e2e run passes, and its session replays in JS. It covers typing, Return, ⌘B, ⌥⌫, an input-method composition and its commit, 👍🏽 deleted by one Backspace, and Shift-Return.
  • Replay vectors reports 212 cases: 99 match JS, 113 wait on a package, 0 fail.

cargo test --workspace --all-features and the Rust CI pass. The GPUI app isn't built on Linux.

Try it

cd rust/apps/lexical-playground-gpui   # or lexical-playground-rune
cargo run --release

@socket-security

socket-security Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedcargo/​gpui-kit@​0.9.17210093100100
Addedcargo/​unicode-segmentation@​1.13.31001009310080

View full report

Base automatically changed from rust-selection to main November 14, 2026 16:52
## Description

Adds the Lexical playground as a native desktop app on the Rune UI
framework (`rust/apps/lexical-playground-rune`), as tmm asked, after
wevm/zero's zbugs-rune. It replaces the web playground idea: the Rust port
is meant for native apps.

- `rust/apps/lexical-playground-core` (in the `rust/` workspace) is the
  editor binding both desktop playgrounds share. It holds the document as
  blocks of styled runs, maps positions to Lexical points (UTF-16) and back,
  and runs edits as steps of the conformance script language through the
  same interpreter that replays `scripts/*`. Deleting at a caret first
  extends the selection over a grapheme, word or line, as the browser's
  `Selection.modify` does for Lexical, so every step runs the same in
  headless JS.
- The Rune app draws a mirror of the document in Rune's `text_input`, which
  does layout, hit-testing, caret movement and input methods. Edits go
  through Lexical before the field sees them (typing, Return, Shift-Return,
  Backspace and Delete with Option and Command, Tab, paste, ⌘A/B/I/U), and a
  change the field makes itself (an IME commit, a cut) is replayed into
  Lexical. It has a format toolbar and a side panel with the tree, the JSON,
  and parity: Replay vectors runs the conformance report in the app, and
  Export session writes a vector file that
  `packages/lexical-conformance/src/replay.test.ts` replays in JS.
- The app is its own Cargo workspace, pinned to Rune's toolchain (1.99.1).
  `bundle.sh` runs it as an app bundle on macOS. The new `rust-playground.yml`
  workflow lints, tests and renders it headless on Linux and macOS.
- DESIGN.md: a Playgrounds section, and rich-text and history move to M2,
  because a text surface needs them first.

## Test plan

### Before

The playgrounds did not exist.

### After

```
$ cd rust && cargo test -p lexical-playground-core
test result: ok. 6 passed; 0 failed
$ cd rust/apps/lexical-playground-rune && cargo clippy --all-targets -- -D warnings && cargo test
test result: ok. 4 passed; 0 failed
$ cargo run -- --snap typed.png --size 1100x720 --scene typed
```

The Rune tests drive the window's real key routing: typing, Return,
Backspace and ⌘B in the field, a toolbar button that formats the selection
and leaves the keyboard in the editor, and an IME-style change replayed in
Lexical. A 19-step session exported from the core binding replays in JS
with every step matching
(`LEXICAL_REPLAY=… pnpm exec vitest run src/replay.test.ts` in
`packages/lexical-conformance`). The same file with one recorded result
changed fails at that step.

Amp-Thread-ID: https://ampcode.com/threads/T-16d45229-bfa8-417a-a091-d3e3888d830d
## Description

JS's `deleteCharacter`, `deleteWord`, `deleteLine` and `modify` extend or
move the selection with the DOM's `Selection.modify`, which only text layout
can answer. A headless editor has none, and throws `window object not found`
there, as JS does. This adds the platform's side as a hook an app sets from
its text layout, as the Swift port's `SelectionMovement`:

- `SelectionMovement::landed_point(from, is_backward, granularity)`,
  `Editor::set_selection_movement`, `SelectionGranularity` and
  `SelectionAlter`.
- `SelectionRef::modify(alter, is_backward, granularity)`.
- With a movement, `$extendSelectionForDeletion` and `modify` continue as JS
  does after `domSelection.modify`: the unmergeable-node edge (meta#7301),
  `applyDOMRange`'s model side (`$internalResolveSelectionPoint`,
  `$normalizeSelectionPointsForBoundaries`, `$normalizeSelection`),
  `$shrinkSelectionToRoot`, `$swapPoints`, and the full
  `$modifySelectionAroundDecoratorsAndBlocks`. Without one, nothing changes.

## Test plan

### Before

Deletion inside text failed with `window object not found` in every editor.

### After

```
$ cargo test -p lexical-editor
test result: ok. 15 passed; 0 failed
$ cargo run -q --locked --release -p lexical-conformance
| **Total** | 212 | 99 | 0 | 113 | 0 | |
```

`deletion_and_modify_with_a_movement_match_js` checks 288 cases (deletion by
character, word and line, and `modify` for every alter, direction and
granularity, over emoji, combining marks, format boundaries, unmergeable,
segmented and token nodes, line breaks and blocks) against JS run in jsdom
with a root element and `Selection.prototype.modify` stubbed by the same
rules as the test's movement. `headless_deletion_matches_js` is unchanged.

Amp-Thread-ID: https://ampcode.com/threads/T-16d45229-bfa8-417a-a091-d3e3888d830d
## Description

Adds the second desktop playground tmm asked for, on GPUI (through GPUI
Kit, as wevm/zero's zbugs-gpui), beside the Rune one:

- `rust/apps/lexical-playground-gpui`: an editor element of its own that
  draws the Lexical document, shapes and wraps it, hit-tests the pointer
  and moves the caret, and is the window's text input client
  (`EntityInputHandler`). Keys are GPUI actions bound as macOS binds them
  (Return, Shift-Return, Backspace/Delete by grapheme, Option by word,
  Command by line, ⌘A/B/I/U, copy, cut, paste). A toolbar, Tree, JSON and
  Parity panels, a status bar, and `bundle.sh` so `cargo run` launches a
  `.app`.
- `Surface` in `lexical-playground-core`: the input-client side, shared by
  both apps. It maps UTF-16 ranges to Lexical points and keeps an input
  method's composition out of Lexical until it commits.
- An end-to-end mode (`--features e2e`), as zbugs-gpui has: the app types,
  formats, composes, deletes and breaks lines in its own window through the
  input handler and key bindings, checks each step, and exports the
  session. The new `gpui-macos` CI job runs it and replays the session in
  JS.
- `replay_vectors` returns a short summary sized for a side panel instead of
  the Markdown table, and the panels scroll and wrap.
- Rune fixes found running it in a real macOS window: the editor's text was
  white on the light appearance, and the toolbar sat under the traffic
  lights. A `parity` scene renders the panel after Replay vectors, and CI
  snapshots it.

## Test plan

### Before

No GPUI app. In a light-mode macOS window the Rune editor's text was
invisible.

### After

On a macOS 27 Mac:

```
$ cargo clippy --all-targets --features e2e -- -D warnings   # and without e2e
$ cargo build --locked --features e2e
$ LEXICAL_PLAYGROUND_E2E=/tmp/gpui-e2e.json ./target/debug/lexical-playground-gpui
e2e: passed
$ cd packages/lexical-conformance
$ LEXICAL_REPLAY=/tmp/gpui-e2e.json pnpm exec vitest run src/replay.test.ts
 Test Files  1 passed (1)
```

Driven by hand with real keystrokes and clicks (System Events and CGEvent):
a 59-step GPUI session and a 37-step Rune session (typing, Return, ⌘B,
Backspace, ⌥⌫, click to place the caret) each replay in JS with every step
equal. Replay vectors in both apps reports 212 cases, 99 match JS, 113 wait
on a package, 0 fail.

```
$ cd rust && cargo test --workspace --all-features --locked   # all pass
$ cd rust/apps/lexical-playground-rune && cargo test --locked  # 4 passed
```

The GPUI app is not built on Linux.

Amp-Thread-ID: https://ampcode.com/threads/T-16d45229-bfa8-417a-a091-d3e3888d830d
## Description

The GPUI playground's sources were committed before `cargo fmt`, so the
`GPUI playground (macOS)` CI job failed its format check. This only
reformats `app.rs`, `e2e.rs` and `editor.rs`; no behavior changes.

## Test plan

### Before

```
$ cargo fmt --check   # in rust/apps/lexical-playground-gpui
Diff in .../src/editor.rs:647: ...
```

### After

```
$ cargo fmt --check && cargo clippy --all-targets --locked --features e2e -- -D warnings
(no output, exit 0)
```

Amp-Thread-ID: https://ampcode.com/threads/T-16d45229-bfa8-417a-a091-d3e3888d830d
## Description

tmm asked for app icons like wevm/zero's playgrounds have, and noticed the
caret didn't blink in either app.

- `AppIcon.icon` in each app: an Icon Composer document with the Lexical
  mark (the Swift playground's layers, scaled to 80% and moved up and left)
  and the framework's mark as a badge in the bottom right corner, the
  Rune and GPUI badges zbugs-rune and zbugs-gpui use, in the same place.
  `bundle.sh` compiles it with `actool` when it changes and names it in the
  `.app`'s Info.plist, as zbugs' do, so the Dock shows it.
- The caret blinks as a Mac text field's does: on and off every 500 ms,
  solid again after any key, edit or move, and hidden while the editor or
  the window doesn't have the keyboard. GPUI: a timer task restarted from
  keystrokes (before their actions), edits and moves, stopped on blur and
  window deactivation. Rune draws its field's caret solid, so the app's
  `poll` hook keeps the phase on the window clock and wakes for the next
  one, and the view paints the caret clear in the off phases.

## Test plan

### Before

The `.app`s had the generic icon, and the caret was always drawn.

### After

On a macOS 27 Mac, after `cargo run --release` in each app:
- The Dock shows both icons, with the badges (`ictool` renders checked in
  light and dark too).
- Screen samples 150 ms apart show the caret alternating while idle, solid
  after each of 6 keystrokes, and gone with Finder active, in both apps.

`cargo fmt --check`, `cargo clippy --all-targets --locked -- -D warnings`
(GPUI also with `--features e2e`) and the Rune tests pass.

Amp-Thread-ID: https://ampcode.com/threads/T-16d45229-bfa8-417a-a091-d3e3888d830d
## Description

Ports `@lexical/history` (`packages/lexical-history/src/index.ts`) to
`lexical-editor` behind a new `history` feature, so the desktop playgrounds
can undo and redo, and adds the core commands and tags it uses:

- `history::register_history(editor, state, delay, date_now, on_change,
  max_depth)` and `create_empty_history_state()`, with the full merge
  logic (`createMergeActionGetter`, `getChangeType`, `isTextNodeUnchanged`,
  the `history-merge`/`history-push`/`historic` tags), undo, redo, clear,
  and the CAN_UNDO/CAN_REDO notifications. The clock is injectable
  (`date_now`, in ms) so merging by time replays deterministically.
- `commands.rs`: `UNDO_COMMAND`, `REDO_COMMAND`, `CAN_UNDO_COMMAND`,
  `CAN_REDO_COMMAND`, `CLEAR_HISTORY_COMMAND`, `CLEAR_EDITOR_COMMAND`, as
  JS core defines them, and the update tags history uses.
- The conformance script language, in `ops.ts` and `scripts.rs`: a script's
  `history: {delay}` registers history on the session's clock; a step's
  `now` (ms) sets that clock (`Date.now()` in JS while the step runs); and
  `dispatch` and handlers name Lexical's own commands by their JS names.
- New JS-recorded vectors, `scripts/history` (10 cases: merging within the
  delay and pushing after it, undo/redo and the empty stacks, redo cleared
  by an edit, the push and merge tags, selection-only updates, formatting,
  paragraphs, deletion, paste/cut, composition, clearing).

`HistoryExtension` waits for the extension API (PARITY.md).

## Test plan

### Before

No history in Rust; `scripts/history` didn't exist.

### After

```
$ cargo run -q --locked --release -p lexical-conformance
| `scripts/history` | 10 | 10 | 0 | 0 | 0 | pass |
| **Total** | 222 | 109 | 0 | 113 | 0 | |
$ cargo test --workspace --all-features --locked   # all pass, 3 new history tests
$ cargo +1.88 check -p lexical-editor --all-features --locked
$ cargo check -p lexical-editor --locked          # history compiled out
```

`pnpm run generate` in `packages/lexical-conformance` adds only
`scripts/history.json`; no other vector changes. `tsc`, ESLint and Prettier
are clean. Making the Rust delay comparison `<=` fails case 1 at step 5.

Amp-Thread-ID: https://ampcode.com/threads/T-16d45229-bfa8-417a-a091-d3e3888d830d
## Description

⌘Z did nothing in either desktop playground, because the editor had no
history. The previous commit ports `@lexical/history`, and this one wires it
in:

- `lexical-playground-core` registers history on the playground's session
  (300 ms delay, on a clock the session stamps on every step), and adds
  `undo()`, `redo()`, `can_undo()` and `can_redo()`. A click that moves the
  selection runs as its own select-only step before the next edit, so typing
  after a click merges into one undo step the way a browser does. Exported
  cases now carry the script's history and handlers, so they replay in JS.
- Rune: ⌘Z runs undo and ⌘⇧Z runs redo.
- GPUI: `Undo` and `Redo` actions bound to `cmd-z` and `cmd-shift-z`; the
  e2e run includes both.

## Test plan

### Before

Pressing ⌘Z in either app on macOS left the text unchanged.

### After

- `cargo test -p lexical-playground-core`: 10 passed, including
  `undo_takes_back_typing_merged_by_time_and_redo_brings_it_back`.
- `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings`
  and the Rune and GPUI app workspaces (GPUI with and without `e2e`, on
  macOS) are clean.
- Conformance: 222 cases, 109 match, 113 need an unported package, 0 fail.
- Exported sessions with undo and redo (21 steps from the core, 17 from the
  GPUI e2e run on macOS) replay in JS through `replay.test.ts` with every
  step equal.
- On macOS with real keystrokes, ⌘Z and ⌘⇧Z undo and redo in both apps,
  and typing within 300 ms comes back as one step.

Amp-Thread-ID: https://ampcode.com/threads/T-16d45229-bfa8-417a-a091-d3e3888d830d
## Description

The GPUI playground always drew light, because GPUI Kit's init sets the
light theme and nothing changed it, while the Rune playground follows the
system appearance. The window now syncs GPUI Kit's theme with its
appearance when it opens and again on every appearance change, so it
switches live. The highlight mark, the one color the editor hardcoded,
becomes a deep amber in dark mode, so light text on it stays readable.

## Test plan

### Before

With macOS in dark mode, the GPUI playground opened, and stayed, light.

### After

- `cargo fmt --check` and `cargo clippy --all-targets --locked -- -D warnings`
  (with and without `e2e`) are clean on macOS.
- `cargo run --release` on macOS: launched in dark mode the window is dark;
  switching System Settings' appearance light → dark → light while it runs
  redraws the toolbar, editor and side panel each time; highlighted text
  reads on amber in dark and on yellow in light.

Amp-Thread-ID: https://ampcode.com/threads/T-16d45229-bfa8-417a-a091-d3e3888d830d
## Description

The Rune playground follows the Mac's appearance, but drew highlighted
text on the same light marker yellow in both, so the light text of dark
mode was hard to read on it. `decorations` now takes the window's
appearance and uses a deep amber in dark mode, as the GPUI playground
does since the previous commit.

## Test plan

### Before

In dark mode, highlighted text was white on light yellow.

### After

- `cargo fmt --check`, `cargo clippy --all-targets --locked -- -D warnings`
  and `cargo test --locked` (4 passed) in the Rune app workspace.
- `cargo run --release` on macOS with everything highlighted: white text on
  amber in dark mode, dark text on yellow in light, switching live in both
  directions.

Amp-Thread-ID: https://ampcode.com/threads/T-16d45229-bfa8-417a-a091-d3e3888d830d
## Description

In both desktop playgrounds, ⌘B over a selection bolded it, the next ⌘B
did nothing (but added two steps), and only a third press unbolded it.

`formatText` splits the selected text into its own node and selects that
node. Its start is then both the start of the new node and the end of the
plain node before it. The playground compared the editor's selection with
the app's by point, and the app maps a position to the end of the node
before it, so before the next edit it sent a `select` step moving the
anchor there. The selection then took that plain node's format, and
`formatText`, which toggles the selection's format, set bold again instead
of clearing it. JS does the same given those steps; a browser just never
sends them, since the DOM selection stays where Lexical put it.

The playground now compares positions: when the editor's selection is
already at the app's positions, it keeps Lexical's points and sends no
`select` step. Every ⌘B now toggles in one step, as in JS.

Also, GPUI's toolbar draws a format that is on as a filled button, as Rune
does: the theme's selected look barely showed in dark mode.

## Test plan

### Before

- `format_toggles_on_and_off_with_one_press_each` (new) fails at press 1:
  `left: ["dogs"] right: []`.
- The GPUI e2e run (new ⌘B steps over "xt", after "ne"):
  `e2e: ⌘B again: "xt" bold is None, the toolbar's true, not false`.

### After

- `cargo test -p lexical-playground-core`: 11 passed. The workspace's fmt,
  clippy and tests are clean; conformance: 222 cases, 109 match, 113 need an
  unported package, 0 fail.
- GPUI e2e on macOS: `e2e: passed`; its 21-step session replays in JS
  through `replay.test.ts` with every step equal (`Tests 1 passed`). Rune's
  fmt, clippy and tests (4 passed) are clean.
- Real keystrokes on macOS, ⇧← ×4 then ⌘B ×3: bold, plain, bold, one
  step each, in both apps, with the B button following.

Amp-Thread-ID: https://ampcode.com/threads/T-16d45229-bfa8-417a-a091-d3e3888d830d
@tmm
tmm force-pushed the rust-playgrounds branch from 5df08d5 to 04907bc Compare November 14, 2026 16:52

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants