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.
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.