From d8d6e127cd41e93df1c96d15252569a8cb601f69 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sat, 9 May 2026 21:47:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(shell-web):=20Phase=20B3=20=E2=80=94=20wir?= =?UTF-8?q?e=20WidgetHost=20into=20mount=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Phase A red-rect demo with the Step 1b inspector composition: WebShell now owns a `WidgetHost` and `mount()` paints the four shell-core widgets (Tree / PropertyRow / Dropdown / TextInput) into a 280-px column on a white-cleared canvas. What's added: - `crates/openpencil-shell-web/src/widget_host.rs` — the only file in shell-web that calls into `openpencil_shell_core::widgets::*`, per spec §1.4 boundary. Module doc anchors the invariant; the paint signature carries the `// glue:` marker that the Phase B4 boundary check script (`tools/check-widget-boundary.sh`) will grep for. - `WidgetHost { tree, width, dropdown, text_input }` owns one of each kind; `WidgetHost::new()` populates them from the B2 sample / new constructors. `Default` forwards to `new()`. - `WidgetHost::paint(&self, backend, available_width)` builds a `LayoutCx`, iterates a `[&dyn Widget; 4]` array, places each at x=16 with 12-px vertical gaps. Reborrows backend each iteration (`&mut *backend`) so subsequent iterations don't fail borrow-check on the moved `&mut WebBackend`. shell-web/src/lib.rs: - Adds `mod widget_host;` cfg-gated to the `skia` feature. - WebShell gains `host: WidgetHost`. - Renames `paint_phase_a` → `paint_inspector`. Body clears the canvas to white, dispatches via `self.host.paint(...)`, then surfaces `take_present_error()` as JsValue exception. Same panic-safe + canvas-type-check + present-error-propagation pattern as Phase A C-hard.2 (codex Phase A gate review approved). - The stub mount entry (no skia feature) is unchanged — the kickoff §1.2 wasm32-clean compile guard CI still uses it. Plan-vs-implementation deviations (deliberate): - Plan B3 step 2 simplifies `mount()` in a way that drops the panic hook + canvas-type-check + present-error propagation. Preserved all three because the codex Phase A gate review explicitly approved them as "panic-safe mount". Plan body's `mount` block is treated as historical sketch. - Plan body's `let mut cx = PaintCx { backend };` would move the reference and fail borrow-check on the second iteration. Changed to explicit reborrow `&mut *backend`. This is the plan's intent, just with the borrow-checker subtlety made explicit. Verification: - `cargo build -p openpencil-shell-web --target wasm32-unknown-unknown --features skia --release` — green - `cargo check -p openpencil-shell-web --target wasm32-unknown-unknown --no-default-features --features web` — green (compile guard) - `bash tools/check-wasm-bundle.sh` — PASS - 0 env.* imports (bundle still LinkError-free) - 615 764 bytes gzip = 58% of 1 MiB ceiling - +2 KiB vs Phase A C-hard.2 (~613 KiB) — widget code is small - `cargo check -p openpencil-shell-native` — green (no regression) Codex iterate review: 1 round → GO with 2 NITs (script name singular vs plural, stale "Phase A red-rect" phrase) — both fixed in this commit. --- crates/openpencil-shell-web/src/lib.rs | 42 +++++----- .../openpencil-shell-web/src/widget_host.rs | 77 +++++++++++++++++++ 2 files changed, 97 insertions(+), 22 deletions(-) create mode 100644 crates/openpencil-shell-web/src/widget_host.rs diff --git a/crates/openpencil-shell-web/src/lib.rs b/crates/openpencil-shell-web/src/lib.rs index a9293c261..a5b3b1d1e 100644 --- a/crates/openpencil-shell-web/src/lib.rs +++ b/crates/openpencil-shell-web/src/lib.rs @@ -16,6 +16,8 @@ #[cfg(feature = "skia")] mod backend; +#[cfg(feature = "skia")] +mod widget_host; // Force the wasm32-unknown-unknown libc/libcxx/libm shim to be linked // even though no Rust code calls it — its `#[no_mangle]` symbols are @@ -38,21 +40,22 @@ use wasm_bindgen::prelude::*; pub struct WebShell { #[cfg(feature = "skia")] backend: backend::WebBackend, + #[cfg(feature = "skia")] + host: widget_host::WidgetHost, } #[cfg(feature = "skia")] impl WebShell { - /// Phase A red-rect demo: clear to white, draw a centered red rect, - /// snapshot to the host canvas. Returns the present error if the - /// final ImageData round-trip failed — callers MUST propagate this - /// to JS instead of treating mount as successful when the canvas - /// stayed blank. - /// - /// Phase B+ replaces this with the widget host paint loop. - fn paint_phase_a(&mut self) -> Result<(), JsValue> { + /// Phase B paint pass: clear the canvas to white, then dispatch to + /// the four shell-core inspector widgets via `WidgetHost`. Returns + /// the present error if the final ImageData round-trip failed — + /// callers MUST propagate this to JS instead of treating mount as + /// successful when the canvas stayed blank. + fn paint_inspector(&mut self) -> Result<(), JsValue> { use openpencil_shell_core::{Color, Point2D, Rect, RenderBackend}; + self.backend.begin_frame(); - // Clear background. + // Clear to white so widget paints sit on a clean background. self.backend.fill_rect( Rect { origin: Point2D::new(0.0, 0.0), @@ -60,14 +63,8 @@ impl WebShell { }, Color::WHITE, ); - // Centered red rect: 320×120 inside the 960×640 canvas. - self.backend.fill_rect( - Rect { - origin: Point2D::new(320.0, 260.0), - size: Point2D::new(320.0, 120.0), - }, - Color::RED, - ); + // Inspector slice: 280 px wide column on the left half. + self.host.paint(&mut self.backend, 280.0); self.backend.end_frame(); if let Some(err) = self.backend.take_present_error() { return Err(err); @@ -107,11 +104,12 @@ pub fn mount(canvas_id: &str) -> Result { .map_err(|_| JsValue::from_str("mount: target element is not "))?; let backend = backend::WebBackend::new(canvas)?; - let mut shell = WebShell { backend }; - // Phase A demo paints synchronously inside mount(); any present error - // (read_pixels / put_image_data) MUST surface as a JS exception so - // callers do not see Ok with an unpainted canvas. - shell.paint_phase_a()?; + let host = widget_host::WidgetHost::new(); + let mut shell = WebShell { backend, host }; + // Phase B inspector paints synchronously inside mount(); any present + // error (read_pixels / put_image_data) MUST surface as a JS exception + // so callers do not see Ok with an unpainted canvas. + shell.paint_inspector()?; Ok(shell) } diff --git a/crates/openpencil-shell-web/src/widget_host.rs b/crates/openpencil-shell-web/src/widget_host.rs new file mode 100644 index 000000000..a23c5c4df --- /dev/null +++ b/crates/openpencil-shell-web/src/widget_host.rs @@ -0,0 +1,77 @@ +//! Step 1b §1.4 widget glue — the only file in shell-web allowed to call +//! into `openpencil_shell_core::widgets`. All widget logic (state, paint, +//! layout, accesskit) lives in shell-core; this host is a thin paint-loop +//! adapter that takes a `&mut WebBackend` and dispatches to the four +//! inspector widgets. +//! +//! Any function that pulls in `openpencil_shell_core::widgets::*` MUST +//! live in this file (per spec §1.4). The B4 boundary check script +//! greps for the `// glue:` marker on the paint signature so the +//! invariant survives drift. + +use crate::backend::WebBackend; +use openpencil_shell_core::widgets::{ + Dropdown, LayoutCx, PaintCx, PropertyRow, TextInput, TreeWidget, Widget, +}; +use openpencil_shell_core::{Point2D, Rect, RenderBackend}; + +/// The Step 1b inspector composition. Owns one of each widget kind so +/// the first-frame Phase B mount paints something meaningful; Phase C +/// event handling will mutate the dropdown / text-input state in place. +pub struct WidgetHost { + tree: TreeWidget, + width: PropertyRow, + dropdown: Dropdown, + text_input: TextInput, +} + +impl WidgetHost { + pub fn new() -> Self { + Self { + tree: TreeWidget::sample(), + width: PropertyRow::new(200, "Width", "960"), + dropdown: Dropdown::sample(), + text_input: TextInput::sample(), + } + } + + /// Dispatches paint to the shell-core widgets stacked vertically with + /// 12 px gaps. The `// glue:` marker on the same line as the + /// signature is what `tools/check-widget-boundary.sh` (Phase B4) + /// greps for to confirm widget calls happen here and nowhere else + /// in shell-web. + pub fn paint(&self, backend: &mut WebBackend, available_width: f32) { // glue: + let layout = LayoutCx { + available_width, + dpi: backend.dpi_scale(), + }; + let mut y = 16.0; + let widgets: [&dyn Widget; 4] = [ + &self.tree, + &self.width, + &self.dropdown, + &self.text_input, + ]; + for widget in widgets { + let box_ = widget.layout(&layout); + let rect = Rect { + origin: Point2D::new(16.0, y), + size: Point2D::new(available_width, box_.rect.size.y), + }; + // Reborrow on every iteration so the `&mut WebBackend` is not + // moved into the first PaintCx — without `&mut *backend` the + // second iteration would fail borrow check. + let mut cx = PaintCx { + backend: &mut *backend, + }; + widget.paint(&mut cx, rect); + y += rect.size.y + 12.0; + } + } +} + +impl Default for WidgetHost { + fn default() -> Self { + Self::new() + } +}