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.
This commit is contained in:
Kayshen-X 2026-05-05 12:23:27 +08:00
parent 786d10a1c6
commit e66e8aefcc
7 changed files with 494 additions and 0 deletions

View file

@ -61,6 +61,16 @@ jobs:
if: runner.os != 'Linux'
run: cargo test --workspace
- run: cargo clippy --workspace --all-targets -- -D warnings
# Step 1a Phase C Task 4: spec v19 §11 + §12.3 boundary invariants.
# Linux runner has the full mobile target stdlib (`rustup target add`
# in subsequent steps would handle ios/android cargo metadata too,
# but the script uses `cargo tree --target` which only needs the
# cfg-gate evaluation, not the actual target sysroot).
- name: Verify Jian boundary invariants
if: runner.os == 'Linux'
run: |
rustup target add aarch64-linux-android aarch64-apple-ios wasm32-unknown-unknown
bash tools/check-jian-boundaries.sh
deny:
name: cargo-deny (native)

View file

@ -502,6 +502,7 @@ Contributions are welcome! See [CLAUDE.md](./CLAUDE.md) for architecture details
- [x] Native agent runtime (`agent-native` — Zig NAPI)
- [x] Git integration — clone, branch, push/pull, folder-mode three-way merge
- [x] Canvas raster export (PNG / JPEG / WEBP / PDF)
- [x] Rust shell — Step 1a (G1 shared Skia context) on `v0.8.0`: `SharedSkiaContext` + `NativeBackend` (Jian-`DrawOp`-backed) + `JianPointerMapper` (Jian `PointerEvent``ShellEvent`) + `basic_window` demo, with the multi-platform CI matrix green (macOS / Linux / Windows desktop, iOS / Android cargo check, wasm32). Spec `v19.3` FROZEN; `vendor/jian` pinned at `c4a794dc`.
- [ ] Collaborative editing
- [ ] Plugin system

View file

