Files
Ferrous-Solitaire/solitaire_engine/src/safe_area.rs
T
funman300 04e99a8d24
Android Release / build-apk (push) Successful in 4m41s
fix(engine): correct Android waste fan overlap and resume layout desync
Bug 1 (card_plugin): waste Draw-Three fan step was a fixed 0.28×card_width,
chosen for the desktop gap ratio (H_GAP_DIVISOR=4). On Android
(H_GAP_DIVISOR=32) the column spacing is only 1.031×card_width, so the same
fraction pushed the top fanned card's centre past the waste column's right
edge. Fix: derive fan_step from column spacing × 0.224 — preserves 0.28×cw
on desktop while reducing to ≈0.231×cw on Android, keeping fanned cards
within their column footprint. Adds regression test on 900×2000 portrait window.

Bug 2 (safe_area): refresh_insets stored its retry counter as Local<u32>,
making it impossible to re-arm after a background/foreground cycle. On resume
the counter was already saturated so JNI was never re-queried; layouts
computed with stale (zero) insets pushed the top card row up under the HUD.
Fix: convert tries to SafeAreaPollTries Resource; add android::rearm_on_resumed
which resets both counter and SafeAreaInsets on AppLifecycle::WillResume so
the poller re-fires; add on_app_resumed (all platforms) which emits a synthetic
WindowResized on WillResume to immediately trigger layout recomputation. Adds
pure-function regression test in layout.rs pinning the suspend→resume invariant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 19:16:24 -07:00

378 lines
14 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.init_resource::<android::SafeAreaPollTries>()
.add_systems(Update, android::refresh_insets)
.add_systems(Update, android::rearm_on_resumed);
}
}
/// 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 top_logical = insets.top / scale;
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 bottom_logical = insets.bottom / scale;
for (anchor, mut node) in &mut q {
node.bottom = Val::Px(anchor.base_bottom + bottom_logical);
}
}
/// Pads the bottom of every [`ModalScrim`] by the logical bottom inset so
/// modal cards don't extend into the Android gesture-navigation zone.
///
/// 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 bottom_logical = insets.bottom / scale;
for mut node in &mut scrims {
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 both `SafeAreaPollTries` and `SafeAreaInsets` to zero, causing
/// `refresh_insets` to re-poll JNI over the next few frames. When it resolves
/// the correct values, `on_safe_area_changed` in `table_plugin` emits a second
/// synthetic `WindowResized` and the layout converges to the right position.
///
/// 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::*;
/// 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 stops. `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 || insets.is_populated() {
return;
}
poll.0 += 1;
match query_insets() {
Ok(v) if v.is_populated() => {
info!(
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
v.top, v.bottom, v.left, v.right, poll.0
);
*insets = v;
}
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 and clears cached insets on
/// `AppLifecycle::WillResume` so that `refresh_insets` re-queries JNI in the
/// frames immediately after the app returns to the foreground.
///
/// Clearing `SafeAreaInsets` to the default (all-zero) fires
/// `on_safe_area_changed` in `table_plugin`, which emits a synthetic
/// `WindowResized`. `on_window_resized` then recomputes the layout;
/// once `refresh_insets` resolves the real values a second synthetic
/// `WindowResized` fires and the layout converges to the correct position.
pub(super) fn rearm_on_resumed(
mut lifecycle: MessageReader<AppLifecycle>,
mut poll: ResMut<SafeAreaPollTries>,
mut insets: ResMut<SafeAreaInsets>,
) {
for event in lifecycle.read() {
if matches!(event, AppLifecycle::WillResume) {
poll.0 = 0;
*insets = SafeAreaInsets::default();
}
}
}
fn query_insets() -> Result<SafeAreaInsets, String> {
use bevy::android::ANDROID_APP;
use jni::{objects::JObject, JavaVM};
let app = ANDROID_APP
.get()
.ok_or_else(|| "ANDROID_APP not initialized".to_string())?;
// SAFETY: `vm_as_ptr()` returns the JavaVM* set up by the Android
// runtime; valid for the lifetime of the process.
let vm = unsafe { JavaVM::from_raw(app.vm_as_ptr().cast()) }
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
let mut env = vm
.attach_current_thread_permanently()
.map_err(|e| format!("attach_current_thread: {e}"))?;
// SAFETY: `activity_as_ptr()` returns the NativeActivity jobject
// pointer — valid for the lifetime of the process.
let activity = unsafe { JObject::from_raw(app.activity_as_ptr() as _) };
(|| -> jni::errors::Result<SafeAreaInsets> {
// 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,
})
})()
.map_err(|e| format!("safe-area JNI: {e}"))
}
}
#[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());
}
}