feat(shell-core): Phase B2 — four static inspector widgets

Lands the four Step 1b inspector widgets in shell-core (per spec
§1.4 — widget logic lives here so shell-native + shell-web reuse
it; only the RenderBackend impl + DOM event mapping + accesskit
DOM mirror are platform-owned).

Widgets:
- `widgets::TreeWidget` (Role::Tree, label "Layers") — sample
  3-item tree (Frame / Title / Button) with selection-aware
  blue-row paint and depth-indented labels. WidgetIds 100-103.
- `widgets::PropertyRow` (Role::Group, label "{label} {value}")
  — single-row label/value pair. PropertyRow uses Role::Group
  rather than Role::GenericContainer so the row label survives
  ARIA filtering on the way to VoiceOver / NVDA (codex B2 R1
  CONCERN). WidgetIds 200-299.
- `widgets::Dropdown` (Role::ComboBox, label "Blend") — sample
  blend-mode picker with 3 options. Phase B static slice does
  NOT yet pop a menu when `state.open == true`; Phase C wires
  click + keyboard handling. WidgetIds 300-399.
- `widgets::TextInput` (Role::TextInput) — single-line input
  with CJK IME preview. Paints `state.preedit` (in-progress
  composition) when present, else `state.value`; non-empty
  preedit also draws an 80px underline. Phase C lands
  compositionstart / update / end → state mutation in shell-web.
  WidgetIds 400-499.

State separation:
- `DropdownState { selected, open }` and `TextInputState { value,
  preedit }` live as their own structs so Phase C event handlers
  can swap them without taking ownership of the surrounding
  widget. `TextInputState::default()` returns the empty state.

Tests (`tests/widgets_static.rs`):
- `four_inspector_widgets_paint_static_content` — paints all
  four widgets through one RecordingBackend, asserts
  ≥5 fills / ≥3 strokes / ≥7 text dispatches (each tightened
  vs the plan sketch to actually catch per-widget regressions).
- Per-widget role + label assertions (Tree / Group / ComboBox /
  TextInput).
- `text_input_paints_preedit_underline_when_composing` —
  drives `state.preedit = "你好"`, paints, asserts the IME
  branch emits 1 fill + 2 strokes (border + underline) + 1
  text run.
- `dropdown_state_independent_state_struct` +
  `text_input_state_default_is_empty` — verify state structs
  are independent + default-constructible.

Plan-vs-implementation deviations (deliberate):
- `WidgetId::new(N)` instead of the plan's `WidgetId(N)` tuple
  literal so the sample/new constructors exercise the B1
  debug_assert non-zero check. Tuple stays public for pattern
  matching + `const` contexts.
- WidgetId range conventions per widget kind (Tree=100s /
  PropertyRow=200s / Dropdown=300s / TextInput=400s) added as
  doc-only comments. Real Phase C host will allocate from a
  counter; the conventions just keep the B-phase fixtures
  predictable.
- Per-widget paint counts in `four_inspector_widgets_…` test
  tightened to ≥5/≥3/≥7 (plan sketch had ≥4/≥3/≥4 which
  wouldn't catch a regression in Tree's selection-row fill).

Verification:
- `cargo test -p openpencil-shell-core` — 10/10 passing
- `cargo check -p openpencil-shell-core --target
  wasm32-unknown-unknown` — green (shell-core stays
  wasm32-clean per spec §1.2 — no platform deps creeping in)
- `cargo check -p openpencil-shell-native` — green

Codex iterate review: 2 rounds → GO.
This commit is contained in:
Kayshen-X 2026-05-09 21:46:00 +08:00
parent b6efa324d1
commit f214f4b4e3
6 changed files with 494 additions and 13 deletions

View file

