crcbl-shell 0.17.2

Crucible windowing shell: own Wayland, X11, Web/canvas and headless backends
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
//! [`ShellEvent`] — everything the window system tells the engine, in engine
//! types.
//!
//! # Timestamps, and the clock they are on
//!
//! Every input-bearing variant carries an [`EventTime`]. This is not
//! decoration: the input design makes the pattern evaluator a pure function
//! over timestamped edges (`docs/notes/simulation.md`) — tap versus hold versus
//! double-tap are *defined* by inter-event durations — and topic 26's client
//! prediction needs input-to-photon latency, which is a subtraction between an
//! event timestamp and a present timestamp. Neither works if the timestamp is
//! the moment the engine got around to draining the queue: a 20 ms frame
//! quantizes every duration to 20 ms and makes a fast double-tap
//! indistinguishable from a slow one.
//!
//! So the timestamp is the **window system's**, rebased onto the engine's
//! monotonic clock. [`EventTime`]'s own documentation states the epoch and
//! spells out the per-platform rebasing each backend owes; the short version is
//! that an `EventTime` and [`FrameClock`](crcbl_core::FrameClock) readings live
//! on one timeline, so they can be subtracted.
//!
//! Non-input variants ([`Resized`](ShellEvent::Resized),
//! [`MonitorsChanged`](ShellEvent::MonitorsChanged)) carry no timestamp. They
//! are state transitions, not samples; nothing computes a duration between two
//! resizes, and a field that exists only to be ignored invites someone to use
//! it for something it is not.
//!
//! # Modifiers are stamped onto every event, not delivered separately
//!
//! Wayland sends `wl_keyboard.modifiers` as its own message, X11 puts a
//! modifier mask on each event, and Win32 makes you call `GetKeyState`. The
//! seam picks the X11 shape: the backend tracks modifier state and stamps the
//! current value onto each event.
//!
//! The reason is that the alternative loses information the consumer cannot
//! recover. If modifiers arrive as a separate event, a consumer that batches a
//! frame's events — which every consumer does — sees "Ctrl went down" and "S
//! went down" as two facts in a list and has to replay them in order to know
//! whether it was `Ctrl+S`. That replay is exactly the state machine each
//! backend has to write anyway, so it is written once, in the backend, where
//! the ordering is authoritative.
//!
//! # Non-exhaustive, unlike [`SurfaceTarget`](crcbl_core::SurfaceTarget)
//!
//! `SurfaceTarget` is deliberately exhaustive so a new platform breaks every
//! HAL backend loudly. `ShellEvent` is the opposite: a consumer that ignores an
//! event it has never heard of degrades gracefully (touch input does nothing,
//! rather than doing something wrong), and
//! [`TextPreedit`](ShellEvent::TextPreedit) arrived that way after it. Breaking
//! every `match` in the engine for each new event is not a useful forcing
//! function, it is churn. (Gamepads are not window events and do not come
//! through here: their backends emit `crcbl_input::GamepadEvent`s from a poll
//! of their own.)
//!
//! [`Touch`](ShellEvent::Touch) is what that promise looked like when it was
//! collected: it landed without touching a single `match` outside the shell,
//! and a game that never heard of it kept playing with one finger because the
//! primary contact is reported through the pointer variants as well.

use std::path::PathBuf;

use crcbl_core::EventTime;
use crcbl_core::input::{
    ButtonState, ContactId, DeviceId, Keysym, Modifiers, PointerButton, Scancode, ScrollDelta,
    TouchPhase,
};

use crate::{
    ClipboardContent, ClipboardRequestId, KeyCode, MonitorId, PhysicalPoint, PhysicalSize,
    ReceivedMime, WindowId,
};

