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