@ -0,0 +1,81 @@
//! `Dropdown` — Step 1b inspector blend-mode style picker.
//!
//! Phase B static slice: shows the currently-selected option only;
//! `state.open == true` does not yet pop a menu (Phase C lands click +
//! keyboard handling that toggles `open` and renders the menu).
use super::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId};
use crate::{Color, Point2D, Rect, TextLayout};
#[derive(Debug, Clone)]
pub struct DropdownState {
pub selected: usize,
pub open: bool,
}
pub struct Dropdown {
pub id: WidgetId,
pub label: String,
pub options: Vec<String>,
pub state: DropdownState,
}
impl Dropdown {
/// Sample blend-mode dropdown. WidgetId range 300-399 is reserved
/// for dropdowns by Step 1b convention.
pub fn sample() -> Self {
Self {
id: WidgetId::new(300),
label: "Blend".to_string(),
options: vec![
"Normal".to_string(),
"Multiply".to_string(),
"Screen".to_string(),
],
state: DropdownState {
selected: 0,
open: false,
},
}
}
}
impl Widget for Dropdown {
fn id(&self) -> WidgetId {
self.id
}
fn layout(&self, cx: &LayoutCx) -> LayoutBox {
LayoutBox {
rect: Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(cx.available_width, 34.0),
},
}
}
fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect) {
cx.backend.fill_rect(rect, Color::WHITE);
cx.backend.stroke_rect(rect, Color::BLACK, 1.0);
let selected = self
.options
.get(self.state.selected)
.map(String::as_str)
.unwrap_or("");
let text = TextLayout::single_run(
selected,
"system-ui",
13.0,
jian_core::scene::Color::rgb(20, 20, 20),
Point2D::new(0.0, 0.0),
);
cx.backend
.draw_text(&text, rect.origin + Point2D::new(8.0, 21.0));
}
fn access_node(&self) -> accesskit::Node {
let mut node = accesskit::Node::new(accesskit::Role::ComboBox);
node.set_label(self.label.clone());
node
}
}

View file

@ -2,14 +2,23 @@
//! 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.
//! B1: the bare trait + paint / layout contexts + WidgetId.
//! B2 (this commit): four static inspector widgets — `TreeWidget` /
//! `PropertyRow` / `Dropdown` / `TextInput` — each with a sample/new
//! constructor, stable WidgetId range, layout/paint/access_node impl.
use crate::{Point2D, Rect, RenderBackend};
pub mod dropdown;
pub mod prop_row;
pub mod text_input;
pub mod tree;
pub use dropdown::{Dropdown, DropdownState};
pub use prop_row::PropertyRow;
pub use text_input::{TextInput, TextInputState};
pub use tree::{TreeItem, TreeWidget};
/// Stable identifier assigned by the widget host. Used by accesskit
/// (`accesskit::NodeId(WidgetId.0)`), the DOM mirror, and event routing.
///

View file

@ -0,0 +1,76 @@
//! `PropertyRow` — Step 1b inspector widget showing a label/value pair.
//!
//! Phase B static slice: text content is owned by the widget and never
//! mutates here; Phase C event handling will introduce a separate
//! `*State` if the row gains an editable value.
use super::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId};
use crate::{Color, Point2D, Rect, TextLayout};
pub struct PropertyRow {
pub id: WidgetId,
pub label: String,
pub value: String,
}
impl PropertyRow {
/// Construct a non-root property row. `id` MUST be non-zero (id 0 is
/// reserved for the host root — see [`super::ROOT_WIDGET_ID`]); the
/// [`super::WidgetId::new`] debug_assert enforces this in dev builds.
pub fn new(id: u64, label: impl Into<String>, value: impl Into<String>) -> Self {
Self {
id: WidgetId::new(id),
label: label.into(),
value: value.into(),
}
}
}
impl Widget for PropertyRow {
fn id(&self) -> WidgetId {
self.id
}
fn layout(&self, cx: &LayoutCx) -> LayoutBox {
LayoutBox {
rect: Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(cx.available_width, 32.0),
},
}
}
fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect) {
cx.backend.fill_rect(rect, Color::WHITE);
cx.backend.stroke_rect(rect, Color::BLACK, 1.0);
let label = TextLayout::single_run(
&self.label,
"system-ui",
13.0,
jian_core::scene::Color::rgb(80, 80, 80),
Point2D::new(0.0, 0.0),
);
let value = TextLayout::single_run(
&self.value,
"system-ui",
13.0,
jian_core::scene::Color::rgb(20, 20, 20),
Point2D::new(0.0, 0.0),
);
cx.backend
.draw_text(&label, rect.origin + Point2D::new(8.0, 20.0));
cx.backend
.draw_text(&value, rect.origin + Point2D::new(rect.size.x * 0.5, 20.0));
}
fn access_node(&self) -> accesskit::Node {
// `Role::Group` instead of `Role::GenericContainer`: GenericContainer
// maps to ARIA `none`/`presentation` and is filtered out of platform
// a11y trees, so the label would be dropped on the way to VoiceOver
// / NVDA. `Group` is the canonical "labelled cluster of related
// children" role and survives the filter (codex B2 R1 CONCERN).
let mut node = accesskit::Node::new(accesskit::Role::Group);
node.set_label(format!("{} {}", self.label, self.value));
node
}
}

