feat(shell-core,shell-native): map Jian PointerEvent to ShellEvent (Step 1a Task 3)
Phase B Task 3 implementation per spec v19 §5.1 + §5.1.1 (FROZEN
2026-05-04):
shell-core:
- New `event` module declaring `ShellEvent` (6 variants per spec §5.1)
+ sub-types `PointerId / TouchId / TouchPhase / TouchForce /
MouseButton / ElementState / ScrollDelta / Modifiers / KeyCode /
WindowEventKind`. Pure OP types — no winit / Jian / GL — so the
enum is wasm32-clean and visible on iOS / Android (spec §11.3).
- TouchForce::Calibrated mirrors winit::Force 1:1 (spec §11.3
invariant) so Step 1f mobile mapper compiles without API break.
- Newtype id fields are `pub` so shell-native can construct them across
crates (spec round 3 BLOCK-R3-4 fix).
shell-native:
- New `event` module (cfg-gated desktop only) housing
`JianPointerMapper` — stateful diff over the per-PointerId
`MouseButtons` snapshot. Diff runs on Down / Up / Move (spec round 3
CONCERN-R3-1 fix); Hover / Move emits a trailing `PointerMove`.
- Touch branch maps Down/Move/Up/Cancel → Started/Moved/Ended/Cancelled;
Touch Hover returns `Vec::new()` (touches never hover).
- Mouse / Pen / Stylus / Trackpad share the same diff branch.
- Degraded inputs (no button transition + no Move emission) return
`Vec::new()` instead of synthesising a `ShellEvent::Other` variant
(spec round 4 CONCERN-R4-1 fix; the enum stays at exactly 6 variants).
Tests:
- 15 new unit tests in shell-native/tests/event_mapping.rs covering
the 4 Touch phases, mouse Hover, LEFT Down/Up pair, multi-button
press/release during Move, Pen/Stylus/Trackpad routing, two
degraded-empty paths, and modifiers propagation (CMD → meta).
- 3 new shape tests in shell-core/tests/event_shape.rs proving the
6-variant invariant + TouchForce::Calibrated field shape +
`pub`-field newtype constructibility.
Verified:
- `cargo test -p openpencil-shell-core -p openpencil-shell-native`
green (36 tests total across both crates).
- `cargo check --target wasm32-unknown-unknown -p openpencil-shell-core`
green; shell-web on wasm32 still compiles with the new module pulled
through.
- `cargo check --target aarch64-apple-ios -p openpencil-shell-native`
+ `--target aarch64-linux-android -p openpencil-shell-native` both
green (mapper cfg-gated out of mobile).
- `cargo metadata --filter-platform aarch64-linux-android` confirms
jian-host-desktop / jian-skia not in the Android dep tree.
- §11.1 grep: 0 actual `use winit/skia_safe/glutin/...` items in
shell-core (only doc-comment references).
- `cargo clippy --all-targets` clean; `cargo fmt --check` clean.
This commit is contained in:
parent
c6c59d95c1
commit
c3e935ccbb
226
crates/openpencil-shell-core/src/event.rs
Normal file
226
crates/openpencil-shell-core/src/event.rs
Normal file
|
|
@ -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<f64>,
|
||||
},
|
||||
/// 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<TouchForce>,
|
||||
},
|
||||
/// Window-level event (resize, scale change, close, focus).
|
||||
Window { kind: WindowEventKind },
|
||||
/// Keyboard event.
|
||||
Key {
|
||||
key: KeyCode,
|
||||
state: ElementState,
|
||||
modifiers: Modifiers,
|
||||
},
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
92
crates/openpencil-shell-core/tests/event_shape.rs
Normal file
92
crates/openpencil-shell-core/tests/event_shape.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
163
crates/openpencil-shell-native/src/event/mod.rs
Normal file
163
crates/openpencil-shell-native/src/event/mod.rs
Normal file
|
|
@ -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<JianPointerId, JianMouseButtons>,
|
||||
}
|
||||
|
||||
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<ShellEvent> {
|
||||
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<MouseButton> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
467
crates/openpencil-shell-native/tests/event_mapping.rs
Normal file
467
crates/openpencil-shell-native/tests/event_mapping.rs
Normal file
|
|
@ -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:?}"),
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue