🔧 Add penpot-render-wasm skill

This commit is contained in:
Elena Torro 2026-05-07 09:45:54 +02:00
parent db1e2a9cfc
commit 938a86560d
9 changed files with 882 additions and 7 deletions

View File

@ -0,0 +1,67 @@
---
name: penpot-render-wasm
description: Penpot render-wasm context loader — architecture, conventions, gotchas, and file map for the Rust/WASM render layer (`render-wasm/`) and its ClojureScript bridge (`frontend/src/app/render_wasm/`). Trigger this aggressively whenever the user is working on, planning, reviewing, debugging, or asking about anything render-wasm in Penpot. Specifically fire on: writing or editing Rust files under `render-wasm/src/`; writing or editing CLJS files under `frontend/src/app/render_wasm/` or workspace viewport/shape rendering code; planning a render-wasm refactor or feature (PDF export, drag/render path, binary props, WASM bindings, V3 text editor, tile cache, atlas); debugging render-wasm bugs (rendering glitches, crashes, panics, perf regressions, layout bugs, drag/zoom/pan bugs, text cursor/selection bugs); answering "how does X work" questions about the render layer; reviewing render-wasm diffs or PRs. Adjacent topics that should also fire: WASM↔CLJS FFI, Skia, dual-rendering (DOM transparent text + Skia visible), `SurfaceId`, `with_current_shape!` / `with_current_shape_mut!`, binary prop alignment / `transmute` conventions, `mem::write_bytes` / `mem::free_bytes`, the V3 text editor (`text-editor-wasm/v1`), drag/atlas/tile cache, `gesture_record!` instrumentation. Lean toward firing — if the user is in this codebase and the topic touches rendering, fire.
---
# Penpot render-wasm
A pre-loaded context bundle for working on Penpot's render-wasm layer: the Rust + Skia render engine compiled to WebAssembly and the ClojureScript code that drives it.
## When this skill fires
Aggressively. The repo has several intertwined subsystems where a small change in one corner breaks invariants in another. The point of loading this skill is to put the relevant invariants in front of you *before* you write code, plan a refactor, or chase a bug.
Concretely: render-wasm work is anything under `render-wasm/`, `frontend/src/app/render_wasm/`, the workspace viewport renderer, or the shape/text rendering pipeline. If you're editing those, planning changes, debugging issues, or even just answering questions about how things work, this skill is in scope.
## Mental model in one paragraph
There is a single Rust render engine compiled to WASM. It owns a `State` (with a `ShapesPool`, a `RenderState`, and friends), drives Skia to render shapes onto layered surfaces, and exposes `#[no_mangle] extern "C"` functions to JS. The ClojureScript side (`frontend/src/app/render_wasm/api.cljs` and friends) calls these functions synchronously from the main thread to push shape data, drive interactive transforms, and request renders. Text is *dual-rendered*: a contenteditable DOM tree provides editing/selection input with `color: transparent`, while Skia draws the visible glyphs. Cursor and selection overlays are drawn on a dedicated `SurfaceId::UI` surface composited on top each frame. Drag/zoom/pan use a tile cache plus an interactive backbuffer crop cache; the V3 text editor (behind `text-editor-wasm/v1`) moves all editing state and overlays into Rust + Skia.
## Decide what to read
This skill bundles six references. Don't read them all unless you're orienting from scratch — pick by what you're about to do.
| You are about to... | Read |
|---|---|
| Write or edit Rust in `render-wasm/src/` | `references/conventions.md`, then any subsystem-specific reference |
| Add a `#[no_mangle]` WASM export | `references/conventions.md` (binary props, `with_current_shape!`, `mem::write_bytes`/`free_bytes`) |
| Add or modify a CLJS↔WASM bridge call | `references/conventions.md` + `references/file-map.md` (api.cljs section) |
| Plan a render-wasm refactor | `references/architecture.md`, then `references/file-map.md` |
| Debug a rendering glitch / crash / perf | `references/architecture.md` + `references/perf.md` |
| Touch the V3 text editor | `references/v3-text-editor.md` |
| Touch drag/zoom/pan/atlas/tile cache | `references/perf.md` (drag instrumentation, backbuffer crop cache pitfalls, why the drag-sprite approach was abandoned) |
| Touch blur/shadow/filter rendering | `references/perf.md` (blur perf section) |
| Verify a change builds | `references/build.md` (project commands; rebuild matrix per change type) |
| Answer "where is X" without making changes | `references/file-map.md` |
## Workflow shapes
### Writing or editing render-wasm code
1. **Identify the subsystem you're touching.** Use `references/file-map.md` to find the canonical files.
2. **Load the conventions for that subsystem** — at minimum the global ones in `references/conventions.md`. If you're touching binary deserialization, layout (flex/grid), text, or anything that runs on the worker thread, read the matching subsection in `conventions.md`.
3. **Make the edit.** Cite invariants when you choose between approaches (e.g., "using `with_current_shape!` because we only need to read"; "guarding `document` access because this can be called from the dashboard worker").
4. **Build using the project scripts.** Use `./build`, `./test`, `./lint` from `render-wasm/` (see `render-wasm/AGENTS.md` and `references/build.md`). Avoid `cargo check` — it tries to rebuild Skia from source and is much slower than the project scripts. CLJS-side bridge changes are verified by CLJS compilation; Rust changes need a `./build` before the frontend picks them up.
### Planning a refactor
1. **Read `references/architecture.md`** to refresh the mental model — especially state ownership, surface layering, and the render loop shape.
2. **Read the subsystem reference(s)** for whatever the refactor touches.
3. **Write a plan.** Call out which invariants the refactor preserves and which it changes. Convention violations are a source of bugs that don't show in tests; surface them explicitly.
### Debugging
1. **Triage by symptom** in `references/perf.md` (perf) or by file in `references/file-map.md`.
2. **Cross-check known pitfalls** in `references/conventions.md` — many "weird" bugs are convention violations (binary prop alignment, missing `free_bytes`, calling `document` from a worker).
3. **Use the `gesture_record!` macro** if you need per-stage timings during a gesture; the macro lives in `render-wasm/src/performance.rs`. See `references/perf.md` for the stage list and how to wire the CLJS receiver back in.
4. **Static analysis is usually enough.** Penpot's render-wasm is well-instrumented and well-named; reading the relevant call sites with citations is faster than capturing a profile in most cases.
## Relationship to AGENTS.md
This skill complements the per-module `AGENTS.md` files (`render-wasm/AGENTS.md`, `frontend/AGENTS.md`). AGENTS.md is the authoritative source for module commands (build, test, lint) and short-form module orientation; the skill carries the deeper architecture, conventions, V3 internals, and perf design lessons that don't fit AGENTS.md's brevity. Read AGENTS.md for "how to work in this module"; read the references here for "how the render layer actually works".
## What this skill does NOT cover
- **Backend (Clojure / Postgres) and the asset pipeline.** Different codebase, different conventions.
- **Pure-CLJS UI work outside the render pipeline** (sidebar forms, modal dialogs, etc.). Use Penpot's general patterns; this skill is render-specific.
- **Standalone text editor (`frontend/text-editor/`)** unless it interacts with V3 — the embedded editor is the V3 case.

View File

@ -0,0 +1,174 @@
# Architecture
The mental model for render-wasm and its CLJS bridge. Read this when planning a refactor or trying to orient on a new subsystem.
---
## Overall shape
```
┌─────────────────────────────────────┐
│ Penpot Workspace (CLJS, main thread)│
│ Re-frame state, viewport, sidebar │
└────────────┬────────────────────────┘
│ frontend/src/app/render_wasm/api.cljs
│ (sync FFI calls, JS objects, h/call)
┌─────────────────────────────────────┐
│ render-wasm (Rust → WASM) │
│ STATE: Box<State>
│ ├─ ShapesPool │
│ ├─ RenderState │
│ │ ├─ Surfaces (Skia) │
│ │ ├─ Tile cache │
│ │ ├─ backbuffer_crop_cache │
│ │ └─ TextEditorState (V3) │
│ └─ Modifiers, fonts, view, etc. │
└────────────┬────────────────────────┘
▼ Skia GPU canvas → HTMLCanvasElement
```
There is exactly one `STATE` global on the Rust side, accessed only through the macros in `main.rs:60-102` (see `conventions.md`). The CLJS side serializes work into FFI calls; nothing on the Rust side runs in parallel with the JS main thread (except the dashboard thumbnail worker, which has its own WASM instance).
---
## Module layout (`render-wasm/src/`)
| Path | Role |
|---|---|
| `main.rs` | WASM entry points (init, set_browser, render driver); access macros |
| `state.rs`, `state/` | `State` struct (`shapes_pool.rs`, `text_editor.rs`); orchestration |
| `shapes.rs`, `shapes/` | Shape model: `Shape`, `Type`, layouts, modifiers, paths, fills, strokes, text, transform |
| `render.rs`, `render/` | Render loop, surfaces, fills/strokes/shadows/filters drawing, tile cache, UI overlay |
| `wasm/` | `#[no_mangle]` exports grouped by subsystem (shapes, text, layouts, paths, blurs, fills, strokes, etc.) |
| `tiles.rs` | Tile index and tile rect math |
| `mem.rs` | WASM memory allocation: `write_bytes`, `free_bytes` (with global lock) |
| `performance.rs` | `run_script!`, `gesture_record!`, perf markers |
| `error.rs` | `Result<()>` + `#[wasm_error]` macro |
| `view.rs` | Viewbox / zoom |
| `fonts/` | Font loading & registration |
| `math.rs`, `math/` | Geometry primitives (Rect, Point, Matrix), shared by shape and render code |
---
## CLJS bridge (`frontend/src/app/render_wasm/`)
| File | Role |
|---|---|
| `api.cljs` | The main bridge. `set-view-box`, `render-finish`, shape upload, sync-selection-rects!, update-text-rect! |
| `text_editor.cljs` | V3 text editor JS FFI wrappers |
| Other namespaces | Shape-type-specific exports, fonts, exports |
Workspace components that drive the bridge:
- `frontend/src/app/main/ui/workspace/viewport_wasm.cljs` — viewport component, calls `set-view-box`, manages `[@canvas-init? selected-shapes transform]` effect
- `frontend/src/app/main/ui/workspace/viewport/actions.cljs` — pointer event handlers (`on-pointer-move`, `schedule-zoom!`, `schedule-scroll!`)
- `frontend/src/app/main/ui/workspace/viewport/hooks.cljs``setup-hover-shapes`, hover stream coordination
- `frontend/src/app/worker.cljs` — worker bridge with `ask-buffered!` (dedupe-by-cmd, 1ms debounce)
- `frontend/src/app/worker/selection.cljs` — quadtree selection queries (worker-side)
---
## Render loop shape
Two main entry paths into the render loop:
1. **Non-interactive frame** — full render of all visible shapes into Backbuffer + tile cache, then composite to Target. Triggered after viewbox changes settle (`set_view_end` debounced), file open, edits.
2. **Interactive transform** (drag/resize/rotate) — fast path that uses the tile cache + backbuffer crop cache where possible. Avoids re-rendering shapes whose pixels can be blitted from the snapshot.
The loop body (in `render.rs::start_render_loop` and downstream) follows roughly:
```
start_render_loop
├─ if non-interactive: rebuild_backbuffer_crop_cache (snapshot for next drag)
├─ partition pending tiles: cached / uncached_visible / uncached_interest
├─ for each pending tile (visible first):
│ ├─ if cached: blit
│ └─ if uncached: render_shape_tree_partial → flush_and_submit
└─ atlas_blit (atlas → target composite)
```
The frame budget is 32 ms; the loop yields between batches in some places but not all (visible tiles render without yielding, by design — they need to be on screen *now*).
For drag-specific paths, see `references/perf.md`.
---
## State + shape ownership
`State` owns:
- `ShapesPool` — the canonical shape store, indexed by `Uuid`. Lookups via `tree.get(&uuid)`.
- `RenderState` — render-side caches and Skia surfaces (separate from the shape data itself).
- `current_shape_id` — the "selected" shape for `_set_shape_*` exports (the FFI is one-shape-at-a-time).
- View, modifiers, fonts, options.
The CLJS side does NOT mirror the shape pool — it pushes shapes into WASM via `_set_shape_*` exports, then queries the pool indirectly through render results. This is one-way data flow; the Rust side is authoritative once shapes are uploaded.
---
## Surface layering (`render-wasm/src/render/surfaces.rs:31`)
The Skia surfaces, in roughly the order they're composited:
```
SurfaceId::Tiles ← per-tile cached renders
SurfaceId::Atlas ← composited tiles for blitting
SurfaceId::Current ← per-shape sub-surface (Fills/Strokes/InnerShadows below)
SurfaceId::Fills ← shape fill (used inside save_layer for layer blur)
SurfaceId::Strokes ← shape stroke
SurfaceId::InnerShadows ← inner shadow effect surface
SurfaceId::Backbuffer ← full-frame composite source
SurfaceId::Target ← what the browser sees
SurfaceId::UI ← overlays drawn on top each frame
```
Filter surfaces (for blur/shadow) come from `render::filters.rs::render_into_filter_surface`.
---
## Text rendering (high level)
Text is split between WASM and CLJS:
- **Layout & glyph drawing** in WASM: `shapes/text.rs`, `render/text.rs`, `render/fonts.rs`. Uses `skia::textlayout::Paragraph`. Output stored in `TextContentLayout.paragraphs: Vec<Vec<Paragraph>>`.
- **DOM tree** in CLJS: `frontend/src/app/util/text/content/to_dom.cljs`, `styles.cljs`. Renders contenteditable with `color: transparent`, providing input/IME and selection ranges.
For V2 editor: cursor and selection are native DOM (the contenteditable does it). For V3 editor: cursor and selection are Skia overlays drawn from `TextEditorState`. See `references/v3-text-editor.md`.
---
## What "interactive transform" means
The render path branches on whether a transform is in flight (`is_interactive_transform()`). When true:
- Backbuffer is NOT refreshed (would invalidate the crop snapshot).
- Cached drag crop images are blitted in place of full re-render for shapes that pass `is_safe_for_drag_crop_cache`.
- `gesture_record!` instrumentation (when wired) only fires under this gate.
This is the fast-but-fragile path; bugs here usually take the form of "stale pixels stick around because we cached too eagerly" — see `references/perf.md` for the canonical example (frames with text descendants).
---
## What "fast mode" means
`fast_mode` is a flag on the render state set during view changes (`set_view_start` enables it; `set_view_end` disables it) and during preview rendering. While on:
- Frame-level blur (`frame_clip_layer_blur`) is skipped.
- `render_preview` engages it explicitly to skip blur/shadow during thumbnail or scroll preview.
- Shape rendering uses cheaper paths where available.
Fast mode is what lets pan/zoom be smooth on large files; without it, every frame would re-render shadows/blurs.
---
## CLJS-side state coordination
Re-frame state holds the workspace data; the WASM bridge reads from subscriptions and pushes into Rust. Key effects to be aware of:
- `mf/with-effect [vbox zoom]` in `viewport_wasm.cljs` — fires on viewport changes, calls `set-view-box`.
- `mf/with-effect [@canvas-init? selected-shapes transform]` — drives `sync-selection-rects!` for the UI overlay.
- `setup-hover-shapes` hook in `viewport/hooks.cljs` — subscribes to `move-stream`, sends `ask-buffered!` queries to the worker.
Backpressure is handled mostly by `ask-buffered!` (drop stale by command) and rAF-coalescing for zoom/pan. Hot paths to watch are anything that fires on every pointermove or every modifier tick.