View file

@ -0,0 +1,89 @@
//! `TextInput` — Step 1b inspector single-line text field with CJK IME
//! preview support.
//!
//! Phase B static slice: paints `state.preedit` (in-progress IME
//! composition) when present, else `state.value` (committed). The
//! preedit underline is the only visual cue; Phase C event handling
//! lands compositionstart / compositionupdate / compositionend → state
//! mutation in shell-web.
use super::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId};
use crate::{Color, Point2D, Rect, TextLayout};
#[derive(Debug, Clone, Default)]
pub struct TextInputState {
pub value: String,
pub preedit: String,
}
pub struct TextInput {
pub id: WidgetId,
pub label: String,
pub state: TextInputState,
}
impl TextInput {
/// Sample text input. WidgetId range 400-499 is reserved for text
/// inputs by Step 1b convention.
pub fn sample() -> Self {
Self {
id: WidgetId::new(400),
label: "Name".to_string(),
state: TextInputState {
value: "Frame 1".to_string(),
preedit: String::new(),
},
}
}
}
impl Widget for TextInput {
fn id(&self) -> WidgetId {
self.id
}
fn layout(&self, cx: &LayoutCx) -> LayoutBox {
LayoutBox {
rect: Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(cx.available_width, 34.0),
},
}
}
fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect) {
cx.backend.fill_rect(rect, Color::WHITE);
cx.backend.stroke_rect(rect, Color::BLACK, 1.0);
let display = if self.state.preedit.is_empty() {
self.state.value.as_str()
} else {
self.state.preedit.as_str()
};
let text = TextLayout::single_run(
display,
"system-ui",
13.0,
jian_core::scene::Color::rgb(20, 20, 20),
Point2D::new(0.0, 0.0),
);
cx.backend
.draw_text(&text, rect.origin + Point2D::new(8.0, 21.0));
if !self.state.preedit.is_empty() {
cx.backend.stroke_rect(
Rect {
origin: Point2D::new(rect.origin.x + 8.0, rect.origin.y + 25.0),
size: Point2D::new(80.0, 1.0),
},
Color::BLACK,
1.0,
);
}
}
fn access_node(&self) -> accesskit::Node {
let mut node = accesskit::Node::new(accesskit::Role::TextInput);
node.set_label(self.label.clone());
node.set_value(self.state.value.clone());
node
}
}

View file

