feat(shell-core): Phase B1 — Widget trait + recording test harness

Adds the widget facade that B2 inspector widgets and Phase C event
handling will plug into. Logic-bearing widget code lives in
shell-core (per spec §1.4); shell-native + shell-web only own their
RenderBackend impls + DOM event mapping + accesskit DOM mirror.

What's added:
- `widgets::Widget` trait with `id` / `layout` / `paint(&self,...)` /
  `access_node` methods. Phase B widgets are static — `paint` is
  `&self`, mutable per-widget state lives in `*State` structs that
  B2 lands. Phase C will extend the trait with a `&mut self` event
  method for input handling.
- `widgets::WidgetId(pub u64)` plus a `pub const ROOT_WIDGET_ID =
  WidgetId(0)` and a `WidgetId::new(id)` constructor with
  `debug_assert!(id != 0)`. The tuple constructor stays public so
  pattern matching + `const` contexts keep working; `::new` is the
  conventional path that surfaces the root-id reservation in debug
  builds. (Codex B1 R1 NIT-7 — make the convention compiler-visible
  before Phase C tree routing lands.)
- `widgets::PaintCx<'a> { backend: &'a mut dyn RenderBackend }` and
  `widgets::LayoutCx { available_width, dpi }` — frame-scoped paint
  context + layout-time context. The `&mut dyn` indirection lets
  shell-native + shell-web reuse the widget code without
  monomorphising over the concrete backend.
- `widgets::LayoutBox { rect: Rect }` with `Debug + Clone + Copy +
  PartialEq` derives.
- A `rect(x, y, w, h)` constructor convenience used by tests + B2.

Test harness (`tests/widgets_static.rs`):
- `RecordingBackend` impl `RenderBackend` counting each call.
- `paint_cx_dispatches_through_dyn_backend` — verifies fill_rect /
  stroke_rect / save / translate / clip_rect / restore all dispatch
  via `&mut dyn RenderBackend`.
- `widget_trait_dispatches_layout_and_paint` — minimal `StubWidget`
  proves the trait shape compiles; asserts layout result, paint
  dispatch count, `WidgetId::new(7)` round-trip, `ROOT_WIDGET_ID.0
  == 0`, and `access_node().role() == Role::GenericContainer`. Real
  semantic roles (TreeItem / EditableText / etc) land with B2.

Plumbing:
- `accesskit = "0.24"` added to shell-core deps to match shell-web's
  pin (the version compatible with shell-native's accesskit_winit
  Step 1a usage). Codex B1 R1 Q3 flagged that shell-native does not
  yet pull accesskit; this is acknowledged as a Phase C tracked item
  — verify the same version when DOM mirror / native a11y wires up.
- `Rect` now derives `PartialEq` so `LayoutBox` can use the same
  derive. `Eq` is intentionally NOT derived (Vec2 carries floats);
  comment in render_backend.rs explains.

Plan-vs-implementation deviations (deliberate, all kept narrow):
- Plan B1 step 2 declares `pub mod {dropdown, prop_row, text_input,
  tree};` + re-exports inside widgets/mod.rs. Omitted here because
  those modules don't exist until B2; declaring them now would
  break the B1 standalone build. Top-block plan mini-patch
  convention applies (override sketches in body).
- Plan didn't enumerate the accesskit dep + `Rect: PartialEq`
  deltas — added with rationale comments.

Verification:
- `cargo test -p openpencil-shell-core` — green
- `cargo check -p openpencil-shell-core --target
  wasm32-unknown-unknown` — green (shell-core stays wasm32-clean
  per spec §1.2)
- `cargo check -p openpencil-shell-native` — green (no regression)

Codex iterate review: 4 rounds → GO. Round 1 CONCERN (3 items),
Round 2 CONCERN (1 stale comment), Round 3 CONCERN (comment vs
test body mismatch), Round 4 GO clean. Q3 (accesskit_winit
alignment) carries to Phase C as informational.
This commit is contained in:
Kayshen-X 2026-05-09 21:45:00 +08:00
parent d20e1acf0f
commit b6efa324d1
6 changed files with 252 additions and 1 deletions

1
Cargo.lock generated
View file

