The native runner outgrew the `examples/` slot — it owns DPI tracking,
caret-blink animation timer, panel-resize cursor, the full Cmd+wheel /
PinchGesture / Pixel/LineDelta dispatch table, etc. None of that is a
sample, so it's been promoted to a real crate.
* New crate `crates/openpencil-desktop/` with a single `[[bin]]`
target. Depends on `openpencil-shell-native` (lib) + winit +
skia-safe (gl), gated to macOS / Linux / Windows.
* `examples/inspector_window.rs` removed; equivalent code lives at
`crates/openpencil-desktop/src/main.rs` with the structs renamed
(DesktopApp / paint) and the doc-block rewritten as a runner spec.
* Run command: `cargo run -p openpencil-desktop --release`. Old
command (`--example inspector_window`) is gone.
* Workspace glob `crates/*` already picks up the new crate, no
Cargo.toml workspace edit needed.
* Docs: crates/CLAUDE.md updated with the new crate row and runner
section retitled "Desktop binary". Top-bar layout test renamed +
uses the TOP_BAR_HEIGHT constant so future height tweaks stop
breaking it.
* Native fill_round_rect now sets anti_alias(true) — was the source of the
stair-stepped tool-button corners. Mirrors the AA flag we already had on
stroke_round_rect / stroke_line / stroke_svg_path.
* LayerPanel paints a right-edge hairline (so the rail reads as a distinct
surface from the canvas) plus an inset hairline between the Pages and
Layers sections (matches the TS LayerPanel border-t).
* Layer + Property panel widths are now first-class Document.ui state
(`layer_panel_width` / `property_panel_width`, defaults 240/280).
Native host detects ±4 px gutter clicks on the panel edges, drags the
width inside [180, 480], and the inspector_window runner flips the
cursor to EwResize while hovering or actively resizing.
* Web host expressions threaded onto the same UiState fields for parity;
drag wiring on web is a follow-up.
* TopBar trimmed: 48 → 40 px height, 32 → 28 icon button, 18 → 16 icon —
the chrome reads less heavy at default zoom.
* Drops the now-unused PropertyPanel `Copy` derive (UiState carries a
String draft) and lowers the toolbar (44×32) and topbar (40 px) so the
rails feel tighter overall.
Stop-hook fix: codex flagged Rust files as not rustfmt-clean.
Run cargo fmt --all across openpencil-shell-{core,native,web}
+ wasm-libc-shim. 67 lib tests still pass, native + web cargo
check clean.
Stop-hook fix: 'caret reset can use a stale clock'. set_now_ms was
only called inside RedrawRequested, so apply_text / apply_backspace /
apply_press routed mid-frame stamped caret_anchor_ms with the
previous frame's now_ms. The result: caret reset visually appeared
delayed by up to one redraw interval (rare but inconsistent).
Refresh self.clock_start.elapsed() at the top of every WindowEvent
so any apply_* called inside the match arm sees the current
timestamp. Drop the redundant inside-RedrawRequested refresh.
Sinks the blink phase logic into vendor/jian (jian-core::anim) so any
host can wire the same square-wave timing instead of reimplementing
per-product. Both OpenPencil chrome and Zode TUI consume the same
helpers.
- vendor/jian bumped to head with new `jian_core::anim` module
(blink_visible / next_blink_flip_ms, 9 unit tests)
- ChatState: `caret_anchor_ms` resets on focus / keystroke /
example fill so the caret reappears solid right after the user
acts, not mid-fade
- AIChatPlaceholder.now_ms threaded from host; paint computes
caret visibility = focused && jian_core::anim::blink_visible
- AIChatPlaceholder caret X uses RenderBackend::measure_text for
pixel-accurate trailing edge (replaces the 7px / 13px guess
per char that drifted on Roboto + Noto-CJK)
- WidgetHostNative.set_now_ms / chat_focused / next_animation_
deadline_ms surface; runner refreshes from a single Instant
anchor + sets ControlFlow::WaitUntil at the next blink flip
- inspector_window: new_events handles ResumeTimeReached → request
redraw so winit actually wakes for the next frame
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
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)
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.
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.
P0 dep-stack probe (Step 1a) cleared all three OS targets in CI
run 25358457742:
- macOS aarch64: full window+GL probe (cross-API state + readback) PASS
- Linux x86_64 (hosted runner): link-time PASS, runtime DEFERRED
(LINUX_GPU_DEFERRED_NO_RUNNER) — Xvfb GLX limitation; same skip as
bevy / rust-skia / iced CI.
- Windows x86_64 (hosted runner): link-time PASS, runtime DEFERRED
(WINDOWS_GPU_DEFERRED_NO_RUNNER per spec §8.2).
Pin versions captured in
`openpencil-docs/superpowers/notes/2026-05-05-skia-glow-loader-compat-probe.md`.
Reverts:
- transient `[dev-dependencies]` block in shell-native Cargo.toml
(skia-safe / glutin / glutin-winit / glow / raw-window-handle /
scopeguard / dev-only winit override).
- transient `tests/p0_probe.rs` + `examples/p0_probe.rs`.
- transient workflow steps that gated `--ignored P0_PROBE_GATE` and the
Xvfb / freetype / mesa apt installs that only the probe needed.
Kept:
- prod winit dep features `["x11", "wayland", "wayland-csd-adwaita",
"rwh_06"]` — needed for Linux to satisfy winit's
`compile_error!("...not supported by winit")` guard. Stage F may
trim this when RenderBackend lands.
- workflow's libxkbcommon / libwayland apt install — winit's link-time
deps for the features above.
- `.gitattributes` — enforces `eol=lf` so future cross-OS rustfmt stays
green.
Task 1 will reintroduce skia-safe / glutin / glow / raw-window-handle
/ scopeguard as permanent prod deps when SharedSkiaContext +
RenderBackend land.
Drives the three-OS CI matrix verification of the skia-safe + glutin +
glow + winit dep stack per Step 1a spec §7.
- examples/p0_probe.rs: stencil_visibility + readback chain runner (must
own a real OS main thread because winit on macOS rejects
EventLoop::new() from cargo test worker threads).
- tests/p0_probe.rs: subprocess-invoke wrapper, gated
#[ignore = "P0_PROBE_GATE"] so default cargo test stays untouched.
- Cargo.toml: add transient [target.'cfg(not(target_arch = "wasm32"))'.
dev-dependencies] block (skia-safe 0.97 + glutin 0.32.3 + glutin-winit
0.5.0 + glow 0.17.0 + raw-window-handle 0.6.2 + scopeguard 1.2.0 +
winit defaults). Pinned to versions resolved in /tmp/skia-glow-probe.
- .github/workflows/rust-check.yml: install Linux GL prereqs (xvfb,
mesa, libxkbcommon, libwayland) and add a P0-probe-gate step running
cargo test --ignored on each OS (Linux through xvfb-run; Windows
early-returns per spec §8.2 WINDOWS_GPU_DEFERRED_NO_RUNNER).
All three artefacts are TRANSIENT — reverted in a follow-up cleanup
commit after CI is green and the loader-compat notes commit lands.
Task 1 owns the permanent integration.
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.
P0 dep-stack probe (Step 1a) cleared all three OS targets in CI
run 25358457742:
- macOS aarch64: full window+GL probe (cross-API state + readback) PASS
- Linux x86_64 (hosted runner): link-time PASS, runtime DEFERRED
(LINUX_GPU_DEFERRED_NO_RUNNER) — Xvfb GLX limitation; same skip as
bevy / rust-skia / iced CI.
- Windows x86_64 (hosted runner): link-time PASS, runtime DEFERRED
(WINDOWS_GPU_DEFERRED_NO_RUNNER per spec §8.2).
Pin versions captured in
`openpencil-docs/superpowers/notes/2026-05-05-skia-glow-loader-compat-probe.md`.
Reverts:
- transient `[dev-dependencies]` block in shell-native Cargo.toml
(skia-safe / glutin / glutin-winit / glow / raw-window-handle /
scopeguard / dev-only winit override).
- transient `tests/p0_probe.rs` + `examples/p0_probe.rs`.
- transient workflow steps that gated `--ignored P0_PROBE_GATE` and the
Xvfb / freetype / mesa apt installs that only the probe needed.
Kept:
- prod winit dep features `["x11", "wayland", "wayland-csd-adwaita",
"rwh_06"]` — needed for Linux to satisfy winit's
`compile_error!("...not supported by winit")` guard. Stage F may
trim this when RenderBackend lands.
- workflow's libxkbcommon / libwayland apt install — winit's link-time
deps for the features above.
- `.gitattributes` — enforces `eol=lf` so future cross-OS rustfmt stays
green.
Task 1 will reintroduce skia-safe / glutin / glow / raw-window-handle
/ scopeguard as permanent prod deps when SharedSkiaContext +
RenderBackend land.
Drives the three-OS CI matrix verification of the skia-safe + glutin +
glow + winit dep stack per Step 1a spec §7.
- examples/p0_probe.rs: stencil_visibility + readback chain runner (must
own a real OS main thread because winit on macOS rejects
EventLoop::new() from cargo test worker threads).
- tests/p0_probe.rs: subprocess-invoke wrapper, gated
#[ignore = "P0_PROBE_GATE"] so default cargo test stays untouched.
- Cargo.toml: add transient [target.'cfg(not(target_arch = "wasm32"))'.
dev-dependencies] block (skia-safe 0.97 + glutin 0.32.3 + glutin-winit
0.5.0 + glow 0.17.0 + raw-window-handle 0.6.2 + scopeguard 1.2.0 +
winit defaults). Pinned to versions resolved in /tmp/skia-glow-probe.
- .github/workflows/rust-check.yml: install Linux GL prereqs (xvfb,
mesa, libxkbcommon, libwayland) and add a P0-probe-gate step running
cargo test --ignored on each OS (Linux through xvfb-run; Windows
early-returns per spec §8.2 WINDOWS_GPU_DEFERRED_NO_RUNNER).
All three artefacts are TRANSIENT — reverted in a follow-up cleanup
commit after CI is green and the loader-compat notes commit lands.
Task 1 owns the permanent integration.