openpencil/crates/openpencil-shell-core/src/widgets/text_input.rs
Kayshen-X a936d22a6f 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 21:50:00 +08:00

120 lines
3.8 KiB
Rust

//! `TextInput` — Step 1b inspector single-line text field with CJK IME
//! preview support.
//!
//! Phase B static slice: paints `state.preedit` (in-progress IME
//! composition) when present, else `state.value` (committed). The
//! preedit underline is the only visual cue; Phase C event handling
//! lands compositionstart / compositionupdate / compositionend → state
//! mutation in shell-web.
use super::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId};
use crate::{Color, Point2D, Rect, TextLayout};
#[derive(Debug, Clone, Default)]
pub struct TextInputState {
pub value: String,
pub preedit: String,
}
impl TextInputState {
/// Phase C2 IME handler: feed a `compositionstart / update / end`
/// event into the text input's preedit/value buffers. This is the
/// only stateful path Phase B widgets need before C2 wires browser
/// listeners; widget code stays platform-free.
///
/// Semantics (per spec §2.4):
/// - `CompositionStart`: clear preedit (caret enters composing mode).
/// - `CompositionUpdate`: replace preedit with the in-flight text.
/// - `CompositionEnd`: append the commit text to value, clear preedit.
///
/// `selection` is recorded by the host elsewhere (Phase D DOM mirror);
/// the static widget paint only needs the preedit string for the
/// underline branch in `TextInput::paint`.
pub fn apply_ime(&mut self, event: &crate::ImeEvent) {
match &event.kind {
crate::ImeKind::CompositionStart => {
self.preedit.clear();
}
crate::ImeKind::CompositionUpdate { selection: _ } => {
self.preedit = event.text.clone();
}
crate::ImeKind::CompositionEnd => {
self.value.push_str(&event.text);
self.preedit.clear();
}
}
}
}
pub struct TextInput {
pub id: WidgetId,
pub label: String,
pub state: TextInputState,
}
impl TextInput {
/// Sample text input. WidgetId range 400-499 is reserved for text
/// inputs by Step 1b convention.
pub fn sample() -> Self {
Self {
id: WidgetId::new(400),
label: "Name".to_string(),
state: TextInputState {
value: "Frame 1".to_string(),
preedit: String::new(),
},
}
}
}
impl Widget for TextInput {
fn id(&self) -> WidgetId {
self.id
}
fn layout(&self, cx: &LayoutCx) -> LayoutBox {
LayoutBox {
rect: Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(cx.available_width, 34.0),
},
}
}
fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect) {
cx.backend.fill_rect(rect, Color::WHITE);
cx.backend.stroke_rect(rect, Color::BLACK, 1.0);
let display = if self.state.preedit.is_empty() {
self.state.value.as_str()
} else {
self.state.preedit.as_str()
};
let text = TextLayout::single_run(
display,
"system-ui",
13.0,
jian_core::scene::Color::rgb(20, 20, 20),
Point2D::new(0.0, 0.0),
);
cx.backend
.draw_text(&text, rect.origin + Point2D::new(8.0, 21.0));
if !self.state.preedit.is_empty() {
cx.backend.stroke_rect(
Rect {
origin: Point2D::new(rect.origin.x + 8.0, rect.origin.y + 25.0),
size: Point2D::new(80.0, 1.0),
},
Color::BLACK,
1.0,
);
}
}
fn access_node(&self) -> accesskit::Node {
let mut node = accesskit::Node::new(accesskit::Role::TextInput);
node.set_label(self.label.clone());
node.set_value(self.state.value.clone());
node
}
}