diff --git a/crates/openpencil-shell-core/src/widgets/dropdown.rs b/crates/openpencil-shell-core/src/widgets/dropdown.rs new file mode 100644 index 000000000..9bbc59ac4 --- /dev/null +++ b/crates/openpencil-shell-core/src/widgets/dropdown.rs @@ -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, + 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 + } +} diff --git a/crates/openpencil-shell-core/src/widgets/mod.rs b/crates/openpencil-shell-core/src/widgets/mod.rs index 73846ad09..2d362fa63 100644 --- a/crates/openpencil-shell-core/src/widgets/mod.rs +++ b/crates/openpencil-shell-core/src/widgets/mod.rs @@ -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. /// diff --git a/crates/openpencil-shell-core/src/widgets/prop_row.rs b/crates/openpencil-shell-core/src/widgets/prop_row.rs new file mode 100644 index 000000000..b97ca8e3d --- /dev/null +++ b/crates/openpencil-shell-core/src/widgets/prop_row.rs @@ -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, value: impl Into) -> 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 + } +} diff --git a/crates/openpencil-shell-core/src/widgets/text_input.rs b/crates/openpencil-shell-core/src/widgets/text_input.rs new file mode 100644 index 000000000..245b996e2 --- /dev/null +++ b/crates/openpencil-shell-core/src/widgets/text_input.rs @@ -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 + } +} diff --git a/crates/openpencil-shell-core/src/widgets/tree.rs b/crates/openpencil-shell-core/src/widgets/tree.rs new file mode 100644 index 000000000..1d890093b --- /dev/null +++ b/crates/openpencil-shell-core/src/widgets/tree.rs @@ -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, +} + +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 + } +} diff --git a/crates/openpencil-shell-core/tests/widgets_static.rs b/crates/openpencil-shell-core/tests/widgets_static.rs index 7f24ca2f5..9e226dbbc 100644 --- a/crates/openpencil-shell-core/tests/widgets_static.rs +++ b/crates/openpencil-shell-core/tests/widgets_static.rs @@ -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> = 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, ""); +}