Commit graph

131 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 f4a569b002 docs(shell): note PropertyPanel editing + Toolbar shape dropdown + desktop crate
* crates/CLAUDE.md — add ShapePicker widget row, PropertyPanel
  X/Y/W/H editing section, Toolbar shape-tool dropdown spec, expanded
  Tool variants, updated UiState bullet listing all the new fields,
  Document::commit_property_edit + Tool::is_shape() helpers.
2026-05-10 23:17:18 +08:00
Kayshen-X 1cacc0eac2 fix(shell-core): chevron sits BELOW the toolbar shape button
Was overlapping the icon at the lower-right of the button. Moved to
the gutter directly below the button, horizontally centered, sized
10 px in muted-foreground. Toolbar now reserves a 10 px extra
bottom slot after ShapeSlot so the chevron has room without bumping
the next button. Hit area extended to include the chevron gutter
so clicking on the caret also opens the picker.
2026-05-10 23:14:56 +08:00
Kayshen-X 79b44e722d style(shell-core): chevron-down on toolbar shape slot
Lower-right corner of the shape button now carries a small
ChevronDown so the dropdown affordance is visible at rest, matching
the TS shape-tool-dropdown's caret. Color follows the active /
muted-foreground split the rest of the slot uses.
2026-05-10 23:13:25 +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 333403a0c7 docs(shell): note empty-agent chip, locale picker, multi-script, chat parity
* crates/CLAUDE.md — register LocalePicker widget, canvas_surface token,
  ai.tipSelectElements key, multi-script per-codepoint font cache.
* TopBar Globe section now describes the 44 px globe-plus-chevron
  compound and the click-anywhere-to-close behaviour.
2026-05-10 19:34:28 +08:00
Kayshen-X bf8f156a7b feat(shell-core): canvas surface, slimmer toolbar, TS-style chat input
* 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.
2026-05-10 19:33:06 +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 3b77cbb211 style(shell-core): drop border ring on Agents 与 MCP chip 2026-05-10 19:16:46 +08:00
Kayshen-X e40c3a6e1c feat(shell-core): TopBar empty-agent chip — 'Agents 与 MCP' affordance
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.
2026-05-10 19:11:06 +08:00
Kayshen-X c6020e3aeb docs(shell): document the i18n + theme toggle infrastructure
crates/CLAUDE.md gains an 'i18n' row + 'Theme + i18n' section that
covers Document::theme()/t() + the 15 locale tables generated
from TS. Includes the convert-locales.py re-run command for
contributors who change TS strings.
2026-05-10 19:08:17 +08:00
Kayshen-X 2a3a6e96d6 fix(shell-core): convert-locales handles multi-line + double-quoted values
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'.
2026-05-10 19:04:38 +08:00
Kayshen-X 544a307fd9 feat(shell-core): import all 15 TS locale tables verbatim
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).
2026-05-10 18:57:20 +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 9506341da0 feat(shell-core): thread localised strings through LayerPanel + AIChatPanel paint
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.
2026-05-10 18:40:46 +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 ed48e1f139 fix(shell-core): 创建组件 icon Diamond → Component to match TS
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.
2026-05-10 18:05:05 +08:00
Kayshen-X 0c0131653e style(shell-core): refine property panel to match TS — section labels foreground, full-width dividers, taller inputs
Previous panel had muted section labels + inset dividers + 26px inputs;
TS reference (apps/web/src/components/panels/right-panel.tsx) uses
foreground-tinted headers, edge-to-edge dividers, and 30px inputs.

- INPUT_HEIGHT 26 → 30 (matches TS Input render height)
- SECTION_HEADER_HEIGHT 28 → 24 (TS  tighter strip)
- SECTION_GAP 1.0 → 8.0 (proper breathing room between divider and
  next label)
- Section labels: muted_foreground → foreground (matches TS)
- Section dividers: PAD_X inset → full canvas-edge-to-edge

Visible polish difference vs the TS screenshot is now mostly the
input baseline / right-padding, which a future text-measure API
will fix once we have skia-side advances.
2026-05-10 18:04:09 +08:00
Kayshen-X dacd4e6994 fix(shell-core): align property panel icons with TS lucide imports
- 创建组件 button: Sparkles → Diamond (TS uses lucide Diamond)
- 弹性布局 buttons: Square/Frame/FolderOpen → LayoutGrid/Rows3/Columns3
  (TS layout-section.tsx imports Columns3/Rows3/LayoutGrid/Check)
- 位置 R input: Redo → RotateCw (TS uses RotateCw for rotation handle)
- Add 9 new lucide d-strings to icons.rs: LayoutGrid, Rows3, Columns3,
  RotateCw, Diamond, Component, Unlink, Check, ArrowUpRight
