Skip to main content

crcbl_shell/
event.rs

1//! [`ShellEvent`] — everything the window system tells the engine, in engine
2//! types.
3//!
4//! # Timestamps, and the clock they are on
5//!
6//! Every input-bearing variant carries an [`EventTime`]. This is not
7//! decoration: the input design makes the pattern evaluator a pure function
8//! over timestamped edges (`docs/notes/simulation.md`) — tap versus hold versus
9//! double-tap are *defined* by inter-event durations — and topic 26's client
10//! prediction needs input-to-photon latency, which is a subtraction between an
11//! event timestamp and a present timestamp. Neither works if the timestamp is
12//! the moment the engine got around to draining the queue: a 20 ms frame
13//! quantizes every duration to 20 ms and makes a fast double-tap
14//! indistinguishable from a slow one.
15//!
16//! So the timestamp is the **window system's**, rebased onto the engine's
17//! monotonic clock. [`EventTime`]'s own documentation states the epoch and
18//! spells out the per-platform rebasing each backend owes; the short version is
19//! that an `EventTime` and [`FrameClock`](crcbl_core::FrameClock) readings live
20//! on one timeline, so they can be subtracted.
21//!
22//! Non-input variants ([`Resized`](ShellEvent::Resized),
23//! [`MonitorsChanged`](ShellEvent::MonitorsChanged)) carry no timestamp. They
24//! are state transitions, not samples; nothing computes a duration between two
25//! resizes, and a field that exists only to be ignored invites someone to use
26//! it for something it is not.
27//!
28//! # Modifiers are stamped onto every event, not delivered separately
29//!
30//! Wayland sends `wl_keyboard.modifiers` as its own message, X11 puts a
31//! modifier mask on each event, and Win32 makes you call `GetKeyState`. The
32//! seam picks the X11 shape: the backend tracks modifier state and stamps the
33//! current value onto each event.
34//!
35//! The reason is that the alternative loses information the consumer cannot
36//! recover. If modifiers arrive as a separate event, a consumer that batches a
37//! frame's events — which every consumer does — sees "Ctrl went down" and "S
38//! went down" as two facts in a list and has to replay them in order to know
39//! whether it was `Ctrl+S`. That replay is exactly the state machine each
40//! backend has to write anyway, so it is written once, in the backend, where
41//! the ordering is authoritative.
42//!
43//! # Non-exhaustive, unlike [`SurfaceTarget`](crcbl_core::SurfaceTarget)
44//!
45//! `SurfaceTarget` is deliberately exhaustive so a new platform breaks every
46//! HAL backend loudly. `ShellEvent` is the opposite: a consumer that ignores an
47//! event it has never heard of degrades gracefully (touch input does nothing,
48//! rather than doing something wrong), and
49//! [`TextPreedit`](ShellEvent::TextPreedit) arrived that way after it. Breaking
50//! every `match` in the engine for each new event is not a useful forcing
51//! function, it is churn. (Gamepads are not window events and do not come
52//! through here: their backends emit `crcbl_input::GamepadEvent`s from a poll
53//! of their own.)
54//!
55//! [`Touch`](ShellEvent::Touch) is what that promise looked like when it was
56//! collected: it landed without touching a single `match` outside the shell,
57//! and a game that never heard of it kept playing with one finger because the
58//! primary contact is reported through the pointer variants as well.
59
60use std::path::PathBuf;
61
62use crcbl_core::EventTime;
63use crcbl_core::input::{
64    ButtonState, ContactId, DeviceId, Keysym, Modifiers, PointerButton, Scancode, ScrollDelta,
65    TouchPhase,
66};
67
68use crate::{
69    ClipboardContent, ClipboardRequestId, KeyCode, MonitorId, PhysicalPoint, PhysicalSize,
70    ReceivedMime, WindowId,
71};
72
73/// Something the window system reported.
74///
75/// Delivered by [`Shell::pump`](crate::Shell::pump). See the [module
76/// docs](self) for the timestamp and modifier conventions.
77#[derive(Clone, Debug, PartialEq)]
78#[non_exhaustive]
79pub enum ShellEvent {
80    /// The client area changed size.
81    ///
82    /// The authoritative size for the swapchain — never the size that was
83    /// asked for in [`WindowDesc`](crate::WindowDesc). Carries the scale factor
84    /// too, because a resize and a scale change can arrive together (dragging a
85    /// window between monitors of different DPI does exactly that) and a
86    /// consumer that reads size from one event and scale from another will
87    /// briefly hold a mismatched pair.
88    Resized {
89        /// Which window.
90        window: WindowId,
91        /// New client area in device pixels.
92        size: PhysicalSize,
93        /// Device pixels per logical unit, at the same instant.
94        scale_factor: f64,
95    },
96
97    /// The window's scale factor changed — it moved to a different monitor, or
98    /// the user changed the display scale.
99    ///
100    /// Carries the *new* physical size as well: on Wayland a scale change is a
101    /// `configure` that also restates the size, and on Win32 `WM_DPICHANGED`
102    /// hands over a suggested rectangle. A consumer must treat this as a resize
103    /// too. The windowing rules (`docs/notes/backends.md`) call out "scale-factor changes
104    /// mid-session must not leak wrong-size swapchains" as a required test, and
105    /// putting both facts in one event is what makes that testable rather than
106    /// order-dependent.
107    ScaleFactorChanged {
108        /// Which window.
109        window: WindowId,
110        /// New device pixels per logical unit.
111        scale_factor: f64,
112        /// Client area in device pixels after the change.
113        size: PhysicalSize,
114    },
115
116    /// The user asked to close the window — title-bar button, Alt-F4, session
117    /// logout.
118    ///
119    /// The window is **still open**. Answer with
120    /// [`reply_close_request`](crate::Shell::reply_close_request); until you
121    /// do, the shell holds the request. See
122    /// [`CloseReply`](crate::CloseReply).
123    CloseRequested {
124        /// Which window.
125        window: WindowId,
126    },
127
128    /// The window was destroyed and its [`WindowId`] is now stale.
129    ///
130    /// Follows a [`CloseReply::Close`](crate::CloseReply::Close), an explicit
131    /// [`destroy_window`](crate::Shell::destroy_window), or the compositor
132    /// taking the window away.
133    WindowDestroyed {
134        /// The window that is gone.
135        window: WindowId,
136    },
137
138    /// Keyboard focus entered or left the window.
139    ///
140    /// A consumer **must** treat focus loss as "every key is now up": no
141    /// platform delivers releases for keys held when focus leaves, and a game
142    /// that does not clear its key state walks into a wall until the player
143    /// taps the key again. The action layer (P2) does this centrally; raw
144    /// consumers do it themselves.
145    Focus {
146        /// Which window.
147        window: WindowId,
148        /// Whether the window now has focus.
149        focused: bool,
150    },
151
152    /// A key went down or came up.
153    ///
154    /// Three views of the same keystroke — see
155    /// [`crcbl_core::input`] for why they are all here and what each is for.
156    Key {
157        /// Which window had focus.
158        window: WindowId,
159        /// Which keyboard.
160        device: DeviceId,
161        /// When it happened; see the [module docs](self).
162        time: EventTime,
163        /// The platform's raw code, for keys the engine has no name for.
164        scancode: Scancode,
165        /// The physical key, layout-independent. `None` when the scancode maps
166        /// to no key this engine names — the key is still bindable through
167        /// `scancode`.
168        key_code: Option<KeyCode>,
169        /// The symbol this key produces in the current layout, for rebind
170        /// menus and shortcut labels.
171        keysym: Keysym,
172        /// Down or up.
173        state: ButtonState,
174        /// Whether this is an auto-repeat, not a fresh press.
175        ///
176        /// Text fields want repeats; a jump button must not. Filtering them at
177        /// the source would break the first, keeping them silently breaks the
178        /// second, so the fact is carried and the consumer decides.
179        repeat: bool,
180        /// Modifiers held at the time; see the [module docs](self).
181        modifiers: Modifiers,
182    },
183
184    /// The pointer moved.
185    ///
186    /// Both fields are optional, and which are present is the difference
187    /// between a UI event and a camera event:
188    ///
189    /// * `abs` is where the pointer is, in window pixels. `None` under
190    ///   [`PointerMode::Locked`](crate::PointerMode::Locked), where there is no
191    ///   meaningful position.
192    /// * `raw_delta` is unaccelerated, unclipped motion. `None` when the
193    ///   backend has no relative-motion source — see
194    ///   [`ShellCaps::RAW_POINTER_MOTION`](crate::ShellCaps::RAW_POINTER_MOTION).
195    ///
196    /// A camera must use `raw_delta` and must not difference successive `abs`
197    /// values: the latter is clamped at the screen edge and has pointer
198    /// acceleration already applied, which is why aim in games that get this
199    /// wrong stops working when the cursor reaches the edge of the display.
200    PointerMotion {
201        /// Which window.
202        window: WindowId,
203        /// Which pointing device.
204        device: DeviceId,
205        /// When it happened.
206        time: EventTime,
207        /// Position in the window, in device pixels.
208        abs: Option<PhysicalPoint>,
209        /// Unaccelerated relative motion in device pixels, `(dx, dy)`.
210        raw_delta: Option<(f64, f64)>,
211    },
212
213    /// The pointer entered or left the window.
214    ///
215    /// Not in the windowing rules' event set, and added anyway: every backend
216    /// delivers enter/leave natively (`wl_pointer.enter`, X11
217    /// `EnterNotify`, `WM_MOUSELEAVE`), the UI layer needs it for hover state,
218    /// and the alternative — inferring it from motion events that stop
219    /// arriving — cannot distinguish "the pointer left" from "the pointer
220    /// stopped moving".
221    PointerFocus {
222        /// Which window.
223        window: WindowId,
224        /// Which pointing device.
225        device: DeviceId,
226        /// When it happened.
227        time: EventTime,
228        /// Whether the pointer is now inside the window.
229        entered: bool,
230        /// Where it entered, if the backend said. Always `None` on leave.
231        position: Option<PhysicalPoint>,
232    },
233
234    /// A pointer button went down or came up.
235    Button {
236        /// Which window.
237        window: WindowId,
238        /// Which pointing device.
239        device: DeviceId,
240        /// When it happened.
241        time: EventTime,
242        /// Which button.
243        button: PointerButton,
244        /// Down or up.
245        state: ButtonState,
246        /// Where the pointer was, if known. `None` under
247        /// [`PointerMode::Locked`](crate::PointerMode::Locked).
248        position: Option<PhysicalPoint>,
249        /// Modifiers held at the time.
250        modifiers: Modifiers,
251    },
252
253    /// A scroll wheel or touchpad scrolled.
254    Wheel {
255        /// Which window.
256        window: WindowId,
257        /// Which pointing device.
258        device: DeviceId,
259        /// When it happened.
260        time: EventTime,
261        /// How far, in the units the device reported — see
262        /// [`ScrollDelta`], which does not collapse detents into pixels.
263        delta: ScrollDelta,
264        /// Where the pointer was, if known.
265        position: Option<PhysicalPoint>,
266        /// Modifiers held at the time. Ctrl+wheel is zoom nearly everywhere,
267        /// so this is load-bearing rather than informational.
268        modifiers: Modifiers,
269    },
270
271    /// A finger landed, moved, lifted, or had its gesture taken away.
272    ///
273    /// **One event per contact**, so two fingers moving at once are two events
274    /// and not one averaged pointer. [`ContactId`] carries the identity and
275    /// [`TouchPhase`] the lifecycle; between them a consumer can hold per-finger
276    /// state, which is the whole reason this variant exists and the reason a
277    /// second finger used to be dropped at the backend.
278    ///
279    /// # A touchscreen also produces pointer events, and that is deliberate
280    ///
281    /// The **primary** contact — the first finger of a gesture, and the only one
282    /// while there is only one — is *also* reported as
283    /// [`PointerMotion`](ShellEvent::PointerMotion) and
284    /// [`Button`](ShellEvent::Button), exactly as a browser emulates mouse
285    /// events for touch and Windows synthesizes them from `WM_POINTER`. So a
286    /// game bound to `Binding::MouseButton` is playable with one finger without
287    /// knowing this variant exists, and a game that wants two fingers reads
288    /// `Touch` and ignores the emulation.
289    ///
290    /// That is an obligation on a backend that sets
291    /// [`ShellCaps::TOUCH`](crate::ShellCaps::TOUCH), not an accident of one
292    /// platform: a backend whose window system does not emulate must do it
293    /// itself, or every existing pointer binding stops working the day it
294    /// reports its first contact. Contacts that are not primary have no pointer
295    /// events at all — a second finger is not the first one teleporting.
296    ///
297    /// # No modifiers, and a position that is never absent
298    ///
299    /// A finger carries no `modifiers`: no platform reports keyboard state on a
300    /// touch event, and a field the backends would have to invent is a field a
301    /// consumer would believe. And `position` is a [`PhysicalPoint`] rather than
302    /// an `Option` — a contact is *defined* by where it touches, including on
303    /// the release, and there is no [`PointerMode::Locked`](crate::PointerMode)
304    /// equivalent that could take the position away.
305    Touch {
306        /// Which window.
307        window: WindowId,
308        /// Which touchscreen. Every contact on one screen shares this — see
309        /// [`ContactId`], which is the one that tells fingers apart.
310        device: DeviceId,
311        /// When it happened.
312        time: EventTime,
313        /// Which finger.
314        contact: ContactId,
315        /// What it just did.
316        phase: TouchPhase,
317        /// Where it is, in device pixels. On
318        /// [`Cancelled`](TouchPhase::Cancelled) this is the last place the
319        /// platform knew about rather than a place the user chose.
320        position: PhysicalPoint,
321    },
322
323    /// Text was committed — by typing, by an input method, or by a paste the
324    /// window system routed as text input.
325    ///
326    /// Separate from [`Key`](ShellEvent::Key) because the relationship is not
327    /// one-to-one: an IME commits several characters from one keystroke, or one
328    /// character from five, and a dead key commits nothing at all. A text field
329    /// that reconstructs its content from key events is a text field that
330    /// cannot type in Japanese.
331    TextCommit {
332        /// Which window.
333        window: WindowId,
334        /// When it happened.
335        time: EventTime,
336        /// The committed text. Never empty.
337        text: String,
338    },
339
340    /// An input method's composition in progress: the pre-edit a text field
341    /// draws at its caret, underlined, until it is committed or abandoned.
342    ///
343    /// **Each one replaces the last**, and an empty `text` means there is no
344    /// pre-edit any more: the composition was committed, cancelled, or deleted
345    /// back to nothing. The committed text itself arrives separately, as
346    /// [`TextCommit`](ShellEvent::TextCommit), and a consumer must not
347    /// synthesize it from the last pre-edit: an input method commits the
348    /// conversion the user chose, which is usually not the reading they typed.
349    /// The two streams are independent — a field inserts each commit at its
350    /// caret and draws the latest pre-edit after it — so their relative order
351    /// within one pump carries no meaning.
352    ///
353    /// A backend with no input-method integration never sends this, and
354    /// composed text still arrives as commits where
355    /// [`ShellCaps::TEXT_IME`](crate::ShellCaps::TEXT_IME) is set; the platform's
356    /// own composition window shows the pre-edit instead.
357    /// [`Shell::set_text_input_area`](crate::Shell::set_text_input_area) is how a
358    /// field tells the input method where to put that window and its
359    /// candidate list.
360    TextPreedit {
361        /// Which window.
362        window: WindowId,
363        /// When it happened.
364        time: EventTime,
365        /// The composition so far. Empty when the pre-edit ended.
366        text: String,
367        /// Where the input method's cursor sits in `text`, as a byte offset on
368        /// a `char` boundary, if it said. `None` for an empty `text`.
369        cursor: Option<usize>,
370    },
371
372    /// The set of monitors changed — hotplug, resolution change, or a layout
373    /// rearrangement.
374    ///
375    /// Carries no payload: [`Shell::monitors`](crate::Shell::monitors) is the
376    /// authority, and duplicating the list into the event would let a consumer
377    /// hold a stale copy that looks authoritative. A window's *own* scale
378    /// change arrives separately as
379    /// [`ScaleFactorChanged`](ShellEvent::ScaleFactorChanged).
380    MonitorsChanged,
381
382    /// A monitor was disconnected.
383    ///
384    /// Arrives before the [`MonitorsChanged`](ShellEvent::MonitorsChanged)
385    /// after which [`Shell::monitors`](crate::Shell::monitors) no longer lists
386    /// it, so a consumer can act on the id while it still knows what the id
387    /// named. After that the id names nothing, and it is never reused.
388    ///
389    /// By the time that `MonitorsChanged` arrives, every
390    /// [panel](crate::WindowRole::Panel) created for this monitor has been sent
391    /// a [`CloseRequested`](ShellEvent::CloseRequested): a bar on an unplugged
392    /// display has nowhere to be. A window system that closes the panel itself
393    /// may send that request before this event. Other windows stay wherever
394    /// the window system moves them.
395    MonitorRemoved {
396        /// The monitor that went.
397        monitor: MonitorId,
398    },
399
400    /// The window asked for the keyboard and the window system refused it.
401    ///
402    /// A [panel](crate::WindowRole::Panel) that takes the keyboard outright
403    /// ([`PanelKeyboard::Exclusive`](crate::PanelKeyboard::Exclusive)) asks
404    /// when it is shown. Win32 refuses the foreground to a process the user is
405    /// not working in, and then no [`Focus`](ShellEvent::Focus) follows: keys
406    /// keep going to the window the user was in. This is how a program finds
407    /// out, without guessing a timeout — to retry, to say so, or to draw the
408    /// user's attention another way.
409    FocusRefused {
410        /// The window that asked.
411        window: WindowId,
412    },
413
414    /// A full-screen window opened on this panel's monitor, or the last one
415    /// closed: `active` says which.
416    ///
417    /// For a bar that should step aside while a game or a video is full
418    /// screen. Raised on Win32, for a [panel](crate::WindowRole::Panel) that
419    /// reserves its edge ([`Reserve::OwnExtent`](crate::Reserve::OwnExtent)),
420    /// from the shell's `ABN_FULLSCREENAPP`. Wayland and AppKit do not need
421    /// it: their compositors already draw a full-screen window over a bar, or
422    /// in a space of its own. X11 does not raise it either, so a bar there
423    /// that cares watches `_NET_WM_STATE_FULLSCREEN` itself.
424    FullScreenApp {
425        /// The panel told.
426        window: WindowId,
427        /// Whether a full-screen window is now open on its monitor.
428        active: bool,
429    },
430
431    /// A file was dropped on the window.
432    ///
433    /// One event per file: a multi-file drop produces several. Only delivered
434    /// when the window was created with
435    /// [`WindowDesc::accept_drops`](crate::WindowDesc::accept_drops) and
436    /// [`ShellCaps::DRAG_DROP`](crate::ShellCaps::DRAG_DROP) is set.
437    DroppedFile {
438        /// Which window.
439        window: WindowId,
440        /// When it happened.
441        time: EventTime,
442        /// The dropped path. Not guaranteed to exist or to be readable — it
443        /// comes from another process, and checking is the consumer's job.
444        path: PathBuf,
445        /// Where in the window it was dropped, if the backend said.
446        position: Option<PhysicalPoint>,
447    },
448
449    /// The answer to a [`clipboard_request`](crate::Shell::clipboard_request).
450    ///
451    /// **Exactly one of these arrives for every accepted request**, and it is
452    /// the only thing that ever answers one. It may be several frames late: a
453    /// backend holds a read until it can answer it rather than reporting an
454    /// empty clipboard it has not actually looked at. See
455    /// [`clipboard`](crate::clipboard) for why reads are asynchronous and why
456    /// there is no "ask again later" outcome.
457    ClipboardData {
458        /// Which window asked.
459        window: WindowId,
460        /// Which request this answers.
461        request: ClipboardRequestId,
462        /// The format that was delivered, spelled the way the *other*
463        /// application spelled it.
464        ///
465        /// A [`ReceivedMime`] rather than a
466        /// [`MimeType`](crate::MimeType) because the peer chose this string,
467        /// not us: asking for `text/plain;charset=utf-8` and being answered
468        /// with `text/plain` is normal and must round-trip verbatim. Compare
469        /// with [`ReceivedMime::matches`], never with string equality.
470        ///
471        /// For [`Empty`](ClipboardContent::Empty) and
472        /// [`Unavailable`](ClipboardContent::Unavailable) there is no peer
473        /// spelling to report, so this is the format that was *asked* for.
474        mime: ReceivedMime,
475        /// What the read produced — bytes, an empty clipboard, or a failure.
476        /// See [`ClipboardContent`], which exists because the three are not one
477        /// case.
478        content: ClipboardContent,
479    },
480}
481
482impl ShellEvent {
483    /// Which window this event concerns, if any.
484    ///
485    /// `None` only for [`MonitorsChanged`](ShellEvent::MonitorsChanged) and
486    /// [`MonitorRemoved`](ShellEvent::MonitorRemoved), which are properties of
487    /// the session rather than of a window. A multi-window consumer routes on
488    /// this.
489    #[must_use]
490    pub const fn window(&self) -> Option<WindowId> {
491        match self {
492            Self::Resized { window, .. }
493            | Self::ScaleFactorChanged { window, .. }
494            | Self::CloseRequested { window }
495            | Self::WindowDestroyed { window }
496            | Self::Focus { window, .. }
497            | Self::Key { window, .. }
498            | Self::PointerMotion { window, .. }
499            | Self::PointerFocus { window, .. }
500            | Self::Button { window, .. }
501            | Self::Wheel { window, .. }
502            | Self::Touch { window, .. }
503            | Self::TextCommit { window, .. }
504            | Self::TextPreedit { window, .. }
505            | Self::DroppedFile { window, .. }
506            | Self::FocusRefused { window }
507            | Self::FullScreenApp { window, .. }
508            | Self::ClipboardData { window, .. } => Some(*window),
509            Self::MonitorsChanged | Self::MonitorRemoved { .. } => None,
510        }
511    }
512
513    /// When this event happened, for the variants that are input samples.
514    ///
515    /// `None` for state transitions; see the [module docs](self) for why they
516    /// deliberately have no timestamp.
517    #[must_use]
518    pub const fn time(&self) -> Option<EventTime> {
519        match self {
520            Self::Key { time, .. }
521            | Self::PointerMotion { time, .. }
522            | Self::PointerFocus { time, .. }
523            | Self::Button { time, .. }
524            | Self::Wheel { time, .. }
525            | Self::Touch { time, .. }
526            | Self::TextCommit { time, .. }
527            | Self::TextPreedit { time, .. }
528            | Self::DroppedFile { time, .. } => Some(*time),
529            Self::Resized { .. }
530            | Self::ScaleFactorChanged { .. }
531            | Self::CloseRequested { .. }
532            | Self::WindowDestroyed { .. }
533            | Self::Focus { .. }
534            | Self::MonitorsChanged
535            | Self::MonitorRemoved { .. }
536            | Self::FocusRefused { .. }
537            | Self::FullScreenApp { .. }
538            | Self::ClipboardData { .. } => None,
539        }
540    }
541
542    /// Whether this event feeds the input pipeline rather than the window
543    /// lifecycle.
544    ///
545    /// The P2 action layer subscribes to exactly this subset.
546    #[inline]
547    #[must_use]
548    pub const fn is_input(&self) -> bool {
549        self.time().is_some()
550    }
551
552    /// A short, stable name for logs and test assertions.
553    ///
554    /// Stable because tests assert on sequences of these: a scripted
555    /// `HeadlessShell` session compares `["Resized", "Focus", "Key", …]`, which
556    /// stays readable in a failure message in a way that a `Debug` dump of the
557    /// full payloads does not.
558    #[must_use]
559    pub const fn name(&self) -> &'static str {
560        match self {
561            Self::Resized { .. } => "Resized",
562            Self::ScaleFactorChanged { .. } => "ScaleFactorChanged",
563            Self::CloseRequested { .. } => "CloseRequested",
564            Self::WindowDestroyed { .. } => "WindowDestroyed",
565            Self::Focus { .. } => "Focus",
566            Self::Key { .. } => "Key",
567            Self::PointerMotion { .. } => "PointerMotion",
568            Self::PointerFocus { .. } => "PointerFocus",
569            Self::Button { .. } => "Button",
570            Self::Wheel { .. } => "Wheel",
571            Self::Touch { .. } => "Touch",
572            Self::TextCommit { .. } => "TextCommit",
573            Self::TextPreedit { .. } => "TextPreedit",
574            Self::MonitorsChanged => "MonitorsChanged",
575            Self::MonitorRemoved { .. } => "MonitorRemoved",
576            Self::FocusRefused { .. } => "FocusRefused",
577            Self::FullScreenApp { .. } => "FullScreenApp",
578            Self::DroppedFile { .. } => "DroppedFile",
579            Self::ClipboardData { .. } => "ClipboardData",
580        }
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use core::time::Duration;
588    use crcbl_core::Pool;
589
590    fn window_id() -> WindowId {
591        let mut pool: Pool<u8> = Pool::new();
592        pool.insert(0).cast()
593    }
594
595    fn key_event(time: EventTime) -> ShellEvent {
596        ShellEvent::Key {
597            window: window_id(),
598            device: DeviceId(1),
599            time,
600            scancode: Scancode(57),
601            key_code: Some(KeyCode::Space),
602            keysym: Keysym::from_char(' '),
603            state: ButtonState::Pressed,
604            repeat: false,
605            modifiers: Modifiers::empty(),
606        }
607    }
608
609    #[test]
610    fn a_clipboard_answer_carries_the_peers_spelling() {
611        let event = ShellEvent::ClipboardData {
612            window: window_id(),
613            request: ClipboardRequestId(3),
614            mime: ReceivedMime::new("text/plain"),
615            content: ClipboardContent::Bytes(b"pasted".to_vec()),
616        };
617        let ShellEvent::ClipboardData { mime, content, .. } = &event else {
618            panic!("wrong variant");
619        };
620        assert_eq!(mime.as_str(), "text/plain");
621        assert!(mime.matches(crate::MimeType::TextUtf8));
622        assert_eq!(content.bytes(), Some(&b"pasted"[..]));
623        assert_eq!(content.text(), Some("pasted"));
624
625        // The three outcomes a caller must be able to tell apart. An empty
626        // *payload* is a successful read; an empty *clipboard* is not the same
627        // thing; and neither is a failure.
628        assert_eq!(ClipboardContent::Bytes(Vec::new()).bytes(), Some(&[][..]));
629        assert_ne!(ClipboardContent::Bytes(Vec::new()), ClipboardContent::Empty);
630        assert_eq!(ClipboardContent::Empty.bytes(), None);
631        assert_eq!(ClipboardContent::Unavailable.bytes(), None);
632        assert_ne!(ClipboardContent::Empty, ClipboardContent::Unavailable);
633        assert_eq!(ClipboardContent::Unavailable.name(), "Unavailable");
634        // Invalid UTF-8 is refused rather than lossily replaced.
635        assert_eq!(ClipboardContent::Bytes(vec![0xff]).text(), None);
636        assert_eq!(
637            event.time(),
638            None,
639            "a clipboard answer is not an input sample"
640        );
641    }
642
643    #[test]
644    fn input_events_carry_a_usable_timestamp_and_lifecycle_events_do_not() {
645        let pressed = key_event(EventTime::from_millis(1_000));
646        let released = key_event(EventTime::from_millis(1_180));
647
648        // This subtraction is the whole reason timestamps exist: 180 ms is a
649        // "tap", not a "hold", and no frame boundary got a vote.
650        let held = released
651            .time()
652            .expect("input")
653            .saturating_since(pressed.time().expect("input"));
654        assert_eq!(held, Duration::from_millis(180));
655
656        assert!(pressed.is_input());
657        assert!(!ShellEvent::MonitorsChanged.is_input());
658        assert_eq!(
659            ShellEvent::Resized {
660                window: window_id(),
661                size: PhysicalSize::new(1, 1),
662                scale_factor: 1.0,
663            }
664            .time(),
665            None
666        );
667    }
668
669    #[test]
670    fn every_variant_reports_its_window_and_a_stable_name() {
671        let window = window_id();
672        let events = [
673            ShellEvent::Resized {
674                window,
675                size: PhysicalSize::new(640, 360),
676                scale_factor: 1.0,
677            },
678            ShellEvent::ScaleFactorChanged {
679                window,
680                scale_factor: 2.0,
681                size: PhysicalSize::new(1280, 720),
682            },
683            ShellEvent::CloseRequested { window },
684            ShellEvent::WindowDestroyed { window },
685            ShellEvent::Focus {
686                window,
687                focused: true,
688            },
689            key_event(EventTime::ZERO),
690            ShellEvent::PointerMotion {
691                window,
692                device: DeviceId::UNKNOWN,
693                time: EventTime::ZERO,
694                abs: Some(PhysicalPoint::new(1.0, 2.0)),
695                raw_delta: Some((0.5, -0.5)),
696            },
697            ShellEvent::PointerFocus {
698                window,
699                device: DeviceId::UNKNOWN,
700                time: EventTime::ZERO,
701                entered: true,
702                position: None,
703            },
704            ShellEvent::Button {
705                window,
706                device: DeviceId::UNKNOWN,
707                time: EventTime::ZERO,
708                button: PointerButton::Left,
709                state: ButtonState::Pressed,
710                position: None,
711                modifiers: Modifiers::CTRL,
712            },
713            ShellEvent::Wheel {
714                window,
715                device: DeviceId::UNKNOWN,
716                time: EventTime::ZERO,
717                delta: ScrollDelta::Lines { x: 0.0, y: 1.0 },
718                position: None,
719                modifiers: Modifiers::empty(),
720            },
721            ShellEvent::Touch {
722                window,
723                device: DeviceId::UNKNOWN,
724                time: EventTime::ZERO,
725                contact: ContactId(1),
726                phase: TouchPhase::Began,
727                position: PhysicalPoint::new(4.0, 5.0),
728            },
729            ShellEvent::TextCommit {
730                window,
731                time: EventTime::ZERO,
732                text: "は".to_string(),
733            },
734            ShellEvent::DroppedFile {
735                window,
736                time: EventTime::ZERO,
737                path: PathBuf::from("/tmp/scene.ron"),
738                position: None,
739            },
740            ShellEvent::ClipboardData {
741                window,
742                request: ClipboardRequestId(1),
743                mime: ReceivedMime::from(crate::MimeType::TextUtf8),
744                content: ClipboardContent::Empty,
745            },
746            ShellEvent::MonitorsChanged,
747            ShellEvent::MonitorRemoved {
748                monitor: MonitorId(2),
749            },
750            ShellEvent::FocusRefused { window },
751            ShellEvent::FullScreenApp {
752                window,
753                active: true,
754            },
755        ];
756
757        let mut names = Vec::new();
758        for event in &events {
759            names.push(event.name());
760            match event {
761                ShellEvent::MonitorsChanged | ShellEvent::MonitorRemoved { .. } => {
762                    assert_eq!(event.window(), None);
763                }
764                _ => assert_eq!(event.window(), Some(window), "{}", event.name()),
765            }
766        }
767        let count = names.len();
768        names.sort_unstable();
769        names.dedup();
770        assert_eq!(names.len(), count, "event names must be unique");
771    }
772
773    /// Two fingers are two contacts, and the event says which is which.
774    ///
775    /// The shape claim in one test: a batch of touch events keyed by
776    /// [`ContactId`] can be reduced to a per-finger position, the two fingers do
777    /// not share an id, and moving one does not move the other. This is what the
778    /// seam is *for* — before it, a second finger had nowhere to go and the web
779    /// backend dropped it.
780    #[test]
781    fn a_batch_of_contacts_keeps_the_two_fingers_apart() {
782        let window = window_id();
783        let touch = |contact: u32, phase: TouchPhase, x: f64, y: f64| ShellEvent::Touch {
784            window,
785            device: DeviceId(3),
786            time: EventTime::from_millis(1),
787            contact: ContactId(contact),
788            phase,
789            position: PhysicalPoint::new(x, y),
790        };
791        let batch = [
792            touch(7, TouchPhase::Began, 10.0, 20.0),
793            touch(9, TouchPhase::Began, 300.0, 400.0),
794            touch(9, TouchPhase::Moved, 305.0, 410.0),
795            touch(7, TouchPhase::Ended, 10.0, 20.0),
796        ];
797
798        let mut live: Vec<(ContactId, TouchPhase, PhysicalPoint)> = Vec::new();
799        for event in &batch {
800            let ShellEvent::Touch {
801                contact,
802                phase,
803                position,
804                ..
805            } = event
806            else {
807                panic!("wrong variant");
808            };
809            live.retain(|(id, ..)| id != contact);
810            if !phase.ends_contact() {
811                live.push((*contact, *phase, *position));
812            }
813        }
814
815        // One finger left, at the place *its own* moves put it — not at the
816        // other's, and not at the average of the two.
817        assert_eq!(
818            live,
819            vec![(
820                ContactId(9),
821                TouchPhase::Moved,
822                PhysicalPoint::new(305.0, 410.0)
823            )]
824        );
825        assert!(TouchPhase::Ended.ends_contact());
826        assert!(TouchPhase::Cancelled.ends_contact());
827        assert!(!TouchPhase::Began.ends_contact());
828        assert!(!TouchPhase::Moved.ends_contact());
829        // A cancel is not an end: they agree that the contact is over and
830        // disagree about everything a consumer does next.
831        assert_ne!(TouchPhase::Ended, TouchPhase::Cancelled);
832        assert!(batch[0].is_input(), "a contact is an input sample");
833        assert_eq!(batch[0].time(), Some(EventTime::from_millis(1)));
834    }
835
836    #[test]
837    fn locked_pointer_motion_has_no_absolute_position() {
838        // The invariant `PointerMode::Locked` documents, expressed as a value.
839        let event = ShellEvent::PointerMotion {
840            window: window_id(),
841            device: DeviceId(2),
842            time: EventTime::from_micros(16_666),
843            abs: None,
844            raw_delta: Some((12.0, -3.0)),
845        };
846        let ShellEvent::PointerMotion { abs, raw_delta, .. } = event else {
847            panic!("wrong variant");
848        };
849        assert_eq!(abs, None);
850        assert_eq!(raw_delta, Some((12.0, -3.0)));
851    }
852}