Skip to main content

frust_shell_common/
ffi_support.rs

1//! Pure, host-testable helpers shared by every platform shell's FFI layer.
2//!
3//! These carry no `jni`/`ndk`/GPU dependency so they compile and are unit-tested
4//! on the host (`cargo test --workspace`), even though the code that calls them
5//! (a shell's FFI boundary and per-frame driver) is platform-gated.
6
7use std::panic::{AssertUnwindSafe, catch_unwind};
8
9use frust_core::WindowMetrics;
10use frust_core::insets::{CornerInset, CornerInsets, EdgeInsets, WindowInsets};
11use kurbo::Size;
12
13/// Sanitize a raw density (e.g. a JNI `jfloat`) into a scale safe to divide or
14/// multiply by: finite and strictly positive, else the `1.0` fallback.
15///
16/// The platform-supplied `density` is untrusted input (the surface state
17/// machine assumes a well-behaved platform, but density arrives through a
18/// separate, unchecked scalar argument); a bogus value here must never propagate
19/// into a `NaN`/infinite or zero-divide layout or paint transform.
20///
21/// A shell's per-frame driver must call this exactly once per frame and reuse
22/// the identical result for both layout (via [`logical_size`]) and the paint
23/// transform, so the two passes can never disagree on scale.
24#[inline]
25pub fn sanitize_scale(raw: f32) -> f64 {
26    if raw.is_finite() && raw > 0.0 {
27        raw as f64
28    } else {
29        1.0
30    }
31}
32
33/// Logical (density-independent) size from a physical pixel size and an
34/// already-[`sanitize_scale`]d scale factor, mirroring the desktop shell's
35/// HiDPI math: lay out in logical pixels, then scale the scene
36/// by `scale` so glyphs re-rasterise sharp at physical resolution.
37#[inline]
38pub fn logical_size(physical_width: u32, physical_height: u32, scale: f64) -> (f64, f64) {
39    (
40        physical_width as f64 / scale,
41        physical_height as f64 / scale,
42    )
43}
44
45/// Build a logical (density-independent) [`WindowInsets`] from the platform's
46/// raw **physical**-px per-edge inset values and an already-[`sanitize_scale`]d
47/// scale factor.
48///
49/// `physical` packs the two per-edge sets a shell reads from the platform, in
50/// device px, in this fixed order:
51///
52/// ```text
53/// [0] view_padding.left    [4] view_insets.left
54/// [1] view_padding.top     [5] view_insets.top
55/// [2] view_padding.right   [6] view_insets.right
56/// [3] view_padding.bottom  [7] view_insets.bottom
57/// ```
58///
59/// where `view_padding` is the system-UI occlusion (status/navigation bars,
60/// cutout) and `view_insets` the fully-obscured area (the IME) — see
61/// [`WindowInsets`]. Each edge value is sanitized (non-finite or negative → 0.0)
62/// **before** being divided by `scale` to convert device px to logical px,
63/// mirroring [`logical_size`]'s HiDPI math and [`sanitize_scale`]'s defensive
64/// posture. A platform-supplied inset must never propagate non-finite or negative
65/// into the layout/paint passes. The framework receives insets in the same
66/// logical space it lays out in (the logical-coordinate contract — the
67/// same discipline pointer events follow).
68///
69/// `scale` must already have passed through [`sanitize_scale`] (finite,
70/// strictly positive); a shell's per-frame driver sanitizes the platform
71/// density exactly once and reuses the identical result here and for
72/// [`logical_size`] so the two never disagree on scale — this function trusts
73/// that contract exactly as [`logical_size`] does.
74#[inline]
75pub fn logical_insets(physical: [f64; 8], scale: f64) -> WindowInsets {
76    let sanitize_edge = |px: f64| if px.is_finite() && px >= 0.0 { px } else { 0.0 };
77    let to_logical = |px: f64| sanitize_edge(px) / scale;
78    WindowInsets::new(
79        EdgeInsets::new(
80            to_logical(physical[0]),
81            to_logical(physical[1]),
82            to_logical(physical[2]),
83            to_logical(physical[3]),
84        ),
85        EdgeInsets::new(
86            to_logical(physical[4]),
87            to_logical(physical[5]),
88            to_logical(physical[6]),
89            to_logical(physical[7]),
90        ),
91    )
92}
93
94/// Convert platform window-control corner extents into logical
95/// [`CornerInsets`].
96///
97/// `physical` order is `[tl_w, tl_h, tr_w, tr_h, bl_w, bl_h, br_w, br_h]`. Each
98/// value is sanitized (non-finite or negative → 0.0) and then divided by
99/// `scale`, exactly like [`logical_insets`]. The caller passes an
100/// already-[`sanitize_scale`]d scale; iOS passes `1.0` because UIKit values are
101/// already logical points. Android does not call this today.
102#[inline]
103pub fn logical_corner_insets(physical: [f64; 8], scale: f64) -> CornerInsets {
104    let to_logical = |px: f64| {
105        let px = if px.is_finite() && px >= 0.0 { px } else { 0.0 };
106        px / scale
107    };
108    let corner = |w: f64, h: f64| CornerInset::new(to_logical(w), to_logical(h));
109    CornerInsets::new(
110        corner(physical[0], physical[1]),
111        corner(physical[2], physical[3]),
112        corner(physical[4], physical[5]),
113        corner(physical[6], physical[7]),
114    )
115}
116
117/// Assemble the app-facing [`WindowMetrics`] from a shell's raw surface
118/// dimensions, its already-[`sanitize_scale`]d scale, and the window's
119/// already-**logical** [`WindowInsets`].
120///
121/// The one place the three shells agree on units. `physical` is the surface's
122/// device-pixel size (Android's `nativeOnSurfaceChanged` pair, iOS's
123/// `frust_resize` pair, winit's `inner_size()`), converted to logical px here
124/// through [`logical_size`] — so [`WindowMetrics::size`] is logical on every
125/// platform, exactly like the coordinates and insets that already cross this
126/// boundary (`docs/CODE_STANDARDS.md`'s physical-at-FFI/logical-inside rule).
127/// `insets` is passed through unchanged because each shell has *already*
128/// resolved it to logical px via [`logical_insets`] (Android divides by its
129/// display density, iOS uses the identity scale because UIKit hands over
130/// points) — this must never re-divide it.
131///
132/// [`WindowMetrics::orientation`](frust_core::WindowMetrics) is derived from the
133/// logical size by [`WindowMetrics::new`]; no platform callback carries an
134/// orientation enum.
135///
136/// `scale` must already have passed through [`sanitize_scale`] (finite,
137/// strictly positive) — the same trust contract [`logical_size`] and
138/// [`logical_insets`] state, so a shell sanitizes the platform density once per
139/// use and feeds the identical value to all three.
140#[inline]
141pub fn window_metrics(physical: (u32, u32), scale: f64, insets: WindowInsets) -> WindowMetrics {
142    let (logical_w, logical_h) = logical_size(physical.0, physical.1, scale);
143    WindowMetrics::new(Size::new(logical_w, logical_h), scale, insets)
144}
145
146/// Per-shell-instance change detector over the window's [`WindowMetrics`]: each
147/// of the three shells owns one and drives it from its own resize/insets entry
148/// points, publishing only what it reports as *changed*.
149///
150/// # Why this is not a per-frame push
151///
152/// Delivering `WindowMetrics` to app code means `provide_context`-ing it under
153/// the reactive root owner, exactly as `Theme` and [`WindowInsets`] already are
154/// — a plain map insert that notifies nothing and creates no subscription, so
155/// re-providing does not itself wake a frame. The guard here exists for cost
156/// at the FFI boundary instead: a shell that re-provided metrics
157/// unconditionally once per frame would pay a lock write plus an allocation
158/// every frame for no observable benefit, since the next rebuild (already
159/// driven by the resize or inset change itself) is what actually picks the
160/// new value up. This type makes the guarded path the only path:
161/// [`poll`](Self::poll) returns `Some` **only** on an actual change, mirroring
162/// [`ThemeOverrideWatcher`](crate::ThemeOverrideWatcher)'s poll-returns-`Option`
163/// shape and `RenderRoot::set_insets`'s `PartialEq`-guarded no-op.
164///
165/// It is deliberately reactive-free (this crate ships no reactive dependency —
166/// see `docs/ARCHITECTURE.md`'s Layer Dependencies): it decides *whether* to
167/// publish and computes *what* to publish, and each shell owns the
168/// `provide_context` call itself. That keeps the unit conversion and the
169/// change-detection host-testable even though both mobile `app` modules are
170/// target-gated and never host-compiled.
171#[derive(Debug, Default)]
172pub struct WindowMetricsPublisher {
173    /// The metrics last reported as changed, or `None` before the first
174    /// [`poll`](Self::poll) — so the seeding poll a shell runs before its very
175    /// first rebuild always publishes.
176    last: Option<WindowMetrics>,
177}
178
179impl WindowMetricsPublisher {
180    /// A fresh publisher that has published nothing yet.
181    pub fn new() -> Self {
182        Self { last: None }
183    }
184
185    /// Assemble the current [`WindowMetrics`] (see [`window_metrics`] for the
186    /// unit contract) and return it **only if it differs** from the last one
187    /// this publisher reported; `None` means the shell must not re-provide.
188    ///
189    /// A shell calls this from every point where one of the inputs actually
190    /// moves — surface create/resize and the insets callback — never from its
191    /// per-frame driver, which only *reads* values these entry points already
192    /// stored.
193    pub fn poll(
194        &mut self,
195        physical: (u32, u32),
196        scale: f64,
197        insets: WindowInsets,
198    ) -> Option<WindowMetrics> {
199        let metrics = window_metrics(physical, scale, insets);
200        if self.last == Some(metrics) {
201            return None;
202        }
203        self.last = Some(metrics);
204        Some(metrics)
205    }
206
207    /// The metrics last published, or `None` before the first change-reporting
208    /// [`poll`](Self::poll).
209    pub fn last(&self) -> Option<WindowMetrics> {
210        self.last
211    }
212}
213
214/// Run `f`, catching any panic so it can never unwind across the FFI boundary
215/// (undefined behaviour); on panic, log at `error` and return `default`.
216///
217/// Every `extern` entry point a platform shell defines routes its body through
218/// this — a panic in a platform callback must be turned into a benign default,
219/// not an unwind into platform-owned frames.
220pub fn guard<T>(what: &str, default: T, f: impl FnOnce() -> T) -> T {
221    match catch_unwind(AssertUnwindSafe(f)) {
222        Ok(value) => value,
223        Err(_) => {
224            log::error!("frust-shell: panic caught at FFI boundary in {what}");
225            default
226        }
227    }
228}
229
230/// Run a shell-spawned render-thread body, catching any panic so the thread
231/// **exits cleanly** instead of unwinding out of the thread closure — following
232/// the same `catch_unwind` + `AssertUnwindSafe` + `error`-log convention as
233/// [`guard`], but for a whole-thread closure rather than an FFI entry point.
234///
235/// A dev-build render-thread panic logs here and returns, which runs the
236/// closure's owned `RenderReceiver`'s [`Drop`] — the receiver-liveness drain
237/// (see `render_split`'s `RenderReceiver`) that fires any orphaned `Ack`'s
238/// safety net, so a UI/main thread blocked on a `Pause`/`SurfaceDestroyed`
239/// barrier unblocks rather than deadlocking. This is a **diagnosability** aid,
240/// not the correctness anchor: correctness rests on the receiver-liveness drain,
241/// which fires on *any* thread exit (clean early `return`, hang teardown, or
242/// this caught panic).
243///
244/// **No-op under the release `panic = "abort"` profile** (root `Cargo.toml`):
245/// there `catch_unwind` never catches — a panic aborts the whole process before
246/// unwinding — so this wrapper matters only in dev / `panic = "unwind"` builds.
247/// The barrier deadlock the caught panic would otherwise cause bites exactly
248/// those non-abort builds (plus clean early-returns and hung threads, which this
249/// wrapper does not touch — the drain covers those).
250pub fn run_guarded_thread(what: &str, f: impl FnOnce()) {
251    if catch_unwind(AssertUnwindSafe(f)).is_err() {
252        log::error!(
253            "frust-shell: panic caught in render thread {what}; thread exiting cleanly \
254             (surface teardown + ack drain run via RenderReceiver drop)"
255        );
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn logical_size_divides_by_scale() {
265        let (w, h) = logical_size(800, 600, 2.0);
266        assert_eq!((w, h), (400.0, 300.0));
267    }
268
269    #[test]
270    fn logical_size_identity_at_scale_one() {
271        assert_eq!(logical_size(1080, 1920, 1.0), (1080.0, 1920.0));
272    }
273
274    #[test]
275    fn sanitize_scale_passes_through_normal_values() {
276        assert_eq!(sanitize_scale(2.0), 2.0);
277        assert_eq!(sanitize_scale(1.0), 1.0);
278        assert_eq!(sanitize_scale(0.75), 0.75_f64);
279    }
280
281    #[test]
282    fn sanitize_scale_falls_back_on_bogus_values() {
283        // Non-positive / non-finite densities must not divide-by-zero or NaN.
284        for bad in [0.0_f32, -1.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
285            assert_eq!(
286                sanitize_scale(bad),
287                1.0,
288                "scale {bad} should fall back to 1.0"
289            );
290        }
291    }
292
293    #[test]
294    fn logical_size_falls_back_on_bogus_scale_once_sanitized() {
295        // logical_size trusts its caller to have sanitized `scale` first (the
296        // caller — a shell's per-frame driver — must do this exactly once and
297        // reuse the result for both layout and paint).
298        for bad in [0.0_f32, -1.0, f32::NAN, f32::INFINITY] {
299            let scale = sanitize_scale(bad);
300            let (w, h) = logical_size(100, 200, scale);
301            assert_eq!(
302                (w, h),
303                (100.0, 200.0),
304                "scale {bad} should fall back to 1.0"
305            );
306        }
307    }
308
309    #[test]
310    fn logical_insets_divides_each_edge_by_scale() {
311        // A @2x display: a 48px status bar + 68px home-indicator padding, IME up.
312        let insets = logical_insets([0.0, 48.0, 0.0, 68.0, 0.0, 0.0, 0.0, 680.0], 2.0);
313        assert_eq!(
314            insets,
315            WindowInsets::new(
316                EdgeInsets::new(0.0, 24.0, 0.0, 34.0),
317                EdgeInsets::new(0.0, 0.0, 0.0, 340.0),
318            )
319        );
320        // The derived safe-area padding collapses the bottom while the IME is up.
321        assert_eq!(insets.padding(), EdgeInsets::new(0.0, 24.0, 0.0, 0.0));
322    }
323
324    #[test]
325    fn logical_insets_identity_at_scale_one() {
326        let physical = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
327        let insets = logical_insets(physical, 1.0);
328        assert_eq!(insets.view_padding, EdgeInsets::new(1.0, 2.0, 3.0, 4.0));
329        assert_eq!(insets.view_insets, EdgeInsets::new(5.0, 6.0, 7.0, 8.0));
330    }
331
332    #[test]
333    fn logical_insets_uses_sanitized_scale_from_caller() {
334        // Mirrors `logical_size`: the helper trusts an already-sanitized scale.
335        let scale = sanitize_scale(f32::NAN); // -> 1.0
336        let insets = logical_insets([0.0, 44.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], scale);
337        assert_eq!(insets.view_padding, EdgeInsets::new(0.0, 44.0, 0.0, 0.0));
338    }
339
340    #[test]
341    fn logical_insets_sanitizes_nan_edges_to_zero() {
342        // Non-finite edge values must not propagate NaN into the layout/paint passes.
343        let physical = [f64::NAN, 44.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
344        let insets = logical_insets(physical, 2.0);
345        assert_eq!(insets.view_padding, EdgeInsets::new(0.0, 22.0, 0.0, 0.0));
346    }
347
348    #[test]
349    fn logical_insets_sanitizes_infinite_edges_to_zero() {
350        // Non-finite edge values must not propagate Inf into the layout/paint passes.
351        let physical = [
352            f64::INFINITY,
353            44.0,
354            f64::NEG_INFINITY,
355            0.0,
356            0.0,
357            0.0,
358            0.0,
359            0.0,
360        ];
361        let insets = logical_insets(physical, 2.0);
362        assert_eq!(insets.view_padding, EdgeInsets::new(0.0, 22.0, 0.0, 0.0));
363    }
364
365    #[test]
366    fn logical_insets_sanitizes_negative_edges_to_zero() {
367        // Negative edge values must not propagate as insets (they're nonsensical).
368        let physical = [-10.0, 44.0, 0.0, -5.0, 0.0, 0.0, 0.0, 0.0];
369        let insets = logical_insets(physical, 2.0);
370        assert_eq!(insets.view_padding, EdgeInsets::new(0.0, 22.0, 0.0, 0.0));
371    }
372
373    #[test]
374    fn logical_insets_normal_values_unchanged() {
375        // Valid finite, non-negative edges pass through normally.
376        let physical = [8.0, 44.0, 16.0, 34.0, 0.0, 0.0, 0.0, 340.0];
377        let insets = logical_insets(physical, 2.0);
378        assert_eq!(insets.view_padding, EdgeInsets::new(4.0, 22.0, 8.0, 17.0));
379        assert_eq!(insets.view_insets, EdgeInsets::new(0.0, 0.0, 0.0, 170.0));
380    }
381
382    #[test]
383    fn logical_corner_insets_divides_each_value_by_scale() {
384        let c = logical_corner_insets([0.0, 0.0, 144.0, 48.0, 2.0, 4.0, 6.0, 8.0], 2.0);
385        assert_eq!(
386            c,
387            CornerInsets::new(
388                CornerInset::new(0.0, 0.0),
389                CornerInset::new(72.0, 24.0),
390                CornerInset::new(1.0, 2.0),
391                CornerInset::new(3.0, 4.0),
392            )
393        );
394    }
395
396    #[test]
397    fn logical_corner_insets_identity_at_scale_one() {
398        let c = logical_corner_insets([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], 1.0);
399        assert_eq!(c.top_left, CornerInset::new(1.0, 2.0));
400        assert_eq!(c.top_right, CornerInset::new(3.0, 4.0));
401        assert_eq!(c.bottom_left, CornerInset::new(5.0, 6.0));
402        assert_eq!(c.bottom_right, CornerInset::new(7.0, 8.0));
403    }
404
405    #[test]
406    fn logical_corner_insets_sanitizes_nan_inf_negative_to_zero() {
407        let c = logical_corner_insets(
408            [
409                f64::NAN,
410                f64::INFINITY,
411                f64::NEG_INFINITY,
412                -4.0,
413                10.0,
414                -0.5,
415                f64::NAN,
416                20.0,
417            ],
418            2.0,
419        );
420        assert_eq!(c.top_left, CornerInset::ZERO);
421        assert_eq!(c.top_right, CornerInset::ZERO);
422        assert_eq!(c.bottom_left, CornerInset::new(5.0, 0.0));
423        assert_eq!(c.bottom_right, CornerInset::new(0.0, 10.0));
424    }
425
426    // --- WindowMetrics: units, derived orientation, change detection ---------
427    //
428    // This is where the three shells' `WindowMetrics` publish path gets its
429    // coverage: both mobile `app` modules are target-gated (never host-compiled)
430    // and the desktop handler needs a live winit window, so the shared,
431    // reactive-free helper below is the only host-testable surface — the same
432    // reason `logical_insets`/`sanitize_scale` live in this module.
433
434    use frust_core::Orientation;
435
436    #[test]
437    fn window_metrics_size_is_logical_on_every_shell() {
438        // Android: `nativeOnSurfaceChanged` reports device pixels + density.
439        // A 1080x2400 @3x phone surface lays out as 360x800 logical.
440        let android = window_metrics((1080, 2400), 3.0, WindowInsets::default());
441        assert_eq!(android.size, Size::new(360.0, 800.0));
442        assert_eq!(android.scale, 3.0);
443
444        // iOS: `frust_resize` reports the drawable's pixel size + UIScreen
445        // scale, so the identical division applies (its *insets* are the only
446        // already-logical input — see the insets test below).
447        let ios = window_metrics((1170, 2532), 3.0, WindowInsets::default());
448        assert_eq!(ios.size, Size::new(390.0, 844.0));
449
450        // Desktop: winit `inner_size()` is physical too; a 2x HiDPI 1600x1200
451        // window is 800x600 logical.
452        let desktop = window_metrics((1600, 1200), 2.0, WindowInsets::default());
453        assert_eq!(desktop.size, Size::new(800.0, 600.0));
454
455        // Unit scale: physical and logical coincide (the non-HiDPI case).
456        let unit = window_metrics((800, 600), 1.0, WindowInsets::default());
457        assert_eq!(unit.size, Size::new(800.0, 600.0));
458    }
459
460    #[test]
461    fn window_metrics_passes_already_logical_insets_through_untouched() {
462        // The insets each shell hands in have ALREADY been through
463        // `logical_insets` (Android divides by its density, iOS uses the
464        // identity scale). Re-dividing them by `scale` here would halve a
465        // status-bar inset on every @2x device — pin the pass-through.
466        let logical = logical_insets([0.0, 48.0, 0.0, 68.0, 0.0, 0.0, 0.0, 0.0], 2.0);
467        assert_eq!(logical.view_padding, EdgeInsets::new(0.0, 24.0, 0.0, 34.0));
468
469        let metrics = window_metrics((800, 1600), 2.0, logical);
470        assert_eq!(metrics.insets, logical);
471        assert_eq!(
472            metrics.insets.view_padding,
473            EdgeInsets::new(0.0, 24.0, 0.0, 34.0)
474        );
475    }
476
477    #[test]
478    fn window_metrics_orientation_flips_when_width_and_height_cross_over() {
479        // Derived from the LOGICAL size (no platform callback carries an
480        // orientation enum), so a rotation reported purely as swapped surface
481        // dimensions still flips it.
482        let portrait = window_metrics((1080, 2400), 3.0, WindowInsets::default());
483        assert_eq!(portrait.orientation, Orientation::Portrait);
484
485        let landscape = window_metrics((2400, 1080), 3.0, WindowInsets::default());
486        assert_eq!(landscape.orientation, Orientation::Landscape);
487
488        // The crossover itself: an exact square reads as portrait (height >= width).
489        let square = window_metrics((1000, 1000), 2.0, WindowInsets::default());
490        assert_eq!(square.orientation, Orientation::Portrait);
491    }
492
493    #[test]
494    fn window_metrics_uses_sanitized_scale_from_caller() {
495        // Same trust contract as `logical_size`/`logical_insets`: the shell
496        // sanitizes once and hands the result in.
497        let scale = sanitize_scale(f32::NAN); // -> 1.0
498        let metrics = window_metrics((400, 800), scale, WindowInsets::default());
499        assert_eq!(metrics.size, Size::new(400.0, 800.0));
500        assert_eq!(metrics.scale, 1.0);
501    }
502
503    #[test]
504    fn window_metrics_publisher_reports_only_actual_changes() {
505        // THE cost-guard anchor: a shell re-`provide_context`s only when this
506        // returns `Some`. An unconditional per-frame re-provide would pay a
507        // lock write plus an allocation every frame at the FFI boundary for
508        // nothing observable, so every repeat below must be `None`.
509        let mut pub_ = WindowMetricsPublisher::new();
510        assert_eq!(pub_.last(), None, "nothing published before the first poll");
511
512        // First poll (the shell's pre-first-rebuild seeding) always publishes.
513        let first = pub_
514            .poll((1080, 2400), 3.0, WindowInsets::default())
515            .expect("the seeding poll must publish");
516        assert_eq!(first.size, Size::new(360.0, 800.0));
517        assert_eq!(pub_.last(), Some(first));
518
519        // Steady state: the same inputs re-reported (a resize callback that
520        // re-delivers unchanged dimensions, or a shell polling more than once)
521        // must NOT re-provide.
522        for _ in 0..100 {
523            assert_eq!(
524                pub_.poll((1080, 2400), 3.0, WindowInsets::default()),
525                None,
526                "unchanged metrics must never be re-provided"
527            );
528        }
529        assert_eq!(
530            pub_.last(),
531            Some(first),
532            "a no-op poll leaves the last value"
533        );
534
535        // A real rotation publishes once, then goes quiet again.
536        let rotated = pub_
537            .poll((2400, 1080), 3.0, WindowInsets::default())
538            .expect("a rotation is a real change");
539        assert_eq!(rotated.orientation, Orientation::Landscape);
540        assert_eq!(pub_.poll((2400, 1080), 3.0, WindowInsets::default()), None);
541    }
542
543    #[test]
544    fn window_metrics_publisher_detects_each_input_independently() {
545        // Size, scale, and insets each move on their own platform callback
546        // (`resize` vs. the insets report), so each must independently trip a
547        // re-provide — and each must then settle.
548        let base_insets = WindowInsets::default();
549        let mut pub_ = WindowMetricsPublisher::new();
550        assert!(pub_.poll((1080, 2400), 3.0, base_insets).is_some());
551
552        // Size only (an in-place resize, e.g. a desktop window drag).
553        assert!(pub_.poll((1080, 2000), 3.0, base_insets).is_some());
554        assert!(pub_.poll((1080, 2000), 3.0, base_insets).is_none());
555
556        // Scale only (a display-density config change at the same pixel size —
557        // it changes the logical size too, but the point is the shell need not
558        // special-case which input moved).
559        assert!(pub_.poll((1080, 2000), 2.0, base_insets).is_some());
560        assert!(pub_.poll((1080, 2000), 2.0, base_insets).is_none());
561
562        // Insets only (the IME coming up: same size, same scale).
563        let ime_up = logical_insets([0.0, 48.0, 0.0, 0.0, 0.0, 0.0, 0.0, 680.0], 2.0);
564        assert!(pub_.poll((1080, 2000), 2.0, ime_up).is_some());
565        assert!(pub_.poll((1080, 2000), 2.0, ime_up).is_none());
566
567        // ...and back down again.
568        assert!(pub_.poll((1080, 2000), 2.0, base_insets).is_some());
569        assert!(pub_.poll((1080, 2000), 2.0, base_insets).is_none());
570    }
571
572    #[test]
573    fn guard_returns_value_on_success() {
574        assert_eq!(guard("ok", 0, || 42), 42);
575    }
576
577    #[test]
578    fn guard_returns_default_on_panic() {
579        let out = guard("boom", -1, || panic!("simulated FFI callback panic"));
580        assert_eq!(
581            out, -1,
582            "a panic must be swallowed and the default returned"
583        );
584    }
585
586    #[test]
587    fn run_guarded_thread_runs_the_body_to_completion() {
588        use std::sync::atomic::{AtomicBool, Ordering};
589        let ran = AtomicBool::new(false);
590        run_guarded_thread("ok", || ran.store(true, Ordering::SeqCst));
591        assert!(ran.load(Ordering::SeqCst), "the body must run");
592    }
593
594    #[test]
595    fn run_guarded_thread_swallows_a_panic() {
596        // A render-thread panic must not unwind out of the wrapper (which would
597        // unwind the thread closure); it is caught and logged, and control returns.
598        run_guarded_thread("boom", || panic!("simulated render-thread panic"));
599    }
600}