View File

@ -0,0 +1,73 @@
# Build and Verification
How to build, test, and verify render-wasm changes. Authoritative source for module commands is `render-wasm/AGENTS.md`; this reference adds context that doesn't fit there.
---
## Module commands (`render-wasm/AGENTS.md`)
```bash
./build # Compile Rust → WASM (requires Emscripten environment)
./watch # Incremental rebuild on file change
./test # Run Rust unit tests (cargo test)
./lint # clippy -D warnings
cargo fmt --check # Format check
```
Single-test invocations:
```bash
cargo test my_test_name # by test function name
cargo test shapes:: # by module prefix
```
Build output lands in `../frontend/resources/public/js/` and is consumed directly by the frontend dev server.
The `_build_env` script sets the Emscripten paths and `EMCC_CFLAGS`. `./build` sources it automatically. The WASM heap is configured to 256 MB initial with geometric growth.
**About `cargo check`:** running `cargo check` on this crate tries to rebuild Skia from source, which is slow (minutes) and frequently fails on environment issues. The project scripts (`./build`, `./test`, `./lint`) drive the right toolchain and are the practical fast-feedback loop. Reach for `cargo check` only when you specifically want to validate something the project scripts don't cover.
---
## What changes require which rebuild
| Change | Required build |
|---|---|
| CLJS-only (e.g., `api.cljs` bridge logic, viewport components) | CLJS hot-reload picks it up |
| Rust in `render-wasm/src/` | `./build` (WASM rebuild) |
| WASM export signature change (`#[no_mangle] extern "C"` function) | `./build` AND update CLJS callers |
| Binary prop layout change (`Raw*Data` struct) | `./build` AND update JS-side encoder in lockstep |
**Binary prop layout changes are the highest-risk class** because the `offset_of!` tests catch Rust-side drift but there's no equivalent on the JS encoder side. Always touch both ends in the same change and call out the JS-encoder update explicitly in the commit/PR.
---
## Verifying frontend integration
For CLJS-only bridge changes (`frontend/src/app/render_wasm/api.cljs` and friends):
- CLJS shadow-cljs hot-reloads on edit; the dev server picks up the change.
- Reload the workspace tab to re-init the WASM module if you've changed init-time code.
- Playwright tests for render-wasm: `npx playwright test --project=render-wasm` from `frontend/`. Config at `frontend/playwright.config.js`; the render-wasm project runs at 1920x1080, 2x DPR. Fixtures at `frontend/playwright/data/render-wasm/get-file-*.json`.
---
## Debugging tools
- **Browser DevTools — Performance tab.** Capture during the gesture you're investigating. The flamegraph names the suspect functions directly.
- **`gesture_record!`** (see `perf.md`) — in-Rust per-stage timing emitted via `run_script!` to a JS receiver (`window.__penpotGestureRecord`). The macro is always compiled (cheap when the receiver is undefined); the CLJS-side buffer/report code lived at `frontend/src/app/util/perf.cljs` and is recoverable from history.
- **`#[wasm_error]`** macro on exports surfaces panics as JS exceptions. Without it, a panic crashes the whole module — the workspace dies.
- **`run_script!("console.log(...)")`** for ad-hoc Rust → console prints during dev. Don't ship these.
---
## When `./build` fails
Most likely causes, in order:
1. A new `#[no_mangle] extern "C"` export with a parameter type that doesn't cross the FFI boundary cleanly.
2. A `transmute` that violates layout assumptions — usually `RAW_*_SIZE` is wrong or a struct field was reordered. The `offset_of!` tests should catch this; if they pass and the build still fails, suspect the JS-side encoder mismatch.
3. A missing `?` propagation on a `Result` inside a `#[wasm_error]` function.
4. Toolchain drift, especially after a major Skia bump or Emscripten upgrade.
Read the build output before guessing — it usually points at the file directly.

View File

