Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e107f5e218 | |||
| 463b7465ed | |||
| 92a5ebb15e | |||
| 89a21c0587 | |||
| 304cb050a7 | |||
| fcc7337c97 | |||
| 16ce2b88d2 | |||
| b9aa2620b8 | |||
| 47f02a60ae |
@@ -10,3 +10,8 @@ data/
|
|||||||
|
|
||||||
# IDE project files
|
# IDE project files
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# Android signing keystores — never commit
|
||||||
|
*.jks
|
||||||
|
*.jks.bak
|
||||||
|
*.keystore
|
||||||
|
|||||||
Generated
+1
@@ -6986,6 +6986,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
|
"bevy",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Android Playability TODO
|
||||||
|
|
||||||
|
**Started:** 2026-05-10 — first hardware screenshot of v0.22.3 APK
|
||||||
|
running on a real device showed the desktop HUD projected onto a
|
||||||
|
360 dp portrait viewport with no mobile adaptation. This list
|
||||||
|
tracks the work needed to make the APK genuinely playable, not
|
||||||
|
just "boots without crashing."
|
||||||
|
|
||||||
|
**Context:** v0.22.3 (signed release APK) builds and launches.
|
||||||
|
JNI bridges (clipboard, keystore) compile but are untested on
|
||||||
|
hardware. The work below is UI/UX port work — no architectural
|
||||||
|
rewrites required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reading from the v0.22.3 screenshot
|
||||||
|
|
||||||
|
| Region | Observation |
|
||||||
|
|--------|-------------|
|
||||||
|
| Top ~5 % | System bar (clock, signal, battery) overlapped by game HUD — no safe-area inset |
|
||||||
|
| HUD text row | `Score:0 Pause Esc Help A Modes [] New_Game N Moves:0 0:08` all overlapping — desktop layout crammed into 360 dp |
|
||||||
|
| Keyboard hints | `Esc`, `A`, `[]`, `N` shown next to buttons — meaningless on touch |
|
||||||
|
| Foundations row | Leftmost foundation (♥) clipped left; rightmost tableau column (♠ 4) clipped right |
|
||||||
|
| Card backs | Face-down cards render as solid red squares, not back-art texture |
|
||||||
|
| Vertical use | Cards occupy top ~30 % only; bottom 70 % empty black — no portrait-aware layout |
|
||||||
|
| Bottom edge | No accommodation for Android gesture / home-indicator area |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0 — Blocking playability
|
||||||
|
|
||||||
|
- [x] **Safe-area insets (top + bottom).** *Closed 2026-05-10 by
|
||||||
|
`b9aa262`.* `SafeAreaInsets` resource + `SafeAreaInsetsPlugin`
|
||||||
|
query `WindowInsets.getInsets(systemBars())` via JNI on Android;
|
||||||
|
HUD anchors carry `SafeAreaAnchoredTop { base_top }` and the
|
||||||
|
change-detection fix-up system re-applies `base_top + insets.top`
|
||||||
|
whenever the resource updates. Bottom inset is captured but not
|
||||||
|
yet consumed (waits for bottom-anchored UI).
|
||||||
|
- [x] **Mobile HUD layout.** *Closed 2026-05-10.* Both the left HUD
|
||||||
|
column and the right action button row are now capped at
|
||||||
|
`max_width: 50 %` and the button row + tier-row child Nodes carry
|
||||||
|
`flex_wrap: Wrap`. On a 360 dp viewport the 6-button row breaks
|
||||||
|
to multiple lines (right-justified) and the tier rows wrap
|
||||||
|
individually instead of overflowing into the action column. On
|
||||||
|
desktop (≥ 1280 px) the 50 % cap is wider than any natural row
|
||||||
|
width so the existing single-line layout is unchanged.
|
||||||
|
- [x] **Card-back asset not rendering.** *Closed 2026-05-10 by
|
||||||
|
`fcc7337`.* `AssetPlugin::file_path = "../assets"` was set
|
||||||
|
unconditionally to fix the desktop `cargo run -p solitaire_app`
|
||||||
|
CWD relativity, but on Android cargo-apk packages the same
|
||||||
|
directory into the APK at `assets/` and Bevy's
|
||||||
|
AndroidAssetReader is already rooted there — prepending `../`
|
||||||
|
walked the reader out of the APK assets root and every load
|
||||||
|
failed silently. The face-down branch then fell through to the
|
||||||
|
`card_back_colour(0)` solid-red brick fallback. Gated the
|
||||||
|
override behind `#[cfg(not(target_os = "android"))]`.
|
||||||
|
- [x] **Viewport overflow.** *Closed 2026-05-10.* `compute_layout`
|
||||||
|
was clamping the input window up to `MIN_WINDOW = 800 × 600`,
|
||||||
|
so a 360 dp phone got laid out as if it were 800-wide and the
|
||||||
|
outer piles fell outside the actual viewport. Lowered the floor
|
||||||
|
to 320 × 400 (below the smallest reasonable phone) so real
|
||||||
|
Android resolutions flow through without clamping, while keeping
|
||||||
|
a sentinel to guard against degenerate / startup-zero windows.
|
||||||
|
New regression test `phone_portrait_layout_fits_horizontally`
|
||||||
|
asserts all 13 piles fit a 360 × 800 viewport.
|
||||||
|
|
||||||
|
## P1 — Touch UX
|
||||||
|
|
||||||
|
- [x] **Suppress keyboard-hint labels on Android.** *Closed
|
||||||
|
2026-05-10.* `spawn_action_button` now nulls the `hotkey`
|
||||||
|
argument on Android via a `#[cfg(target_os = "android")]` rebind,
|
||||||
|
so the U / Esc / F1 / N chips next to the action row labels
|
||||||
|
disappear on touch builds. Other hint sites (onboarding panel,
|
||||||
|
pause-modal `Esc` hint, mode-card hotkey chips on the home
|
||||||
|
screen, replay overlay footer, modal toggle hints in
|
||||||
|
profile/stats/leaderboard/settings, help screen) survive — they
|
||||||
|
live behind navigation and a touch user reaches them less often.
|
||||||
|
Track as a P3 sweep when more screens are audited on hardware.
|
||||||
|
- [x] **Thumb-sized hit targets.** *Closed 2026-05-10.* Action
|
||||||
|
button Node carries `min_width: Val::Px(48.0), min_height:
|
||||||
|
Val::Px(48.0)` — meets Material's 48 dp baseline on touch and is
|
||||||
|
a no-op for buttons whose content already exceeds 48 px in
|
||||||
|
either axis. Applied universally rather than cfg-gated since
|
||||||
|
Material's guideline applies to all input modes. Cards, pile
|
||||||
|
markers, modal close buttons not yet audited — track as P3 if
|
||||||
|
they fall below threshold on hardware.
|
||||||
|
- [ ] **Portrait-first card spacing.** Stretch tableau piles vertically
|
||||||
|
to fill height; reduce inter-pile gaps so 7 columns fit in 360 dp.
|
||||||
|
- [ ] **Double-tap auto-move visible feedback.** `handle_double_tap`
|
||||||
|
exists since `395a322` — verify it triggers on hardware and add a
|
||||||
|
brief source-card flash / highlight to confirm to the user.
|
||||||
|
|
||||||
|
## P2 — Polish
|
||||||
|
|
||||||
|
- [ ] **Drag responsiveness on touch.** Bevy default touch-to-mouse
|
||||||
|
mapping can lag; confirm drag start threshold isn't too high for a
|
||||||
|
finger.
|
||||||
|
- [ ] **Long-press menu.** Alternative to right-click (which doesn't
|
||||||
|
exist on touch). Wire to the existing right-click-highlight system.
|
||||||
|
- [ ] **HUD typography.** Reduce text sizes for `Score:`, `Moves:`,
|
||||||
|
timer so they fit cleanly in one row.
|
||||||
|
- [ ] **Orientation lock.** Set `android:screenOrientation="portrait"`
|
||||||
|
in cargo-apk manifest (or design a landscape layout).
|
||||||
|
|
||||||
|
## P3 — Asset density
|
||||||
|
|
||||||
|
- [ ] **Density-aware card scaling.** Currently single texture size; on
|
||||||
|
a high-DPI phone the cards look small. Scale by
|
||||||
|
`Window::scale_factor()` or ship multiple PNG sizes.
|
||||||
|
- [ ] **App-icon density buckets.** Nine sizes already exist in
|
||||||
|
`assets/icon/`; verify the manifest references them so Android's
|
||||||
|
launcher picks the right one.
|
||||||
|
|
||||||
|
## P4 — Stability / runtime
|
||||||
|
|
||||||
|
- [ ] **B0004 ECS hierarchy warnings.** Flagged in
|
||||||
|
`SESSION_HANDOFF.md` after APK launch verification — investigate
|
||||||
|
whether they cause gameplay bugs on hardware vs. AVD.
|
||||||
|
- [ ] **AVD functional tests for JNI bridges.** Clipboard (`2c822ba`)
|
||||||
|
and Keystore (`f281425`) shipped but never tested on real device
|
||||||
|
or AVD.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes / decisions
|
||||||
|
|
||||||
|
* This list is screenshot-driven; expect more items to surface once
|
||||||
|
P0 unblocks actually moving cards on hardware.
|
||||||
|
* The pattern across all the bugs is "no one ran the relevant code
|
||||||
|
path on Android yet." The hard work — Bevy 0.18 on Android,
|
||||||
|
JNI bridges, signed CI builds — is done. What's left is a
|
||||||
|
coordinated pass of `#[cfg(target_os = "android")]` gates plus
|
||||||
|
making `LayoutResource` query the real surface size.
|
||||||
|
* Where possible, prefer responsive layout (query window size) over
|
||||||
|
branching `#[cfg]` blocks. Branches are fine for input methods
|
||||||
|
(touch vs. mouse) but not for screen geometry — a foldable or
|
||||||
|
desktop window of equivalent size should look the same.
|
||||||
@@ -30,7 +30,8 @@ use solitaire_engine::{
|
|||||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
||||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||||
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
OnboardingPlugin, PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin,
|
||||||
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SelectionPlugin, SettingsPlugin,
|
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
|
||||||
|
SelectionPlugin, SettingsPlugin,
|
||||||
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
|
SplashPlugin, StatsPlugin, SyncPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin,
|
||||||
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
TimeAttackPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||||
WinSummaryPlugin,
|
WinSummaryPlugin,
|
||||||
@@ -131,11 +132,20 @@ pub fn run() {
|
|||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
// The `assets/` directory lives at the workspace root, but
|
// The `assets/` directory lives at the workspace root, but
|
||||||
// Bevy resolves `AssetPlugin::file_path` relative to the
|
// on desktop Bevy resolves `AssetPlugin::file_path` relative
|
||||||
// binary package's `CARGO_MANIFEST_DIR` (`solitaire_app/`).
|
// to the binary package's `CARGO_MANIFEST_DIR`
|
||||||
// Point one level up so `cargo run -p solitaire_app` finds
|
// (`solitaire_app/`), so `cargo run -p solitaire_app` would
|
||||||
// card faces, backs, backgrounds, and the UI font.
|
// miss the workspace-root `assets/` without a `../` prefix.
|
||||||
|
//
|
||||||
|
// On Android cargo-apk packages the same directory into the
|
||||||
|
// APK at `assets/` (via `[package.metadata.android].assets`
|
||||||
|
// in solitaire_app/Cargo.toml). Bevy's `AndroidAssetReader`
|
||||||
|
// is already rooted there, so any `file_path` other than the
|
||||||
|
// default makes it walk *out* of the APK's assets root and
|
||||||
|
// all loads fail silently — which is what produced the
|
||||||
|
// solid-red card-back fallback in the v0.22.3 screenshot.
|
||||||
.set(bevy::asset::AssetPlugin {
|
.set(bevy::asset::AssetPlugin {
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
file_path: "../assets".to_string(),
|
file_path: "../assets".to_string(),
|
||||||
..default()
|
..default()
|
||||||
}),
|
}),
|
||||||
@@ -173,6 +183,7 @@ pub fn run() {
|
|||||||
.add_plugins(PlayBySeedPlugin)
|
.add_plugins(PlayBySeedPlugin)
|
||||||
.add_plugins(DifficultyPlugin)
|
.add_plugins(DifficultyPlugin)
|
||||||
.add_plugins(TimeAttackPlugin)
|
.add_plugins(TimeAttackPlugin)
|
||||||
|
.add_plugins(SafeAreaInsetsPlugin)
|
||||||
.add_plugins(HudPlugin)
|
.add_plugins(HudPlugin)
|
||||||
.add_plugins(HelpPlugin)
|
.add_plugins(HelpPlugin)
|
||||||
.add_plugins(HomePlugin::default())
|
.add_plugins(HomePlugin::default())
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
|
|||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::layout::HUD_BAND_HEIGHT;
|
use crate::layout::HUD_BAND_HEIGHT;
|
||||||
|
use crate::safe_area::{SafeAreaAnchoredTop, SafeAreaInsets};
|
||||||
|
use crate::ui_theme::SPACE_2;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||||
BG_ELEVATED_PRESSED, BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
|
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
|
/// 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
|
/// playable card. The fill is `BG_HUD_BAND` — midnight purple at 0.70
|
||||||
/// alpha, so the green felt reads through subtly.
|
/// 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((
|
commands.spawn((
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
top: Val::Px(0.0),
|
top: Val::Px(BASE_TOP + top_inset),
|
||||||
left: Val::Px(0.0),
|
left: Val::Px(0.0),
|
||||||
width: Val::Percent(100.0),
|
width: Val::Percent(100.0),
|
||||||
height: Val::Px(HUD_BAND_HEIGHT),
|
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
|
// paint on top, but above the card sprites (which are 2D-world
|
||||||
// entities and rendered behind UI regardless).
|
// entities and rendered behind UI regardless).
|
||||||
ZIndex(Z_HUD - 1),
|
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
|
/// player's #1 complaint. This restructure groups by purpose, lets
|
||||||
/// transient items disappear cleanly, and uses the typography scale to
|
/// transient items disappear cleanly, and uses the typography scale to
|
||||||
/// make Score the visual protagonist.
|
/// 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_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
let font_score = TextFont {
|
let font_score = TextFont {
|
||||||
font: font_handle.clone(),
|
font: font_handle.clone(),
|
||||||
@@ -434,6 +444,16 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
|||||||
let row_node = || Node {
|
let row_node = || Node {
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
column_gap: VAL_SPACE_3,
|
column_gap: VAL_SPACE_3,
|
||||||
|
// On a narrow viewport the four tier rows (Score/Moves/Timer,
|
||||||
|
// Mode/Challenge/Draw-cycle/Won-previously, Undos/Recycles/
|
||||||
|
// Auto-complete, selection chip) can collectively be wider than
|
||||||
|
// the available space and overflow into the action-button column
|
||||||
|
// on the right. `flex_wrap: Wrap` lets each tier soft-wrap onto
|
||||||
|
// a second line; on a desktop window the rows stay single-line
|
||||||
|
// because the parent column has no width cap and the row never
|
||||||
|
// exceeds the natural line width.
|
||||||
|
flex_wrap: FlexWrap::Wrap,
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
align_items: AlignItems::Baseline,
|
align_items: AlignItems::Baseline,
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
@@ -443,12 +463,21 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
|||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
left: VAL_SPACE_3,
|
left: VAL_SPACE_3,
|
||||||
top: VAL_SPACE_2,
|
top: Val::Px(SPACE_2 + top_inset),
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
|
// Cap the column at 50% of viewport so on narrow
|
||||||
|
// (mobile) widths the inner tier rows have a bounded
|
||||||
|
// width to wrap against, and the column can't bleed
|
||||||
|
// into the right-anchored action button row (also
|
||||||
|
// capped at 50%). On desktop 50% of 1920 = 960 px,
|
||||||
|
// wider than any tier row's natural width, so the
|
||||||
|
// visible layout is unaffected.
|
||||||
|
max_width: Val::Percent(50.0),
|
||||||
row_gap: VAL_SPACE_1,
|
row_gap: VAL_SPACE_1,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
ZIndex(Z_HUD),
|
ZIndex(Z_HUD),
|
||||||
|
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||||
))
|
))
|
||||||
.with_children(|hud| {
|
.with_children(|hud| {
|
||||||
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
|
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
|
||||||
@@ -568,7 +597,12 @@ fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
|||||||
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
|
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
|
||||||
/// because it's the most consequential action; the destructive button sits
|
/// because it's the most consequential action; the destructive button sits
|
||||||
/// on its own visual edge.
|
/// 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 {
|
let font = TextFont {
|
||||||
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
// TYPE_BODY (14.0) — was a hardcoded `16.0` until the
|
// TYPE_BODY (14.0) — was a hardcoded `16.0` until the
|
||||||
@@ -585,13 +619,28 @@ fn spawn_action_buttons(font_res: Option<Res<FontResource>>, mut commands: Comma
|
|||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
right: VAL_SPACE_3,
|
right: VAL_SPACE_3,
|
||||||
top: VAL_SPACE_2,
|
top: Val::Px(SPACE_2 + top_inset),
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
|
// 6 buttons total ~510 px wide; on a desktop window
|
||||||
|
// (typically >= 1280 px) `max_width: 50%` is >= 640 px
|
||||||
|
// and the row stays a single line. On a 360 dp phone
|
||||||
|
// 50% is 180 px and the row wraps to two-three lines —
|
||||||
|
// which keeps the buttons out of the left HUD column's
|
||||||
|
// horizontal range and prevents the off-screen-left
|
||||||
|
// clipping seen in the v0.22.3 hardware screenshot.
|
||||||
|
max_width: Val::Percent(50.0),
|
||||||
|
flex_wrap: FlexWrap::Wrap,
|
||||||
|
// When the row wraps, buttons pack to the *end* of each
|
||||||
|
// line so the row stays visually right-aligned (matches
|
||||||
|
// the `right: VAL_SPACE_3` anchor).
|
||||||
|
justify_content: JustifyContent::FlexEnd,
|
||||||
column_gap: VAL_SPACE_2,
|
column_gap: VAL_SPACE_2,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
ZIndex(Z_HUD),
|
ZIndex(Z_HUD),
|
||||||
|
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||||
))
|
))
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
// Menu and Modes don't have a single hotkey accelerator
|
// Menu and Modes don't have a single hotkey accelerator
|
||||||
@@ -681,6 +730,14 @@ fn spawn_action_button<M: Component>(
|
|||||||
font: &TextFont,
|
font: &TextFont,
|
||||||
order: i32,
|
order: i32,
|
||||||
) {
|
) {
|
||||||
|
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
|
||||||
|
// touch device — the button itself is the affordance — and they
|
||||||
|
// visibly clutter the narrow-viewport action row. Force the hint
|
||||||
|
// off on Android; the chevrons on Menu/Modes remain because they
|
||||||
|
// indicate dropdown behaviour and still apply on touch.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
let hotkey: Option<&'static str> = None;
|
||||||
|
|
||||||
let hotkey_font = TextFont {
|
let hotkey_font = TextFont {
|
||||||
font: font.font.clone(),
|
font: font.font.clone(),
|
||||||
font_size: TYPE_CAPTION,
|
font_size: TYPE_CAPTION,
|
||||||
@@ -707,6 +764,14 @@ fn spawn_action_button<M: Component>(
|
|||||||
// companion commit). Vertical padding stays at VAL_SPACE_2
|
// companion commit). Vertical padding stays at VAL_SPACE_2
|
||||||
// so button height tracks the rest of the chrome band.
|
// so button height tracks the rest of the chrome band.
|
||||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
||||||
|
// 48 px floors meet Material's recommended thumb-target
|
||||||
|
// size on touch and are a no-op on desktop for buttons
|
||||||
|
// whose content already exceeds 48 px in either axis
|
||||||
|
// (Menu, Modes, New Game, etc.). Without these, "Undo"
|
||||||
|
// ends up ~46 × 33 px — comfortably tappable with a mouse
|
||||||
|
// but right at the threshold for a finger.
|
||||||
|
min_width: Val::Px(48.0),
|
||||||
|
min_height: Val::Px(48.0),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
|||||||
@@ -21,9 +21,25 @@ pub enum LayoutSystem {
|
|||||||
UpdateOnResize,
|
UpdateOnResize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimum supported window dimensions. Layout is still computed below this
|
/// Minimum window dimensions used as a layout floor.
|
||||||
/// size but cards will be small.
|
///
|
||||||
pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0);
|
/// `compute_layout` runs `window.max(MIN_WINDOW)` so a window smaller than this
|
||||||
|
/// on either axis is laid out as if it were at least this size. The floor
|
||||||
|
/// exists to guard against degenerate / divide-by-zero layouts on very small
|
||||||
|
/// surfaces (Bevy can briefly report 0-size windows during startup or after
|
||||||
|
/// minimisation on some compositors); it is not a "minimum supported playable
|
||||||
|
/// size" — desktop builds enforce that via `WindowResizeConstraints` set in
|
||||||
|
/// `solitaire_app::lib`.
|
||||||
|
///
|
||||||
|
/// The previous floor of 800×600 was set with desktop in mind and produced
|
||||||
|
/// the wrong behaviour on Android: a 360 dp phone got laid out as if it were
|
||||||
|
/// 800-wide, pushing the leftmost foundation past `-180` and the rightmost
|
||||||
|
/// tableau pile past `+180`, which clipped both at the visible viewport
|
||||||
|
/// edges (visible in the v0.22.3 hardware screenshot). 320×400 is below the
|
||||||
|
/// smallest reasonable phone (≈ 360×640) so every real device flows through
|
||||||
|
/// without clamping, while still being large enough that the layout math
|
||||||
|
/// produces non-degenerate card sizes.
|
||||||
|
pub const MIN_WINDOW: Vec2 = Vec2::new(320.0, 400.0);
|
||||||
|
|
||||||
/// Aspect ratio (height / width) of a standard playing card.
|
/// Aspect ratio (height / width) of a standard playing card.
|
||||||
///
|
///
|
||||||
@@ -205,11 +221,39 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn layout_below_minimum_clamps_to_minimum() {
|
fn layout_below_minimum_clamps_to_minimum() {
|
||||||
let below = compute_layout(Vec2::new(400.0, 300.0));
|
// 200×200 sits below the floor on both axes, so the clamp pulls each
|
||||||
|
// axis up to MIN_WINDOW and the layout matches compute_layout(MIN_WINDOW).
|
||||||
|
let below = compute_layout(Vec2::new(200.0, 200.0));
|
||||||
let at_min = compute_layout(MIN_WINDOW);
|
let at_min = compute_layout(MIN_WINDOW);
|
||||||
assert_eq!(below.card_size, at_min.card_size);
|
assert_eq!(below.card_size, at_min.card_size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression for the v0.22.3 Android viewport-overflow bug. A typical
|
||||||
|
/// portrait-phone viewport (360 dp × 800 dp) must produce a layout
|
||||||
|
/// where every pile fits horizontally — i.e. card_width is derived
|
||||||
|
/// from the actual window, not a clamped-up desktop floor.
|
||||||
|
#[test]
|
||||||
|
fn phone_portrait_layout_fits_horizontally() {
|
||||||
|
let window = Vec2::new(360.0, 800.0);
|
||||||
|
let layout = compute_layout(window);
|
||||||
|
let half_w = window.x / 2.0;
|
||||||
|
let half_card = layout.card_size.x / 2.0;
|
||||||
|
for (pile, pos) in &layout.pile_positions {
|
||||||
|
assert!(
|
||||||
|
pos.x - half_card >= -half_w - 1e-3,
|
||||||
|
"{:?} overflows left at portrait phone window {:?}",
|
||||||
|
pile,
|
||||||
|
window
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
pos.x + half_card <= half_w + 1e-3,
|
||||||
|
"{:?} overflows right at portrait phone window {:?}",
|
||||||
|
pile,
|
||||||
|
window
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tableau_columns_are_sorted_left_to_right() {
|
fn tableau_columns_are_sorted_left_to_right() {
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
let layout = compute_layout(Vec2::new(1280.0, 800.0));
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ pub mod replay_playback;
|
|||||||
pub mod settings_plugin;
|
pub mod settings_plugin;
|
||||||
pub mod progress_plugin;
|
pub mod progress_plugin;
|
||||||
pub mod resources;
|
pub mod resources;
|
||||||
|
pub mod safe_area;
|
||||||
pub mod selection_plugin;
|
pub mod selection_plugin;
|
||||||
pub mod splash_plugin;
|
pub mod splash_plugin;
|
||||||
pub mod stats_plugin;
|
pub mod stats_plugin;
|
||||||
@@ -138,6 +139,7 @@ pub use settings_plugin::{
|
|||||||
};
|
};
|
||||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
pub use layout::{compute_layout, Layout, LayoutResource};
|
||||||
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||||
|
pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin};
|
||||||
pub use selection_plugin::{
|
pub use selection_plugin::{
|
||||||
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
|
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