@ -0,0 +1,240 @@
//! Spec v19 §1.2 acceptance #1 — basic_window demo.
//!
//! Phase C Task 4 deliverable: a minimal winit + `SharedSkiaContext` +
//! `NativeBackend` + `JianPointerMapper` integration that paints chrome
//! (rect / text / box outline) on every frame and translates pointer
//! events through the Phase B Task 3 mapper. The macOS / Linux / Windows
//! runtime is exercised by maintainers manually (`notes/step-1a-{macos,
//! linux, windows}-manual-smoke.md`); CI only verifies that
//! `cargo build --examples --workspace` compiles on every desktop OS.
//!
//! Run with:
//! ```text
//! cargo run -p openpencil-shell-native --example basic_window
//! ```
//!
//! The window should display:
//! - White background (chrome canvas clear).
//! - Red filled rect at (50, 50) - 100x100.
//! - "Hello 你好" black text at (50, 200).
//! - Blue stroked rect outline at (200, 50) - 200x150.
//!
//! Closing the window must run `SharedSkiaContext::teardown` exactly
//! once (the `Drop` impl is the safety net) and exit cleanly.
#![cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
use jian_core::scene::Color as JianColor;
use jian_host_desktop::pointer::PointerTranslator;
use openpencil_shell_core::{Color, Point2D, Rect, TextLayout};
use openpencil_shell_native::{
JianPointerMapper, NativeBackend, SharedSkiaContext, SharedSkiaError,
};
use winit::application::ApplicationHandler;
use winit::event::WindowEvent;
use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::window::{Window, WindowId};
/// Per-frame chrome paint. Pulled into a free function so both the
/// initial `Resumed` paint and `RedrawRequested` redraws share the
/// exact same draw list (spec §5.2.1 frame-scoped backend pattern).
fn paint_chrome(ctx: &mut SharedSkiaContext, backend: &mut NativeBackend) {
let rect_fill = Rect {
origin: Point2D::new(50.0, 50.0),
size: Point2D::new(100.0, 100.0),
};
let rect_outline = Rect {
origin: Point2D::new(200.0, 50.0),
size: Point2D::new(200.0, 150.0),
};
let text = TextLayout::single_run(
"Hello 你好",
"",
24.0,
JianColor::rgb(0, 0, 0),
Point2D::new(50.0, 200.0),
);
ctx.begin_frame();
ctx.with_frame(|canvas, _glow| {
// White background — clear the framebuffer through Skia.
canvas.clear(skia_safe::Color::WHITE);
// 1. Filled red rectangle (acceptance #1 chrome rect).
backend.fill_rect(canvas, rect_fill, Color::RED);
// 2. Black "Hello 你好" — exercises CJK path through jian-skia
// `textlayout` ParagraphBuilder.
backend.draw_text(canvas, &text, Point2D::ZERO);
// 3. Blue stroked box (acceptance #1 chrome outline).
backend.stroke_rect(canvas, rect_outline, Color::BLUE, 2.0);
});
ctx.present();
}
struct BasicWindowApp {
window: Option<Window>,
ctx: Option<SharedSkiaContext>,
backend: Option<NativeBackend>,
/// Phase B Task 3 wiring: winit `WindowEvent` → Jian `PointerEvent`
/// → `JianPointerMapper` → `ShellEvent`. We only translate pointer
/// events here; window/resize/close events go straight to the
/// match arms below.
pointer_translator: PointerTranslator,
pointer_mapper: JianPointerMapper,
/// Fatal teardown / surface error captured for post-loop diagnosis.
error: Option<SharedSkiaError>,
}
impl BasicWindowApp {
fn new() -> Self {
Self {
window: None,
ctx: None,
backend: None,
pointer_translator: PointerTranslator::new(),
pointer_mapper: JianPointerMapper::new(),
error: None,
}
}
}
impl ApplicationHandler for BasicWindowApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() {
return;
}
let attrs = Window::default_attributes()
.with_title("OpenPencil — basic_window (Step 1a §1.2 acceptance #1)")
.with_inner_size(winit::dpi::LogicalSize::new(800u32, 600u32));
let window = match event_loop.create_window(attrs) {
Ok(w) => w,
Err(err) => {
eprintln!("basic_window: create_window failed: {err}");
event_loop.exit();
return;
}
};
// Build the GL stack + Skia context bound to the new window.
let dpi = window.scale_factor() as f32;
match SharedSkiaContext::new_desktop(&window) {
Ok(ctx) => {
self.ctx = Some(ctx);
self.backend = Some(NativeBackend::with_dpi(dpi));
}
Err(err) => {
eprintln!("basic_window: SharedSkiaContext::new_desktop failed: {err}");
self.error = Some(err);
event_loop.exit();
return;
}
}
self.window = Some(window);
// First paint so the window has visible content even before
// the OS schedules a `RedrawRequested`. Subsequent redraws come
// through the event match below.
if let (Some(ctx), Some(backend)) = (self.ctx.as_mut(), self.backend.as_mut()) {
paint_chrome(ctx, backend);
}
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_window_id: WindowId,
event: WindowEvent,
) {
// Phase B Task 3 wiring: route pointer-flavoured winit events
// through Jian's PointerTranslator + our JianPointerMapper.
// Non-pointer events (Resized / RedrawRequested / CloseRequested)
// bypass the Jian path per spec §5.1.1.
match &event {
WindowEvent::ModifiersChanged(m) => {
self.pointer_translator.update_modifiers(m.state());
}
WindowEvent::CursorMoved { .. }
| WindowEvent::CursorLeft { .. }
| WindowEvent::MouseInput { .. }
| WindowEvent::Touch(_) => {
if let Some(jian_event) = self.pointer_translator.translate(&event) {
let shell_events = self.pointer_mapper.from_jian_pointer(&jian_event);
// The demo doesn't act on pointer events — it just
// proves the pipeline compiles + runs without
// panicking. Real widget dispatch lands in Step 1c+.
let _ = shell_events;
}
}
_ => {}
}
match event {
WindowEvent::CloseRequested => {
event_loop.exit();
}
WindowEvent::Resized(size) => {
if let Some(ctx) = self.ctx.as_mut() {
if let Err(err) = ctx.resize(size.width, size.height) {
eprintln!("basic_window: resize failed: {err}");
self.error = Some(err);
event_loop.exit();
}
}
if let Some(window) = self.window.as_ref() {
window.request_redraw();
}
}
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
if let Some(backend) = self.backend.as_mut() {
backend.set_dpi(scale_factor as f32);
}
}
WindowEvent::RedrawRequested => {
if let (Some(ctx), Some(backend)) = (self.ctx.as_mut(), self.backend.as_mut()) {
paint_chrome(ctx, backend);
}
}
_ => {}
}
}
fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
// Idempotent teardown — the `Drop` impl on `SharedSkiaContext`
// is the safety net, but explicit teardown gives the demo a
// visible "no leaks" signal at exit (acceptance #6).
if let Some(mut ctx) = self.ctx.take() {
if let Err(err) = ctx.teardown() {
eprintln!("basic_window: teardown failed: {err}");
}
}
self.backend.take();
self.window.take();
}
}
fn main() {
// Per-frame `#[instrument]` spans are emitted but no subscriber is
// initialised by default — the demo deliberately stays free of
// dev-only crates. Add `tracing_subscriber::fmt::try_init()` here
// (and drop it as a dev-dep) if you want to inspect spans.
let event_loop = match EventLoop::new() {
Ok(el) => el,
Err(err) => {
eprintln!("basic_window: EventLoop::new failed: {err}");
std::process::exit(1);
}
};
event_loop.set_control_flow(winit::event_loop::ControlFlow::Wait);
let mut app = BasicWindowApp::new();
if let Err(err) = event_loop.run_app(&mut app) {
eprintln!("basic_window: run_app exited with error: {err}");
std::process::exit(1);
}
if let Some(err) = app.error {
eprintln!("basic_window: fatal error during run: {err}");
std::process::exit(1);
}
}

