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()); + } +}