/// Something the window system reported.
///
/// Delivered by [`Shell::pump`](crate::Shell::pump). See the [module
/// docs](self) for the timestamp and modifier conventions.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum ShellEvent {
    /// The client area changed size.
    ///
    /// The authoritative size for the swapchain — never the size that was
    /// asked for in [`WindowDesc`](crate::WindowDesc). Carries the scale factor
    /// too, because a resize and a scale change can arrive together (dragging a
    /// window between monitors of different DPI does exactly that) and a
    /// consumer that reads size from one event and scale from another will
    /// briefly hold a mismatched pair.
    Resized {
        /// Which window.
        window: WindowId,
        /// New client area in device pixels.
        size: PhysicalSize,
        /// Device pixels per logical unit, at the same instant.
        scale_factor: f64,
    },

    /// The window's scale factor changed — it moved to a different monitor, or
    /// the user changed the display scale.
    ///
    /// Carries the *new* physical size as well: on Wayland a scale change is a
    /// `configure` that also restates the size, and on Win32 `WM_DPICHANGED`
    /// hands over a suggested rectangle. A consumer must treat this as a resize
    /// too. The windowing rules (`docs/notes/backends.md`) call out "scale-factor changes
    /// mid-session must not leak wrong-size swapchains" as a required test, and
    /// putting both facts in one event is what makes that testable rather than
    /// order-dependent.
    ScaleFactorChanged {
        /// Which window.
        window: WindowId,
        /// New device pixels per logical unit.
        scale_factor: f64,
        /// Client area in device pixels after the change.
        size: PhysicalSize,
    },

    /// The user asked to close the window — title-bar button, Alt-F4, session
    /// logout.
    ///
    /// The window is **still open**. Answer with
    /// [`reply_close_request`](crate::Shell::reply_close_request); until you
    /// do, the shell holds the request. See
    /// [`CloseReply`](crate::CloseReply).
    CloseRequested {
        /// Which window.
        window: WindowId,
    },

    /// The window was destroyed and its [`WindowId`] is now stale.
    ///
    /// Follows a [`CloseReply::Close`](crate::CloseReply::Close), an explicit
    /// [`destroy_window`](crate::Shell::destroy_window), or the compositor
    /// taking the window away — every window at once when the connection to
    /// the window system is lost.
    WindowDestroyed {
        /// The window that is gone.
        window: WindowId,
    },

    /// Keyboard focus entered or left the window.
    ///
    /// A consumer **must** treat focus loss as "every key is now up": no
    /// platform delivers releases for keys held when focus leaves, and a game
    /// that does not clear its key state walks into a wall until the player
    /// taps the key again. The action layer (P2) does this centrally; raw
    /// consumers do it themselves.
    Focus {
        /// Which window.
        window: WindowId,
        /// Whether the window now has focus.
        focused: bool,
    },

    /// A key went down or came up.
    ///
    /// Three views of the same keystroke — see
    /// [`crcbl_core::input`] for why they are all here and what each is for.
    ///
    /// **A key the input method consumes while composing is not reported as
    /// a `Key`** — neither its press nor its release. The Backspace that
    /// deletes a kana from a [`TextPreedit`](ShellEvent::TextPreedit) and the
    /// Enter that commits the conversion are the input method's, and a text
    /// field that also saw them as editing keys would delete a character or
    /// submit the form. This is what a Wayland compositor already does by not
    /// sending the key at all; the Win32 backend recognizes the keystrokes its
    /// input method marks as taken. The AppKit backend cannot yet tell such a
    /// keystroke apart, and still reports it.
    Key {
        /// Which window had focus.
        window: WindowId,
        /// Which keyboard.
        device: DeviceId,
        /// When it happened; see the [module docs](self).
        time: EventTime,
        /// The platform's raw code, for keys the engine has no name for.
        scancode: Scancode,
        /// The physical key, layout-independent. `None` when the scancode maps
        /// to no key this engine names — the key is still bindable through
        /// `scancode`. Right for game bindings (WASD stays where the fingers
        /// are), wrong for menus and shortcuts: an xkb remap such as
        /// `caps:escape` or a virtual keyboard's own keymap does not change it
        /// (`wtype`'s Down arrives on evdev 1, which is Escape).
        key_code: Option<KeyCode>,
        /// The symbol this key produces in the current layout, for menus,
        /// shortcuts, rebind menus and shortcut labels. The keys no layout
        /// moves have named constants, [`Keysym::ESCAPE`], [`Keysym::DOWN`]
        /// and the rest.
        keysym: Keysym,
        /// Down or up.
        state: ButtonState,
        /// Whether this is an auto-repeat, not a fresh press.
        ///
        /// Text fields want repeats; a jump button must not. Filtering them at
        /// the source would break the first, keeping them silently breaks the
        /// second, so the fact is carried and the consumer decides.
        repeat: bool,
        /// Modifiers held at the time; see the [module docs](self).
        modifiers: Modifiers,
    },

    /// The pointer moved.
    ///
    /// Both fields are optional, and which are present is the difference
    /// between a UI event and a camera event:
    ///
    /// * `abs` is where the pointer is, in window pixels. `None` under
    ///   [`PointerMode::Locked`](crate::PointerMode::Locked), where there is no
    ///   meaningful position.
    /// * `raw_delta` is unaccelerated, unclipped motion. `None` when the
    ///   backend has no relative-motion source — see
    ///   [`ShellCaps::RAW_POINTER_MOTION`](crate::ShellCaps::RAW_POINTER_MOTION).
    ///
    /// A camera must use `raw_delta` and must not difference successive `abs`
    /// values: the latter is clamped at the screen edge and has pointer
    /// acceleration already applied, which is why aim in games that get this
    /// wrong stops working when the cursor reaches the edge of the display.
    PointerMotion {
        /// Which window.
        window: WindowId,
        /// Which pointing device.
        device: DeviceId,
        /// When it happened.
        time: EventTime,
        /// Position in the window, in device pixels.
        abs: Option<PhysicalPoint>,
        /// Unaccelerated relative motion in device pixels, `(dx, dy)`.
        raw_delta: Option<(f64, f64)>,
    },

    /// The pointer entered or left the window.
    ///
    /// Not in the windowing rules' event set, and added anyway: every backend
    /// delivers enter/leave natively (`wl_pointer.enter`, X11
    /// `EnterNotify`, `WM_MOUSELEAVE`), the UI layer needs it for hover state,
    /// and the alternative — inferring it from motion events that stop
    /// arriving — cannot distinguish "the pointer left" from "the pointer
    /// stopped moving".
    PointerFocus {
        /// Which window.
        window: WindowId,
        /// Which pointing device.
        device: DeviceId,
        /// When it happened.
        time: EventTime,
        /// Whether the pointer is now inside the window.
        entered: bool,
        /// Where it entered, if the backend said. Always `None` on leave.
        position: Option<PhysicalPoint>,
    },

    /// A pointer button went down or came up.
    Button {
        /// Which window.
        window: WindowId,
        /// Which pointing device.
        device: DeviceId,
        /// When it happened.
        time: EventTime,
        /// Which button.
        button: PointerButton,
        /// Down or up.
        state: ButtonState,
        /// Where the pointer was, if known. `None` under
        /// [`PointerMode::Locked`](crate::PointerMode::Locked).
        position: Option<PhysicalPoint>,
        /// Modifiers held at the time.
        modifiers: Modifiers,
    },

    /// A scroll wheel or touchpad scrolled.
    Wheel {
        /// Which window.
        window: WindowId,
        /// Which pointing device.
        device: DeviceId,
        /// When it happened.
        time: EventTime,
        /// How far, in the units the device reported — see
        /// [`ScrollDelta`], which does not collapse detents into pixels.
        delta: ScrollDelta,
        /// Where the pointer was, if known.
        position: Option<PhysicalPoint>,
        /// Modifiers held at the time. Ctrl+wheel is zoom nearly everywhere,
        /// so this is load-bearing rather than informational.
        modifiers: Modifiers,
    },

    /// A finger landed, moved, lifted, or had its gesture taken away.
    ///
    /// **One event per contact**, so two fingers moving at once are two events
    /// and not one averaged pointer. [`ContactId`] carries the identity and
    /// [`TouchPhase`] the lifecycle; between them a consumer can hold per-finger
    /// state, which is the whole reason this variant exists and the reason a
    /// second finger used to be dropped at the backend.
    ///
    /// # A touchscreen also produces pointer events, and that is deliberate
    ///
    /// The **primary** contact — the first finger of a gesture, and the only one
    /// while there is only one — is *also* reported as
    /// [`PointerMotion`](ShellEvent::PointerMotion) and
    /// [`Button`](ShellEvent::Button), exactly as a browser emulates mouse
    /// events for touch and Windows synthesizes them from `WM_POINTER`. So a
    /// game bound to `Binding::MouseButton` is playable with one finger without
    /// knowing this variant exists, and a game that wants two fingers reads
    /// `Touch` and ignores the emulation.
    ///
    /// That is an obligation on a backend that sets
    /// [`ShellCaps::TOUCH`](crate::ShellCaps::TOUCH), not an accident of one
    /// platform: a backend whose window system does not emulate must do it
    /// itself, or every existing pointer binding stops working the day it
    /// reports its first contact. Contacts that are not primary have no pointer
    /// events at all — a second finger is not the first one teleporting.
    ///
    /// # No modifiers, and a position that is never absent
    ///
    /// A finger carries no `modifiers`: no platform reports keyboard state on a
    /// touch event, and a field the backends would have to invent is a field a
    /// consumer would believe. And `position` is a [`PhysicalPoint`] rather than
    /// an `Option` — a contact is *defined* by where it touches, including on
    /// the release, and there is no [`PointerMode::Locked`](crate::PointerMode)
    /// equivalent that could take the position away.
    Touch {
        /// Which window.
        window: WindowId,
        /// Which touchscreen. Every contact on one screen shares this — see
        /// [`ContactId`], which is the one that tells fingers apart.
        device: DeviceId,
        /// When it happened.
        time: EventTime,
        /// Which finger.
        contact: ContactId,
        /// What it just did.
        phase: TouchPhase,
        /// Where it is, in device pixels. On
        /// [`Cancelled`](TouchPhase::Cancelled) this is the last place the
        /// platform knew about rather than a place the user chose.
        position: PhysicalPoint,
    },

    /// Text was committed — by typing, by an input method, or by a paste the
    /// window system routed as text input.
    ///
    /// Separate from [`Key`](ShellEvent::Key) because the relationship is not
    /// one-to-one: an IME commits several characters from one keystroke, or one
    /// character from five, and a dead key commits nothing at all. A text field
    /// that reconstructs its content from key events is a text field that
    /// cannot type in Japanese.
    TextCommit {
        /// Which window.
        window: WindowId,
        /// When it happened.
        time: EventTime,
        /// The committed text. Never empty.
        text: String,
    },

    /// An input method's composition in progress: the pre-edit a text field
    /// draws at its caret, underlined, until it is committed or abandoned.
    ///
    /// **Each one replaces the last**, and an empty `text` means there is no
    /// pre-edit any more: the composition was committed, cancelled, or deleted
    /// back to nothing. The committed text itself arrives separately, as
    /// [`TextCommit`](ShellEvent::TextCommit), and a consumer must not
    /// synthesize it from the last pre-edit: an input method commits the
    /// conversion the user chose, which is usually not the reading they typed.
    /// The two streams are independent — a field inserts each commit at its
    /// caret and draws the latest pre-edit after it — so their relative order
    /// within one pump carries no meaning.
    ///
    /// The keystrokes that edit a composition are the input method's: a key
    /// it consumes while composing is not reported as a
    /// [`Key`](ShellEvent::Key).
    ///
    /// Sent where [`ShellCaps::TEXT_IME`](crate::ShellCaps::TEXT_IME) is set.
    /// A backend without it never sends this.
    /// [`Shell::set_text_input_area`](crate::Shell::set_text_input_area) is how a
    /// field tells the input method where to put that window and its
    /// candidate list.
    TextPreedit {
        /// Which window.
        window: WindowId,
        /// When it happened.
        time: EventTime,
        /// The composition so far. Empty when the pre-edit ended.
        text: String,
        /// Where the input method's cursor sits in `text`, as a byte offset on
        /// a `char` boundary, if it said. `None` for an empty `text`.
        cursor: Option<usize>,
    },

    /// The set of monitors changed — hotplug, resolution change, or a layout
    /// rearrangement.
    ///
    /// Carries no payload: [`Shell::monitors`](crate::Shell::monitors) is the
    /// authority, and duplicating the list into the event would let a consumer
    /// hold a stale copy that looks authoritative. A window's *own* scale
    /// change arrives separately as
    /// [`ScaleFactorChanged`](ShellEvent::ScaleFactorChanged).
    MonitorsChanged,

    /// A monitor was disconnected.
    ///
    /// Arrives before the [`MonitorsChanged`](ShellEvent::MonitorsChanged)
    /// after which [`Shell::monitors`](crate::Shell::monitors) no longer lists
    /// it, so a consumer can act on the id while it still knows what the id
    /// named. After that the id names nothing, and it is never reused.
    ///
    /// By the time that `MonitorsChanged` arrives, every
    /// [panel](crate::WindowRole::Panel) created for this monitor has been sent
    /// a [`CloseRequested`](ShellEvent::CloseRequested): a bar on an unplugged
    /// display has nowhere to be. A window system that closes the panel itself
    /// may send that request before this event. Other windows stay wherever
    /// the window system moves them.
    MonitorRemoved {
        /// The monitor that went.
        monitor: MonitorId,
    },

    /// The window asked for the keyboard and the window system refused it.
    ///
    /// A [panel](crate::WindowRole::Panel) that takes the keyboard outright
    /// ([`PanelKeyboard::Exclusive`](crate::PanelKeyboard::Exclusive)) asks
    /// when it is shown. Win32 refuses the foreground to a process the user is
    /// not working in, and AppKit can decline to make the panel the key
    /// window; then no [`Focus`](ShellEvent::Focus) follows: keys keep going
    /// to the window the user was in. This is how a program finds
    /// out, without guessing a timeout — to retry, to say so, or to draw the
    /// user's attention another way.
    FocusRefused {
        /// The window that asked.
        window: WindowId,
    },

    /// A full-screen window opened on this panel's monitor, or the last one
    /// closed: `active` says which.
    ///
    /// For a bar that should step aside while a game or a video is full
    /// screen. Raised on Win32, for a [panel](crate::WindowRole::Panel) that
    /// reserves its edge ([`Reserve::OwnExtent`](crate::Reserve::OwnExtent)),
    /// from the shell's `ABN_FULLSCREENAPP`. Wayland and AppKit do not need
    /// it: their compositors already draw a full-screen window over a bar, or
    /// in a space of its own. X11 does not raise it either, so a bar there
    /// that cares watches `_NET_WM_STATE_FULLSCREEN` itself.
    FullScreenApp {
        /// The panel told.
        window: WindowId,
        /// Whether a full-screen window is now open on its monitor.
        active: bool,
    },

    /// A good time to present the window's next frame: the answer to a
    /// [`request_frame`](crate::Shell::request_frame).
    ///
    /// **One per request**, however many requests were made before it came,
    /// and none for a window destroyed first. Where
    /// [`ShellCaps::FRAME_CALLBACK`](crate::ShellCaps::FRAME_CALLBACK) is set
    /// the compositor sends it, and may hold it back for as long as nothing of
    /// the window is visible — an output it has turned off, a hidden window —
    /// which is when presenting would block or be wasted. Elsewhere it arrives
    /// at the next [`pump`](crate::Shell::pump) after the request.
    FrameReady {
        /// The window that may present.
        window: WindowId,
    },

    /// A file was dropped on the window.
    ///
    /// One event per file: a multi-file drop produces several. Only delivered
    /// when the window was created with
    /// [`WindowDesc::accept_drops`](crate::WindowDesc::accept_drops) and
    /// [`ShellCaps::DRAG_DROP`](crate::ShellCaps::DRAG_DROP) is set.
    DroppedFile {
        /// Which window.
        window: WindowId,
        /// When it happened.
        time: EventTime,
        /// The dropped path. Not guaranteed to exist or to be readable — it
        /// comes from another process, and checking is the consumer's job.
        path: PathBuf,
        /// Where in the window it was dropped, if the backend said.
        position: Option<PhysicalPoint>,
    },

    /// The answer to a [`clipboard_request`](crate::Shell::clipboard_request).
    ///
    /// **Exactly one of these arrives for every accepted request**, and it is
    /// the only thing that ever answers one. It may be several frames late: a
    /// backend holds a read until it can answer it rather than reporting an
    /// empty clipboard it has not actually looked at. See
    /// [`clipboard`](crate::clipboard) for why reads are asynchronous and why
    /// there is no "ask again later" outcome.
    ClipboardData {
        /// Which window asked.
        window: WindowId,
        /// Which request this answers.
        request: ClipboardRequestId,
        /// The format that was delivered, spelled the way the *other*
        /// application spelled it.
        ///
        /// A [`ReceivedMime`] rather than a
        /// [`MimeType`](crate::MimeType) because the peer chose this string,
        /// not us: asking for `text/plain;charset=utf-8` and being answered
        /// with `text/plain` is normal and must round-trip verbatim. Compare
        /// with [`ReceivedMime::matches`], never with string equality.
        ///
        /// For [`Empty`](ClipboardContent::Empty) and
        /// [`Unavailable`](ClipboardContent::Unavailable) there is no peer
        /// spelling to report, so this is the format that was *asked* for.
        mime: ReceivedMime,
        /// What the read produced — bytes, an empty clipboard, or a failure.
        /// See [`ClipboardContent`], which exists because the three are not one
        /// case.
        content: ClipboardContent,
    },

    /// An assistive technology — a screen reader, a magnifier, voice control
    /// — started reading `window`.
    ///
    /// From here until
    /// [`AccessibilityDeactivated`](ShellEvent::AccessibilityDeactivated), the
    /// program sends the window's accessibility tree with
    /// [`update_accessibility`](crate::Shell::update_accessibility): a whole
    /// tree first, then what changed. Before it nobody is reading, and building
    /// a tree is wasted work.
    #[cfg(feature = "accesskit")]
    AccessibilityActivated {
        /// The window being read.
        window: WindowId,
    },

    /// An assistive technology asked for something done to a node of
    /// `window`'s accessibility tree: focus it, click it, scroll it into view,
    /// set its value.
    ///
    /// `request.target_node` is the node, by the
    /// [`NodeId`](accesskit::NodeId) the program gave it. Act on it as the
    /// matching input would have; the result shows in the next tree update,
    /// and nothing else answers the request.
    #[cfg(feature = "accesskit")]
    AccessibilityAction {
        /// The window the node is in.
        window: WindowId,
        /// What was asked, and of which node.
        request: accesskit::ActionRequest,
    },

    /// The last assistive technology reading `window` stopped. Trees sent from
    /// here on are dropped until the next
    /// [`AccessibilityActivated`](ShellEvent::AccessibilityActivated), which
    /// wants a whole tree again.
    #[cfg(feature = "accesskit")]
    AccessibilityDeactivated {
        /// The window no longer read.
        window: WindowId,
    },
}

