Files
Ferrous-Solitaire/solitaire_engine/src/safe_area.rs
T
funman300 a39e04329e
Test / test (pull_request) Successful in 12m14s
fix(engine): force full board repaint when the decor-view poller fires (#130)
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>
2026-07-07 16:01:25 -07:00

519 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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
/// 13 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()
);
}
}