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:
parent
d20e1acf0f
commit
b6efa324d1
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
101
crates/openpencil-shell-core/src/widgets/mod.rs
Normal file
101
crates/openpencil-shell-core/src/widgets/mod.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
137
crates/openpencil-shell-core/tests/widgets_static.rs
Normal file
137
crates/openpencil-shell-core/tests/widgets_static.rs
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue