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); +}