impl ShellEvent {
    /// Which window this event concerns, if any.
    ///
    /// `None` only for [`MonitorsChanged`](ShellEvent::MonitorsChanged) and
    /// [`MonitorRemoved`](ShellEvent::MonitorRemoved), which are properties of
    /// the session rather than of a window. A multi-window consumer routes on
    /// this.
    #[must_use]
    pub const fn window(&self) -> Option<WindowId> {
        match self {
            Self::Resized { window, .. }
            | Self::ScaleFactorChanged { window, .. }
            | Self::CloseRequested { window }
            | Self::WindowDestroyed { window }
            | Self::Focus { window, .. }
            | Self::Key { window, .. }
            | Self::PointerMotion { window, .. }
            | Self::PointerFocus { window, .. }
            | Self::Button { window, .. }
            | Self::Wheel { window, .. }
            | Self::Touch { window, .. }
            | Self::TextCommit { window, .. }
            | Self::TextPreedit { window, .. }
            | Self::DroppedFile { window, .. }
            | Self::FocusRefused { window }
            | Self::FullScreenApp { window, .. }
            | Self::FrameReady { window }
            | Self::ClipboardData { window, .. } => Some(*window),
            #[cfg(feature = "accesskit")]
            Self::AccessibilityActivated { window }
            | Self::AccessibilityAction { window, .. }
            | Self::AccessibilityDeactivated { window } => Some(*window),
            Self::MonitorsChanged | Self::MonitorRemoved { .. } => None,
        }
    }

