crcbl-shell 0.12.0

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
//! `NSApplication` bootstrap, the window delegate class, and what the delegate
//! records into.
//!
//! # Decision: the main thread is a precondition, not a preference
//!
//! AppKit is main-thread-only, and not in the soft sense that it is merely
//! recommended: `-[NSApplication nextEventMatchingMask:untilDate:inMode:dequeue:]`
//! carries an assertion that **raises `NSInternalInconsistencyException`** when
//! it is called from anywhere else, and an Objective-C exception unwinding
//! through a Rust frame is undefined behaviour. Even where a call does not
//! assert, the WindowServer's mach port is attached to the *main* run loop, so a
//! secondary thread's pump would create windows that never receive an event.
//!
//! So [`require_main_thread`] is checked in [`AppKitShell::open`](super::AppKitShell)
//! before anything else, and a shell created anywhere else is a clean
//! [`ShellError::Backend`] naming the rule. This is the concrete reason
//! [`Shell`](crate::Shell) is not `Send`, stated in the crate documentation and
//! enforced here.
//!
//! **This has a consequence for testing that is worth knowing before writing a
//! test rather than after.** Rust's `libtest` runs a test body on a thread it
//! spawns — measured on this workspace's toolchain, with and without
//! `--test-threads=1`, and true in both — so a `#[test]` can never be on the
//! main thread and can never drive a window. `docs/backlog.md` carries what that
//! costs and the one-line manifest change that would buy it back.
//!
//! # Decision: `NSApplicationActivationPolicyRegular`, explicitly
//!
//! A process that is not inside a `.app` bundle starts as a background
//! application: no Dock icon, no menu bar, and — the part that matters — it
//! **cannot become frontmost**, so its windows open behind everything and never
//! take the keyboard. `setActivationPolicy:` is the documented lever that turns
//! an unbundled binary into an ordinary application, and it is what every engine
//! that ships a plain executable calls.
//!
//! It is asked for rather than assumed, and its answer is kept: the call returns
//! `BOOL` and [`Bootstrap::policy_accepted`] is that answer, because "the window
//! opened but nothing could focus it" is a failure that otherwise arrives with
//! no diagnosis at all.
//!
//! # Decision: an observer, not the application delegate
//!
//! Screen reconfiguration is `applicationDidChangeScreenParameters:` on
//! `NSApplicationDelegate`, and taking that slot would displace the delegate of
//! any host application that embeds this engine — a shell is not entitled to be
//! the application. `NSNotificationCenter` delivers the same fact to an object
//! of our own, and the observer is removed when the shell is dropped.

use core::ffi::CStr;
use core::ptr;
use std::cell::{Cell, RefCell};
use std::path::PathBuf;
use std::sync::OnceLock;

use crate::{PhysicalRect, ShellBackend, ShellError, TextPurpose};

use super::events::{RawEvent, enqueue};
use super::ffi::{self, Class, Id, Imp, ObjcBool, Pool, Sel, YES, value};

