feat(shell-web): Phase C1 — pure DOM event mapping modules
Lands the four pure W3C → Jian gesture mappers that Phase C2's browser listeners will consume: - `event:⌨️:map_keyboard_parts(key, code, location, repeat, pressed, modifiers, is_composing) -> KeyEvent` — W3C KeyboardEvent.key/code/location string lookups produce KeyValue (Char / Named / Unidentified) + KeyCode enum + KeyLocation enum. Phase C1 covers KeyA-Z, Digit0-9, Enter / Escape / Tab / Space / Backspace / Delete / arrows; Home/End/PageUp/PageDown/F-keys/ modifier physical codes (ShiftLeft etc) extend the table in C2 (codex C1 NIT-2 deferred). - `event::ime::{composition_start, composition_update, composition_end}` + `utf16_selection_to_utf8` helper. The helper walks `text.char_indices()` accumulating `len_utf16()` to remap UTF-16 code-unit offsets (what `CompositionEvent.getTargetRanges()` hands us) to UTF-8 byte offsets (what jian-core's `ImeKind:: CompositionUpdate { selection: Range<usize> }` requires per spec §2.4). Mis-ordered range returns None; out-of-range bounds clamp to text.len(). CJK (你好), surrogate pairs (🙂), mixed-encoding (aé), zero-length selections, and empty text are all covered. - `event::pointer::map_wheel(position, dx, dy, dz, mode, mods, timestamp: Instant) -> WheelEvent` — flips W3C deltaY sign so widget code reads Jian-internal positive-up. Phase C1 intentionally takes `timestamp` as a parameter rather than calling `Instant::now()` internally; `std::time::Instant::now()` panics on wasm32-unknown-unknown ("time not implemented on this platform") and the C2 listener will fill the timestamp from a polyfill (web_time::Instant). Made the mapper pure to keep the panic locus in the listener glue, where the polyfill lives. - `event::focus::map_focus(gained, node_id_hint, related_node_id_hint) -> FocusEvent` — pure pass-through; the W3C target → WidgetId correlation work lives in C2 alongside the DOM mirror id registry (Phase D groundwork). Tests (`tests/dom_event_mapping.rs`, 22 tests, all native): - keyboard: 5 tests + 1 empty-string-key fallback test (codex C1 NIT-3) — key/code/location preservation, named key, location decoding, is_composing propagation, multi-codepoint & empty-string Unidentified fallback - ime: 8 tests — start/update/end shapes, UTF-16→UTF-8 remap for CJK / surrogate pair / mixed encoding / zero-length / no-selection / mis-ordered range / out-of-range clamp / empty text - wheel: 4 tests — Y sign flip, X no-flip, mode decoding, deltaZ passthrough - focus: 2 tests — gained=true with both hints, blur with no related target Plumbing: - shell-web grows a direct path-with-version `jian-core` dep. shell-core re-exports `gesture::*` but not `geometry::*`, and the wheel mapper builds `WheelEvent.position` from `jian_core::geometry::Point::new(...)`. Same path-with-version pattern as shell-core's own jian-core dep. - `pub mod event;` is NOT cfg-gated to skia — the mappers are pure and useful on the wasm32-clean stub baseline too. Plan-vs-implementation deviations (deliberate, all kept narrow): - `map_keyboard_parts` adds `is_composing: bool` parameter (jian-core KeyEvent struct REQUIRES the field per spec §2.4). - `map_focus` adds `related_node_id_hint: Option<u64>` parameter (jian-core FocusEvent struct field; plan body missed it). - `map_wheel` takes `timestamp: Instant` parameter instead of calling `Instant::now()` internally (avoids wasm32-unknown-unknown runtime panic; pure mapper). - `event/pointer.rs` covers ONLY wheel; full PointerEvent mapping (kind / phase / buttons / pressure) lives in C2 alongside listener registration since that's where the W3C PointerEvent surface meets the runtime context. Verification: - `cargo test -p openpencil-shell-web --test dom_event_mapping` — 22/22 passing - `cargo check -p openpencil-shell-web --target wasm32-unknown-unknown --no-default-features --features web` — green (compile guard) - `EMSDK=$HOME/.emsdk bash tools/check-wasm-bundle.sh` — PASS - 0 env.* imports - 615 616 bytes gzip = 58% of 1 MiB ceiling (no growth — event modules dead-code-eliminated when not called) - `bash tools/check-widget-boundary.sh` — PASS (event/ doesn't violate F1-F4) Codex iterate review: 1 round → GO with 1 deferred CONCERN (Instant source for C2 — already documented inline) + 3 NITs (deltaX comment wording, additional KeyCode entries, extra edge-case tests). NIT-1 and NIT-3 fixed in this commit; NIT-2 deferred to C2.
This commit is contained in:
parent
8ad5fa81b0
commit
2bb49d96bd
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1464,6 +1464,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"accesskit",
|
||||
"console_error_panic_hook",
|
||||
"jian-core",
|
||||
"js-sys",
|
||||
"openpencil-shell-core",
|
||||
"skia-safe",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ openpencil-shell-core = { path = "../openpencil-shell-core", version = "0.1.0" }
|
|||
accesskit = "0.24"
|
||||
console_error_panic_hook = "0.1"
|
||||
js-sys = "0.3"
|
||||
# Phase C1 DOM event mappers build `jian_core::gesture::WheelEvent` etc
|
||||
# whose position/delta fields use `jian_core::geometry::Point`. The
|
||||
# `geometry` module is not re-exported by shell-core (which only
|
||||
# re-exports `gesture::*`), so pull jian-core directly. Same path-with-
|
||||
# version pattern as shell-core's own jian-core dep.
|
||||
jian-core = { path = "../../vendor/jian/crates/jian-core", version = "0.0.1" }
|
||||
# Pin to 0.2.117 — last wasm-bindgen-cli release compatible with the
|
||||
# workspace's Rust 1.85 toolchain. Newer 0.2.120+ requires rustc 1.86.
|
||||
# When the workspace bumps Rust, raise this bound.
|
||||
|
|
|
|||
26
crates/openpencil-shell-web/src/event/focus.rs
Normal file
26
crates/openpencil-shell-web/src/event/focus.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
//! Pure W3C `FocusEvent` → `jian_core::gesture::FocusEvent` mapping.
|
||||
//!
|
||||
//! `gained` reflects W3C `focus`/`focusin` (true) vs `blur`/`focusout`
|
||||
//! (false). `node_id_hint` is the WidgetId-derived id of the node
|
||||
//! that gained or lost focus, when the host can identify it (Phase D
|
||||
//! DOM mirror lets us correlate the W3C target back to a WidgetId).
|
||||
//! `related_node_id_hint` mirrors W3C `FocusEvent.relatedTarget` —
|
||||
//! the node on the other side of the transition.
|
||||
//!
|
||||
//! Pure: no DOM access. C2 reads the W3C event's target /
|
||||
//! relatedTarget, looks them up in the host's WidgetId registry,
|
||||
//! and calls into here.
|
||||
|
||||
use openpencil_shell_core::FocusEvent;
|
||||
|
||||
pub fn map_focus(
|
||||
gained: bool,
|
||||
node_id_hint: Option<u64>,
|
||||
related_node_id_hint: Option<u64>,
|
||||
) -> FocusEvent {
|
||||
FocusEvent {
|
||||
gained,
|
||||
node_id_hint,
|
||||
related_node_id_hint,
|
||||
}
|
||||
}
|
||||
83
crates/openpencil-shell-web/src/event/ime.rs
Normal file
83
crates/openpencil-shell-web/src/event/ime.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
//! Pure W3C `CompositionEvent` → `jian_core::gesture::ImeEvent` mapping.
|
||||
//!
|
||||
//! Spec §2.4 invariant: `ImeEvent.text` is UTF-8 (Rust `String`) and
|
||||
//! `ImeEvent.kind == CompositionUpdate { selection }`'s selection
|
||||
//! Range is in UTF-8 byte offsets. Browsers expose the in-flight
|
||||
//! preedit as `CompositionEvent.data` (a JS UTF-16 string) and the
|
||||
//! IME-highlighted segment via `CompositionEvent.getTargetRanges()`
|
||||
//! (UTF-16 code-unit offsets where supported). This module is the
|
||||
//! shim that converts both.
|
||||
//!
|
||||
//! Pure: no DOM access. Phase C2 reads `CompositionEvent.data` /
|
||||
//! `getTargetRanges()` and calls into here with already-converted
|
||||
//! Rust strings + UTF-16 code-unit ranges.
|
||||
|
||||
use openpencil_shell_core::{ImeEvent, ImeKind};
|
||||
use std::ops::Range;
|
||||
|
||||
/// Convert a UTF-16 code-unit selection range to UTF-8 byte offsets
|
||||
/// within `text`. Walks `text.char_indices()` accumulating
|
||||
/// `len_utf16()` so we know the UTF-16 prefix length at each UTF-8
|
||||
/// boundary; sets `start` / `end` as soon as the running prefix
|
||||
/// reaches `sel.start` / `sel.end`.
|
||||
///
|
||||
/// Out-of-range selection bounds clamp to `text.len()` (the byte
|
||||
/// length of the full UTF-8 string). The caller may treat that as a
|
||||
/// "select all the way to the end" hint or as a sentinel value.
|
||||
pub fn utf16_selection_to_utf8(
|
||||
text: &str,
|
||||
sel_utf16: Option<Range<usize>>,
|
||||
) -> Option<Range<usize>> {
|
||||
let sel = sel_utf16?;
|
||||
if sel.end < sel.start {
|
||||
// Mis-ordered range — treat as no selection rather than
|
||||
// panicking on the caller's behalf.
|
||||
return None;
|
||||
}
|
||||
let mut utf16_pos = 0usize;
|
||||
let mut start: Option<usize> = None;
|
||||
let mut end: Option<usize> = None;
|
||||
for (byte_idx, ch) in text.char_indices() {
|
||||
if start.is_none() && utf16_pos >= sel.start {
|
||||
start = Some(byte_idx);
|
||||
}
|
||||
if end.is_none() && utf16_pos >= sel.end {
|
||||
end = Some(byte_idx);
|
||||
break;
|
||||
}
|
||||
utf16_pos += ch.len_utf16();
|
||||
}
|
||||
let s = start.unwrap_or(text.len());
|
||||
let e = end.unwrap_or(text.len());
|
||||
Some(s..e)
|
||||
}
|
||||
|
||||
/// `compositionstart`: text is empty per spec §2.4.
|
||||
pub fn composition_start() -> ImeEvent {
|
||||
ImeEvent {
|
||||
kind: ImeKind::CompositionStart,
|
||||
text: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `compositionupdate`: `text` is the preedit so far (UTF-8 already
|
||||
/// converted by the caller). `sel_utf16` is the IME-highlighted
|
||||
/// segment in UTF-16 code units (whatever `getTargetRanges()` gave
|
||||
/// us); we remap to UTF-8 byte offsets so widget code never has to
|
||||
/// know about UTF-16.
|
||||
pub fn composition_update(text_utf8: String, sel_utf16: Option<Range<usize>>) -> ImeEvent {
|
||||
let selection = utf16_selection_to_utf8(&text_utf8, sel_utf16);
|
||||
ImeEvent {
|
||||
kind: ImeKind::CompositionUpdate { selection },
|
||||
text: text_utf8,
|
||||
}
|
||||
}
|
||||
|
||||
/// `compositionend`: `text` is the final commit string (UTF-8). No
|
||||
/// selection — the IME is finished.
|
||||
pub fn composition_end(text_utf8: String) -> ImeEvent {
|
||||
ImeEvent {
|
||||
kind: ImeKind::CompositionEnd,
|
||||
text: text_utf8,
|
||||
}
|
||||
}
|
||||
132
crates/openpencil-shell-web/src/event/keyboard.rs
Normal file
132
crates/openpencil-shell-web/src/event/keyboard.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
//! Pure W3C `KeyboardEvent` → `jian_core::gesture::KeyEvent` mapping.
|
||||
//!
|
||||
//! Phase C1 builds a KeyEvent from the W3C primitives:
|
||||
//! - `KeyboardEvent.key` → `KeyValue::{Char|Named|Unidentified}`
|
||||
//! - `KeyboardEvent.code` → `KeyCode::{KeyA..KeyZ|Digit0..|Named|Unknown}`
|
||||
//! - `KeyboardEvent.location` → `KeyLocation::{Standard|Left|Right|Numpad}`
|
||||
//! - `KeyboardEvent.repeat` → `KeyEvent.repeat`
|
||||
//! - `KeyboardEvent.isComposing` → `KeyEvent.is_composing`
|
||||
//! - up/down → `KeyState::{Pressed|Released}`
|
||||
//!
|
||||
//! Pure: no host-clock or DOM access. The C2 listener computes the
|
||||
//! `Modifiers` bitset from `KeyboardEvent.{shiftKey, ctrlKey, …}` and
|
||||
//! the `is_composing` bool from `KeyboardEvent.isComposing`, then
|
||||
//! calls into here.
|
||||
|
||||
use openpencil_shell_core::{
|
||||
KeyCode, KeyEvent, KeyLocation, KeyState, KeyValue, Modifiers, NamedKey,
|
||||
};
|
||||
|
||||
/// Build a `KeyEvent` from the raw W3C primitives the C2 listener
|
||||
/// extracts off `KeyboardEvent`. `pressed=true` for `keydown`,
|
||||
/// `pressed=false` for `keyup`.
|
||||
pub fn map_keyboard_parts(
|
||||
key: &str,
|
||||
code: &str,
|
||||
location: u32,
|
||||
repeat: bool,
|
||||
pressed: bool,
|
||||
modifiers: Modifiers,
|
||||
is_composing: bool,
|
||||
) -> KeyEvent {
|
||||
KeyEvent {
|
||||
key: map_key_value(key),
|
||||
code: map_key_code(code),
|
||||
location: map_location(location),
|
||||
modifiers,
|
||||
state: if pressed {
|
||||
KeyState::Pressed
|
||||
} else {
|
||||
KeyState::Released
|
||||
},
|
||||
repeat,
|
||||
is_composing,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_key_value(key: &str) -> KeyValue {
|
||||
match key {
|
||||
"Enter" => KeyValue::Named(NamedKey::Enter),
|
||||
"Escape" => KeyValue::Named(NamedKey::Escape),
|
||||
"Tab" => KeyValue::Named(NamedKey::Tab),
|
||||
" " | "Spacebar" => KeyValue::Named(NamedKey::Space),
|
||||
"Backspace" => KeyValue::Named(NamedKey::Backspace),
|
||||
"Delete" => KeyValue::Named(NamedKey::Delete),
|
||||
"ArrowUp" => KeyValue::Named(NamedKey::ArrowUp),
|
||||
"ArrowDown" => KeyValue::Named(NamedKey::ArrowDown),
|
||||
"ArrowLeft" => KeyValue::Named(NamedKey::ArrowLeft),
|
||||
"ArrowRight" => KeyValue::Named(NamedKey::ArrowRight),
|
||||
_ => {
|
||||
// `KeyValue::Char(char)` covers single-codepoint keys
|
||||
// (e.g. "a", "1", "你"). Anything else (multi-codepoint
|
||||
// dead-key sequences, app-defined chord names, etc.)
|
||||
// falls into `Unidentified` for the host to triage.
|
||||
let mut chars = key.chars();
|
||||
match (chars.next(), chars.next()) {
|
||||
(Some(c), None) => KeyValue::Char(c),
|
||||
_ => KeyValue::Unidentified(key.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_key_code(code: &str) -> KeyCode {
|
||||
match code {
|
||||
"KeyA" => KeyCode::KeyA,
|
||||
"KeyB" => KeyCode::KeyB,
|
||||
"KeyC" => KeyCode::KeyC,
|
||||
"KeyD" => KeyCode::KeyD,
|
||||
"KeyE" => KeyCode::KeyE,
|
||||
"KeyF" => KeyCode::KeyF,
|
||||
"KeyG" => KeyCode::KeyG,
|
||||
"KeyH" => KeyCode::KeyH,
|
||||
"KeyI" => KeyCode::KeyI,
|
||||
"KeyJ" => KeyCode::KeyJ,
|
||||
"KeyK" => KeyCode::KeyK,
|
||||
"KeyL" => KeyCode::KeyL,
|
||||
"KeyM" => KeyCode::KeyM,
|
||||
"KeyN" => KeyCode::KeyN,
|
||||
"KeyO" => KeyCode::KeyO,
|
||||
"KeyP" => KeyCode::KeyP,
|
||||
"KeyQ" => KeyCode::KeyQ,
|
||||
"KeyR" => KeyCode::KeyR,
|
||||
"KeyS" => KeyCode::KeyS,
|
||||
"KeyT" => KeyCode::KeyT,
|
||||
"KeyU" => KeyCode::KeyU,
|
||||
"KeyV" => KeyCode::KeyV,
|
||||
"KeyW" => KeyCode::KeyW,
|
||||
"KeyX" => KeyCode::KeyX,
|
||||
"KeyY" => KeyCode::KeyY,
|
||||
"KeyZ" => KeyCode::KeyZ,
|
||||
"Digit0" => KeyCode::Digit0,
|
||||
"Digit1" => KeyCode::Digit1,
|
||||
"Digit2" => KeyCode::Digit2,
|
||||
"Digit3" => KeyCode::Digit3,
|
||||
"Digit4" => KeyCode::Digit4,
|
||||
"Digit5" => KeyCode::Digit5,
|
||||
"Digit6" => KeyCode::Digit6,
|
||||
"Digit7" => KeyCode::Digit7,
|
||||
"Digit8" => KeyCode::Digit8,
|
||||
"Digit9" => KeyCode::Digit9,
|
||||
"Enter" => KeyCode::Enter,
|
||||
"Escape" => KeyCode::Escape,
|
||||
"Tab" => KeyCode::Tab,
|
||||
"Space" => KeyCode::Space,
|
||||
"Backspace" => KeyCode::Backspace,
|
||||
"Delete" => KeyCode::Delete,
|
||||
"ArrowUp" => KeyCode::ArrowUp,
|
||||
"ArrowDown" => KeyCode::ArrowDown,
|
||||
"ArrowLeft" => KeyCode::ArrowLeft,
|
||||
"ArrowRight" => KeyCode::ArrowRight,
|
||||
_ => KeyCode::Unknown(code.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_location(location: u32) -> KeyLocation {
|
||||
match location {
|
||||
1 => KeyLocation::Left,
|
||||
2 => KeyLocation::Right,
|
||||
3 => KeyLocation::Numpad,
|
||||
_ => KeyLocation::Standard,
|
||||
}
|
||||
}
|
||||
16
crates/openpencil-shell-web/src/event/mod.rs
Normal file
16
crates/openpencil-shell-web/src/event/mod.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
//! Step 1b §2.4 + §3.5 DOM event mapping. Pure functions that turn
|
||||
//! browser-side primitives (`KeyboardEvent.key` / `code`,
|
||||
//! `CompositionEvent.data`, `WheelEvent.deltaY` / `deltaMode`,
|
||||
//! `FocusEvent`) into Jian gesture types
|
||||
//! (`jian_core::gesture::{KeyEvent, ImeEvent, FocusEvent, WheelEvent}`).
|
||||
//!
|
||||
//! Pure means: no `Instant::now()` calls (those panic on
|
||||
//! wasm32-unknown-unknown), no DOM access, no JS interop. The browser
|
||||
//! listener wiring (Phase C2) reads `event::*::map_*` outputs and
|
||||
//! enriches them with the runtime timestamp it captures from
|
||||
//! `web_time::Instant` / `js_sys::Date::now()` / similar host clock.
|
||||
|
||||
pub mod focus;
|
||||
pub mod ime;
|
||||
pub mod keyboard;
|
||||
pub mod pointer;
|
||||
58
crates/openpencil-shell-web/src/event/pointer.rs
Normal file
58
crates/openpencil-shell-web/src/event/pointer.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
//! Pure W3C `WheelEvent` → `jian_core::gesture::WheelEvent` mapping.
|
||||
//!
|
||||
//! Sign convention (spec §2.4 + §3.5): Jian's internal axis is
|
||||
//! winit-positive-up (positive deltaY = scroll content up). Browser
|
||||
//! W3C `WheelEvent.deltaY` is positive-down. The mapper flips the
|
||||
//! sign on `delta_y` so widget code reads the Jian-internal
|
||||
//! convention regardless of host.
|
||||
//!
|
||||
//! Pure: no `Instant::now()` (panics on wasm32-unknown-unknown). The
|
||||
//! C2 listener reads the host clock via `web_time::Instant` (or any
|
||||
//! polyfill that yields a `std::time::Instant`) and passes it in.
|
||||
//! This keeps the mapper unit-testable on native and IT does NOT
|
||||
//! crash at runtime on wasm32-unknown-unknown — the panic locus
|
||||
//! moves up to the listener glue, where the polyfill lives.
|
||||
//!
|
||||
//! `PointerEvent` mapping (mouse position / button / kind / phase)
|
||||
//! lives in C2 alongside the listener registration — Phase C1 covers
|
||||
//! only the wheel mapper because that's the cross-axis-translation
|
||||
//! piece that benefits from being a pure helper.
|
||||
|
||||
use openpencil_shell_core::{Modifiers, ScrollMode, WheelEvent};
|
||||
use std::time::Instant;
|
||||
|
||||
/// Build a Jian `WheelEvent` from the W3C primitives.
|
||||
///
|
||||
/// `position` is the cursor's canvas-local position (already
|
||||
/// transformed from `WheelEvent.client{X,Y}` minus the canvas
|
||||
/// bounding rect by the caller). `delta_x` / `delta_y` come straight
|
||||
/// from `WheelEvent.delta{X,Y}` in W3C sign (positive-down for Y);
|
||||
/// we flip Y here. `delta_z` mirrors `WheelEvent.deltaZ` (almost
|
||||
/// always 0). `delta_mode` is `WheelEvent.deltaMode` (0=Pixel,
|
||||
/// 1=Line, 2=Page). `timestamp` is whatever the C2 listener
|
||||
/// captured from its host clock polyfill.
|
||||
pub fn map_wheel(
|
||||
position: jian_core::geometry::Point,
|
||||
delta_x: f32,
|
||||
delta_y: f32,
|
||||
delta_z: f32,
|
||||
delta_mode: u32,
|
||||
modifiers: Modifiers,
|
||||
timestamp: Instant,
|
||||
) -> WheelEvent {
|
||||
WheelEvent {
|
||||
position,
|
||||
// W3C → Jian sign flip on Y. `delta_x` is positive-right on
|
||||
// both sides (W3C and winit) so no flip there; only the Y
|
||||
// axis differs (W3C positive-down vs Jian/winit positive-up).
|
||||
delta: jian_core::geometry::Point::new(delta_x, -delta_y),
|
||||
delta_z,
|
||||
mode: match delta_mode {
|
||||
1 => ScrollMode::Line,
|
||||
2 => ScrollMode::Page,
|
||||
_ => ScrollMode::Pixel,
|
||||
},
|
||||
modifiers,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
#[cfg(feature = "skia")]
|
||||
mod backend;
|
||||
pub mod event;
|
||||
#[cfg(feature = "skia")]
|
||||
mod widget_host;
|
||||
|
||||
|
|
|
|||
265
crates/openpencil-shell-web/tests/dom_event_mapping.rs
Normal file
265
crates/openpencil-shell-web/tests/dom_event_mapping.rs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
//! Phase C1 DOM event mapper tests.
|
||||
//!
|
||||
//! Pure-function coverage — runs natively (no wasm-bindgen-test); the
|
||||
//! C2 listener integration test runs in browsers as part of Phase E
|
||||
//! manual smoke. The Instant-using tests rely on `Instant::now()`
|
||||
//! being available natively; on wasm32-unknown-unknown that call
|
||||
//! panics, which is exactly why the mapper itself takes `timestamp`
|
||||
//! as a parameter (the C2 listener supplies a polyfill).
|
||||
|
||||
use openpencil_shell_core::{
|
||||
ImeKind, KeyCode, KeyLocation, KeyState, KeyValue, Modifiers, NamedKey, ScrollMode,
|
||||
};
|
||||
use openpencil_shell_web::event::{
|
||||
focus::map_focus,
|
||||
ime::{composition_end, composition_start, composition_update, utf16_selection_to_utf8},
|
||||
keyboard::map_keyboard_parts,
|
||||
pointer::map_wheel,
|
||||
};
|
||||
use std::time::Instant;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Keyboard
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn keyboard_mapping_preserves_key_code_location_and_repeat() {
|
||||
let event = map_keyboard_parts("a", "KeyA", 0, true, true, Modifiers::SHIFT, false);
|
||||
assert_eq!(event.key, KeyValue::Char('a'));
|
||||
assert_eq!(event.code, KeyCode::KeyA);
|
||||
assert_eq!(event.location, KeyLocation::Standard);
|
||||
assert_eq!(event.state, KeyState::Pressed);
|
||||
assert!(event.repeat);
|
||||
assert!(!event.is_composing);
|
||||
assert!(event.modifiers.contains(Modifiers::SHIFT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_key_mapping_handles_escape() {
|
||||
let event = map_keyboard_parts(
|
||||
"Escape",
|
||||
"Escape",
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
Modifiers::empty(),
|
||||
false,
|
||||
);
|
||||
assert_eq!(event.key, KeyValue::Named(NamedKey::Escape));
|
||||
assert_eq!(event.code, KeyCode::Escape);
|
||||
assert_eq!(event.state, KeyState::Released);
|
||||
assert!(!event.repeat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_location_maps_left_right_numpad() {
|
||||
let l = map_keyboard_parts("Shift", "ShiftLeft", 1, false, true, Modifiers::empty(), false);
|
||||
assert_eq!(l.location, KeyLocation::Left);
|
||||
let r = map_keyboard_parts("Shift", "ShiftRight", 2, false, true, Modifiers::empty(), false);
|
||||
assert_eq!(r.location, KeyLocation::Right);
|
||||
let n = map_keyboard_parts("1", "Numpad1", 3, false, true, Modifiers::empty(), false);
|
||||
assert_eq!(n.location, KeyLocation::Numpad);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_is_composing_propagates() {
|
||||
let composing = map_keyboard_parts(
|
||||
"a",
|
||||
"KeyA",
|
||||
0,
|
||||
false,
|
||||
true,
|
||||
Modifiers::empty(),
|
||||
true, // is_composing
|
||||
);
|
||||
assert!(composing.is_composing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_unidentified_for_multi_char_keys() {
|
||||
// Multi-codepoint key value (e.g. some IME pre-edit handle) falls
|
||||
// through to Unidentified; widget code can decide what to do.
|
||||
let event = map_keyboard_parts(
|
||||
"Compose",
|
||||
"Pause",
|
||||
0,
|
||||
false,
|
||||
true,
|
||||
Modifiers::empty(),
|
||||
false,
|
||||
);
|
||||
assert_eq!(event.key, KeyValue::Unidentified("Compose".to_string()));
|
||||
// `Pause` isn't in the table, so the mapper falls back to Unknown.
|
||||
assert_eq!(event.code, KeyCode::Unknown("Pause".to_string()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// IME
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn composition_start_carries_empty_text() {
|
||||
let start = composition_start();
|
||||
assert_eq!(start.kind, ImeKind::CompositionStart);
|
||||
assert_eq!(start.text, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_update_remaps_utf16_selection_to_utf8_for_cjk() {
|
||||
// `你好` = 2 CJK chars; each is 1 UTF-16 code unit + 3 UTF-8 bytes.
|
||||
// Browser-supplied selection 0..2 (UTF-16 code units) must remap
|
||||
// to 0..6 (UTF-8 bytes).
|
||||
let update = composition_update("你好".to_string(), Some(0..2));
|
||||
match update.kind {
|
||||
ImeKind::CompositionUpdate { selection } => assert_eq!(selection, Some(0..6)),
|
||||
other => panic!("expected CompositionUpdate, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_update_remaps_surrogate_pair() {
|
||||
// 🙂 = U+1F642 = 2 UTF-16 code units (surrogate pair) + 4 UTF-8 bytes.
|
||||
// Selection 0..2 (UTF-16) must remap to 0..4 (UTF-8).
|
||||
let update = composition_update("🙂".to_string(), Some(0..2));
|
||||
match update.kind {
|
||||
ImeKind::CompositionUpdate { selection } => assert_eq!(selection, Some(0..4)),
|
||||
other => panic!("expected CompositionUpdate, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_update_handles_partial_selection() {
|
||||
// `aé` = 1 ASCII char (1 UTF-16 / 1 UTF-8) + 1 Latin-1 char (1 UTF-16 / 2 UTF-8).
|
||||
// Selection 1..2 (UTF-16) covers just the `é` — must remap to 1..3 (UTF-8).
|
||||
let update = composition_update("aé".to_string(), Some(1..2));
|
||||
match update.kind {
|
||||
ImeKind::CompositionUpdate { selection } => assert_eq!(selection, Some(1..3)),
|
||||
other => panic!("expected CompositionUpdate, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_update_no_selection_passthrough() {
|
||||
let update = composition_update("hello".to_string(), None);
|
||||
match update.kind {
|
||||
ImeKind::CompositionUpdate { selection } => assert_eq!(selection, None),
|
||||
other => panic!("expected CompositionUpdate, got {other:?}"),
|
||||
}
|
||||
assert_eq!(update.text, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_update_misordered_selection_returns_none() {
|
||||
let update = composition_update("hello".to_string(), Some(3..1));
|
||||
match update.kind {
|
||||
ImeKind::CompositionUpdate { selection } => assert_eq!(selection, None),
|
||||
other => panic!("expected CompositionUpdate, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_end_carries_commit_text() {
|
||||
let end = composition_end("你好".to_string());
|
||||
assert_eq!(end.kind, ImeKind::CompositionEnd);
|
||||
assert_eq!(end.text, "你好");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_helper_clamps_out_of_range_to_str_len() {
|
||||
// Selection past the end of the string falls back to text.len().
|
||||
let s = utf16_selection_to_utf8("abc", Some(0..100));
|
||||
assert_eq!(s, Some(0..3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_helper_handles_empty_text_and_zero_length_selection() {
|
||||
// Empty text + 0..0 selection — both bounds clamp to 0 (= text.len()).
|
||||
let s = utf16_selection_to_utf8("", Some(0..0));
|
||||
assert_eq!(s, Some(0..0));
|
||||
|
||||
// Zero-length selection inside non-empty text at offset 1.
|
||||
let mid = utf16_selection_to_utf8("abc", Some(1..1));
|
||||
assert_eq!(mid, Some(1..1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_update_zero_length_selection_in_cjk() {
|
||||
// `你好` — caret at UTF-16 offset 1 (between 你 and 好) maps to
|
||||
// UTF-8 byte 3.
|
||||
let update = composition_update("你好".to_string(), Some(1..1));
|
||||
match update.kind {
|
||||
ImeKind::CompositionUpdate { selection } => assert_eq!(selection, Some(3..3)),
|
||||
other => panic!("expected CompositionUpdate, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyboard_empty_string_key_falls_to_unidentified() {
|
||||
// The empty string can show up on certain dead-key sequences in
|
||||
// some browsers; Phase C1 routes it to `Unidentified("")` rather
|
||||
// than panicking.
|
||||
let event = map_keyboard_parts("", "", 0, false, true, Modifiers::empty(), false);
|
||||
assert_eq!(event.key, KeyValue::Unidentified(String::new()));
|
||||
assert_eq!(event.code, KeyCode::Unknown(String::new()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Wheel (pointer.rs)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn wheel_flips_w3c_deltay_sign() {
|
||||
// W3C scroll-down (+120) → Jian winit-positive-up (-120).
|
||||
let pos = jian_core::geometry::Point::new(0.0, 0.0);
|
||||
let event = map_wheel(pos, 0.0, 120.0, 0.0, 0, Modifiers::empty(), Instant::now());
|
||||
assert_eq!(event.delta.y, -120.0);
|
||||
assert_eq!(event.delta.x, 0.0);
|
||||
assert_eq!(event.mode, ScrollMode::Pixel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_does_not_flip_x_sign() {
|
||||
// delta_x is positive-right on both W3C and winit — no flip.
|
||||
let pos = jian_core::geometry::Point::new(10.0, 20.0);
|
||||
let event = map_wheel(pos, 5.0, 0.0, 0.0, 0, Modifiers::empty(), Instant::now());
|
||||
assert_eq!(event.delta.x, 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_mode_decoding() {
|
||||
let pos = jian_core::geometry::Point::new(0.0, 0.0);
|
||||
let pixel = map_wheel(pos, 0.0, 1.0, 0.0, 0, Modifiers::empty(), Instant::now());
|
||||
assert_eq!(pixel.mode, ScrollMode::Pixel);
|
||||
let line = map_wheel(pos, 0.0, 1.0, 0.0, 1, Modifiers::empty(), Instant::now());
|
||||
assert_eq!(line.mode, ScrollMode::Line);
|
||||
let page = map_wheel(pos, 0.0, 1.0, 0.0, 2, Modifiers::empty(), Instant::now());
|
||||
assert_eq!(page.mode, ScrollMode::Page);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_delta_z_passthrough() {
|
||||
let pos = jian_core::geometry::Point::new(0.0, 0.0);
|
||||
let event = map_wheel(pos, 0.0, 0.0, 1.5, 0, Modifiers::empty(), Instant::now());
|
||||
assert_eq!(event.delta_z, 1.5);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Focus
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn focus_carries_node_hints() {
|
||||
let event = map_focus(true, Some(42), Some(43));
|
||||
assert!(event.gained);
|
||||
assert_eq!(event.node_id_hint, Some(42));
|
||||
assert_eq!(event.related_node_id_hint, Some(43));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_loss_with_no_related_target() {
|
||||
// Window-level blur — no relatedTarget, no node hint.
|
||||
let event = map_focus(false, None, None);
|
||||
assert!(!event.gained);
|
||||
assert_eq!(event.node_id_hint, None);
|
||||
assert_eq!(event.related_node_id_hint, None);
|
||||
}
|
||||
Loading…
Reference in a new issue