From b9aa2620b86bf337fce25921cb6204cd85b78562 Mon Sep 17 00:00:00 2001 From: funman300 Date: Sun, 10 May 2026 20:37:06 -0700 Subject: [PATCH] feat(android): safe-area insets for HUD positioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> 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 --- solitaire_app/src/lib.rs | 4 +- solitaire_engine/src/hud_plugin.rs | 29 +++- solitaire_engine/src/lib.rs | 2 + solitaire_engine/src/safe_area.rs | 242 +++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 solitaire_engine/src/safe_area.rs diff --git a/solitaire_app/src/lib.rs b/solitaire_app/src/lib.rs index f5db63f..cc778c1 100644 --- a/solitaire_app/src/lib.rs +++ b/solitaire_app/src/lib.rs @@ -30,7 +30,8 @@ use solitaire_engine::{ CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, - RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin, + RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, + SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, @@ -173,6 +174,7 @@ pub fn run() { .add_plugins(PlayBySeedPlugin) .add_plugins(DifficultyPlugin) .add_plugins(TimeAttackPlugin) + .add_plugins(SafeAreaInsetsPlugin) .add_plugins(HudPlugin) .add_plugins(HelpPlugin) .add_plugins(HomePlugin::default()) diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin.rs index dfc0782..dfcdf43 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin.rs @@ -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>, 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>, mut commands: Commands) { +fn spawn_hud( + font_res: Option>, + insets: Option>, + 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>, 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>, 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>, mut commands: Commands) { +fn spawn_action_buttons( + font_res: Option>, + insets: Option>, + 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>, 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 diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 30b82ea..c762bf5 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -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, }; diff --git a/solitaire_engine/src/safe_area.rs b/solitaire_engine/src/safe_area.rs new file mode 100644 index 0000000..8ca40cc --- /dev/null +++ b/solitaire_engine/src/safe_area.rs @@ -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::() + .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, + 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, + mut tries: Local, + ) { + // 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 { + 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 { + // 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()); + } +}