fix(android): UX-1/UX-5b/UX-7/BUG-3 — safe-area modals, glyph, help wrap, modal guard

- UX-1 (safe_area.rs): apply_safe_area_to_modal_scrims pads ModalScrim
  bottom by insets.bottom / scale_factor so Done buttons clear the
  gesture bar; fires on inset change + Added<ModalScrim>
- UX-5b (home_plugin.rs): replace Geometric Shapes (U+25xx, missing
  from FiraMono) with card suits U+2660/2665/2666
- UX-7 (help_plugin.rs): shorten Android ≡ button description to
  "Open menu (Stats, Settings, Profile...)" — fits one line at 360 dp
- BUG-3 (hud_plugin.rs): guard spawn_menu_popover with
  scrims.is_empty() so tapping ≡ while a modal is open is a no-op

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-12 22:20:07 -07:00
parent 04f3dab563
commit a381a42f21
5 changed files with 65 additions and 25 deletions
+21
View File
@@ -6,6 +6,27 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
### Fixed
- **BUG-3: Multi-modal stacking** (`hud_plugin.rs`). `handle_menu_button`
now checks `scrims.is_empty()` — a `Query<(), With<ModalScrim>>` guard —
before calling `spawn_menu_popover`. Tapping ≡ while any modal (Stats,
Settings, Profile, Help) is open is now a no-op. Previously Stats + Profile
could be open simultaneously.
- **UX-7: Help text single-line overflow** (`help_plugin.rs`). The HUD menu
button description "Menu: Stats, Settings, Profile, Achievements" wrapped to
two lines on Android. Shortened to "Open menu (Stats, Settings, Profile...)"
which fits on one line. Verified on device.
- **UX-5b: Home mode glyph corruption** (`home_plugin.rs`). Mode selector icons
were using Geometric Shapes block (U+25xx) absent from the bundled FiraMono
font — rendered as missing-glyph rectangles on Android. Replaced with card
suits (U+26602666) which FiraMono covers: ♦ Daily, ♥ Zen, ♠ Challenge.
- **UX-1: Modal Done button in gesture zone** (`safe_area.rs`). New
`apply_safe_area_to_modal_scrims` Bevy system pads every `ModalScrim` bottom
by `SafeAreaInsets.bottom / scale_factor`. Modal cards are now centred over
the safe area, not the full physical screen. The Settings / Help / Stats Done
buttons are reachable on gesture-nav Android devices. Verified on device.
--- ---
## [0.23.0] — 2026-05-12 ## [0.23.0] — 2026-05-12
+1 -1
View File
@@ -159,7 +159,7 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
ControlRow { keys: "||", description: "Pause / resume" }, ControlRow { keys: "||", description: "Pause / resume" },
ControlRow { keys: "?", description: "This help screen" }, ControlRow { keys: "?", description: "This help screen" },
ControlRow { keys: "", description: "Show a hint" }, ControlRow { keys: "", description: "Show a hint" },
ControlRow { keys: "", description: "Menu: Stats, Settings, Profile, Achievements" }, ControlRow { keys: "", description: "Open menu (Stats, Settings, Profile...)" },
], ],
}, },
]; ];
+14 -22
View File
@@ -150,32 +150,24 @@ impl HomeMode {
/// readability rather than visual fidelity. Swap to `Image` nodes /// readability rather than visual fidelity. Swap to `Image` nodes
/// when art lands; the rest of the tile layout doesn't change. /// when art lands; the rest of the tile layout doesn't change.
/// ///
/// Picks are constrained to **card suits** (U+2660-2666) and basic /// Picks are constrained to **card suits** (U+2660-2666), the
/// **Geometric Shapes** (U+25xx) — the two ranges the bundled /// **Arrows** block (U+2190-21FF), and ASCII — ranges confirmed
/// FiraMono-Medium face actually covers. Earlier choices in /// present in the bundled FiraMono-Medium face. The Geometric
/// Dingbats (★ ❀ ✦) and Misc Symbols (⌚) rendered as /// Shapes block (U+25xx) is NOT covered by FiraMono; glyphs in
/// missing-glyph rectangles because FiraMono's coverage there is /// that range render as missing-glyph rectangles on Android.
/// minimal.
fn glyph(self) -> &'static str { fn glyph(self) -> &'static str {
match self { match self {
// Black club — card suit, the obvious solitaire mark. // Black club — card suit; the obvious solitaire mark.
HomeMode::Classic => "\u{2663}", HomeMode::Classic => "\u{2663}",
// Black diamond — Geometric Shapes; reads as the day's gem. // Black diamond suit — "gem of the day" reading.
HomeMode::Daily => "\u{25C6}", HomeMode::Daily => "\u{2666}",
// White circle — Geometric Shapes; reads as the Zen enso. // Black heart suit — calm/warm; conveys the Zen mood.
HomeMode::Zen => "\u{25CB}", HomeMode::Zen => "\u{2665}",
// Black up-pointing triangle — Geometric Shapes; reads as // Black spade suit — sharp/high-stakes; signals difficulty.
// a mountain / a step up in difficulty. HomeMode::Challenge => "\u{2660}",
HomeMode::Challenge => "\u{25B2}", // Rightwards arrow — "go / fast-forward" for the timed mode.
// Rightwards arrow — Arrows block (U+2190-21FF), a core
// range every dev-oriented monospace font (FiraMono
// included) ships. Reads as "go / fast-forward" for the
// timed mode. Earlier ▶ (U+25B6) did not render; FiraMono
// ships ▲ (up triangle) but evidently not the sideways
// siblings.
HomeMode::TimeAttack => "\u{2192}", HomeMode::TimeAttack => "\u{2192}",
// Number sign — ASCII, universally available. Reads as // Number sign — ASCII; "a specific seed ID".
// "a specific number / seed ID".
HomeMode::PlayBySeed => "#", HomeMode::PlayBySeed => "#",
} }
} }
+3 -1
View File
@@ -40,6 +40,7 @@ use crate::resources::GameStateResource;
use crate::selection_plugin::SelectionState; use crate::selection_plugin::SelectionState;
use crate::time_attack_plugin::TimeAttackResource; use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_focus::{FocusGroup, Focusable}; use crate::ui_focus::{FocusGroup, Focusable};
use crate::ui_modal::ModalScrim;
use crate::ui_tooltip::Tooltip; use crate::ui_tooltip::Tooltip;
/// Marker on the score text node. /// Marker on the score text node.
@@ -1074,6 +1075,7 @@ fn handle_menu_button(
interaction_query: Query<&Interaction, (With<MenuButton>, Changed<Interaction>)>, interaction_query: Query<&Interaction, (With<MenuButton>, Changed<Interaction>)>,
popovers: Query<Entity, With<MenuPopover>>, popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>, backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
scrims: Query<(), With<ModalScrim>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
mut commands: Commands, mut commands: Commands,
) { ) {
@@ -1088,7 +1090,7 @@ fn handle_menu_button(
for e in &backdrops { for e in &backdrops {
commands.entity(e).despawn(); commands.entity(e).despawn();
} }
} else { } else if scrims.is_empty() {
spawn_menu_popover(&mut commands, font_res.as_deref()); spawn_menu_popover(&mut commands, font_res.as_deref());
} }
} }
+26 -1
View File
@@ -19,6 +19,8 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::ui_modal::ModalScrim;
/// Pixel sizes of the system-reserved regions on each edge of the /// Pixel sizes of the system-reserved regions on each edge of the
/// surface. Zero on desktop. /// surface. Zero on desktop.
#[derive(Resource, Debug, Clone, Copy, Default, PartialEq)] #[derive(Resource, Debug, Clone, Copy, Default, PartialEq)]
@@ -54,7 +56,7 @@ pub struct SafeAreaInsetsPlugin;
impl Plugin for SafeAreaInsetsPlugin { impl Plugin for SafeAreaInsetsPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<SafeAreaInsets>() app.init_resource::<SafeAreaInsets>()
.add_systems(Update, apply_safe_area_anchors); .add_systems(Update, (apply_safe_area_anchors, apply_safe_area_to_modal_scrims));
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
app.add_systems(Update, android::refresh_insets); app.add_systems(Update, android::refresh_insets);
@@ -87,6 +89,29 @@ fn apply_safe_area_anchors(
} }
} }
/// 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);
}
}
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
mod android { mod android {
use super::SafeAreaInsets; use super::SafeAreaInsets;