diff --git a/.github/workflows/rust-check.yml b/.github/workflows/rust-check.yml index 55d56d61a..8289ee059 100644 --- a/.github/workflows/rust-check.yml +++ b/.github/workflows/rust-check.yml @@ -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) diff --git a/README.md b/README.md index 4d14e946b..95e9d650b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/openpencil-shell-native/examples/basic_window.rs b/crates/openpencil-shell-native/examples/basic_window.rs new file mode 100644 index 000000000..519647b6c --- /dev/null +++ b/crates/openpencil-shell-native/examples/basic_window.rs @@ -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, + ctx: Option, + backend: Option, + /// 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, +} + +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); + } +} diff --git a/crates/openpencil-shell-native/notes/step-1a-linux-manual-smoke.md b/crates/openpencil-shell-native/notes/step-1a-linux-manual-smoke.md new file mode 100644 index 000000000..39d85b363 --- /dev/null +++ b/crates/openpencil-shell-native/notes/step-1a-linux-manual-smoke.md @@ -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`. diff --git a/crates/openpencil-shell-native/notes/step-1a-macos-manual-smoke.md b/crates/openpencil-shell-native/notes/step-1a-macos-manual-smoke.md new file mode 100644 index 000000000..7f4d94ea2 --- /dev/null +++ b/crates/openpencil-shell-native/notes/step-1a-macos-manual-smoke.md @@ -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. diff --git a/crates/openpencil-shell-native/notes/step-1a-windows-manual-smoke.md b/crates/openpencil-shell-native/notes/step-1a-windows-manual-smoke.md new file mode 100644 index 000000000..a145b05be --- /dev/null +++ b/crates/openpencil-shell-native/notes/step-1a-windows-manual-smoke.md @@ -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`. diff --git a/tools/check-jian-boundaries.sh b/tools/check-jian-boundaries.sh new file mode 100755 index 000000000..6fbf88100 --- /dev/null +++ b/tools/check-jian-boundaries.sh @@ -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."