2026-05-10 18:02:08 +08:00
Kayshen-X e92ed98203 docs(shell): note canvas_region invariant for collapsed sidebar
Recent codex stop-hook iterations exposed three sites where input
hit-test hardcoded LAYER_PANEL_WIDTH while paint followed
canvas_region (which collapses to 0 when sidebar is closed):
over_canvas, apply_wheel cursor offset, toolbar hit rect. Document
the invariant so future widgets don't re-introduce the drift.
2026-05-10 17:57:07 +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 a578178842 docs(shell): add crates/CLAUDE.md for the Rust shell
Documents the crate layout, key invariants (wasm32-clean shell-core,
800-line file ceiling, web bundle 1 MiB / 0 env.* imports), Document
model, widget catalog, RenderBackend surface, native + web runner
specifics, hit-test order, and the perf gotchas (native chrome paint
605ms→sub-ms via cached typeface path; skia matrix reset; jian DrawOp
bypass for line + round-rect + svg-path).
2026-05-10 17:16:29 +08:00
Kayshen-X 8a95770c40 fix(shell-web): wire TopBar sidebar toggle + selection-clear parity with native
Stop-hook fix: web apply_press never wired the TopBar PanelLeft hit
or the empty-canvas selection-clear, so the sidebar collapse + click-
blank-to-deselect interactions only worked in the native demo. This
brings web behaviour in line:

- apply_press top-of-function now hit-tests TopBar; PanelLeft toggles
  Document.ui.sidebar_open. Other top-bar gaps eat the click so they
  don't fall through to canvas pan.
- canvas_region + over_canvas branch on sidebar_open so the canvas
  region extends to viewport_left when the LayerPanel is hidden.
- apply_click skips the LayerPanel hit-test entirely when the sidebar
  is collapsed.
- paint conditionally skips LayerPanel and uses canvas_region's
  collapsed-aware canvas_left for the StatusBar anchor.
- Empty-canvas press clears Document.selected (collapses RightPanel),
  matching native.

Also: collapsed AI chat pill — entire pill click toggles back open
instead of requiring a precise hit on the chevron icon (40px hit zone
was too tight).
2026-05-10 17:14:13 +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 c299b3990c fix(shell-core): Step 3 stop-hook — clip-isolate canvas viewport paint
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
2026-05-10 13:29:45 +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 7e1fbcabf0 fix(shell-web): Step 3 stop-hook — read canvas size from backend
Codex Step 3 stop-hook flagged: "web repaint ignores actual
canvas size". The prior fix `9cf0f865` hardcoded `960.0` to
match the smoke HTML's `<canvas id="op" width="960">`, but
that's brittle — any host that mounts onto a differently-sized
canvas (responsive HTML, programmatic mount, future smoke
fixture changes) gets the wrong layout viewport.

Fix: WebBackend gains `canvas_width(&self) -> u32` +
`canvas_height(&self) -> u32` accessors. The `width` /
`height` fields are already refreshed at construction
(`canvas.width()`) and on `RenderBackend::resize`, so reading
them per-paint reflects whatever the host's `<canvas>` width
attribute currently is.

`paint_inspector` now:
- Reads `viewport_w` + `viewport_h` from the backend at the
  start of each frame.
- Uses them for the white-background clear AND for the
  WidgetHost::paint viewport_width arg.

This also resolves the prior "web smoke paints only the
toolbar" issue since the smoke canvas is 960×640 — the first
frame still receives 960 as viewport_width, but now via the
backend instead of a hardcode.

Verification:
- `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 031
  bytes gzip = 86% of 1 MiB ceiling (negligible delta — two
  small accessor methods).
2026-05-10 13:14:17 +08:00
Kayshen-X 01c5baf8f5 refactor(wasm-libc-shim): split imp.rs to honor 800-line ceiling
Codex Step 3 stop-hook flagged: `wasm-libc-shim` violates the
repo file-size gate. After the Step 3 additions
(`bd6f4836` + `12b9af92`) `imp.rs` grew to 845 lines, over
the 800-line ceiling documented in `openpencil/CLAUDE.md`
("Single files must not exceed 800 lines").

# Layout

```
crates/wasm-libc-shim/src/imp/
  mod.rs   (521 lines) — original C-hard.2 surface:
                          allocator + libm + libc string +
                          libc misc + C++ ABI + operator new/
                          delete + threads + libcxx panic stubs
  step3.rs (349 lines) — Skia font-path additions: extra
                          string ops (strncmp/strncpy/strstr/
                          strrchr/strcat/strtol/tolower/qsort),
                          file I/O sentinel returns, mmap,
                          setjmp/longjmp, _ZnwmRKSt9nothrow_t
