openpencil/crates/op-editor-ui/tests/widgets_static.rs

511 lines
16 KiB
Rust
Raw Normal View History

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.
2026-05-09 13:46:00 +00:00
//! Phase B1 + B2 widget facade smoke tests.
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.
2026-05-09 13:45:00 +00:00
//!
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.
2026-05-09 13:46:00 +00:00
//! 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.
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.
2026-05-09 13:45:00 +00:00
2026-05-16 15:33:47 +00:00
use op_editor_ui::widgets::{
rect, Dropdown, DropdownState, LayoutBox, LayoutCx, PaintCx, PropertyRow, TextInput,
TextInputState, TreeWidget, Widget, WidgetId, ROOT_WIDGET_ID,
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.
2026-05-09 13:45:00 +00:00
};
2026-05-16 15:33:47 +00:00
use op_editor_ui::{
feat(shell-core): Phase C2.1 — widget event handlers + WidgetHost forwarding Lands the data-flow piece of plan C2: shell-core widgets gain pure state-mutation methods (`TextInputState::apply_ime`, `DropdownState::apply_key`) that the WidgetHost forwards to from the two new `// glue:` marked methods. Phase C2.2 will land the browser closure registration that drives these methods from real DOM events. shell-core (widgets stay platform-agnostic per spec §1.4): - `TextInputState::apply_ime(&ImeEvent)` — CompositionStart clears preedit, CompositionUpdate replaces preedit with `event.text` (selection deferred to Phase D DOM mirror), CompositionEnd appends the commit text to value and clears preedit. - `DropdownState::apply_key(&KeyEvent, option_count)` — ArrowDown advances + opens (saturates at last option, no wrap), ArrowUp retreats with `saturating_sub` + opens, Enter / Escape close without mutating selection. Skips on Released or empty options. shell-web (glue file widget_host.rs, both methods carry `// glue:` markers per spec §1.4 boundary check): - `WidgetHost::apply_ime(&ImeEvent)` — forwards to text_input.state - `WidgetHost::apply_key(&KeyEvent)` — forwards to dropdown.state with `dropdown.options.len()` for the option count Tests (`tests/widgets_static.rs`, +10 → 20 total): - text_input apply_ime: Start clears preedit (value untouched); Update replaces preedit (value untouched); End commits + clears; double-Start without End each clears (codex C2.1 R1 CONCERN-1 — pathological host state machine) - dropdown apply_key: ArrowDown advances + saturates; ArrowUp retreats + saturating_sub; Enter / Escape close + Escape preserves selection (codex C2.1 R1 CONCERN-2); Released = no-op; zero options = no-op; unrelated NamedKey (Tab) = no-op - Helper `keydown(named)` / `keyup(named)` build minimal KeyEvents; apply_key reads only `key` + `state` so the harness's KeyCode field is intentionally Unknown(String::new()) Plan-vs-implementation deviations (deliberate): - WidgetHost forwarding methods carry `// glue:` markers. F3 in the boundary script's exemption set covered `fn paint(...)`; the same exemption applies here because these methods import + invoke `openpencil_shell_core::{ImeEvent, KeyEvent}` at the signature line. Verified `bash tools/check-widget-boundary.sh` still PASS. - Codex C2.1 R1 CONCERN-3 (WidgetHost forwarding has no direct test coverage) deferred to C2.2 — the browser closure registration there exercises the forwarding end-to-end via real DOM events, which is more meaningful than mocking WidgetHost in shell-web tests with read-only accessors that would only exist for testing. Verification: - `cargo test -p openpencil-shell-core --test widgets_static` — 20/20 passing - `cargo check -p openpencil-shell-core --target wasm32-unknown-unknown` — green (shell-core stays wasm32-clean per spec §1.2) - `bash tools/check-widget-boundary.sh` — PASS Codex iterate review: 2 rounds → GO.
2026-05-09 13:50:00 +00:00
Color, ImeEvent, ImeKind, KeyCode, KeyEvent, KeyLocation, KeyState, KeyValue, Modifiers,
NamedKey, Point2D, Rect, RenderBackend, TextLayout,
};
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.
2026-05-09 13:45:00 +00:00
#[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;
}
feat(shell): step 4-6 chrome — TS-equivalent editor UI + interactions Step 4 (visual lift): - Theme tokens (shadcn-dark palette) in shell-core - Lucide-style icons via stroke_svg_path (skia parse_path::from_svg) - Vertical Toolbar / sectioned LayerPanel (Pages + Layers) / TopBar / floating StatusBar / floating AIChatPanel widgets - Native + web backends: stroke_line / fill_round_rect / stroke_round_rect / stroke_svg_path primitives - CJK fallback typeface: cached PingFang/Noto-CJK on native via match_family_style_character; embedded NotoSansCJK-Subset (8.7 KB) on web alongside Roboto Step 5 (infinite canvas + AI chat input): - Document.viewport (pan + zoom 10–800%) with cursor-centered zoom_at + Hand-tool drag pan + dotted background grid - Trackpad PixelDelta → pan, LineDelta / pinch / Cmd+swipe → zoom (winit MouseScrollDelta + PinchGesture + Modifiers) - Document.chat (input / messages / focused / collapsed / 4-corner anchor) — WidgetHost wires apply_text / apply_backspace / apply_send + DOM keydown listener - AI chat panel drag → 4-corner snap via ChatAnchor::nearest - Collapsed mode: compact pill (MessageSquare + "New Chat" + ChevronUp), entire pill click expands Step 6 (RightPanel + chrome polish): - PropertyPanel rewrite: 设计/代码 tabs, 创建组件, 位置, 弹性布局, 尺寸, 图层, 填充, 描边, 效果, 导出 — file split into property_panel.rs + property_panel_sections.rs (under 800 ea.) - Node::aggregate_bounds for Group / unbounded containers so the panel reports child-union W/H instead of 0×0 - TopBar PanelLeft button toggles Document.ui.sidebar_open - Click empty canvas clears selection (collapses RightPanel) - Native font cache (Roboto + system CJK typeface) bypasses jian-skia textlayout: chrome paint 605 ms → sub-ms Hit-test order = paint order reversed (chat → toolbar → layer panel → canvas) so the topmost overlay always wins, plus toolbar bounding-rect consumes gap clicks so they don't fall through. 64 lib tests + 21 widgets_static green; native + web cargo check clean. Web wasm rebuild gated on EMSDK (tools/check-wasm-bundle.sh runs the bundle ceiling guard).
2026-05-10 09:07:59 +00:00
fn stroke_line(&mut self, _from: Point2D, _to: Point2D, _color: Color, _width: f32) {
// Counted alongside strokes for the existing test asserts;
// a separate counter is unnecessary for Step 4 visual lift.
self.strokes += 1;
}
fn fill_round_rect(&mut self, _rect: Rect, _radius: f32, _color: Color) {
self.rects += 1;
}
fn stroke_round_rect(&mut self, _rect: Rect, _radius: f32, _color: Color, _width: f32) {
self.strokes += 1;
}
fn stroke_svg_path(
&mut self,
_d: &str,
_top_left: Point2D,
_size: f32,
_color: Color,
_width: f32,
) {
self.strokes += 1;
}
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.
2026-05-09 13:45:00 +00:00
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
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.
2026-05-09 13:46:00 +00:00
// assert their semantic roles in the tests below.
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.
2026-05-09 13:45:00 +00:00
let node = widget.access_node();
assert_eq!(node.role(), accesskit::Role::GenericContainer);
}
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.
2026-05-09 13:46:00 +00:00
// ---------------------------------------------------------------------
// 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
);
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.
2026-05-09 13:46:00 +00:00
}
#[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, "");
}
feat(shell-core): Phase C2.1 — widget event handlers + WidgetHost forwarding Lands the data-flow piece of plan C2: shell-core widgets gain pure state-mutation methods (`TextInputState::apply_ime`, `DropdownState::apply_key`) that the WidgetHost forwards to from the two new `// glue:` marked methods. Phase C2.2 will land the browser closure registration that drives these methods from real DOM events. shell-core (widgets stay platform-agnostic per spec §1.4): - `TextInputState::apply_ime(&ImeEvent)` — CompositionStart clears preedit, CompositionUpdate replaces preedit with `event.text` (selection deferred to Phase D DOM mirror), CompositionEnd appends the commit text to value and clears preedit. - `DropdownState::apply_key(&KeyEvent, option_count)` — ArrowDown advances + opens (saturates at last option, no wrap), ArrowUp retreats with `saturating_sub` + opens, Enter / Escape close without mutating selection. Skips on Released or empty options. shell-web (glue file widget_host.rs, both methods carry `// glue:` markers per spec §1.4 boundary check): - `WidgetHost::apply_ime(&ImeEvent)` — forwards to text_input.state - `WidgetHost::apply_key(&KeyEvent)` — forwards to dropdown.state with `dropdown.options.len()` for the option count Tests (`tests/widgets_static.rs`, +10 → 20 total): - text_input apply_ime: Start clears preedit (value untouched); Update replaces preedit (value untouched); End commits + clears; double-Start without End each clears (codex C2.1 R1 CONCERN-1 — pathological host state machine) - dropdown apply_key: ArrowDown advances + saturates; ArrowUp retreats + saturating_sub; Enter / Escape close + Escape preserves selection (codex C2.1 R1 CONCERN-2); Released = no-op; zero options = no-op; unrelated NamedKey (Tab) = no-op - Helper `keydown(named)` / `keyup(named)` build minimal KeyEvents; apply_key reads only `key` + `state` so the harness's KeyCode field is intentionally Unknown(String::new()) Plan-vs-implementation deviations (deliberate): - WidgetHost forwarding methods carry `// glue:` markers. F3 in the boundary script's exemption set covered `fn paint(...)`; the same exemption applies here because these methods import + invoke `openpencil_shell_core::{ImeEvent, KeyEvent}` at the signature line. Verified `bash tools/check-widget-boundary.sh` still PASS. - Codex C2.1 R1 CONCERN-3 (WidgetHost forwarding has no direct test coverage) deferred to C2.2 — the browser closure registration there exercises the forwarding end-to-end via real DOM events, which is more meaningful than mocking WidgetHost in shell-web tests with read-only accessors that would only exist for testing. Verification: - `cargo test -p openpencil-shell-core --test widgets_static` — 20/20 passing - `cargo check -p openpencil-shell-core --target wasm32-unknown-unknown` — green (shell-core stays wasm32-clean per spec §1.2) - `bash tools/check-widget-boundary.sh` — PASS Codex iterate review: 2 rounds → GO.
2026-05-09 13:50:00 +00:00
// ---------------------------------------------------------------------
// C2 widget event handlers (apply_ime / apply_key)
// ---------------------------------------------------------------------
fn keydown(named: NamedKey) -> KeyEvent {
KeyEvent {
key: KeyValue::Named(named),
code: KeyCode::Unknown(String::new()),
location: KeyLocation::Standard,
modifiers: Modifiers::empty(),
state: KeyState::Pressed,
repeat: false,
is_composing: false,
}
}
fn keyup(named: NamedKey) -> KeyEvent {
KeyEvent {
state: KeyState::Released,
..keydown(named)
}
}
#[test]
fn text_input_apply_ime_start_clears_preedit() {
let mut state = TextInputState {
value: "Frame 1".into(),
preedit: "stale".into(),
};
state.apply_ime(&ImeEvent {
kind: ImeKind::CompositionStart,
text: String::new(),
});
assert_eq!(state.preedit, "");
// CompositionStart must NOT touch the committed value.
assert_eq!(state.value, "Frame 1");
}
#[test]
fn text_input_apply_ime_update_replaces_preedit() {
let mut state = TextInputState {
value: "Frame ".into(),
preedit: String::new(),
};
state.apply_ime(&ImeEvent {
kind: ImeKind::CompositionUpdate { selection: None },
text: "你".into(),
});
assert_eq!(state.preedit, "你");
state.apply_ime(&ImeEvent {
kind: ImeKind::CompositionUpdate { selection: None },
text: "你好".into(),
});
assert_eq!(state.preedit, "你好");
// Update path NEVER mutates value.
assert_eq!(state.value, "Frame ");
}
#[test]
fn text_input_apply_ime_end_appends_to_value_and_clears_preedit() {
let mut state = TextInputState {
value: "Frame ".into(),
preedit: "你好".into(),
};
state.apply_ime(&ImeEvent {
kind: ImeKind::CompositionEnd,
text: "你好".into(),
});
assert_eq!(state.value, "Frame 你好");
assert_eq!(state.preedit, "");
}
#[test]
fn text_input_apply_ime_double_start_clears_preedit_each_time() {
// Pathological host state machine: a CompositionStart followed by
// another CompositionStart without an intervening End. Each Start
// unconditionally resets preedit; the committed value never moves.
// (Codex C2.1 R1 CONCERN-1.)
let mut state = TextInputState {
value: "Frame ".into(),
preedit: "old".into(),
};
state.apply_ime(&ImeEvent {
kind: ImeKind::CompositionStart,
text: String::new(),
});
assert_eq!(state.preedit, "");
state.preedit = "leftover".into();
state.apply_ime(&ImeEvent {
kind: ImeKind::CompositionStart,
text: String::new(),
});
assert_eq!(state.preedit, "");
assert_eq!(state.value, "Frame ");
}
#[test]
fn dropdown_apply_key_arrow_down_advances_and_opens() {
let mut state = DropdownState {
selected: 0,
open: false,
};
state.apply_key(&keydown(NamedKey::ArrowDown), 3);
assert_eq!(state.selected, 1);
assert!(state.open);
state.apply_key(&keydown(NamedKey::ArrowDown), 3);
assert_eq!(state.selected, 2);
// Saturates at option_count - 1; does not wrap.
state.apply_key(&keydown(NamedKey::ArrowDown), 3);
assert_eq!(state.selected, 2);
}
#[test]
fn dropdown_apply_key_arrow_up_retreats_with_saturating_sub() {
let mut state = DropdownState {
selected: 1,
open: false,
};
state.apply_key(&keydown(NamedKey::ArrowUp), 3);
assert_eq!(state.selected, 0);
assert!(state.open);
// Already at 0 — saturating_sub holds at 0, no panic.
state.apply_key(&keydown(NamedKey::ArrowUp), 3);
assert_eq!(state.selected, 0);
}
#[test]
fn dropdown_apply_key_enter_and_escape_close() {
let mut state = DropdownState {
selected: 1,
open: true,
};
state.apply_key(&keydown(NamedKey::Enter), 3);
assert!(!state.open);
assert_eq!(state.selected, 1);
state.open = true;
state.apply_key(&keydown(NamedKey::Escape), 3);
assert!(!state.open);
// Escape closes the menu but does NOT mutate selection — confirm
// both halves of the close path leave selected stable (codex C2.1
// R1 CONCERN-2).
assert_eq!(state.selected, 1);
}
#[test]
fn dropdown_apply_key_ignores_keyup() {
let mut state = DropdownState {
selected: 1,
open: false,
};
state.apply_key(&keyup(NamedKey::ArrowDown), 3);
// Released event is a no-op.
assert_eq!(state.selected, 1);
assert!(!state.open);
}
#[test]
fn dropdown_apply_key_ignores_zero_options() {
let mut state = DropdownState {
selected: 0,
open: false,
};
state.apply_key(&keydown(NamedKey::ArrowDown), 0);
// No options → no-op rather than panic on `option_count - 1`.
assert_eq!(state.selected, 0);
assert!(!state.open);
}
#[test]
fn dropdown_apply_key_ignores_unrelated_keys() {
let mut state = DropdownState {
selected: 1,
open: true,
};
state.apply_key(&keydown(NamedKey::Tab), 3);
// Tab is not bound; state should be unchanged.
assert_eq!(state.selected, 1);
assert!(state.open);
}
fix(shell-core): Phase C stop-hook #2 — skip dropdown keys during IME Codex stop-hook #2: with the hidden IME textarea now focused (R1 fix in fe994c4b), every keystroke routes through it — including arrow keys the user is pressing to navigate the IME's candidate picker. The window-level keydown listener still fires on these keystrokes, and `DropdownState::apply_key` was mutating selection + opening the menu behind the IME panel. Surfaced as: "focused IME textarea lets composing keys mutate dropdown state." Spec §2.4 says: "Widgets that consume keys directly should usually skip dispatch when is_composing == true and let the ImeEvent path handle the composition instead." The plumbing for `is_composing` already runs through Phase C2.2 (W3C `KeyboardEvent.isComposing` → C1 `map_keyboard_parts(..., is_composing)` → `KeyEvent.is_composing` → C2.1 `DropdownState::apply_key`); we just weren't honoring the bit on the consuming side. Fix: add `event.is_composing` to the early-return condition in `DropdownState::apply_key`. ArrowDown/Up/Enter/Escape during a composition no-op now; the IME's candidate picker keeps the keystroke and the dropdown stays put. `TextInputState::apply_ime` is unaffected — it already only processes ImeEvent, never KeyEvent, so composing-key bleed- through was never a concern there. Test: `dropdown_apply_key_ignores_keys_during_ime_composition` asserts both ArrowDown (would advance + open) and Enter (would close) are no-ops when `is_composing` is true. Test count 20 → 21. Verification: - `cargo test -p openpencil-shell-core --test widgets_static` — 21/21 passing - `cargo check -p openpencil-shell-core --target wasm32-unknown-unknown` — green - `cargo build -p openpencil-shell-web --target wasm32-unknown-unknown --features skia --release` — green - `bash tools/check-wasm-bundle.sh` — PASS: - 0 env.* imports - 622 156 bytes gzip = 59% of 1 MiB ceiling Phase D may extend this guard pattern to other widgets that gain key handling (Tree typeahead, etc.); the spec §2.4 is_composing contract becomes a per-widget invariant.
2026-05-09 13:54:00 +00:00
#[test]
fn dropdown_apply_key_ignores_keys_during_ime_composition() {
// Spec §2.4 widget rule: skip key dispatch while is_composing is
// true. ArrowDown during a CJK composition belongs to the IME's
// candidate-picker UI, NOT to the dropdown selection cycle.
// Codex Phase C stop-hook (#2): "focused IME textarea lets
// composing keys mutate dropdown state."
let mut state = DropdownState {
selected: 0,
open: false,
};
let composing_arrow = KeyEvent {
is_composing: true,
..keydown(NamedKey::ArrowDown)
};
state.apply_key(&composing_arrow, 3);
assert_eq!(state.selected, 0, "composing ArrowDown must not advance");
assert!(!state.open, "composing ArrowDown must not open");
// Same protection for Enter / Escape — IME 'commit' shouldn't
// close the dropdown.
let mut state2 = DropdownState {
selected: 1,
open: true,
};
let composing_enter = KeyEvent {
is_composing: true,
..keydown(NamedKey::Enter)
};
state2.apply_key(&composing_enter, 3);
assert!(state2.open, "composing Enter must not close");
}