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.
120 lines
3.8 KiB
Rust
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
|
|
}
|
|
}
|