/// Everything the delegate and view callbacks write into, and the pump reads out
/// of.
///
/// The AppKit counterpart of the Win32 backend's `Shared`, and it exists for the
/// same reason: a callback runs re-entrantly inside a shell method, so it may
/// not touch the shell. A [`RefCell`] rather than a lock, because the whole of
/// this backend is main-thread-only and a second thread cannot reach it.
///
/// Every field beside the queue is there because a callback needs a value the
/// shell owns and cannot pass it one: an Objective-C object built by
/// [`objc_allocateClassPair`](super::ffi::objc_allocateClassPair) has no
/// instance storage here — see [`DELEGATES`] for why that is deliberate — so
/// anything a method needs across calls lives on this side of the boundary.
#[derive(Debug, Default)]
pub(super) struct Shared {
    queue: RefCell<Vec<RawEvent>>,
    /// Text the input method committed, waiting for its
    /// [`TextCommitted`](RawEvent::TextCommitted) marker to claim it.
    ///
    /// Keyed by window and consumed in order, which is the same arrangement the
    /// Win32 backend uses for a file drop's paths: the payload cannot live in a
    /// [`Copy`] enum, and its *place in the stream* still has to be kept.
    text: RefCell<Vec<(usize, String)>>,
    /// Paths a drop delivered, waiting for its
    /// [`FilesDropped`](RawEvent::FilesDropped) marker to claim them.
    ///
    /// The same arrangement as [`text`](Self::text) and for the same reason: a
    /// dragging pasteboard is only valid inside `performDragOperation:`, so the
    /// paths are read there, and a `Vec<PathBuf>` cannot live in a [`Copy`]
    /// enum.
    drops: RefCell<Vec<(usize, Vec<PathBuf>)>>,
    /// The timestamp of the key event currently inside `interpretKeyEvents:`.
    ///
    /// `insertText:` is called synchronously from within that call and carries
    /// no event of its own, so the commit would otherwise have to be stamped
    /// with the moment the input method got round to it. This is the keystroke's
    /// own timestamp, which is what the seam asks for.
    keying: Cell<f64>,
    /// How many UTF-16 units of pre-edit text the input method is holding in
    /// each window's view. A window with no entry holds none.
    ///
    /// `hasMarkedText` and `markedRange` are required reading for an input
    /// method to compose at all, and both are answers about this number — in
    /// every window, including one whose field reports no pre-edit, because a
    /// dead key composes through marked text too.
    marked: RefCell<Vec<(usize, usize)>>,
    /// Pre-edits the input method set, waiting for their
    /// [`Preedit`](RawEvent::Preedit) markers: the text and the cursor as a
    /// byte offset, already converted. The same arrangement as
    /// [`text`](Self::text).
    preedits: RefCell<Vec<(usize, String, Option<usize>)>>,
    /// The field each window last named with
    /// [`set_text_input`](crate::Shell::set_text_input). A window with no entry
    /// has none, which is how every window starts.
    ///
    /// Here rather than on the shell's window record because the view reads it
    /// from inside a callback, to decide whether a pre-edit is reported.
    fields: RefCell<Vec<(usize, TextPurpose)>>,
    /// The area each window last gave
    /// [`set_text_input_area`](crate::Shell::set_text_input_area), in its
    /// client pixels, which `firstRectForCharacterRange:actualRange:` answers
    /// from. A window with no entry has handed placement back.
    areas: RefCell<Vec<(usize, PhysicalRect)>>,
    /// The `NSCursor` each window last asked for, as an integer.
    ///
    /// Read by `cursorUpdate:`, which is the only moment a cursor shape can be
    /// applied so that it survives the next pointer movement. The objects are
    /// AppKit's own shared singletons, so there is nothing to retain and nothing
    /// that can dangle.
    cursors: RefCell<Vec<(usize, usize)>>,
}

impl Shared {
    /// Records one thing a callback saw, collapsing it into a pending
    /// equivalent where [`enqueue`] says that is sound.
    ///
    /// Never panics on a borrow: nothing else holds this across a call into
    /// AppKit, which is the invariant that makes a `RefCell` here safe at all.
    fn push(&self, event: RawEvent) {
        enqueue(&mut self.queue.borrow_mut(), event);
    }

    /// Takes everything recorded so far.
    pub(super) fn take_events(&self) -> Vec<RawEvent> {
        core::mem::take(&mut self.queue.borrow_mut())
    }

    /// Queues a committed string for `window`.
    pub(super) fn push_text(&self, window: usize, text: String) {
        self.text.borrow_mut().push((window, text));
    }

    /// Takes the oldest string committed for `window`.
    ///
    /// In order, so that two commits in one pump reach the consumer as two
    /// [`TextCommit`](crate::ShellEvent::TextCommit)s in the order they were
    /// typed.
    pub(super) fn take_text(&self, window: usize) -> Option<String> {
        let mut queued = self.text.borrow_mut();
        let index = queued.iter().position(|(owner, _)| *owner == window)?;
        Some(queued.remove(index).1)
    }

    /// Queues the paths one drop delivered on `window`.
    pub(super) fn push_drop(&self, window: usize, paths: Vec<PathBuf>) {
        self.drops.borrow_mut().push((window, paths));
    }

    /// Takes the oldest drop delivered on `window`.
    ///
    /// In order, so that two drops in one pump reach the consumer as two
    /// batches of [`DroppedFile`](crate::ShellEvent::DroppedFile)s in the order
    /// they landed.
    pub(super) fn take_drop(&self, window: usize) -> Option<Vec<PathBuf>> {
        let mut queued = self.drops.borrow_mut();
        let index = queued.iter().position(|(owner, _)| *owner == window)?;
        Some(queued.remove(index).1)
    }

    /// The timestamp of the key event being interpreted.
    pub(super) fn keying(&self) -> f64 {
        self.keying.get()
    }

    /// Records the timestamp of the key event about to be interpreted.
    pub(super) fn set_keying(&self, seconds: f64) {
        self.keying.set(seconds);
    }

    /// How much pre-edit text the input method is holding in `window`.
    pub(super) fn marked(&self, window: usize) -> usize {
        self.marked
            .borrow()
            .iter()
            .find(|(owner, _)| *owner == window)
            .map_or(0, |(_, units)| *units)
    }

