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}