The canvas was pan-only; nodes could only be selected from the
LayerPanel and never moved without editing X/Y in the property
panel. Now:
* Document::node_at_doc_point walks the active page top-most-first
and returns the topmost node whose aggregate bounds contain the
document-space point. Children are tested before parents so a
click on a button-rect inside a Frame selects the rect, not the
Frame.
* Document::translate_selected moves the selected node by (dx, dy)
document px. Leaf nodes update bounds.origin directly; container
nodes (Group / unbounded Frame) translate every descendant that
carries bounds, so dragging a Group moves the whole subtree.
* WidgetHostNative tracks a NodeDragState. Press over a node ⇒
select + start node-drag. Cursor-move converts the screen-space
delta to document space via the live zoom (no canvas_region
offset needed because deltas are translation-invariant) and
calls translate_selected. Release clears the drag.
* The Hand tool keeps its pure-pan behaviour. Empty-canvas press
with any other tool clears the selection + starts a pan-drag,
same as before.
- LocalePicker / ShapePicker no longer paint a soft black offset rect
underneath; popover background + border hairline are enough to
read as floating, and the shadow was bleeding into the canvas.
- ShapePicker anchors 8 px to the right of the toolbar PANEL edge
(not just the slot button), so the dropdown reads as a separate
surface instead of butting flush against the toolbar's right border.
The vertical toolbar's shape button is now a compound slot driven by
`Document.ui.shape_tool` (defaults to Rect). Click it to open a
`ShapePicker` dropdown anchored immediately to the right of the
slot — seven rows mirror the TS app's shape-tool-dropdown:
· Rectangle (Square icon)
· Ellipse (Circle)
· Polygon (Triangle)
· Line (Minus)
· Icon (Sparkles, opens icon picker — host follow-up)
· Import Image or SVG… (ImagePlus, opens file dialog — host follow-up)
· Pen (PenTool)
Picking a shape updates ui.shape_tool (so the toolbar slot's icon
flips), sets doc.tool to that variant, and closes the panel. Click
anywhere else closes silently.
* New Tool variants: Ellipse / Polygon / Line / Pen. Tool::is_shape()
reports membership in the slot's group so the slot highlights when
any of them is active.
* New icons: Circle, Triangle, PenTool, ImagePlus (lucide d-strings).
* New widget shape_picker.rs (≤ 280 lines) with hit-test + Widget
impl + 3 unit tests; ShapeChoice variant for the host to dispatch
on (Tool / OpenIconPicker / ImportImageOrSvg).
* PropertyLabels-style locale lookup falls back to English literals
for the row labels (shapes.rectangle / ellipse / polygon / line /
icon / importImageSvg / pen) — already present in zh.ts.
* Native host wires the open/close/dispatch loop alongside the
existing locale picker; paint slot z-priority sits below the
locale picker so a stack of overlays still does the right thing.
The right-rail inspector picks up locale-aware labels and accepts
keyboard edits on the four most-used number inputs.
* New `PropertyLabels` struct in property_panel_sections; resolved
once per panel build via `Document::t`. All hardcoded chinese
section titles (位置/弹性布局/尺寸/图层/填充/描边/效果/导出),
the 设计/代码 tab strip, the 创建组件 button label, and the five
尺寸 checkboxes (填充宽/高 / 适应宽/高 / 裁剪内容) now flip with
the TopBar Globe locale picker. Falls back to English when the TS
locale tables don't carry a key.
* PropertyPanel now carries `focus / draft / caret_anchor_ms /
now_ms` so the focused input renders the live edit buffer with a
primary-color border + blinking caret. `for_selection_at(doc,
now_ms)` is the new entry point; `for_selection` keeps a
zero-clock variant for static contexts (tests, etc.).
* New `editable_input_rects` in sections — single source of truth
for the X / Y / W / H rect layout, shared by paint and
`PropertyPanel::hit_test`.
* WidgetHostNative wires the full edit cycle: clicking a row
focuses + seeds the draft from the snapshot, `apply_text`
filters digits/decimal/leading-minus into the draft, `apply_send`
parses + commits via `Document::commit_property_edit`, and
`apply_escape` discards. Click-outside-the-panel auto-commits.
`next_animation_deadline_ms` now wakes for property focus too so
the caret blinks at the same 500 ms cadence as the chat input.
* `PropertyFocus` already existed; `Document::commit_property_edit`
+ helper walk now mutate Node.bounds for the X/Y/W/H cases.
Rotation/opacity/hex inputs accept focus + clear cleanly but are
no-ops at the node level until the schema grows those fields.
The native runner outgrew the `examples/` slot — it owns DPI tracking,
caret-blink animation timer, panel-resize cursor, the full Cmd+wheel /
PinchGesture / Pixel/LineDelta dispatch table, etc. None of that is a
sample, so it's been promoted to a real crate.
* New crate `crates/openpencil-desktop/` with a single `[[bin]]`
target. Depends on `openpencil-shell-native` (lib) + winit +
skia-safe (gl), gated to macOS / Linux / Windows.
* `examples/inspector_window.rs` removed; equivalent code lives at
`crates/openpencil-desktop/src/main.rs` with the structs renamed
(DesktopApp / paint) and the doc-block rewritten as a runner spec.
* Run command: `cargo run -p openpencil-desktop --release`. Old
command (`--example inspector_window`) is gone.
* Workspace glob `crates/*` already picks up the new crate, no
Cargo.toml workspace edit needed.
* Docs: crates/CLAUDE.md updated with the new crate row and runner
section retitled "Desktop binary". Top-bar layout test renamed +
uses the TOP_BAR_HEIGHT constant so future height tweaks stop
breaking it.
* Native fill_round_rect now sets anti_alias(true) — was the source of the
stair-stepped tool-button corners. Mirrors the AA flag we already had on
stroke_round_rect / stroke_line / stroke_svg_path.
* LayerPanel paints a right-edge hairline (so the rail reads as a distinct
surface from the canvas) plus an inset hairline between the Pages and
Layers sections (matches the TS LayerPanel border-t).
* Layer + Property panel widths are now first-class Document.ui state
(`layer_panel_width` / `property_panel_width`, defaults 240/280).
Native host detects ±4 px gutter clicks on the panel edges, drags the
width inside [180, 480], and the inspector_window runner flips the
cursor to EwResize while hovering or actively resizing.
* Web host expressions threaded onto the same UiState fields for parity;
drag wiring on web is a follow-up.
* TopBar trimmed: 48 → 40 px height, 32 → 28 icon button, 18 → 16 icon —
the chrome reads less heavy at default zoom.
* Drops the now-unused PropertyPanel `Copy` derive (UiState carries a
String draft) and lowers the toolbar (44×32) and topbar (40 px) so the
rails feel tighter overall.
TopBar Globe button is now a wider compound (44 px) carrying both
the globe glyph AND a small chevron-down — visually signals the
dropdown affordance the way the TS i18n switcher does.
Click-while-open behaviour fixed: any click outside the dropdown
(including a second click on the Globe itself) closes the picker
and swallows the press, instead of close→re-toggle-open which left
the picker stuck open.
Native font path now resolves a typeface PER CODEPOINT and renders
each contiguous-typeface segment with its own `Font`. Korean
한국어 / Devanagari हिन्दी / Thai ไทย / Vietnamese precomposed
`Tiếng Việt` now render against the right system font instead
of dropping through the Han-only fallback. Per-codepoint cache
keyed on `char as i32` keeps repeat lookups free.
Adds a LocalePicker widget that paints a vertical list of all 15
native-script locale names (English / 简体中文 / 繁體中文 / 日本語 /
한국어 / Français / Español / Deutsch / Português / Русский / हिन्दी
/ Türkçe / ไทย / Tiếng Việt / Bahasa Indonesia) with a Check icon
and primary tint on the active row.
Globe click toggles `Document.ui.locale_picker_open` instead of
silently cycling. Row click sets the locale + closes; clicking
outside the panel closes silently. Picker paints on top of every
other layer (chat / status / canvas) so it never gets covered.
Native + web hosts share the implementation via
shell-core::widgets::LocalePicker; `TopBar::globe_rect` exposes
the icon-button anchor so the panel stays glued under the icon
even after a viewport resize.
Stop-hook fix: codex flagged Rust files as not rustfmt-clean.
Run cargo fmt --all across openpencil-shell-{core,native,web}
+ wasm-libc-shim. 67 lib tests still pass, native + web cargo
check clean.
Stop-hook fix: 'caret reset can use a stale clock'. set_now_ms was
only called inside RedrawRequested, so apply_text / apply_backspace /
apply_press routed mid-frame stamped caret_anchor_ms with the
previous frame's now_ms. The result: caret reset visually appeared
delayed by up to one redraw interval (rare but inconsistent).
Refresh self.clock_start.elapsed() at the top of every WindowEvent
so any apply_* called inside the match arm sees the current
timestamp. Drop the redundant inside-RedrawRequested refresh.
Sinks the blink phase logic into vendor/jian (jian-core::anim) so any
host can wire the same square-wave timing instead of reimplementing
per-product. Both OpenPencil chrome and Zode TUI consume the same
helpers.
- vendor/jian bumped to head with new `jian_core::anim` module
(blink_visible / next_blink_flip_ms, 9 unit tests)
- ChatState: `caret_anchor_ms` resets on focus / keystroke /
example fill so the caret reappears solid right after the user
acts, not mid-fade
- AIChatPlaceholder.now_ms threaded from host; paint computes
caret visibility = focused && jian_core::anim::blink_visible
- AIChatPlaceholder caret X uses RenderBackend::measure_text for
pixel-accurate trailing edge (replaces the 7px / 13px guess
per char that drifted on Roboto + Noto-CJK)
- WidgetHostNative.set_now_ms / chat_focused / next_animation_
deadline_ms surface; runner refreshes from a single Instant
anchor + sets ControlFlow::WaitUntil at the next blink flip
- inspector_window: new_events handles ResumeTimeReached → request
redraw so winit actually wakes for the next frame
TS LayerPanel renders the selected row with bg-blue-500/15 + primary
text color + primary icon color (apps/web/src/components/panels/
layer-item.tsx). My panel was using theme.row_selected (gray #262626)
+ foreground text, which read as 'darker gray on dark gray' — not
the clear 'this is selected' affordance the TS app gives.
- Add Theme.row_selected_primary (rgba(0x3B82F6, 0.18) — blue 15%)
- LayerPanel: selected layer row uses row_selected_primary bg,
primary text + primary icon
- Page rows still use the neutral row_selected (matches TS where
the active page tab is also subdued gray)
Stop-hook fix: native over_canvas + apply_wheel + apply_click
LayerPanel hit-test all hardcoded LAYER_PANEL_WIDTH for the canvas
left edge. When the sidebar was collapsed, paint moved the canvas
left to x=0 but input still treated x∈[0,240) as 'over the LayerPanel'
— so clicks in that strip resolved to LayerPanel hits (against
nothing), wheel zoom anchored off-screen to the left of the cursor,
and pan-drag refused to start in that strip.
over_canvas now derives both x and y bounds from canvas_region;
apply_wheel uses canvas_region for the cursor offset; apply_click
short-circuits when sidebar is closed (LayerPanel isn't painted)
and lets the empty-canvas branch clear selection + start pan-drag.
Stop-hook fix: toolbar hit-test rects in apply_press / apply_click /
toolbar_rect were hardcoded to LAYER_PANEL_WIDTH + TOOLBAR_INSET_X,
but paint uses canvas_region's dynamic canvas_left (which is 0 when
sidebar is collapsed). When the user collapsed the sidebar, the
toolbar visibly slid left to x=12 but clicks still tried to hit it
at x=252, leaving the toolbar effectively unclickable.
Now both apply_press / apply_click in native + the toolbar_rect helper
in web compute the anchor from canvas_region, so hit-test always
matches paint. Wheel zoom in web also uses canvas_region's cx0/cy0
instead of the hardcoded LAYER_PANEL_WIDTH so cursor-centered zoom
keeps the right document point fixed when the sidebar is closed.
Codex Step 3 R1 BLOCK: the prior fix `10cae1e5` exposed
canvas_height() on WebBackend but only used it for the white-
background clear, NOT for `WidgetHost::paint`. The host's
canvas viewport rect still derived its height from a hardcoded
`640.0` (web) / `600.0` (native), so any window/canvas at a
non-default height got the wrong bottom edge.
Fix: extend both `paint` signatures to accept
`viewport_height: f32` and replace the hardcoded
`640.0 - rail_top_y` / `600.0 - rail_top_y` expressions with
`(viewport_height - rail_top_y).max(0.0)`.
Web side:
- `widget_host.rs::WidgetHost::paint(backend, viewport_width,
viewport_height)` — `// glue:` marker preserved on the
signature line.
- `lib.rs::paint_inspector` reads BOTH `viewport_w` and
`viewport_h` from the backend and forwards them to
`host.paint`.
Native side:
- `widget_host.rs::WidgetHostNative::paint(frame,
viewport_width, viewport_height)` — `// glue:` marker
preserved.
- `examples/inspector_window.rs::paint_inspector(...,
viewport_width, viewport_height)` — both axes plumbed
through.
- `InspectorApp` gains `viewport_height: f32` cached field
refreshed in the `Resized` arm so window-drag responsively
updates the canvas viewport rect.
Stale comment that said "Window height isn't passed through
this signature; assume 600 px" updated to cite the codex
finding.
Verification:
- `cargo build -p openpencil-shell-native --example
inspector_window` — green
- `cargo build -p openpencil-shell-web --target
wasm32-unknown-unknown --features skia --release` — green
- `wasm-bindgen --target web` — produces ../pkg/*
- `bash tools/check-wasm-bundle.sh` — PASS, 0 env.*, 907 092
bytes gzip = 86% of 1 MiB ceiling
- `grep "640.0\|600.0" crates/openpencil-shell-web/src/widget_
host.rs crates/openpencil-shell-native/src/widget_host.rs`
— only one match, inside a comment citing the prior bug
Codex Step 3 R1 BLOCK: `MIN_RAIL_WIDTH: f32 = 80.0` was defined
twice — once in `crates/openpencil-shell-web/src/widget_host.rs`
and once in `crates/openpencil-shell-native/src/widget_host.rs`.
Each had a comment claiming "mirrors the other"; nothing
enforced agreement. A future drift on one side would silently
break cross-platform layout parity.
Move to a single canonical `pub const MIN_RAIL_WIDTH: f32 = 80.0`
in `crates/openpencil-shell-core/src/widgets/mod.rs`. Both hosts
import it via the existing `widgets::*` use list.
Verification:
- `cargo build -p openpencil-shell-native --example
inspector_window` — green
- `cargo build -p openpencil-shell-web --target
wasm32-unknown-unknown --features skia --release` — green
- `cargo test -p openpencil-shell-core --lib` — 39 tests passing
- grep confirms one definition + two imports + 4 use sites
Node grows bounds + fill + stroke + text fields; new
`widgets::CanvasViewport` recursively renders document nodes as
visual primitives; both hosts (web + native) now lay out
Toolbar-top + LayerPanel-left + CanvasViewport-center +
PropertyPanel-right. The `inspector_window` example launches a
1100×700 window showing a real document mock instead of just an
inspector slice. Direct run command:
cargo run -p openpencil-shell-native --example inspector_window
What's added:
shell-core:
- `Rect::ZERO` const + `Rect::xywh(x,y,w,h)` builder — used
pervasively by Step 3 fixtures.
- `Color` derives `PartialEq` so `Option<Color>` field comparisons
work in tests.
- `document::Stroke { color, width }` for outlines.
- `document::Node` gains: `bounds: Rect` (origin + size), `fill:
Option<Color>`, `stroke: Option<Stroke>`, `text: Option<String>`.
Existing `Node::leaf` / `Node::with_children` keep working with
defaults (Rect::ZERO, all None). Builder mutators
`with_bounds` / `with_fill` / `with_stroke(color, width)` /
`with_text(s)` chain off them.
- `Document::sample()` now configures concrete geometry for the
demo: a 360×240 white-with-black-stroke Frame containing a
"Hello OpenPencil" Title and a blue Button (rect + "Click me"
text).
shell-core/widgets/canvas_viewport.rs (new, 5 unit tests):
- `CanvasViewport<'a>` borrows a `&Document` and impls `Widget`.
- `paint()` clears canvas to light-grey background, then walks
the active page's nodes recursively:
* Frame: fill + stroke + recurse
* Group / Other(_): no own paint, just recurse
* Rect: fill + stroke
* Text: draw `text` string at bounds.origin via TextLayout
- Selected node gets a 2px blue stroke OVER its normal paint so
the user can see the picked node across kinds.
- `accesskit::Role::Canvas` + label "Canvas".
- `from_document(&doc)` reserves WidgetId 4000 (matches the
per-component id range convention: 1000s = LayerPanel, 2000s
= PropertyPanel, 3000s = Toolbar, 4000s = canvas).
shell-web (`widget_host.rs`):
- Aux Dropdown + TextInput retired. Layout: rails take ~1/4
width each; canvas takes the middle ~1/2 (640px tall band
below the toolbar). Below MIN_RAIL_WIDTH the host paints the
toolbar only and skips rails+canvas.
- `apply_ime` / `apply_key` are now no-op stubs (Step 4+ wires
per-widget focus before they can route back to the document).
shell-native (`widget_host.rs`):
- Mirror of shell-web's layout. Canvas band 600px tall (matches
default `inspector_window` window height).
shell-native (`examples/inspector_window.rs`):
- Window upgraded to 1100×700 (was 800×600) so all three rails
+ center canvas have room.
- `viewport_width` cached on `InspectorApp`, refreshed on
`Resized` so dragging the window resizes the layout live.
- `paint_inspector` takes the current viewport_width.
Verification:
- `cargo test -p openpencil-shell-core --lib` — 39 tests passing
(was 34; +5 canvas_viewport unit tests)
- `cargo build -p openpencil-shell-native --example
inspector_window` — green (desktop launch ready)
- `cargo build -p openpencil-shell-web --target
wasm32-unknown-unknown --features skia --release` — green
- `cargo check -p openpencil-shell-native --target
aarch64-apple-ios` — green (mobile widget stack inherits
CanvasViewport unchanged)
- `cargo check -p openpencil-shell-native --target
aarch64-linux-android` — green
- `cargo check -p openpencil-shell-core --target
wasm32-unknown-unknown` — green (shell-core stays
wasm32-clean per spec §1.2)
- `bash tools/check-wasm-bundle.sh` — PASS:
- 0 env.* imports
- 624 474 bytes gzip = 59% of 1 MiB ceiling (negligible
growth — canvas_viewport adds ~50 LOC of paint logic)
Codex Step 2 R1 returned NO-GO with 1 BLOCK + 4 CONCERNs. All
addressed:
# BLOCK — NodeId(0) constructible in release builds
`NodeId` had a `pub u64` tuple field, so any caller could write
`NodeId(0)` directly and shadow `NodeId::NONE`. The `NodeId::new`
constructor only `debug_assert`ed against 0; release builds
silently let `Node::leaf(0, ...)` produce a zero-id Node that
collided with the NONE sentinel and confused
`Document::selected_node`.
Fix:
- Inner `u64` is now private (`pub struct NodeId(u64)`).
- `NodeId::new` hard-panics in BOTH debug and release if
id == 0 (was `debug_assert`).
- New `NodeId::raw(self) -> u64` accessor for read paths
(to_widget_id, serde Step 4+, tests).
- New `#[should_panic]` test runs in both build modes.
# CONCERN-1 — selection / LayerPanel page mismatch
`Document::selected_node` walked all pages while
`LayerPanel::from_document` rendered only `pages[0]`. A
selection on page 2 drove PropertyPanel while the LayerPanel
showed page 1 with no highlight.
Fix:
- New `Document::active_page_index: usize` field (defaults to 0).
- New `Document::active_page() -> Option<&Page>` accessor.
- `Document::selected_node` now ONLY searches the active page.
A selection on a non-active page returns `None`.
- `LayerPanel::from_document` now walks `active_page()`.
- New tests:
- `from_document_scopes_to_active_page_only`
- `document_selected_node_scopes_to_active_page`
- `document_active_page_returns_indexed_page`
- `document_active_page_returns_none_when_index_out_of_range`
# CONCERN-2 — duplicate node ids unenforced
`Node::leaf` / `Node::with_children` / `Page::new` accepted
arbitrary id assignment with no uniqueness check; dup ids would
make `selected_node` return the first hit while LayerPanel might
mark several rows selected.
Fix:
- New `Document::find_duplicate_id() -> Option<NodeId>` walker
(HashSet over page ids + recursive node ids; first dup wins).
- New `Document::validate() -> Result<(), String>` runs the
duplicate scan + `active_page_index` range check.
- `Document::sample()` now `debug_assert`s self-validation so
any fixture-time regression is caught in tests.
- New tests:
- `document_sample_passes_validate`
- `document_validate_catches_duplicate_node_id`
- `document_validate_catches_active_page_index_out_of_range`
# CONCERN-3 — rail_w can go negative on tiny viewports
WidgetHost (web) + WidgetHostNative (native) computed
`rail_w = 240.0_f32.min(viewport_width / 2.0 - 8.0)`. When
viewport_width < 16 the expression went negative, producing
negative-size Rects.
Fix:
- New `MIN_RAIL_WIDTH: f32 = 80.0` const in both hosts.
- `rail_w_raw = (viewport_width / 2.0 - 8.0).min(240.0)` then
`rail_w = rail_w_raw.max(0.0)` clamps to non-negative.
- If `rail_w < MIN_RAIL_WIDTH` the host paints the Toolbar only
and skips both rails — there's no usable space for a
meaningful LayerPanel + PropertyPanel split.
# CONCERN-4 — toolbar overflow silently drops buttons
`Toolbar::paint` early-returns from the per-button loop when a
button would overflow the rect, leaving later tools unreachable
on narrow viewports.
Fix (Step 2 scope = doc only):
- Inline comment in `Toolbar::paint` documents the limitation +
enumerates the Step 3+ resolutions (horizontal scroll inside
the toolbar rect, "More tools" overflow dropdown, icon-only
mode at narrow widths). Phase D pointer/wheel routing has to
land before any of those is wirable.
Test count: 24 → 33 lib tests (+9 new). All 64 shell-core tests
green; web + native + iOS + Android all compile; bundle gate
PASS at 624 466 bytes gzip (59% of 1 MiB ceiling).
Pivot toward "去除 TS, 打通 jian/op". Lands the spine the Rust
shell needs to replace `apps/web` (TS) — a Document model that
the Rust editor consumes, plus three composite widgets
(LayerPanel / PropertyPanel / Toolbar) that render the editor UI
from the document. Same surface on shell-web (browser via
WidgetHost) and shell-native (desktop via WidgetHostNative).
What's added:
shell-core:
- `crates/openpencil-shell-core/src/document.rs` — minimal
Document model: NodeId(u64) (with NONE sentinel + ::new
debug_assert mirroring WidgetId), NodeKind enum (Frame /
Group / Rect / Text / Other(String)), Node (recursive tree
with id + kind + name + children + find()), Page (id + name +
children + find()), Document (pages + selected NodeId +
selected_node()/first_page()/sample()/empty() helpers). 8 unit
tests cover sentinel semantics, find walk, sample shape,
selection state, kind label. Step 3+ extends with fills /
strokes / transform / variables / components.
- `crates/openpencil-shell-core/src/widgets/layer_panel.rs` —
LayerPanel rebuilt per frame from `Document::pages[0]` via a
depth-first walk into a flat `LayerItem` list with depth +
selection state. Paints depth-indented rows with selection
highlight + kind-label column. accesskit::Role::Tree, label
"Layers". 6 unit tests.
- `crates/openpencil-shell-core/src/widgets/property_panel.rs`
— PropertyPanel rebuilt per frame from `Document::
selected_node()`. Paints a header strip + 3 PropertyRow
rows (Name / Type / Children count) when something is
selected; "(no selection)" placeholder otherwise. accesskit::
Role::Group with the selection's "Type — Name" label. 5 unit
tests.
- `crates/openpencil-shell-core/src/widgets/toolbar.rs` —
Toolbar with default 4-tool set (Select / Rect / Text / Pen),
active-tool fill highlight, label-per-button. accesskit::
Role::Toolbar. 4 unit tests.
The composite widgets live alongside the B2 primitives in
`widgets/` (one module, primitives + compositions all
`impl Widget`). They were briefly housed in a `chrome/` submodule
but the name collided with the higher-level "OP chrome =
openpencil-shell" architectural term — module renamed +
inline references updated to "editor UI".
shell-web (`widget_host.rs`):
- WidgetHost now owns `Document::sample()` + auxiliary widget
state (Dropdown + TextInput Step 1b holdovers); per-frame
builds LayerPanel + PropertyPanel from the document.
- paint() lays out Toolbar pinned top, LayerPanel left rail
(240 px or viewport/2-8), PropertyPanel right rail (same
width), aux Dropdown + TextInput stacked under property
panel.
- apply_ime / apply_key still route to aux widgets (Step 3 will
fold them into the document-driven property sections).
shell-native (`widget_host.rs`):
- Mirror of shell-web's structure: Document::sample() +
Toolbar + aux widgets. Same Toolbar-top + LayerPanel-left +
PropertyPanel-right layout for cross-platform visual parity
(Phase E manual smoke acceptance).
Verification:
- `cargo test -p openpencil-shell-core` — lib 24 + jian 6 +
render_backend 4 + widgets_static 21 = 55/55 passing
- `cargo build -p openpencil-shell-native --example
inspector_window` — green (desktop)
- `cargo build -p openpencil-shell-web --target
wasm32-unknown-unknown --features skia --release` — green
- `cargo check -p openpencil-shell-native --target
aarch64-apple-ios` — green
- `cargo check -p openpencil-shell-native --target
aarch64-linux-android` — green
- `cargo check -p openpencil-shell-core --target
wasm32-unknown-unknown` — green (shell-core stays
wasm32-clean per spec §1.2)
- `bash tools/check-wasm-bundle.sh` — PASS:
- 0 env.* imports
- 624 699 bytes gzip = 59% of 1 MiB ceiling (+3 KiB vs
Step 1b: editor-UI composition adds ~3 KiB of view-build
code)
- `bash tools/check-widget-boundary.sh` — PASS
- `bash tools/check-jian-boundaries.sh` — 4/4 invariants pass
Step 2 scope (kill-spike pivot toward TS removal):
- Document model: minimal but extensible spine. Step 3+ adds
fills / strokes / transform / variables / components / ...
- Editor UI: per-frame view rebuild from document — cheap
enough at sample-doc scale, lets the host stay stateless for
the document tree.
- Cross-platform parity: same WidgetHost shape on web +
native + mobile (compile-checked).
What's still on the path to "去除 TS":
- Step 3+: fills / strokes / transform — render real document
geometry, not just inspector text. Canvas viewport widget.
- Step 4+: real document I/O (load / save), backed by
serde-roundtrip of the document model.
- Step 5+: replace `apps/web` (TS React + Zustand) with the
wasm shell mount.
The previous commit's inline rationale at the top of the cross-
platform widget stack `[target...]` block claimed "Spec §12.3 jian
boundary invariants 2 & 3 are unchanged", which is incorrect — the
same commit also revised Invariant 2 in
`tools/check-jian-boundaries.sh` to allow `jian-skia` on iOS /
Android (only `jian-host-desktop` stays forbidden on mobile).
Replaced the stale paragraph with an accurate one that:
- names the 2026-05-10 revision date
- describes what Invariant 2 now permits (jian-skia) and forbids
(jian-host-desktop)
- notes Invariant 3 (wasm32 forbids both) is unchanged
- points at the boundary script header for the full rationale
Per 2026-05-10 user directive ("extend, jian 最后也会需要 ios 和
android"): lift the desktop-only cfg gate so the widget render
stack (skia-safe + jian-skia + NativeBackend + widget_host)
compiles for iOS (`aarch64-apple-ios`) AND Android
(`aarch64-linux-android`) cargo check too. Mobile shells now have
a real widget-rendering surface to target in Step 1f, and the
"shell-core widgets are platform-agnostic" claim from spec §1.4
is now compile-verified across desktop trio + mobile pair + wasm.
Cargo.toml restructure (`crates/openpencil-shell-native/Cargo.toml`):
- New `[target.'cfg(any(macos, linux, windows, ios, android))']`
block for the cross-platform widget stack: `skia-safe = "0.97"`
(default-features = false; binary-cache + textlayout) and
`jian-skia` (textlayout). Both pull on every desktop trio +
mobile pair target.
- Existing desktop-only block kept for the GUI host stack: adds
`gl` to skia-safe's features (iOS deprecated GL — Metal goes
in Step 1f; Android GL/Vulkan via the platform provider not
via skia-safe's bundled bindings here), plus glutin / glutin-
winit / winit / scopeguard / jian-host-desktop. Cargo
deduplicates: skia-safe resolves to one crate-version with
feature-union (binary-cache + textlayout from the wider block
+ gl from the desktop block on desktop-only).
src/lib.rs gate lift:
- `pub mod backend;` and `pub mod widget_host;` cfg now includes
`target_os = "ios"` and `target_os = "android"`. `pub use`
re-exports follow.
- `canvas_view_stub` stays desktop-only (uses glow GL-isolation
probe with no mobile equivalent).
- Comment block at the cfg site cites the user directive +
Step 1f handoff (real EaglProvider / AndroidEglProvider impls
+ Metal / Vulkan / event integration).
Boundary script revision (`tools/check-jian-boundaries.sh`):
- Invariant 2 was: mobile targets must NOT pull jian-host-desktop
OR jian-skia. Per the user directive, jian-skia is now ALLOWED
on mobile (the widget render stack uses it). jian-host-desktop
remains forbidden — it carries winit / glutin / desktop
GLContextProvider impls that have no mobile equivalent.
- Header comment block + active grep narrowed accordingly. The
Step 1f path through EaglProvider / AndroidEglProvider is the
spec-blessed mobile host plugin point (no IPC / CLI needed).
Verification:
- `cargo check -p openpencil-shell-native --target
aarch64-apple-ios` — green (skia-bindings + jian-skia +
shell-native all compile)
- `cargo check -p openpencil-shell-native --target
aarch64-linux-android` — green (same)
- `cargo check -p openpencil-shell-native` — green (no desktop
regression)
- `cargo check -p openpencil-shell-core --target
wasm32-unknown-unknown` — green (shell-core stays wasm32-clean)
- `cargo test -p openpencil-shell-core --test widgets_static` —
21/21 passing (widget logic untouched)
- `bash tools/check-wasm-bundle.sh` — PASS:
- 0 env.* imports
- 622 156 bytes gzip = 59% of 1 MiB ceiling (no web regression)
- `bash tools/check-widget-boundary.sh` — PASS
- `bash tools/check-jian-boundaries.sh` — 4/4 invariants pass
(Invariant 2 revised to allow jian-skia on mobile)
What's still mobile-pending (Step 1f scope):
- `EaglProvider` (iOS) — Metal-backed `GlContextProvider` impl
(skia-safe `metal` feature when iOS host actually runs)
- `AndroidEglProvider` (Android) — GL/Vulkan-backed impl
- Mobile host runners (UIKit AppDelegate / Activity wrappers)
- Mobile event translation (jian-host-ios / jian-host-android —
siblings of jian-host-desktop)
- `inspector_window` example is desktop-only by design (winit +
SharedSkiaContext::new_desktop); mobile shells will land their
own UIKit / Activity runners that consume the SAME
`WidgetHostNative::paint(&mut frame, width)` surface
The widget glue itself (NativeFrameBackend + WidgetHostNative)
is platform-agnostic in shape — no winit / glutin / EGL types
leak in. Step 1f mobile work plugs in providers, not widgets.
Lands the shell-native consumer of shell-core's Step 1b widget
module so spec §1.4 is concrete: same widget code, same paint
output on macOS / Linux / Windows desktop AND
wasm32-unknown-unknown browsers. User priority for this commit
("主要是native 端") + the parallel Phase D web work.
What's added:
- `crates/openpencil-shell-native/src/widget_host.rs` (~155 LOC):
* `NativeFrameBackend<'a>` — frame-scoped wrapper holding
`(&mut NativeBackend, &skia_safe::Canvas)`, impls
shell-core's `RenderBackend` by forwarding to the existing
`NativeBackend::{fill_rect, stroke_rect, draw_text,
clip_rect, save, restore, translate}` methods (each takes
the canvas as a separate arg in the existing API).
`begin_frame`/`end_frame` no-op because `SharedSkiaContext::
with_frame` owns those bracket points; `resize` no-op because
surface resize lives on `SharedSkiaContext::resize`. Spec
§5.2.1 explicitly deferred this RenderBackend impl to Step
1c+ widget tree work — this is that landing site.
* `WidgetHostNative` — owns one of each B1/B2 widget
(TreeWidget::sample, PropertyRow::new(200, "Width", "960"),
Dropdown::sample, TextInput::sample). `paint(&self, frame,
available_width)` mirrors shell-web's `WidgetHost::paint`
exactly (16/12 px gaps, 280 px column) so the visual layout
is identical between platforms — Phase E manual smoke
acceptance criterion.
* `// glue:` markers for the (future) cross-crate widget-
boundary gate.
- `crates/openpencil-shell-native/examples/inspector_window.rs`
(~150 LOC) — winit + SharedSkiaContext + NativeBackend +
WidgetHostNative end-to-end. Same shape as `basic_window.rs`
but the per-frame paint dispatches to `WidgetHostNative`
instead of hard-coded chrome. cfg-gated to desktop OS; CI
verifies `cargo build --examples` only.
- `crates/openpencil-shell-native/src/lib.rs` — adds `pub mod
widget_host;` cfg-gated to desktop OS (matches the existing
`backend` / `canvas_view_stub` gating per spec §11). Re-exports
`NativeFrameBackend` + `WidgetHostNative` at the crate root.
Mobile (iOS / Android) considered (per 2026-05-10 user directive
"安卓和ios 不需要 ipc / 本地 cli — 只需要 custom provider"):
- The widget glue is platform-agnostic in shape — no winit /
glutin / EGL / desktop-only types leak in. `NativeFrameBackend`
only borrows `NativeBackend` + `&skia_safe::Canvas`;
`WidgetHostNative` only consumes shell-core widgets + the
`RenderBackend` trait. Both compile on any target where
`NativeBackend` compiles.
- Today the desktop-only cfg on `widget_host` mirrors the
desktop-only cfg on `backend` (per spec §11 invariants 1 & 3:
mobile widget rendering lands in Step 1f). When Step 1f ships
real `EaglProvider` (iOS) / `AndroidEglProvider` (Android)
impls and lifts the desktop cfg, `WidgetHostNative` follows
automatically — no rewrite, no IPC / CLI infrastructure.
- Doc comment in `widget_host.rs` + `inspector_window.rs`
explicitly documents this Step 1f path.
- Verified both iOS (`aarch64-apple-ios`) and Android
(`aarch64-linux-android`) cargo check still green with
shell-native's mobile compile guard in place.
Verification:
- `cargo build -p openpencil-shell-native --example
inspector_window` — green (desktop)
- `cargo check -p openpencil-shell-native` — green (no
regression on Step 1a basic_window)
- `cargo check -p openpencil-shell-native --target
aarch64-apple-ios` — green (mobile compile guard intact)
- `cargo check -p openpencil-shell-native --target
aarch64-linux-android` — green (mobile compile guard intact)
- `cargo check -p openpencil-shell-core --target
wasm32-unknown-unknown` — green (shell-core stays
wasm32-clean per spec §1.2)
- `cargo test -p openpencil-shell-core --test widgets_static` —
21/21 (no widget changes)
- `bash tools/check-wasm-bundle.sh` — PASS (web bundle still 0
env.* / 622 KiB gzip / 59% ceiling — no regression)
- `bash tools/check-widget-boundary.sh` — PASS
- `bash tools/check-jian-boundaries.sh` — 4/4 invariants PASS
Phase D (web DOM mirror + native accesskit_winit integration)
follows.
The merge of origin/v0.8.0 brought in Kayshen's 504f1874 / ef4f9f67
which delete src/event.rs + src/event/mod.rs (re-affirming v19.4 drop).
Local 6af3a7d7 had wired `pub mod event` into both shell-core and
shell-native lib.rs to keep those files alive — now the source files
are gone and the `pub mod event` declarations point at nothing.
Drop the dangling declarations:
- crates/openpencil-shell-core/src/lib.rs: remove `pub mod event;`
- crates/openpencil-shell-native/src/lib.rs: remove `pub mod event;`
+ the `pub use event::JianPointerMapper;` re-export
Verify: cargo check --workspace --tests + cargo test -p shell-core
-p shell-native both green; format:check + tsc + vitest 4223/4223 pass.
This finalizes the same direction Kayshen's commit message describes
("shell-core exposes Jian gesture types directly without an OP-side
wrapper") — confirmed with user before merging.
Step 1a v19.4 commit f9dd4b56 deleted ShellEvent + JianPointerMapper, but
the merge in 46f77eab (cf61bcc1 ↔ origin/v0.8.0) inadvertently re-added
src/event/mod.rs and tests/event_mapping.rs from the inbound side.
src/event/mod.rs is dead source — lib.rs no longer declares
`pub mod event` and no consumer imports JianPointerMapper. The test file
imports a symbol lib.rs no longer re-exports, so
`cargo test -p openpencil-shell-native --no-run` fails compile.
Step 1b Phase 0 must start from a green baseline; deleting both files
restores it.
cargo test -p openpencil-shell-native --no-run → all integration tests
compile clean.
Picks up the keyboard/IME/focus event additions + W3C wheel deltaMode
landed in jian commit d5d358e. shell-core re-exports of the new types
land in the next commit; this commit only moves the pointer + Cargo.lock.
cargo test -p openpencil-shell-core --test gesture_re_export → 6/6 PASS
against the pinned submodule.
The merge of origin/v0.8.0 (commit 19582fc) resurrected the 4 ShellEvent
files that b133ebc0 / f9dd4b56 declared dropped — the same modify-vs-delete
artifact 50c9c3de cleaned up after 6fbee16a. Per user direction keep the
files: declare the event modules in both lib.rs + re-export JianPointerMapper
at shell-native crate root so the existing tests resolve.
Tests: event_shape 3/3, event_mapping 15/15, workspace cargo check + test
green, format:check / tsc / vitest (4204) all pass.
Also ignore xhs-images/ + .baoyu-skills/ in .prettierignore (local tutorial
scratch dirs; oxfmt was scanning markdown inside).
Per user 2026-05-05 directive: OP render engine + event types stay
consistent with Jian. The OP-specific ShellEvent enum + JianPointerMapper
translation layer (Phase B Task 3 commit f2169d00) was over-designed —
OP-side abstraction provides no value over directly consuming
jian_core::gesture::PointerEvent.
Deleted:
- crates/openpencil-shell-core/src/event.rs (ShellEvent enum + 9 subtypes)
- crates/openpencil-shell-core/tests/event_shape.rs (3 unit tests)
- crates/openpencil-shell-native/src/event/mod.rs (JianPointerMapper)
- crates/openpencil-shell-native/tests/event_mapping.rs (15 unit tests)
Added:
- shell-core lib.rs re-exports jian_core::gesture::{PointerEvent,
PointerKind, PointerPhase, MouseButtons, Modifiers, PointerId} so
consumer code can import Jian event types via the OP shell crate.
OP visual model differentiation (single-page + infinite canvas
recommended, multi-page also supported, no routing, cross-page event
linkage when multi-page) lives at canvas viewport layer (Step 1c+),
not at event type abstraction.
spec v19.3 → v19.4 mini-patch (separate commit in openpencil-docs)
documents the simplification.
Phase B Task 3 implementation per spec v19 §5.1 + §5.1.1 (FROZEN
2026-05-04):
shell-core:
- New `event` module declaring `ShellEvent` (6 variants per spec §5.1)
+ sub-types `PointerId / TouchId / TouchPhase / TouchForce /
MouseButton / ElementState / ScrollDelta / Modifiers / KeyCode /
WindowEventKind`. Pure OP types — no winit / Jian / GL — so the
enum is wasm32-clean and visible on iOS / Android (spec §11.3).
- TouchForce::Calibrated mirrors winit::Force 1:1 (spec §11.3
invariant) so Step 1f mobile mapper compiles without API break.
- Newtype id fields are `pub` so shell-native can construct them across
crates (spec round 3 BLOCK-R3-4 fix).
shell-native:
- New `event` module (cfg-gated desktop only) housing
`JianPointerMapper` — stateful diff over the per-PointerId
`MouseButtons` snapshot. Diff runs on Down / Up / Move (spec round 3
CONCERN-R3-1 fix); Hover / Move emits a trailing `PointerMove`.
- Touch branch maps Down/Move/Up/Cancel → Started/Moved/Ended/Cancelled;
Touch Hover returns `Vec::new()` (touches never hover).
- Mouse / Pen / Stylus / Trackpad share the same diff branch.
- Degraded inputs (no button transition + no Move emission) return
`Vec::new()` instead of synthesising a `ShellEvent::Other` variant
(spec round 4 CONCERN-R4-1 fix; the enum stays at exactly 6 variants).
Tests:
- 15 new unit tests in shell-native/tests/event_mapping.rs covering
the 4 Touch phases, mouse Hover, LEFT Down/Up pair, multi-button
press/release during Move, Pen/Stylus/Trackpad routing, two
degraded-empty paths, and modifiers propagation (CMD → meta).
- 3 new shape tests in shell-core/tests/event_shape.rs proving the
6-variant invariant + TouchForce::Calibrated field shape +
`pub`-field newtype constructibility.
Verified:
- `cargo test -p openpencil-shell-core -p openpencil-shell-native`
green (36 tests total across both crates).
- `cargo check --target wasm32-unknown-unknown -p openpencil-shell-core`
green; shell-web on wasm32 still compiles with the new module pulled
through.
- `cargo check --target aarch64-apple-ios -p openpencil-shell-native`
+ `--target aarch64-linux-android -p openpencil-shell-native` both
green (mapper cfg-gated out of mobile).
- `cargo metadata --filter-platform aarch64-linux-android` confirms
jian-host-desktop / jian-skia not in the Android dep tree.
- §11.1 grep: 0 actual `use winit/skia_safe/glutin/...` items in
shell-core (only doc-comment references).
- `cargo clippy --all-targets` clean; `cargo fmt --check` clean.
Linux GPU tests:
- skia-safe Interface::new_native dlopens libGL.so + glXGetProcAddress;
fails on EGL pbuffer + llvmpipe (Mesa headless setup). Wiring
Interface::new_load_with(eglGetProcAddress) needs a new
GlContextProvider::get_proc_address method (spec §3.1 mini-patch
follow-up). Tracked LINUX_GPU_SKIA_LOADER_TBD.
- gpu_smoke + gpu_chrome_stub_composition Linux variants now #[ignore]
with explicit reason matching Windows pattern (#[ignore =
WINDOWS_GPU_DEFERRED_NO_RUNNER]); CI Linux test step drops xvfb +
STEP1A_REQUIRE_GPU env (no longer needed since tests ignored).
- macOS continues running real GPU smoke (no skia loader issue).
Windows ARM64:
- new aarch64-pc-windows-msvc matrix entry — cargo check only
(cross-compile from x86_64 windows-latest; no Win11 ARM hosted runner GA yet).
- rust-release.yml also gains windows-aarch64 archive build.
macos-local verify: all 14 tests pass (gpu_smoke + gpu_chrome_stub_composition
still run on macOS host).
Spec v19 §11 invariant 1 requires shell-native to compile on iOS / Android
cargo check, with the `GlContextProvider` trait (invariant 2) importable on
every non-wasm target. Previously the desktop GL stack (glutin / winit /
skia-safe) was referenced unconditionally in src/, so mobile cargo check
broke the moment the Cargo.toml target-gated those deps to macOS / Linux /
Windows.
This change cfg-gates the desktop-only modules and items so the mobile
cargo check builds only the cross-platform surface:
- src/lib.rs: gate `backend` + `canvas_view_stub` modules and their
re-exports to desktop OS targets; add `EaglProvider` / `AndroidEglProvider`
re-exports under `target_os = "ios"` / `"android"`. `GlContextProvider`,
`ProviderError`, `ProviderResult` stay always-on (per §11 invariant 2).
- src/context/mod.rs: split into a cross-platform trait surface +
per-platform provider re-exports; gate `shared` (depends on `skia_safe` +
`winit`) to desktop only.
- src/context/provider.rs: cfg-gate `GlutinProvider` struct + impls + the
`pick_display_api` helper to desktop OS only; localize `CString` /
`NonZeroU32` imports inside fn bodies; gate `from_error` to desktop to
silence dead_code on mobile (the only caller is `GlutinProvider`).
- Cargo.toml: split deps into a cross-platform `cfg(not(wasm32))` block
(jian-core + glow + raw-window-handle, all required by the trait
signature on every non-wasm target) and a desktop-only block (skia-safe,
glutin, glutin-winit, winit, scopeguard, jian-skia, jian-host-desktop).
Merges the previously duplicate desktop `[target...]` table headers that
cargo rejected.
- ci: rust-multiplatform.yml mobile-check job now runs cargo check on
shell-native too (per the comment update there).
Verification:
- cargo check -p openpencil-shell-native --target aarch64-apple-darwin: PASS
- cargo check -p openpencil-shell-native --target aarch64-apple-ios: PASS
- cargo check -p openpencil-shell-native --target aarch64-linux-android: PASS
- cargo check -p openpencil-shell-native --target wasm32-unknown-unknown:
FAILS with the spec §1.2 `compile_error!` (intended).
- cargo test -p openpencil-shell-native: 14/14 PASS.
- cargo clippy -p openpencil-shell-native --all-targets -- -D warnings: clean
on macOS, iOS, Android targets.
- cargo fmt --check: clean.
- tests/common/mod.rs: egl.get_display(DEFAULT_DISPLAY) wrapped in unsafe block
(khronos-egl 6.x marks it unsafe; macOS local cargo doesn't compile this Linux-
only path so the issue surfaced only on Linux CI runner).
- rust-multiplatform.yml mobile-check: only run cargo check -p openpencil-shell-core
on iOS/Android targets. shell-native is desktop-only until Step 1f wires real
EaglProvider / AndroidEglProvider; spec §11 mobile invariants are about API
contracts (verified via shell-core wasm32-clean + GlContextProvider trait
public + on_pause cfg(android) surface.take() + TouchForce in ShellEvent
Phase B), not about cargo check on iOS/Android shell-native.
Phase B Task 3 implementation per spec v19 §5.1 + §5.1.1 (FROZEN
2026-05-04):
shell-core:
- New `event` module declaring `ShellEvent` (6 variants per spec §5.1)
+ sub-types `PointerId / TouchId / TouchPhase / TouchForce /
MouseButton / ElementState / ScrollDelta / Modifiers / KeyCode /
WindowEventKind`. Pure OP types — no winit / Jian / GL — so the
enum is wasm32-clean and visible on iOS / Android (spec §11.3).
- TouchForce::Calibrated mirrors winit::Force 1:1 (spec §11.3
invariant) so Step 1f mobile mapper compiles without API break.
- Newtype id fields are `pub` so shell-native can construct them across
crates (spec round 3 BLOCK-R3-4 fix).
shell-native:
- New `event` module (cfg-gated desktop only) housing
`JianPointerMapper` — stateful diff over the per-PointerId
`MouseButtons` snapshot. Diff runs on Down / Up / Move (spec round 3
CONCERN-R3-1 fix); Hover / Move emits a trailing `PointerMove`.
- Touch branch maps Down/Move/Up/Cancel → Started/Moved/Ended/Cancelled;
Touch Hover returns `Vec::new()` (touches never hover).
- Mouse / Pen / Stylus / Trackpad share the same diff branch.
- Degraded inputs (no button transition + no Move emission) return
`Vec::new()` instead of synthesising a `ShellEvent::Other` variant
(spec round 4 CONCERN-R4-1 fix; the enum stays at exactly 6 variants).
Tests:
- 15 new unit tests in shell-native/tests/event_mapping.rs covering
the 4 Touch phases, mouse Hover, LEFT Down/Up pair, multi-button
press/release during Move, Pen/Stylus/Trackpad routing, two
degraded-empty paths, and modifiers propagation (CMD → meta).
- 3 new shape tests in shell-core/tests/event_shape.rs proving the
6-variant invariant + TouchForce::Calibrated field shape +
`pub`-field newtype constructibility.
Verified:
- `cargo test -p openpencil-shell-core -p openpencil-shell-native`
green (36 tests total across both crates).
- `cargo check --target wasm32-unknown-unknown -p openpencil-shell-core`
green; shell-web on wasm32 still compiles with the new module pulled
through.
- `cargo check --target aarch64-apple-ios -p openpencil-shell-native`
+ `--target aarch64-linux-android -p openpencil-shell-native` both
green (mapper cfg-gated out of mobile).
- `cargo metadata --filter-platform aarch64-linux-android` confirms
jian-host-desktop / jian-skia not in the Android dep tree.
- §11.1 grep: 0 actual `use winit/skia_safe/glutin/...` items in
shell-core (only doc-comment references).
- `cargo clippy --all-targets` clean; `cargo fmt --check` clean.
Linux GPU tests:
- skia-safe Interface::new_native dlopens libGL.so + glXGetProcAddress;
fails on EGL pbuffer + llvmpipe (Mesa headless setup). Wiring
Interface::new_load_with(eglGetProcAddress) needs a new
GlContextProvider::get_proc_address method (spec §3.1 mini-patch
follow-up). Tracked LINUX_GPU_SKIA_LOADER_TBD.
- gpu_smoke + gpu_chrome_stub_composition Linux variants now #[ignore]
with explicit reason matching Windows pattern (#[ignore =
WINDOWS_GPU_DEFERRED_NO_RUNNER]); CI Linux test step drops xvfb +
STEP1A_REQUIRE_GPU env (no longer needed since tests ignored).
- macOS continues running real GPU smoke (no skia loader issue).
Windows ARM64:
- new aarch64-pc-windows-msvc matrix entry — cargo check only
(cross-compile from x86_64 windows-latest; no Win11 ARM hosted runner GA yet).
- rust-release.yml also gains windows-aarch64 archive build.
macos-local verify: all 14 tests pass (gpu_smoke + gpu_chrome_stub_composition
still run on macOS host).
Spec v19 §11 invariant 1 requires shell-native to compile on iOS / Android
cargo check, with the `GlContextProvider` trait (invariant 2) importable on
every non-wasm target. Previously the desktop GL stack (glutin / winit /
skia-safe) was referenced unconditionally in src/, so mobile cargo check
broke the moment the Cargo.toml target-gated those deps to macOS / Linux /
Windows.
This change cfg-gates the desktop-only modules and items so the mobile
cargo check builds only the cross-platform surface:
- src/lib.rs: gate `backend` + `canvas_view_stub` modules and their
re-exports to desktop OS targets; add `EaglProvider` / `AndroidEglProvider`
re-exports under `target_os = "ios"` / `"android"`. `GlContextProvider`,
`ProviderError`, `ProviderResult` stay always-on (per §11 invariant 2).
- src/context/mod.rs: split into a cross-platform trait surface +
per-platform provider re-exports; gate `shared` (depends on `skia_safe` +
`winit`) to desktop only.
- src/context/provider.rs: cfg-gate `GlutinProvider` struct + impls + the
`pick_display_api` helper to desktop OS only; localize `CString` /
`NonZeroU32` imports inside fn bodies; gate `from_error` to desktop to
silence dead_code on mobile (the only caller is `GlutinProvider`).
- Cargo.toml: split deps into a cross-platform `cfg(not(wasm32))` block
(jian-core + glow + raw-window-handle, all required by the trait
signature on every non-wasm target) and a desktop-only block (skia-safe,
glutin, glutin-winit, winit, scopeguard, jian-skia, jian-host-desktop).
Merges the previously duplicate desktop `[target...]` table headers that
cargo rejected.
- ci: rust-multiplatform.yml mobile-check job now runs cargo check on
shell-native too (per the comment update there).
Verification:
- cargo check -p openpencil-shell-native --target aarch64-apple-darwin: PASS
- cargo check -p openpencil-shell-native --target aarch64-apple-ios: PASS
- cargo check -p openpencil-shell-native --target aarch64-linux-android: PASS
- cargo check -p openpencil-shell-native --target wasm32-unknown-unknown:
FAILS with the spec §1.2 `compile_error!` (intended).
- cargo test -p openpencil-shell-native: 14/14 PASS.
- cargo clippy -p openpencil-shell-native --all-targets -- -D warnings: clean
on macOS, iOS, Android targets.
- cargo fmt --check: clean.
- tests/common/mod.rs: egl.get_display(DEFAULT_DISPLAY) wrapped in unsafe block
(khronos-egl 6.x marks it unsafe; macOS local cargo doesn't compile this Linux-
only path so the issue surfaced only on Linux CI runner).
- rust-multiplatform.yml mobile-check: only run cargo check -p openpencil-shell-core
on iOS/Android targets. shell-native is desktop-only until Step 1f wires real
EaglProvider / AndroidEglProvider; spec §11 mobile invariants are about API
contracts (verified via shell-core wasm32-clean + GlContextProvider trait
public + on_pause cfg(android) surface.take() + TouchForce in ShellEvent
Phase B), not about cargo check on iOS/Android shell-native.
Apply 5 patches from Codex Phase A Gate round 2 review against spec
v19.1 (FROZEN at openpencil-docs commit 526791f):
- BLOCK 1: `SharedSkiaContext::new(provider) -> Result<Self>` single-arg
per spec §3.3. Provider owns surface configuration; constructor queries
GL viewport / sample count / stencil bits via glow after make_current
returns (option C — no trait change, no caller-side `SurfaceConfig`).
`dpi` field on `SurfaceConfig` was dead and is dropped.
- BLOCK 2(a): `glow()` returns `Option<&Arc<glow::Context>>` (borrow,
not clone) per spec §3.3. Hot-path callers clone explicitly.
- BLOCK 2(b): mobile `on_pause` drops `glow_handle` alongside surface
per spec §3.4 — backing GL context is invalid once activity backgrounds.
- CONCERN 1: `default_framebuffer_id` is now a required trait method
(no default body); explicit overrides on `GlutinProvider` (0),
`EglPbufferProvider` (0), `EaglProvider` (unimplemented! Step 1f),
`AndroidEglProvider` (0). Forces Step 1f mobile impls to specify the
non-zero CAEAGLLayer-backed FBO rather than silently inheriting 0.
- CONCERN 2: new `tests/resize_smoke.rs` with two raster-backed tests —
grow 400×300→800×600→400×300 paints through `NativeBackend` without
panic; resize span emits on grow / shrink / 0×0 clamp paths.
- NIT: stale "Spec mini-patch pending" comments rewritten to reflect
v19.1 frozen state.
cargo build / test / clippy / fmt all green on macOS local.
Open-source codebase convention: all source-code comments in English.
Translates Chinese comments across openpencil-shell-{core,native,web}
.rs and Cargo.toml files. Logic, identifiers, and string literals
unchanged; the literal CJK fixture "Hello 你好" in raster_text_smoke
stays since it exercises the textlayout CJK path.
Applies Codex Phase A Gate round 1 review (3 BLOCK + 2 CONCERN + 1 NIT)
against the Task 2 SharedSkiaContext + NativeBackend implementation.
BLOCK 1 — `ProviderError::from_msg` `pub(crate)` blocked the Linux EGL
pbuffer test helper from constructing typed provider errors. Promoted
to `pub` so out-of-tree provider impls (test pbuffer, future Step 1f
mobile providers) can produce diagnostically-identical errors.
BLOCK 2 — Linux GPU smoke + chrome-stub-composition tests silently
returned `Ok(())` on EGL pbuffer setup failure, turning acceptance #3 /
#4 into false positives on hosted CI without GPU. Now gated by
`STEP1A_REQUIRE_GPU=1`: real-GPU runners panic on setup failure;
dev / hostless runs surface an explicit `INCONCLUSIVE` marker before
returning. Mirrors the macOS `catch_unwind` skip path in the same file.
BLOCK 3 — `tests/memory_loop.rs` was running 100 cycles against
`SharedSkiaContext::inert_for_test()` (every Option<> field None), so
the RSS budget proved nothing about real allocation lifecycle. Renamed
constructor to `inert_for_lifecycle_test()` (clearer intent) and split
the test into:
- Phase 0 warmup (100 inert + 100 raster) so Skia's lazy
glyph/path/binding caches are populated before measurement;
- Phase 1 lifecycle idempotence (100 inert);
- Phase 2 real-resource cycle: raster surface on macOS / Windows
(winit::EventLoop main-thread-only on macOS; Win Actions runner
has no GPU per spec §8.1), full EGL pbuffer + GL surface on Linux
when `STEP1A_REQUIRE_GPU=1`, raster fallback otherwise.
Budget kept at 5 % per acceptance #6 with a 1.5 MB absolute floor to
absorb macOS sysinfo's coarse RSS sampling jitter on small baselines.
CONCERN 1 — `GlContextProvider` had three non-spec methods (`resize`,
`size`, `default_framebuffer_id`). Audit:
- `resize`: actually used by `SharedSkiaContext::resize` (window /
pbuffer resize → Skia FBO rewrap). KEPT, spec mini-patch
documented in comment, escalation needed for spec v19 → v19.1.
- `default_framebuffer_id`: used by `SharedSkiaContext::new` /
`resize` for the FBO id Skia wraps; iOS EAGL provider (Step 1f)
will need non-zero values. KEPT, same escalation path.
- `size`: unused anywhere. DELETED (YAGNI), along with the unused
`size: (u32, u32)` field on `GlutinProvider` and the iOS / Android
stub impls.
CONCERN 2 — `glow_handle: Option<Arc<glow::Context>>` deviates from
spec v19 lines 120-125 + 191 (`Arc<glow::Context>`). Real lifecycle
needs the handle droppable: teardown releases the loaded function
table, `inert_for_lifecycle_test` has no GL backing, Step 1f Android
`on_pause` must drop alongside the EGL context. KEPT as Option<Arc>,
spec mini-patch documented for v19.1 escalation.
NIT 1 — Removed Task 1 link-check helper `placeholder()`. Task 2's
full re-export chain (`SharedSkiaContext`, `NativeBackend`, …)
already proves shell-core ↔ shell-native linkage; placeholder is
YAGNI now.
Verification (macOS local):
- cargo build -p openpencil-shell-native: clean
- cargo test -p openpencil-shell-native: 12/12 pass (8 binaries)
- cargo clippy -p openpencil-shell-native --tests --all-targets
-- -D warnings: clean
- cargo fmt -p openpencil-shell-native -- --check: clean
- memory_loop stress 8 consecutive runs: 8/8 pass
Anchor v19 pivot at the workspace level: vendor Jian as a git submodule
pinned to fork commit ad13ce6 (P0.5 mini-gate GO; skia-safe 0.78 → 0.97 +
new pub draw_on_canvas adapter), wire jian-core / jian-skia / jian-host-desktop
as path deps with explicit version per spec §12.2, and re-export the
Jian render/geometry/scene types from shell-core so shell-native can
translate the OP RenderBackend facade into jian DrawOp commands.
shell-core stays wasm32-clean: only jian-core (already wasm32-validated
in P0.5) plus glam / bitflags / thiserror / tracing land here.
shell-native picks up the full P0-pinned GL stack (skia-safe 0.97.0,
glutin 0.32.3, glutin-winit 0.5.0, glow 0.17.0, winit 0.30.13,
raw-window-handle 0.6.2, scopeguard 1.2) plus jian-skia (textlayout)
and target-gated jian-host-desktop (default-features = false, no `run`
feature so we skip Jian's softbuffer raster present path — OP owns its
own GPU swap_buffers per spec §3.6).
Adds OP RenderBackend trait + Rect / Color (with RED/GREEN/BLUE/BLACK/
WHITE/TRANSPARENT named constants per spec §5.2) + TextLayout facade
that wraps jian_core::render::TextRun explicitly (TextRun has no Default
impl, fields enumerated to honour spec §5.2 round-2 CONCERN-1 fix).
Boundary checks all pass:
- wasm32 shell-web metadata: no jian-host-desktop / jian-skia
- aarch64-linux-android shell-native metadata: no jian-host-desktop
- shell-core src: no glutin / skia_safe / winit / glow imports
Tasks 2-4 (SharedSkiaContext + NativeBackend + ShellEvent mapping +
acceptance) follow per plan v7.