@ -0,0 +1,103 @@
//! `TreeWidget` — Step 1b inspector "Layers" panel.
//!
//! Phase B static slice: items are pre-baked via `TreeWidget::sample()`;
//! Phase C event handling adds selection state mutation. Each `TreeItem`
//! carries its own `WidgetId` so accesskit can address rows individually
//! when the DOM mirror lands in Phase D.
use super::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId};
use crate::{Color, Point2D, Rect, TextLayout};
#[derive(Debug, Clone)]
pub struct TreeItem {
pub id: WidgetId,
pub label: String,
pub depth: u8,
pub selected: bool,
}
pub struct TreeWidget {
pub id: WidgetId,
pub items: Vec<TreeItem>,
}
impl TreeWidget {
/// Sample tree mirroring the inspector's "Layers" panel. WidgetId
/// range 100-199 is reserved for tree nodes by Step 1b convention so
/// id collisions across widget kinds (PropertyRow=200s, Dropdown=300s,
/// TextInput=400s) cannot happen by accident.
pub fn sample() -> Self {
Self {
id: WidgetId::new(100),
items: vec![
TreeItem {
id: WidgetId::new(101),
label: "Frame".to_string(),
depth: 0,
selected: true,
},
TreeItem {
id: WidgetId::new(102),
label: "Title".to_string(),
depth: 1,
selected: false,
},
TreeItem {
id: WidgetId::new(103),
label: "Button".to_string(),
depth: 1,
selected: false,
},
],
}
}
}
impl Widget for TreeWidget {
fn id(&self) -> WidgetId {
self.id
}
fn layout(&self, cx: &LayoutCx) -> LayoutBox {
LayoutBox {
rect: Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(cx.available_width, self.items.len() as f32 * 28.0 + 8.0),
},
}
}
fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect) {
cx.backend.fill_rect(rect, Color::WHITE);
for (index, item) in self.items.iter().enumerate() {
let y = rect.origin.y + 4.0 + index as f32 * 28.0;
let row = Rect {
origin: Point2D::new(rect.origin.x + 4.0, y),
size: Point2D::new(rect.size.x - 8.0, 24.0),
};
if item.selected {
cx.backend.fill_rect(row, Color::BLUE);
}
let text = TextLayout::single_run(
&item.label,
"system-ui",
13.0,
jian_core::scene::Color::rgb(0, 0, 0),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&text,
Point2D::new(
row.origin.x + 8.0 + f32::from(item.depth) * 16.0,
row.origin.y + 17.0,
),
);
}
}
fn access_node(&self) -> accesskit::Node {
let mut node = accesskit::Node::new(accesskit::Role::Tree);
node.set_label("Layers");
node
}
}

View file

