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.
* Theme: new `canvas_surface` token (#181818 dark / #fafafa light) —
CanvasViewport now paints the surface with this distinct shade so
the canvas reads as its own surface rather than blending into the
chrome background.
* AI chat panel: rebuilt the bottom of the panel to mirror the TS
reference. Single hairline separator between body and input,
borderless 14 px textarea with the same caret blink driver,
dedicated 40 px toolbar carrying ✦ Default ▾ on the left and
attach + send (24 px primary square) on the right. ai.tipSelectElements
string wired in (used by the empty-state body).
* Toolbar (vertical floating column): trimmed from 48×36 to 44×32 so
the tool buttons feel less heavy at default zoom levels.
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.
Default Document has no connected agent, so for_document now sets
agent_count = 0 and the chip switches to the empty-state look:
LayoutGrid icon + 'Agents 与 MCP' label (TS topbar.agentsAndMcp /
en topbar.agentsAndMcp). Active state (agent_count >= 1) keeps the
Sparkles + green dot + 'N agent' look.
Chip width is now driven by RenderBackend::measure_text so the
border ring tracks the actual rendered string instead of a
per-char estimate.
Earlier convert-locales.py was line-based + single-quote-only, missing
~16 keys per locale where:
- the value spans onto the next line ('long.key.name':\n 'value')
- the value uses double quotes for English contractions ('topbar.dontSave': "Don't Save")
Switch to a regex.finditer over the whole file with multi-line +
double-quote alternation. All 15 locales now report 706 keys each,
matching the TS source (apps/web/src/i18n/locales/*.ts).
Stop-hook: 'locale import is incomplete'.
Replaces the hand-rolled 25-key i18n.rs with 15 generated locale
modules (~700 keys each) mirrored from
apps/web/src/i18n/locales/*.ts via tools/convert-locales.py.
Locale enum expanded to match the TS dropdown:
EnUs / ZhCn / ZhTw / Ja / Ko / Fr / Es / De / Pt / Ru / Hi / Tr /
Th / Vi / Id (15 total). Each carries its native-script
display_name() (English / 简体中文 / 繁體中文 / 日本語 / 한국어 /
Français / Español / Deutsch / Português / Русский / हिन्दी /
Türkçe / ไทย / Tiếng Việt / Bahasa Indonesia).
Globe icon click cycles all 15 via Locale::next() (round-robin
through Locale::ALL).
Chrome key references updated to TS dot.case naming so the same
key resolves on both sides:
- topbar.untitled → common.untitled
- layer_panel.pages → pages.title
- layer_panel.layers → layers.title
- chat.new_chat → ai.newChat
- chat.start_with_ai → ai.tryExample
- chat.input_placeholder → ai.designWithAgent
Generator script lives at tools/convert-locales.py (re-run when
TS strings update). Each locale .rs file is ≤ 710 lines (under
the 800-line ceiling). Cross-locale fallback: missing keys try
EN before falling through to the key itself.
68 lib tests pass (+1 i18n fallback test).
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.
Theme + locale toggle infrastructure landed in ed36df56, but the
visible chrome strings were still hardcoded so flipping the Globe
icon didn't actually change anything. Now:
- LayerPanel resolves '页面' / '图层' from doc.t() at construction
and stores as String fields; paint reads those instead of
hardcoded literals.
- AIChatPlaceholder resolves 'New Chat' / '用 AI 开始设计' /
'用 Agent 设计…' the same way; paint_examples takes the hint
label as a parameter.
TopBar 'untitled' label was already wired (for_document uses
doc.t). 67 lib tests still pass.
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)
TS imports both Diamond (instance indicator) and Component (cluster
of 4 small diamonds, used for the 创建组件 button). I picked the
single-diamond Diamond by mistake; the button uses Component.
Codex stop-hook flagged: "canvas viewport is not paint-isolated".
Root cause: `CanvasViewport::paint` walked the document tree and
issued draw calls translated by `viewport_origin = rect.origin`,
but never clipped to the widget's `rect`. A document node whose
bounds extend past the canvas-widget rect (e.g. a frame at
document (40, 40)–(960, 640) painted into a 300-px-wide canvas
band) would spill onto the LayerPanel / PropertyPanel area
sitting in adjacent rects.
Fix: wrap the entire viewport paint in
save → clip_rect(rect) → bg fill → recursive node paints →
restore. The host-level clip stack catches whatever the
recursive paint emits, and the recursive code stays unaware of
the bounds (no per-node clipping needed).
Defensive guard up front: `if rect.size.x <= 0.0 || rect.size.y
<= 0.0 { return; }` so a zero-size canvas (host clamped below
MIN_RAIL_WIDTH) doesn't even open the save scope.
Test additions (41 lib tests, was 39):
- `paint_is_clip_isolated_save_clip_then_restore` extends the
RecordingBackend to capture op order via a new `Op` enum;
asserts the first three ops are Save → Clip → Fill (canvas
bg), the last op is Restore, and save/restore counts balance.
- `paint_with_zero_size_rect_skips_entirely` confirms the
defensive zero-size early return — backend.ops stays empty
when rect has zero width or height.
Verification:
- `cargo test -p openpencil-shell-core --lib` — 41 tests passing
- `cargo build -p openpencil-shell-native --example
inspector_window` — green
- `cargo build -p openpencil-shell-web --target
wasm32-unknown-unknown --features skia --release` — green
- `bash tools/check-wasm-bundle.sh` — PASS, 0 env.*, 907 141
bytes gzip = 86% of 1 MiB ceiling
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)
Two iterative tightenings on Document::validate after the R1
fixes landed in 3d291ec8.
# R2 CONCERN-1: empty-pages document silently passed validate
`Document::validate` previously gated the active_page_index
range check on `!pages.is_empty()`, so a `Document { pages:
vec![], active_page_index: 99, ... }` returned `Ok(())` —
inconsistent with the implicit "every Document has at least one
page" invariant that Document::empty() and Document::sample()
both establish.
Fix:
- `validate()` now treats `pages.is_empty()` as the FIRST
violation it returns. Empty pages is itself an invariant
violation — `Document::empty()` is the constructor for the
default single-page shape.
- `active_page_index` range check now fires unconditionally.
New test `document_validate_catches_empty_pages` covers two
sub-cases:
- `pages: vec![], active_page_index: 0` → Err("pages is empty")
- `pages: vec![], active_page_index: 99` → Err (empty check
fires first, range check short-circuited)
# R3 CONCERN: empty-vs-range ordering not asserted
The R2 second sub-case only asserted `.is_err()` without
proving WHICH violation fired first. Strengthened to:
- assert error contains "pages is empty"
- assert error does NOT contain "active_page_index"
Both asserts carry failure messages so a future regression
points at the cause.
Test count: 34 lib + 21 widgets_static + 6 jian + 4
render_backend = 65 shell-core tests passing.
R4 GO from codex.
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.
Codex stop-hook #2: with the hidden IME textarea now focused (R1
fix in fe994c4b), every keystroke routes through it — including
arrow keys the user is pressing to navigate the IME's candidate
picker. The window-level keydown listener still fires on these
keystrokes, and `DropdownState::apply_key` was mutating selection
+ opening the menu behind the IME panel. Surfaced as: "focused
IME textarea lets composing keys mutate dropdown state."
Spec §2.4 says: "Widgets that consume keys directly should
usually skip dispatch when is_composing == true and let the
ImeEvent path handle the composition instead." The plumbing for
`is_composing` already runs through Phase C2.2 (W3C
`KeyboardEvent.isComposing` → C1 `map_keyboard_parts(...,
is_composing)` → `KeyEvent.is_composing` → C2.1
`DropdownState::apply_key`); we just weren't honoring the bit on
the consuming side.
Fix: add `event.is_composing` to the early-return condition in
`DropdownState::apply_key`. ArrowDown/Up/Enter/Escape during a
composition no-op now; the IME's candidate picker keeps the
keystroke and the dropdown stays put.
`TextInputState::apply_ime` is unaffected — it already only
processes ImeEvent, never KeyEvent, so composing-key bleed-
through was never a concern there.
Test: `dropdown_apply_key_ignores_keys_during_ime_composition`
asserts both ArrowDown (would advance + open) and Enter (would
close) are no-ops when `is_composing` is true. Test count
20 → 21.
Verification:
- `cargo test -p openpencil-shell-core --test widgets_static` —
21/21 passing
- `cargo check -p openpencil-shell-core --target
wasm32-unknown-unknown` — green
- `cargo build -p openpencil-shell-web --target
wasm32-unknown-unknown --features skia --release` — green
- `bash tools/check-wasm-bundle.sh` — PASS:
- 0 env.* imports
- 622 156 bytes gzip = 59% of 1 MiB ceiling
Phase D may extend this guard pattern to other widgets that gain
key handling (Tree typeahead, etc.); the spec §2.4 is_composing
contract becomes a per-widget invariant.
Lands the data-flow piece of plan C2: shell-core widgets gain pure
state-mutation methods (`TextInputState::apply_ime`,
`DropdownState::apply_key`) that the WidgetHost forwards to from the
two new `// glue:` marked methods. Phase C2.2 will land the browser
closure registration that drives these methods from real DOM
events.
shell-core (widgets stay platform-agnostic per spec §1.4):
- `TextInputState::apply_ime(&ImeEvent)` — CompositionStart clears
preedit, CompositionUpdate replaces preedit with `event.text`
(selection deferred to Phase D DOM mirror), CompositionEnd
appends the commit text to value and clears preedit.
- `DropdownState::apply_key(&KeyEvent, option_count)` — ArrowDown
advances + opens (saturates at last option, no wrap), ArrowUp
retreats with `saturating_sub` + opens, Enter / Escape close
without mutating selection. Skips on Released or empty options.
shell-web (glue file widget_host.rs, both methods carry `// glue:`
markers per spec §1.4 boundary check):
- `WidgetHost::apply_ime(&ImeEvent)` — forwards to text_input.state
- `WidgetHost::apply_key(&KeyEvent)` — forwards to dropdown.state
with `dropdown.options.len()` for the option count
Tests (`tests/widgets_static.rs`, +10 → 20 total):
- text_input apply_ime: Start clears preedit (value untouched);
Update replaces preedit (value untouched); End commits +
clears; double-Start without End each clears (codex C2.1 R1
CONCERN-1 — pathological host state machine)
- dropdown apply_key: ArrowDown advances + saturates; ArrowUp
retreats + saturating_sub; Enter / Escape close + Escape
preserves selection (codex C2.1 R1 CONCERN-2); Released = no-op;
zero options = no-op; unrelated NamedKey (Tab) = no-op
- Helper `keydown(named)` / `keyup(named)` build minimal KeyEvents;
apply_key reads only `key` + `state` so the harness's KeyCode
field is intentionally Unknown(String::new())
Plan-vs-implementation deviations (deliberate):
- WidgetHost forwarding methods carry `// glue:` markers. F3 in
the boundary script's exemption set covered `fn paint(...)`;
the same exemption applies here because these methods import +
invoke `openpencil_shell_core::{ImeEvent, KeyEvent}` at the
signature line. Verified `bash tools/check-widget-boundary.sh`
still PASS.
- Codex C2.1 R1 CONCERN-3 (WidgetHost forwarding has no direct
test coverage) deferred to C2.2 — the browser closure
registration there exercises the forwarding end-to-end via
real DOM events, which is more meaningful than mocking
WidgetHost in shell-web tests with read-only accessors that
would only exist for testing.
Verification:
- `cargo test -p openpencil-shell-core --test widgets_static` —
20/20 passing
- `cargo check -p openpencil-shell-core --target
wasm32-unknown-unknown` — green (shell-core stays wasm32-clean
per spec §1.2)
- `bash tools/check-widget-boundary.sh` — PASS
Codex iterate review: 2 rounds → GO.
Lands the four Step 1b inspector widgets in shell-core (per spec
§1.4 — widget logic lives here so shell-native + shell-web reuse
it; only the RenderBackend impl + DOM event mapping + accesskit
DOM mirror are platform-owned).
Widgets:
- `widgets::TreeWidget` (Role::Tree, label "Layers") — sample
3-item tree (Frame / Title / Button) with selection-aware
blue-row paint and depth-indented labels. WidgetIds 100-103.
- `widgets::PropertyRow` (Role::Group, label "{label} {value}")
— single-row label/value pair. PropertyRow uses Role::Group
rather than Role::GenericContainer so the row label survives
ARIA filtering on the way to VoiceOver / NVDA (codex B2 R1
CONCERN). WidgetIds 200-299.
- `widgets::Dropdown` (Role::ComboBox, label "Blend") — sample
blend-mode picker with 3 options. Phase B static slice does
NOT yet pop a menu when `state.open == true`; Phase C wires
click + keyboard handling. WidgetIds 300-399.
- `widgets::TextInput` (Role::TextInput) — single-line input
with CJK IME preview. Paints `state.preedit` (in-progress
composition) when present, else `state.value`; non-empty
preedit also draws an 80px underline. Phase C lands
compositionstart / update / end → state mutation in shell-web.
WidgetIds 400-499.
State separation:
- `DropdownState { selected, open }` and `TextInputState { value,
preedit }` live as their own structs so Phase C event handlers
can swap them without taking ownership of the surrounding
widget. `TextInputState::default()` returns the empty state.
Tests (`tests/widgets_static.rs`):
- `four_inspector_widgets_paint_static_content` — paints all
four widgets through one RecordingBackend, asserts
≥5 fills / ≥3 strokes / ≥7 text dispatches (each tightened
vs the plan sketch to actually catch per-widget regressions).
- Per-widget role + label assertions (Tree / Group / ComboBox /
TextInput).
- `text_input_paints_preedit_underline_when_composing` —
drives `state.preedit = "你好"`, paints, asserts the IME
branch emits 1 fill + 2 strokes (border + underline) + 1
text run.
- `dropdown_state_independent_state_struct` +
`text_input_state_default_is_empty` — verify state structs
are independent + default-constructible.
Plan-vs-implementation deviations (deliberate):
- `WidgetId::new(N)` instead of the plan's `WidgetId(N)` tuple
literal so the sample/new constructors exercise the B1
debug_assert non-zero check. Tuple stays public for pattern
matching + `const` contexts.
- WidgetId range conventions per widget kind (Tree=100s /
PropertyRow=200s / Dropdown=300s / TextInput=400s) added as
doc-only comments. Real Phase C host will allocate from a
counter; the conventions just keep the B-phase fixtures
predictable.
- Per-widget paint counts in `four_inspector_widgets_…` test
tightened to ≥5/≥3/≥7 (plan sketch had ≥4/≥3/≥4 which
wouldn't catch a regression in Tree's selection-row fill).
Verification:
- `cargo test -p openpencil-shell-core` — 10/10 passing
- `cargo check -p openpencil-shell-core --target
wasm32-unknown-unknown` — green (shell-core stays
wasm32-clean per spec §1.2 — no platform deps creeping in)
- `cargo check -p openpencil-shell-native` — green
Codex iterate review: 2 rounds → GO.
Adds the widget facade that B2 inspector widgets and Phase C event
handling will plug into. Logic-bearing widget code lives in
shell-core (per spec §1.4); shell-native + shell-web only own their
RenderBackend impls + DOM event mapping + accesskit DOM mirror.
What's added:
- `widgets::Widget` trait with `id` / `layout` / `paint(&self,...)` /
`access_node` methods. Phase B widgets are static — `paint` is
`&self`, mutable per-widget state lives in `*State` structs that
B2 lands. Phase C will extend the trait with a `&mut self` event
method for input handling.
- `widgets::WidgetId(pub u64)` plus a `pub const ROOT_WIDGET_ID =
WidgetId(0)` and a `WidgetId::new(id)` constructor with
`debug_assert!(id != 0)`. The tuple constructor stays public so
pattern matching + `const` contexts keep working; `::new` is the
conventional path that surfaces the root-id reservation in debug
builds. (Codex B1 R1 NIT-7 — make the convention compiler-visible
before Phase C tree routing lands.)
- `widgets::PaintCx<'a> { backend: &'a mut dyn RenderBackend }` and
`widgets::LayoutCx { available_width, dpi }` — frame-scoped paint
context + layout-time context. The `&mut dyn` indirection lets
shell-native + shell-web reuse the widget code without
monomorphising over the concrete backend.
- `widgets::LayoutBox { rect: Rect }` with `Debug + Clone + Copy +
PartialEq` derives.
- A `rect(x, y, w, h)` constructor convenience used by tests + B2.
Test harness (`tests/widgets_static.rs`):
- `RecordingBackend` impl `RenderBackend` counting each call.
- `paint_cx_dispatches_through_dyn_backend` — verifies fill_rect /
stroke_rect / save / translate / clip_rect / restore all dispatch
via `&mut dyn RenderBackend`.
- `widget_trait_dispatches_layout_and_paint` — minimal `StubWidget`
proves the trait shape compiles; asserts layout result, paint
dispatch count, `WidgetId::new(7)` round-trip, `ROOT_WIDGET_ID.0
== 0`, and `access_node().role() == Role::GenericContainer`. Real
semantic roles (TreeItem / EditableText / etc) land with B2.
Plumbing:
- `accesskit = "0.24"` added to shell-core deps to match shell-web's
pin (the version compatible with shell-native's accesskit_winit
Step 1a usage). Codex B1 R1 Q3 flagged that shell-native does not
yet pull accesskit; this is acknowledged as a Phase C tracked item
— verify the same version when DOM mirror / native a11y wires up.
- `Rect` now derives `PartialEq` so `LayoutBox` can use the same
derive. `Eq` is intentionally NOT derived (Vec2 carries floats);
comment in render_backend.rs explains.
Plan-vs-implementation deviations (deliberate, all kept narrow):
- Plan B1 step 2 declares `pub mod {dropdown, prop_row, text_input,
tree};` + re-exports inside widgets/mod.rs. Omitted here because
those modules don't exist until B2; declaring them now would
break the B1 standalone build. Top-block plan mini-patch
convention applies (override sketches in body).
- Plan didn't enumerate the accesskit dep + `Rect: PartialEq`
deltas — added with rationale comments.
Verification:
- `cargo test -p openpencil-shell-core` — green
- `cargo check -p openpencil-shell-core --target
wasm32-unknown-unknown` — green (shell-core stays wasm32-clean
per spec §1.2)
- `cargo check -p openpencil-shell-native` — green (no regression)
Codex iterate review: 4 rounds → GO. Round 1 CONCERN (3 items),
Round 2 CONCERN (1 stale comment), Round 3 CONCERN (comment vs
test body mismatch), Round 4 GO clean. Q3 (accesskit_winit
alignment) carries to Phase C as informational.
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.
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.
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.
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.
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.
Drives the three-OS CI matrix verification of the skia-safe + glutin +
glow + winit dep stack per Step 1a spec §7.
- examples/p0_probe.rs: stencil_visibility + readback chain runner (must
own a real OS main thread because winit on macOS rejects
EventLoop::new() from cargo test worker threads).
- tests/p0_probe.rs: subprocess-invoke wrapper, gated
#[ignore = "P0_PROBE_GATE"] so default cargo test stays untouched.
- Cargo.toml: add transient [target.'cfg(not(target_arch = "wasm32"))'.
dev-dependencies] block (skia-safe 0.97 + glutin 0.32.3 + glutin-winit
0.5.0 + glow 0.17.0 + raw-window-handle 0.6.2 + scopeguard 1.2.0 +
winit defaults). Pinned to versions resolved in /tmp/skia-glow-probe.
- .github/workflows/rust-check.yml: install Linux GL prereqs (xvfb,
mesa, libxkbcommon, libwayland) and add a P0-probe-gate step running
cargo test --ignored on each OS (Linux through xvfb-run; Windows
early-returns per spec §8.2 WINDOWS_GPU_DEFERRED_NO_RUNNER).
All three artefacts are TRANSIENT — reverted in a follow-up cleanup
commit after CI is green and the loader-compat notes commit lands.
Task 1 owns the permanent integration.
The merge in 6fbee16a took 'theirs' for 5 conflict files but git
silently kept 4 files from our side that upstream had DELETED in
b133ebc0 ("drop OP ShellEvent + JianPointerMapper"). Modify-vs-
delete merges don't always surface as unmerged paths — the file
still existed locally + had no conflict markers, so the merge
commit went out clean despite leaving these orphans:
crates/openpencil-shell-core/src/event.rs (ShellEvent enum)
crates/openpencil-shell-core/tests/event_shape.rs (3 tests)
crates/openpencil-shell-native/src/event/mod.rs (JianPointerMapper)
crates/openpencil-shell-native/tests/event_mapping.rs (15 tests)
Codex flagged the consequence: tests in event_mapping.rs still
imported ShellEvent + JianPointerMapper, both of which the
upstream's lib.rs no longer exports — workspace test compile
broken.
Removed all four files. cargo check --workspace --tests now
finishes 0 errors / 0 warnings on Apple Silicon (1.85.1 toolchain),
no remaining ShellEvent / JianPointerMapper references in the
crates tree.
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.
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.
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.
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.