feat(android): safe-area insets for HUD positioning
Adds SafeAreaInsets resource + SafeAreaInsetsPlugin that report the
OS-reserved regions (status bar, gesture/nav bar, display cutout)
around the playable surface. Desktop reports all zeros; Android
queries WindowInsets.getInsets(systemBars()) via JNI on the decor
view, polling for up to 120 frames since getRootWindowInsets()
returns null until the view is laid out.
UI that should respect the top inset carries a SafeAreaAnchoredTop
{ base_top } marker. A change-detection system re-applies
`base_top + insets.top` whenever the resource changes, so late
inset arrival (frame 1-3 on Android) and future orientation
changes flow through without re-spawning entities.
Wires the three top-anchored HUD spawn sites — hud_band, hud
column, action button row — to the new pattern. Spawn systems
take Option<Res<SafeAreaInsets>> so HudPlugin still works
standalone in unit tests (mirrors the existing FontResource
pattern).
Closes P0 #1 of docs/android/PLAYABILITY_TODO.md. Resolves the
status-bar/HUD collision visible in the v0.22.3 hardware
screenshot.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,8 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::layout::HUD_BAND_HEIGHT;
|
||||
use crate::safe_area::{SafeAreaAnchoredTop, SafeAreaInsets};
|
||||
use crate::ui_theme::SPACE_2;
|
||||
use crate::ui_theme::{
|
||||
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
|
||||
@@ -376,11 +378,13 @@ impl Plugin for HudPlugin {
|
||||
/// bottom edge lines up exactly with the top edge of the highest
|
||||
/// playable card. The fill is `BG_HUD_BAND` — midnight purple at 0.70
|
||||
/// alpha, so the green felt reads through subtly.
|
||||
fn spawn_hud_band(mut commands: Commands) {
|
||||
fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
|
||||
const BASE_TOP: f32 = 0.0;
|
||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
||||
commands.spawn((
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(0.0),
|
||||
top: Val::Px(BASE_TOP + top_inset),
|
||||
left: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(HUD_BAND_HEIGHT),
|
||||
@@ -391,6 +395,7 @@ fn spawn_hud_band(mut commands: Commands) {
|
||||
// paint on top, but above the card sprites (which are 2D-world
|
||||
// entities and rendered behind UI regardless).
|
||||
ZIndex(Z_HUD - 1),
|
||||
SafeAreaAnchoredTop { base_top: BASE_TOP },
|
||||
));
|
||||
}
|
||||
|
||||
@@ -413,7 +418,12 @@ fn spawn_hud_band(mut commands: Commands) {
|
||||
/// player's #1 complaint. This restructure groups by purpose, lets
|
||||
/// transient items disappear cleanly, and uses the typography scale to
|
||||
/// make Score the visual protagonist.
|
||||
fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
fn spawn_hud(
|
||||
font_res: Option<Res<FontResource>>,
|
||||
insets: Option<Res<SafeAreaInsets>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
||||
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_score = TextFont {
|
||||
font: font_handle.clone(),
|
||||
@@ -443,12 +453,13 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: VAL_SPACE_3,
|
||||
top: VAL_SPACE_2,
|
||||
top: Val::Px(SPACE_2 + top_inset),
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
..default()
|
||||
},
|
||||
ZIndex(Z_HUD),
|
||||
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||
))
|
||||
.with_children(|hud| {
|
||||
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
|
||||
@@ -568,7 +579,12 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
|
||||
/// because it's the most consequential action; the destructive button sits
|
||||
/// on its own visual edge.
|
||||
fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
fn spawn_action_buttons(
|
||||
font_res: Option<Res<FontResource>>,
|
||||
insets: Option<Res<SafeAreaInsets>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
||||
let font = TextFont {
|
||||
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||
// TYPE_BODY (14.0) — was a hardcoded `16.0` until the
|
||||
@@ -585,13 +601,14 @@ fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Comma
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
right: VAL_SPACE_3,
|
||||
top: VAL_SPACE_2,
|
||||
top: Val::Px(SPACE_2 + top_inset),
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: VAL_SPACE_2,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
ZIndex(Z_HUD),
|
||||
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||
))
|
||||
.with_children(|row| {
|
||||
// Menu and Modes don't have a single hotkey accelerator
|
||||
|
||||
@@ -35,6 +35,7 @@ pub mod replay_playback;
|
||||
pub mod settings_plugin;
|
||||
pub mod progress_plugin;
|
||||
pub mod resources;
|
||||
pub mod safe_area;
|
||||
pub mod selection_plugin;
|
||||
pub mod splash_plugin;
|
||||
pub mod stats_plugin;
|
||||
@@ -138,6 +139,7 @@ pub use settings_plugin::{
|
||||
};
|
||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
||||
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||
pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin};
|
||||
pub use selection_plugin::{
|
||||
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
//! 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::*;
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
pub struct SafeAreaInsetsPlugin;
|
||||
|
||||
impl Plugin for SafeAreaInsetsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<SafeAreaInsets>()
|
||||
.add_systems(Update, apply_safe_area_anchors);
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
app.add_systems(Update, android::refresh_insets);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
|
||||
) {
|
||||
if !insets.is_changed() {
|
||||
return;
|
||||
}
|
||||
for (anchor, mut node) in &mut q {
|
||||
node.top = Val::Px(anchor.base_top + insets.top);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod android {
|
||||
use super::SafeAreaInsets;
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// 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 tries: Local<u32>,
|
||||
) {
|
||||
// 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 *tries >= MAX_TRIES || insets.is_populated() {
|
||||
return;
|
||||
}
|
||||
*tries += 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, *tries
|
||||
);
|
||||
*insets = v;
|
||||
}
|
||||
Ok(_) => {
|
||||
// Layout not ready yet; try again next frame.
|
||||
}
|
||||
Err(e) => {
|
||||
// Don't spam — log once and let polling continue silently.
|
||||
if *tries == 1 {
|
||||
warn!("safe_area: JNI query failed (will retry): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user