From b6efa324d16e8ef1f0b28b7a4d80b6ea79ca650f Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sat, 9 May 2026 21:45:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(shell-core):=20Phase=20B1=20=E2=80=94=20Wi?= =?UTF-8?q?dget=20trait=20+=20recording=20test=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 1 + crates/openpencil-shell-core/Cargo.toml | 6 + crates/openpencil-shell-core/src/lib.rs | 1 + .../src/render_backend.rs | 7 +- .../openpencil-shell-core/src/widgets/mod.rs | 101 +++++++++++++ .../tests/widgets_static.rs | 137 ++++++++++++++++++ 6 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 crates/openpencil-shell-core/src/widgets/mod.rs create mode 100644 crates/openpencil-shell-core/tests/widgets_static.rs diff --git a/Cargo.lock b/Cargo.lock index d4eee7488..df6c60a59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/openpencil-shell-core/Cargo.toml b/crates/openpencil-shell-core/Cargo.toml index 3bbb55ce4..8e2b074d9 100644 --- a/crates/openpencil-shell-core/Cargo.toml +++ b/crates/openpencil-shell-core/Cargo.toml @@ -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). diff --git a/crates/openpencil-shell-core/src/lib.rs b/crates/openpencil-shell-core/src/lib.rs index fe8d836e0..3666f3138 100644 --- a/crates/openpencil-shell-core/src/lib.rs +++ b/crates/openpencil-shell-core/src/lib.rs @@ -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}; diff --git a/crates/openpencil-shell-core/src/render_backend.rs b/crates/openpencil-shell-core/src/render_backend.rs index 61d97dd13..736fef92e 100644 --- a/crates/openpencil-shell-core/src/render_backend.rs +++ b/crates/openpencil-shell-core/src/render_backend.rs @@ -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, diff --git a/crates/openpencil-shell-core/src/widgets/mod.rs b/crates/openpencil-shell-core/src/widgets/mod.rs new file mode 100644 index 000000000..73846ad09 --- /dev/null +++ b/crates/openpencil-shell-core/src/widgets/mod.rs @@ -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), + } +} diff --git a/crates/openpencil-shell-core/tests/widgets_static.rs b/crates/openpencil-shell-core/tests/widgets_static.rs new file mode 100644 index 000000000..7f24ca2f5 --- /dev/null +++ b/crates/openpencil-shell-core/tests/widgets_static.rs @@ -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); +}