View file

@ -0,0 +1,48 @@
# Step 1a Linux manual GPU smoke (per spec v19 §1.2 acceptance #1)
**Status**: PENDING — to be filled by a maintainer running on a Linux
desktop with a real or Mesa software-rendered GL driver.
The CI Linux runner ships Mesa llvmpipe via Xvfb but `gpu_smoke` /
`gpu_chrome_stub_composition` are currently `#[ignore]`'d under
`LINUX_GPU_SKIA_LOADER_TBD` (spec §3.1 mini-patch — `skia-safe`'s
`Interface::new_native` cannot resolve GL syms from EGL pbuffer +
llvmpipe; the proper fix is `new_load_with(eglGetProcAddress)` and is
deferred to Step 1f). The spec acceptance #1 on Linux therefore needs
a separate human runtime check.
## Prerequisites
- Ubuntu 22.04+ / Fedora 40+ / Arch with `mesa` / `libglvnd` /
`xkbcommon` / `wayland-client` / `freetype` / `fontconfig` installed
(the same set the CI `Install Linux GL prereqs` step provisions).
- X11 or Wayland session.
- Rust toolchain 1.85.
- Submodules at `vendor/jian@c4a794dc`.
## Required commands
```bash
cargo run -p openpencil-shell-native --example basic_window
# After Step 1f spec §3.1 mini-patch lands, also:
# cargo test -p openpencil-shell-native --test gpu_smoke -- --include-ignored gpu_smoke
# cargo test -p openpencil-shell-native --test gpu_chrome_stub_composition -- --include-ignored gpu_chrome_stub_composition
```
## Expected outcomes
- `basic_window` opens an 800x600 window showing:
- White background.
- Red filled rect at `(50, 50) — 100x100`.
- Black `Hello 你好` text at `(50, 200)`.
- Blue stroked rect outline at `(200, 50) — 200x150`.
- Closing the window exits cleanly.
- Running under Xvfb + Mesa llvmpipe is acceptable for "no real GPU"
hosts; the chrome must still render correctly.
## Where to record results
Append the run date, distro/version, GL renderer (`glxinfo | grep
OpenGL.renderer`), and output excerpt to this file (replacing the
`Status: PENDING` line) and commit on `v0.8.0` with
`docs(shell-native): record Linux manual GPU smoke`.

View file

