diff --git a/Cargo.lock b/Cargo.lock index df6c60a59..24c7878c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1464,6 +1464,7 @@ version = "0.1.0" dependencies = [ "accesskit", "console_error_panic_hook", + "jian-core", "js-sys", "openpencil-shell-core", "skia-safe", diff --git a/crates/openpencil-shell-web/Cargo.toml b/crates/openpencil-shell-web/Cargo.toml index 01e5659d8..1c3494ead 100644 --- a/crates/openpencil-shell-web/Cargo.toml +++ b/crates/openpencil-shell-web/Cargo.toml @@ -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. diff --git a/crates/openpencil-shell-web/src/event/focus.rs b/crates/openpencil-shell-web/src/event/focus.rs new file mode 100644 index 000000000..576f6b478 --- /dev/null +++ b/crates/openpencil-shell-web/src/event/focus.rs @@ -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, + related_node_id_hint: Option, +) -> FocusEvent { + FocusEvent { + gained, + node_id_hint, + related_node_id_hint, + } +} diff --git a/crates/openpencil-shell-web/src/event/ime.rs b/crates/openpencil-shell-web/src/event/ime.rs new file mode 100644 index 000000000..f07dfb858 --- /dev/null +++ b/crates/openpencil-shell-web/src/event/ime.rs @@ -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>, +) -> Option> { + 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 = None; + let mut end: Option = 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>) -> 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, + } +} diff --git a/crates/openpencil-shell-web/src/event/keyboard.rs b/crates/openpencil-shell-web/src/event/keyboard.rs new file mode 100644 index 000000000..be29c0954 --- /dev/null +++ b/crates/openpencil-shell-web/src/event/keyboard.rs @@ -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, + } +} diff --git a/crates/openpencil-shell-web/src/event/mod.rs b/crates/openpencil-shell-web/src/event/mod.rs new file mode 100644 index 000000000..05913e1a0 --- /dev/null +++ b/crates/openpencil-shell-web/src/event/mod.rs @@ -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; diff --git a/crates/openpencil-shell-web/src/event/pointer.rs b/crates/openpencil-shell-web/src/event/pointer.rs new file mode 100644 index 000000000..177536518 --- /dev/null +++ b/crates/openpencil-shell-web/src/event/pointer.rs @@ -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, + } +} diff --git a/crates/openpencil-shell-web/src/lib.rs b/crates/openpencil-shell-web/src/lib.rs index a5b3b1d1e..ff591bd3a 100644 --- a/crates/openpencil-shell-web/src/lib.rs +++ b/crates/openpencil-shell-web/src/lib.rs @@ -16,6 +16,7 @@ #[cfg(feature = "skia")] mod backend; +pub mod event; #[cfg(feature = "skia")] mod widget_host; diff --git a/crates/openpencil-shell-web/tests/dom_event_mapping.rs b/crates/openpencil-shell-web/tests/dom_event_mapping.rs new file mode 100644 index 000000000..3139b3eec --- /dev/null +++ b/crates/openpencil-shell-web/tests/dom_event_mapping.rs @@ -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); +}