    /// Records how much pre-edit text the input method is holding in
    /// `window`.
    pub(super) fn set_marked(&self, window: usize, units: usize) {
        set_entry(
            &mut self.marked.borrow_mut(),
            window,
            (units > 0).then_some(units),
        );
    }

    /// How much pre-edit text the input method was holding in `window`,
    /// leaving none: the composition ended.
    pub(super) fn take_marked(&self, window: usize) -> usize {
        let units = self.marked(window);
        self.set_marked(window, 0);
        units
    }

    /// Queues a pre-edit for `window`, already converted to the seam's text
    /// and byte cursor.
    pub(super) fn push_preedit(&self, window: usize, text: String, cursor: Option<usize>) {
        self.preedits.borrow_mut().push((window, text, cursor));
    }

    /// Takes the oldest pre-edit queued for `window`.
    pub(super) fn take_preedit(&self, window: usize) -> Option<(String, Option<usize>)> {
        let mut queued = self.preedits.borrow_mut();
        let index = queued.iter().position(|(owner, ..)| *owner == window)?;
        let (_, text, cursor) = queued.remove(index);
        Some((text, cursor))
    }

    /// The field `window` last named, if it names one now.
    pub(super) fn field_of(&self, window: usize) -> Option<TextPurpose> {
        self.fields
            .borrow()
            .iter()
            .find(|(owner, _)| *owner == window)
            .map(|(_, purpose)| *purpose)
    }

    /// Records the field `window` names, or that it names none.
    pub(super) fn set_field(&self, window: usize, field: Option<TextPurpose>) {
        set_entry(&mut self.fields.borrow_mut(), window, field);
    }

    /// The area `window` last gave for its caret, if it has not handed
    /// placement back.
    pub(super) fn area_of(&self, window: usize) -> Option<PhysicalRect> {
        self.areas
            .borrow()
            .iter()
            .find(|(owner, _)| *owner == window)
            .map(|(_, area)| *area)
    }

    /// Records the area `window` gave for its caret, or that it gave none.
    pub(super) fn set_area(&self, window: usize, area: Option<PhysicalRect>) {
        set_entry(&mut self.areas.borrow_mut(), window, area);
    }

    /// The `NSCursor` a window wants, if it has asked for one.
    pub(super) fn cursor_of(&self, window: usize) -> Option<usize> {
        self.cursors
            .borrow()
            .iter()
            .find(|(owner, _)| *owner == window)
            .map(|(_, cursor)| *cursor)
    }

    /// Records the `NSCursor` a window wants.
    pub(super) fn set_cursor(&self, window: usize, cursor: usize) {
        let mut cursors = self.cursors.borrow_mut();
        match cursors.iter_mut().find(|(owner, _)| *owner == window) {
            Some(entry) => entry.1 = cursor,
            None => cursors.push((window, cursor)),
        }
    }

    /// Discards everything held for a window that has just been destroyed.
    ///
    /// The Win32 backend's `forget`, for the same reason: a callback that fired
    /// during `close` must not produce an event naming a handle the consumer has
    /// already been told is gone — [obligation 1](crate::Shell) forbids it. The
    /// text and drop queues go with it, or the next window to commit or receive
    /// a drop would be handed the dead one's payload.
    pub(super) fn forget(&self, window: usize) {
        self.queue
            .borrow_mut()
            .retain(|event| event.window() != Some(window));
        self.text.borrow_mut().retain(|(owner, _)| *owner != window);
        self.drops
            .borrow_mut()
            .retain(|(owner, _)| *owner != window);
        self.cursors
            .borrow_mut()
            .retain(|(owner, _)| *owner != window);
        self.preedits
            .borrow_mut()
            .retain(|(owner, ..)| *owner != window);
        self.fields
            .borrow_mut()
            .retain(|(owner, _)| *owner != window);
        self.marked
            .borrow_mut()
            .retain(|(owner, _)| *owner != window);
        self.areas
            .borrow_mut()
            .retain(|(owner, _)| *owner != window);
    }
}

/// Replaces `window`'s entry with `value`, adds one, or removes it for `None`.
fn set_entry<T>(entries: &mut Vec<(usize, T)>, window: usize, value: Option<T>) {
    entries.retain(|(owner, _)| *owner != window);
    if let Some(value) = value {
        entries.push((window, value));
    }
}

// ---------------------------------------------------------------------------
// The delegate → shell mapping
// ---------------------------------------------------------------------------