    /// When this event happened, for the variants that are input samples.
    ///
    /// `None` for state transitions; see the [module docs](self) for why they
    /// deliberately have no timestamp.
    #[must_use]
    pub const fn time(&self) -> Option<EventTime> {
        match self {
            Self::Key { time, .. }
            | Self::PointerMotion { time, .. }
            | Self::PointerFocus { time, .. }
            | Self::Button { time, .. }
            | Self::Wheel { time, .. }
            | Self::Touch { time, .. }
            | Self::TextCommit { time, .. }
            | Self::TextPreedit { time, .. }
            | Self::DroppedFile { time, .. } => Some(*time),
            Self::Resized { .. }
            | Self::ScaleFactorChanged { .. }
            | Self::CloseRequested { .. }
            | Self::WindowDestroyed { .. }
            | Self::Focus { .. }
            | Self::MonitorsChanged
            | Self::MonitorRemoved { .. }
            | Self::FocusRefused { .. }
            | Self::FullScreenApp { .. }
            | Self::FrameReady { .. }
            | Self::ClipboardData { .. } => None,
            #[cfg(feature = "accesskit")]
            Self::AccessibilityActivated { .. }
            | Self::AccessibilityAction { .. }
            | Self::AccessibilityDeactivated { .. } => None,
        }
    }