@ -1426,6 +1426,7 @@ version = "0.1.0"
name = "openpencil-shell-core"
version = "0.1.0"
dependencies = [
"accesskit",
"bitflags 2.11.1",
"glam",
"jian-core",

View file

@ -18,6 +18,12 @@ tracing = { workspace = true }
# v19 pivot: glam Vec2 used as Point2D; bitflags used for widget facade internal flags.
glam = { version = "0.29", default-features = false, features = ["std"] }
bitflags = "2"
# Step 1b §3.6 widget facade: every Widget exposes an `accesskit::Node`
# representation so the host (native + shell-web DOM mirror) can build a
# unified accessibility tree. Pinned to 0.24 to match shell-web (the
# version compatible with the upstream accesskit_winit Step 1a uses on
# shell-native).
accesskit = "0.24"
# v19 pivot: re-export Jian render/geometry/scene types (spec §5.2 widget-facing facade
# wraps jian-core; native-only deps live in shell-native, jian-core itself is wasm32-clean).

View file

@ -18,6 +18,7 @@
pub mod jian;
pub mod render_backend;
pub mod widgets;
// Re-export the primary API for upstream crates / widgets / tests.
pub use render_backend::{Color, Point2D, Rect, RenderBackend, TextLayout};

View file

@ -26,7 +26,12 @@ use jian_core::render::{TextAlign, TextRun};
pub type Point2D = glam::Vec2;
/// Rectangle (origin + size as two Vec2s).
#[derive(Debug, Clone, Copy)]
///
/// Derives `PartialEq` so `widgets::LayoutBox` can compare layout
/// results in tests. `Eq` is intentionally NOT derived: `Vec2` carries
/// floats, and exact float equality is only meaningful when callers
/// have been careful about how the values were produced.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub origin: Point2D,
pub size: Point2D,

View file

@ -0,0 +1,101 @@
//! Widget facade for shell-core (Step 1b §1.4 — widget logic lives here so
//! both shell-native and shell-web reuse it; only RenderBackend / DOM event
//! mapping / accesskit DOM mirror are platform-owned).
//!
//! B1 (this commit): the bare trait + paint / layout contexts + WidgetId.
//! B2 lands the four static inspector widgets (Tree / PropertyRow /
//! Dropdown / TextInput) under this module's child modules; they are
//! declared in B2 — keep the surface here minimal until then so the crate
//! compiles standalone after B1.
use crate::{Point2D, Rect, RenderBackend};
/// Stable identifier assigned by the widget host. Used by accesskit
/// (`accesskit::NodeId(WidgetId.0)`), the DOM mirror, and event routing.
///
/// `WidgetId(0)` is reserved for the root host node — see
/// [`ROOT_WIDGET_ID`]. Use [`WidgetId::new`] to construct non-root ids
/// with a debug-time check; the tuple constructor stays public so
/// `const`-context callers (e.g. test fixtures) and pattern matches keep
/// working.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct WidgetId(pub u64);
/// The reserved root-host id. Phase C tree routing skips this id when
/// dispatching to widgets (the root is the implicit host frame). Made a
/// named constant so the convention is compiler-visible — see codex
/// Phase B1 review NIT-7.
pub const ROOT_WIDGET_ID: WidgetId = WidgetId(0);
impl WidgetId {
/// Constructs a non-root `WidgetId`. In debug builds, panics if the
/// caller tries to allocate id 0 (reserved for [`ROOT_WIDGET_ID`]);
/// in release the value is accepted as-is so production paths are
/// not punished for a host bug. Phase C tree routing should use this
/// constructor for any id derived from widget allocation.
#[inline]
pub const fn new(id: u64) -> Self {
debug_assert!(
id != 0,
"WidgetId::new(0) — id 0 is reserved for ROOT_WIDGET_ID"
);
Self(id)
}
}
/// Result of a `Widget::layout` call — the absolute rectangle the widget
/// occupies in its parent frame. Phase B keeps this minimal (no taffy yet);
/// Phase C / D may extend with taffy-style intrinsic sizing once the four
/// inspector widgets shake out their layout needs.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LayoutBox {
pub rect: Rect,
}
/// Frame-scoped paint context. Holds the active `RenderBackend` so widgets
/// can issue draw calls; the `&mut dyn` indirection lets shell-native +
/// shell-web share the same widget code without monomorphising over the
/// concrete backend type.
pub struct PaintCx<'a> {
pub backend: &'a mut dyn RenderBackend,
}
/// Layout-time context. Phase B passes the available width + the host's
/// dpi scale; later phases may add font metrics / theme tokens.
#[derive(Debug, Clone, Copy)]
pub struct LayoutCx {
pub available_width: f32,
pub dpi: f32,
}
/// The widget facade. Step 1b widgets are static — `paint` takes `&self`
/// and only sees the host-provided rect; mutable per-widget state lives in
/// dedicated `*State` structs (see B2). Future phases may add a `&mut self`
/// `event` method for input handling; Phase C wires DOM events in
/// shell-web and the trait surface gets extended in lockstep.
pub trait Widget {
/// Stable identifier (assigned by the host).
fn id(&self) -> WidgetId;
/// Compute the widget's layout in the given context. Pure — no
/// rendering side effects.
fn layout(&self, cx: &LayoutCx) -> LayoutBox;
/// Paint the widget into `rect` via `cx.backend`. The host is
/// responsible for placing the rect; the widget only paints relative
/// to it.
fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect);
/// Generate the accesskit Node for this widget. Used by both the
/// shell-native accesskit_winit adapter (Step 1a) and the shell-web
/// DOM mirror (Phase D). The host assigns NodeIds from `WidgetId`.
fn access_node(&self) -> accesskit::Node;
}
/// Convenience constructor used by tests + B2 widget impls.
pub fn rect(x: f32, y: f32, width: f32, height: f32) -> Rect {
Rect {
origin: Point2D::new(x, y),
size: Point2D::new(width, height),
}
}