thread_local! {
    /// Which [`Shared`] each live delegate object belongs to.
    ///
    /// # Decision: a Rust-side table, not an Objective-C instance variable
    ///
    /// The obvious shape is the Win32 one — a pointer in the object, read back
    /// in the callback — and on this runtime that means `class_addIvar` plus
    /// `ivar_getOffset` plus pointer arithmetic into the middle of an
    /// Objective-C object. That is three chances to be wrong by a word, and
    /// being wrong by a word corrupts the object rather than failing; **none of
    /// it can be run, let alone falsified, from the machine this backend was
    /// written on.**
    ///
    /// A thread-local table is safe Rust with the same lifetime story and one
    /// extra lookup on a path that runs a handful of times per frame. It is
    /// thread-local rather than static because everything here is main-thread
    /// only, which makes the affinity the mechanism rather than a comment.
    ///
    /// The entries are raw pointers into [`Rc`](std::rc::Rc)-owned allocations
    /// the shell holds; [`Delegate::drop`] removes the entry before the shell
    /// drops the `Rc`, which is the whole soundness argument and is the same one
    /// `win32::shell`'s `GWLP_USERDATA` makes.
    static DELEGATES: RefCell<Vec<(usize, *const Shared)>> = const { RefCell::new(Vec::new()) };
}

/// Runs `read` against whichever [`Shared`] owns `delegate`.
///
/// A delegate this process does not know about answers `None` rather than being
/// an error: AppKit can deliver a notification queued before [`Delegate::drop`]
/// removed the entry, and there is nothing left to tell. A `nil` delegate — a
/// window whose delegate was cleared on the way out — is the same case and takes
/// the same path.
///
/// The table lookup finishes **before** `read` runs, so a callback that reaches
/// back into AppKit and re-enters this cannot deadlock on the borrow.
pub(super) fn with_shared<R>(delegate: Id, read: impl FnOnce(&Shared) -> R) -> Option<R> {
    let shared = DELEGATES.with_borrow(|table| {
        table
            .iter()
            .find(|(known, _)| *known == delegate as usize)
            .map(|(_, shared)| *shared)
    })?;
    // SAFETY: the pointer was registered by `Delegate::new` from an `Rc` the
    // shell owns, and `Delegate::drop` removes it before that `Rc` is dropped —
    // so a pointer still in the table names a live `Shared`. The borrow above
    // has already been released, so this cannot re-enter the table.
    Some(read(unsafe { &*shared }))
}

/// Records `event` against whichever shell owns `delegate`.
pub(super) fn record(delegate: Id, event: RawEvent) {
    with_shared(delegate, |shared| shared.push(event));
}

/// The `NSWindow *` a delegate notification is about, as the integer the queue
/// matches on.
///
/// # Safety
///
/// `notification` must be a live `NSNotification` or `nil`.
unsafe fn notified_window(notification: Id) -> usize {
    if notification.is_null() {
        return 0;
    }
    // SAFETY: every `NSNotification` answers `object`, and for the window
    // notifications this backend observes it is the `NSWindow`.
    unsafe { ffi::msg(notification, ffi::sel(c"object")) as usize }
}

// The delegate methods. Each is installed by `delegate_class` with the type
// encoding beside it, each does the least it can, and none of them touches the
// shell — see `super::events` for the whole argument.

/// `windowShouldClose:` — **answers `NO`, always.**
///
/// The seam makes closing a *question*: a
/// [`CloseRequested`](crate::ShellEvent::CloseRequested) is delivered and the
/// window stays open until
/// [`reply_close_request`](crate::Shell::reply_close_request) says otherwise.
/// Returning `YES` here would let AppKit close the window before anyone could
/// ask about unsaved work, which is the same interception the Win32 backend
/// performs by not passing `WM_CLOSE` to `DefWindowProc`.
///
/// # Safety
///
/// Called only by AppKit, with a delegate this module created.
unsafe extern "C" fn window_should_close(delegate: Id, _cmd: Sel, sender: Id) -> ObjcBool {
    record(
        delegate,
        RawEvent::CloseRequested {
            window: sender as usize,
        },
    );
    ffi::NO
}

/// `windowDidResize:`.
///
/// # Safety
///
/// Called only by AppKit, with a delegate this module created.
unsafe extern "C" fn window_did_resize(delegate: Id, _cmd: Sel, notification: Id) {
    // SAFETY: AppKit passes a live `NSNotification`.
    let window = unsafe { notified_window(notification) };
    record(delegate, RawEvent::Resized { window });
}

/// `windowDidChangeBackingProperties:` — a new `backingScaleFactor`, or a new
/// colour space.
///
/// # Safety
///
/// Called only by AppKit, with a delegate this module created.
unsafe extern "C" fn window_did_change_backing(delegate: Id, _cmd: Sel, notification: Id) {
    // SAFETY: AppKit passes a live `NSNotification`.
    let window = unsafe { notified_window(notification) };
    record(delegate, RawEvent::BackingChanged { window });
    // The scale change is published *before* the resize it comes with, and the
    // resize is what carries the new extent — so both are recorded, and
    // `translate` emits them in that order.
    record(delegate, RawEvent::Resized { window });
}