```

Both files emit `#[no_mangle]` symbols at the same flat global
namespace — wasm-ld resolves them identically regardless of
the parent Rust module. Module structure is purely a source-
organisation concern, no ABI implications.

# Plumbing

- `git mv imp.rs imp/mod.rs` to preserve history.
- `imp/mod.rs` declares `mod step3;` at the top + makes
  `is_space` `pub(super)` so `step3::strtol` can reuse it
  without re-implementing the whitespace check.
- `c_long` import dropped from `mod.rs` (only `step3.rs` uses
  it now).

# Verification

- `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.*, 906 988
  bytes gzip = 86% of 1 MiB ceiling (no growth — the split is
  source-only)
- File sizes:
  - imp/mod.rs: 521 lines (under 800 ✓)
  - imp/step3.rs: 349 lines (under 800 ✓)
2026-05-10 13:02:47 +08:00
Kayshen-X 7b29943946 fix(wasm-libc-shim): Step 3 R1 — c_long for strtol/ftell/fseek
Codex Step 3 R1 CONCERN: wasm32-unknown-unknown sizes `long` as
32-bit, but the new libc shim returned `i64` from `strtol` /
`ftell` and accepted `i64 offset` in `fseek`. The wasm-ld
linker resolved the mismatch by inserting `signature_mismatch:
strtol` / `signature_mismatch:ftell` / `signature_mismatch:
fseek` trap stubs into the bundle — calling any of them at
runtime would have crashed even though the shim crate
"compiled".

Fix: use `core::ffi::c_long` (= i32 on wasm32, i64 on
desktop) for return + offset types. Verified with
`wasm-objdump -x | grep signature_mismatch:` — no entries
remain for strtol / ftell / fseek / setjmp / longjmp / fopen /
fread / fclose / fprintf. (The remaining `signature_mismatch:
_ZNSt3__2…` entries are our libcxx_stub! macros, which return
`!` and are correct to trap-on-call.)

Two NIT fixes folded in:

- `strtol` now accepts an explicit `0x` prefix when the
  caller passes `base=16` (codex Step 3 R1 NIT-2 — strtoull
  already had the same handling; mirrored for parity).
- `qsort` no longer silently no-ops on element size > 256;
  panics loudly so a real call site gets a usable diagnostic
  (codex Step 3 R1 NIT-3). Tiny font-feature / glyph-run
  arrays stay under the threshold.

WebBackend typeface caching tightened (codex Step 3 R1 NIT-1):
- New sticky `typeface_tried: bool` flag flips on first
  attempt regardless of outcome. Subsequent draw_text calls
  skip the FontMgr / from_data round-trip if `typeface` is
  still None — a one-time failure no longer re-parses the
  TTF on every frame.

Verification:
- `cargo build -p openpencil-shell-web --target
  wasm32-unknown-unknown --features skia --release` — green
- `wasm-bindgen --target web` — produces ../pkg/*
- `wasm-objdump -x | grep "signature_mismatch:" | grep -E
  "strtol|ftell|fseek|..."` — empty (all shim signatures
  resolve cleanly)
- `bash tools/check-wasm-bundle.sh` — PASS:
  - 0 env.* imports
  - 906 964 bytes gzip = 86% of 1 MiB ceiling (unchanged)
2026-05-10 12:51:17 +08:00
Kayshen-X fe5249567f fix(shell-web): Step 3 stop-hook — actually render canvas text
Codex stop-hook flagged: "web Step 3 cannot render the claimed
canvas text". Root cause: WebBackend::draw_text was a Phase A
no-op stub. CanvasViewport calls draw_text for "Hello
OpenPencil" / "Click me" / Layer panel labels / etc — none of
those text strings actually rendered in the browser.

# Fix

`crates/openpencil-shell-web/src/backend/mod.rs::WebBackend`:
- Embeds `assets/Roboto-Regular.ttf` (Apache 2.0, 35 KB, copied
  from rust-skia test resources) via `include_bytes!`. The
  C-hard wasm32-unknown-unknown skia build uses
  `skia_enable_fontmgr_custom_empty=yes` (see
  `vendor/skia-safe-op/skia-bindings/build_support/platform/
  wasm_unknown.rs`), so there are no system fonts and we have
  to bake the bytes in.
- New `typeface: Option<Typeface>` field, lazy-init on first
  draw_text via `FontMgr::custom_empty().and_then(|m|
  m.new_from_data(ROBOTO_TTF, None))`. Build failure → None
  silently no-ops subsequent draws (no panic — text just
  doesn't render).
- `draw_text` now iterates `layout.runs()` and calls
  `Canvas::draw_str` per run with a `Font::new(typeface,
  font_size)` + `Paint` from the run color.

`crates/openpencil-shell-web/Cargo.toml`:
- Drops `textlayout` skia-safe feature. We use raw `draw_str`
  not paragraph builder; textlayout pulled ICU + Harfbuzz +
  ~400 KB gzip + a swarm of font-lookup imports we don't
  exercise.

# 24 new env.* imports — wasm-libc-shim expansion

Even without textlayout, Skia's font path imports 24 libc
symbols our prior C-hard.2 shim didn't cover. All resolved:

`crates/wasm-libc-shim/src/imp.rs` — Rust extern "C" shims:
- string ops (real impls): strncmp, strncpy, strstr, strrchr,
  strcat, strtol, tolower, qsort (insertion sort, fits Skia's
  small-array call sites; debug_assert on element size > 256)
- file I/O (sentinel error returns, no filesystem on wasm):
  fopen → null, fread → 0, fclose → 0, fputc → c, fileno → -1,
  fstat → -1, pread → -1, ftell → -1, fseek → -1
- env: getenv → null
- mmap: returns MAP_FAILED ((void*)-1); munmap → -1
- setjmp/longjmp: setjmp returns 0 (treat as initial call);
  longjmp panics — happy text path through in-memory TTF parse
  should never trigger it
- C++ nothrow new: `_ZnwmRKSt9nothrow_t` forwards to malloc,
  returns nullptr on OOM (the nothrow contract)

`crates/wasm-libc-shim/src/stdio_stub.c`:
- fprintf C-side variadic stub (Skia diagnostic path) that
  routes into the same panic helper as snprintf / vsnprintf /
  vfprintf — same fail-fast policy.

# Verification

- `cargo build -p openpencil-shell-web --target
  wasm32-unknown-unknown --features skia --release` — green
- `wasm-bindgen --target web` produces ../pkg/openpencil_shell
  _web.{js,_bg.wasm}
- `bash tools/check-wasm-bundle.sh` — PASS:
  - 0 env.* imports preserved (24 new ones absorbed by shim)
  - 906 935 bytes gzip = 86% of 1 MiB ceiling (+286 KB vs
    pre-text — Skia font/freetype subsystem is substantial.
    Headroom: 14% of ceiling)
- `cargo test -p openpencil-shell-core --lib` — 39 tests
- `cargo build -p openpencil-shell-native --example
  inspector_window` — green (desktop demo unchanged — uses
  jian-skia textlayout via NativeBackend, not the web font
  path)
- `cargo check -p openpencil-shell-native --target
  aarch64-apple-ios` — green
- `cargo check -p openpencil-shell-native --target
  aarch64-linux-android` — green

# Re-run the demo

```
EMSDK="$HOME/.emsdk" cargo build -p openpencil-shell-web \
    --target wasm32-unknown-unknown --features skia --release
