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:
Kayshen-X 2026-05-09 21:49:00 +08:00
parent 8ad5fa81b0
commit 2bb49d96bd
9 changed files with 588 additions and 0 deletions

1
Cargo.lock generated
View file

@ -1464,6 +1464,7 @@ version = "0.1.0"
dependencies = [
"accesskit",
"console_error_panic_hook",
"jian-core",
"js-sys",
"openpencil-shell-core",
"skia-safe",

View file

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

View 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,
}
}

View 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,
}
}

View 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,
}
}

View 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;

View 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,
}
}

View file

@ -16,6 +16,7 @@
#[cfg(feature = "skia")]
mod backend;
pub mod event;
#[cfg(feature = "skia")]
mod widget_host;

View 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);
}