/// `windowDidChangeScreen:` — dragged onto another display.
///
/// # Safety
///
/// Called only by AppKit, with a delegate this module created.
unsafe extern "C" fn window_did_change_screen(delegate: Id, _cmd: Sel, notification: Id) {
    // SAFETY: AppKit passes a live `NSNotification`.
    let window = unsafe { notified_window(notification) };
    // The *monitor* in an effective `Borderless { monitor }` changed even when
    // the scale did not, and that is part of the configuration.
    record(delegate, RawEvent::BackingChanged { window });
}

/// `windowDidBecomeKey:`.
///
/// # Safety
///
/// Called only by AppKit, with a delegate this module created.
unsafe extern "C" fn window_did_become_key(delegate: Id, _cmd: Sel, notification: Id) {
    // SAFETY: AppKit passes a live `NSNotification`.
    let window = unsafe { notified_window(notification) };
    record(
        delegate,
        RawEvent::Focus {
            window,
            focused: true,
        },
    );
}

/// `windowDidResignKey:`.
///
/// # Safety
///
/// Called only by AppKit, with a delegate this module created.
unsafe extern "C" fn window_did_resign_key(delegate: Id, _cmd: Sel, notification: Id) {
    // SAFETY: AppKit passes a live `NSNotification`.
    let window = unsafe { notified_window(notification) };
    record(
        delegate,
        RawEvent::Focus {
            window,
            focused: false,
        },
    );
}

/// `windowWillClose:` — the window is going away without this shell asking.
///
/// # Safety
///
/// Called only by AppKit, with a delegate this module created.
unsafe extern "C" fn window_will_close(delegate: Id, _cmd: Sel, notification: Id) {
    // SAFETY: AppKit passes a live `NSNotification`.
    let window = unsafe { notified_window(notification) };
    record(delegate, RawEvent::Closed { window });
}

/// The screen-parameters observer. Named for this crate so it can never collide
/// with a real AppKit selector.
///
/// # Safety
///
/// Called only by `NSNotificationCenter`, with a delegate this module created.
unsafe extern "C" fn screens_changed(delegate: Id, _cmd: Sel, _notification: Id) {
    record(delegate, RawEvent::ScreensChanged);
}

/// The name of the class this module builds at runtime.
///
/// Not a per-process-unique name on purpose, exactly as the Win32 backend's
/// window class is not: two copies of this engine in one process should share
/// one class, and the [`OnceLock`] below is what makes that safe.
const DELEGATE_CLASS: &CStr = c"CrcblWindowDelegate";

/// The registration, once per process. `Err` carries what went wrong.
static DELEGATE: OnceLock<Result<usize, String>> = OnceLock::new();