wasm-bindgen --target web --out-dir crates/openpencil-shell-web/pkg \
    target/wasm32-unknown-unknown/release/openpencil_shell_web.wasm
cd crates/openpencil-shell-web/smoke
python3 -m http.server 8000
# Browser: http://localhost:8000/step-1b.html
```

Now the canvas viewport renders "Hello OpenPencil" + "Click me"
text in addition to the rect/stroke geometry.
2026-05-10 12:38:33 +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 3192d237f4 fix(shell-web): Step 3 stop-hook — pass full canvas width to host
Codex stop-hook flagged: "web smoke paints only the toolbar".

Root cause: shell-web's `paint_inspector` still passed the Step
1b leftover `280.0` to `host.paint`, but Step 3's WidgetHost
layout takes ~1/4 width per rail. With viewport_width=280 the
rail_w computation:

    rail_w = ((280.0 / 4.0) - 8.0).min(240.0).max(0.0) = 62.0

falls below MIN_RAIL_WIDTH (80), so the host's early-return
silently fired and only the toolbar painted. The smoke HTML
canvas is 960×640 — the host was getting a synthetic
viewport that didn't reflect reality.

Fix: pass `960.0` to `host.paint`, matching the smoke HTML's
`<canvas id="op" width="960">`. Now LayerPanel + CanvasViewport
+ PropertyPanel all paint into the canvas.

Inline comment cites the codex finding so a future hardcoded
viewport width regression is obvious.

Bundle untouched at 624 474 bytes gzip / 0 env.* imports.
2026-05-10 12:15:39 +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 fb2d3b1a8d fix(shell-core): Step 2 codex R2+R3 — empty-pages validate gap
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.
2026-05-10 11:05:53 +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
Kayshen-X af66f3d849 fix(shell-core): Phase C stop-hook #2 — skip dropdown keys during IME
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.
2026-05-09 21:54:00 +08:00