@ -0,0 +1,157 @@
# Conventions and Gotchas
The recurring rules to follow when writing render-wasm code. Each section names the invariant, *why* it exists (so you can judge edge cases), and where to look in the codebase for examples.
---
## State access macros (`render-wasm/src/main.rs:60-102`)
The Rust side stores a single `STATE: Option<Box<State>>` global. Direct access requires `unsafe`; do not write that yourself. Use the macros:
| Macro | Mutability | When to use |
|---|---|---|
| `with_state!(state, { ... })` | read-only state | Reading state without touching the current shape |
| `with_state_mut!(state, { ... })` | mutable state | Mutating state (render options, view, etc.) without touching a shape |
| `with_current_shape!(state, \|shape: &Shape\| { ... })` | read-only shape, read-only state | Querying a shape's properties |
| `with_current_shape_mut!(state, \|shape: &mut Shape\| { ... })` | mutable shape, mutable state, **calls `state.touch_current()`** | Modifying a shape — this is what most `set_shape_*` exports use |
| `with_state_mut_current_shape!(state, \|shape: &Shape\| { ... })` | read-only shape, mutable state | Reading a shape but needing mutable state for something else (no auto-touch) |
The `touch_current()` call in `with_current_shape_mut!` marks the shape dirty for re-render. If you bypass this (using `_state_mut_current_shape!` for a read-modify-write), the shape will not invalidate. That's almost always a bug.
**Why these exist:** the alternative is `unsafe { STATE.as_mut() }` everywhere. Centralizing the access pattern keeps the unsafe footprint small and makes the dirty-tracking automatic.
---
## Binary prop deserialization (`render-wasm/src/wasm/shapes/base_props.rs`, `wasm/text/effect_props.rs`)
Many WASM exports take a binary blob from JS instead of individual numeric arguments. The convention is:
1. Define a `RawXxxData` struct mirroring the JS-side binary layout. Use `#[repr(C)] #[repr(align(4))]`.
2. Implement `From<[u8; SIZE]>` via `unsafe { std::mem::transmute(bytes) }`.
3. Add helper methods on the struct for flag parsing, enum conversion, etc. — keep the struct dumb, put logic in helpers.
4. Write tests that assert:
- `std::mem::size_of::<RawXxxData>()` matches expected size
- `std::mem::align_of::<RawXxxData>()` is 4
- `std::mem::offset_of!(RawXxxData, field)` for each field — guards against silent reordering
- A round-trip `from_bytes(known_bytes) → RawXxxData` produces expected field values
**Worked example:** `RawBasePropsData` at `render-wasm/src/wasm/shapes/base_props.rs:19-100`, with the test fixture starting at line 175.
**Why so strict:** the JS side encodes these blobs by layout-aware bit packing. Any silent struct reordering (e.g., a Rust compiler decision to move a `u8` field for alignment) corrupts every shape upload without a compile error. The `offset_of!` tests are the only thing that catches this before runtime.
---
## WASM memory: `mem::write_bytes` and `mem::free_bytes` (`render-wasm/src/mem.rs`)
`mem::write_bytes(bytes)` allocates a buffer in WASM memory and returns a pointer the JS side reads. **It uses a global lock.** You must call `mem::free_bytes()` before the next `write_bytes` call, or the lock blocks.
The pattern (e.g., `wasm/text.rs:307-343`):
```rust
mem::free_bytes()?; // release any prior allocation
let ptr = mem::write_bytes(bytes)?; // allocate + copy + return pointer
// ... JS reads from ptr ...
// next time: free_bytes() before write_bytes() again
```
If you forget the `free_bytes`, the second call deadlocks (or panics, depending on the path) and the symptom is a frozen WASM call from JS. If you double-`free_bytes`, that's a no-op and harmless.
---
## Worker-safe `run_script!` calls (`render-wasm/src/performance.rs`)
The `run_script!` macro evaluates a JS expression from Rust. It's used heavily for performance markers, console logs, and the `gesture_record!` instrumentation.
**`render_sync` and `render_sync_shape` run inside the dashboard thumbnail worker thread**, where `document` is undefined. Any `run_script!` that references `document` will crash with `wasm-critical`.
The fix is to guard inside the JS expression itself:
```rust
run_script!(format!(
"typeof document !== 'undefined' && document.{}",
something
));
```
**Why this is easy to miss:** the `run_script!` invocation looks identical between main-thread and worker call paths. The crash only triggers when a thumbnail is rendered, which may not happen in interactive testing — so a missing guard can ship and only fail when a teammate hits the dashboard.
---
## Layout `MIN_SIZE` sentinel (`shapes/modifiers/flex_layout.rs:16`, `shapes/modifiers/grid_layout.rs:15`)
Both flex and grid layout use `const MIN_SIZE: f32 = 0.01;` as a non-zero sentinel for tracks/lines. **Don't suggest replacing it with `0.0`.** The 0.01 is intentional: division and clamping later in the layout pipeline produce NaN or infinities at exactly zero, and propagating a small positive sentinel through the math is cheaper than scattering `if x == 0.0` guards.
If the value annoys you (it propagates into rounded sizes that show up off-by-a-pixel), compensate around it (snap to integer at the boundary), don't remove it.
---
## Build commands and verification (`references/build.md` for the full version)
The render-wasm crate has its own build/test/lint commands documented in `render-wasm/AGENTS.md` (`./build`, `./test`, `./lint`, `cargo fmt --check`). `cargo check` against this crate is slow because it rebuilds Skia from source — prefer the project scripts. CLJS-side bridge changes are verified by CLJS compilation; Rust changes need a WASM rebuild before the frontend picks them up.
---
## Error handling: `Result<()>` and `#[wasm_error]`
WASM exports return `Result<()>` and are wrapped in `#[wasm_error]` (see `render-wasm/src/error.rs`). The macro converts a Rust error into a JS-visible exception. Inside an export, prefer `?` propagation; only return `Ok(())` at the end.
For panics (which shouldn't happen but do): they crash the WASM module, taking the workspace with them. If you're tempted to `unwrap()` something speculative, prefer `if let Some(...)` and an early return.
---
## Surface layering and `SurfaceId`
`SurfaceId` (`render-wasm/src/render/surfaces.rs:31`) names the layered Skia surfaces. The relevant ones:
- `SurfaceId::Target` — the final canvas the browser sees
- `SurfaceId::Backbuffer` — full-frame composite of all shapes
- `SurfaceId::Current` — per-shape sub-surface used during shape rendering
- `SurfaceId::UI` — overlays composited on top of `Target` each frame (selection rects, drag visuals, debug)
- `SurfaceId::Atlas`, `SurfaceId::Tiles` — the tile cache surfaces
- `SurfaceId::Fills`, `SurfaceId::Strokes`, `SurfaceId::InnerShadows` — sub-surfaces for shape effects
**Convention:** `SurfaceId::UI` is composited on top of the render target each frame in `render/ui.rs`. Anything drawn there should be world-space-transformed before draw (apply `scale(zoom*dpr) + translate(-vbox.left,-vbox.top)`). Selection rects use `skia::Path::polygon` + `canvas.draw_path`.
The CLJS side syncs UI overlays via `sync-selection-rects!` in `api.cljs` (clears + adds 4 corners per selected shape), driven by an effect in `viewport_wasm.cljs` that watches `[@canvas-init? selected-shapes transform]`.
---
## Dual rendering: DOM transparent + Skia visible
Text is rendered twice. The DOM contenteditable tree (V2 or V3) has `color: transparent` — it exists to capture native input/IME, selection ranges, and (in V2) cursor blink. Skia draws the visible glyphs on the render target.
**Implications:**
- DOM measurements (getBoundingClientRect, range rects) and Skia paragraph layout must stay in sync. The hook point is `update-text-rect!` in `api.cljs`, called when WASM layout is up-to-date.
- The editor instance lives at `:workspace-editor` in app state; the root DOM is `(.-root editor)`.
- `TextContentLayout.paragraphs` is `Vec<Vec<skia::textlayout::Paragraph>>`; the first inner element has the geometry the renderer uses.
- For V3 (`text-editor-wasm/v1`), the cursor and selection are *not* DOM — they're Skia overlays drawn on `SurfaceId::UI`, driven by `TextEditorState`. See `references/v3-text-editor.md`.
---
## Drag rendering: tile cache vs backbuffer crop cache
Two caches are involved during drag:
- **Tile cache** (`render-wasm/src/render.rs`, `tiles.rs`) — chunks the frame into tiles, caches rendered tiles, invalidates on modifier fan-out.
- **Backbuffer crop cache** (`render::rebuild_backbuffer_crop_cache`, `is_safe_for_drag_crop_cache`) — for non-overlapping top-level shapes, snapshots a region of `Backbuffer` once at drag start and blits it during the drag instead of re-rendering the whole shape.
`is_safe_for_drag_crop_cache` (`shapes.rs:1394`) gates whether a candidate's cached image is *used*. It rejects: shape itself is `Type::Text`, frames with `clip_content=false` whose visible content exceeds bounds, shapes with blur, shadows, opacity != 1.0, or non-default blend mode.
**Historical pitfall:** a frame containing text *children* passes the gate (it's `Type::Frame`, not `Type::Text`), but the snapshot may have been taken before the text's paragraph layout completed — yielding a glyph-less fill rect during drag. The lesson is that `is_safe_for_drag_crop_cache` doesn't recurse through descendants; if the snapshot timing matters for what's inside, you need additional capture-time gating, not read-time gating.
---
## Don't add features beyond what's asked
This isn't a render-wasm-specific rule, but it bites here often: render-wasm has half-finished WIPs (PDF export, abandoned drag-sprite work). Don't extrapolate from one example to a generalization in your edit. If a function does X and the task is "do Y", do Y; don't refactor the X path "while you're here". See the root `AGENTS.md` for the broader principle.
---
## Pointer hygiene and `unsafe`
The Rust side has several `unsafe` blocks (state global, transmute for binary props, FFI call sites). When adding more:
- Document the safety invariant inline (`// SAFETY: ...`) — what makes this `unsafe` block sound.
- Prefer wrapping into a safe macro/function so the `unsafe` is in one place.
- For binary deserialization, the safety invariant is "the byte buffer matches the struct layout" — and the `offset_of!` tests are what enforces that invariant.

View File

@ -0,0 +1,111 @@
# File Map
Quick lookup of where things live. Organized by subsystem rather than by alphabet — when you're answering "where does X happen", scan the relevant section.
---
## WASM entry points + state
- `render-wasm/src/main.rs``init`, `set_browser`, `clean_up`, render driver, **state access macros** (lines 60-102: `with_current_shape!`, `with_current_shape_mut!`, `with_state_mut_current_shape!`)
- `render-wasm/src/state.rs` + `state/``State` struct, `ShapesPool`, `TextEditorState`
- `render-wasm/src/error.rs``Result<()>`, `#[wasm_error]` macro
- `render-wasm/src/mem.rs``write_bytes`, `free_bytes` (with global lock)
- `render-wasm/src/performance.rs``run_script!`, `gesture_record!`, perf markers
- `render-wasm/src/wapi.rs` — JS API helpers (request_animation_frame, cancel_animation_frame, etc.)
## Render path (Rust)
- `render-wasm/src/render.rs` — main render loop, `start_render_loop`, `process_animation_frame`, `render_shape_tree_partial`, tile cache, `rebuild_backbuffer_crop_cache`, `is_safe_for_drag_crop_cache` (read path)
- `render-wasm/src/render/surfaces.rs``SurfaceId` enum + `Surfaces` struct, surface alloc/canvas access
- `render-wasm/src/render/fills.rs`, `strokes.rs`, `shadows.rs` — per-effect drawing
- `render-wasm/src/render/filters.rs``render_into_filter_surface` (with `extra_downscale` for adaptive blur)
- `render-wasm/src/render/text.rs` — Skia text drawing
- `render-wasm/src/render/text_editor.rs` — V3 cursor + selection overlay (`render_overlay`, `calculate_cursor_rect`, `calculate_selection_rects`)
- `render-wasm/src/render/ui.rs``SurfaceId::UI` overlay compositing
- `render-wasm/src/render/grid_layout.rs` — grid layout debug viz
- `render-wasm/src/render/fonts.rs` — font management for rendering
- `render-wasm/src/render/images.rs` — image fill rendering
- `render-wasm/src/render/options.rs` — render options state
- `render-wasm/src/render/debug.rs` — debug overlay
- `render-wasm/src/render/gpu_state.rs` — Skia GPU context
- `render-wasm/src/tiles.rs` — tile rect math, tile index
## Shapes (Rust model)
- `render-wasm/src/shapes.rs` + `shapes/``Shape`, `Type`, properties
- `render-wasm/src/shapes/frames.rs`, `groups.rs`, `rects.rs`, `text.rs`, `bools.rs` — per-type
- `render-wasm/src/shapes/paths.rs` + `paths/` — path geometry
- `render-wasm/src/shapes/fills.rs`, `strokes.rs`, `shadows.rs`, `blurs.rs`, `blend.rs`, `corners.rs` — effects/properties
- `render-wasm/src/shapes/layouts.rs` + `modifiers.rs` + `modifiers/` (`flex_layout.rs`, `grid_layout.rs`) — layout computation
- `render-wasm/src/shapes/transform.rs` — shape transform (matrix)
- `render-wasm/src/shapes/text_paths.rs`, `stroke_paths.rs` — derived paths
- `render-wasm/src/shapes/svg_attrs.rs`, `svgraw.rs` — SVG-specific attrs
## WASM exports (the FFI surface)
- `render-wasm/src/wasm.rs` + `wasm/``#[no_mangle] extern "C"` functions grouped by subsystem
- `render-wasm/src/wasm/shapes/base_props.rs``set_shape_base_props` + `RawBasePropsData` (canonical binary-prop example)
- `render-wasm/src/wasm/text.rs`, `text/` — text shape exports
- `render-wasm/src/wasm/text_editor.rs` — V3 editor exports (lifecycle, cursor, selection, editing, navigation, render overlay)
- `render-wasm/src/wasm/text/helpers.rs` — V3 word boundary, cursor movement, deletion, insertion helpers
- `render-wasm/src/wasm/layouts.rs`, `layouts/grid.rs` — layout exports
- `render-wasm/src/wasm/paths.rs`, `paths/` — path exports
- `render-wasm/src/wasm/fills.rs`, `fills/` — fill exports
- `render-wasm/src/wasm/strokes.rs`, `shadows.rs`, `blurs.rs`, `blend.rs`, `transforms.rs`, `svg_attrs.rs`, `fonts.rs`, `mem.rs`
## Frontend bridge (CLJS)
- `frontend/src/app/render_wasm/api.cljs` — bridge: `set-view-box`, `render-finish`, `set-shape-*`, `sync-selection-rects!`, `update-text-rect!`, shape upload pipeline (`process-object`, `process-shapes-chunk`, `process-next-chunk`, `yield-to-browser`)
- `frontend/src/app/render_wasm/text_editor.cljs` — V3 editor JS FFI wrappers
- `frontend/src/app/render_wasm/exports/wasm.cljs` — exports/snapshot wiring
- `frontend/src/app/render_wasm/...` — other bridge namespaces
## Workspace components (CLJS)
- `frontend/src/app/main/ui/workspace/viewport_wasm.cljs` — viewport, calls `set-view-box`, manages overlay effects, editor selection logic at lines 467-480 (V3→V2→V1)
- `frontend/src/app/main/ui/workspace/viewport/actions.cljs` — pointer/wheel handlers (`on-pointer-move`, `schedule-zoom!`, `schedule-scroll!`, `on-mouse-wheel`)
- `frontend/src/app/main/ui/workspace/viewport/hooks.cljs``setup-hover-shapes`, `over-shapes-stream`, hover query coordination
- `frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs` — V3 contenteditable component (input wrapper)
- `frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs` — V2 editor component
- `frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss` — V2/V3 editor styles
- `frontend/src/app/main/ui/workspace/shapes/text/editor.cljs` — V1 (legacy Draft-JS) editor
## Worker side
- `frontend/src/app/worker.cljs``ask-buffered!`, dedupe-by-cmd, 1ms debounce
- `frontend/src/app/worker/selection.cljs` — quadtree selection queries
- `frontend/src/app/util/worker.cljs``ask-buffered!` send wrapper
## Text content (CLJS)
- `frontend/src/app/util/text/content/to_dom.cljs` — DOM builder
- `frontend/src/app/util/text/content/styles.cljs` — style mapping
- `frontend/text-editor/src/editor/TextEditor.css` — standalone editor CSS
## Feature flags
- `frontend/src/app/main/features.cljs:38-41``render-wasm/v1`, `text-editor/v2`, `text-editor-wasm/v1` (V3)
## Tests
- `frontend/playwright/ui/render-wasm-specs/shapes.spec.js`, `texts.spec.js` — render-wasm Playwright tests
- `frontend/playwright/data/render-wasm/get-file-*.json` — transit-encoded test fixtures
- `frontend/playwright/ui/pages/WasmWorkspacePage.js` — page object
- `frontend/playwright.config.js` — render-wasm project config (1920x1080, 2x DPR)
---
## Symptom → file map
| If you're chasing... | Open first |
|---|---|
| A drag visual glitch | `render.rs::rebuild_backbuffer_crop_cache`, `shapes.rs::is_safe_for_drag_crop_cache` |
| A `wasm-critical` panic from a worker | `performance.rs::run_script!` call sites — guard `document` access |
| A binary-prop deserialization mismatch | The relevant `Raw*Data` struct + its `offset_of!` tests |
| A WASM call that hangs | `mem::write_bytes` without a preceding `mem::free_bytes` |
| A flex/grid sizing oddity that's exactly 0.01 off | `MIN_SIZE` sentinel in `flex_layout.rs:16` / `grid_layout.rs:15` — don't remove it |
| Hover/zoom/pan freezing the UI | `api.cljs::render-finish``_set_view_end``render.rs::rebuild_tile_index` (sync, main thread) |
| Filter / blur / shadow perf | `render.rs::render_drop_black_shadow`, `render/filters.rs::render_into_filter_surface` (with `extra_downscale`) |
| V3 cursor not where you expect | `render/text_editor.rs::calculate_cursor_rect`, Skia paragraph layout dependency |
| Selection rect drawing oddly | `render/ui.rs`, `api.cljs::sync-selection-rects!` |
| A text shape's measured rect being stale | `update-text-rect!` in `api.cljs` — hook point after WASM layout |

View File

@ -0,0 +1,137 @@
# Performance Patterns
Recurring performance patterns in render-wasm and how to think about them. This isn't a bug catalog — it's the design lessons that have stuck.
---
## The frame budget
The render loop's frame budget is **32 ms** (two frames at 60Hz; nominal target is 16 ms but the loop tolerates the doubled budget). Anything that needs to happen "this frame" must fit, including:
- The full shape tree walk (or partial walk for the visible region)
- Any modifier fan-out and tile invalidation
- Skia draw calls for fills, strokes, shadows, blurs
- Compositing surfaces and submitting to GPU
When a single sync WASM call exceeds 32 ms on the main JS thread, DOM events queue and the user perceives a freeze. See "Main-thread blocking" below.
---
## Main-thread blocking from sync FFI
The single most common Penpot perf failure mode. `api.cljs` is on the main JS thread; any sync WASM call inside it blocks DOM events for the duration.
**Hot offenders:**
- `_set_view_end()``rebuild_tile_index()` (called via `render-finish`, debounced 100 ms). Walks all top-level shapes; cost scales with N.
- Shape upload pipeline (`process-object`, `process-shapes-chunk`). Chunks of ~100 shapes with `yield-to-browser` (MessageChannel-based, ~0 ms minimum) between batches, but each chunk blocks for its own duration.
- Per-pointermove sync calls (`text-editor-pointer-move`, hit testing). Cheap individually; lethal when other work is also blocking.
**Diagnostic signal:** events arrive in bursts after a quiet period.
**Solution patterns:** chunk + yield (`yield-to-browser` via `MessageChannel`), throttle the trigger, defer until interaction end. `OffscreenCanvas` + worker-side rendering is the heavy variant; nothing in render-wasm currently uses it.
---
## Tile cache invalidation
Two failure shapes:
**Over-invalidation** — modifier fan-out walks ancestors + descendants + tile coverage. If `expanded_count >> input_count` or `invalidated_tiles >> expected`, the bug is the walk.
**Under-rendering** — pending tiles don't reach the visible priority bucket fast enough. The 4-way split (`pending:cached`, `pending:uncached_visible`, `pending:uncached_interest`, `pending:total`) is what to instrument.
Files: `render-wasm/src/render.rs::rebuild_touched_tiles` (modifier-driven), `start_render_loop` (pending-tile partitioning).
---
## Backbuffer crop cache (drag fast path)
`backbuffer_crop_cache` (`render.rs::rebuild_backbuffer_crop_cache`) snapshots a region of `Backbuffer` at non-interactive frame time, then blits it during drag instead of re-rendering. Big perf win when correct.
Two failure shapes:
- **Capture-before-layout** — snapshot taken before async work (font load, paragraph layout) finishes. Result: cache holds the container's fill but no glyphs. *Visual: dragging a frame with text children shows a colored rect with no text.* Fix is capture-time gating, not read-time gating (the cache is never modified during drag, so use-time checks are too late).
- **Reading the cache when shape moved past invalidation** — stale tile reads. Less common.
Read-side gating: `is_safe_for_drag_crop_cache` in `shapes.rs` (rejects Type::Text, frames-with-overflow, blur, shadow, opacity, blend mode). It does NOT recurse into descendants — that's the trap with text children.
---
## Filter / blur / shadow
Historically: filters used `render_with_filter_surface`, which `surface_clone`s per filtered shape. Surface cloning is expensive; per-shape during pan/zoom dominates.
Current state (commit `337cfc2`, "Improve performance on shapes with blur"):
- **Layer blur via `save_layer`** — opens a `save_layer` with an `ImageFilter` on the Fills/Strokes/InnerShadows sub-surfaces directly. Avoids the surface clone, preserves clip correctness. (`render.rs:~793` `blur_sigma_for_layers`, `~1024` `save_layer` open.)
- **Drop shadow adaptive downscale**`blur_downscale = (BLUR_DOWNSCALE_THRESHOLD / shadow.blur).max(MIN_BLUR_DOWNSCALE)` for blur > 8 px. Gaussian blur is scale-equivariant, so downscaling + smaller sigma yields identical output with ~k³ less GPU work. Constants: `BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0`, `MIN_BLUR_DOWNSCALE: f32 = 0.125`.
- **`render_into_filter_surface(extra_downscale, ...)`** — generalized parameter passed by callers. `extra_downscale = 1.0` → no change; `< 1.0` → pre-scale the filter canvas before drawing. Constants `MIN_FIT_SCALE = 0.1` (per-axis overflow clamp), `MIN_COMBINED_SCALE = 0.03` (sub-pixel surface floor).
- **Fast mode**`render_preview` engages `fast_mode` to skip blur/shadow during preview. Frame-level `frame_clip_layer_blur` is gated on `!fast_mode`.
If a filter regression appears, first check whether `fast_mode` is being engaged on the path you'd expect, then check the `extra_downscale` value flowing into the filter surface.
Files: `render-wasm/src/render.rs` (`blur_sigma_for_layers`, `save_layer` open, `render_drop_black_shadow`), `render-wasm/src/render/filters.rs` (`render_into_filter_surface`).
---
## `gesture_record!` instrumentation
The `gesture_record!` macro (`render-wasm/src/performance.rs`) emits `(stage, value)` tuples via `run_script!` into `window.__penpotGestureRecord`. The JS side (`frontend/src/app/util/perf.cljs` when wired) buffers and reports.
Always compiled (no feature flag) because the call site is cheap when `__penpotGestureRecord` is undefined. Pattern at the call site:
```rust
let _instrument = self.options.is_interactive_transform();
let _t = if _instrument { performance::get_time_ms() } else { 0.0 };
// ... measured work ...
if _instrument {
let dt = performance::get_time_ms() - _t;
crate::gesture_record!("stage_name", dt);
}
```
Stages from a past instrumentation pass (since removed; the macro and these names are documented here so they can be restored quickly when needed):
- `render:rebuild_touched_tiles`, `render:start_render_loop`, `render:atlas_blit`, `render:pending_tiles_update`
- `render:shape_tree_partial`, `render:flush_and_submit`
- `render:tile_cached`, `render:tile_uncached_shapes`, `render:apply_to_canvas`
- `pending:total`, `pending:cached`, `pending:uncached_visible`, `pending:uncached_interest`
- `nodes:initial_queue`, `nodes:processed`, `nodes:skipped_hidden`, `nodes:skipped_invisible`
- `drag_subtree:modified`, `drag_subtree:total`
- `drag_backdrop:capture_tiles`, `drag_backdrop:capture_ms`
- `modifier_tiles:input_count`, `modifier_tiles:expanded_count`, `modifier_tiles:invalidated_tiles`
When restoring, gate on `is_interactive_transform()` unless you specifically want zoom/pan timing too. The CLJS-side receiver (`__penpotGestureRecord`) lived in `frontend/src/app/util/perf.cljs`; recover that file from history together with the Rust call sites if you bring back the full pipeline.
---
## Drag-sprite approach: abandoned
The drag-sprite approach (captured sprite + atlas backdrop) was tried and abandoned. It was too fragile — every tile-cache eviction path needed a "drag active?" guard, and the surface area for "we forgot one" was unbounded.
The decision was to optimize the normal drag render path instead (backbuffer crop cache, fast mode, filter perf work above). 439 lines were removed from `render-wasm/src/{render,main,render/surfaces}.rs` when this was abandoned.
**Lesson:** when a perf optimization requires invariants enforced across many call sites, count the call sites first. If you can't enumerate them, the optimization will leak.
---
## CLJS-side perf: subscription churn
Subscriptions that re-evaluate on every modifier or every frame can dominate sidebar interaction cost. Patterns to watch:
- A sub depends on `objects-modified` (changes every modifier tick) when it could depend on a coarser signal.
- A render component reads a sub that returns a fresh map/vector each call → equality breaks, re-renders cascade.
- `with-meta` wrapping a value upstream → defeats memoization. (`viewport_wasm.cljs`'s `frame-titles*` had a spurious `with-meta` removed in the blur perf commit.)
- Outline/overlay components rendering during pan when they could be hidden — gate on `(not panning)` like `show-frame-outline?` and `show-outlines?` in `viewport_wasm.cljs`.
---
## Decision principles
When optimizing render-wasm:
- **Prefer to make the slow path skip-able rather than make it faster.** `fast_mode`, `is_interactive_transform()`, and the cache check before render are all this pattern.
- **Cache at non-interactive moments; blit during interactive moments.** Backbuffer crop cache, tile cache, atlas all follow this.
- **Keep the JS main thread free.** Anything that doesn't need the main thread (worker queries, deferred WASM calls) should be off it. `OffscreenCanvas` is the eventual destination for the render loop itself if main-thread work becomes the dominant cost.
- **Measure before refactoring.** `gesture_record!` is cheap to wire up; speculative optimizations have a poor ROI in this codebase because the hot paths are usually obvious in the timings once instrumented.

View File

@ -0,0 +1,133 @@
# V3 Text Editor (`text-editor-wasm/v1`)
The V3 editor is the WASM-native text editor: all editing logic in Rust, cursor and selection rendered as Skia overlays on `SurfaceId::UI`. The CLJS side is a thin contenteditable wrapper that captures input events and forwards them to WASM.
---
## Editor generations
| Gen | File | Feature flag | Notes |
|-----|------|--------------|-------|
| V1 | `frontend/src/app/main/ui/workspace/shapes/text/editor.cljs` | (none / default) | Legacy Draft-JS |
| V2 | `frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs` | `text-editor/v2` | DOM-based contenteditable, JS logic |
| V3 | `frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs` | `text-editor-wasm/v1` | WASM-native, Skia overlay |
Selection logic in `viewport_wasm.cljs:467-480` checks flags in order: V3 → V2 → V1.
## Feature flags (`frontend/src/app/main/features.cljs:38-41`)
- `render-wasm/v1` — enables WASM rendering; **automatically enables** `text-editor/v2`
- `text-editor/v2` — V2 editor (auto-enabled with render-wasm)
- `text-editor-wasm/v1` — V3 editor (independent flag, only functional with render-wasm active)
---
## Data flow
```
User input on contenteditable div
→ v3_editor.cljs event handler (keydown / beforeinput / compositionend / paste / cut / copy / dblclick)
→ wasm.api/text-editor-* call (via render_wasm/text_editor.cljs)
→ state/text_editor.rs processes operation (insert, delete, move, select)
→ TextEditorState updated (selection, content, blink)
→ text_editor_export_content() → JSON {paragraphs, spans, text}
→ CLJS merge-exported-texts-into-content (preserves per-span styling)
→ v2-update-text-shape-content Redux action
→ request-render → Skia draws text + overlay (cursor / selection)
```
Two key implications:
1. After every text mutation, the CLJS side must call `sync-wasm-text-editor-content!` to export and update Redux state. Forgetting this leaves the DOM and WASM out of sync.
2. Cursor and selection are NOT DOM. They live in `TextEditorState` and render via `text_editor_render_overlay()` to `SurfaceId::UI`.
---
## Key files
### Rust / WASM
- `render-wasm/src/state/text_editor.rs``TextEditorState` struct: selection, blink, active shape, pointer tracking
- `render-wasm/src/wasm/text_editor.rs``#[no_mangle]` exports: start/stop, cursor, selection, editing, navigation, render overlay
- `render-wasm/src/wasm/text/helpers.rs` — word boundary detection, cursor movement (forward/backward/up/down/line-start/end), deletion, insertion
- `render-wasm/src/render/text_editor.rs``render_overlay()`, `calculate_cursor_rect()`, `calculate_selection_rects()` using Skia
### Frontend (CLJS)
- `frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs` — V3 component (contenteditable + event handlers)
- `frontend/src/app/render_wasm/text_editor.cljs` — JS FFI wrappers for all WASM text editor functions
- `frontend/src/app/render_wasm/api.cljs` — public API re-exports, render loop integration (blink, overlay, poll-event)
---
## WASM export functions (`render-wasm/src/wasm/text_editor.rs`)
| Group | Exports |
|---|---|
| **Lifecycle** | `text_editor_start(a,b,c,d)`, `text_editor_stop()` |
| **Cursor** | `text_editor_set_cursor_from_offset(x,y)`, `text_editor_set_cursor_from_point(x,y)` |
| **Selection** | `text_editor_pointer_down/move/up(x,y)`, `text_editor_select_word_boundary(x,y)`, `text_editor_select_all()` |
| **Editing** | `text_editor_insert_text()`, `text_editor_delete_backward(word_boundary)`, `text_editor_delete_forward(word_boundary)`, `text_editor_insert_paragraph()` |
| **Navigation** | `text_editor_move_cursor(direction, word_boundary, extend_selection)` |
| **Rendering** | `text_editor_render_overlay()`, `text_editor_update_blink(timestamp_ms)` |
| **Events** | `text_editor_poll_event()``TextEditorEvent` (`ContentChanged`, `SelectionChanged`, `NeedsLayout`) |
| **Export** | `text_editor_export_content()`, `text_editor_export_selection()` |
`text_editor_move_cursor` direction enum: `0`=Backward, `1`=Forward, `2`=LineBefore, `3`=LineAfter, `4`=LineStart, `5`=LineEnd. Boolean flags: `word_boundary`, `extend_selection`.
**Direction/flag enum drift is a common source of bugs** — keep CLJS-side constants in lockstep with Rust enums.
---
## V2 vs V3 differences
| Aspect | V2 | V3 |
|---|---|---|
| Text operations | JS / DOM mutations | Rust / WASM |
| Cursor | DOM native | WASM state + Skia overlay |
| Selection | DOM native | WASM state + Skia overlay |
| State location | JS objects + Redux | WASM `TextEditorState` |
| Styling | Direct DOM style manipulation | WASM stores per-span; exports JSON |
| Synchronization | Event-driven DOM | Explicit export after each WASM op |
---
## Common pitfalls
### Skia layout dependency for cursor rect
`calculate_cursor_rect` calls `get_rects_for_range()` and `get_line_metrics()` on the Skia paragraph. These return correct values only after the paragraph has been laid out (`paragraph.layout(width)`). If a cursor query fires before layout, it can return zeroed or stale rects.
**Fix shape:** always check that the paragraph is laid out before querying. The hook point in CLJS is `update-text-rect!` (in `api.cljs`), which fires after WASM layout completes.
### Cursor blink across start/stop
`text_editor_update_blink(timestamp_ms)` is called every frame. Verify that blink doesn't break on start/stop transitions — the blink phase resets on cursor movement, but a missed reset can leave the cursor invisible.
### Multi-line / cross-paragraph selection
`calculate_selection_rects()` must handle multi-line spans, cross-paragraph selections, and RTL text. RTL is the easy thing to forget; if RTL is in scope, write a test for it.
### Word boundary
`is_word_char()` (in `wasm/text/helpers.rs`) treats alphanumeric + underscore as word chars. Verify behavior matches platform conventions when adding new word-boundary cases (CJK, combining marks, emoji).
### Content export format
`text_editor_export_content()` returns JSON with `{paragraphs, spans, text}`. Verify it matches what `merge-exported-texts-into-content` in CLJS expects — schema drift here silently corrupts text content.
### Event handlers in `v3_editor.cljs`
Should handle: `keydown` (navigation, deletion), `beforeinput` (text insert), `compositionend` (IME), `paste`, `copy`, `cut`, `dblclick` (word select). Missing one of these doesn't immediately break the editor but leaves a feature gap.
### Feature flag gating
New V3 behavior should be behind `text-editor-wasm/v1`. Don't leak V3 changes into V2 paths — V2 stays as it is until V3 fully replaces it.
---
## Architectural distinctions
- **Dual rendering still applies.** The contenteditable DOM has `color: transparent`; Skia draws the visible text. V3 changes *only the cursor and selection* from DOM-native to Skia overlay; the visible text is still Skia-rendered in both V2 and V3.
- **Editor instance** stored at `:workspace-editor` in app state; root DOM via `(.-root editor)`.
- **State export** is the synchronization mechanism. After any WASM mutation, export → merge into CLJS content → dispatch to Redux. The merge is non-trivial because spans carry styling that the WASM side stores per-span.

View File

@ -32,6 +32,16 @@ precision while maintaining a strong focus on maintainability and performance.
5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects
`.gitignore` by default.
## Skills
Project-shipped skills live under `.opencode/skills/`. Notable skills for
domain-specific work:
- `.opencode/skills/penpot-render-wasm/` — deeper architecture, conventions,
V3 text editor internals, and performance design lessons for the
Rust/WASM render layer and its CLJS bridge. Complements
`render-wasm/AGENTS.md`.
## Changelogs
The project has two changelogs:

View File

@ -30,8 +30,13 @@ configured to 256 MB initial with geometric growth.
## Architecture
**Global state** — a single `unsafe static mut State` accessed
exclusively through `with_state!` / `with_state_mut!` macros. Never
access it directly.
exclusively through the `with_state!`, `with_state_mut!`,
`with_current_shape!`, `with_current_shape_mut!`, and
`with_state_mut_current_shape!` macros (defined at the top of
`src/main.rs`). Never access the global directly. Use
`with_current_shape_mut!` when modifying a shape — it calls
`state.touch_current()` to mark the shape dirty for re-render;
read-modify-writes that bypass it will silently skip invalidation.
**Tile-based rendering** — only 512×512 tiles within the viewport
(plus a pre-render buffer) are drawn each frame. Tiles outside the
@ -48,11 +53,12 @@ parent/child relationships are tracked separately.
| Path | Role |
|------|------|
| `src/lib.rs` | WASM exports — all functions callable from JS |
| `src/state.rs` | Global `State` struct definition |
| `src/render/` | Tile rendering pipeline, Skia surface management |
| `src/shapes/` | Shape types and Skia draw logic per shape |
| `src/wasm/` | JS interop helpers (memory, string encoding) |
| `src/main.rs` | WASM entry points (`init`, `clean_up`, render driver) and the state-access macros |
| `src/state.rs`, `src/state/` | Global `State` struct, `ShapesPool`, `TextEditorState` |
| `src/wasm/` | `#[no_mangle] extern "C"` exports grouped by subsystem (shapes, text, layouts, paths, fills, ...) |
| `src/render.rs`, `src/render/` | Render loop, tile cache, Skia surface management |
| `src/shapes.rs`, `src/shapes/` | Shape types, layouts, modifiers, transforms, per-shape draw inputs |
| `src/mem.rs` | WASM memory allocation (`write_bytes`, `free_bytes`) used by the FFI |
## Frontend Integration
@ -60,3 +66,10 @@ The WASM module is loaded by `app.render-wasm.*` namespaces in the
frontend. ClojureScript calls exported Rust functions to push shape
data, then calls `render_frame`. Do not change export function
signatures without updating the corresponding ClojureScript bridge.
## Deeper Context
For longer-form architecture, conventions, V3 text editor internals,
and performance design lessons, see the `penpot-render-wasm` skill at
`.opencode/skills/penpot-render-wasm/`. This `AGENTS.md` is the
short-form module guide; the skill carries the depth.