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
|
|
|
//!
|
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
|
|
|
|
refactor: extract op-editor-ui crate
Relocate the widget facade (widgets/, including the Widget trait,
render primitives, the editor-UI compositions, the CanvasViewport
center canvas, the lucide icon drawer, and editor_state_ext), the
theme tokens, the layout-resolved render scene (layout_scene +
layout_scene_hit), and the design-variable aggregation (scene_vars)
out of openpencil-shell-core into a dedicated op-editor-ui crate.
The canvas widgets (canvas_viewport*) stay inside op-editor-ui rather
than splitting into a separate op-canvas crate: they depend on the
widgets/ siblings editor_state_ext + icons and on the Widget trait, so
a clean mechanical split is not possible — per the task's explicit
allowance not to force a fragile split.
op-editor-ui's lib.rs mirrors the old shell-core crate-root re-exports
(render_backend facade types + jian gesture types + the i18n alias) so
every intra-module `crate::Color` / `crate::theme` / `crate::widgets`
path resolves unchanged — a pure relocation with no path rewrites
inside the moved modules. openpencil-shell-core becomes a thin
re-export shim (`pub use op_editor_ui::{widgets, theme, ...}`) so the
hosts keep resolving `openpencil_shell_core::*` until the Task 7.3
host rename dissolves the crate. The widgets_static integration test
moves to op-editor-ui/tests with its imports rewritten. The widget
boundary script's reverse-check path is updated to the new crate.
No behaviour change; all tests move with their code.
2026-05-16 15:33:47 +00:00
|
|
|
use op_editor_ui::widgets::{
|
2026-05-10 10:47:33 +00:00
|
|
|
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
|
|
|
};
|
refactor: extract op-editor-ui crate
Relocate the widget facade (widgets/, including the Widget trait,
render primitives, the editor-UI compositions, the CanvasViewport
center canvas, the lucide icon drawer, and editor_state_ext), the
theme tokens, the layout-resolved render scene (layout_scene +
layout_scene_hit), and the design-variable aggregation (scene_vars)
out of openpencil-shell-core into a dedicated op-editor-ui crate.
The canvas widgets (canvas_viewport*) stay inside op-editor-ui rather
than splitting into a separate op-canvas crate: they depend on the
widgets/ siblings editor_state_ext + icons and on the Widget trait, so
a clean mechanical split is not possible — per the task's explicit
allowance not to force a fragile split.
op-editor-ui's lib.rs mirrors the old shell-core crate-root re-exports
(render_backend facade types + jian gesture types + the i18n alias) so
every intra-module `crate::Color` / `crate::theme` / `crate::widgets`
path resolves unchanged — a pure relocation with no path rewrites
inside the moved modules. openpencil-shell-core becomes a thin
re-export shim (`pub use op_editor_ui::{widgets, theme, ...}`) so the
hosts keep resolving `openpencil_shell_core::*` until the Task 7.3
host rename dissolves the crate. The widgets_static integration test
moves to op-editor-ui/tests with its imports rewritten. The widget
boundary script's reverse-check path is updated to the new crate.
No behaviour change; all tests move with their code.
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
|
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);
|
|
|
|
|
}
|
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.
|
2026-05-10 10:47:33 +00:00
|
|
|
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
|
|
|
|
|
);
|
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);
|
|
|
|
|
}
|
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");
|
|
|
|
|
}
|