/// Builds — or returns — the delegate class.
///
/// # Why it is built at runtime rather than declared
///
/// There is no Objective-C compiler in this build. `objc_allocateClassPair` plus
/// `class_addMethod` is what `@implementation` compiles *to*, and writing it out
/// is the whole of the "hand-written Objective-C runtime FFI" that
/// the windowing rules (`docs/notes/backends.md`) ask for on this platform. The Win32 backend does
/// the same thing with `RegisterClassExW` and a `WNDPROC`; the difference is
/// only that Win32 has one entry point and this has one per message.
///
/// `objc_allocateClassPair` answering null means a class of that name already
/// exists — the same code loaded twice, in two modules — so the existing one is
/// looked up and used. It is ours by name and by method table, so delegating to
/// it is correct.
fn delegate_class() -> Result<Class, ShellError> {
    let outcome = DELEGATE.get_or_init(|| {
        let Some(superclass) = ffi::class(c"NSObject") else {
            return Err(
                "the Objective-C runtime has no NSObject, so this image has no \
                        Foundation in it"
                    .to_string(),
            );
        };
        // SAFETY: `NSObject` is a live class, the name is a NUL-terminated
        // literal, and no extra instance storage is asked for — the
        // delegate-to-shell mapping is a Rust table, see `DELEGATES`.
        let class = unsafe { ffi::objc_allocateClassPair(superclass, DELEGATE_CLASS.as_ptr(), 0) };
        if class.is_null() {
            // Already registered by another copy of this code in this process.
            let Some(existing) = ffi::class(DELEGATE_CLASS) else {
                return Err(
                    "objc_allocateClassPair refused CrcblWindowDelegate and no class \
                            of that name exists"
                        .to_string(),
                );
            };
            crcbl_core::log::debug!(
                "the {DELEGATE_CLASS:?} class was already registered in this process"
            );
            return Ok(existing as usize);
        }

        let void_notification = c"v@:@";
        let bool_notification = std::ffi::CString::new(format!("{}@:@", ffi::ENC_BOOL))
            .expect("the encoding characters contain no NUL");
        // Every entry is `(selector, implementation, type encoding)`, and the
        // encoding has to match the implementation: the runtime reads it to
        // build an `NSInvocation` if AppKit ever forwards one of these, and a
        // wrong return width there is a wrong-sized read nothing here would see.
        let methods: [(&CStr, Imp, &CStr); 8] = [
            (
                c"windowShouldClose:",
                // SAFETY: casting a correctly-typed `extern "C"` function to the
                // runtime's opaque `IMP`, which is how every method is
                // installed; the encoding beside it describes the real
                // signature.
                unsafe {
                    core::mem::transmute::<unsafe extern "C" fn(Id, Sel, Id) -> ObjcBool, Imp>(
                        window_should_close,
                    )
                },
                bool_notification.as_c_str(),
            ),
            (
                c"windowDidResize:",
                // SAFETY: as above.
                unsafe {
                    core::mem::transmute::<unsafe extern "C" fn(Id, Sel, Id), Imp>(
                        window_did_resize,
                    )
                },
                void_notification,
            ),
            (
                c"windowDidChangeBackingProperties:",
                // SAFETY: as above.
                unsafe {
                    core::mem::transmute::<unsafe extern "C" fn(Id, Sel, Id), Imp>(
                        window_did_change_backing,
                    )
                },
                void_notification,
            ),
            (
                c"windowDidChangeScreen:",
                // SAFETY: as above.
                unsafe {
                    core::mem::transmute::<unsafe extern "C" fn(Id, Sel, Id), Imp>(
                        window_did_change_screen,
                    )
                },
                void_notification,
            ),
            (
                c"windowDidBecomeKey:",
                // SAFETY: as above.
                unsafe {
                    core::mem::transmute::<unsafe extern "C" fn(Id, Sel, Id), Imp>(
                        window_did_become_key,
                    )
                },
                void_notification,
            ),
            (
                c"windowDidResignKey:",
                // SAFETY: as above.
                unsafe {
                    core::mem::transmute::<unsafe extern "C" fn(Id, Sel, Id), Imp>(
                        window_did_resign_key,
                    )
                },
                void_notification,
            ),
            (
                c"windowWillClose:",
                // SAFETY: as above.
                unsafe {
                    core::mem::transmute::<unsafe extern "C" fn(Id, Sel, Id), Imp>(
                        window_will_close,
                    )
                },
                void_notification,
            ),
            (
                SCREENS_CHANGED,
                // SAFETY: as above.
                unsafe {
                    core::mem::transmute::<unsafe extern "C" fn(Id, Sel, Id), Imp>(screens_changed)
                },
                void_notification,
            ),
        ];
        for (name, imp, types) in methods {
            // SAFETY: `class` is the class pair this call is building and has
            // not been registered yet, which is when methods may be added; the
            // implementation matches the encoding beside it.
            let added = unsafe { ffi::class_addMethod(class, ffi::sel(name), imp, types.as_ptr()) };
            if added == ffi::NO {
                return Err(format!(
                    "class_addMethod refused {name:?} on CrcblWindowDelegate"
                ));
            }
        }

        // Declaring conformance is not what makes the methods reachable — AppKit
        // dispatches them through `respondsToSelector:` — but `setDelegate:` on
        // a modern AppKit is typed against the protocol, and a delegate that
        // does not claim it is the kind of thing a future release starts
        // refusing. Cheap, and the absence would be silent.
        // SAFETY: the protocol may legitimately be absent (it is a linker-level
        // object), which `class_addProtocol` reports rather than crashing on.
        let protocol = unsafe { ffi::objc_getProtocol(c"NSWindowDelegate".as_ptr()) };
        if !protocol.is_null() {
            // SAFETY: `class` is still unregistered and `protocol` is live.
            unsafe { ffi::class_addProtocol(class, protocol) };
        }

        // SAFETY: `class` is a complete class pair that has not been registered.
        unsafe { ffi::objc_registerClassPair(class) };
        Ok(class as usize)
    });
    match outcome {
        Ok(class) => Ok(*class as Class),
        Err(detail) => Err(ShellError::Connect {
            backend: ShellBackend::AppKit,
            detail: detail.clone(),
        }),
    }
}

/// The selector the screen-parameters observer is registered under.
const SCREENS_CHANGED: &CStr = c"crcblScreenParametersChanged:";

