Commit graph

82 commits

Author SHA1 Message Date
Kayshen-X 2cda18318d feat(shell): selection handles + drag-create + per-node flags + LayerPanel polish
Re-apply 4 reset commits (1854dfa6 → b94274c6) bundled with session
follow-ons. Native + web hosts share the new behavior end-to-end.

Selection + canvas interaction:
- bounded Frame drag now translates descendants too
- 8 selection handles with hover-cursor feedback
- thinner selection outline + smaller AA handles
- handle-drag resize for rect/ellipse/polygon/line/frame/text
- drag-to-create shapes / frames / text from the active tool
- per-NodeKind hit-test (oval / triangle / line slack / point-in-poly)
- rotation pivot is kind-aware (handles negative-size Lines)

Per-node flags (TS parity):
- Node.hidden / locked / collapsed / fill_type (moved off Document.ui)
- mutators gated by is_editable / is_subtree_editable so locked /
  hidden subtrees can't be translated, resized, rotated, recolored,
  or deleted as collateral

Multi-select + marquee + clipboard + keyboard shortcuts:
- selected_set + anchor; shift+click toggles set membership
- marquee rect-select with screen-px threshold + ADD-only shift
- copy / cut / paste / duplicate / nudge / reorder / select-all
- escape one-layer-per-press priority cascade (property-focus →
  locale picker → shape picker → fill-type picker → chat → selection)
- Cmd-letter chord guards (!shift) so Cmd-Shift-letter doesn't fall
  through to text input; !modifier guards on named keys

LayerPanel polish:
- hover-reveal eye/lock affordances (TS parity)
- Eye → EyeOff icon when hidden; Lock → LockOpen when unlocked
- locked Lock renders in warm orange
- chevron expand/collapse for container rows; collapsed subtree
  hides from tree (paint/hit-test unaffected)
- `+` add-page button wired end-to-end (mints fresh id past
  max_node_id + 1, names "Page N", overflow-safe)
- smaller, refined trailing icons (12 px @ 1.2 stroke)
- 18 px chevron-to-kind-icon gap

RenderBackend trait grew fill_oval / stroke_oval / fill_polygon /
stroke_polygon / rotate so both native and web backends can paint
the new node shapes.

Refactor:
- split native widget_host.rs (1799 lines) into spine + 7 sibling
  submodules under widget_host/ to stay under the 800-line ceiling
