feat(shell-web): Phase B3 — wire WidgetHost into mount path

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.
This commit is contained in:
Kayshen-X 2026-05-09 21:47:00 +08:00
parent f214f4b4e3
commit d8d6e127cd
2 changed files with 97 additions and 22 deletions

View file

@ -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<WebShell, JsValue> {
.map_err(|_| JsValue::from_str("mount: target element is not <canvas>"))?;
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)
}

View file

@ -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()
}
}