/// One shell's delegate object, and its observer registration.
///
/// Owns an Objective-C object, so it has a [`Drop`] that releases it — there is
/// no ARC here and nothing else will.
#[derive(Debug)]
pub(super) struct Delegate {
    object: Id,
}

impl Delegate {
    /// Creates the delegate and points it at `shared`.
    ///
    /// # Errors
    ///
    /// [`ShellError::Connect`] if the class could not be built or the object
    /// could not be allocated.
    pub(super) fn new(shared: *const Shared) -> Result<Self, ShellError> {
        let class = delegate_class()?;
        // SAFETY: `class` is a registered class; `alloc` then `init` is the
        // designated way to create an `NSObject`, and the result is owned by
        // this struct with a retain count of one.
        let object = unsafe {
            let allocated = ffi::msg(class, ffi::sel(c"alloc"));
            ffi::msg(allocated, ffi::sel(c"init"))
        };
        if object.is_null() {
            return Err(ShellError::Connect {
                backend: ShellBackend::AppKit,
                detail: "[[CrcblWindowDelegate alloc] init] returned nil".to_string(),
            });
        }
        DELEGATES.with_borrow_mut(|table| table.push((object as usize, shared)));

        // The screen-parameters observer. `object:nil` means "from any sender",
        // which is right — the notification comes from `NSApplication`.
        // SAFETY: `defaultCenter` is a class method on a live class; the
        // observer, the selector and the name are all live, and `nil` is a legal
        // sender filter. The centre keeps an **unretained** reference to the
        // observer, which is why `drop` below removes it before releasing.
        unsafe {
            if let Some(centre_class) = ffi::class(c"NSNotificationCenter") {
                let centre = ffi::msg(centre_class, ffi::sel(c"defaultCenter"));
                let add: unsafe extern "C" fn(Id, Sel, Id, Sel, Id, Id) = ffi::msg_send();
                add(
                    centre,
                    ffi::sel(c"addObserver:selector:name:object:"),
                    object,
                    ffi::sel(SCREENS_CHANGED),
                    ffi::NSApplicationDidChangeScreenParametersNotification,
                    ptr::null_mut(),
                );
            }
        }
        Ok(Self { object })
    }

    /// The object to hand `setDelegate:`.
    pub(super) const fn object(&self) -> Id {
        self.object
    }
}

impl Drop for Delegate {
    /// Deregisters the observer, forgets the shell mapping, and releases the
    /// object — in that order, and the order is the soundness argument.
    ///
    /// `NSNotificationCenter` holds an **unretained** reference to an observer,
    /// so an observer released while still registered leaves the centre with a
    /// dangling pointer it will message on the next display change. And a
    /// delegate whose mapping is still in [`DELEGATES`] could be handed a
    /// [`Shared`] the shell is about to drop; removing it first is what makes
    /// the raw pointer in that table sound.
    fn drop(&mut self) {
        // SAFETY: `self.object` is the live object this struct allocated.
        unsafe {
            if let Some(centre_class) = ffi::class(c"NSNotificationCenter") {
                let centre = ffi::msg(centre_class, ffi::sel(c"defaultCenter"));
                ffi::msg1_void(centre, ffi::sel(c"removeObserver:"), self.object);
            }
        }
        DELEGATES
            .with_borrow_mut(|table| table.retain(|(known, _)| *known != self.object as usize));
        // SAFETY: one `release` for the one retain `alloc` gave it. Nothing else
        // holds a reference: the windows' `delegate` slot is weak, and it has
        // been cleared by `AppKitShell::drop` before this runs.
        unsafe { ffi::msg_void(self.object, ffi::sel(c"release")) };
    }
}

// ---------------------------------------------------------------------------
// NSApplication
// ---------------------------------------------------------------------------

/// Whether this is the thread AppKit will accept calls on.
#[must_use]
pub(super) fn is_main_thread() -> bool {
    let Some(class) = ffi::class(c"NSThread") else {
        // No Foundation in the image at all. Everything after this will fail
        // anyway, and answering `false` makes it fail at the one place that
        // explains itself.
        return false;
    };
    // SAFETY: `isMainThread` is a class method on `NSThread` returning `BOOL`.
    unsafe { ffi::msg_bool(class, ffi::sel(c"isMainThread")) }
}

/// [`ShellError`] for a shell asked for on the wrong thread.
///
/// # Errors
///
/// Always, when this is not the main thread.
pub(super) fn require_main_thread() -> Result<(), ShellError> {
    if is_main_thread() {
        return Ok(());
    }
    Err(ShellError::Backend(
        "the AppKit backend must be opened on the process's main thread: AppKit asserts on it \
         and raises an Objective-C exception, and the WindowServer's event source is attached \
         to the main run loop, so a shell created anywhere else would either abort or open a \
         window that never receives an event"
            .to_string(),
    ))
}

/// What [`bootstrap`] did, kept so that a failure to focus has a diagnosis.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct Bootstrap {
    /// The `NSApplication` singleton.
    pub app: Id,
    /// Whether `setActivationPolicy:NSApplicationActivationPolicyRegular` was
    /// accepted.
    ///
    /// `false` means this process cannot become frontmost, so its windows will
    /// open behind everything and never take the keyboard. Not fatal — a window
    /// still exists and still renders — but it is the difference between a
    /// focus test that is meaningful and one that cannot pass, so it is recorded
    /// rather than discarded.
    pub policy_accepted: bool,
}

/// Whether the once-per-process half of [`bootstrap`] has run, and what it
/// found.
static LAUNCHED: OnceLock<bool> = OnceLock::new();

/// Brings `NSApplication` up far enough to own windows and deliver events.
///
/// The three steps, in the order AppKit requires:
///
/// 1. `sharedApplication`, which creates the singleton and — as a side effect
///    nothing else provides — connects the process to the WindowServer.
/// 2. `setActivationPolicy:`, before any window exists, so the first window
///    opens as a normal application's window rather than a background one's.
/// 3. `finishLaunching`, which posts `NSApplicationWillFinishLaunching` and
///    completes the launch AppKit would otherwise do inside `run` — and this
///    backend never calls `run`, because the seam's
///    [`pump`](crate::Shell::pump) *is* the loop.
///
/// Steps 2 and 3 are once per process; step 1 is idempotent by definition.
///
/// # Errors
///
/// [`ShellError::Connect`] if `NSApplication` is not in this image or the
/// singleton could not be created — which is what a process with no WindowServer
/// session looks like.
pub(super) fn bootstrap() -> Result<Bootstrap, ShellError> {
    let _pool = Pool::push();
    let Some(class) = ffi::class(c"NSApplication") else {
        return Err(ShellError::Connect {
            backend: ShellBackend::AppKit,
            detail: "the Objective-C runtime has no NSApplication, so this image has no AppKit \
                     in it"
                .to_string(),
        });
    };
    // SAFETY: `sharedApplication` is a class method returning the singleton,
    // autoreleased into the pool above but also retained by the runtime for the
    // life of the process.
    let app = unsafe { ffi::msg(class, ffi::sel(c"sharedApplication")) };
    if app.is_null() {
        return Err(ShellError::Connect {
            backend: ShellBackend::AppKit,
            detail: "[NSApplication sharedApplication] returned nil, which is what a process \
                     with no WindowServer session gets"
                .to_string(),
        });
    }

    let policy_accepted = *LAUNCHED.get_or_init(|| {
        // SAFETY: `setActivationPolicy:` takes an `NSInteger` and returns
        // `BOOL`; the value is the documented `Regular`.
        let accepted = unsafe {
            let set: unsafe extern "C" fn(Id, Sel, isize) -> ObjcBool = ffi::msg_send();
            set(
                app,
                ffi::sel(c"setActivationPolicy:"),
                value::ACTIVATION_POLICY_REGULAR,
            ) != ffi::NO
        };
        if !accepted {
            crcbl_core::log::warn!(
                "setActivationPolicy:Regular was refused; this process cannot become \
                 frontmost, so its windows will open behind other applications and will not \
                 take the keyboard"
            );
        }
        // SAFETY: `finishLaunching` takes no arguments and returns nothing. It
        // is what `run` would have called, and this backend never calls `run`.
        unsafe { ffi::msg_void(app, ffi::sel(c"finishLaunching")) };
        accepted
    });

    activate(app);
    Ok(Bootstrap {
        app,
        policy_accepted,
    })
}

/// Brings this process to the front.
///
/// `activate` is the macOS 14 spelling and `activateIgnoringOtherApps:` the one
/// before it, which is deprecated rather than removed. Which exists is asked
/// with `respondsToSelector:` rather than assumed from a deployment target,
/// because sending a selector a system does not have is a crash and this crate
/// has no deployment target to consult.
fn activate(app: Id) {
    // SAFETY: `app` is the live `NSApplication` singleton; both selectors take
    // the arguments their branches pass and return nothing.
    unsafe {
        let modern = ffi::sel(c"activate");
        if ffi::responds_to(app, modern) {
            ffi::msg_void(app, modern);
        } else {
            let legacy: unsafe extern "C" fn(Id, Sel, ObjcBool) = ffi::msg_send();
            legacy(app, ffi::sel(c"activateIgnoringOtherApps:"), YES);
        }
    }
}