diff --git a/.opencode/skills/penpot-render-wasm/SKILL.md b/.opencode/skills/penpot-render-wasm/SKILL.md new file mode 100644 index 0000000000..5ce30305c5 --- /dev/null +++ b/.opencode/skills/penpot-render-wasm/SKILL.md @@ -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. diff --git a/.opencode/skills/penpot-render-wasm/references/architecture.md b/.opencode/skills/penpot-render-wasm/references/architecture.md new file mode 100644 index 0000000000..d1caff9bfb --- /dev/null +++ b/.opencode/skills/penpot-render-wasm/references/architecture.md @@ -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 │ + │ ├─ 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>`. +- **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. diff --git a/.opencode/skills/penpot-render-wasm/references/build.md b/.opencode/skills/penpot-render-wasm/references/build.md new file mode 100644 index 0000000000..1891275eba --- /dev/null +++ b/.opencode/skills/penpot-render-wasm/references/build.md @@ -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. diff --git a/.opencode/skills/penpot-render-wasm/references/conventions.md b/.opencode/skills/penpot-render-wasm/references/conventions.md new file mode 100644 index 0000000000..d4e91d81e7 --- /dev/null +++ b/.opencode/skills/penpot-render-wasm/references/conventions.md @@ -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>` 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::()` matches expected size + - `std::mem::align_of::()` 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>`; 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. diff --git a/.opencode/skills/penpot-render-wasm/references/file-map.md b/.opencode/skills/penpot-render-wasm/references/file-map.md new file mode 100644 index 0000000000..cb2e7d496f --- /dev/null +++ b/.opencode/skills/penpot-render-wasm/references/file-map.md @@ -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 | diff --git a/.opencode/skills/penpot-render-wasm/references/perf.md b/.opencode/skills/penpot-render-wasm/references/perf.md new file mode 100644 index 0000000000..f8a6ce4bc7 --- /dev/null +++ b/.opencode/skills/penpot-render-wasm/references/perf.md @@ -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. diff --git a/.opencode/skills/penpot-render-wasm/references/v3-text-editor.md b/.opencode/skills/penpot-render-wasm/references/v3-text-editor.md new file mode 100644 index 0000000000..d665080c16 --- /dev/null +++ b/.opencode/skills/penpot-render-wasm/references/v3-text-editor.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index dac88e8261..df46b065c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: diff --git a/render-wasm/AGENTS.md b/render-wasm/AGENTS.md index 511b7da0c9..c8a0212b26 100644 --- a/render-wasm/AGENTS.md +++ b/render-wasm/AGENTS.md @@ -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.