Skip to content

Design ergonomic Glyph mutation API that hides NAPI cost #39

@kostyafarber

Description

@kostyafarber

Problem

Every structural glyph mutation (addPointToContour, insertPointBefore, addContour, closeContour, etc.) goes through NativeBridge.#dispatch(), which:

  1. Calls Rust via NAPI
  2. Rust serializes the entire glyph snapshot as JSON (all 50K+ points)
  3. JS JSON.parses the full response
  4. JS reconciles the entire reactive model via glyph.apply(snapshot)#syncFromSnapshot()

At 50K points this costs ~80ms p50 / ~400ms p95 per point addition (#37).

Meanwhile, the draft system (createDraft / setPositions / finish) is fast (~2ms at 50K) because it skips NAPI during the hot loop and syncs once at the end. But tool authors have to know to use it — and it only works for position changes, not structural mutations.

Current API surface (too many patterns)

Pattern When to use Cost
glyph.addPointToContour() Structural change O(N) full snapshot round-trip
glyph.movePointTo() Absolute position set O(N) snapshot + O(N) findPoint + NAPI
editor.createDraft()draft.setPositions() Position drag hot path O(change) JS-only
glyph.movePoints() Delta position change O(N) full snapshot round-trip

Tool and plugin authors shouldn't need to remember which of these to call. glyph.addPointToContour() should just be fast.

Design goals

  • Single ergonomic API on Glyph — tool authors call glyph.addPoint(), glyph.movePointTo(), etc. and the system picks the fast path
  • Structural mutations should be O(1), not O(N) — adding one point to a 50K-point glyph shouldn't serialize all 50K points
  • Draft pattern should be internal — tools shouldn't need to manually create/finish/discard drafts for common operations
  • Plugin authors get the same API — no "if you're writing a plugin, use this other lower-level thing"

Possible approaches

A: Local-first Glyph model + lazy Rust sync

Glyph model applies mutations locally in JS (assigns temp IDs), queues them, and batch-syncs to Rust on the next frame or at explicit checkpoints (undo recording, save). Rust becomes the persistence layer, not the authority for every mutation.

B: Lightweight Rust responses

Add NAPI methods that return just the assigned point ID (as f64) instead of the full CommandResult JSON. JS patches the model locally with the real ID. Rust stays authoritative but the response is O(1).

C: Batched command protocol

Accumulate multiple structural mutations into a single NAPI call that returns one snapshot. #createHandles currently does 2-4 separate round-trips — a batch would make it 1.

D: Hybrid (B + C)

Lightweight responses for individual mutations (pen clicks), batched protocol for compound operations (handle creation). Draft system stays for position-only drags but is hidden behind the Glyph API.

Context

Files

  • apps/desktop/src/renderer/src/bridge/NativeBridge.ts#dispatch, #execute, movePointTo
  • apps/desktop/src/renderer/src/lib/model/Glyph.ts — reactive model, apply(), #syncFromSnapshot()
  • apps/desktop/src/renderer/src/lib/editor/Editor.tscreateDraft()
  • apps/desktop/src/renderer/src/types/draft.tsGlyphDraft interface
  • crates/shift-node/src/font_engine.rs — NAPI command helpers, CommandResult serialization
  • crates/shift-core/src/snapshot.rsCommandResult::success() always includes full snapshot

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions