53 KiB
Rust Shell
Native + web editor chrome implemented in Rust against jian-skia. Goal: TS-equivalent editor UI surface so the backend can later swap underneath without UI regressions.
Crate layout
crates/
├── openpencil-shell-core/ Platform-free widgets + Document model + RenderBackend trait
├── openpencil-shell-native/ Native lib: WidgetHostNative + NativeBackend + SharedSkiaContext
├── openpencil-shell-web/ Browser runner: wasm32-unknown-unknown + skia-safe-op fork
├── openpencil-desktop/ Desktop binary: winit event loop + skia-safe GL surface
└── wasm-libc-shim/ ~95 env.* shims (libc / libm / libcxx) for the wasm32 build
vendor/skia-safe-op/ is a fork of rust-skia that compiles for wasm32-unknown-unknown (no emscripten runtime); vendor/jian/ is a submodule providing the rendering primitive layer (jian-skia Skia adapter, jian-host-desktop GL plumbing, jian-core event types). Both are referenced by path in workspace Cargo.toml.
Key invariants
- shell-core stays wasm32-clean (spec v19 §1.2). No skia-safe / winit / accesskit_winit. The
RenderBackendtrait is the only seam between widget code and platform. - Widget code lives in shell-core only. Hosts (shell-native
widget_host.rs, shell-webwidget_host.rs) are the ONLY files allowed to callopenpencil_shell_core::widgets::*. Boundary script:tools/check-widget-boundary.sh. - Max 800 lines per file — same rule as the TS workspace.
property_panel.rsis split into 5 files (see PropertyPanel row in the widget table).widget_host.rs(shell-native) is split into a slim spine + 8 sibling submodules underwidget_host/(see "Native widget_host layout" below).document.rsis split into a spine + sibling submodules underdocument/— types indocument.rs,impl Documentinmutators.rs, free walkers +ReorderDirectioninwalkers.rs, chat types inchat.rs, pen-tool ops inpen.rs, color-picker state incolor_picker.rs, group/ungroup ops ingrouping.rs, page CRUD inpage_mutators.rs, tests split intotests_mutators.rs+tests_geometry.rs. - Web bundle ceiling: 1 MiB gzip + 0 env.* imports. Enforced by
tools/check-wasm-bundle.sh. Ceiling currently at ~916 KB after embedding Roboto + NotoSansCJK subset.
Document model (shell-core/src/document/)
Single source of truth for editor state — mirrors TS useCanvasStore + useDocumentStore + useAIStore collapsed into one.
Document
├── pages: Vec<Page> (id + name + nodes)
├── active_page_index
├── selected: NodeId (NONE = no selection)
├── tool: Tool (Select / Rect / Ellipse / Polygon / Line / Pen / Text / Frame / Hand)
├── viewport: Viewport (pan_x / pan_y / zoom + zoom_at + pan)
├── chat: ChatState (messages, input, focused, anchor, collapsed)
└── ui: UiState (sidebar_open, layer_panel_width, property_panel_width,
property_focus, property_input_draft, property_caret_anchor_ms,
property_draft_select_all,
settings_input_draft,
agent_settings_open, agent_settings (focus, tab, connected[5],
mcp_server, mcp_cli_enabled[6], images_*, hover_provider),
color_picker, pen_in_progress, pen_cursor_doc,
pending_pen_history,
layer_context_menu, page_context_menu,
theme_mode, locale, locale_picker_open,
shape_picker_open, shape_tool,
flex_layout, size_fill_width / fill_height / hug_width /
hug_height / clip_content, fill_type, fill_type_picker_open,
property_tab (Design|Code))
Mutators on Document:
commit_property_edit(focus, value)— write parsed f32 to position / size / rotation / stroke width.set_selected_color(is_fill, color)— write hex-parsedColorto fill or stroke.set_selected_bounds(rect)— handle-drag resize.set_selected_rotation(radians)— rotation-ring drag.translate_selected(dx, dy)— node-drag move (recurses into descendants when the matched node is bounded so children don't detach).delete_selected()— remove the selected node from its parent's children (Delete / Backspace shortcut).duplicate_selected(&mut next_id, offset_doc_px)— deep-clone with fresh ids; lifts the allocator pastmax_node_id() + 1(checked_addsou64::MAXreturns None instead of colliding).reorder_selected(ReorderDirection::Up | Down)— swap with next/prev sibling ([/]).deselect_all()— clear selection (Escape last tier).max_node_id()— largest raw id across pages + children, for the duplicate allocator guard.node_at_doc_point(p)— top-most-first hit-test honoring per-node rotation.start_pen_path/add_pen_point/finish_pen_path(document/pen.rs) — multi-anchorNodeKind::Pathbuilder; history snapshot captured BEFORE the first anchor so undo restores pre-pen state; finish strips 1-anchor (invisible) paths instead of polluting the undo stack.group_selected/ungroup_selected(document/grouping.rs) — Cmd+G / Cmd+Shift+G; group wraps selected siblings under a freshNodeKind::Groupwhoseaggregate_boundscovers the union.add_page/duplicate_page/remove_page/rename_page_committed(document/page_mutators.rs).open_color_picker(target, click_y)(document/color_picker.rs) — anchors a floating HSV picker; HSV stays anchored across the RGB-rounding cycle so dragging the hue slider doesn't visibly snap.
Node.rotation: f32 (radians, cw +); paint applies RenderBackend::rotate(radians, pivot) around the node's centre. Bounded-Frame drag carries descendants — children's bounds are document-space-absolute. Node.corner_radius: f32 (doc-px) is honored by Rect / Frame paint via fill_round_rect / stroke_round_rect when radius * zoom > 0.5; below that threshold paint collapses to fill_rect / stroke_rect.
Node::aggregate_bounds returns child-union bounds for container nodes (Group / unbounded Frame) so the property panel reports meaningful W/H.
NodeKind now spans Frame / Group / Rect / Ellipse / Polygon / Line / Text / Path / Other; each has its own canvas paint (oval, triangle polygon, diagonal line, fill+stroke rect, polyline through node.points, draw_str). The RenderBackend trait grew fill_oval / stroke_oval / fill_polygon / stroke_polygon / rotate / fill_svg_path so both native + web backends can paint them; fill_svg_path covers brand logos that ship as filled SVG paths in a non-24×24 viewBox.
FillType { Solid, LinearGradient, RadialGradient, Image } + FlexLayout { Free, Vertical, Horizontal } drive the property panel's dropdowns / button groups; both live on Document.ui so toggles persist across selection changes.
Widgets (shell-core/src/widgets/)
| Widget | Section | File |
|---|---|---|
| TopBar | Top — file name, agent chip, theme/i18n/fullscreen, sidebar toggle | top_bar.rs |
| LayerPanel | Left rail — Pages + Layers sections | layer_panel.rs |
| Toolbar | Vertical floating column — Select / shape slot / Text / Frame / Hand | toolbar.rs |
| ShapePicker | Toolbar shape-slot dropdown (Rect / Ellipse / Polygon / Line / Pen / Icon / Import) | shape_picker.rs |
| CanvasViewport | Center — node tree + grid + viewport transform | canvas_viewport.rs |
| PropertyPanel | Right rail — 设计/代码 tabs + 10 sections + interactive inputs (X/Y/W/H/R, hex, stroke width) + flex/size toggles + fill-type picker | property_panel.rs + property_panel_sections.rs + property_panel_inputs.rs + property_panel_layout.rs + property_panel_fill.rs |
| AIChatPlaceholder | Floating — chat with drag + 4-corner snap + collapse pill | ai_chat_panel.rs |
| LocalePicker | TopBar Globe-button dropdown (15 native names + Check) | locale_picker.rs |
| StatusBar | Floating bottom-right — zoom controls | status_bar.rs |
| icons | lucide d-string library (50+ icons; 24×24 viewBox stroke art) | icons.rs |
| brand_icons | Claude / OpenAI / Gemini / Copilot / OpenCode brand logos (filled SVG paths, non-24×24 viewBoxes) | brand_icons.rs |
| ColorPicker | HSV overlay (Cmd-Shift-C or fill/stroke swatch click) — sat/value box + hue strip + hex input | color_picker.rs |
| LayerContextMenu | Right-click overlay on layer rows + page tabs (Rename / Duplicate / Delete / Group / Ungroup / Lock / Hide; subset on page tabs) | layer_context_menu.rs |
| AgentSettings | Cmd+, modal — 880×640 with sidebar nav (Agents / MCP / Images / System) + scrollable right pane | agent_settings_panel.rs + agent_settings_{i18n,images,mcp,system}.rs |
| theme | shadcn-dark palette tokens (incl. canvas_surface) |
theme.rs |
| i18n | 15 locale tables (706 keys each, TS-mirrored) — extracted into the op-i18n crate |
op-i18n/src/i18n/{en,zh_cn,zh_tw,ja,ko,fr,es,de,pt,ru,hi,tr,th,vi,id}.rs |
Theme + i18n
Document.ui carries chrome-level state including theme_mode (Dark/Light), locale, and locale_picker_open:
-
Document::theme()returns the activeTheme. Widget builders read it instead of hardcodingTheme::dark(), so flipping the TopBar Sun icon reflows the entire chrome. -
Document::t(key)translates viai18n::translate(self.ui.locale, key). Keys follow the TSapps/web/src/i18n/locales/*.tsdot.case convention (common.untitled,pages.title,layers.title,ai.newChat,ai.tipSelectElements,rightPanel.design,layout.flexLayout,fill.title,stroke.title,effects.title,export.title,property.createComponent,topbar.agentsAndMcp). -
15 supported locales (matches TS dropdown order): EnUs / ZhCn / ZhTw / Ja / Ko / Fr / Es / De / Pt / Ru / Hi / Tr / Th / Vi / Id. Each carries a
display_name()(English / 简体中文 / 繁體中文 / 日本語 / 한국어 / Français / Español / Deutsch / Português / Русский / हिन्दी / Türkçe / ไทย / Tiếng Việt / Bahasa Indonesia). -
TopBar Globe-button is a 44 px-wide compound (globe + chevron-down) opening a
LocalePickerdropdown — clicking a row setsDocument.ui.localeand closes; clicking outside (or the Globe again) closes silently. The picker paints as the top-most overlay so it covers chat / status / canvas. -
Multi-script chrome strings (한국어 / हिन्दी / ไทย / Tiếng Việt) render against per-codepoint typeface lookups (
FontMgr::match_family_style_charactercached peri32inNativeBackend), with each string broken into contiguous-typeface segments before draw. -
Tables are generated by
tools/convert-locales.py. Re-run after changing TS locales:python3 tools/convert-locales.pyEach locale file is ≤ 730 lines (under the 800-line ceiling). Cross-locale fallback: missing keys try EN before falling through to the key itself for debug visibility.
Toolbar shape-tool dropdown
The toolbar's compound ShapeSlot paints whichever shape variant is current (ui.shape_tool, default Rect) plus a small chevron-down in the gutter directly below the button (SHAPE_SLOT_BOTTOM_EXTRA = 10 px). Click anywhere on the slot — including the chevron — to toggle ui.shape_picker_open.
ShapePicker::for_document(doc) paints a 220 × 7-row dropdown anchored to the right of the slot. The seven rows mirror the TS shape-tool-dropdown verbatim:
- Rectangle / Ellipse / Polygon / Line / Pen →
ShapeChoice::Tool(Tool::*)— the host writesui.shape_tool+doc.tooland closes the panel. - Icon →
ShapeChoice::OpenIconPicker(host follow-up). - Import Image or SVG… →
ShapeChoice::ImportImageOrSvg(host follow-up).
Click anywhere outside the panel closes it silently. Locale lookups for the row labels (shapes.rectangle / ellipse / polygon / line / icon / importImageSvg / pen) come straight from the TS table; missing keys fall back to English literals.
PropertyPanel input editing
Document.ui carries the focused property field, a draft buffer, and a caret-blink anchor:
property_focus: Option<PropertyFocus>—PositionX / PositionY / Rotation / PositionR / SizeW / SizeH / Opacity / FillHex / StrokeHex / StrokeWidth. All 10 variants are wired end-to-end: numeric focuses go throughDocument::commit_property_edit(PositionRwritesnode.corner_radius), hex focuses throughset_selected_color(is_fill, color).property_input_draft: String— live keystrokes accumulate here.apply_textis focus-aware:- Numeric focuses (Position / Size / Rotation / Opacity / StrokeWidth) gate
[0-9], leading-, and a single.. - Hex focuses (FillHex / StrokeHex) preserve a sticky
#prefix, accept[0-9a-fA-F]only, and cap the draft at 7 chars (#RRGGBB). No select-all-on-focus — backspace removes one char at a time, typing appends one.
- Numeric focuses (Position / Size / Rotation / Opacity / StrokeWidth) gate
property_caret_anchor_ms: u64— drives caret blink off the samejian_core::anim::blink_visiblecadence as the chat input.
Hex parsing is forgiving: parse_hex_color zero-pads 1-5 char inputs to 6 and expands CSS shorthand #RGB → #RRGGBB, so mid-edit commits don't visibly "reset" the colour.
PropertyPanel::for_selection_at(doc, now_ms) is the entry point. The host calls panel.hit_test(panel_rect, point) to map clicks onto a PropertyFocus, and panel.hit_test_action(panel_rect, point) to map clicks onto a PropertyPanelAction. Commit on Enter, discard on Escape, auto-commit on click outside the property panel.
Buttons + checkboxes — PropertyPanelAction
PropertyPanelAction
├── SetFlexLayout(FlexLayout) Free / Vertical / Horizontal
├── ToggleSizeFillWidth / FillHeight
├── ToggleSizeHugWidth / HugHeight
├── ToggleSizeClipContent
├── ToggleFillTypePicker head-row dropdown
└── SetFillType(FillType) Solid / LinearGradient / RadialGradient / Image
The hit-test walker action_button_rects_with_fill_picker(panel_rect, visible, fill_picker_open) lives in property_panel_layout.rs and emits one Rect per action. Same y-walk math as editable_input_rects so paint + hit-test stay in sync regardless of which sections are filtered.
Fill-type dropdown
FillType { Solid, LinearGradient, RadialGradient, Image } lives on Document.ui. The Fill section head row paints <swatch> <type-label ▾> <opacity%> <X>; clicking the label opens an overlay popover with 4 rows. Body branches per type:
- Solid — hex input + caret.
- LinearGradient — Angle row + 色标 header + 2 default stops.
- RadialGradient — 色标 header + 2 stops (no angle).
- Image — 填充 row.
fill_body_height(fill_type) in property_panel_layout.rs returns the body height per variant; layout walkers thread it through VisibleSections { …, fill_type } so sections after Fill stay aligned with paint when the user flips type. Outside clicks close the picker via a dedicated swallow branch in apply_press, above all other property-panel hit-tests.
Per-NodeKind section filtering
SectionCapabilities::for_kind(NodeKind) returns which sections paint for the current selection (Frame omits Stroke, Text omits Effects/Export, etc.). The returned VisibleSections is threaded through every paint routine and both layout walkers so hidden sections cause subsequent rects to shift up by the right amount.
File split
property_panel_sections.rs was split into 5 files to honor the 800-line ceiling:
property_panel.rs—PropertyPanel, snapshot,SectionCapabilities, hit-test entry points.property_panel_sections.rs— section paint routines +PropertyLabels+EditContext.property_panel_inputs.rs— shared paint helpers (label / divider / input variants), layout constants,format_color_hex,to_jian_color.property_panel_layout.rs—VisibleSections/SizeFlags/fill_body_height+ the two layout walkers.property_panel_fill.rs— fill-type label table, picker overlay, head row, all 4 body variants.
PropertyLabels::for_document(doc) resolves every section title (位置/弹性布局/尺寸/图层/填充/描边/效果/导出), the 设计/代码 tabs, the 创建组件 button, and the size checkboxes (填充宽/高 / 适应宽/高 / 裁剪内容) via Document::t, falling back to English when a key isn't in the TS locale table.
RenderBackend trait
fill_rect / stroke_rect / draw_text / clip_rect
save / restore / translate
stroke_line / fill_round_rect / stroke_round_rect / stroke_svg_path
resize / dpi_scale
stroke_svg_path parses lucide d-strings via skia_safe::utils::parse_path::from_svg. PaintCap::Round + PaintJoin::Round to match lucide's stroke style.
Native widget_host layout
crates/openpencil-shell-native/src/widget_host.rs is a slim spine (~265 lines) holding the public surface — WidgetHostNative struct, drag-state structs, CursorHint enum, PanelResizeKind enum, the constructor and tiny accessors (set_now_ms / chat_focused / next_animation_deadline_ms) — plus mod declarations for the sibling submodules under widget_host/:
| File | Purpose |
|---|---|
widget_host/frame_backend.rs |
NativeFrameBackend (RenderBackend impl over NativeBackend + &Canvas) |
widget_host/helpers.rs |
parse_hex_color / color_to_hex / rect_contains / resize_bounds + the inset / gutter / width constants |
widget_host/geometry.rs |
impl WidgetHostNative — canvas-region / panel-resize hover / cursor hint / picker rect math |
widget_host/input.rs |
impl WidgetHostNative — apply_wheel / _pan_gesture / _cursor_move / _release[_with_viewport] / _text / _backspace / _send / _escape / _property_action / commit_property_focus_if_any / _click |
widget_host/press.rs |
impl WidgetHostNative — apply_press + create_node_for_active_tool (largest single method; routes through 10 hit-test layers) |
widget_host/paint.rs |
impl WidgetHostNative::paint — full editor-UI composition pass |
Keyboard shortcuts
Native (openpencil-desktop) + web (shell-web) both dispatch the following P1 keyboard shortcuts through WidgetHostNative / WidgetHost methods. The desktop runner reads modifier state from WindowEvent::ModifiersChanged (zoom_modifier = Cmd/Ctrl, shift_modifier = Shift); the web shell reads evt.meta_key() || evt.ctrl_key() and evt.shift_key() from KeyboardEvent.
| Key | Method | Behaviour (TS parity: use-edit-shortcuts.ts + use-clipboard-shortcuts.ts) |
|---|---|---|
Backspace |
apply_backspace |
Pops a char when an input is focused; else delete_selected(). |
Delete |
apply_delete |
delete_selected() regardless of which non-text overlay is open. |
Cmd/Ctrl+D |
apply_duplicate |
duplicate_selected(&mut next_node_id, 10.0) and selects the clone. |
ArrowUp/Down/Left/Right |
apply_nudge(dx, dy) |
Translates selection by 1 doc px, or 10 with Shift. |
[ |
apply_reorder(Down) |
Swap with previous sibling (back in z-order). |
] |
apply_reorder(Up) |
Swap with next sibling (forward in z-order). |
Escape |
apply_escape |
One layer per press, in priority order: property-focus → locale picker → shape picker → fill-type picker → chat focus → selection. |
Enter |
apply_send |
Commits property edit or sends chat. |
All struct fields and intra-module helpers are scoped pub(in crate::widget_host) so submodule impl blocks can reach them while the public surface stays minimal. Each file is under 480 lines.
Desktop binary (openpencil-desktop/)
crates/openpencil-desktop/src/main.rs is the production desktop entry. It owns the winit ApplicationHandler, opens a GL window via SharedSkiaContext::new_desktop, and dispatches every WindowEvent onto WidgetHostNative::apply_*. Behaviour:
- DPI scale via
canvas.scale((dpi, dpi))per frame (preceded byreset_matrix()so it doesn't compound) - LOGICAL viewport sizes (physical / dpi)
- Cursor position cached on
CursorMoved, dispatched onMouseInput MouseScrollDelta::PixelDelta→ trackpad pan;LineDelta/PinchGesture→ zoom; modifier (Cmd/Ctrl) promotes pixel-delta to zoom- Cursor flips to
EwResizewhen over a panel-resize gutter (host.panel_resize_hover) WaitUntil(host.next_animation_deadline_ms())pumps the caret-blink redraw
Native font path bypasses jian-skia's textlayout (which builds a fresh FontCollection per call → 605ms chrome frame): NativeBackend caches a Roboto Typeface + per-codepoint system fonts (resolved via FontMgr::match_family_style_character, cached per i32) so multi-script chrome (한국어 / हिन्दी / ไทย / Tiếng Việt) renders against the right font. draw_text segments each run by typeface and dispatches each segment via Canvas::draw_str.
Run: cargo run -p openpencil-desktop --release.
Web runner (shell-web/)
Single mount(canvas_id) entry point exposed to JS. Wires DOM listeners on the canvas + window:
- mousedown/mousemove/mouseup → apply_press / apply_cursor_move / apply_release
- wheel → apply_wheel
- keydown (window) → apply_text / apply_backspace / apply_send
- IME composition (hidden textarea) → apply_ime stubs
Skia surface: wasm32-unknown-unknown raster (N32_PREMUL) + put_image_data. Fonts: embedded Roboto-Regular.ttf (~35 KB) + NotoSansCJK-Subset.ttc (~8.7 KB) loaded via FontMgr::custom_empty().new_from_data.
Build: needs EMSDK env var pointing at an emsdk install (brew emscripten won't work — needs the real emsdk layout $EMSDK/upstream/emscripten/llvm/bin/clang++). Once set: tools/check-wasm-bundle.sh runs the full bundle gate (cargo → wasm-bindgen → wasm-opt -Oz, asserts 0 env.* imports + ≤1 MiB gzip).
Smoke: crates/openpencil-shell-web/smoke/step-1b.html — start python3 -m http.server 8000 from crates/openpencil-shell-web/ and open http://localhost:8000/smoke/step-1b.html.
Hit-test order
Hit-test runs in REVERSE paint order so the topmost overlay always wins:
- TopBar (sidebar toggle button) — also eats other top-bar gaps
- AI chat panel (DragHandle starts drag; FocusInput / Send / Example / ToggleCollapse defer to apply_click)
- Toolbar (button hits dispatch tools; gaps inside the bounding rect eat clicks)
- apply_click → LayerPanel rows / Page rows + chat-defocus (skipped when sidebar collapsed)
- Empty canvas press → clear
selected(collapses RightPanel) + start pan-drag
Coordinate invariant
Every input path that reasons about the canvas region MUST derive its rects from canvas_region(viewport_w, viewport_h). Never reuse LAYER_PANEL_WIDTH for hit-test — paint follows canvas_region, which collapses to canvas_left = 0 when Document.ui.sidebar_open == false. Sites that proved this rule by violating it: over_canvas, apply_wheel cursor offset, toolbar hit rect in apply_press / apply_click. Web apply_wheel zoom anchor + toolbar_rect() helper follow the same rule.
Settings modal (Cmd+,)
agent_settings_panel.rs + 4 tab modules render an 880×640 modal opened from the TopBar agent chip or Cmd+,. Sidebar nav: Agents / MCP / Images / System. Right pane scrolls; modal paints last (over dim scrim) so it covers every other widget.
- Agents —
+ 添加服务商and+ 添加 Agentactions in two empty-state sections, then 5 provider cards (Claude / Codex / OpenCode / GitHub Copilot / Gemini) with real brand logos fromwidgets/brand_icons.rs. Hovering a connected card swaps the green✓ Connectedrow for a red断开连接button; both lifecycle actions toggleagent_settings.connected[i]. - MCP — server status card with port input + Start/Stop button, then a 2×3 grid of CLI integration toggles (Claude Code / Codex / Gemini / OpenCode / Kiro / GitHub Copilot). Port input is editable (see "Settings input editing" below).
- Images — Image Search Ready/Not-configured indicator + collapsible Advanced section (Openverse OAuth Client ID / Secret + Register link + Test button), then Image Generation section with
+ Addempty state. - System — read-only Auto-update status card (no updater backend wired yet — a togglable switch would lie to the user; the row paints as informational text).
agent_settings_i18n.rs carries a hand-maintained EN/ZH key table (~50 keys, settings.tab.* / settings.agents.* / settings.mcp.* / settings.images.* / settings.system.* / settings.provider.*). The repo's main op-i18n/src/i18n/{en,zh_cn,…}.rs tables stay untouched (they're auto-generated from apps/web/src/i18n/locales/*.ts); when those TS tables grow settings.* keys, this hand-table collapses into per-locale lookup calls.
Settings input editing
SettingsFocus { McpPort } is to settings inputs what PropertyFocus is to property-panel inputs. Click on the port field → AgentSettingsHit::FocusMcpPort → agent_settings.focus = Some(McpPort) + UiState.settings_input_draft seeded from current port. apply_text / apply_backspace / apply_send / apply_escape all route to the draft FIRST (swallowing every keystroke so non-digit chars don't leak into chat / rename / text-edit). Commit parses u16 and clamps ≥1024. Close / Outside / SelectTab / re-Focus all commit any pending draft first so a typed value isn't silently lost.
Mirrored on native (widget_host/property_dispatch.rs::commit_settings_focus_if_any) and web (widget_host/keyboard.rs::commit_settings_focus).
Pen tool
document/pen.rs builds a NodeKind::Path with points: Vec<Point2D> (doc coords). State on Document.ui:
pen_in_progress: Option<NodeId>— the path being authored (None when idle).pen_cursor_doc: Option<Point2D>— last cursor doc coord, drives the rubber-band preview from the last anchor to the cursor while authoring.pending_pen_history: Option<DocumentSnapshot>— snapshot captured BEFOREstart_pen_pathmutates the tree. Pushed onto the undo stack only when the path commits with ≥ 2 anchors; a lone-anchor (invisible) path is stripped without polluting history.
Press while tool == Pen calls start_pen_path or add_pen_point depending on pen_in_progress. Enter / Escape / tool change → finish_pen_path. Canvas paints the path through node.points as a polyline, plus a dashed preview line from points.last() to pen_cursor_doc while authoring.
Color picker (HSV)
document/color_picker.rs + widgets/color_picker.rs. State: ColorPickerState { target (Fill|Stroke), hue, sat, val, drag (None|SvBox|HueSlider), anchor_y }. Open via Cmd-Shift-C or fill/stroke swatch click. HSV stays anchored across the RGB-rounding cycle — dragging the hue slider rewrites only hue and reconstructs RGB from the cached (hue, sat, val), so the saturation+value crosshair doesn't visibly snap as round-trip rounding pulls a slightly different RGB out of the same HSV.
Hit-test order on the picker: hex input row → SvBox → HueSlider → close X. Outside-click closes silently. apply_cursor_move reads state.drag to feed live SvBox / HueSlider updates.
Layer panel right-click + drag-into-container
widgets/layer_context_menu.rs paints a 200×N overlay on right-click of a layer row or page tab. Rows are gated on LayerContextTarget:
- Layer: Rename / Duplicate / Delete / Group / Ungroup / Lock / Hide (most rows route to dedicated
Document::*_selectedops; Group/Ungroup live indocument/grouping.rs). - Page: Rename / Duplicate / Delete (
document/page_mutators.rs).
Cursor-move feeds hovered_row so the menu highlights the row under the cursor. Outside-click closes silently. The menu paints AFTER the layer panel and BEFORE the settings modal so it sits below modals but above everything else.
Layer-panel drag now supports cross-parent reparenting: a drag whose drop-target falls inside a container row (Frame / Group with children) reparents the dragged node under that container instead of just reordering siblings. The walker in widgets/layer_panel_walkers.rs returns a DropTarget { parent, before, into_container } triple so the commit step can call move_into_container vs reorder_in_place.
Performance gotchas
- Native chrome paint: ~30 text draws × jian-skia textlayout's per-call
FontCollection::new()= ~600ms/frame. Fix is the cached typeface path described above. Don't add new draw_text calls without cache awareness. - skia canvas matrix is stateful across
with_frame—canvas.reset_matrix()before applying DPI scale each frame, otherwise scale compounds. - jian-skia's
DrawOp::Rect/DrawOp::Textgo through its image-cached path.stroke_line/fill_round_rect/stroke_round_rect/stroke_svg_path/fill_svg_pathbypass jian and call skia canvas directly (necessary because jian doesn't have those DrawOp variants).
Hot-path optimizations (v0.8.0)
- History via VecDeque (
document/mutators.rs) —pop_front+push_backcapped at 100 entries; the oldVec::remove(0)was O(n) on every commit past the cap. Document::treturns&'static str— every locale value is a string literal, so chrome paint stores&'static strinstead of cloning aStringper frame.PropertyLabelsis aCopystruct of static slices; widget builders propagate the'staticlifetime so no per-paint allocations happen for labels.- Viewport culling (
widgets/canvas_viewport.rs) —paint_nodetakes acull: Rect(canvas region + 64 px stroke/handle margin). Leaf nodes outside the cull skip paint entirely; containers always recurse so off-screen-parent / on-screen-child still renders. - Redraw scheduler (
openpencil-desktop/src/main.rs) —request_redraw(dirty: bool)+ aprepare_redrawstep that skips paint when only a tracked redraw fired and no visible state changed (kills the first-click chip flicker because macOS GL swap chain didn't perfectly hidecanvas.clear(BLACK)between same-output frames). - Cursor-move coalescing —
apply_cursor_movecached aspending_cursor_move, drained onRedrawRequestedAND right beforeapply_press/apply_release/apply_right_press(so the final drag-end frame isn't dropped). Without the press-time drain a fast drag-release could fire press before the queued cursor-move and the release saw stale hover state. - Font cache prewarm (
openpencil-shell-native/src/backend/skia.rs) —NativeBackend::newwalksPREWARM_CJK_CODEPOINTS(~50 chars covering every CJK glyph used in the chrome + settings modal) throughFontMgr::match_family_style_characterat startup. Without this the first cross-tab paint stutters because each unseen CJK char triggers a synchronous system font scan.
Native widget_host layout (expanded)
The shell-native widget_host/ directory now houses 8 sibling submodules under the slim spine:
| File | Purpose |
|---|---|
widget_host/frame_backend.rs |
NativeFrameBackend (RenderBackend impl over NativeBackend + &Canvas) |
widget_host/helpers.rs |
parse_hex_color / color_to_hex / rect_contains / resize_bounds + the inset / gutter / width constants |
widget_host/geometry.rs |
Canvas region / panel-resize hover / cursor hint / picker rect math + update_agent_settings_hover (hover-card tracking for the agent cards) |
widget_host/input.rs |
apply_* (non-press input handlers) including the settings-focus keyboard early-return |
widget_host/press.rs |
apply_press + create_node_for_active_tool (largest single method; routes through 10+ hit-test layers including the settings modal + color picker + layer context) |
widget_host/paint.rs |
WidgetHostNative::paint — full editor-UI composition pass |
widget_host/property_dispatch.rs |
Property-panel action dispatch + commit_property_focus_if_any + commit_settings_focus_if_any |
widget_host/shortcuts.rs |
Keyboard shortcut helpers (Cmd-Shift-C color picker, Cmd-Shift-G ungroup, etc.) |
Canonical .op / .pen loader (v0.8.0+)
openpencil-desktop/src/pen_doc_adapter.rs bridges the canonical jian_ops_schema::PenDocument into the desktop's private DocPayload so files saved by the TS editor, Jian apps, or any other tool emitting the canonical schema load through the shared parser. Adapter responsibilities:
- Variant routing — all 12
PenNodevariants (frame / group / rectangle / ellipse / line / polygon / path / text / text_input / image / icon_font / ref) becomeNodePayload. Image / TextInput / IconFont degrade where their renderer isn't wired yet (image → grey placeholder rect; text_input → text node with seeded value / placeholder; icon_font →NodeKind::Other("icon_font")withiconFontNamestashed intext). - Layout via
jian_core::layout::LayoutEngine— taffy-backed flex solver, with the canonicalclipContent/justifyContent/alignItemssemantics. Each page-root gets its own engine pass; harvested rects are offset by(base.x, base.y)so multi-design files (e.g.pencil-demo.op's 14 side-by-side mock-ups) spread across the infinite canvas instead of overlapping at origin. - Text measurement via
jian_skia::SkiaMeasure— real skia paragraph shaper plugged in withLayoutEngine::with_backend(Rc::new(SkiaMeasure::new())). Replaces the defaultEstimateBackend(~10% character-count heuristic) sofit_contenttext frames size against the same glyph advancesdraw_textpaints with. - Version tolerance —
persistence::load_canonicalretries withversionrewritten to"1.0"when the canonical schema rejects on a legacy major (e.g. pencil-demo.op'sversion: "2.8"), so older TS files still load best-effort.
pen_doc_path_bounds.rs ports pen-core/src/path-anchors.ts::getPathBoundsFromAnchors — cubic Bezier derivative roots + extrema sweep — so authored paths with handles/curves scale into their width / height box the same way the canonical renderer paints.
Font weight + text wrapping
- Numeric-string
fontWeight—.opfiles emit weights as JSON strings ("700","600","normal"). Bothvendor/jian/.../layout/mod.rs::resolve_weightandpen_doc_adapter::resolve_font_weightparse numeric strings first, then fall back to lucide-style named weights (bold/semibold/medium/light/thin/normal/regular/extralight/extrabold/black/heavy/demibold/ultralight/ultrabold/hairline). Node.text_wrap: bool— set only when the schema authoredtextGrowth: fixed-width(orfixed-width-and-height). Canvas paint wraps text only when this flag is true; otherwise paints single-line at the authored width so font-fallback overshoot doesn't break lines the TS app shows on one line.\ncharacters split into multiple lines either way.wrap_text(canvas_viewport_overlay.rs) — greedy CJK-aware line breaker mirroringpen-renderer/paint-utils.ts::wrapLine. Per-character breaks for CJK codepoints (CJK Unified + Extension A, Hiragana/Katakana, Hangul, CJK symbols, full-width), word breaks for Latin runs, blank-line preservation on empty\nsegments. Takesweight: u16so measurement matches the weighted typeface paint will use.RenderBackend::measure_text_weighted(text, font_size, weight)— every backend (NativeBackend,WebBackend,NativeFrameBackend) overrides this. Native keys its per-codepoint typeface cache on(codepoint, weight)and queries FontMgr withFontStyle::new(Weight, Width::Normal, Slant::Upright). Web ships a single-weight bundle someasure_text_weightedforwards tomeasure_text. The trait default forwards tomeasure_textfor the heuristic fallback.- Synthetic bold — both native and web
draw_textsetPaintStyle::StrokeAndFill+stroke_width = font_size * 0.06for weights ≥ 600 so the bundled single-weight Roboto-Regular paints heavy when the file asks for bold.
Icon coverage
icons.rs + icons_data.rs (sibling — keeps icons.rs under cap as the catalogue grows) cover ~75 lucide variants spanning chrome glyphs (Plus / Minus / Search / Settings / etc.) plus first-party iconFontName strings from packages/pen-core/src/element-builders/ (calendar, clock, map-pin, more-vertical, chevron-left, trending-up/-down, compass, refresh-cw, layout-dashboard, users, package, zap, sliders-horizontal, activity, loader, focus, chart-line, settings-2, arrow-right, check-circle, alert-triangle, alert-octagon, sticky-note, bar-chart-2, bold/italic/underline/strikethrough, shopping-cart/-bag, send, message-circle, rocket, menu, credit-card, x-circle, mail, smartphone, chrome, apple, user). All d-strings copied verbatim from node_modules/.bun/lucide-react@0.545.0/dist/esm/icons/*.js.
Icon::from_name(&str) -> Option<Icon> resolves kebab-case glyph names plus common aliases (back / forward / cart / bag / hamburger / card / cancel / house / chart-bar / like / team / etc.). The unknown-name fallback strokes the canonical FALLBACK_ICON_D (M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0) — a small centred dot, matching pen-renderer/node-renderer.ts::FALLBACK_ICON_D — so unsupported icons read as "unknown glyph" instead of a solid block.
paint_icon_font_node(backend, name, rect, fill) in icons.rs is the canvas entry point: scales the 24×24 viewBox to min(w, h), centres it, strokes at (size / 24) * 2 widths to match lucide's 2 px reference stroke.
Hover state across menus + panels
Every dropdown / sidebar / row list paints a per-row tint when the cursor is over it. State lives on Document.ui (or the appropriate sub-struct) and is updated by the host's apply_cursor_move:
| Surface | State | Tint |
|---|---|---|
File menu (file_menu.rs) |
Document.ui.file_menu_hover: Option<FileMenuChoice> |
theme.muted row wash |
Locale picker (locale_picker.rs) |
Document.ui.locale_picker_hover: Option<Locale> |
theme.muted |
Shape picker (shape_picker.rs) |
Document.ui.shape_picker_hover: Option<ShapeChoice> |
theme.muted |
| Layer panel rows | Document.ui.hovered_layer_id / hovered_page_index |
theme.muted row wash |
| AgentSettings nav (sidebar tabs) | agent_settings.hover_nav: Option<AgentSettingsTab> |
theme.accent |
| AgentSettings provider cards | agent_settings.hover_provider: usize |
theme.accent card wash |
| Layer context menu | layer_context_menu.hovered_row: Option<u8> |
row highlight |
Every host close path clears its respective hover state so reopening starts un-hovered.
File-menu polish
- Action labels strip the trailing
…(Open file / Save As / Export image — pure label, no Mac-convention ellipsis). - Recent file rows truncate long file names with a CJK-aware
truncate_to_widthhelper that reserves space for the age column on the right. - Compact row metrics (
ROW_HEIGHT = 30,HEADER_HEIGHT = 22,PAD_Y = 6,MENU_WIDTH = 300) — the menu reads tighter than the default 36/28/8/320.
Open / Save / Export error dialogs
persistence.rs::show_error_dialog pops a native rfd::MessageDialog on every failed load / save / export with a bilingual (EN / ZH per Document.ui.locale) title + path + detail body. OpenRecent failures additionally prune the stale entry from Document.ui.recent_files.
MCP server (shell-core)
crates/openpencil-shell-core/src/mcp.rs (+ siblings under mcp/) carries the MCP wire layer that mirrors the TS packages/pen-mcp server. Serde-free hand-rolled JSON-RPC parser keeps the wasm32 bundle small.
Tool catalog
77 first-party tools registered today (v0.8.0+). The
authoritative list is openpencil-desktop/src/mcp_serve.rs —
TOOL_SCHEMAS (JSON inputSchemas) plus the rebuild_registry
r.register(...) calls, guarded by an exact-count assertion test
(tools_list_response_includes_all_seventy_seven_tools).
By category:
- Read (18) —
get_document_info/get_selection/get_node/get_node_children/get_node_parent/list_pages/list_variables/get_active_theme/list_components/get_component/snapshot_layout/get_canvas_bounds/find_node_by_name/count_nodes/list_node_kinds/get_history_depth/get_viewport/get_selection_set. Each snapshotsDocumentstate at registration. - Node write (insert/update/delete/move/copy/replace) —
mcp/write_tools.rs. - Per-node attribute writers —
set_node_rotation/_text/_corner_radius/_font_size/_font_weight/_stroke_hex/_stroke_width/_fill_hex/_name(mcp/node_attr_tools.rs). - Selection / clipboard / canvas ops —
set_selection/set_selection_set/toggle_node_selection/clear_selection/duplicate/delete/nudge/group/ungroup/reorder/align_selected/copy_selected/cut_selected/paste_clipboard/set_active_tool/set_viewport/set_node_hidden|locked|collapsed/undo/redo(mcp/selected_ops_tools.rs+mcp/component_tools.rs). - Pages + components —
add_page/rename_page/delete_page/duplicate_page/reorder_page/set_active_page/instantiate_component/create_component/delete_component/rename_component(mcp/component_tools.rs). - Variables + themes —
set_variable_color|number|string|boolean/create_variable/delete_variable/rename_variable/set_active_axis_value/cycle_active_axis_value(mcp/scalar_vars.rs+mcp/component_tools.rs). - Batch design —
batch_design/design_skeleton/design_content/design_refine(mcp/batch_design.rs).
Read tools snapshot Document state at registration time. Write tools stay &self: they validate args and return ToolOutcome::OkWithCommand(result, command) for the host to apply via Document::apply_mcp_command(command). The apply path follows pre-validate-then-mutate discipline (id space, target existence, geometry, hex, container-children consent) so a bad arg never leaves the document half-mutated.
McpCommand::ReplaceNode.drop_children: bool is a destructive-swap guard — replacing a container without explicit drop_children=true is refused at apply time so a Frame / Group can't silently lose its subtree.
Wire-format hardening
mcp/parser.rs::parse_tool_call accepts both the real MCP tools/call envelope and the legacy direct-method shape. Multiple stop-gates baked in:
- Structured args reject the parse.
parse_flat_object_bodyreturnsNoneon{or[for any value;parse_tool_callpropagates that through a tri-state (ParamsResult::Missing/Body/Malformed). No scalar tool ever sees an object or array as a string-typed arg — earlier sentinel approaches were dropped because a literal{...}could collide with a real variable name. argumentsfield is a top-level walker.arguments_fielditerates top-level key/value pairs in the params body so a nestedmeta.argumentscan't shadow the real top-level field, and"name":"arguments"doesn't false-positive.- No client hangs on parse failure.
run_stdio_with_applierrecovers the JSON-RPCidviaparser::extract_request_idand writes a typedToolErrorCode::InvalidArgumentresponse so the client correlates + fails fast. Id-less lines drop silently — nothing to correlate against. - Read-only path refuses write tools.
run_stdio(no applier) demotes anyOkWithCommandresponse toToolErrorCode::Internal; clients can't see a "wrote: true" for an unapplied mutation.
File layout
mcp.rs Spine: types + ToolRegistry + run_stdio*
mcp/json_serializer.rs JSON-RPC wire serializer (response_to_json + helpers)
mcp/parser.rs Wire parse — tri-state arguments_field, top-level walker
mcp/tools.rs Core read tools
mcp/extra_read_tools.rs get_node_children (carved off tools.rs at the 800-cap)
mcp/write_tools.rs Core node write tools (insert/update/delete/move/copy/replace)
mcp/node_attr_tools.rs Per-node attribute writers (rotation/text/font/stroke/fill/name)
mcp/selected_ops_tools.rs Selection ops (dup/delete/nudge/group/align/clipboard) + tests
mcp/component_tools.rs Components + pages + selection/viewport/flag/tool/undo tools
mcp/batch_design.rs BatchDesign tool + hand-rolled nodes_json parser
mcp/scalar_vars.rs Scalar + Color variables + create/delete/rename_variable
mcp/*_tests.rs Per-module sibling test files (800-line cap convention)
mcp_tests.rs (in crate root) Cross-cutting: stdio dispatch, parser invariants
Host wiring
openpencil-desktop --mcp <path> (crates/openpencil-desktop/src/mcp_serve.rs) runs a JSON-RPC stdio MCP server backed by the .op file at <path>. External CLIs (Claude Code / Codex / Gemini / Copilot) spawn the binary in this mode to drive the Rust editor the same way they drive TS pen-mcp.
- Handshake:
initializereturns protocol version + capabilities + serverInfo;tools/listenumerates all 21 tools with JSON inputSchemas;notifications/initialized+pinghandled inline. - Per-call lifecycle: re-build the
ToolRegistryagainst the live document (so read-tool snapshots reflect prior writes) → dispatch throughrun_stdio_with_applier→ applier closure mutates the doc + saves to disk on each successful write. - Top-level method / id sniffing uses the same key-walker discipline as the wire parser so nested keys can't shadow the real top-level fields.
Pending
- A real JSON Node parser would unlock
replace_node's subtree path + grow batch_design / design_skeleton beyond leaf-only. - Per-phase apply semantics for the design_* workflow (e.g. design_refine emitting UpdateNode batches against existing nodes instead of fresh inserts).
- HttpServer / streamable-http MCP transport (lifecycle scaffold exists in
chat_http_server.rs; wire protocol unverified).
AI chat (real provider integration)
The floating chat panel (widgets/ai_chat_panel.rs) is wired to
real CLI agents, not a stub:
ChatState::begin_send(shell-coredocument/chat.rs) — the native send path: pushes the user message + an empty assistant bubble, raiseschat.pending_send. The web shell keeps the offlinesend()echo stub.ChatProvider(shell-corechat_provider.rs) is the transport-free trait; real impls live desktop-side:chat_runtime.rs(BuiltInProvider, agent-rs),chat_claude.rs(ClaudeCodeProvider,anthropic-agent-sdk),chat_subprocess.rs(SubprocessProviderfor Claude/Gemini/Copilot),chat_copilot.rs,chat_http_server.rs(Codex/OpenCode —for_clireturnsNone, protocol unverified upstream).chat_session.rs—ChatSessionruns a turn on a worker thread (ChatProvider::sendis a blocking iterator).launch_if_pendingdrainspending_send→provider_for_agent(chat_selected_agent);pumpstreams deltas into the transcript each frame. The winit loop wakes ~30 fps while a turn runs.- Model chip — the chat panel's bottom-left chip shows the selected
agent (
AgentProvider::name); clicking it cyclesui.chat_selected_agentthroughagent_settings.connectedviaDocument::cycle_chat_agent(AIChatHit::CycleModel). - Codex / OpenCode have no
ChatProviderbridge yet — selecting them writes an expliciterror: … not wired yetinto the transcript rather than silently rerouting to another agent.