Skip to main content

swiftui_native/
render.rs

1//! Deterministic offscreen rendering — the basis of the visual parity tests.
2//!
3//! Everything here is pinned so that two runs, or a Rust run and a Swift run,
4//! produce byte-identical output:
5//!
6//! - the bitmap is created explicitly at 1 pixel per point, so the result does
7//!   not depend on the attached display's backing scale factor;
8//! - the appearance is forced to Aqua, so it does not depend on the host's
9//!   light/dark setting;
10//! - the view is hosted in an offscreen window, so layout matches what an
11//!   on-screen window would produce.
12
13use crate::hosting::HostingView;
14use crate::objc::{self};
15// Every use of `NSRect` here is in an AppKit path, so on iOS the name has
16// nothing to refer to.
17#[cfg(target_os = "macos")]
18use crate::objc::NSRect;
19use crate::views::ViewHandle;
20use core::ffi::c_void;
21
22/// A rendered bitmap: 8-bit RGBA, row-major, no padding.
23pub struct Rendered {
24    pub width: u32,
25    pub height: u32,
26    pub pixels: Vec<u8>,
27}
28
29impl Rendered {
30    /// Number of pixels differing from `other` by more than `tolerance` in any
31    /// channel. `u32::MAX` if the dimensions differ.
32    pub fn diff_count(&self, other: &Rendered, tolerance: u8) -> u32 {
33        if self.width != other.width || self.height != other.height {
34            return u32::MAX;
35        }
36        // Almost every comparison in a passing sweep is of two identical images,
37        // and that case is one `memcmp` rather than a walk over four bytes per
38        // pixel through nested iterators. Slice equality lowers to `memcmp`.
39        if self.pixels == other.pixels {
40            return 0;
41        }
42        // Exact comparison is a word compare per pixel, not four byte compares.
43        if tolerance == 0 {
44            return self
45                .pixels
46                .chunks_exact(4)
47                .zip(other.pixels.chunks_exact(4))
48                .filter(|(a, b)| a != b)
49                .count() as u32;
50        }
51        let mut n = 0;
52        for (a, b) in self.pixels.chunks_exact(4).zip(other.pixels.chunks_exact(4)) {
53            if a.iter().zip(b).any(|(x, y)| x.abs_diff(*y) > tolerance) {
54                n += 1;
55            }
56        }
57        n
58    }
59
60    /// How many distinct RGBA values the bitmap contains.
61    ///
62    /// A render that came out blank has one; a case whose diff is zero *and*
63    /// whose colour count is one proved nothing, so the harness reports both.
64    pub fn distinct(&self) -> usize {
65        // An open-addressed table with a multiply-shift hash, not `HashSet` and
66        // not a sort. Every pixel of every render goes through here: `HashSet`'s
67        // SipHash costs more per pixel than the whole image is worth, and
68        // sorting pays O(n log n) to answer a question that only needs
69        // membership. A render has a few hundred distinct colours out of tens of
70        // thousands of pixels, so the table stays small and cache-resident and
71        // almost every probe hits an existing entry on the first try.
72        //
73        // Reusing one scratch buffer across calls was tried under the sorting
74        // version and measured no faster: the cost was never the allocation.
75        let pixels = self.pixels.chunks_exact(4);
76        // Power of two, so the mask is the modulo. Start well above the couple
77        // of hundred colours a typical render has, and grow if a gradient or a
78        // photograph blows past it.
79        let mut bits = 12;
80        let mut table = vec![u32::MAX; 1 << bits];
81        // `u32::MAX` marks an empty slot, so a genuine white-opaque pixel needs
82        // somewhere else to live.
83        let mut has_sentinel = false;
84        let mut count = 0usize;
85        for px in pixels {
86            let key = u32::from_le_bytes([px[0], px[1], px[2], px[3]]);
87            if key == u32::MAX {
88                if !has_sentinel {
89                    has_sentinel = true;
90                    count += 1;
91                }
92                continue;
93            }
94            // Fibonacci hashing: one multiply, then take the high bits, which
95            // mixes far better than masking the low ones for pixel values that
96            // differ only in their top byte.
97            let mut i = ((key as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) >> (64 - bits)) as usize;
98            loop {
99                let slot = table[i];
100                if slot == key {
101                    break;
102                }
103                if slot == u32::MAX {
104                    table[i] = key;
105                    count += 1;
106                    // Grow past half full, where linear probing starts to cluster.
107                    if count > (1 << bits) / 2 {
108                        bits += 1;
109                        let mut bigger = vec![u32::MAX; 1 << bits];
110                        for &v in table.iter().filter(|&&v| v != u32::MAX) {
111                            let mut j = ((v as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
112                                >> (64 - bits)) as usize;
113                            while bigger[j] != u32::MAX {
114                                j = (j + 1) & ((1 << bits) - 1);
115                            }
116                            bigger[j] = v;
117                        }
118                        table = bigger;
119                    }
120                    break;
121                }
122                i = (i + 1) & ((1 << bits) - 1);
123            }
124        }
125        count
126    }
127
128    /// Write a PNG for eyeballing.
129    ///
130    /// `NSBitmapImageRep` on the Mac, so the file matches what the macOS
131    /// capture paths produced byte for byte; CoreGraphics and ImageIO
132    /// elsewhere, which are the same API on every Apple platform.
133    #[cfg(target_os = "macos")]
134    pub fn write_png(&self, path: &str) -> bool {
135        unsafe {
136            let rep = new_bitmap(self.width, self.height);
137            let data = objc::send_ret_ptr(rep, objc::sel(c"bitmapData")) as *mut u8;
138            let row = objc::send_ret_u64(rep, objc::sel(c"bytesPerRow")) as usize;
139            for y in 0..self.height as usize {
140                let src = &self.pixels[y * self.width as usize * 4..][..self.width as usize * 4];
141                core::ptr::copy_nonoverlapping(src.as_ptr(), data.add(y * row), src.len());
142            }
143            let ok = write_rep_png(rep, path);
144            objc::send(rep, objc::sel(c"release"));
145            ok
146        }
147    }
148
149    /// Write a PNG for eyeballing.
150    #[cfg(not(target_os = "macos"))]
151    pub fn write_png(&self, path: &str) -> bool {
152        crate::cg::write_png(self.width, self.height, &self.pixels, path)
153    }
154}
155
156/// Render a view offscreen at exactly `width` x `height` pixels.
157#[cfg(target_os = "macos")]
158pub fn render(view: &ViewHandle, width: u32, height: u32) -> Rendered {
159    let hv = HostingView::new(view);
160    let out = render_hosting(&hv, width, height);
161    // Tear the hosting view down and let AppKit finish the job before
162    // returning. Draining *after* the capture, so nothing the run loop does can
163    // change what was captured.
164    drop(hv);
165    drain();
166    out
167}
168
169/// Let AppKit and SwiftUI finish work they have deferred to the run loop.
170///
171/// Closing a window and releasing a hosting view does not free them: both are
172/// torn down on the next turn of the run loop, and a capture loop that never
173/// turns it accumulates every frame it has taken — about fifty kilobytes each,
174/// which is a hundred megabytes over a couple of thousand frames.
175///
176/// An application has a run loop and never needs this. [`render`] calls it for
177/// the view it owns. Anything driving [`render_hosting`] over a hosting view of
178/// its own should call it too, at whatever interval suits: once every dozen
179/// frames is enough to keep memory flat, and costs less overall than not doing
180/// it at all.
181pub fn drain() {
182    pump(0.0);
183}
184
185/// Render an existing hosting view — used when the root is swapped between frames.
186#[cfg(target_os = "macos")]
187pub fn render_hosting(hv: &HostingView, width: u32, height: u32) -> Rendered {
188    unsafe {
189        let _pool = objc::AutoreleasePool::new();
190        let app = objc::shared_app();
191        let _ = app;
192
193        // The window belongs to the hosting view and lives as long as it does.
194        let _ = offscreen_window_for(hv, width, height);
195        hv.set_frame(width as f64, height as f64);
196        capture_view(hv.as_ptr(), width, height)
197    }
198}
199
200/// The hosting view's own offscreen window, sized for this capture.
201#[cfg(target_os = "macos")]
202unsafe fn offscreen_window_for(hv: &HostingView, width: u32, height: u32) -> *mut c_void {
203    unsafe { hv.offscreen(width as f64, height as f64) }
204}
205
206/// Capture any `NSView` into a `width` x `height` bitmap.
207///
208/// Some things SwiftUI draws never reach a hosting view's own layer — a
209/// `.toolbar` is installed into the window's titlebar, which is a sibling of the
210/// content view — so verifying them means capturing a different view than the
211/// one the root was set on. See [`capture_window`].
212///
213/// # Safety
214/// `view` must be a live `NSView`.
215#[cfg(target_os = "macos")]
216pub unsafe fn capture_view(view: *mut c_void, width: u32, height: u32) -> Rendered {
217    unsafe {
218        let _pool = objc::AutoreleasePool::new();
219        let bounds = NSRect::new(0.0, 0.0, width as f64, height as f64);
220        // A bitmap we create ourselves is exactly width x height pixels, so the
221        // output never picks up the display's 2x backing scale.
222        let rep = new_bitmap(width, height);
223        objc::send_rect_id(
224            view,
225            objc::sel(c"cacheDisplayInRect:toBitmapImageRep:"),
226            bounds,
227            rep,
228        );
229
230        let data = objc::send_ret_ptr(rep, objc::sel(c"bitmapData"));
231        let row = objc::send_ret_u64(rep, objc::sel(c"bytesPerRow")) as usize;
232        let mut pixels = vec![0u8; (width * height * 4) as usize];
233        for y in 0..height as usize {
234            core::ptr::copy_nonoverlapping(
235                data.add(y * row),
236                pixels.as_mut_ptr().add(y * width as usize * 4),
237                width as usize * 4,
238            );
239        }
240        objc::send(rep, objc::sel(c"release"));
241        Rendered { width, height, pixels }
242    }
243}
244
245/// Capture a window including its titlebar, at `width` x `height`.
246///
247/// The titlebar is drawn by the window's *frame view*, the private superview of
248/// the content view. Capturing that is the only way to see a `.toolbar` from a
249/// test.
250///
251/// # Safety
252/// `window` must be a live `NSWindow`.
253#[cfg(target_os = "macos")]
254pub unsafe fn capture_window(window: *mut c_void, width: u32, height: u32) -> Rendered {
255    unsafe {
256        let content = objc::send(window, objc::sel(c"contentView"));
257        let frame_view = objc::send(content, objc::sel(c"superview"));
258        let target = if frame_view.is_null() { content } else { frame_view };
259        capture_view(target, width, height)
260    }
261}
262
263/// Build a real titled window around `view`, sized so its **frame** — titlebar
264/// included — is exactly `width` x `height`, and capture the whole thing.
265///
266/// A macOS `.toolbar` is installed into the window's titlebar, which is a
267/// sibling of the content view, so it is invisible to every offscreen capture.
268/// This is the path that can see it.
269///
270/// The window is ordered in without being made key, and the app stays an
271/// accessory, so running this does not steal focus from whatever is in front.
272#[cfg(target_os = "macos")]
273pub fn render_chrome(view: &ViewHandle, width: u32, height: u32) -> Rendered {
274    unsafe {
275        let _pool = objc::AutoreleasePool::new();
276        // Reusing one titled window across captures was tried: it saved nothing
277        // — building the window is not where the time goes — and 36 variants
278        // came back different, so each capture gets its own.
279        let win = titled_window(view, width, height);
280        pump_until(0.45, || objc::send_ret_bool(win, objc::sel(c"isVisible")));
281        let out = capture_window(win, width, height);
282        objc::send(win, objc::sel(c"close"));
283        // A closed window is torn down on the next turn of the run loop, not on
284        // the call — but that is one turn, not fifty milliseconds of them. The
285        // memory pass at the end of a sweep is what would notice if it were not
286        // enough.
287        pump(0.01);
288        out
289    }
290}
291
292/// Present `view` in a real window and capture the **sheet it puts up**, not the
293/// window itself.
294///
295/// `.alert`, `.sheet` and `.confirmationDialog` on macOS present into a separate
296/// child window attached to the parent. Capturing the parent shows only that it
297/// dimmed; capturing the sheet shows the dialog SwiftUI actually built, which is
298/// what the modifier is for.
299///
300/// Returns a single flat pixel if nothing was presented, so a case that silently
301/// stopped presenting shows up as flat rather than passing.
302#[cfg(target_os = "macos")]
303pub fn render_sheet(view: &ViewHandle, win_w: u32, win_h: u32, w: u32, h: u32) -> Rendered {
304    unsafe {
305        let _pool = objc::AutoreleasePool::new();
306        let win = titled_window(view, win_w, win_h);
307        pump_until(0.7, || !objc::send(win, objc::sel(c"attachedSheet")).is_null());
308        let sheet = objc::send(win, objc::sel(c"attachedSheet"));
309        let out = if sheet.is_null() {
310            Rendered { width: w, height: h, pixels: vec![0; (w * h * 4) as usize] }
311        } else {
312            capture_window(sheet, w, h)
313        };
314        objc::send(win, objc::sel(c"close"));
315        // A closed window is torn down on the next turn of the run loop, not on
316        // the call — but that is one turn, not fifty milliseconds of them. The
317        // memory pass at the end of a sweep is what would notice if it were not
318        // enough.
319        pump(0.01);
320        out
321    }
322}
323
324/// Render `view` in a real window and capture it **as the window server
325/// composited it**, rather than as its own layers draw.
326///
327/// This is the only path that shows Liquid Glass. `.glassEffect` and
328/// `.buttonStyle(.glass)` are resolved by the compositor, not by the view's
329/// layer tree, so `cacheDisplayInRect` renders them away and `ImageRenderer`
330/// drops the subtree — through either one a glass case passes without the glass
331/// ever existing.
332///
333/// Asks for nominal resolution so the result is in points and does not pick up
334/// the display's backing scale.
335#[cfg(target_os = "macos")]
336pub fn render_composited(view: &ViewHandle, width: u32, height: u32) -> Rendered {
337    unsafe {
338        let _pool = objc::AutoreleasePool::new();
339        let win = titled_window(view, width, height);
340        pump_until(0.7, || objc::send_ret_bool(win, objc::sel(c"isVisible")));
341        let out = capture_composited(win, width, height);
342        objc::send(win, objc::sel(c"close"));
343        // A closed window is torn down on the next turn of the run loop, not on
344        // the call — but that is one turn, not fifty milliseconds of them. The
345        // memory pass at the end of a sweep is what would notice if it were not
346        // enough.
347        pump(0.01);
348        out
349    }
350}
351
352/// Run the run loop until `ready` holds, or `limit` seconds have passed.
353///
354/// The capture paths used to sleep a fixed 0.45–0.7s each, long enough for the
355/// slowest case. Most are ready in a fraction of that, and with sixty on-screen
356/// variants in the suite the difference is most of a minute. The timeout keeps
357/// the old behaviour as the worst case rather than the every case.
358#[cfg(target_os = "macos")]
359pub fn pump_until(limit: f64, mut ready: impl FnMut() -> bool) {
360    // 5ms, not 20: the step is the granularity of the answer, and a window that
361    // becomes visible after 3ms was being noticed at 20 and charged 40 with the
362    // settling turn. The settle stays at 20ms — it is there to let the frame that
363    // satisfied the condition draw, which is a property of the frame rather than
364    // of how often we look.
365    let step = 0.005;
366    let mut waited = 0.0;
367    while waited < limit {
368        pump(step);
369        waited += step;
370        if ready() {
371            // One more turn so the frame that satisfied the condition is drawn.
372            pump(0.02);
373            return;
374        }
375    }
376}
377
378/// Ask the window server for a window's composited contents.
379///
380/// # Safety
381/// `window` must be a live, on-screen `NSWindow`.
382#[cfg(target_os = "macos")]
383pub unsafe fn capture_composited(window: *mut c_void, width: u32, height: u32) -> Rendered {
384        let _pool = objc::AutoreleasePool::new();
385    unsafe extern "C" {
386        fn CGWindowListCreateImage(
387            bounds: objc::NSRect,
388            list_option: u32,
389            window_id: u32,
390            image_option: u32,
391        ) -> *mut c_void;
392    }
393    unsafe {
394        let id = objc::send_ret_u64(window, objc::sel(c"windowNumber")) as u32;
395        // CGRectNull bounds: use the window's own. optionIncludingWindow (1<<3),
396        // boundsIgnoreFraming (1<<0) | nominalResolution (1<<4).
397        let cg = CGWindowListCreateImage(
398            // `CGRectInfinite`: the whole screen.
399            objc::NSRect::new(f64::INFINITY, f64::INFINITY, 0.0, 0.0),
400            1 << 3,
401            id,
402            (1 << 4) | 1,
403        );
404        if cg.is_null() {
405            return Rendered { width, height, pixels: vec![0; (width * height * 4) as usize] };
406        }
407        let rep = objc::send_id(
408            objc::send(objc::class(c"OBJC_CLASS_$_NSBitmapImageRep"), objc::sel(c"alloc")),
409            objc::sel(c"initWithCGImage:"),
410            cg,
411        );
412        let got_w = objc::send_ret_u64(rep, objc::sel(c"pixelsWide")) as usize;
413        let got_h = objc::send_ret_u64(rep, objc::sel(c"pixelsHigh")) as usize;
414        let row = objc::send_ret_u64(rep, objc::sel(c"bytesPerRow")) as usize;
415        let data = objc::send_ret_ptr(rep, objc::sel(c"bitmapData"));
416
417        // Copy whatever overlaps the requested size, so a window the server
418        // sized slightly differently still lines up at the origin instead of
419        // reading past the buffer.
420        let mut pixels = vec![0u8; (width * height * 4) as usize];
421        let copy_w = got_w.min(width as usize);
422        for y in 0..got_h.min(height as usize) {
423            core::ptr::copy_nonoverlapping(
424                data.add(y * row),
425                pixels.as_mut_ptr().add(y * width as usize * 4),
426                copy_w * 4,
427            );
428        }
429        Rendered { width, height, pixels }
430    }
431}
432
433/// Present `view` in a real window and capture the **popover it puts up**.
434///
435/// A `.popover` is not an attached sheet — it gets its own `_NSPopoverWindow`,
436/// which is why [`render_sheet`] cannot see it. This finds that window among the
437/// application's and captures it.
438///
439/// Returns a single flat pixel if no popover appeared.
440#[cfg(target_os = "macos")]
441pub fn render_popover(view: &ViewHandle, win_w: u32, win_h: u32, w: u32, h: u32) -> Rendered {
442    unsafe {
443        let _pool = objc::AutoreleasePool::new();
444        let win = titled_window(view, win_w, win_h);
445        // Wait for the popover to exist rather than for a fixed 0.8s. Its
446        // neighbours were converted to `pump_until` and this one was not, which
447        // made it the most expensive capture in the suite by an order of
448        // magnitude — 0.92s a variant, nearly all of it sleeping past the point
449        // the window was already there.
450        pump_until(0.8, || !find_window(|name| name.contains("Popover")).is_null());
451        let popover = find_window(|name| name.contains("Popover"));
452        let out = if popover.is_null() {
453            Rendered { width: w, height: h, pixels: vec![0; (w * h * 4) as usize] }
454        } else {
455            capture_window(popover, w, h)
456        };
457        objc::send(win, objc::sel(c"close"));
458        // Closing the parent leaves the popover's own window behind, and a
459        // stray borderless window stays key and swallows the mouse events a
460        // later test posts. Take it down explicitly.
461        if !popover.is_null() {
462            objc::send_id(popover, objc::sel(c"orderOut:"), core::ptr::null_mut());
463            objc::send(popover, objc::sel(c"close"));
464        }
465        pump(0.01);
466        out
467    }
468}
469
470/// The first application window whose class name satisfies `pred`, or null.
471#[cfg(target_os = "macos")]
472unsafe fn find_window(pred: impl Fn(&str) -> bool) -> *mut c_void {
473    unsafe {
474        let wins = objc::send(objc::shared_app(), objc::sel(c"windows"));
475        if wins.is_null() {
476            return core::ptr::null_mut();
477        }
478        let n = objc::send_ret_u64(wins, objc::sel(c"count"));
479        for i in 0..n {
480            let w = objc::send_u64_id(wins, objc::sel(c"objectAtIndex:"), i, core::ptr::null_mut());
481            let cls = objc::send(w, objc::sel(c"className"));
482            let p = objc::send_ret_ptr(cls, objc::sel(c"UTF8String"));
483            if p.is_null() {
484                continue;
485            }
486            let name = core::ffi::CStr::from_ptr(p as *const i8).to_string_lossy();
487            if pred(&name) {
488                return w;
489            }
490        }
491        core::ptr::null_mut()
492    }
493}
494
495/// A titled window whose *frame* is `width` x `height`, shown without taking focus.
496#[cfg(target_os = "macos")]
497unsafe fn titled_window(view: &ViewHandle, width: u32, height: u32) -> *mut c_void {
498    unsafe {
499        let app = objc::shared_app();
500        // `.accessory`, not `.regular`: the windows appear but never steal focus.
501        objc::send_i64(app, objc::sel(c"setActivationPolicy:"), 1);
502        // A bare executable has no bundle identifier, so AppKit cannot key its
503        // window-tab index and faults the second time a titled, resizable window
504        // is registered. Nothing here wants tabbing anyway.
505        objc::send_bool(
506            objc::class(c"OBJC_CLASS_$_NSWindow"),
507            objc::sel(c"setAllowsAutomaticWindowTabbing:"),
508            false,
509        );
510
511        let hosting = HostingView::new(view);
512        let bounds = NSRect::new(0.0, 0.0, width as f64, height as f64);
513        let win = objc::send(objc::class(c"OBJC_CLASS_$_NSWindow"), objc::sel(c"alloc"));
514        type InitWin =
515            unsafe extern "C" fn(*mut c_void, *const c_void, NSRect, u64, u64, bool) -> *mut c_void;
516        let init: InitWin = core::mem::transmute(objc::msg_send_addr());
517        // titled | closable | miniaturizable | resizable
518        let win = init(win, objc::sel(c"initWithContentRect:styleMask:backing:defer:"), bounds, 15, 2, false);
519
520        let name = objc::nsstring("NSAppearanceNameAqua");
521        let appearance = objc::send_id(
522            objc::class(c"OBJC_CLASS_$_NSAppearance"),
523            objc::sel(c"appearanceNamed:"),
524            name,
525        );
526        if !appearance.is_null() {
527            objc::send_id(win, objc::sel(c"setAppearance:"), appearance);
528        }
529        objc::send_i64(win, objc::sel(c"setTabbingMode:"), 2); // .disallowed
530        // `.none`, so AppKit does not animate the window in or a sheet down. The
531        // capture paths wait for the window to be on screen and a sheet to be
532        // attached, and with the animation running that wait is the animation's
533        // duration — a third of a second per sheet, which is most of what a
534        // sheet variant costs.
535        objc::send_i64(win, objc::sel(c"setAnimationBehavior:"), 2);
536        objc::send_id(win, objc::sel(c"setTitle:"), objc::nsstring("parity"));
537        objc::send_id(win, objc::sel(c"setContentView:"), hosting.as_ptr());
538        // Setting the *frame* (not the content rect) makes the frame view exactly
539        // width x height, so the captured bitmap lines up with what was asked for
540        // however tall the titlebar turns out to be.
541        type SetFrame = unsafe extern "C" fn(*mut c_void, *const c_void, NSRect, bool);
542        let set_frame: SetFrame = core::mem::transmute(objc::msg_send_addr());
543        set_frame(win, objc::sel(c"setFrame:display:"), bounds, true);
544        objc::send_id(win, objc::sel(c"orderFront:"), core::ptr::null_mut());
545        // `setContentView:` retained it; the window owns it from here.
546        drop(hosting);
547        win
548    }
549}
550
551/// Run `body` once, `delay` seconds from now, *inside* whatever loop is running.
552///
553/// A context menu, a drag or a modal sheet does not return to the caller: AppKit
554/// enters its own event loop in `NSEventTrackingRunLoopMode` and stays there
555/// until the user dismisses it. [`pump_events`] never gets a turn while that is
556/// happening, which is why anything that opens a menu cannot be observed from
557/// the outside at all — and why forcing the window closed from another thread
558/// takes the process down.
559///
560/// A `CFRunLoopTimer` added to the *common* modes is the way in. Common modes
561/// include event tracking, so the timer fires while the menu is up, on the main
562/// thread, between events — exactly where it is safe to look at the window list
563/// and to post the key event that dismisses it.
564///
565/// The timer repeats every `interval` seconds until `body` returns `true`, so a
566/// dismissal that does not take the first time is simply tried again.
567///
568/// # Safety
569/// Must be called on the main thread, and `body` runs on it too.
570#[cfg(target_os = "macos")]
571pub unsafe fn during_tracking(delay: f64, interval: f64, body: fn() -> bool) {
572    #[repr(C)]
573    struct TimerContext {
574        version: isize,
575        info: *mut c_void,
576        retain: *const c_void,
577        release: *const c_void,
578        copy_description: *const c_void,
579    }
580    unsafe extern "C" fn fire(timer: *mut c_void, info: *mut c_void) {
581        unsafe {
582            let body: fn() -> bool = core::mem::transmute(info);
583            if body() {
584                let invalidate: unsafe extern "C" fn(*mut c_void) =
585                    core::mem::transmute(cf(c"CFRunLoopTimerInvalidate"));
586                invalidate(timer);
587            }
588        }
589    }
590    unsafe {
591        type Create = unsafe extern "C" fn(
592            *const c_void, f64, f64, u64, isize,
593            unsafe extern "C" fn(*mut c_void, *mut c_void),
594            *mut TimerContext,
595        ) -> *mut c_void;
596        type Add = unsafe extern "C" fn(*mut c_void, *mut c_void, *mut c_void);
597        type Now = unsafe extern "C" fn() -> f64;
598        type Main = unsafe extern "C" fn() -> *mut c_void;
599        let create: Create = core::mem::transmute(cf(c"CFRunLoopTimerCreate"));
600        let add: Add = core::mem::transmute(cf(c"CFRunLoopAddTimer"));
601        let now: Now = core::mem::transmute(cf(c"CFAbsoluteTimeGetCurrent"));
602        let main: Main = core::mem::transmute(cf(c"CFRunLoopGetMain"));
603        let mut ctx = TimerContext {
604            version: 0,
605            info: body as *mut c_void,
606            retain: core::ptr::null(),
607            release: core::ptr::null(),
608            copy_description: core::ptr::null(),
609        };
610        let timer = create(core::ptr::null(), now() + delay, interval, 0, 0, fire, &mut ctx);
611        // The *constant*, not a string with the same characters: CF compares the
612        // common-modes name by pointer, so an equal `NSString` silently adds the
613        // timer to a mode called "kCFRunLoopCommonModes" that nothing runs.
614        let modes = *(cf(c"kCFRunLoopCommonModes") as *const *mut c_void);
615        add(main(), timer, modes);
616    }
617}
618
619/// A CoreFoundation entry point, by name.
620///
621/// CoreFoundation is already in the process — AppKit cannot load without it —
622/// so this never needs a `dlopen`, only a lookup.
623#[cfg(target_os = "macos")]
624fn cf(name: &core::ffi::CStr) -> *mut c_void {
625    let p = unsafe { libc::dlsym((-2isize) as *mut c_void, name.as_ptr()) };
626    assert!(!p.is_null(), "CoreFoundation: {}", name.to_string_lossy());
627    p
628}
629
630/// Post a key press and release to whichever window is key.
631///
632/// Window number 0 means "the key window", which while a menu is tracking is the
633/// menu itself — so an Escape posted here is what dismisses it. Menus read the
634/// event queue directly, so this reaches them without a `sendEvent:`.
635///
636/// # Safety
637/// Must be called on the main thread.
638#[cfg(target_os = "macos")]
639pub unsafe fn post_key(key_code: u16, characters: &str) {
640    unsafe {
641        type Make = unsafe extern "C" fn(
642            *mut c_void, *const c_void, u64, objc::NSPoint, u64, f64, i64,
643            *mut c_void, *mut c_void, *mut c_void, bool, u16,
644        ) -> *mut c_void;
645        let make: Make = core::mem::transmute(objc::msg_send_addr());
646        let chars = objc::nsstring(characters);
647        // 10 = NSEventTypeKeyDown, 11 = NSEventTypeKeyUp.
648        for kind in [10u64, 11] {
649            let event = make(
650                objc::class(c"OBJC_CLASS_$_NSEvent"),
651                objc::sel(c"keyEventWithType:location:modifierFlags:timestamp:windowNumber:context:characters:charactersIgnoringModifiers:isARepeat:keyCode:"),
652                kind,
653                objc::NSPoint { x: 0.0, y: 0.0 },
654                0,
655                uptime(),
656                0,
657                core::ptr::null_mut(),
658                chars,
659                chars,
660                false,
661                key_code,
662            );
663            if !event.is_null() {
664                objc::send_id_bool(objc::shared_app(), objc::sel(c"postEvent:atStart:"), event, true);
665            }
666        }
667    }
668}
669
670/// Run a real event loop for `seconds`, dequeuing and dispatching `NSEvent`s.
671///
672/// [`pump`] turns the run loop, which is enough for SwiftUI's clock and for
673/// display, but it never touches the application's *event queue* — so an event
674/// posted with `postEvent:atStart:` just sits there and a gesture never fires.
675/// This is the loop that delivers them.
676///
677/// Use [`pump`] for anything that only needs time to pass; use this when
678/// something has to receive input.
679#[cfg(target_os = "macos")]
680pub fn pump_events(seconds: f64) {
681    unsafe {
682        let app = objc::shared_app();
683        let deadline = uptime() + seconds;
684        while uptime() < deadline {
685            type Next = unsafe extern "C" fn(
686                *mut c_void, *const c_void, u64, *mut c_void, *mut c_void, bool,
687            ) -> *mut c_void;
688            let next: Next = core::mem::transmute(objc::msg_send_addr());
689            type DateFn = unsafe extern "C" fn(*mut c_void, *const c_void, f64) -> *mut c_void;
690            let date: DateFn = core::mem::transmute(objc::msg_send_addr());
691            let until = date(
692                objc::class(c"OBJC_CLASS_$_NSDate"),
693                objc::sel(c"dateWithTimeIntervalSinceNow:"),
694                0.01,
695            );
696            let event = next(
697                app,
698                objc::sel(c"nextEventMatchingMask:untilDate:inMode:dequeue:"),
699                u64::MAX,
700                until,
701                objc::nsstring("kCFRunLoopDefaultMode"),
702                true,
703            );
704            if !event.is_null() {
705                objc::send_id(app, objc::sel(c"sendEvent:"), event);
706            }
707        }
708    }
709}
710
711/// Seconds since boot — the timebase `NSEvent` timestamps use.
712pub fn uptime() -> f64 {
713    unsafe {
714        let info = objc::send(
715            objc::class(c"OBJC_CLASS_$_NSProcessInfo"),
716            objc::sel(c"processInfo"),
717        );
718        objc::send_ret_f64(info, objc::sel(c"systemUptime"))
719    }
720}
721
722/// Bring the application forward and wait until the window server agrees.
723///
724/// Mouse events are only routed to a view's gestures while the application is
725/// active, so anything posting synthetic input has to establish that first —
726/// and activation is asynchronous, so asking is not enough.
727#[cfg(target_os = "macos")]
728pub fn activate(timeout: f64) -> bool {
729    unsafe {
730        let app = objc::shared_app();
731        objc::send_i64(app, objc::sel(c"setActivationPolicy:"), 0); // .regular
732
733        // `activateIgnoringOtherApps:` is deprecated and, for a binary with no
734        // bundle, increasingly ignored; `activate` is its macOS 14 replacement
735        // and `NSRunningApplication` is a third route. Try all of them — which
736        // one works depends on the OS version and on what is frontmost.
737        objc::send_bool(app, objc::sel(c"activateIgnoringOtherApps:"), true);
738        if objc::responds(app, objc::sel(c"activate")) {
739            objc::send(app, objc::sel(c"activate"));
740        }
741        let running = objc::send(
742            objc::class(c"OBJC_CLASS_$_NSRunningApplication"),
743            objc::sel(c"currentApplication"),
744        );
745        if !running.is_null() {
746            // NSApplicationActivateAllWindows | NSApplicationActivateIgnoringOtherApps
747            objc::send_u64(running, objc::sel(c"activateWithOptions:"), 1 | 2);
748        }
749
750        let deadline = uptime() + timeout;
751        while uptime() < deadline {
752            if objc::send(app, objc::sel(c"isActive")) as usize & 1 == 1 {
753                return true;
754            }
755            pump_events(0.05);
756        }
757        false
758    }
759}
760
761/// Give up frontmost status and wait until the window server agrees.
762///
763/// Being active is not cosmetic: an alert draws its default button accented
764/// while its application is frontmost and plain when it is not. So anything
765/// that compares renders has to pin the state rather than inherit whatever
766/// [`activate`] last left behind.
767#[cfg(target_os = "macos")]
768pub fn deactivate(timeout: f64) -> bool {
769    unsafe {
770        let app = objc::shared_app();
771        // `.prohibited` is what the Swift reference renders under, and unlike
772        // `.accessory` it takes the application out of the foreground outright
773        // rather than asking politely.
774        objc::send_i64(app, objc::sel(c"setActivationPolicy:"), 2);
775        objc::send_id(app, objc::sel(c"hide:"), core::ptr::null_mut());
776        objc::send(app, objc::sel(c"deactivate"));
777        let deadline = uptime() + timeout;
778        while uptime() < deadline {
779            if objc::send(app, objc::sel(c"isActive")) as usize & 1 == 0 {
780                return true;
781            }
782            pump_events(0.05);
783        }
784        false
785    }
786}
787
788/// [`pump_until`], but dequeuing application events as well.
789///
790/// Anything waiting on a synthetic click needs this rather than [`pump_until`]:
791/// turning the run loop does not drain the application's event queue, so the
792/// event that would satisfy `ready` never arrives and the wait always runs to
793/// its limit.
794#[cfg(target_os = "macos")]
795pub fn pump_events_until(limit: f64, mut ready: impl FnMut() -> bool) {
796    let step = 0.005;
797    let mut waited = 0.0;
798    while waited < limit {
799        pump_events(step);
800        waited += step;
801        if ready() {
802            pump_events(0.02);
803            return;
804        }
805    }
806}
807
808/// Post a synthetic mouse event to `window`, to be delivered by [`pump_events`].
809///
810/// `kind` is an `NSEventType`: 1 left-down, 2 left-up, 6 left-dragged,
811/// 3 right-down, 4 right-up. `x`/`y` are in the window's coordinate space, with
812/// the origin at the bottom left.
813///
814/// # Safety
815/// `window` must be a live `NSWindow`.
816#[cfg(target_os = "macos")]
817pub unsafe fn post_mouse(window: *mut c_void, kind: u64, x: f64, y: f64, clicks: i64) {
818    unsafe {
819        let event = mouse_event(window, kind, x, y, clicks);
820        if !event.is_null() {
821            objc::send_id_bool(objc::shared_app(), objc::sel(c"postEvent:atStart:"), event, false);
822        }
823    }
824}
825
826/// Build a synthetic `NSEvent` in `window`'s coordinate space.
827///
828/// `kind` is an `NSEventType`: 1 left-down, 2 left-up, 6 left-dragged,
829/// 3 right-down, 4 right-up. `x`/`y` have the origin at the bottom left.
830/// Build a synthetic `NSEvent` without delivering it, so a caller can choose the
831/// route. Only `examples/activation_probe.rs` needs this — it compares the
832/// routes against each other; everything else should use [`send_mouse`].
833///
834/// # Safety
835/// `window` must be a live `NSWindow`.
836#[cfg(target_os = "macos")]
837pub unsafe fn mouse_event_for(
838    window: *mut c_void,
839    kind: u64,
840    x: f64,
841    y: f64,
842    clicks: i64,
843) -> *mut c_void {
844    unsafe { mouse_event(window, kind, x, y, clicks) }
845}
846
847#[cfg(target_os = "macos")]
848unsafe fn mouse_event(window: *mut c_void, kind: u64, x: f64, y: f64, clicks: i64) -> *mut c_void {
849    unsafe {
850        let number = objc::send_ret_u64(window, objc::sel(c"windowNumber")) as i64;
851        type Make = unsafe extern "C" fn(
852            *mut c_void, *const c_void, u64, objc::NSPoint, u64, f64, i64, *mut c_void, i64, i64, f32,
853        ) -> *mut c_void;
854        let make: Make = core::mem::transmute(objc::msg_send_addr());
855        make(
856            objc::class(c"OBJC_CLASS_$_NSEvent"),
857            objc::sel(c"mouseEventWithType:location:modifierFlags:timestamp:windowNumber:context:eventNumber:clickCount:pressure:"),
858            kind,
859            objc::NSPoint { x, y },
860            0,
861            uptime(),
862            number,
863            core::ptr::null_mut(),
864            0,
865            clicks,
866            if kind == 2 || kind == 4 { 0.0 } else { 1.0 },
867        )
868    }
869}
870
871/// Hand a synthetic mouse event straight to `window`, bypassing the
872/// application's event queue.
873///
874/// [`post_mouse`] enqueues on `NSApp` and needs the run loop to dequeue it;
875/// `sendEvent:` is the method `NSApp` would itself call once it had, so the
876/// event arrives without waiting on the queue.
877///
878/// This is not a way around needing focus, and neither is anything else. With
879/// the process inactive and its window on screen but not key — measured, the
880/// window was `isVisible: true` and occlusion-visible on a real screen — a
881/// click was delivered by none of: this, `NSApp sendEvent:`, `NSApp
882/// postEvent:`, `mouseDown:` at the hit-tested view, or any of those with
883/// `acceptsFirstMouse:` installed on the hosting view's class at run time.
884/// Nor by changing what the window *is*: an `NSPanel`, and a
885/// `nonactivatingPanel` — which becomes key without activating its
886/// application, and is the obvious thing to reach for here — both stayed
887/// non-key and delivered nothing, with and without a `makeKeyWindow` or an
888/// [`activate`].
889///
890/// What decides it is `CGSSessionScreenIsLocked`. A locked login session has no
891/// active application and cannot be given one, so every route above is closed
892/// at the same place; unlock it and all of them work. That is the point of the
893/// lock rather than a gap, so code here should ask whether the session is
894/// locked and say so, not try to route around it.
895///
896/// Below AppKit is worse, not better: Quartz Event Services
897/// (`CGEventPostToPid`, `CGEventPost`) is gated on the Accessibility
898/// permission, and an unbundled binary reports `AXIsProcessTrusted() == false`,
899/// so both of those deliver nothing even when the application *is* frontmost.
900/// See `examples/activation_probe.rs`, which measures each route side by side.
901///
902/// # Safety
903/// `window` must be a live `NSWindow`.
904#[cfg(target_os = "macos")]
905pub unsafe fn send_mouse(window: *mut c_void, kind: u64, x: f64, y: f64, clicks: i64) {
906    unsafe {
907        let event = mouse_event(window, kind, x, y, clicks);
908        if !event.is_null() {
909            objc::send_id(window, objc::sel(c"sendEvent:"), event);
910        }
911    }
912}
913
914/// Send a key down and up straight to `window`.
915///
916/// [`post_key`] puts the event on the *application's* queue, which is what a
917/// tracking menu reads. This hands it to one window with `sendEvent:`, the same
918/// route [`send_mouse`] takes — which is what a check driving a focused view
919/// wants, since it does not depend on which window the application thinks is
920/// key beyond that window being able to accept it at all.
921///
922/// `flags` are `NSEvent.ModifierFlags`, so `1 << 20` is Command and `1 << 17`
923/// Shift. They are what SwiftUI turns into the `EventModifiers` a `KeyPress`
924/// carries.
925///
926/// # Safety
927/// `window` must be a live `NSWindow`, and this must run on the main thread.
928#[cfg(target_os = "macos")]
929pub unsafe fn send_key(window: *mut c_void, key_code: u16, characters: &str, flags: u64) {
930    unsafe {
931        type Make = unsafe extern "C" fn(
932            *mut c_void, *const c_void, u64, objc::NSPoint, u64, f64, i64,
933            *mut c_void, *mut c_void, *mut c_void, bool, u16,
934        ) -> *mut c_void;
935        let make: Make = core::mem::transmute(objc::msg_send_addr());
936        let chars = objc::nsstring(characters);
937        let number = objc::send_ret_u64(window, objc::sel(c"windowNumber")) as i64;
938        // 10 = NSEventTypeKeyDown, 11 = NSEventTypeKeyUp.
939        for kind in [10u64, 11] {
940            let event = make(
941                objc::class(c"OBJC_CLASS_$_NSEvent"),
942                objc::sel(c"keyEventWithType:location:modifierFlags:timestamp:windowNumber:context:characters:charactersIgnoringModifiers:isARepeat:keyCode:"),
943                kind,
944                objc::NSPoint { x: 0.0, y: 0.0 },
945                flags,
946                uptime(),
947                number,
948                core::ptr::null_mut(),
949                chars,
950                chars,
951                false,
952                key_code,
953            );
954            if !event.is_null() {
955                objc::send_id(window, objc::sel(c"sendEvent:"), event);
956            }
957        }
958    }
959}
960
961// ═══════════════════════════════════════════════════════════════════════════
962// UIKit capture
963// ═══════════════════════════════════════════════════════════════════════════
964//
965// AppKit's `cacheDisplayInRect:toBitmapImageRep:` has no UIKit counterpart, so
966// these go through the layer instead. A hosting controller's view has to be
967// inside a window before UIKit will lay it out at all — the same reason the
968// macOS path builds an offscreen `NSWindow`.
969
970/// Render a view offscreen at exactly `width` x `height` pixels.
971#[cfg(not(target_os = "macos"))]
972pub fn render(view: &ViewHandle, width: u32, height: u32) -> Rendered {
973    let hv = HostingView::new(view);
974    render_hosting(&hv, width, height)
975}
976
977/// Render an existing hosting view — used when the root is swapped between frames.
978#[cfg(not(target_os = "macos"))]
979pub fn render_hosting(hv: &HostingView, width: u32, height: u32) -> Rendered {
980    unsafe {
981        let _pool = objc::AutoreleasePool::new();
982        let window = hv.offscreen(width as f64, height as f64);
983        hv.set_frame(width as f64, height as f64);
984        objc::send(window, objc::sel(c"layoutIfNeeded"));
985        capture_view(hv.as_ptr(), width, height)
986    }
987}
988
989/// Capture any `UIView` into a `width` x `height` bitmap.
990///
991/// # Safety
992/// `view` must be a live `UIView`.
993#[cfg(not(target_os = "macos"))]
994pub unsafe fn capture_view(view: *mut c_void, width: u32, height: u32) -> Rendered {
995    unsafe {
996        let layer = objc::send(view, objc::sel(c"layer"));
997        assert!(!layer.is_null(), "the view has no layer to render");
998        let pixels = crate::cg::capture_layer(layer, width, height);
999        Rendered { width, height, pixels }
1000    }
1001}
1002
1003/// Run the main run loop for `seconds`, letting SwiftUI's clock advance.
1004///
1005/// Needed between frames when capturing something driven by [`crate::views::timeline`]:
1006/// without turning the run loop, SwiftUI never gets to redisplay.
1007pub fn pump(seconds: f64) {
1008    unsafe {
1009        let loop_ = objc::send(
1010            objc::class(c"OBJC_CLASS_$_NSRunLoop"),
1011            objc::sel(c"currentRunLoop"),
1012        );
1013        type DateFn = unsafe extern "C" fn(*mut c_void, *const c_void, f64) -> *mut c_void;
1014        let f: DateFn = core::mem::transmute(objc::msg_send_addr());
1015        let until = f(
1016            objc::class(c"OBJC_CLASS_$_NSDate"),
1017            objc::sel(c"dateWithTimeIntervalSinceNow:"),
1018            seconds,
1019        );
1020        objc::send_id(loop_, objc::sel(c"runUntilDate:"), until);
1021    }
1022}
1023
1024/// Render straight to a PNG file.
1025pub fn render_png(view: &ViewHandle, width: u32, height: u32, path: &str) -> bool {
1026    let img = render(view, width, height);
1027    img.write_png(path)
1028}
1029
1030#[cfg(target_os = "macos")]
1031unsafe fn new_bitmap(width: u32, height: u32) -> *mut c_void {
1032    let alloc = unsafe {
1033        objc::send(objc::class(c"OBJC_CLASS_$_NSBitmapImageRep"), objc::sel(c"alloc"))
1034    };
1035    #[allow(clippy::too_many_arguments)]
1036    type Init = unsafe extern "C" fn(
1037        *mut c_void,
1038        *const c_void,
1039        *mut u8, // planes
1040        i64,     // pixelsWide
1041        i64,     // pixelsHigh
1042        i64,     // bitsPerSample
1043        i64,     // samplesPerPixel
1044        bool,    // hasAlpha
1045        bool,    // isPlanar
1046        *mut c_void, // colorSpaceName
1047        i64,     // bytesPerRow
1048        i64,     // bitsPerPixel
1049    ) -> *mut c_void;
1050    let init: Init = unsafe { core::mem::transmute(objc::msg_send_addr()) };
1051    let space = unsafe { objc::nsstring("NSDeviceRGBColorSpace") };
1052    let rep = unsafe {
1053        init(
1054            alloc,
1055            objc::sel(c"initWithBitmapDataPlanes:pixelsWide:pixelsHigh:bitsPerSample:samplesPerPixel:hasAlpha:isPlanar:colorSpaceName:bytesPerRow:bitsPerPixel:"),
1056            core::ptr::null_mut(),
1057            width as i64,
1058            height as i64,
1059            8,
1060            4,
1061            true,
1062            false,
1063            space,
1064            0,
1065            0,
1066        )
1067    };
1068    assert!(!rep.is_null(), "NSBitmapImageRep creation failed");
1069    rep
1070}
1071
1072#[cfg(target_os = "macos")]
1073unsafe fn write_rep_png(rep: *mut c_void, path: &str) -> bool {
1074    unsafe {
1075        let props =
1076            objc::send(objc::class(c"OBJC_CLASS_$_NSDictionary"), objc::sel(c"dictionary"));
1077        // 4 = NSBitmapImageFileTypePNG
1078        let data =
1079            objc::send_u64_id(rep, objc::sel(c"representationUsingType:properties:"), 4, props);
1080        if data.is_null() {
1081            return false;
1082        }
1083        objc::send_id_bool(
1084            data,
1085            objc::sel(c"writeToFile:atomically:"),
1086            objc::nsstring(path),
1087            true,
1088        )
1089    }
1090}
1091
1092
1093/// Render a view through SwiftUI's own `ImageRenderer`.
1094///
1095/// This is a different path from [`render`], and the difference matters:
1096/// `cacheDisplayInRect` captures the AppKit layer tree, which does *not* execute
1097/// Metal shader effects or Liquid Glass compositing — those are resolved by
1098/// SwiftUI's renderer, so they come out as no-ops. `ImageRenderer` goes through
1099/// SwiftUI itself and produces what the shader actually computes.
1100///
1101/// The view is framed to the requested size first, since `ImageRenderer` renders
1102/// at the content's natural size.
1103pub fn render_image(view: &ViewHandle, width: u32, height: u32) -> Rendered {
1104    unsafe {
1105        let _pool = objc::AutoreleasePool::new();
1106        let framed = crate::views::frame(view, width as f64, height as f64);
1107
1108        let meta = crate::abi::generic_metadata2(
1109            crate::resolve::image_renderer_ma(),
1110            crate::resolve::anyview_meta(),
1111            crate::resolve::anyview_wt(),
1112        );
1113        assert!(!meta.is_null(), "ImageRenderer<AnyView> metadata");
1114
1115        // init(content:) — x0 = @in AnyView, x20 = ImageRenderer<AnyView>.Type
1116        // `as_ptr` hands back a `+1` AnyView of its own; the initialiser
1117        // consumes it. No extra retain.
1118        let mut content: u64 = framed.as_ptr() as u64;
1119        let renderer: *mut c_void;
1120        core::arch::asm!("blr {f}", f = in(reg) crate::resolve::image_renderer_init(),
1121            in("x0") &mut content as *mut u64,
1122            in("x20") meta,
1123            lateout("x0") renderer,
1124            lateout("x1") _, lateout("x2") _, lateout("x3") _, lateout("x4") _,
1125            lateout("x5") _, lateout("x6") _, lateout("x7") _, lateout("x8") _,
1126            lateout("x9") _, lateout("x10") _, lateout("x11") _, lateout("x12") _,
1127            lateout("x13") _, lateout("x14") _, lateout("x15") _, lateout("x16") _,
1128            lateout("x17") _, lateout("lr") _, lateout("x21") _, clobber_abi("C"),
1129        );
1130        assert!(!renderer.is_null(), "ImageRenderer creation failed");
1131
1132        // scale = 1, so the output is exactly width x height pixels
1133        core::arch::asm!("blr {f}", f = in(reg) crate::resolve::image_renderer_scale_set(),
1134            in("d0") 1.0f64,
1135            in("x20") renderer,
1136            lateout("x0") _, lateout("x1") _, lateout("x2") _, lateout("x3") _,
1137            lateout("x4") _, lateout("x5") _, lateout("x6") _, lateout("x7") _,
1138            lateout("x8") _, lateout("x9") _, lateout("x10") _, lateout("x11") _,
1139            lateout("x12") _, lateout("x13") _, lateout("x14") _, lateout("x15") _,
1140            lateout("x16") _, lateout("x17") _, lateout("lr") _, lateout("x21") _,
1141            clobber_abi("C"),
1142        );
1143
1144        let cg: *mut c_void;
1145        core::arch::asm!("blr {f}", f = in(reg) crate::resolve::image_renderer_cgimage(),
1146            in("x20") renderer,
1147            lateout("x0") cg,
1148            lateout("x1") _, lateout("x2") _, lateout("x3") _, lateout("x4") _,
1149            lateout("x5") _, lateout("x6") _, lateout("x7") _, lateout("x8") _,
1150            lateout("x9") _, lateout("x10") _, lateout("x11") _, lateout("x12") _,
1151            lateout("x13") _, lateout("x14") _, lateout("x15") _, lateout("x16") _,
1152            lateout("x17") _, lateout("lr") _, lateout("x21") _, clobber_abi("C"),
1153        );
1154        assert!(!cg.is_null(), "ImageRenderer produced no image");
1155
1156        let out = {
1157            #[cfg(target_os = "macos")]
1158            {
1159                let alloc =
1160                    objc::send(objc::class(c"OBJC_CLASS_$_NSBitmapImageRep"), objc::sel(c"alloc"));
1161                let rep = objc::send_id(alloc, objc::sel(c"initWithCGImage:"), cg);
1162                assert!(!rep.is_null(), "NSBitmapImageRep from CGImage failed");
1163
1164                let data = objc::send_ret_ptr(rep, objc::sel(c"bitmapData"));
1165                let row = objc::send_ret_u64(rep, objc::sel(c"bytesPerRow")) as usize;
1166                let w = objc::send_ret_u64(rep, objc::sel(c"pixelsWide")) as u32;
1167                let h = objc::send_ret_u64(rep, objc::sel(c"pixelsHigh")) as u32;
1168
1169                let mut pixels = vec![0u8; (w * h * 4) as usize];
1170                for y in 0..h as usize {
1171                    core::ptr::copy_nonoverlapping(
1172                        data.add(y * row),
1173                        pixels.as_mut_ptr().add(y * w as usize * 4),
1174                        w as usize * 4,
1175                    );
1176                }
1177                objc::send(rep, objc::sel(c"release"));
1178                Rendered { width: w, height: h, pixels }
1179            }
1180            #[cfg(not(target_os = "macos"))]
1181            {
1182                let (w, h, pixels) = crate::cg::cgimage_to_rgba(cg);
1183                Rendered { width: w, height: h, pixels }
1184            }
1185        };
1186
1187        // A Swift property getter returns an owned value, and `ImageRenderer`
1188        // is a class: both are ours to let go of. Rendering a few hundred
1189        // frames otherwise costs a renderer and a full-size image each.
1190        unsafe extern "C" {
1191            fn CGImageRelease(image: *mut c_void);
1192        }
1193        CGImageRelease(cg);
1194        swift_runtime_sys::RuntimeRaw::swift_release(renderer);
1195        out
1196    }
1197}
1198
1199// ═══════════════════════════════════════════════════════════════════════════
1200// Animated capture
1201// ═══════════════════════════════════════════════════════════════════════════
1202
1203/// A sequence of rendered frames.
1204pub struct Animation {
1205    pub frames: Vec<Rendered>,
1206}
1207
1208impl Animation {
1209    /// Render `count` frames, asking `build` for the view at each step.
1210    ///
1211    /// `t` runs from 0.0 to 1.0 inclusive, so the caller expresses the animation
1212    /// as a pure function of progress. That keeps capture deterministic: no
1213    /// wall-clock, no run loop, and the same frames every run and on both sides
1214    /// of a parity comparison.
1215    pub fn sweep(
1216        count: usize,
1217        width: u32,
1218        height: u32,
1219        build: impl Fn(f64) -> ViewHandle,
1220    ) -> Self {
1221        Self::sweep_with(count, width, height, false, build)
1222    }
1223
1224    /// As [`Animation::sweep`], but choosing the capture path.
1225    ///
1226    /// Filter effects — blur, saturation, brightness, contrast, grayscale — and
1227    /// Metal shaders do not execute through `cacheDisplayInRect`; capturing an
1228    /// animation of one that way yields frames that are all identical.
1229    pub fn sweep_with(
1230        count: usize,
1231        width: u32,
1232        height: u32,
1233        via_image_renderer: bool,
1234        build: impl Fn(f64) -> ViewHandle,
1235    ) -> Self {
1236        let frames = (0..count)
1237            .map(|i| {
1238                let t = if count <= 1 { 0.0 } else { i as f64 / (count - 1) as f64 };
1239                let view = build(t);
1240                if via_image_renderer {
1241                    render_image(&view, width, height)
1242                } else {
1243                    render(&view, width, height)
1244                }
1245            })
1246            .collect();
1247        Self { frames }
1248    }
1249
1250    /// Total pixels differing from `other`, summed over frames.
1251    /// `u32::MAX` if the frame counts differ.
1252    pub fn diff_count(&self, other: &Animation, tolerance: u8) -> u32 {
1253        if self.frames.len() != other.frames.len() {
1254            return u32::MAX;
1255        }
1256        self.frames
1257            .iter()
1258            .zip(&other.frames)
1259            .map(|(a, b)| a.diff_count(b, tolerance))
1260            .fold(0u32, |acc, n| acc.saturating_add(n))
1261    }
1262
1263    /// Per-frame change: how many pixels differ between consecutive frames.
1264    ///
1265    /// An animation whose frames are all identical is not animating, however
1266    /// well-formed the file is.
1267    pub fn frame_deltas(&self) -> Vec<u32> {
1268        self.frames
1269            .windows(2)
1270            .map(|w| w[0].diff_count(&w[1], 0))
1271            .collect()
1272    }
1273
1274    /// Write the frames tiled left-to-right as one still image.
1275    ///
1276    /// An APNG only animates in a viewer that supports it — most macOS previews
1277    /// show frame one and stop — so a filmstrip is the version that is legible
1278    /// anywhere.
1279    pub fn write_filmstrip(&self, path: &str) -> bool {
1280        let Some(first) = self.frames.first() else { return false };
1281        let (fw, fh) = (first.width as usize, first.height as usize);
1282        let n = self.frames.len();
1283        let gap = 4usize;
1284        let width = fw * n + gap * (n - 1);
1285        let mut pixels = vec![0u8; width * fh * 4];
1286        for (i, frame) in self.frames.iter().enumerate() {
1287            let x0 = i * (fw + gap);
1288            for y in 0..fh {
1289                let src = &frame.pixels[y * fw * 4..(y + 1) * fw * 4];
1290                let dst = (y * width + x0) * 4;
1291                pixels[dst..dst + fw * 4].copy_from_slice(src);
1292            }
1293        }
1294        Rendered { width: width as u32, height: fh as u32, pixels }.write_png(path)
1295    }
1296
1297    /// Write the frames as an animated PNG.
1298    pub fn write_apng(&self, path: &str, delay_ms: u16) -> std::io::Result<()> {
1299        std::fs::write(path, encode_apng(&self.frames, delay_ms))
1300    }
1301}
1302
1303// ── A minimal APNG encoder ────────────────────────────────────────────────
1304//
1305// Deflate is emitted as *stored* (uncompressed) blocks. That is a valid zlib
1306// stream, needs no compression code and no dependency, and produces bytes that
1307// are identical for identical pixels — which is what matters for a test
1308// artifact. The files are large; that is the trade.
1309
1310fn crc32(data: &[u8]) -> u32 {
1311    let mut table = [0u32; 256];
1312    for (i, entry) in table.iter_mut().enumerate() {
1313        let mut c = i as u32;
1314        for _ in 0..8 {
1315            c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
1316        }
1317        *entry = c;
1318    }
1319    let mut c = 0xFFFF_FFFFu32;
1320    for &b in data {
1321        c = table[((c ^ b as u32) & 0xFF) as usize] ^ (c >> 8);
1322    }
1323    c ^ 0xFFFF_FFFF
1324}
1325
1326fn adler32(data: &[u8]) -> u32 {
1327    let (mut a, mut b) = (1u32, 0u32);
1328    for &byte in data {
1329        a = (a + byte as u32) % 65521;
1330        b = (b + a) % 65521;
1331    }
1332    (b << 16) | a
1333}
1334
1335/// zlib stream made of stored deflate blocks.
1336fn zlib_stored(data: &[u8]) -> Vec<u8> {
1337    let mut out = vec![0x78, 0x01]; // CMF/FLG for a stored stream
1338    let mut chunks = data.chunks(65535).peekable();
1339    if data.is_empty() {
1340        out.extend_from_slice(&[0x01, 0x00, 0x00, 0xFF, 0xFF]);
1341    }
1342    while let Some(chunk) = chunks.next() {
1343        let last = chunks.peek().is_none();
1344        out.push(u8::from(last));
1345        let n = chunk.len() as u16;
1346        out.extend_from_slice(&n.to_le_bytes());
1347        out.extend_from_slice(&(!n).to_le_bytes());
1348        out.extend_from_slice(chunk);
1349    }
1350    out.extend_from_slice(&adler32(data).to_be_bytes());
1351    out
1352}
1353
1354fn chunk(kind: &[u8; 4], payload: &[u8]) -> Vec<u8> {
1355    let mut out = Vec::with_capacity(payload.len() + 12);
1356    out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
1357    out.extend_from_slice(kind);
1358    out.extend_from_slice(payload);
1359    let mut crc_input = Vec::with_capacity(payload.len() + 4);
1360    crc_input.extend_from_slice(kind);
1361    crc_input.extend_from_slice(payload);
1362    out.extend_from_slice(&crc32(&crc_input).to_be_bytes());
1363    out
1364}
1365
1366/// PNG scanlines with a leading filter byte of 0 on each row.
1367fn scanlines(frame: &Rendered) -> Vec<u8> {
1368    let stride = frame.width as usize * 4;
1369    let mut raw = Vec::with_capacity((stride + 1) * frame.height as usize);
1370    for y in 0..frame.height as usize {
1371        raw.push(0);
1372        raw.extend_from_slice(&frame.pixels[y * stride..(y + 1) * stride]);
1373    }
1374    raw
1375}
1376
1377fn encode_apng(frames: &[Rendered], delay_ms: u16) -> Vec<u8> {
1378    assert!(!frames.is_empty(), "an APNG needs at least one frame");
1379    let (w, h) = (frames[0].width, frames[0].height);
1380
1381    let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
1382
1383    let mut ihdr = Vec::new();
1384    ihdr.extend_from_slice(&w.to_be_bytes());
1385    ihdr.extend_from_slice(&h.to_be_bytes());
1386    ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); // 8-bit RGBA
1387    png.extend_from_slice(&chunk(b"IHDR", &ihdr));
1388
1389    let mut actl = Vec::new();
1390    actl.extend_from_slice(&(frames.len() as u32).to_be_bytes());
1391    actl.extend_from_slice(&0u32.to_be_bytes()); // loop forever
1392    png.extend_from_slice(&chunk(b"acTL", &actl));
1393
1394    let mut seq = 0u32;
1395    for (i, frame) in frames.iter().enumerate() {
1396        let mut fctl = Vec::new();
1397        fctl.extend_from_slice(&seq.to_be_bytes());
1398        seq += 1;
1399        fctl.extend_from_slice(&w.to_be_bytes());
1400        fctl.extend_from_slice(&h.to_be_bytes());
1401        fctl.extend_from_slice(&0u32.to_be_bytes()); // x offset
1402        fctl.extend_from_slice(&0u32.to_be_bytes()); // y offset
1403        fctl.extend_from_slice(&delay_ms.to_be_bytes());
1404        fctl.extend_from_slice(&1000u16.to_be_bytes()); // delay denominator
1405        fctl.extend_from_slice(&[0, 0]); // dispose = none, blend = source
1406        png.extend_from_slice(&chunk(b"fcTL", &fctl));
1407
1408        let data = zlib_stored(&scanlines(frame));
1409        if i == 0 {
1410            png.extend_from_slice(&chunk(b"IDAT", &data));
1411        } else {
1412            let mut fdat = seq.to_be_bytes().to_vec();
1413            seq += 1;
1414            fdat.extend_from_slice(&data);
1415            png.extend_from_slice(&chunk(b"fdAT", &fdat));
1416        }
1417    }
1418
1419    png.extend_from_slice(&chunk(b"IEND", &[]));
1420    png
1421}