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 — every window at once when the connection to
133    /// the window system is lost.
134    WindowDestroyed {
135        /// The window that is gone.
136        window: WindowId,
137    },
138
139    /// Keyboard focus entered or left the window.
140    ///
141    /// A consumer **must** treat focus loss as "every key is now up": no
142    /// platform delivers releases for keys held when focus leaves, and a game
143    /// that does not clear its key state walks into a wall until the player
144    /// taps the key again. The action layer (P2) does this centrally; raw
145    /// consumers do it themselves.
146    Focus {
147        /// Which window.
148        window: WindowId,
149        /// Whether the window now has focus.
150        focused: bool,
151    },
152
153    /// A key went down or came up.
154    ///
155    /// Three views of the same keystroke — see
156    /// [`crcbl_core::input`] for why they are all here and what each is for.
157    ///
158    /// **A key the input method consumes while composing is not reported as
159    /// a `Key`** — neither its press nor its release. The Backspace that
160    /// deletes a kana from a [`TextPreedit`](ShellEvent::TextPreedit) and the
161    /// Enter that commits the conversion are the input method's, and a text
162    /// field that also saw them as editing keys would delete a character or
163    /// submit the form. This is what a Wayland compositor already does by not
164    /// sending the key at all; the Win32 backend recognizes the keystrokes its
165    /// input method marks as taken. The AppKit backend cannot yet tell such a
166    /// keystroke apart, and still reports it.
167    Key {
168        /// Which window had focus.
169        window: WindowId,
170        /// Which keyboard.
171        device: DeviceId,
172        /// When it happened; see the [module docs](self).
173        time: EventTime,
174        /// The platform's raw code, for keys the engine has no name for.
175        scancode: Scancode,
176        /// The physical key, layout-independent. `None` when the scancode maps
177        /// to no key this engine names — the key is still bindable through
178        /// `scancode`. Right for game bindings (WASD stays where the fingers
179        /// are), wrong for menus and shortcuts: an xkb remap such as
180        /// `caps:escape` or a virtual keyboard's own keymap does not change it
181        /// (`wtype`'s Down arrives on evdev 1, which is Escape).
182        key_code: Option<KeyCode>,
183        /// The symbol this key produces in the current layout, for menus,
184        /// shortcuts, rebind menus and shortcut labels. The keys no layout
185        /// moves have named constants, [`Keysym::ESCAPE`], [`Keysym::DOWN`]
186        /// and the rest.
187        keysym: Keysym,
188        /// Down or up.
189        state: ButtonState,
190        /// Whether this is an auto-repeat, not a fresh press.
191        ///
192        /// Text fields want repeats; a jump button must not. Filtering them at
193        /// the source would break the first, keeping them silently breaks the
194        /// second, so the fact is carried and the consumer decides.
195        repeat: bool,
196        /// Modifiers held at the time; see the [module docs](self).
197        modifiers: Modifiers,
198    },
199
200    /// The pointer moved.
201    ///
202    /// Both fields are optional, and which are present is the difference
203    /// between a UI event and a camera event:
204    ///
205    /// * `abs` is where the pointer is, in window pixels. `None` under
206    ///   [`PointerMode::Locked`](crate::PointerMode::Locked), where there is no
207    ///   meaningful position.
208    /// * `raw_delta` is unaccelerated, unclipped motion. `None` when the
209    ///   backend has no relative-motion source — see
210    ///   [`ShellCaps::RAW_POINTER_MOTION`](crate::ShellCaps::RAW_POINTER_MOTION).
211    ///
212    /// A camera must use `raw_delta` and must not difference successive `abs`
213    /// values: the latter is clamped at the screen edge and has pointer
214    /// acceleration already applied, which is why aim in games that get this
215    /// wrong stops working when the cursor reaches the edge of the display.
216    PointerMotion {
217        /// Which window.
218        window: WindowId,
219        /// Which pointing device.
220        device: DeviceId,
221        /// When it happened.
222        time: EventTime,
223        /// Position in the window, in device pixels.
224        abs: Option<PhysicalPoint>,
225        /// Unaccelerated relative motion in device pixels, `(dx, dy)`.
226        raw_delta: Option<(f64, f64)>,
227    },
228
229    /// The pointer entered or left the window.
230    ///
231    /// Not in the windowing rules' event set, and added anyway: every backend
232    /// delivers enter/leave natively (`wl_pointer.enter`, X11
233    /// `EnterNotify`, `WM_MOUSELEAVE`), the UI layer needs it for hover state,
234    /// and the alternative — inferring it from motion events that stop
235    /// arriving — cannot distinguish "the pointer left" from "the pointer
236    /// stopped moving".
237    PointerFocus {
238        /// Which window.
239        window: WindowId,
240        /// Which pointing device.
241        device: DeviceId,
242        /// When it happened.
243        time: EventTime,
244        /// Whether the pointer is now inside the window.
245        entered: bool,
246        /// Where it entered, if the backend said. Always `None` on leave.
247        position: Option<PhysicalPoint>,
248    },
249
250    /// A pointer button went down or came up.
251    Button {
252        /// Which window.
253        window: WindowId,
254        /// Which pointing device.
255        device: DeviceId,
256        /// When it happened.
257        time: EventTime,
258        /// Which button.
259        button: PointerButton,
260        /// Down or up.
261        state: ButtonState,
262        /// Where the pointer was, if known. `None` under
263        /// [`PointerMode::Locked`](crate::PointerMode::Locked).
264        position: Option<PhysicalPoint>,
265        /// Modifiers held at the time.
266        modifiers: Modifiers,
267    },
268
269    /// A scroll wheel or touchpad scrolled.
270    Wheel {
271        /// Which window.
272        window: WindowId,
273        /// Which pointing device.
274        device: DeviceId,
275        /// When it happened.
276        time: EventTime,
277        /// How far, in the units the device reported — see
278        /// [`ScrollDelta`], which does not collapse detents into pixels.
279        delta: ScrollDelta,
280        /// Where the pointer was, if known.
281        position: Option<PhysicalPoint>,
282        /// Modifiers held at the time. Ctrl+wheel is zoom nearly everywhere,
283        /// so this is load-bearing rather than informational.
284        modifiers: Modifiers,
285    },
286
287    /// A finger landed, moved, lifted, or had its gesture taken away.
288    ///
289    /// **One event per contact**, so two fingers moving at once are two events
290    /// and not one averaged pointer. [`ContactId`] carries the identity and
291    /// [`TouchPhase`] the lifecycle; between them a consumer can hold per-finger
292    /// state, which is the whole reason this variant exists and the reason a
293    /// second finger used to be dropped at the backend.
294    ///
295    /// # A touchscreen also produces pointer events, and that is deliberate
296    ///
297    /// The **primary** contact — the first finger of a gesture, and the only one
298    /// while there is only one — is *also* reported as
299    /// [`PointerMotion`](ShellEvent::PointerMotion) and
300    /// [`Button`](ShellEvent::Button), exactly as a browser emulates mouse
301    /// events for touch and Windows synthesizes them from `WM_POINTER`. So a
302    /// game bound to `Binding::MouseButton` is playable with one finger without
303    /// knowing this variant exists, and a game that wants two fingers reads
304    /// `Touch` and ignores the emulation.
305    ///
306    /// That is an obligation on a backend that sets
307    /// [`ShellCaps::TOUCH`](crate::ShellCaps::TOUCH), not an accident of one
308    /// platform: a backend whose window system does not emulate must do it
309    /// itself, or every existing pointer binding stops working the day it
310    /// reports its first contact. Contacts that are not primary have no pointer
311    /// events at all — a second finger is not the first one teleporting.
312    ///
313    /// # No modifiers, and a position that is never absent
314    ///
315    /// A finger carries no `modifiers`: no platform reports keyboard state on a
316    /// touch event, and a field the backends would have to invent is a field a
317    /// consumer would believe. And `position` is a [`PhysicalPoint`] rather than
318    /// an `Option` — a contact is *defined* by where it touches, including on
319    /// the release, and there is no [`PointerMode::Locked`](crate::PointerMode)
320    /// equivalent that could take the position away.
321    Touch {
322        /// Which window.
323        window: WindowId,
324        /// Which touchscreen. Every contact on one screen shares this — see
325        /// [`ContactId`], which is the one that tells fingers apart.
326        device: DeviceId,
327        /// When it happened.
328        time: EventTime,
329        /// Which finger.
330        contact: ContactId,
331        /// What it just did.
332        phase: TouchPhase,
333        /// Where it is, in device pixels. On
334        /// [`Cancelled`](TouchPhase::Cancelled) this is the last place the
335        /// platform knew about rather than a place the user chose.
336        position: PhysicalPoint,
337    },
338
339    /// Text was committed — by typing, by an input method, or by a paste the
340    /// window system routed as text input.
341    ///
342    /// Separate from [`Key`](ShellEvent::Key) because the relationship is not
343    /// one-to-one: an IME commits several characters from one keystroke, or one
344    /// character from five, and a dead key commits nothing at all. A text field
345    /// that reconstructs its content from key events is a text field that
346    /// cannot type in Japanese.
347    TextCommit {
348        /// Which window.
349        window: WindowId,
350        /// When it happened.
351        time: EventTime,
352        /// The committed text. Never empty.
353        text: String,
354    },
355
356    /// An input method's composition in progress: the pre-edit a text field
357    /// draws at its caret, underlined, until it is committed or abandoned.
358    ///
359    /// **Each one replaces the last**, and an empty `text` means there is no
360    /// pre-edit any more: the composition was committed, cancelled, or deleted
361    /// back to nothing. The committed text itself arrives separately, as
362    /// [`TextCommit`](ShellEvent::TextCommit), and a consumer must not
363    /// synthesize it from the last pre-edit: an input method commits the
364    /// conversion the user chose, which is usually not the reading they typed.
365    /// The two streams are independent — a field inserts each commit at its
366    /// caret and draws the latest pre-edit after it — so their relative order
367    /// within one pump carries no meaning.
368    ///
369    /// The keystrokes that edit a composition are the input method's: a key
370    /// it consumes while composing is not reported as a
371    /// [`Key`](ShellEvent::Key).
372    ///
373    /// Sent where [`ShellCaps::TEXT_IME`](crate::ShellCaps::TEXT_IME) is set.
374    /// A backend without it never sends this.
375    /// [`Shell::set_text_input_area`](crate::Shell::set_text_input_area) is how a
376    /// field tells the input method where to put that window and its
377    /// candidate list.
378    TextPreedit {
379        /// Which window.
380        window: WindowId,
381        /// When it happened.
382        time: EventTime,
383        /// The composition so far. Empty when the pre-edit ended.
384        text: String,
385        /// Where the input method's cursor sits in `text`, as a byte offset on
386        /// a `char` boundary, if it said. `None` for an empty `text`.
387        cursor: Option<usize>,
388    },
389
390    /// The set of monitors changed — hotplug, resolution change, or a layout
391    /// rearrangement.
392    ///
393    /// Carries no payload: [`Shell::monitors`](crate::Shell::monitors) is the
394    /// authority, and duplicating the list into the event would let a consumer
395    /// hold a stale copy that looks authoritative. A window's *own* scale
396    /// change arrives separately as
397    /// [`ScaleFactorChanged`](ShellEvent::ScaleFactorChanged).
398    MonitorsChanged,
399
400    /// A monitor was disconnected.
401    ///
402    /// Arrives before the [`MonitorsChanged`](ShellEvent::MonitorsChanged)
403    /// after which [`Shell::monitors`](crate::Shell::monitors) no longer lists
404    /// it, so a consumer can act on the id while it still knows what the id
405    /// named. After that the id names nothing, and it is never reused.
406    ///
407    /// By the time that `MonitorsChanged` arrives, every
408    /// [panel](crate::WindowRole::Panel) created for this monitor has been sent
409    /// a [`CloseRequested`](ShellEvent::CloseRequested): a bar on an unplugged
410    /// display has nowhere to be. A window system that closes the panel itself
411    /// may send that request before this event. Other windows stay wherever
412    /// the window system moves them.
413    MonitorRemoved {
414        /// The monitor that went.
415        monitor: MonitorId,
416    },
417
418    /// The window asked for the keyboard and the window system refused it.
419    ///
420    /// A [panel](crate::WindowRole::Panel) that takes the keyboard outright
421    /// ([`PanelKeyboard::Exclusive`](crate::PanelKeyboard::Exclusive)) asks
422    /// when it is shown. Win32 refuses the foreground to a process the user is
423    /// not working in, and AppKit can decline to make the panel the key
424    /// window; then no [`Focus`](ShellEvent::Focus) follows: keys keep going
425    /// to the window the user was in. This is how a program finds
426    /// out, without guessing a timeout — to retry, to say so, or to draw the
427    /// user's attention another way.
428    FocusRefused {
429        /// The window that asked.
430        window: WindowId,
431    },
432
433    /// A full-screen window opened on this panel's monitor, or the last one
434    /// closed: `active` says which.
435    ///
436    /// For a bar that should step aside while a game or a video is full
437    /// screen. Raised on Win32, for a [panel](crate::WindowRole::Panel) that
438    /// reserves its edge ([`Reserve::OwnExtent`](crate::Reserve::OwnExtent)),
439    /// from the shell's `ABN_FULLSCREENAPP`. Wayland and AppKit do not need
440    /// it: their compositors already draw a full-screen window over a bar, or
441    /// in a space of its own. X11 does not raise it either, so a bar there
442    /// that cares watches `_NET_WM_STATE_FULLSCREEN` itself.
443    FullScreenApp {
444        /// The panel told.
445        window: WindowId,
446        /// Whether a full-screen window is now open on its monitor.
447        active: bool,
448    },
449
450    /// A good time to present the window's next frame: the answer to a
451    /// [`request_frame`](crate::Shell::request_frame).
452    ///
453    /// **One per request**, however many requests were made before it came,
454    /// and none for a window destroyed first. Where
455    /// [`ShellCaps::FRAME_CALLBACK`](crate::ShellCaps::FRAME_CALLBACK) is set
456    /// the compositor sends it, and may hold it back for as long as nothing of
457    /// the window is visible — an output it has turned off, a hidden window —
458    /// which is when presenting would block or be wasted. Elsewhere it arrives
459    /// at the next [`pump`](crate::Shell::pump) after the request.
460    FrameReady {
461        /// The window that may present.
462        window: WindowId,
463    },
464
465    /// A file was dropped on the window.
466    ///
467    /// One event per file: a multi-file drop produces several. Only delivered
468    /// when the window was created with
469    /// [`WindowDesc::accept_drops`](crate::WindowDesc::accept_drops) and
470    /// [`ShellCaps::DRAG_DROP`](crate::ShellCaps::DRAG_DROP) is set.
471    DroppedFile {
472        /// Which window.
473        window: WindowId,
474        /// When it happened.
475        time: EventTime,
476        /// The dropped path. Not guaranteed to exist or to be readable — it
477        /// comes from another process, and checking is the consumer's job.
478        path: PathBuf,
479        /// Where in the window it was dropped, if the backend said.
480        position: Option<PhysicalPoint>,
481    },
482
483    /// The answer to a [`clipboard_request`](crate::Shell::clipboard_request).
484    ///
485    /// **Exactly one of these arrives for every accepted request**, and it is
486    /// the only thing that ever answers one. It may be several frames late: a
487    /// backend holds a read until it can answer it rather than reporting an
488    /// empty clipboard it has not actually looked at. See
489    /// [`clipboard`](crate::clipboard) for why reads are asynchronous and why
490    /// there is no "ask again later" outcome.
491    ClipboardData {
492        /// Which window asked.
493        window: WindowId,
494        /// Which request this answers.
495        request: ClipboardRequestId,
496        /// The format that was delivered, spelled the way the *other*
497        /// application spelled it.
498        ///
499        /// A [`ReceivedMime`] rather than a
500        /// [`MimeType`](crate::MimeType) because the peer chose this string,
501        /// not us: asking for `text/plain;charset=utf-8` and being answered
502        /// with `text/plain` is normal and must round-trip verbatim. Compare
503        /// with [`ReceivedMime::matches`], never with string equality.
504        ///
505        /// For [`Empty`](ClipboardContent::Empty) and
506        /// [`Unavailable`](ClipboardContent::Unavailable) there is no peer
507        /// spelling to report, so this is the format that was *asked* for.
508        mime: ReceivedMime,
509        /// What the read produced — bytes, an empty clipboard, or a failure.
510        /// See [`ClipboardContent`], which exists because the three are not one
511        /// case.
512        content: ClipboardContent,
513    },
514
515    /// An assistive technology — a screen reader, a magnifier, voice control
516    /// — started reading `window`.
517    ///
518    /// From here until
519    /// [`AccessibilityDeactivated`](ShellEvent::AccessibilityDeactivated), the
520    /// program sends the window's accessibility tree with
521    /// [`update_accessibility`](crate::Shell::update_accessibility): a whole
522    /// tree first, then what changed. Before it nobody is reading, and building
523    /// a tree is wasted work.
524    #[cfg(feature = "accesskit")]
525    AccessibilityActivated {
526        /// The window being read.
527        window: WindowId,
528    },
529
530    /// An assistive technology asked for something done to a node of
531    /// `window`'s accessibility tree: focus it, click it, scroll it into view,
532    /// set its value.
533    ///
534    /// `request.target_node` is the node, by the
535    /// [`NodeId`](accesskit::NodeId) the program gave it. Act on it as the
536    /// matching input would have; the result shows in the next tree update,
537    /// and nothing else answers the request.
538    #[cfg(feature = "accesskit")]
539    AccessibilityAction {
540        /// The window the node is in.
541        window: WindowId,
542        /// What was asked, and of which node.
543        request: accesskit::ActionRequest,
544    },
545
546    /// The last assistive technology reading `window` stopped. Trees sent from
547    /// here on are dropped until the next
548    /// [`AccessibilityActivated`](ShellEvent::AccessibilityActivated), which
549    /// wants a whole tree again.
550    #[cfg(feature = "accesskit")]
551    AccessibilityDeactivated {
552        /// The window no longer read.
553        window: WindowId,
554    },
555}
556
557impl ShellEvent {
558    /// Which window this event concerns, if any.
559    ///
560    /// `None` only for [`MonitorsChanged`](ShellEvent::MonitorsChanged) and
561    /// [`MonitorRemoved`](ShellEvent::MonitorRemoved), which are properties of
562    /// the session rather than of a window. A multi-window consumer routes on
563    /// this.
564    #[must_use]
565    pub const fn window(&self) -> Option<WindowId> {
566        match self {
567            Self::Resized { window, .. }
568            | Self::ScaleFactorChanged { window, .. }
569            | Self::CloseRequested { window }
570            | Self::WindowDestroyed { window }
571            | Self::Focus { window, .. }
572            | Self::Key { window, .. }
573            | Self::PointerMotion { window, .. }
574            | Self::PointerFocus { window, .. }
575            | Self::Button { window, .. }
576            | Self::Wheel { window, .. }
577            | Self::Touch { window, .. }
578            | Self::TextCommit { window, .. }
579            | Self::TextPreedit { window, .. }
580            | Self::DroppedFile { window, .. }
581            | Self::FocusRefused { window }
582            | Self::FullScreenApp { window, .. }
583            | Self::FrameReady { window }
584            | Self::ClipboardData { window, .. } => Some(*window),
585            #[cfg(feature = "accesskit")]
586            Self::AccessibilityActivated { window }
587            | Self::AccessibilityAction { window, .. }
588            | Self::AccessibilityDeactivated { window } => Some(*window),
589            Self::MonitorsChanged | Self::MonitorRemoved { .. } => None,
590        }
591    }
592
593    /// When this event happened, for the variants that are input samples.
594    ///
595    /// `None` for state transitions; see the [module docs](self) for why they
596    /// deliberately have no timestamp.
597    #[must_use]
598    pub const fn time(&self) -> Option<EventTime> {
599        match self {
600            Self::Key { time, .. }
601            | Self::PointerMotion { time, .. }
602            | Self::PointerFocus { time, .. }
603            | Self::Button { time, .. }
604            | Self::Wheel { time, .. }
605            | Self::Touch { time, .. }
606            | Self::TextCommit { time, .. }
607            | Self::TextPreedit { time, .. }
608            | Self::DroppedFile { time, .. } => Some(*time),
609            Self::Resized { .. }
610            | Self::ScaleFactorChanged { .. }
611            | Self::CloseRequested { .. }
612            | Self::WindowDestroyed { .. }
613            | Self::Focus { .. }
614            | Self::MonitorsChanged
615            | Self::MonitorRemoved { .. }
616            | Self::FocusRefused { .. }
617            | Self::FullScreenApp { .. }
618            | Self::FrameReady { .. }
619            | Self::ClipboardData { .. } => None,
620            #[cfg(feature = "accesskit")]
621            Self::AccessibilityActivated { .. }
622            | Self::AccessibilityAction { .. }
623            | Self::AccessibilityDeactivated { .. } => None,
624        }
625    }
626
627    /// Whether this event feeds the input pipeline rather than the window
628    /// lifecycle.
629    ///
630    /// The P2 action layer subscribes to exactly this subset.
631    #[inline]
632    #[must_use]
633    pub const fn is_input(&self) -> bool {
634        self.time().is_some()
635    }
636
637    /// A short, stable name for logs and test assertions.
638    ///
639    /// Stable because tests assert on sequences of these: a scripted
640    /// `HeadlessShell` session compares `["Resized", "Focus", "Key", …]`, which
641    /// stays readable in a failure message in a way that a `Debug` dump of the
642    /// full payloads does not.
643    #[must_use]
644    pub const fn name(&self) -> &'static str {
645        match self {
646            Self::Resized { .. } => "Resized",
647            Self::ScaleFactorChanged { .. } => "ScaleFactorChanged",
648            Self::CloseRequested { .. } => "CloseRequested",
649            Self::WindowDestroyed { .. } => "WindowDestroyed",
650            Self::Focus { .. } => "Focus",
651            Self::Key { .. } => "Key",
652            Self::PointerMotion { .. } => "PointerMotion",
653            Self::PointerFocus { .. } => "PointerFocus",
654            Self::Button { .. } => "Button",
655            Self::Wheel { .. } => "Wheel",
656            Self::Touch { .. } => "Touch",
657            Self::TextCommit { .. } => "TextCommit",
658            Self::TextPreedit { .. } => "TextPreedit",
659            Self::MonitorsChanged => "MonitorsChanged",
660            Self::MonitorRemoved { .. } => "MonitorRemoved",
661            Self::FocusRefused { .. } => "FocusRefused",
662            Self::FullScreenApp { .. } => "FullScreenApp",
663            Self::FrameReady { .. } => "FrameReady",
664            Self::DroppedFile { .. } => "DroppedFile",
665            Self::ClipboardData { .. } => "ClipboardData",
666            #[cfg(feature = "accesskit")]
667            Self::AccessibilityActivated { .. } => "AccessibilityActivated",
668            #[cfg(feature = "accesskit")]
669            Self::AccessibilityAction { .. } => "AccessibilityAction",
670            #[cfg(feature = "accesskit")]
671            Self::AccessibilityDeactivated { .. } => "AccessibilityDeactivated",
672        }
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679    use core::time::Duration;
680    use crcbl_core::Pool;
681
682    fn window_id() -> WindowId {
683        let mut pool: Pool<u8> = Pool::new();
684        pool.insert(0).cast()
685    }
686
687    fn key_event(time: EventTime) -> ShellEvent {
688        ShellEvent::Key {
689            window: window_id(),
690            device: DeviceId(1),
691            time,
692            scancode: Scancode(57),
693            key_code: Some(KeyCode::Space),
694            keysym: Keysym::from_char(' '),
695            state: ButtonState::Pressed,
696            repeat: false,
697            modifiers: Modifiers::empty(),
698        }
699    }
700
701    #[test]
702    fn a_clipboard_answer_carries_the_peers_spelling() {
703        let event = ShellEvent::ClipboardData {
704            window: window_id(),
705            request: ClipboardRequestId(3),
706            mime: ReceivedMime::new("text/plain"),
707            content: ClipboardContent::Bytes(b"pasted".to_vec()),
708        };
709        let ShellEvent::ClipboardData { mime, content, .. } = &event else {
710            panic!("wrong variant");
711        };
712        assert_eq!(mime.as_str(), "text/plain");
713        assert!(mime.matches(crate::MimeType::TextUtf8));
714        assert_eq!(content.bytes(), Some(&b"pasted"[..]));
715        assert_eq!(content.text(), Some("pasted"));
716
717        // The three outcomes a caller must be able to tell apart. An empty
718        // *payload* is a successful read; an empty *clipboard* is not the same
719        // thing; and neither is a failure.
720        assert_eq!(ClipboardContent::Bytes(Vec::new()).bytes(), Some(&[][..]));
721        assert_ne!(ClipboardContent::Bytes(Vec::new()), ClipboardContent::Empty);
722        assert_eq!(ClipboardContent::Empty.bytes(), None);
723        assert_eq!(ClipboardContent::Unavailable.bytes(), None);
724        assert_ne!(ClipboardContent::Empty, ClipboardContent::Unavailable);
725        assert_eq!(ClipboardContent::Unavailable.name(), "Unavailable");
726        // Invalid UTF-8 is refused rather than lossily replaced.
727        assert_eq!(ClipboardContent::Bytes(vec![0xff]).text(), None);
728        assert_eq!(
729            event.time(),
730            None,
731            "a clipboard answer is not an input sample"
732        );
733    }
734
735    #[test]
736    fn input_events_carry_a_usable_timestamp_and_lifecycle_events_do_not() {
737        let pressed = key_event(EventTime::from_millis(1_000));
738        let released = key_event(EventTime::from_millis(1_180));
739
740        // This subtraction is the whole reason timestamps exist: 180 ms is a
741        // "tap", not a "hold", and no frame boundary got a vote.
742        let held = released
743            .time()
744            .expect("input")
745            .saturating_since(pressed.time().expect("input"));
746        assert_eq!(held, Duration::from_millis(180));
747
748        assert!(pressed.is_input());
749        assert!(!ShellEvent::MonitorsChanged.is_input());
750        assert_eq!(
751            ShellEvent::Resized {
752                window: window_id(),
753                size: PhysicalSize::new(1, 1),
754                scale_factor: 1.0,
755            }
756            .time(),
757            None
758        );
759    }
760
761    #[test]
762    fn every_variant_reports_its_window_and_a_stable_name() {
763        let window = window_id();
764        let events = [
765            ShellEvent::Resized {
766                window,
767                size: PhysicalSize::new(640, 360),
768                scale_factor: 1.0,
769            },
770            ShellEvent::ScaleFactorChanged {
771                window,
772                scale_factor: 2.0,
773                size: PhysicalSize::new(1280, 720),
774            },
775            ShellEvent::CloseRequested { window },
776            ShellEvent::WindowDestroyed { window },
777            ShellEvent::Focus {
778                window,
779                focused: true,
780            },
781            key_event(EventTime::ZERO),
782            ShellEvent::PointerMotion {
783                window,
784                device: DeviceId::UNKNOWN,
785                time: EventTime::ZERO,
786                abs: Some(PhysicalPoint::new(1.0, 2.0)),
787                raw_delta: Some((0.5, -0.5)),
788            },
789            ShellEvent::PointerFocus {
790                window,
791                device: DeviceId::UNKNOWN,
792                time: EventTime::ZERO,
793                entered: true,
794                position: None,
795            },
796            ShellEvent::Button {
797                window,
798                device: DeviceId::UNKNOWN,
799                time: EventTime::ZERO,
800                button: PointerButton::Left,
801                state: ButtonState::Pressed,
802                position: None,
803                modifiers: Modifiers::CTRL,
804            },
805            ShellEvent::Wheel {
806                window,
807                device: DeviceId::UNKNOWN,
808                time: EventTime::ZERO,
809                delta: ScrollDelta::Lines { x: 0.0, y: 1.0 },
810                position: None,
811                modifiers: Modifiers::empty(),
812            },
813            ShellEvent::Touch {
814                window,
815                device: DeviceId::UNKNOWN,
816                time: EventTime::ZERO,
817                contact: ContactId(1),
818                phase: TouchPhase::Began,
819                position: PhysicalPoint::new(4.0, 5.0),
820            },
821            ShellEvent::TextCommit {
822                window,
823                time: EventTime::ZERO,
824                text: "は".to_string(),
825            },
826            ShellEvent::DroppedFile {
827                window,
828                time: EventTime::ZERO,
829                path: PathBuf::from("/tmp/scene.ron"),
830                position: None,
831            },
832            ShellEvent::ClipboardData {
833                window,
834                request: ClipboardRequestId(1),
835                mime: ReceivedMime::from(crate::MimeType::TextUtf8),
836                content: ClipboardContent::Empty,
837            },
838            ShellEvent::MonitorsChanged,
839            ShellEvent::MonitorRemoved {
840                monitor: MonitorId(2),
841            },
842            ShellEvent::FocusRefused { window },
843            ShellEvent::FullScreenApp {
844                window,
845                active: true,
846            },
847            ShellEvent::FrameReady { window },
848        ];
849        #[cfg(feature = "accesskit")]
850        let events = {
851            let mut events = events.to_vec();
852            events.extend([
853                ShellEvent::AccessibilityActivated { window },
854                ShellEvent::AccessibilityAction {
855                    window,
856                    request: accesskit::ActionRequest {
857                        action: accesskit::Action::Click,
858                        target_tree: accesskit::TreeId::ROOT,
859                        target_node: accesskit::NodeId(1),
860                        data: None,
861                    },
862                },
863                ShellEvent::AccessibilityDeactivated { window },
864            ]);
865            events
866        };
867
868        let mut names = Vec::new();
869        for event in &events {
870            names.push(event.name());
871            match event {
872                ShellEvent::MonitorsChanged | ShellEvent::MonitorRemoved { .. } => {
873                    assert_eq!(event.window(), None);
874                }
875                _ => assert_eq!(event.window(), Some(window), "{}", event.name()),
876            }
877        }
878        let count = names.len();
879        names.sort_unstable();
880        names.dedup();
881        assert_eq!(names.len(), count, "event names must be unique");
882    }
883
884    /// Two fingers are two contacts, and the event says which is which.
885    ///
886    /// The shape claim in one test: a batch of touch events keyed by
887    /// [`ContactId`] can be reduced to a per-finger position, the two fingers do
888    /// not share an id, and moving one does not move the other. This is what the
889    /// seam is *for* — before it, a second finger had nowhere to go and the web
890    /// backend dropped it.
891    #[test]
892    fn a_batch_of_contacts_keeps_the_two_fingers_apart() {
893        let window = window_id();
894        let touch = |contact: u32, phase: TouchPhase, x: f64, y: f64| ShellEvent::Touch {
895            window,
896            device: DeviceId(3),
897            time: EventTime::from_millis(1),
898            contact: ContactId(contact),
899            phase,
900            position: PhysicalPoint::new(x, y),
901        };
902        let batch = [
903            touch(7, TouchPhase::Began, 10.0, 20.0),
904            touch(9, TouchPhase::Began, 300.0, 400.0),
905            touch(9, TouchPhase::Moved, 305.0, 410.0),
906            touch(7, TouchPhase::Ended, 10.0, 20.0),
907        ];
908
909        let mut live: Vec<(ContactId, TouchPhase, PhysicalPoint)> = Vec::new();
910        for event in &batch {
911            let ShellEvent::Touch {
912                contact,
913                phase,
914                position,
915                ..
916            } = event
917            else {
918                panic!("wrong variant");
919            };
920            live.retain(|(id, ..)| id != contact);
921            if !phase.ends_contact() {
922                live.push((*contact, *phase, *position));
923            }
924        }
925
926        // One finger left, at the place *its own* moves put it — not at the
927        // other's, and not at the average of the two.
928        assert_eq!(
929            live,
930            vec![(
931                ContactId(9),
932                TouchPhase::Moved,
933                PhysicalPoint::new(305.0, 410.0)
934            )]
935        );
936        assert!(TouchPhase::Ended.ends_contact());
937        assert!(TouchPhase::Cancelled.ends_contact());
938        assert!(!TouchPhase::Began.ends_contact());
939        assert!(!TouchPhase::Moved.ends_contact());
940        // A cancel is not an end: they agree that the contact is over and
941        // disagree about everything a consumer does next.
942        assert_ne!(TouchPhase::Ended, TouchPhase::Cancelled);
943        assert!(batch[0].is_input(), "a contact is an input sample");
944        assert_eq!(batch[0].time(), Some(EventTime::from_millis(1)));
945    }
946
947    #[test]
948    fn locked_pointer_motion_has_no_absolute_position() {
949        // The invariant `PointerMode::Locked` documents, expressed as a value.
950        let event = ShellEvent::PointerMotion {
951            window: window_id(),
952            device: DeviceId(2),
953            time: EventTime::from_micros(16_666),
954            abs: None,
955            raw_delta: Some((12.0, -3.0)),
956        };
957        let ShellEvent::PointerMotion { abs, raw_delta, .. } = event else {
958            panic!("wrong variant");
959        };
960        assert_eq!(abs, None);
961        assert_eq!(raw_delta, Some((12.0, -3.0)));
962    }
963}