a39e04329e
Test / test (pull_request) Successful in 12m14s
The #152 poller fixes resolution + layout on a missed fold/unfold, but a transient clip could survive if card sprites held stale visuals after geometry converged. Emit StateChangedEvent alongside the synthetic WindowResized so card_plugin re-renders every sprite from scratch — silent self-heal, no player-facing prompt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
519 lines
20 KiB
Rust
519 lines
20 KiB
Rust
//! Safe-area insets.
|
||
//!
|
||
//! Reports the OS-reserved regions around the playable surface (status
|
||
//! bar at the top, gesture / navigation bar at the bottom on Android,
|
||
//! display cutouts, etc.) so UI anchored to a screen edge can avoid
|
||
//! collisions.
|
||
//!
|
||
//! On non-Android targets all four edges report `0.0`. On Android the
|
||
//! values come from `WindowInsets.getInsets(WindowInsets.Type.systemBars())`
|
||
//! via JNI; the call is retried for the first few frames because
|
||
//! `getRootWindowInsets()` only returns useful values after the decor
|
||
//! view has been laid out at least once.
|
||
//!
|
||
//! UI that wants to respect the top inset should tag itself with the
|
||
//! [`SafeAreaAnchoredTop`] marker carrying the layout's original top
|
||
//! offset; [`apply_safe_area_anchors`] re-applies `base_top + insets.top`
|
||
//! whenever the resource changes, so late inset arrival or orientation
|
||
//! changes flow through automatically.
|
||
|
||
use bevy::prelude::*;
|
||
use bevy::window::{AppLifecycle, WindowResized};
|
||
|
||
use crate::ui_modal::ModalScrim;
|
||
|
||
/// Pixel sizes of the system-reserved regions on each edge of the
|
||
/// surface. Zero on desktop.
|
||
#[derive(Resource, Debug, Clone, Copy, Default, PartialEq)]
|
||
pub struct SafeAreaInsets {
|
||
pub top: f32,
|
||
pub bottom: f32,
|
||
pub left: f32,
|
||
pub right: f32,
|
||
}
|
||
|
||
impl SafeAreaInsets {
|
||
/// `true` when any edge has a non-zero reservation. Used by the
|
||
/// Android polling system to know it can stop querying.
|
||
pub fn is_populated(&self) -> bool {
|
||
self.top > 0.0 || self.bottom > 0.0 || self.left > 0.0 || self.right > 0.0
|
||
}
|
||
}
|
||
|
||
/// Marker for `Node` entities whose `top` offset should be re-applied
|
||
/// as `base_top + SafeAreaInsets::top`.
|
||
///
|
||
/// `base_top` is the offset the layout would have used on a surface
|
||
/// with no system reservation (i.e. on desktop). The fix-up system
|
||
/// adds the current top inset on top of it whenever the resource
|
||
/// changes.
|
||
#[derive(Component, Debug, Clone, Copy)]
|
||
pub struct SafeAreaAnchoredTop {
|
||
pub base_top: f32,
|
||
}
|
||
|
||
/// Marker for `Node` entities whose `bottom` offset should be re-applied
|
||
/// as `base_bottom + SafeAreaInsets::bottom / scale`.
|
||
///
|
||
/// Use this for elements anchored to the bottom edge (e.g. a bottom action
|
||
/// bar) so they clear the Android gesture-navigation zone automatically.
|
||
#[derive(Component, Debug, Clone, Copy)]
|
||
pub struct SafeAreaAnchoredBottom {
|
||
pub base_bottom: f32,
|
||
}
|
||
|
||
pub struct SafeAreaInsetsPlugin;
|
||
|
||
impl Plugin for SafeAreaInsetsPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
// Both message types may already be registered by GamePlugin / TablePlugin;
|
||
// add_message is idempotent.
|
||
app.add_message::<AppLifecycle>()
|
||
.add_message::<WindowResized>()
|
||
.init_resource::<SafeAreaInsets>()
|
||
.add_systems(
|
||
Update,
|
||
(
|
||
apply_safe_area_anchors,
|
||
apply_safe_area_bottom_anchors,
|
||
apply_safe_area_to_modal_scrims,
|
||
on_app_resumed,
|
||
),
|
||
);
|
||
|
||
#[cfg(target_os = "android")]
|
||
app.add_message::<crate::events::StateChangedEvent>()
|
||
.init_resource::<android::SafeAreaPollTries>()
|
||
.add_systems(Update, android::refresh_insets)
|
||
.add_systems(Update, android::rearm_on_resumed)
|
||
.add_systems(Update, android::refresh_surface_size);
|
||
}
|
||
}
|
||
|
||
/// Re-applies `base_top + insets.top` to every entity carrying the
|
||
/// [`SafeAreaAnchoredTop`] marker whenever [`SafeAreaInsets`] changes.
|
||
///
|
||
/// Bevy resource change detection (`Res::is_changed`) is `true` on the
|
||
/// frame the resource is inserted and every frame a `ResMut` borrow
|
||
/// occurs. Combined with the Android polling loop short-circuiting
|
||
/// once insets are populated, this runs at most a handful of times in
|
||
/// a session.
|
||
fn apply_safe_area_anchors(
|
||
insets: Res<SafeAreaInsets>,
|
||
windows: Query<&Window>,
|
||
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
|
||
) {
|
||
if !insets.is_changed() {
|
||
return;
|
||
}
|
||
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
|
||
// expects logical pixels (≈ dp). Divide by the window scale factor so
|
||
// the HUD band shifts by the correct number of dp on high-DPI devices.
|
||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||
let window_height = windows.iter().next().map_or(800.0, |w| w.height());
|
||
let max_inset = window_height * 0.25;
|
||
let raw_top = insets.top / scale;
|
||
if raw_top > max_inset {
|
||
warn!(
|
||
"safe_area: top inset {raw_top:.0}px exceeds 25% of window height ({max_inset:.0}px); clamping"
|
||
);
|
||
}
|
||
let top_logical = raw_top.min(max_inset);
|
||
for (anchor, mut node) in &mut q {
|
||
node.top = Val::Px(anchor.base_top + top_logical);
|
||
}
|
||
}
|
||
|
||
/// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying
|
||
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
|
||
fn apply_safe_area_bottom_anchors(
|
||
insets: Res<SafeAreaInsets>,
|
||
windows: Query<&Window>,
|
||
mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
|
||
) {
|
||
if !insets.is_changed() {
|
||
return;
|
||
}
|
||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||
let window_height = windows.iter().next().map_or(800.0, |w| w.height());
|
||
let max_inset = window_height * 0.25;
|
||
let raw_bottom = insets.bottom / scale;
|
||
if raw_bottom > max_inset {
|
||
warn!(
|
||
"safe_area: bottom inset {raw_bottom:.0}px exceeds 25% of window height ({max_inset:.0}px); clamping"
|
||
);
|
||
}
|
||
let bottom_logical = raw_bottom.min(max_inset);
|
||
for (anchor, mut node) in &mut q {
|
||
node.bottom = Val::Px(anchor.base_bottom + bottom_logical);
|
||
}
|
||
}
|
||
|
||
/// Pads both edges of every [`ModalScrim`] by the logical system-bar insets so
|
||
/// modal cards are centred within the usable area (between the status bar at
|
||
/// the top and the gesture-navigation bar at the bottom).
|
||
///
|
||
/// `padding.top` = status-bar inset; `padding.bottom` = gesture-bar inset.
|
||
/// With `align_items: Center` / `justify_content: Center` on the scrim the
|
||
/// `ModalCard` lands at the visual midpoint of the visible content area.
|
||
///
|
||
/// Fires when [`SafeAreaInsets`] changes (covers the common case of insets
|
||
/// arriving a few frames after app start) AND when a new `ModalScrim` is
|
||
/// spawned (covers modals opened after insets have already settled).
|
||
fn apply_safe_area_to_modal_scrims(
|
||
insets: Res<SafeAreaInsets>,
|
||
windows: Query<&Window>,
|
||
mut scrims: Query<&mut Node, With<ModalScrim>>,
|
||
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
|
||
) {
|
||
let has_new = !new_scrims.is_empty();
|
||
if !insets.is_changed() && !has_new {
|
||
return;
|
||
}
|
||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||
let window_height = windows.iter().next().map_or(800.0, |w| w.height());
|
||
// Clamp each inset to 25% of screen height so an unexpectedly large OS
|
||
// value can't push the modal card off the visible area entirely.
|
||
let top_logical = (insets.top / scale).min(window_height * 0.25);
|
||
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25);
|
||
for mut node in &mut scrims {
|
||
// Set both edges so the scrim's content box equals the usable area
|
||
// between the status bar and the gesture/navigation bar. With
|
||
// `align_items: Center` / `justify_content: Center` on the scrim,
|
||
// the modal card is centred within that usable region rather than
|
||
// the full viewport, correcting the slight upward shift seen when
|
||
// only the bottom inset was applied.
|
||
node.padding.top = Val::Px(top_logical);
|
||
node.padding.bottom = Val::Px(bottom_logical);
|
||
}
|
||
}
|
||
|
||
/// Emits a synthetic `WindowResized` on `AppLifecycle::WillResume` so that
|
||
/// `on_window_resized` (in `table_plugin`) recomputes the board layout with
|
||
/// whatever `SafeAreaInsets` are current at that moment.
|
||
///
|
||
/// On Android the `android::rearm_on_resumed` system runs in the same frame
|
||
/// and resets `SafeAreaPollTries` (the cached `SafeAreaInsets` keep their
|
||
/// last-known values), causing `refresh_insets` to re-poll JNI over the next
|
||
/// few frames. If the insets changed while backgrounded, `on_safe_area_changed`
|
||
/// in `table_plugin` emits a second synthetic `WindowResized` and the layout
|
||
/// converges to the right position; if they didn't, nothing is rewritten and
|
||
/// the layout stays put.
|
||
///
|
||
/// On non-Android targets this handler still fires — it ensures that a resume
|
||
/// event always refreshes the layout (e.g., after a minimise/restore on
|
||
/// desktop) even though insets are always zero.
|
||
fn on_app_resumed(
|
||
mut lifecycle: MessageReader<AppLifecycle>,
|
||
windows: Query<(Entity, &Window)>,
|
||
mut resize_events: MessageWriter<WindowResized>,
|
||
) {
|
||
for event in lifecycle.read() {
|
||
if !matches!(event, AppLifecycle::WillResume) {
|
||
continue;
|
||
}
|
||
let Some((entity, window)) = windows.iter().next() else {
|
||
return;
|
||
};
|
||
resize_events.write(WindowResized {
|
||
window: entity,
|
||
width: window.resolution.width(),
|
||
height: window.resolution.height(),
|
||
});
|
||
}
|
||
}
|
||
|
||
#[cfg(target_os = "android")]
|
||
mod android {
|
||
use super::{AppLifecycle, SafeAreaInsets};
|
||
use bevy::prelude::*;
|
||
use bevy::window::WindowResized;
|
||
|
||
/// Tracks how many frames `refresh_insets` has polled. Stored as a
|
||
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
|
||
/// when `AppLifecycle::WillResume` fires, causing the poller to re-query JNI
|
||
/// after a background/foreground cycle.
|
||
#[derive(Resource, Default)]
|
||
pub(super) struct SafeAreaPollTries(pub u32);
|
||
|
||
/// Polls Android for safe-area insets until we get a non-zero
|
||
/// reading, then settles until [`rearm_on_resumed`] re-arms it on the
|
||
/// next foreground resume — insets can change while backgrounded
|
||
/// (rotation, fold/unfold, gesture ↔ 3-button nav). The poll counter
|
||
/// (not `insets.is_populated()`) gates the loop, so a re-armed cycle
|
||
/// re-queries JNI even though cached values are already populated.
|
||
/// `getRootWindowInsets()` returns `null` (or all-zero `Insets`)
|
||
/// until the decor view has been laid out, which is typically frame
|
||
/// 1–3 of a fresh launch.
|
||
pub(super) fn refresh_insets(
|
||
mut insets: ResMut<SafeAreaInsets>,
|
||
mut poll: ResMut<SafeAreaPollTries>,
|
||
) {
|
||
// Cap retries so we don't burn CPU forever on edge-to-edge
|
||
// devices that genuinely report zero insets.
|
||
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
|
||
|
||
if poll.0 >= MAX_TRIES {
|
||
return;
|
||
}
|
||
poll.0 += 1;
|
||
|
||
match query_insets() {
|
||
Ok(v) if v.is_populated() => {
|
||
if *insets != v {
|
||
info!(
|
||
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
|
||
v.top, v.bottom, v.left, v.right, poll.0
|
||
);
|
||
*insets = v;
|
||
}
|
||
// Settled for this poll cycle; `rearm_on_resumed` re-arms on
|
||
// the next resume. Writing `insets` only on an actual change
|
||
// keeps resource change detection (and the relayout it
|
||
// triggers) quiet on resumes where nothing moved.
|
||
poll.0 = MAX_TRIES;
|
||
}
|
||
Ok(_) => {
|
||
// Layout not ready yet; try again next frame.
|
||
}
|
||
Err(e) => {
|
||
// Don't spam — log once and let polling continue silently.
|
||
if poll.0 == 1 {
|
||
warn!("safe_area: JNI query failed (will retry): {e}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Resets the inset poller on `AppLifecycle::WillResume` so that
|
||
/// `refresh_insets` re-queries JNI in the frames immediately after the app
|
||
/// returns to the foreground.
|
||
///
|
||
/// The cached `SafeAreaInsets` are intentionally **not** zeroed here.
|
||
/// Zeroing them would cause two layout recomputes on every resume:
|
||
/// once with zero insets (wrong position) and again when JNI resolves the
|
||
/// real values — visible as a flash. By preserving the last-known values
|
||
/// the layout remains stable; if JNI returns a different value (e.g. after
|
||
/// a rotation) the single update that fires when `SafeAreaInsets` actually
|
||
/// changes is enough.
|
||
pub(super) fn rearm_on_resumed(
|
||
mut lifecycle: MessageReader<AppLifecycle>,
|
||
mut poll: ResMut<SafeAreaPollTries>,
|
||
) {
|
||
for event in lifecycle.read() {
|
||
if matches!(event, AppLifecycle::WillResume) {
|
||
// Evidence line for #130: winit's Android backend has open
|
||
// TODOs around forwarding resume notifications, so whether
|
||
// this ever fires on a given device is an open question.
|
||
info!("safe_area: AppLifecycle::WillResume received; re-arming inset poll");
|
||
poll.0 = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Polls the decor view's size via JNI and forces a relayout when it
|
||
/// disagrees with Bevy's cached `Window` resolution (#130).
|
||
///
|
||
/// winit's Android backend does not forward content-rect changes that
|
||
/// happen while the app is backgrounded (fold/unfold on foldables), so
|
||
/// after a fold cycle Bevy can keep rendering and laying out for the
|
||
/// previous screen's dimensions. Unlike `refresh_insets` this poller
|
||
/// never settles: it cannot rely on `AppLifecycle::WillResume` to re-arm
|
||
/// it, because that event is itself delivered through the same unreliable
|
||
/// lifecycle plumbing. A JNI round-trip every `POLL_INTERVAL_FRAMES`
|
||
/// frames is cheap.
|
||
///
|
||
/// On a mismatch it:
|
||
/// 1. writes the real size into `window.resolution` so the renderer
|
||
/// reconfigures the surface and systems reading `window.width()` see
|
||
/// the truth,
|
||
/// 2. emits a synthetic `WindowResized` (logical pixels) so
|
||
/// `on_window_resized` in `table_plugin` recomputes the board layout,
|
||
/// 3. re-arms the inset poller, because a screen change almost always
|
||
/// moves the system bars too — covering the "re-poll never fires"
|
||
/// hole left open in #116,
|
||
/// 4. emits `StateChangedEvent` so `card_plugin`'s sync pipeline
|
||
/// re-renders every card sprite from scratch — belt-and-braces for
|
||
/// the transient tableau clip of #130, where geometry converged but
|
||
/// stale card visuals survived the relayout.
|
||
pub(super) fn refresh_surface_size(
|
||
mut frame: Local<u32>,
|
||
mut windows: Query<(Entity, &mut Window)>,
|
||
mut resize_events: MessageWriter<WindowResized>,
|
||
mut state_events: MessageWriter<crate::events::StateChangedEvent>,
|
||
mut poll: ResMut<SafeAreaPollTries>,
|
||
) {
|
||
const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps
|
||
|
||
*frame += 1;
|
||
if !frame.is_multiple_of(POLL_INTERVAL_FRAMES) {
|
||
return;
|
||
}
|
||
let Some((entity, mut window)) = windows.iter_mut().next() else {
|
||
return;
|
||
};
|
||
|
||
let (decor_w, decor_h) = match query_decor_size() {
|
||
Ok(size) => size,
|
||
Err(e) => {
|
||
// One-time note; the bridge simply isn't up yet during the
|
||
// first frames of a launch.
|
||
if *frame == POLL_INTERVAL_FRAMES {
|
||
warn!("safe_area: decor size query failed (will retry): {e}");
|
||
}
|
||
return;
|
||
}
|
||
};
|
||
if decor_w == 0 || decor_h == 0 {
|
||
return; // decor view not laid out yet
|
||
}
|
||
|
||
// Reads go through `Deref` and do not trip change detection; only
|
||
// mutate `window` once a mismatch is confirmed.
|
||
let cached_w = window.resolution.physical_width();
|
||
let cached_h = window.resolution.physical_height();
|
||
if decor_w == cached_w && decor_h == cached_h {
|
||
return;
|
||
}
|
||
|
||
info!(
|
||
"safe_area: decor view is {decor_w}x{decor_h} but cached resolution is \
|
||
{cached_w}x{cached_h}; forcing relayout (fold/unfold missed by winit?)"
|
||
);
|
||
window.resolution.set_physical_resolution(decor_w, decor_h);
|
||
let scale = window.scale_factor();
|
||
resize_events.write(WindowResized {
|
||
window: entity,
|
||
width: decor_w as f32 / scale,
|
||
height: decor_h as f32 / scale,
|
||
});
|
||
state_events.write(crate::events::StateChangedEvent);
|
||
poll.0 = 0;
|
||
}
|
||
|
||
/// Physical pixel size of the activity's decor view — the ground truth
|
||
/// for the surface we are actually being displayed on, independent of
|
||
/// whatever winit last told Bevy.
|
||
fn query_decor_size() -> Result<(u32, u32), String> {
|
||
use solitaire_data::android_jni;
|
||
|
||
android_jni::with_activity_env(|env, activity| {
|
||
let window = env
|
||
.call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
|
||
.l()?;
|
||
let decor = env
|
||
.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
|
||
.l()?;
|
||
let w = env.call_method(&decor, "getWidth", "()I", &[])?.i()?;
|
||
let h = env.call_method(&decor, "getHeight", "()I", &[])?.i()?;
|
||
Ok((w.max(0) as u32, h.max(0) as u32))
|
||
})
|
||
}
|
||
|
||
fn query_insets() -> Result<SafeAreaInsets, String> {
|
||
use solitaire_data::android_jni;
|
||
|
||
android_jni::with_activity_env(|env, activity| {
|
||
// Window window = activity.getWindow();
|
||
let window = env
|
||
.call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
|
||
.l()?;
|
||
|
||
// View decor = window.getDecorView();
|
||
let decor = env
|
||
.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
|
||
.l()?;
|
||
|
||
// WindowInsets insets = decor.getRootWindowInsets();
|
||
let raw_insets = env
|
||
.call_method(
|
||
&decor,
|
||
"getRootWindowInsets",
|
||
"()Landroid/view/WindowInsets;",
|
||
&[],
|
||
)?
|
||
.l()?;
|
||
if raw_insets.is_null() {
|
||
return Ok(SafeAreaInsets::default());
|
||
}
|
||
|
||
// int types = WindowInsets.Type.systemBars();
|
||
// (Static method on the WindowInsets$Type inner class.
|
||
// Available since API 30 / Android 11.)
|
||
let type_class = env.find_class("android/view/WindowInsets$Type")?;
|
||
let bars_type = env
|
||
.call_static_method(&type_class, "systemBars", "()I", &[])?
|
||
.i()?;
|
||
|
||
// Insets bars = insets.getInsets(types);
|
||
let bars = env
|
||
.call_method(
|
||
&raw_insets,
|
||
"getInsets",
|
||
"(I)Landroid/graphics/Insets;",
|
||
&[bars_type.into()],
|
||
)?
|
||
.l()?;
|
||
|
||
// `Insets` exposes `top`, `bottom`, `left`, `right` as public
|
||
// `int` fields (pixel values, not dp).
|
||
let top = env.get_field(&bars, "top", "I")?.i()? as f32;
|
||
let bottom = env.get_field(&bars, "bottom", "I")?.i()? as f32;
|
||
let left = env.get_field(&bars, "left", "I")?.i()? as f32;
|
||
let right = env.get_field(&bars, "right", "I")?.i()? as f32;
|
||
|
||
Ok(SafeAreaInsets {
|
||
top,
|
||
bottom,
|
||
left,
|
||
right,
|
||
})
|
||
})
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn default_is_zero_and_not_populated() {
|
||
let i = SafeAreaInsets::default();
|
||
assert_eq!(i.top, 0.0);
|
||
assert_eq!(i.bottom, 0.0);
|
||
assert!(!i.is_populated());
|
||
}
|
||
|
||
#[test]
|
||
fn is_populated_returns_true_for_any_nonzero_edge() {
|
||
assert!(
|
||
SafeAreaInsets {
|
||
top: 24.0,
|
||
..Default::default()
|
||
}
|
||
.is_populated()
|
||
);
|
||
assert!(
|
||
SafeAreaInsets {
|
||
bottom: 16.0,
|
||
..Default::default()
|
||
}
|
||
.is_populated()
|
||
);
|
||
assert!(
|
||
SafeAreaInsets {
|
||
left: 8.0,
|
||
..Default::default()
|
||
}
|
||
.is_populated()
|
||
);
|
||
assert!(
|
||
SafeAreaInsets {
|
||
right: 8.0,
|
||
..Default::default()
|
||
}
|
||
.is_populated()
|
||
);
|
||
}
|
||
}
|