    /// Whether this event feeds the input pipeline rather than the window
    /// lifecycle.
    ///
    /// The P2 action layer subscribes to exactly this subset.
    #[inline]
    #[must_use]
    pub const fn is_input(&self) -> bool {
        self.time().is_some()
    }

    /// A short, stable name for logs and test assertions.
    ///
    /// Stable because tests assert on sequences of these: a scripted
    /// `HeadlessShell` session compares `["Resized", "Focus", "Key", …]`, which
    /// stays readable in a failure message in a way that a `Debug` dump of the
    /// full payloads does not.
    #[must_use]
    pub const fn name(&self) -> &'static str {
        match self {
            Self::Resized { .. } => "Resized",
            Self::ScaleFactorChanged { .. } => "ScaleFactorChanged",
            Self::CloseRequested { .. } => "CloseRequested",
            Self::WindowDestroyed { .. } => "WindowDestroyed",
            Self::Focus { .. } => "Focus",
            Self::Key { .. } => "Key",
            Self::PointerMotion { .. } => "PointerMotion",
            Self::PointerFocus { .. } => "PointerFocus",
            Self::Button { .. } => "Button",
            Self::Wheel { .. } => "Wheel",
            Self::Touch { .. } => "Touch",
            Self::TextCommit { .. } => "TextCommit",
            Self::TextPreedit { .. } => "TextPreedit",
            Self::MonitorsChanged => "MonitorsChanged",
            Self::MonitorRemoved { .. } => "MonitorRemoved",
            Self::FocusRefused { .. } => "FocusRefused",
            Self::FullScreenApp { .. } => "FullScreenApp",
            Self::FrameReady { .. } => "FrameReady",
            Self::DroppedFile { .. } => "DroppedFile",
            Self::ClipboardData { .. } => "ClipboardData",
            #[cfg(feature = "accesskit")]
            Self::AccessibilityActivated { .. } => "AccessibilityActivated",
            #[cfg(feature = "accesskit")]
            Self::AccessibilityAction { .. } => "AccessibilityAction",
            #[cfg(feature = "accesskit")]
            Self::AccessibilityDeactivated { .. } => "AccessibilityDeactivated",
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use core::time::Duration;
    use crcbl_core::Pool;

    fn window_id() -> WindowId {
        let mut pool: Pool<u8> = Pool::new();
        pool.insert(0).cast()
    }

    fn key_event(time: EventTime) -> ShellEvent {
        ShellEvent::Key {
            window: window_id(),
            device: DeviceId(1),
            time,
            scancode: Scancode(57),
            key_code: Some(KeyCode::Space),
            keysym: Keysym::from_char(' '),
            state: ButtonState::Pressed,
            repeat: false,
            modifiers: Modifiers::empty(),
        }
    }

    #[test]
    fn a_clipboard_answer_carries_the_peers_spelling() {
        let event = ShellEvent::ClipboardData {
            window: window_id(),
            request: ClipboardRequestId(3),
            mime: ReceivedMime::new("text/plain"),
            content: ClipboardContent::Bytes(b"pasted".to_vec()),
        };
        let ShellEvent::ClipboardData { mime, content, .. } = &event else {
            panic!("wrong variant");
        };
        assert_eq!(mime.as_str(), "text/plain");
        assert!(mime.matches(crate::MimeType::TextUtf8));
        assert_eq!(content.bytes(), Some(&b"pasted"[..]));
        assert_eq!(content.text(), Some("pasted"));

        // The three outcomes a caller must be able to tell apart. An empty
        // *payload* is a successful read; an empty *clipboard* is not the same
        // thing; and neither is a failure.
        assert_eq!(ClipboardContent::Bytes(Vec::new()).bytes(), Some(&[][..]));
        assert_ne!(ClipboardContent::Bytes(Vec::new()), ClipboardContent::Empty);
        assert_eq!(ClipboardContent::Empty.bytes(), None);
        assert_eq!(ClipboardContent::Unavailable.bytes(), None);
        assert_ne!(ClipboardContent::Empty, ClipboardContent::Unavailable);
        assert_eq!(ClipboardContent::Unavailable.name(), "Unavailable");
        // Invalid UTF-8 is refused rather than lossily replaced.
        assert_eq!(ClipboardContent::Bytes(vec![0xff]).text(), None);
        assert_eq!(
            event.time(),
            None,
            "a clipboard answer is not an input sample"
        );
    }

    #[test]
    fn input_events_carry_a_usable_timestamp_and_lifecycle_events_do_not() {
        let pressed = key_event(EventTime::from_millis(1_000));
        let released = key_event(EventTime::from_millis(1_180));

        // This subtraction is the whole reason timestamps exist: 180 ms is a
        // "tap", not a "hold", and no frame boundary got a vote.
        let held = released
            .time()
            .expect("input")
            .saturating_since(pressed.time().expect("input"));
        assert_eq!(held, Duration::from_millis(180));

        assert!(pressed.is_input());
        assert!(!ShellEvent::MonitorsChanged.is_input());
        assert_eq!(
            ShellEvent::Resized {
                window: window_id(),
                size: PhysicalSize::new(1, 1),
                scale_factor: 1.0,
            }
            .time(),
            None
        );
    }

    #[test]
    fn every_variant_reports_its_window_and_a_stable_name() {
        let window = window_id();
        let events = [
            ShellEvent::Resized {
                window,
                size: PhysicalSize::new(640, 360),
                scale_factor: 1.0,
            },
            ShellEvent::ScaleFactorChanged {
                window,
                scale_factor: 2.0,
                size: PhysicalSize::new(1280, 720),
            },
            ShellEvent::CloseRequested { window },
            ShellEvent::WindowDestroyed { window },
            ShellEvent::Focus {
                window,
                focused: true,
            },
            key_event(EventTime::ZERO),
            ShellEvent::PointerMotion {
                window,
                device: DeviceId::UNKNOWN,
                time: EventTime::ZERO,
                abs: Some(PhysicalPoint::new(1.0, 2.0)),
                raw_delta: Some((0.5, -0.5)),
            },
            ShellEvent::PointerFocus {
                window,
                device: DeviceId::UNKNOWN,
                time: EventTime::ZERO,
                entered: true,
                position: None,
            },
            ShellEvent::Button {
                window,
                device: DeviceId::UNKNOWN,
                time: EventTime::ZERO,
                button: PointerButton::Left,
                state: ButtonState::Pressed,
                position: None,
                modifiers: Modifiers::CTRL,
            },
            ShellEvent::Wheel {
                window,
                device: DeviceId::UNKNOWN,
                time: EventTime::ZERO,
                delta: ScrollDelta::Lines { x: 0.0, y: 1.0 },
                position: None,
                modifiers: Modifiers::empty(),
            },
            ShellEvent::Touch {
                window,
                device: DeviceId::UNKNOWN,
                time: EventTime::ZERO,
                contact: ContactId(1),
                phase: TouchPhase::Began,
                position: PhysicalPoint::new(4.0, 5.0),
            },
            ShellEvent::TextCommit {
                window,
                time: EventTime::ZERO,
                text: "は".to_string(),
            },
            ShellEvent::DroppedFile {
                window,
                time: EventTime::ZERO,
                path: PathBuf::from("/tmp/scene.ron"),
                position: None,
            },
            ShellEvent::ClipboardData {
                window,
                request: ClipboardRequestId(1),
                mime: ReceivedMime::from(crate::MimeType::TextUtf8),
                content: ClipboardContent::Empty,
            },
            ShellEvent::MonitorsChanged,
            ShellEvent::MonitorRemoved {
                monitor: MonitorId(2),
            },
            ShellEvent::FocusRefused { window },
            ShellEvent::FullScreenApp {
                window,
                active: true,
            },
            ShellEvent::FrameReady { window },
        ];
        #[cfg(feature = "accesskit")]
        let events = {
            let mut events = events.to_vec();
            events.extend([
                ShellEvent::AccessibilityActivated { window },
                ShellEvent::AccessibilityAction {
                    window,
                    request: accesskit::ActionRequest {
                        action: accesskit::Action::Click,
                        target_tree: accesskit::TreeId::ROOT,
                        target_node: accesskit::NodeId(1),
                        data: None,
                    },
                },
                ShellEvent::AccessibilityDeactivated { window },
            ]);
            events
        };

        let mut names = Vec::new();
        for event in &events {
            names.push(event.name());
            match event {
                ShellEvent::MonitorsChanged | ShellEvent::MonitorRemoved { .. } => {
                    assert_eq!(event.window(), None);
                }
                _ => assert_eq!(event.window(), Some(window), "{}", event.name()),
            }
        }
        let count = names.len();
        names.sort_unstable();
        names.dedup();
        assert_eq!(names.len(), count, "event names must be unique");
    }

    /// Two fingers are two contacts, and the event says which is which.
    ///
    /// The shape claim in one test: a batch of touch events keyed by
    /// [`ContactId`] can be reduced to a per-finger position, the two fingers do
    /// not share an id, and moving one does not move the other. This is what the
    /// seam is *for* — before it, a second finger had nowhere to go and the web
    /// backend dropped it.
    #[test]
    fn a_batch_of_contacts_keeps_the_two_fingers_apart() {
        let window = window_id();
        let touch = |contact: u32, phase: TouchPhase, x: f64, y: f64| ShellEvent::Touch {
            window,
            device: DeviceId(3),
            time: EventTime::from_millis(1),
            contact: ContactId(contact),
            phase,
            position: PhysicalPoint::new(x, y),
        };
        let batch = [
            touch(7, TouchPhase::Began, 10.0, 20.0),
            touch(9, TouchPhase::Began, 300.0, 400.0),
            touch(9, TouchPhase::Moved, 305.0, 410.0),
            touch(7, TouchPhase::Ended, 10.0, 20.0),
        ];

        let mut live: Vec<(ContactId, TouchPhase, PhysicalPoint)> = Vec::new();
        for event in &batch {
            let ShellEvent::Touch {
                contact,
                phase,
                position,
                ..
            } = event
            else {
                panic!("wrong variant");
            };
            live.retain(|(id, ..)| id != contact);
            if !phase.ends_contact() {
                live.push((*contact, *phase, *position));
            }
        }

        // One finger left, at the place *its own* moves put it — not at the
        // other's, and not at the average of the two.
        assert_eq!(
            live,
            vec![(
                ContactId(9),
                TouchPhase::Moved,
                PhysicalPoint::new(305.0, 410.0)
            )]
        );
        assert!(TouchPhase::Ended.ends_contact());
        assert!(TouchPhase::Cancelled.ends_contact());
        assert!(!TouchPhase::Began.ends_contact());
        assert!(!TouchPhase::Moved.ends_contact());
        // A cancel is not an end: they agree that the contact is over and
        // disagree about everything a consumer does next.
        assert_ne!(TouchPhase::Ended, TouchPhase::Cancelled);
        assert!(batch[0].is_input(), "a contact is an input sample");
        assert_eq!(batch[0].time(), Some(EventTime::from_millis(1)));
    }

    #[test]
    fn locked_pointer_motion_has_no_absolute_position() {
        // The invariant `PointerMode::Locked` documents, expressed as a value.
        let event = ShellEvent::PointerMotion {
            window: window_id(),
            device: DeviceId(2),
            time: EventTime::from_micros(16_666),
            abs: None,
            raw_delta: Some((12.0, -3.0)),
        };
        let ShellEvent::PointerMotion { abs, raw_delta, .. } = event else {
            panic!("wrong variant");
        };
        assert_eq!(abs, None);
        assert_eq!(raw_delta, Some((12.0, -3.0)));
    }
}