@ -0,0 +1,42 @@
# Step 1a macOS manual GPU smoke (per spec v19 §1.2 acceptance #1)
**Status**: PASS — exercised on Apple Silicon during Phase C Task 4
implementation (2026-05-05).
The macOS path through `gpu_smoke` / `gpu_chrome_stub_composition`
already runs in CI on `macos-latest` (`SharedSkiaContext::new_desktop`
- raster + chrome+stub composition). This note captures the
maintainer-run `cargo run --example basic_window` smoke in addition
to the automated checks (spec v19 §1.2 acceptance #1).
## Run on Apple Silicon (macos-latest, M-series)
```bash
cargo run -p openpencil-shell-native --example basic_window
```
### Expected window contents
- 800x600 window titled `OpenPencil — basic_window (Step 1a §1.2 acceptance #1)`.
- White background.
- Red filled rect at `(50, 50) — 100x100` (chrome).
- Black `Hello 你好` text at `(50, 200)` (chrome via Jian skia
textlayout).
- Blue stroked rect outline at `(200, 50) — 200x150`.
- Closing the window via Cmd-W / red button → process exits with
status 0 (no panic, no driver complaint).
### Verified
- Build: `cargo build --examples --workspace` succeeds clean.
- Process: launches without stderr output, holds the window until
closed, no hangs on teardown.
- `SharedSkiaContext::teardown` runs once via `exiting()` and is a
no-op on `Drop`.
## When to update this file
- After upgrading `vendor/jian` / `glutin` / `winit` / `skia-safe`.
- After reworking `paint_chrome` to call new `NativeBackend` methods.
- After macOS releases that change EAGL / Metal-translated bridging.

View file

@ -0,0 +1,47 @@
# Step 1a Windows manual GPU smoke (per spec v19 §8.1)
**Status**: PENDING — to be filled by a maintainer running on a Windows
desktop with a real GL driver.
The standard GitHub Actions `windows-latest` runner has no GPU driver
(`WINDOWS_GPU_DEFERRED_NO_RUNNER`), so spec v19 §8.1 requires the
following sequence to be exercised by a human on real hardware before
Step 1a can be declared "live on Windows".
## Prerequisites
- Windows 10/11 (x86_64 or aarch64) with a working OpenGL 3.3+ driver
(default factory drivers on most modern GPUs satisfy this).
- Rust toolchain 1.85 (via `rustup toolchain install 1.85`).
- Submodules checked out (`git submodule update --init --recursive`) so
`vendor/jian` is at `c4a794dc` (Step 1a freeze).
## Required commands
```pwsh
cargo run -p openpencil-shell-native --example basic_window
cargo test -p openpencil-shell-native --test gpu_smoke -- --include-ignored gpu_smoke
cargo test -p openpencil-shell-native --test gpu_chrome_stub_composition -- --include-ignored gpu_chrome_stub_composition
```
## Expected outcomes
- `basic_window` opens an 800x600 window showing:
- White background.
- Red filled rect at `(50, 50) — 100x100` (chrome).
- Black `Hello 你好` text at `(50, 200)` (chrome via Jian skia
textlayout).
- Blue stroked rect outline at `(200, 50) — 200x150`.
- Closing the window exits cleanly (no panic, no driver complaint
in the console).
- `gpu_smoke` (`#[ignore]`'d on Windows by `WINDOWS_GPU_DEFERRED_NO_RUNNER`)
passes when run with `--include-ignored` on a real-GPU host.
- `gpu_chrome_stub_composition` likewise passes — chrome pixel reads
back red even after `CanvasViewportStub::render_into` pollutes
`STENCIL_TEST` and `BlendFunc(ONE, ZERO)`.
## Where to record results
Append the run date, Windows build, GPU/driver, and output excerpt to
this file (replacing the `Status: PENDING` line) and commit on
`v0.8.0` with `docs(shell-native): record Windows manual GPU smoke`.

106
tools/check-jian-boundaries.sh Executable file
View file

@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Step 1a Phase C Task 4 / spec v19 §11 + §12.3 boundary invariants.
#
# Verifies the following Jian crate boundary invariants from outside the
# Rust build system. Run from the repo root.
#
# Invariant 1 (§12.3): openpencil-app must NOT depend directly on any
# `jian-*` crate — Jian is a shell-native implementation detail; the
# app only sees OP's `RenderBackend` / `ShellEvent` facade.
#
# Invariant 2 (§11.1, §12.3): mobile targets (`aarch64-linux-android`,
# `aarch64-apple-ios`) must NOT pull `jian-host-desktop` or `jian-skia`
# into the dependency closure — those carry the desktop GL stack +
# `skia-safe` build.rs that fails on cross-compile.
#
# Invariant 3 (§11.1, §1.2): wasm32 builds of `openpencil-shell-web`
# must NOT pull `jian-host-desktop` or `jian-skia` (skia-safe build.rs
# fails on wasm32; Jian-core is wasm32-clean per P0.5 and is the only
# Jian crate allowed in the bundle).
#
# Invariant 4 (§1.2): `openpencil-shell-web` must NOT depend on
# `jian-host-desktop` at all — even as a non-default optional dep.
#
# Exit codes:
# 0 — all invariants pass.
# 1+ — one or more invariants fail; the failing crate names are
# echoed before the script exits.
#
# Dependencies: `cargo`, `jq` (for cargo metadata JSON parsing).
set -euo pipefail
if ! command -v jq >/dev/null 2>&1; then
echo "check-jian-boundaries.sh: \`jq\` is required but not installed." >&2
echo " apt: sudo apt-get install -y jq" >&2
echo " brew: brew install jq" >&2
exit 2
fi
# ── Invariant 1: openpencil-app has no direct jian-* dependency. ──────
# `cargo metadata` returns a workspace-wide resolve graph; we filter
# the `resolve.nodes[]` entry whose name matches `openpencil-app` and
# inspect its direct `deps[]`. A direct dep on any `jian-*` crate
# fails the invariant.
metadata_full="$(cargo metadata --format-version 1)"
forbidden_app="$(echo "$metadata_full" | jq -r '
[.packages[] | select(.name == "openpencil-app") | .id] as $app_ids
| .resolve.nodes[]
| select(.id as $id | $app_ids | index($id))
| .deps[].name
' | grep -E '^jian-' || true)"
if [ -n "$forbidden_app" ]; then
echo "INVARIANT 1 FAILED: openpencil-app directly depends on jian-* crate(s):" >&2
echo "$forbidden_app" >&2
exit 1
fi
# ── Invariant 2: mobile targets don't pull jian-host-desktop / jian-skia. ──
# We use `cargo tree` (which honours `--target` cfg-gates) and inspect
# the dependency closure of `openpencil-shell-native` — only the deps
# that actually compile under the mobile target are listed.
for target in aarch64-linux-android aarch64-apple-ios; do
tree_mobile="$(cargo tree -p openpencil-shell-native \
--target "$target" \
--prefix none \
--edges normal,build 2>/dev/null || true)"
forbidden_mobile="$(echo "$tree_mobile" \
| grep -oE '\bjian-(host-desktop|skia)\b' \
| sort -u || true)"
if [ -n "$forbidden_mobile" ]; then
echo "INVARIANT 2 FAILED ($target): forbidden Jian crates in closure:" >&2
echo "$forbidden_mobile" >&2
exit 1
fi
done
# ── Invariant 3: wasm32 has no jian-host-desktop / jian-skia. ──────────
# `jian-core` IS allowed (P0.5 wasm32-clean).
tree_wasm="$(cargo tree -p openpencil-shell-web \
--target wasm32-unknown-unknown \
--prefix none \
--edges normal,build 2>/dev/null || true)"
forbidden_wasm="$(echo "$tree_wasm" \
| grep -oE '\bjian-(host-desktop|skia)\b' \
| sort -u || true)"
if [ -n "$forbidden_wasm" ]; then
echo "INVARIANT 3 FAILED: wasm32 openpencil-shell-web pulls forbidden Jian crates:" >&2
echo "$forbidden_wasm" >&2
exit 1
fi
# ── Invariant 4: openpencil-shell-web has no jian-host-desktop dep. ───
# Distinct from invariant 3 (which checks the resolved closure on the
# wasm32 target): this checks the manifest itself across all targets.
# `cargo tree --all-targets` would include dev-deps; we explicitly
# filter `--edges normal,build` for the manifest-level invariant.
shell_web_deps="$(cargo tree -p openpencil-shell-web \
--prefix none \
--edges normal,build 2>/dev/null \
| grep -E '\bjian-host-desktop\b' || true)"
if [ -n "$shell_web_deps" ]; then
echo "INVARIANT 4 FAILED: openpencil-shell-web depends on jian-host-desktop:" >&2
echo "$shell_web_deps" >&2
exit 1
fi
echo "check-jian-boundaries.sh: all 4 Jian boundary invariants pass."