- split web widget_host.rs into spine + paint + keyboard siblings
- amend tools/check-widget-boundary.sh + spec § 1.4 to allow
  widget_host/* sibling files; tighten `// glue:` marker rule to
  the immediately-preceding line (rustfmt-stable)

Stop-hook iterations addressed:
- allocator overflow guards (checked_add) on duplicate / paste /
  add_page paths
- subtree-size precheck before any id mint in deep_clone
- hidden subtree skipped in paint AND selection overlay
- nested protected delete leak closed via is_subtree_editable
- per-FocusKind hex/numeric input gating; sticky `#` prefix on hex
- ScaleFactorChanged refreshes viewport from window.inner_size()

122 shell-core tests pass; cargo fmt --all --check clean;
cargo check --workspace clean; widget boundary check clean.
2026-05-11 21:30:06 +08:00
Kayshen-X e2aff6c542 feat(shell): canvas click-to-select + drag-to-move
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.
2026-05-10 23:23:28 +08:00
Kayshen-X 4bb91af968 style(shell-core): drop dropdown shadow + add toolbar→picker gap
- 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.
2026-05-10 23:18:39 +08:00
Kayshen-X 40f387a774 feat(shell-core): Toolbar shape-tool dropdown — Rect/Ellipse/Polygon/Line/Pen + Icon/Image
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.
2026-05-10 23:12:35 +08:00
Kayshen-X 16e3c9ccf5 feat(shell-core): PropertyPanel i18n + X/Y/W/H input editing
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.
2026-05-10 23:02:58 +08:00
Kayshen-X db69fc5fc7 refactor(shell): promote inspector_window to openpencil-desktop binary crate
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.
2026-05-10 19:50:10 +08:00
Kayshen-X 967201162a feat(shell): AA round-rects + Layer/Property dividers + resizable rails + smaller chrome
* 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.
2026-05-10 19:42:46 +08:00
Kayshen-X 7b800f67bd feat(shell): chevron + close-on-globe + multi-script font fallback
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.
2026-05-10 19:26:48 +08:00
Kayshen-X 4f95c0860b feat(shell-core): TopBar Globe → locale picker dropdown
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.
2026-05-10 19:21:36 +08:00
Kayshen-X c5d408d6be style(shell): cargo fmt --all (rustfmt-clean)
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.
2026-05-10 18:47:33 +08:00
Kayshen-X 91d9e99a94 feat(shell): theme + locale toggle wired to TopBar Sun + Globe icons
Sun click flips dark↔light; Globe cycles ZhCn↔EnUs. Both pipe
through Document.ui (theme_mode + locale) so any widget builder
that reads doc.theme() / doc.t(key) reflows immediately.

- Document.ui.theme_mode: ThemeMode { Dark, Light } with
  ThemeMode::flipped()
- Document.ui.locale: Locale { ZhCn, EnUs } with Locale::next()
- Document::theme() returns dark/light from ui.theme_mode
- Document::t(key) calls i18n::translate with ui.locale
- New i18n module — flat per-locale match tables, ~25 keys for
  chrome strings (TopBar / LayerPanel / PropertyPanel / chat).
  Unknown keys fall through to the key itself for debug visibility.
- TopBar.hit_test resolves Sun → ToggleTheme + Globe → ToggleLocale
- WidgetHost (native + web) routes both new TopBarHit variants
- LayerPanel / PropertyPanel / CanvasViewport / Toolbar /
  AIChatPlaceholder constructors swapped Theme::dark() →
  doc.theme() so the chrome flips together
- TopBar / StatusBar gained for_document(doc) builders
- StatusBar.zoom_percent now reads from Document.viewport.zoom

67 lib tests pass (+3 i18n unit tests).
2026-05-10 18:37:41 +08:00
Kayshen-X 71a3b9010b fix(shell-native): refresh host clock at top of every WindowEvent
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.
2026-05-10 18:24:58 +08:00
Kayshen-X 0c84202798 feat(shell): caret blink driven by jian-core::anim primitives
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
2026-05-10 18:19:46 +08:00
Kayshen-X a7f9eb120f style(shell-core): selected layer row uses primary-tinted bg + primary text/icon
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)
2026-05-10 18:08:47 +08:00
Kayshen-X 1d2dd789a9 fix(shell-native): collapsed-sidebar canvas input uses canvas_region
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.
2026-05-10 17:26:20 +08:00
Kayshen-X 0954629626 fix(shell): collapsed-sidebar toolbar hit-test follows canvas_region
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.
2026-05-10 17:21:18 +08:00
Kayshen-X 484c6032b8 feat(shell): step 4-6 chrome — TS-equivalent editor UI + interactions
Step 4 (visual lift):
- Theme tokens (shadcn-dark palette) in shell-core
- Lucide-style icons via stroke_svg_path (skia parse_path::from_svg)
- Vertical Toolbar / sectioned LayerPanel (Pages + Layers) /
  TopBar / floating StatusBar / floating AIChatPanel widgets
- Native + web backends: stroke_line / fill_round_rect /
  stroke_round_rect / stroke_svg_path primitives
- CJK fallback typeface: cached PingFang/Noto-CJK on native via
  match_family_style_character; embedded NotoSansCJK-Subset
  (8.7 KB) on web alongside Roboto

Step 5 (infinite canvas + AI chat input):
- Document.viewport (pan + zoom 10–800%) with cursor-centered
  zoom_at + Hand-tool drag pan + dotted background grid
- Trackpad PixelDelta → pan, LineDelta / pinch / Cmd+swipe →
  zoom (winit MouseScrollDelta + PinchGesture + Modifiers)
- Document.chat (input / messages / focused / collapsed /
  4-corner anchor) — WidgetHost wires apply_text /
  apply_backspace / apply_send + DOM keydown listener
- AI chat panel drag → 4-corner snap via ChatAnchor::nearest
- Collapsed mode: compact pill (MessageSquare + "New Chat" +
  ChevronUp), entire pill click expands

Step 6 (RightPanel + chrome polish):
- PropertyPanel rewrite: 设计/代码 tabs, 创建组件, 位置, 弹性布局,
  尺寸, 图层, 填充, 描边, 效果, 导出 — file split into
  property_panel.rs + property_panel_sections.rs (under 800 ea.)
- Node::aggregate_bounds for Group / unbounded containers so
  the panel reports child-union W/H instead of 0×0
- TopBar PanelLeft button toggles Document.ui.sidebar_open
- Click empty canvas clears selection (collapses RightPanel)
- Native font cache (Roboto + system CJK typeface) bypasses
  jian-skia textlayout: chrome paint 605 ms → sub-ms

Hit-test order = paint order reversed (chat → toolbar → layer
panel → canvas) so the topmost overlay always wins, plus
toolbar bounding-rect consumes gap clicks so they don't fall
through.

64 lib tests + 21 widgets_static green; native + web
cargo check clean. Web wasm rebuild gated on EMSDK
(tools/check-wasm-bundle.sh runs the bundle ceiling guard).
2026-05-10 17:07:59 +08:00
Kayshen-X 007e97ba03 fix(shell): Step 3 stop-hook R2 — plumb viewport_height through paint
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
2026-05-10 13:20:17 +08:00
Kayshen-X 8523f7fbcf refactor(shell): single canonical MIN_RAIL_WIDTH in shell-core
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
2026-05-10 12:20:44 +08:00
Kayshen-X b161299e88 feat(shell): Step 3 — Node geometry + CanvasViewport center widget
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)
2026-05-10 12:08:24 +08:00
Kayshen-X aa966d0937 fix(shell): Step 2 codex R1 — sentinel + page-scope + clamp + overflow doc
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).
2026-05-10 10:53:59 +08:00
Kayshen-X 472f1061b2 feat(shell): Step 2 — Document model + editor-UI widgets driving WidgetHost
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.
2026-05-10 10:34:25 +08:00
Kayshen-X c109b54f70 docs(shell-native): correct stale Cargo.toml comment about Invariant 2
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
2026-05-10 10:17:10 +08:00
Kayshen-X 97ef49d76f feat(shell-native): extend widget stack to iOS + Android cargo check
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.
2026-05-10 10:16:36 +08:00
Kayshen-X 987ce82a24 feat(shell-native): WidgetHostNative + inspector_window — cross-platform proof
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.
2026-05-10 10:11:01 +08:00
Fini d582a715e4 fix(shell): drop orphan pub mod event refs after Step 1b §3.2 merge
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.
2026-05-08 22:35:00 +08:00
Fini b2f6ae99ff Merge remote-tracking branch 'origin/v0.8.0' into v0.8.0 2026-05-08 22:33:12 +08:00
Kayshen-X e759bededd refactor(shell-native): finish v19.4 cleanup — drop orphan event/ + stale test
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.
2026-05-08 22:03:34 +08:00
Kayshen-X 136274a3ec chore(vendor): bump jian submodule to d5d358e (Step 1b §3.2 P0.5A)
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.
2026-05-08 22:03:08 +08:00
Fini b5219a2c17 fix(shell): wire ShellEvent + JianPointerMapper through lib.rs after merge
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).
2026-05-08 07:30:00 +08:00
Fini c81cfe82a4 Merge branch 'v0.8.0' of github.com:ZSeven-W/openpencil into v0.8.0
# Conflicts:
#	.github/workflows/rust-multiplatform.yml
#	README.md
#	crates/openpencil-shell-core/src/lib.rs
#	crates/openpencil-shell-native/examples/basic_window.rs
#	crates/openpencil-shell-native/src/lib.rs
2026-05-05 22:51:00 +08:00
Kayshen-X e706454235 refactor(shell): drop OP ShellEvent + JianPointerMapper — re-export Jian events directly
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.
2026-05-05 22:42:00 +08:00
Kayshen-X 543f556cdf feat(shell-native): Step 1a Task 4 — basic_window demo + acceptance + Phase C Gate
Phase C Task 4 closes Step 1a (G1 shared Skia context) on v0.8.0:

- crates/openpencil-shell-native/examples/basic_window.rs:
  winit + SharedSkiaContext::new_desktop + NativeBackend (Jian DrawOp)
  + JianPointerMapper integration. Paints chrome rect + "Hello 你好"
  + box outline; close → idempotent teardown. Demonstrates Phase B
  Task 3 winit → Jian PointerTranslator → JianPointerMapper →
  ShellEvent pipeline end-to-end.
- crates/openpencil-shell-native/notes/step-1a-{macos,linux,windows}-manual-smoke.md:
  manual GPU smoke runbooks for spec §1.2 acceptance #1 (macOS PASS
  recorded; Linux/Windows pending real-hardware run, deferred per
  CONCERN-R5-1 + WINDOWS_GPU_DEFERRED_NO_RUNNER).
- tools/check-jian-boundaries.sh: spec §11 + §12.3 invariants.
  Verifies that openpencil-app has no direct jian-* dep, mobile
  (aarch64-linux-android, aarch64-apple-ios) and wasm32 closures
  exclude jian-host-desktop / jian-skia, and openpencil-shell-web
  declares no jian-host-desktop dep at the manifest level.
- .github/workflows/rust-check.yml: wires bash tools/check-jian-boundaries.sh
  on Linux runner with mobile + wasm32 targets installed.
- README.md: roadmap entry for the Step 1a milestone.

Verified locally on macOS aarch64:
- cargo fmt --all -- --check
- cargo clippy --workspace --all-targets -- -D warnings
- cargo build --examples --workspace
- cargo test --workspace (38 PASS, 0 FAIL, 0 IGNORED)
- cargo check -p openpencil-shell-native --target {aarch64-linux-android, aarch64-apple-ios}
- bash tools/check-jian-boundaries.sh (4 invariants PASS)
- spec §11 invariants 1–4 grep checks PASS

Spec v19.3 FROZEN (openpencil-docs 651090d); Plan v7 FROZEN.
vendor/jian pinned at c4a794dc.
2026-05-05 22:39:00 +08:00
Kayshen-X c3e935ccbb feat(shell-core,shell-native): map Jian PointerEvent to ShellEvent (Step 1a Task 3)
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.
2026-05-05 22:36:00 +08:00
Kayshen-X c6c59d95c1 ci: defer Linux GPU smoke + add Windows arm64 matrix
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).
2026-05-05 22:33:00 +08:00
Kayshen-X 919a1381f2 fix(shell-native): cfg-gate desktop GL stack so iOS/Android cargo check passes
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.
2026-05-05 22:24:00 +08:00
Kayshen-X d699cb89bd ci+test: fix Linux EGL unsafe wrap + drop shell-native from mobile cargo check
- 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.
2026-05-05 22:21:00 +08:00
Kayshen-X cf50616db1 style: apply formatter + bump vendor/agent submodule + ignore vendors in oxfmt
- .prettierignore: 加 vendor/agent + vendor/jian + target/(submodule 不在本仓 format 范围)
- vendor/agent: 62c4bad(cosmetic format-only delta in agent-rs)
- root + shell-native + shell-web Cargo.toml / deny.toml / README.md / wasm-bundle-check workflow: oxfmt auto-style
2026-05-05 22:12:00 +08:00
Kayshen-X c55807e432 ci: remove TS/Electron workflows (build-electron / ci / docker / publish-cli)
Rust-ification 阶段,CI 只保留 Rust 相关:
- rust-check.yml: cargo fmt + build + test (with STEP1A_REQUIRE_GPU=1 on Linux) + clippy + cargo-deny
- wasm-bundle-check.yml: wasm32 target check

删除:
- build-electron.yml: Electron desktop build (Rust 化后用 openpencil-shell-native)
- ci.yml: TS type-check + Vitest + web build (Rust 化后已废)
- docker.yml: TS Docker image (Rust 化后重做)
- publish-cli.yml: npm packages (Rust 化后改 cargo publish)
2026-05-05 22:09:00 +08:00
Kayshen-X c9aaa0efc4 feat(shell-native): Step 1a Task 4 — basic_window demo + acceptance + Phase C Gate
Phase C Task 4 closes Step 1a (G1 shared Skia context) on v0.8.0:

- crates/openpencil-shell-native/examples/basic_window.rs:
  winit + SharedSkiaContext::new_desktop + NativeBackend (Jian DrawOp)
  + JianPointerMapper integration. Paints chrome rect + "Hello 你好"
  + box outline; close → idempotent teardown. Demonstrates Phase B
  Task 3 winit → Jian PointerTranslator → JianPointerMapper →
  ShellEvent pipeline end-to-end.
- crates/openpencil-shell-native/notes/step-1a-{macos,linux,windows}-manual-smoke.md:
  manual GPU smoke runbooks for spec §1.2 acceptance #1 (macOS PASS
  recorded; Linux/Windows pending real-hardware run, deferred per
  CONCERN-R5-1 + WINDOWS_GPU_DEFERRED_NO_RUNNER).
- tools/check-jian-boundaries.sh: spec §11 + §12.3 invariants.
  Verifies that openpencil-app has no direct jian-* dep, mobile
  (aarch64-linux-android, aarch64-apple-ios) and wasm32 closures
  exclude jian-host-desktop / jian-skia, and openpencil-shell-web
  declares no jian-host-desktop dep at the manifest level.
- .github/workflows/rust-check.yml: wires bash tools/check-jian-boundaries.sh
  on Linux runner with mobile + wasm32 targets installed.
- README.md: roadmap entry for the Step 1a milestone.

Verified locally on macOS aarch64:
- cargo fmt --all -- --check
- cargo clippy --workspace --all-targets -- -D warnings
- cargo build --examples --workspace
- cargo test --workspace (38 PASS, 0 FAIL, 0 IGNORED)
- cargo check -p openpencil-shell-native --target {aarch64-linux-android, aarch64-apple-ios}
- bash tools/check-jian-boundaries.sh (4 invariants PASS)
- spec §11 invariants 1–4 grep checks PASS

Spec v19.3 FROZEN (openpencil-docs 651090d); Plan v7 FROZEN.
vendor/jian pinned at c4a794dc.
2026-05-05 21:51:00 +08:00
Kayshen-X bb321f115c feat(shell-core,shell-native): map Jian PointerEvent to ShellEvent (Step 1a Task 3)
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.
2026-05-05 21:48:00 +08:00
Kayshen-X 133202c5b7 ci: defer Linux GPU smoke + add Windows arm64 matrix
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).
2026-05-05 21:45:00 +08:00
Kayshen-X c1a394439a fix(shell-native): cfg-gate desktop GL stack so iOS/Android cargo check passes
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.
2026-05-05 21:36:00 +08:00
Kayshen-X 61f16acdd3 ci+test: fix Linux EGL unsafe wrap + drop shell-native from mobile cargo check
- 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.
2026-05-05 21:33:00 +08:00
Kayshen-X 7fb674d928 style: apply formatter + bump vendor/agent submodule + ignore vendors in oxfmt
- .prettierignore: 加 vendor/agent + vendor/jian + target/(submodule 不在本仓 format 范围)
- vendor/agent: 62c4bad(cosmetic format-only delta in agent-rs)
- root + shell-native + shell-web Cargo.toml / deny.toml / README.md / wasm-bundle-check workflow: oxfmt auto-style
2026-05-05 21:24:00 +08:00
Kayshen-X b066a1c057 feat(shell-native): Phase A Gate round 3 fixes
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.
2026-05-05 21:15:00 +08:00
Kayshen-X 656b57a024 style(shell): convert all comments to English
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.
2026-05-05 21:12:00 +08:00
Kayshen-X d26b53c06d fix(shell-native): Phase A Gate round 1 fixes (Task 2 patches)
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
2026-05-05 21:09:00 +08:00
Kayshen-X c1ce879582 feat(shell-native): SharedSkiaContext + NativeBackend over Jian DrawOp
Step 1a Task 2 (spec v19 §3 / §5.2.1, plan v7).

- `SharedSkiaContext`: own GL stack + Skia DirectContext + Surface,
  `Option<>`-field idempotent teardown, `with_frame(|canvas, glow|)`
  callback, lifecycle hooks (on_pause/on_resume/on_low_memory) with
  Android surface drop contract; tracing spans + events on every
  per-frame entry point.
- `GlContextProvider` trait + `GlutinProvider` desktop impl + iOS /
  Android stubs; trait carries no `Send` bound (per spec §3.1).
- `CanvasViewportStub::render_into(&Canvas)` deliberately pollutes
  STENCIL_TEST + blend func to verify chrome-paint isolation.
- `NativeBackend`: frame-scoped methods mirroring OP `RenderBackend`
  trait surface (no direct trait impl in 1a; Step 1c+ wraps via
  `WithCanvas<'a>` newtype). Translates `fill_rect / stroke_rect /
  draw_text / clip_rect / save / restore / translate` to
  `jian_core::render::DrawOp` and submits via
  `jian_skia::SkiaBackend::draw_on_canvas`. Public `draw_op` helper +
  `to_jian_color` / `to_jian_rect` converters.
- Tests:
  - `teardown_idempotent.rs` — teardown × 3 + lifecycle hook idempotence.
  - `memory_loop.rs` — 100 × create/begin_frame/present/teardown × 3
    with sysinfo RSS budget < 5 %.
  - `tracing_spans.rs` — `tracing-test` (no-env-filter) catches
    begin_frame / with_frame / present / resize / teardown / on_pause /
    on_resume / on_low_memory events.
  - `raster_composition.rs` — chrome-only fill_rect on raster surface,
    pixel-asserts red + black + untouched-bg.
  - `raster_text_smoke.rs` — "Hello 你好" through textlayout feature,
    asserts visible glyph rasterisation.
  - `gpu_smoke.rs` — Linux EGL pbuffer (non-ignored) + macOS invisible
    winit window (graceful inconclusive when off main thread; full
    path runs from `cargo run --example basic_window`) + Windows
    `#[ignore]` per spec §8.1.
  - `gpu_chrome_stub_composition.rs` — chrome+stub on the same GL
    surface, asserts chrome pixels survive stub's GL pollution.
- Cargo.toml: add `jian-core` direct dep + `tracing` / `thiserror`
  workspace deps; dev-deps `sysinfo`, `tracing-test` (with
  `no-env-filter`), Linux-only `khronos-egl` + `libloading`.

`cargo build`, `cargo test`, `cargo clippy --all-targets -- -D warnings`,
`cargo fmt --all -- --check` all green on macOS.
2026-05-05 21:06:00 +08:00
Kayshen-X 2f60bd49f8 feat(workspace): pin Jian submodule and shell wrapper deps (Step 1a Task 1)
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.
2026-05-05 21:00:00 +08:00