View file

@ -0,0 +1,137 @@
//! Phase B1 widget facade smoke tests.
//!
//! Proves the `Widget` trait + `PaintCx` / `LayoutCx` shape compiles and
//! that a recording backend can be plugged in via the `&mut dyn
//! RenderBackend` field. B2 lands the four real widgets and extends this
//! file with per-widget paint-call assertions; today we only verify the
//! plumbing.
use openpencil_shell_core::widgets::{
LayoutBox, LayoutCx, PaintCx, ROOT_WIDGET_ID, Widget, WidgetId, rect,
};
use openpencil_shell_core::{Color, Point2D, Rect, RenderBackend, TextLayout};
#[derive(Default)]
struct RecordingBackend {
rects: usize,
strokes: usize,
text: usize,
saves: usize,
restores: usize,
clips: usize,
translates: usize,
}
impl RenderBackend for RecordingBackend {
fn begin_frame(&mut self) {}
fn end_frame(&mut self) {}
fn fill_rect(&mut self, _rect: Rect, _color: Color) {
self.rects += 1;
}
fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {
self.strokes += 1;
}
fn draw_text(&mut self, _layout: &TextLayout, _origin: Point2D) {
self.text += 1;
}
fn clip_rect(&mut self, _rect: Rect) {
self.clips += 1;
}
fn save(&mut self) {
self.saves += 1;
}
fn restore(&mut self) {
self.restores += 1;
}
fn translate(&mut self, _offset: Point2D) {
self.translates += 1;
}
fn resize(&mut self, _width: u32, _height: u32) {}
fn dpi_scale(&self) -> f32 {
1.0
}
}
#[test]
fn paint_cx_dispatches_through_dyn_backend() {
let mut backend = RecordingBackend::default();
{
let cx = PaintCx {
backend: &mut backend,
};
cx.backend.fill_rect(rect(0.0, 0.0, 10.0, 10.0), Color::RED);
cx.backend
.stroke_rect(rect(1.0, 2.0, 8.0, 8.0), Color::WHITE, 1.5);
cx.backend.save();
cx.backend.translate(Point2D::new(2.0, 3.0));
cx.backend.clip_rect(rect(0.0, 0.0, 4.0, 4.0));
cx.backend.restore();
}
assert_eq!(backend.rects, 1, "fill_rect dispatch");
assert_eq!(backend.strokes, 1, "stroke_rect dispatch");
assert_eq!(backend.saves, 1, "save dispatch");
assert_eq!(backend.restores, 1, "restore dispatch");
assert_eq!(backend.translates, 1, "translate dispatch");
assert_eq!(backend.clips, 1, "clip_rect dispatch");
}
/// A trivial widget impl proves the trait shape. Uses
/// `accesskit::Role::GenericContainer` (canonical "intentional placeholder",
/// ARIA `none`/`presentation`) so the test stays stable across unrelated
/// accesskit version bumps; B2 widgets use semantic roles.
struct StubWidget {
id: WidgetId,
box_rect: Rect,
}
impl Widget for StubWidget {
fn id(&self) -> WidgetId {
self.id
}
fn layout(&self, _cx: &LayoutCx) -> LayoutBox {
LayoutBox {
rect: self.box_rect,
}
}
fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect) {
cx.backend.fill_rect(rect, Color::WHITE);
}
fn access_node(&self) -> accesskit::Node {
// `GenericContainer` (ARIA `none` / `presentation`) is the canonical
// "intentional placeholder" role. Real B2 widgets use semantic
// roles (TreeItem / EditableText / etc); see codex B1 review NIT-5.
accesskit::Node::new(accesskit::Role::GenericContainer)
}
}
#[test]
fn widget_trait_dispatches_layout_and_paint() {
let widget = StubWidget {
id: WidgetId::new(7),
box_rect: rect(0.0, 0.0, 100.0, 24.0),
};
let layout_cx = LayoutCx {
available_width: 320.0,
dpi: 1.0,
};
let layout = widget.layout(&layout_cx);
assert_eq!(layout.rect.size.x, 100.0);
let mut backend = RecordingBackend::default();
{
let mut paint_cx = PaintCx {
backend: &mut backend,
};
widget.paint(&mut paint_cx, layout.rect);
}
assert_eq!(backend.rects, 1);
assert_eq!(widget.id(), WidgetId::new(7));
// Sanity check the root id constant survives codegen.
assert_eq!(ROOT_WIDGET_ID.0, 0);
// Trait surface check: access_node returns the placeholder
// `Role::GenericContainer` advertised by `StubWidget`. Real B2 widgets
// will assert their semantic roles (TreeItem / EditableText / etc).
let node = widget.access_node();
assert_eq!(node.role(), accesskit::Role::GenericContainer);
}