@ -1,13 +1,14 @@
//! Phase B1 widget facade smoke tests.
//! Phase B1 + B2 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.
//! B1 piece: proves the `Widget` trait + `PaintCx` / `LayoutCx` shape
//! compiles + that a recording backend plugs in via `&mut dyn
//! RenderBackend`. B2 piece: proves the four inspector widgets (Tree /
//! PropertyRow / Dropdown / TextInput) paint and emit accesskit nodes
//! with the expected semantic roles.
use openpencil_shell_core::widgets::{
LayoutBox, LayoutCx, PaintCx, ROOT_WIDGET_ID, Widget, WidgetId, rect,
Dropdown, DropdownState, LayoutBox, LayoutCx, PaintCx, PropertyRow, ROOT_WIDGET_ID, TextInput,
TextInputState, TreeWidget, Widget, WidgetId, rect,
};
use openpencil_shell_core::{Color, Point2D, Rect, RenderBackend, TextLayout};
@ -131,7 +132,129 @@ fn widget_trait_dispatches_layout_and_paint() {
// Trait surface check: access_node returns the placeholder
// `Role::GenericContainer` advertised by `StubWidget`. Real B2 widgets
// will assert their semantic roles (TreeItem / EditableText / etc).
// assert their semantic roles in the tests below.
let node = widget.access_node();
assert_eq!(node.role(), accesskit::Role::GenericContainer);
}
// ---------------------------------------------------------------------
// B2: four inspector widgets paint static content + expose semantic
// accesskit roles.
// ---------------------------------------------------------------------
#[test]
fn four_inspector_widgets_paint_static_content() {
let layout = LayoutCx {
available_width: 240.0,
dpi: 1.0,
};
let widgets: Vec<Box<dyn Widget>> = vec![
Box::new(TreeWidget::sample()),
Box::new(PropertyRow::new(200, "Width", "960")),
Box::new(Dropdown::sample()),
Box::new(TextInput::sample()),
];
let mut backend = RecordingBackend::default();
for widget in widgets {
let box_ = widget.layout(&layout);
let mut cx = PaintCx {
backend: &mut backend,
};
widget.paint(&mut cx, box_.rect);
}
// Each widget paints at least its background fill (4); Tree adds one
// more for the selected row → ≥ 5. Stroked rects: PropertyRow,
// Dropdown, TextInput each stroke their border (3). Text runs:
// PropertyRow has 2 (label+value); Tree has 3 items; Dropdown has 1;
// TextInput has 1 → ≥ 7 total.
assert!(backend.rects >= 5, "fill_rect dispatch ≥ 5 (got {})", backend.rects);
assert!(backend.strokes >= 3, "stroke_rect dispatch ≥ 3 (got {})", backend.strokes);
assert!(backend.text >= 7, "draw_text dispatch ≥ 7 (got {})", backend.text);
}
#[test]
fn tree_widget_advertises_tree_role_and_layers_label() {
let tree = TreeWidget::sample();
let node = tree.access_node();
assert_eq!(node.role(), accesskit::Role::Tree);
// accesskit::Node exposes label() returning Option<&str> in 0.24.
assert_eq!(node.label(), Some("Layers"));
// Sample tree has 3 items.
assert_eq!(tree.items.len(), 3);
assert!(tree.items.iter().any(|item| item.selected));
}
#[test]
fn property_row_advertises_label_and_value() {
let row = PropertyRow::new(201, "Width", "960");
let node = row.access_node();
// `Role::Group` (not GenericContainer) so the label survives ARIA
// filtering — see codex B2 R1 CONCERN + the fix in prop_row.rs.
assert_eq!(node.role(), accesskit::Role::Group);
assert_eq!(node.label(), Some("Width 960"));
}
#[test]
fn dropdown_advertises_combobox_role() {
let drop = Dropdown::sample();
let node = drop.access_node();
assert_eq!(node.role(), accesskit::Role::ComboBox);
assert_eq!(node.label(), Some("Blend"));
// Sample preserves the closed/first-selected state.
assert_eq!(drop.state.selected, 0);
assert!(!drop.state.open);
}
#[test]
fn text_input_advertises_text_input_role_and_value() {
let input = TextInput::sample();
let node = input.access_node();
assert_eq!(node.role(), accesskit::Role::TextInput);
assert_eq!(node.label(), Some("Name"));
assert_eq!(node.value(), Some("Frame 1"));
}
#[test]
fn text_input_paints_preedit_underline_when_composing() {
// The preedit-underline painting branch only fires when
// `state.preedit` is non-empty; verify it via the recording backend.
let mut input = TextInput::sample();
input.state.preedit = "你好".to_string();
let layout_cx = LayoutCx {
available_width: 240.0,
dpi: 1.0,
};
let layout = input.layout(&layout_cx);
let mut backend = RecordingBackend::default();
{
let mut cx = PaintCx {
backend: &mut backend,
};
input.paint(&mut cx, layout.rect);
}
// 1 fill (background) + 2 strokes (border + preedit underline) +
// 1 text run (preedit content).
assert_eq!(backend.rects, 1);
assert_eq!(backend.strokes, 2);
assert_eq!(backend.text, 1);
}
#[test]
fn dropdown_state_independent_state_struct() {
// DropdownState lives on its own so input handling can swap it
// without taking ownership of the surrounding Dropdown widget.
let s = DropdownState {
selected: 2,
open: true,
};
assert_eq!(s.selected, 2);
assert!(s.open);
}
#[test]
fn text_input_state_default_is_empty() {
let s = TextInputState::default();
assert_eq!(s.value, "");
assert_eq!(s.preedit, "");
}