diff --git a/crates/openpencil-shell-core/src/event.rs b/crates/openpencil-shell-core/src/event.rs new file mode 100644 index 000000000..e176c3a7e --- /dev/null +++ b/crates/openpencil-shell-core/src/event.rs @@ -0,0 +1,226 @@ +//! `ShellEvent` — OP widget-facing primitive event enum (spec v19 §5.1). +//! +//! Per spec §1.2 (FROZEN 2026-05-04) shell-core must compile on +//! `wasm32-unknown-unknown` and remain platform-neutral on iOS / Android. +//! This module therefore declares **only OP types** — no winit, no Jian, +//! no GL — so the enum is visible everywhere widgets compile (mobile + +//! WASM included). The desktop mapper that lifts Jian `PointerEvent` into +//! `ShellEvent` lives in `openpencil-shell-native::event` (target-gated to +//! macOS / Linux / Windows; Step 1f extends to mobile). +//! +//! ## Spec invariants (§11 mobile-readiness) +//! - 6 variants: `PointerMove / PointerButton / MouseWheel / Touch / Window / Key`. +//! - `Touch` carries `TouchForce` (`Calibrated` mirrors winit::Force 1:1 +//! to avoid Step 1f mobile API break, plus `Normalized` for Android). +//! - Newtype id fields are `pub` (spec round 3 BLOCK-R3-4 fix) so callers +//! in shell-native can construct them across crate boundaries. + +use crate::render_backend::Point2D; + +/// Stable identity for a single pointer (mouse/pen/stylus/trackpad cursor). +/// +/// Widened to `u64` here so OP can ingest mappers from platforms (iOS, Web) +/// whose finger ids exceed Jian's `u32` `PointerId`. Desktop mapper widens +/// `jian_core::gesture::PointerId(u32)` → `PointerId(u64)` losslessly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PointerId(pub u64); + +/// Stable identity for a single touch/finger contact. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TouchId(pub u64); + +/// Lifecycle of a touch contact (spec §5.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TouchPhase { + /// Finger landed. + Started, + /// Finger moved while held. + Moved, + /// Finger lifted normally. + Ended, + /// System cancelled tracking (focus loss / iOS face-proximity / + /// Android system-gesture intercept). + Cancelled, +} + +/// Pressure / force for a touch contact (mirrors `winit::event::Force` +/// 1:1 to avoid Step 1f mobile API break, per spec §11.3 invariant). +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TouchForce { + /// iOS 3D Touch / Apple Pencil. `force` is the raw force value; + /// `max_possible_force` is the touch sensor's max; `altitude_angle` + /// is the Pencil tilt angle in radians (π/2 = perpendicular). + Calibrated { + force: f64, + max_possible_force: f64, + altitude_angle: Option, + }, + /// Android pressure (already normalized to [0.0, 1.0]). + Normalized(f64), +} + +/// Mouse buttons (spec §5.1; mirrors winit::event::MouseButton). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MouseButton { + Left, + Right, + Middle, + Back, + Forward, + Other(u16), +} + +/// Pressed/released state for buttons + keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ElementState { + Pressed, + Released, +} + +/// Mouse wheel / two-finger trackpad scroll delta. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ScrollDelta { + /// Discrete scroll, in lines (mouse wheel notch). + LineDelta { x: f32, y: f32 }, + /// Continuous scroll, in logical pixels (trackpad). + PixelDelta(Point2D), +} + +/// Modifier-key state at the moment an event was raised. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Modifiers { + pub shift: bool, + pub ctrl: bool, + pub alt: bool, + /// Cmd on macOS, Super/Win on Linux/Windows (mirrors Jian `Modifiers::CMD`). + pub meta: bool, +} + +/// Subset of keys OP currently surfaces (spec §5.1; expanded as widgets +/// need them in Step 1c+). Variant names follow winit::keyboard::KeyCode +/// for easy mapping. `Other(u32)` carries the raw scancode so +/// shell-native can pass through unmapped keys without losing them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum KeyCode { + // Letters + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, + Y, + Z, + // Digits + Digit0, + Digit1, + Digit2, + Digit3, + Digit4, + Digit5, + Digit6, + Digit7, + Digit8, + Digit9, + // Whitespace / control + Space, + Enter, + Tab, + Backspace, + Escape, + Delete, + // Arrows + ArrowLeft, + ArrowRight, + ArrowUp, + ArrowDown, + // Modifiers (released as standalone keys) + Shift, + Control, + Alt, + Meta, + /// Anything else — raw scancode. Step 1c+ widgets that need a + /// specific key add a named variant. + Other(u32), +} + +/// Window-level event kinds (spec §5.1; the desktop mapper synthesizes +/// these directly from winit `WindowEvent`, never via Jian). +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum WindowEventKind { + /// Window content area resized (logical pixels via `inner_size`). + Resized { width: u32, height: u32 }, + /// HiDPI scale factor changed (Retina toggle, monitor switch). + ScaleFactorChanged(f64), + /// User clicked the close button / hit Cmd-W / etc. + CloseRequested, + /// Focus gained (`true`) or lost (`false`). + Focused(bool), +} + +/// Widget-facing primitive event (spec v19 §5.1). +/// +/// Layered on top of Jian `PointerEvent` (which only carries +/// pointer/touch primitives — pos/buttons-bitset/phase). Window / Key / +/// MouseWheel events do **not** route through Jian; they come straight +/// from winit on desktop. See [`crate::event`] module docs and spec +/// §5.1.1 for the full mapping contract. +#[derive(Debug, Clone, PartialEq)] +pub enum ShellEvent { + /// Pointer moved (cursor or pen) — no button state change. + PointerMove { + id: PointerId, + pos: Point2D, + modifiers: Modifiers, + }, + /// One pointer button transitioned Pressed/Released. + /// Multi-button transitions (e.g. mid-gesture press of an additional + /// button) are emitted as one `PointerButton` per changed bit, plus + /// a trailing `PointerMove` if Jian phase was `Move` — see spec + /// §5.1.1 for the full diff contract. + PointerButton { + id: PointerId, + button: MouseButton, + state: ElementState, + pos: Point2D, + modifiers: Modifiers, + }, + /// Mouse wheel / trackpad scroll. + MouseWheel { + delta: ScrollDelta, + modifiers: Modifiers, + }, + /// Touch contact lifecycle event (mobile-ready; Step 1f). + Touch { + id: TouchId, + phase: TouchPhase, + pos: Point2D, + force: Option, + }, + /// Window-level event (resize, scale change, close, focus). + Window { kind: WindowEventKind }, + /// Keyboard event. + Key { + key: KeyCode, + state: ElementState, + modifiers: Modifiers, + }, +} diff --git a/crates/openpencil-shell-core/src/lib.rs b/crates/openpencil-shell-core/src/lib.rs index d34932fd9..f14ca0a2d 100644 --- a/crates/openpencil-shell-core/src/lib.rs +++ b/crates/openpencil-shell-core/src/lib.rs @@ -10,9 +10,19 @@ //! + geometry/scene aliases for shell-native's internal translation (widget code never sees them). //! - the [`render_backend`] module defines OP's own widget-facing facade //! (`RenderBackend` trait + `Rect` / `Color` / `TextLayout`, spec §5.2). +//! - the [`event`] module declares OP's `ShellEvent` enum + sub-types +//! (spec §5.1). Widget code consumes these on every platform; the +//! desktop Jian/winit → ShellEvent mapper lives in +//! `openpencil-shell-native::event` (target-gated to desktop today; +//! Step 1f extends to mobile). +pub mod event; pub mod jian; pub mod render_backend; // Re-export the primary API for upstream crates / widgets / tests. +pub use event::{ + ElementState, KeyCode, Modifiers, MouseButton, PointerId, ScrollDelta, ShellEvent, TouchForce, + TouchId, TouchPhase, WindowEventKind, +}; pub use render_backend::{Color, Point2D, Rect, RenderBackend, TextLayout}; diff --git a/crates/openpencil-shell-core/tests/event_shape.rs b/crates/openpencil-shell-core/tests/event_shape.rs new file mode 100644 index 000000000..3ac5f00cc --- /dev/null +++ b/crates/openpencil-shell-core/tests/event_shape.rs @@ -0,0 +1,92 @@ +//! Plan v7 Task 3 Step 12 — proves the `ShellEvent` enum shape (spec +//! §5.1) is reachable through the public re-export path and matches the +//! 6-variant invariant. Constructed via the cross-platform OP types +//! only — no Jian / winit / GL imports — so this test compiles on +//! wasm32 and mobile too (verified by `cargo check +//! --target wasm32-unknown-unknown -p openpencil-shell-core`). + +use openpencil_shell_core::event::{ + ElementState, KeyCode, Modifiers, MouseButton, PointerId, ScrollDelta, ShellEvent, TouchForce, + TouchId, TouchPhase, WindowEventKind, +}; +use openpencil_shell_core::render_backend::Point2D; + +#[test] +fn six_variants_constructible_via_re_export() { + let mods = Modifiers { + shift: true, + ctrl: false, + alt: false, + meta: false, + }; + let pos = Point2D::new(1.0, 2.0); + + let events = [ + ShellEvent::PointerMove { + id: PointerId(1), + pos, + modifiers: mods, + }, + ShellEvent::PointerButton { + id: PointerId(1), + button: MouseButton::Left, + state: ElementState::Pressed, + pos, + modifiers: mods, + }, + ShellEvent::MouseWheel { + delta: ScrollDelta::LineDelta { x: 0.0, y: 1.0 }, + modifiers: mods, + }, + ShellEvent::Touch { + id: TouchId(7), + phase: TouchPhase::Started, + pos, + force: Some(TouchForce::Normalized(0.5)), + }, + ShellEvent::Window { + kind: WindowEventKind::Resized { + width: 800, + height: 600, + }, + }, + ShellEvent::Key { + key: KeyCode::Escape, + state: ElementState::Released, + modifiers: mods, + }, + ]; + assert_eq!(events.len(), 6, "spec §5.1 declares exactly 6 variants"); +} + +#[test] +fn touch_force_calibrated_mirrors_winit() { + // Spec §11.3 invariant: `TouchForce::Calibrated` mirrors + // `winit::event::Force::Calibrated` 1:1 — fields exist with the + // declared names so a Step 1f mobile mapper compiles. + let f = TouchForce::Calibrated { + force: 0.4, + max_possible_force: 1.0, + altitude_angle: Some(std::f64::consts::FRAC_PI_2), + }; + if let TouchForce::Calibrated { + force, + max_possible_force, + altitude_angle, + } = f + { + assert_eq!(force, 0.4); + assert_eq!(max_possible_force, 1.0); + assert_eq!(altitude_angle, Some(std::f64::consts::FRAC_PI_2)); + } else { + panic!("expected Calibrated variant"); + } +} + +#[test] +fn newtype_id_fields_pub_constructible() { + // Round 3 BLOCK-R3-4 fix: `TouchId(pub u64)` + `PointerId(pub u64)` + // constructible across crates so shell-native's mapper works. + let _ = TouchId(42); + let _ = PointerId(42); +} diff --git a/crates/openpencil-shell-native/src/event/mod.rs b/crates/openpencil-shell-native/src/event/mod.rs new file mode 100644 index 000000000..f91ac269a --- /dev/null +++ b/crates/openpencil-shell-native/src/event/mod.rs @@ -0,0 +1,163 @@ +//! Desktop event mapping (spec v19 §5.1.1). +//! +//! `winit::event::WindowEvent` → +//! ([`PointerTranslator`] from `jian_host_desktop`) → +//! `jian_core::gesture::PointerEvent` → +//! ([`JianPointerMapper`]) → +//! `openpencil_shell_core::ShellEvent`. +//! +//! Window / Key / MouseWheel events do **not** go through Jian — they +//! map straight from winit. Pointer / Touch primitives go through Jian +//! so OP reuses Jian's button-set + cursor-cache state machine +//! (`jian-host-desktop/src/pointer.rs`). +//! +//! This module is **desktop-only** (target-gated in `lib.rs`). Mobile +//! pointer/touch mapping lands in Step 1f. + +use std::collections::HashMap; + +use jian_core::gesture::{ + Modifiers as JianModifiers, MouseButtons as JianMouseButtons, PointerEvent as JianPointerEvent, + PointerId as JianPointerId, PointerKind as JianPointerKind, PointerPhase as JianPointerPhase, +}; +use openpencil_shell_core::event::{ + ElementState, Modifiers, MouseButton, PointerId, ShellEvent, TouchForce, TouchId, TouchPhase, +}; +use openpencil_shell_core::render_backend::Point2D; + +/// Stateful mapper from Jian `PointerEvent` to OP `ShellEvent` (spec +/// §5.1.1). Owns a per-`PointerId` snapshot of the last-seen +/// `MouseButtons` bitset so it can diff button transitions across +/// `Down / Up / Move` phases — Jian carries the **current** bitset, not +/// added/removed deltas (see `jian-core/src/gesture/pointer.rs:48-59` +/// + `jian-host-desktop/src/pointer.rs:104-134` for why all three +/// phases must diff). +#[derive(Debug, Clone, Default)] +pub struct JianPointerMapper { + previous_buttons: HashMap, +} + +impl JianPointerMapper { + pub fn new() -> Self { + Self::default() + } + + /// Translate one Jian `PointerEvent` into zero, one, or many + /// `ShellEvent`s. Empty `Vec` = degraded input the caller should + /// ignore (no `ShellEvent::Other` variant exists; see spec §5.1). + /// + /// Multi-button transitions during `Move` produce a `PointerButton` + /// per changed bit followed by a trailing `PointerMove` (spec + /// §5.1.1 round 3 CONCERN-R3-1 fix). + pub fn from_jian_pointer(&mut self, p: &JianPointerEvent) -> Vec { + let mut out = Vec::new(); + match p.kind { + JianPointerKind::Touch => { + let phase = match p.phase { + JianPointerPhase::Down => TouchPhase::Started, + JianPointerPhase::Move => TouchPhase::Moved, + JianPointerPhase::Up => TouchPhase::Ended, + JianPointerPhase::Cancel => TouchPhase::Cancelled, + // Touches never `Hover`: return empty so callers + // ignore the event rather than fabricate a phase. + JianPointerPhase::Hover => return out, + }; + out.push(ShellEvent::Touch { + id: TouchId(u64::from(p.id.0)), + phase, + pos: jian_point_to_point2d(p.position), + force: Some(TouchForce::Normalized(f64::from(p.pressure))), + }); + } + JianPointerKind::Mouse + | JianPointerKind::Pen + | JianPointerKind::Stylus + | JianPointerKind::Trackpad => { + let prev = self + .previous_buttons + .get(&p.id) + .copied() + .unwrap_or_default(); + let added = p.buttons.difference(prev); + let removed = prev.difference(p.buttons); + self.previous_buttons.insert(p.id, p.buttons); + + let pos = jian_point_to_point2d(p.position); + let modifiers = from_jian_modifiers(p.modifiers); + let id = PointerId(u64::from(p.id.0)); + + // 1. Emit one `PointerButton{Pressed}` per added bit. + for bit in added.iter() { + if let Some(button) = mouse_buttons_bit_to_button(bit) { + out.push(ShellEvent::PointerButton { + id, + button, + state: ElementState::Pressed, + pos, + modifiers, + }); + } + } + // 2. Emit one `PointerButton{Released}` per removed bit. + for bit in removed.iter() { + if let Some(button) = mouse_buttons_bit_to_button(bit) { + out.push(ShellEvent::PointerButton { + id, + button, + state: ElementState::Released, + pos, + modifiers, + }); + } + } + // 3. Hover / Move add a trailing `PointerMove` so motion + // is preserved alongside the button transitions. + // Down / Up don't emit Move (the PointerButton event + // carries the current pos). Cancel doesn't appear + // here — Jian only raises `Cancel` on Touch, which + // was branched out above; an unexpected + // `Mouse|Pen|Stylus|Trackpad + Cancel` is treated as + // degraded and ignored. + match p.phase { + JianPointerPhase::Hover | JianPointerPhase::Move => { + out.push(ShellEvent::PointerMove { id, pos, modifiers }); + } + JianPointerPhase::Down | JianPointerPhase::Up | JianPointerPhase::Cancel => {} + } + } + } + out + } +} + +/// Convert one `JianMouseButtons` single-bit flag to OP `MouseButton`. +/// Returns `None` if the input has zero or more than one bit set — +/// callers iterate the bitset one bit at a time so `None` should never +/// happen in practice; treating it as a no-op keeps the mapper +/// degradation contract intact (empty `Vec` rather than panic). +fn mouse_buttons_bit_to_button(bit: JianMouseButtons) -> Option { + match bit { + JianMouseButtons::LEFT => Some(MouseButton::Left), + JianMouseButtons::RIGHT => Some(MouseButton::Right), + JianMouseButtons::MIDDLE => Some(MouseButton::Middle), + JianMouseButtons::BACK => Some(MouseButton::Back), + JianMouseButtons::FORWARD => Some(MouseButton::Forward), + _ => None, + } +} + +/// Convert Jian `Modifiers` bitflags to OP `Modifiers` struct. +/// Jian's `CMD` bit maps to OP's `meta` (Cmd on macOS, Super/Win +/// elsewhere — same convention as `winit::keyboard::ModifiersState::super_key`). +fn from_jian_modifiers(m: JianModifiers) -> Modifiers { + Modifiers { + shift: m.contains(JianModifiers::SHIFT), + ctrl: m.contains(JianModifiers::CTRL), + alt: m.contains(JianModifiers::ALT), + meta: m.contains(JianModifiers::CMD), + } +} + +fn jian_point_to_point2d(p: jian_core::geometry::Point) -> Point2D { + Point2D::new(p.x, p.y) +} diff --git a/crates/openpencil-shell-native/src/lib.rs b/crates/openpencil-shell-native/src/lib.rs index 29c6f4e9c..c81cf564e 100644 --- a/crates/openpencil-shell-native/src/lib.rs +++ b/crates/openpencil-shell-native/src/lib.rs @@ -42,11 +42,21 @@ pub mod context; pub mod backend; #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] pub mod canvas_view_stub; +// `event` is desktop-only too — the JianPointerMapper imports +// `jian_core::gesture::*`, which is wasm32-clean but pulls +// platform-only types (`std::time::Instant`) that mobile cargo check +// also accepts. We still cfg-gate to spec §5.1.1 (mapper body desktop +// only; mobile mapper lands in Step 1f and may use a different +// platform event source). +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] +pub mod event; #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] pub use backend::{to_jian_color, to_jian_rect, NativeBackend}; #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] pub use canvas_view_stub::CanvasViewportStub; +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] +pub use event::JianPointerMapper; // Cross-platform re-exports — visible on every (non-wasm) target. pub use context::{GlContextProvider, ProviderError, ProviderResult}; diff --git a/crates/openpencil-shell-native/tests/event_mapping.rs b/crates/openpencil-shell-native/tests/event_mapping.rs new file mode 100644 index 000000000..eec5e5187 --- /dev/null +++ b/crates/openpencil-shell-native/tests/event_mapping.rs @@ -0,0 +1,467 @@ +//! Spec v19 §5.1.1 unit tests — Jian `PointerEvent` → OP `ShellEvent` +//! mapper (`JianPointerMapper`). +//! +//! Coverage target (plan v7 Task 3 Step 11 + spec §5.1.1 fixture list): +//! - 4 Touch phases (Started / Moved / Ended / Cancelled). +//! - Mouse Hover → `[PointerMove]`; LEFT Down/Up pair → Pressed/Released. +//! - Multi-button Move (press / release mid-gesture) — diff each bit +//! then trailing PointerMove (CONCERN-R3-1). +//! - Pen / Stylus / Trackpad route through the Mouse branch. +//! - Degraded inputs (no button change on Down/Up, Touch Hover) → +//! `Vec::new()` (spec round 4 CONCERN-R4-1; no `ShellEvent::Other`). +//! - Modifiers propagate (shift/ctrl/alt/meta/CMD). + +use std::time::Instant; + +use jian_core::geometry::point; +use jian_core::gesture::{ + Modifiers as JianModifiers, MouseButtons as JianMouseButtons, PointerEvent as JianPointerEvent, + PointerId as JianPointerId, PointerKind, PointerPhase, +}; +use openpencil_shell_core::event::{ + ElementState, Modifiers, MouseButton, PointerId, ShellEvent, TouchForce, TouchId, TouchPhase, +}; +use openpencil_shell_native::JianPointerMapper; + +/// Build a `JianPointerEvent` with default tilt / pressure / timestamp, +/// overriding the fields each test cares about. +fn jian_event( + id: u32, + kind: PointerKind, + phase: PointerPhase, + buttons: JianMouseButtons, + modifiers: JianModifiers, + pos_x: f32, + pos_y: f32, +) -> JianPointerEvent { + JianPointerEvent { + id: JianPointerId(id), + kind, + phase, + position: point(pos_x, pos_y), + pressure: 1.0, + buttons, + modifiers, + tilt: None, + timestamp: Instant::now(), + } +} + +// ---------------------------------------------------------------- Touch ---- + +#[test] +fn touch_down_emits_started_phase() { + let mut mapper = JianPointerMapper::new(); + let ev = jian_event( + 7, + PointerKind::Touch, + PointerPhase::Down, + JianMouseButtons::empty(), + JianModifiers::empty(), + 10.0, + 20.0, + ); + let out = mapper.from_jian_pointer(&ev); + assert_eq!(out.len(), 1); + match &out[0] { + ShellEvent::Touch { + id, + phase, + pos, + force, + } => { + assert_eq!(*id, TouchId(7)); + assert_eq!(*phase, TouchPhase::Started); + assert_eq!(pos.x, 10.0); + assert_eq!(pos.y, 20.0); + assert_eq!(*force, Some(TouchForce::Normalized(1.0))); + } + other => panic!("expected ShellEvent::Touch, got {other:?}"), + } +} + +#[test] +fn touch_move_emits_moved_phase() { + let mut mapper = JianPointerMapper::new(); + let ev = jian_event( + 1, + PointerKind::Touch, + PointerPhase::Move, + JianMouseButtons::empty(), + JianModifiers::empty(), + 0.0, + 0.0, + ); + let out = mapper.from_jian_pointer(&ev); + assert!(matches!( + out.as_slice(), + [ShellEvent::Touch { + phase: TouchPhase::Moved, + .. + }] + )); +} + +#[test] +fn touch_up_emits_ended_phase() { + let mut mapper = JianPointerMapper::new(); + let ev = jian_event( + 1, + PointerKind::Touch, + PointerPhase::Up, + JianMouseButtons::empty(), + JianModifiers::empty(), + 0.0, + 0.0, + ); + let out = mapper.from_jian_pointer(&ev); + assert!(matches!( + out.as_slice(), + [ShellEvent::Touch { + phase: TouchPhase::Ended, + .. + }] + )); +} + +#[test] +fn touch_cancel_emits_cancelled_phase() { + let mut mapper = JianPointerMapper::new(); + let ev = jian_event( + 1, + PointerKind::Touch, + PointerPhase::Cancel, + JianMouseButtons::empty(), + JianModifiers::empty(), + 0.0, + 0.0, + ); + let out = mapper.from_jian_pointer(&ev); + assert!(matches!( + out.as_slice(), + [ShellEvent::Touch { + phase: TouchPhase::Cancelled, + .. + }] + )); +} + +#[test] +fn touch_hover_returns_empty_vec() { + // Touches never `Hover`; mapper drops the event so callers don't + // synthesize a fake `Moved` phase. + let mut mapper = JianPointerMapper::new(); + let ev = jian_event( + 1, + PointerKind::Touch, + PointerPhase::Hover, + JianMouseButtons::empty(), + JianModifiers::empty(), + 0.0, + 0.0, + ); + let out = mapper.from_jian_pointer(&ev); + assert!(out.is_empty(), "expected empty Vec for touch Hover"); +} + +// ---------------------------------------------------------------- Mouse ---- + +#[test] +fn mouse_hover_emits_pointer_move() { + let mut mapper = JianPointerMapper::new(); + let ev = jian_event( + 3, + PointerKind::Mouse, + PointerPhase::Hover, + JianMouseButtons::empty(), + JianModifiers::empty(), + 100.0, + 50.0, + ); + let out = mapper.from_jian_pointer(&ev); + assert_eq!(out.len(), 1); + match &out[0] { + ShellEvent::PointerMove { id, pos, modifiers } => { + assert_eq!(*id, PointerId(3)); + assert_eq!(pos.x, 100.0); + assert_eq!(pos.y, 50.0); + assert_eq!(*modifiers, Modifiers::default()); + } + other => panic!("expected PointerMove, got {other:?}"), + } +} + +#[test] +fn mouse_left_down_then_up() { + let mut mapper = JianPointerMapper::new(); + + // Down: previous = empty, current = LEFT → emit Pressed. + let down = jian_event( + 9, + PointerKind::Mouse, + PointerPhase::Down, + JianMouseButtons::LEFT, + JianModifiers::empty(), + 0.0, + 0.0, + ); + let out = mapper.from_jian_pointer(&down); + assert_eq!(out.len(), 1); + match &out[0] { + ShellEvent::PointerButton { button, state, .. } => { + assert_eq!(*button, MouseButton::Left); + assert_eq!(*state, ElementState::Pressed); + } + other => panic!("expected PointerButton{{Pressed}}, got {other:?}"), + } + + // Up: previous = LEFT, current = empty → emit Released. + let up = jian_event( + 9, + PointerKind::Mouse, + PointerPhase::Up, + JianMouseButtons::empty(), + JianModifiers::empty(), + 0.0, + 0.0, + ); + let out = mapper.from_jian_pointer(&up); + assert_eq!(out.len(), 1); + match &out[0] { + ShellEvent::PointerButton { button, state, .. } => { + assert_eq!(*button, MouseButton::Left); + assert_eq!(*state, ElementState::Released); + } + other => panic!("expected PointerButton{{Released}}, got {other:?}"), + } +} + +#[test] +fn multi_button_press_during_move() { + // CONCERN-R3-1 fixture (a): LEFT held + Move with LEFT|RIGHT → + // [PointerButton{RIGHT, Pressed}, PointerMove]. + let mut mapper = JianPointerMapper::new(); + + // Prime previous = LEFT via a Down. + let prime = jian_event( + 4, + PointerKind::Mouse, + PointerPhase::Down, + JianMouseButtons::LEFT, + JianModifiers::empty(), + 0.0, + 0.0, + ); + let _ = mapper.from_jian_pointer(&prime); + + // Now Move with LEFT|RIGHT. + let mid = jian_event( + 4, + PointerKind::Mouse, + PointerPhase::Move, + JianMouseButtons::LEFT | JianMouseButtons::RIGHT, + JianModifiers::empty(), + 5.0, + 7.0, + ); + let out = mapper.from_jian_pointer(&mid); + assert_eq!(out.len(), 2, "expected button Pressed + PointerMove"); + match &out[0] { + ShellEvent::PointerButton { button, state, .. } => { + assert_eq!(*button, MouseButton::Right); + assert_eq!(*state, ElementState::Pressed); + } + other => panic!("expected PointerButton{{Right,Pressed}}, got {other:?}"), + } + assert!(matches!(out[1], ShellEvent::PointerMove { .. })); +} + +#[test] +fn multi_button_release_during_move() { + // CONCERN-R3-1 fixture (b): LEFT|RIGHT held + Move with LEFT only → + // [PointerButton{RIGHT, Released}, PointerMove]. + let mut mapper = JianPointerMapper::new(); + + // Prime previous = LEFT|RIGHT via two Downs. + let _ = mapper.from_jian_pointer(&jian_event( + 2, + PointerKind::Mouse, + PointerPhase::Down, + JianMouseButtons::LEFT, + JianModifiers::empty(), + 0.0, + 0.0, + )); + let _ = mapper.from_jian_pointer(&jian_event( + 2, + PointerKind::Mouse, + PointerPhase::Move, + JianMouseButtons::LEFT | JianMouseButtons::RIGHT, + JianModifiers::empty(), + 0.0, + 0.0, + )); + + let release = jian_event( + 2, + PointerKind::Mouse, + PointerPhase::Move, + JianMouseButtons::LEFT, + JianModifiers::empty(), + 9.0, + 9.0, + ); + let out = mapper.from_jian_pointer(&release); + assert_eq!(out.len(), 2, "expected button Released + PointerMove"); + match &out[0] { + ShellEvent::PointerButton { button, state, .. } => { + assert_eq!(*button, MouseButton::Right); + assert_eq!(*state, ElementState::Released); + } + other => panic!("expected PointerButton{{Right,Released}}, got {other:?}"), + } + assert!(matches!(out[1], ShellEvent::PointerMove { .. })); +} + +// ---------------------------------------------------- Pen / Stylus / Trackpad + +#[test] +fn pen_phase_routes_through_mouse_branch() { + let mut mapper = JianPointerMapper::new(); + let ev = jian_event( + 11, + PointerKind::Pen, + PointerPhase::Down, + JianMouseButtons::LEFT, + JianModifiers::empty(), + 0.0, + 0.0, + ); + let out = mapper.from_jian_pointer(&ev); + assert_eq!(out.len(), 1); + assert!(matches!( + out[0], + ShellEvent::PointerButton { + button: MouseButton::Left, + state: ElementState::Pressed, + .. + } + )); +} + +#[test] +fn stylus_phase_routes_through_mouse_branch() { + let mut mapper = JianPointerMapper::new(); + let ev = jian_event( + 12, + PointerKind::Stylus, + PointerPhase::Down, + JianMouseButtons::LEFT, + JianModifiers::empty(), + 0.0, + 0.0, + ); + let out = mapper.from_jian_pointer(&ev); + assert!(matches!( + out.as_slice(), + [ShellEvent::PointerButton { + button: MouseButton::Left, + state: ElementState::Pressed, + .. + }] + )); +} + +#[test] +fn trackpad_phase_routes_through_mouse_branch() { + let mut mapper = JianPointerMapper::new(); + let ev = jian_event( + 13, + PointerKind::Trackpad, + PointerPhase::Move, + JianMouseButtons::empty(), + JianModifiers::empty(), + 1.0, + 2.0, + ); + let out = mapper.from_jian_pointer(&ev); + // No buttons changed → only a PointerMove. + assert!(matches!(out.as_slice(), [ShellEvent::PointerMove { .. }])); +} + +// ---------------------------------------------------------- Degraded inputs + +#[test] +fn degraded_down_no_button_change_returns_empty() { + // Round 4 CONCERN-R4-1 fix: empty buttons on Down with empty + // previous → mapper returns `Vec::new()` (no `ShellEvent::Other`). + let mut mapper = JianPointerMapper::new(); + let ev = jian_event( + 5, + PointerKind::Mouse, + PointerPhase::Down, + JianMouseButtons::empty(), + JianModifiers::empty(), + 0.0, + 0.0, + ); + let out = mapper.from_jian_pointer(&ev); + assert!(out.is_empty(), "expected empty Vec, got {out:?}"); +} + +#[test] +fn degraded_up_no_button_change_returns_empty() { + // Up where current buttons match previous → no diff, no Move + // (Up phase doesn't emit Move) → empty Vec. + let mut mapper = JianPointerMapper::new(); + // Prime previous = LEFT. + let _ = mapper.from_jian_pointer(&jian_event( + 6, + PointerKind::Mouse, + PointerPhase::Down, + JianMouseButtons::LEFT, + JianModifiers::empty(), + 0.0, + 0.0, + )); + // Now Up but buttons still LEFT (e.g. host-side bookkeeping bug). + let ev = jian_event( + 6, + PointerKind::Mouse, + PointerPhase::Up, + JianMouseButtons::LEFT, + JianModifiers::empty(), + 0.0, + 0.0, + ); + let out = mapper.from_jian_pointer(&ev); + assert!(out.is_empty(), "expected empty Vec, got {out:?}"); +} + +// ---------------------------------------------------------------- Modifiers + +#[test] +fn modifiers_propagate() { + let mut mapper = JianPointerMapper::new(); + let mods = JianModifiers::SHIFT | JianModifiers::CTRL | JianModifiers::ALT | JianModifiers::CMD; + let ev = jian_event( + 8, + PointerKind::Mouse, + PointerPhase::Hover, + JianMouseButtons::empty(), + mods, + 0.0, + 0.0, + ); + let out = mapper.from_jian_pointer(&ev); + assert_eq!(out.len(), 1); + match &out[0] { + ShellEvent::PointerMove { modifiers, .. } => { + assert!(modifiers.shift); + assert!(modifiers.ctrl); + assert!(modifiers.alt); + assert!(modifiers.meta, "Jian CMD must map to OP meta"); + } + other => panic!("expected PointerMove, got {other:?}"), + } +}