6193d31497
apply_safe_area_to_modal_scrims now sets both padding.top (status-bar height) and padding.bottom (gesture-bar height) on every ModalScrim. With align_items/justify_content: Center on the scrim, the modal card lands at the visual midpoint of the visible area between the two system bars, fixing the slight upward shift that occurred when only the bottom inset was applied. Also: mark all rewrite-plan phases (0–3) complete; drop obsolete stash whose 20 files are already incorporated into master; update CLAUDE.md §14.3 to document both edges. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
418 lines
16 KiB
Rust
418 lines
16 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.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 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 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 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 || 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 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) {
|
||
poll.0 = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn query_insets() -> Result<SafeAreaInsets, String> {
|
||
use bevy::android::ANDROID_APP;
|
||
use jni::{JavaVM, objects::JObject};
|
||
|
||
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()
|
||
);
|
||
}
|
||
}
|