fix(android): correct physical→logical px conversion for safe-area insets

`WindowInsets.getInsets(systemBars())` returns physical pixels (e.g. 84 px
on a 2.625× Pixel 7) but both Bevy's `Val::Px` (UI layer) and the world-
space layout coordinate system use logical pixels. Dividing by
`window.scale_factor()` before applying gives the correct 32 dp offset.

- `safe_area.rs::apply_safe_area_anchors`: query `Window`, divide `insets.top`
  by `scale_factor()` before writing `Val::Px(base_top + top_logical)`.
- `layout.rs::compute_layout`: new `safe_area_top: f32` parameter (logical px)
  subtracts from the vertical budget (`card_width_height_based`) and from
  `top_y` so both card sizing and pile positioning honour the status-bar band.
- `table_plugin.rs`: `setup_table` and `on_window_resized` now read
  `SafeAreaInsets` and divide by scale before passing `safe_area_top` to
  `compute_layout`. New `on_safe_area_changed` system fires a synthetic
  `WindowResized` when insets arrive (~frame 2-3 on Android) so the full
  resize pipeline (layout → pile markers → card snap) re-runs automatically.
- All test call-sites updated with `, 0.0` safe_area_top (desktop/no inset).
- Two regression tests added: shift amount equals `safe_area_top` exactly;
  horizontal layout is unaffected by vertical inset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-11 16:59:27 -07:00
parent 8a3e30bd16
commit cc161cc37f
7 changed files with 153 additions and 60 deletions
+6 -6
View File
@@ -1862,7 +1862,7 @@ mod tests {
// At game start waste is empty, so all 52 cards are across stock + tableau. // At game start waste is empty, so all 52 cards are across stock + tableau.
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne); let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let layout = let layout =
crate::layout::compute_layout(Vec2::new(1280.0, 800.0)); crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
assert_eq!(positions.len(), 52); assert_eq!(positions.len(), 52);
} }
@@ -1882,7 +1882,7 @@ mod tests {
.collect(); .collect();
assert_eq!(waste_ids.len(), 3); assert_eq!(waste_ids.len(), 3);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0)); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
// Filter rendered positions to only waste cards (by card ID). // Filter rendered positions to only waste cards (by card ID).
@@ -1911,7 +1911,7 @@ mod tests {
let waste_ids: std::collections::HashSet<u32> = let waste_ids: std::collections::HashSet<u32> =
waste_pile.iter().map(|c| c.id).collect(); waste_pile.iter().map(|c| c.id).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0)); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
let mut waste_rendered: Vec<_> = positions let mut waste_rendered: Vec<_> = positions
@@ -1936,7 +1936,7 @@ mod tests {
fn card_positions_tableau_cards_are_fanned_downward() { fn card_positions_tableau_cards_are_fanned_downward() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne); let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let layout = let layout =
crate::layout::compute_layout(Vec2::new(1280.0, 800.0)); crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
// Collect positions for Tableau(6) (should have 7 cards). // Collect positions for Tableau(6) (should have 7 cards).
@@ -2248,7 +2248,7 @@ mod tests {
#[test] #[test]
fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() { fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() {
let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne); let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne);
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0)); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
// Tableau(6) has 7 cards: 6 face-down + 1 face-up on top. // Tableau(6) has 7 cards: 6 face-down + 1 face-up on top.
@@ -2409,7 +2409,7 @@ mod tests {
// Sanity-check: the new font size matches FONT_SIZE_FRAC × the // Sanity-check: the new font size matches FONT_SIZE_FRAC × the
// post-resize card width, so the in-place path is using the // post-resize card width, so the in-place path is using the
// refreshed Layout. // refreshed Layout.
let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0)); let expected_layout = crate::layout::compute_layout(Vec2::new(800.0, 600.0), 0.0);
let expected = expected_layout.card_size.x * FONT_SIZE_FRAC; let expected = expected_layout.card_size.x * FONT_SIZE_FRAC;
assert!( assert!(
(after - expected).abs() < 1e-3, (after - expected).abs() < 1e-3,
+2 -2
View File
@@ -604,7 +604,7 @@ mod tests {
use crate::layout::compute_layout; use crate::layout::compute_layout;
let game = GameState::new(42, DrawMode::DrawOne); let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
// A cursor far off-screen should never hit anything. // A cursor far off-screen should never hit anything.
assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout)); assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout));
} }
@@ -624,7 +624,7 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
app.add_plugins(MinimalPlugins) app.add_plugins(MinimalPlugins)
.insert_resource(GameStateResource(game)) .insert_resource(GameStateResource(game))
.insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0)))) .insert_resource(LayoutResource(compute_layout(Vec2::new(1280.0, 800.0), 0.0)))
.insert_resource(DragState::default()) .insert_resource(DragState::default())
.add_systems(Update, update_drop_target_overlays); .add_systems(Update, update_drop_target_overlays);
app app
+12 -12
View File
@@ -1667,7 +1667,7 @@ mod tests {
#[test] #[test]
fn find_draggable_picks_top_of_tableau() { fn find_draggable_picks_top_of_tableau() {
let game = GameState::new(42, DrawMode::DrawOne); let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
// In tableau 6, the visually topmost card is the last (face-up) one. // In tableau 6, the visually topmost card is the last (face-up) one.
// Its position: base.y + fan * 6. // Its position: base.y + fan * 6.
@@ -1681,7 +1681,7 @@ mod tests {
#[test] #[test]
fn find_draggable_skips_face_down_cards() { fn find_draggable_skips_face_down_cards() {
let game = GameState::new(42, DrawMode::DrawOne); let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
// Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at // Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at
// the bottom (index 6). Click at the topmost face-down card's // the bottom (index 6). Click at the topmost face-down card's
@@ -1702,7 +1702,7 @@ mod tests {
// face-up bottom card, clicking the visible card face missed the // face-up bottom card, clicking the visible card face missed the
// hit-test box and only the bottom strip of the card responded. // hit-test box and only the bottom strip of the card responded.
let game = GameState::new(42, DrawMode::DrawOne); let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card // Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at // sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
@@ -1741,7 +1741,7 @@ mod tests {
face_up: true, face_up: true,
}); });
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
// The Queen's geometric center (index 1) is inside the Jack's bounding box // The Queen's geometric center (index 1) is inside the Jack's bounding box
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the // (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
// Queen we click in her visible strip: the 0.25h band above the Jack's top // Queen we click in her visible strip: the 0.25h band above the Jack's top
@@ -1773,7 +1773,7 @@ mod tests {
face_up: true, face_up: true,
}); });
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
// Both cards in waste sit at the same (x, y). Clicking should pick // Both cards in waste sit at the same (x, y). Clicking should pick
// the visually top card (id 201), with count = 1. // the visually top card (id 201), with count = 1.
let pos = card_position(&game, &layout, &PileType::Waste, 0); let pos = card_position(&game, &layout, &PileType::Waste, 0);
@@ -1786,7 +1786,7 @@ mod tests {
#[test] #[test]
fn find_drop_target_hits_empty_tableau_pile_marker() { fn find_drop_target_hits_empty_tableau_pile_marker() {
let game = GameState::new(42, DrawMode::DrawOne); let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
// Move all cards out of tableau 0 so its marker is the only drop area. // Move all cards out of tableau 0 so its marker is the only drop area.
let mut game = game; let mut game = game;
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
@@ -1798,7 +1798,7 @@ mod tests {
#[test] #[test]
fn find_drop_target_returns_none_for_origin() { fn find_drop_target_returns_none_for_origin() {
let game = GameState::new(42, DrawMode::DrawOne); let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
let pos = layout.pile_positions[&PileType::Tableau(3)]; let pos = layout.pile_positions[&PileType::Tableau(3)];
let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(3)); let target = find_drop_target(pos, &game, &layout, &PileType::Tableau(3));
assert_eq!(target, None); assert_eq!(target, None);
@@ -1807,7 +1807,7 @@ mod tests {
#[test] #[test]
fn pile_drop_rect_extends_for_tableau_with_cards() { fn pile_drop_rect_extends_for_tableau_with_cards() {
let game = GameState::new(42, DrawMode::DrawOne); let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
// Tableau 6 has 7 cards. // Tableau 6 has 7 cards.
let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game); let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game);
// Expected: card_height + 6 * fan. fan = 0.25 * card_height, so // Expected: card_height + 6 * fan. fan = 0.25 * card_height, so
@@ -1832,7 +1832,7 @@ mod tests {
waste.cards.push(Card { id: 201, suit: Suit::Hearts, rank: Rank::Three, face_up: true }); waste.cards.push(Card { id: 201, suit: Suit::Hearts, rank: Rank::Three, face_up: true });
waste.cards.push(Card { id: 202, suit: Suit::Clubs, rank: Rank::Four, face_up: true }); waste.cards.push(Card { id: 202, suit: Suit::Clubs, rank: Rank::Four, face_up: true });
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
let waste_base = layout.pile_positions[&PileType::Waste]; let waste_base = layout.pile_positions[&PileType::Waste];
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width. // Top card (slot=2) is at base.x + 2 * 0.28 * card_width.
let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x; let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
@@ -1848,7 +1848,7 @@ mod tests {
#[test] #[test]
fn find_draggable_returns_none_for_click_on_empty_pile() { fn find_draggable_returns_none_for_click_on_empty_pile() {
let mut game = GameState::new(42, DrawMode::DrawOne); let mut game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
// Clear tableau 0 so it's an empty slot. // Clear tableau 0 so it's an empty slot.
game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear(); game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
let pos = layout.pile_positions[&PileType::Tableau(0)]; let pos = layout.pile_positions[&PileType::Tableau(0)];
@@ -1859,7 +1859,7 @@ mod tests {
#[test] #[test]
fn pile_drop_rect_is_card_sized_for_non_tableau() { fn pile_drop_rect_is_card_sized_for_non_tableau() {
let game = GameState::new(42, DrawMode::DrawOne); let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
for pile in [ for pile in [
PileType::Waste, PileType::Waste,
PileType::Foundation(2), PileType::Foundation(2),
@@ -2360,7 +2360,7 @@ mod tests {
app.init_resource::<crate::pending_hint::PendingHintTask>(); app.init_resource::<crate::pending_hint::PendingHintTask>();
app.init_resource::<ButtonInput<KeyCode>>(); app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(crate::layout::LayoutResource( app.insert_resource(crate::layout::LayoutResource(
crate::layout::compute_layout(Vec2::new(1280.0, 800.0)), crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0),
)); ));
app.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne))); app.insert_resource(GameStateResource(GameState::new(42, DrawMode::DrawOne)));
app.add_systems(Update, handle_keyboard_hint); app.add_systems(Update, handle_keyboard_hint);
+76 -27
View File
@@ -108,7 +108,13 @@ pub struct Layout {
pub tableau_facedown_fan_frac: f32, pub tableau_facedown_fan_frac: f32,
} }
/// Compute the board layout from a window size. /// Compute the board layout from a window size and safe-area top inset.
///
/// `safe_area_top` is the **logical-pixel** height of the OS-reserved region
/// at the top of the screen (status bar on Android). Pass `0.0` on desktop or
/// when the inset is unknown. Note that Android's `WindowInsets` API returns
/// **physical** pixels; callers must divide by `window.scale_factor()` before
/// passing the value here.
/// ///
/// # Geometry /// # Geometry
/// - `card_width` is the smaller of: /// - `card_width` is the smaller of:
@@ -124,7 +130,7 @@ pub struct Layout {
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns /// - Top row (stock, waste, 4 foundations) aligns with tableau columns
/// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the /// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the
/// waste/stock cluster from the foundations. /// waste/stock cluster from the foundations.
pub fn compute_layout(window: Vec2) -> Layout { pub fn compute_layout(window: Vec2, safe_area_top: f32) -> Layout {
let window = window.max(MIN_WINDOW); let window = window.max(MIN_WINDOW);
// Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width. // Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width.
@@ -147,7 +153,7 @@ pub fn compute_layout(window: Vec2) -> Layout {
// (window.y - HUD_BAND_HEIGHT) = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT) // (window.y - HUD_BAND_HEIGHT) = w * (0.5 + (1 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT)
let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC; let fan_factor = 1.0 + (MAX_TABLEAU_CARDS - 1.0) * TABLEAU_FAN_FRAC;
let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT; let height_denom = 0.5 + (1.0 + fan_factor + VERTICAL_GAP_FRAC) * CARD_ASPECT;
let card_width_height_based = (window.y - HUD_BAND_HEIGHT).max(0.0) / height_denom; let card_width_height_based = (window.y - safe_area_top - HUD_BAND_HEIGHT).max(0.0) / height_denom;
let card_width = card_width_width_based.min(card_width_height_based); let card_width = card_width_width_based.min(card_width_height_based);
let card_height = card_width * CARD_ASPECT; let card_height = card_width * CARD_ASPECT;
@@ -167,7 +173,7 @@ pub fn compute_layout(window: Vec2) -> Layout {
}; };
let vertical_gap = card_height * VERTICAL_GAP_FRAC; let vertical_gap = card_height * VERTICAL_GAP_FRAC;
let top_y = window.y / 2.0 - HUD_BAND_HEIGHT - h_gap - card_height / 2.0; let top_y = window.y / 2.0 - safe_area_top - HUD_BAND_HEIGHT - h_gap - card_height / 2.0;
let tableau_y = top_y - card_height - vertical_gap; let tableau_y = top_y - card_height - vertical_gap;
let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13); let mut pile_positions: HashMap<PileType, Vec2> = HashMap::with_capacity(13);
@@ -247,15 +253,15 @@ mod tests {
#[test] #[test]
fn layout_has_all_thirteen_piles() { fn layout_has_all_thirteen_piles() {
assert_all_piles_present(&compute_layout(Vec2::new(1280.0, 800.0))); assert_all_piles_present(&compute_layout(Vec2::new(1280.0, 800.0), 0.0));
assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.0))); assert_all_piles_present(&compute_layout(Vec2::new(800.0, 600.0), 0.0));
assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0))); assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0), 0.0));
} }
#[test] #[test]
fn card_size_scales_with_window_width() { fn card_size_scales_with_window_width() {
let small = compute_layout(Vec2::new(800.0, 600.0)); let small = compute_layout(Vec2::new(800.0, 600.0), 0.0);
let large = compute_layout(Vec2::new(1920.0, 1080.0)); let large = compute_layout(Vec2::new(1920.0, 1080.0), 0.0);
assert!(large.card_size.x > small.card_size.x); assert!(large.card_size.x > small.card_size.x);
assert!( assert!(
(large.card_size.y / large.card_size.x - CARD_ASPECT).abs() < 1e-5, (large.card_size.y / large.card_size.x - CARD_ASPECT).abs() < 1e-5,
@@ -266,9 +272,9 @@ mod tests {
#[test] #[test]
fn layout_below_minimum_clamps_to_minimum() { fn layout_below_minimum_clamps_to_minimum() {
// 200×200 sits below the floor on both axes, so the clamp pulls each // 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). // axis up to MIN_WINDOW and the layout matches compute_layout(MIN_WINDOW, 0.0).
let below = compute_layout(Vec2::new(200.0, 200.0)); let below = compute_layout(Vec2::new(200.0, 200.0), 0.0);
let at_min = compute_layout(MIN_WINDOW); let at_min = compute_layout(MIN_WINDOW, 0.0);
assert_eq!(below.card_size, at_min.card_size); assert_eq!(below.card_size, at_min.card_size);
} }
@@ -279,7 +285,7 @@ mod tests {
#[test] #[test]
fn phone_portrait_layout_fits_horizontally() { fn phone_portrait_layout_fits_horizontally() {
let window = Vec2::new(360.0, 800.0); let window = Vec2::new(360.0, 800.0);
let layout = compute_layout(window); let layout = compute_layout(window, 0.0);
let half_w = window.x / 2.0; let half_w = window.x / 2.0;
let half_card = layout.card_size.x / 2.0; let half_card = layout.card_size.x / 2.0;
for (pile, pos) in &layout.pile_positions { for (pile, pos) in &layout.pile_positions {
@@ -300,7 +306,7 @@ mod tests {
#[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), 0.0);
for i in 0..6 { for i in 0..6 {
let lhs = layout.pile_positions[&PileType::Tableau(i)].x; let lhs = layout.pile_positions[&PileType::Tableau(i)].x;
let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x; let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x;
@@ -310,7 +316,7 @@ mod tests {
#[test] #[test]
fn top_row_is_above_tableau_row() { fn top_row_is_above_tableau_row() {
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
let stock_y = layout.pile_positions[&PileType::Stock].y; let stock_y = layout.pile_positions[&PileType::Stock].y;
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y; let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
assert!(stock_y > tableau_y); assert!(stock_y > tableau_y);
@@ -323,7 +329,7 @@ mod tests {
#[test] #[test]
fn top_row_clears_hud_band() { fn top_row_clears_hud_band() {
let window = Vec2::new(1280.0, 800.0); let window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(window); let layout = compute_layout(window, 0.0);
let stock_y = layout.pile_positions[&PileType::Stock].y; let stock_y = layout.pile_positions[&PileType::Stock].y;
let card_top = stock_y + layout.card_size.y / 2.0; let card_top = stock_y + layout.card_size.y / 2.0;
let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT; let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT;
@@ -335,7 +341,7 @@ mod tests {
#[test] #[test]
fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() { fn stock_aligns_with_tableau_col_0_and_waste_with_col_1() {
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
let stock_x = layout.pile_positions[&PileType::Stock].x; let stock_x = layout.pile_positions[&PileType::Stock].x;
let waste_x = layout.pile_positions[&PileType::Waste].x; let waste_x = layout.pile_positions[&PileType::Waste].x;
let t0_x = layout.pile_positions[&PileType::Tableau(0)].x; let t0_x = layout.pile_positions[&PileType::Tableau(0)].x;
@@ -346,7 +352,7 @@ mod tests {
#[test] #[test]
fn foundations_align_with_tableau_cols_3_to_6() { fn foundations_align_with_tableau_cols_3_to_6() {
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
for slot in 0..4_u8 { for slot in 0..4_u8 {
let f_x = layout.pile_positions[&PileType::Foundation(slot)].x; let f_x = layout.pile_positions[&PileType::Foundation(slot)].x;
let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x; let t_x = layout.pile_positions[&PileType::Tableau(3 + slot as usize)].x;
@@ -365,7 +371,7 @@ mod tests {
// keep a worst-case 13-card column inside the window. (Most desktop // keep a worst-case 13-card column inside the window. (Most desktop
// monitors fall into this regime — e.g. 1280x800, 1920x1080.) // monitors fall into this regime — e.g. 1280x800, 1920x1080.)
let window = Vec2::new(2560.0, 1080.0); let window = Vec2::new(2560.0, 1080.0);
let layout = compute_layout(window); let layout = compute_layout(window, 0.0);
let width_based = window.x / 9.0; let width_based = window.x / 9.0;
assert!( assert!(
layout.card_size.x < width_based, layout.card_size.x < width_based,
@@ -381,7 +387,7 @@ mod tests {
// the bottleneck and card_width matches the legacy window.x / 9 // the bottleneck and card_width matches the legacy window.x / 9
// derivation exactly. // derivation exactly.
let window = Vec2::new(900.0, 1600.0); let window = Vec2::new(900.0, 1600.0);
let layout = compute_layout(window); let layout = compute_layout(window, 0.0);
let width_based = window.x / 9.0; let width_based = window.x / 9.0;
assert!( assert!(
(layout.card_size.x - width_based).abs() < 1e-3, (layout.card_size.x - width_based).abs() < 1e-3,
@@ -395,7 +401,7 @@ mod tests {
fn worst_case_tableau_fits_vertically_on_default_resolution() { fn worst_case_tableau_fits_vertically_on_default_resolution() {
// Default app resolution (see solitaire_app/src/main.rs). // Default app resolution (see solitaire_app/src/main.rs).
let window = Vec2::new(1280.0, 800.0); let window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(window); let layout = compute_layout(window, 0.0);
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y; let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
let card_h = layout.card_size.y; let card_h = layout.card_size.y;
// Bottom edge of the 13th fanned face-up card. // Bottom edge of the 13th fanned face-up card.
@@ -414,7 +420,7 @@ mod tests {
fn worst_case_tableau_fits_vertically_on_full_hd() { fn worst_case_tableau_fits_vertically_on_full_hd() {
// The bug originally reproduced at 1920x1080. Lock in a regression test. // The bug originally reproduced at 1920x1080. Lock in a regression test.
let window = Vec2::new(1920.0, 1080.0); let window = Vec2::new(1920.0, 1080.0);
let layout = compute_layout(window); let layout = compute_layout(window, 0.0);
let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y; let tableau_y = layout.pile_positions[&PileType::Tableau(6)].y;
let card_h = layout.card_size.y; let card_h = layout.card_size.y;
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0; let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
@@ -430,8 +436,8 @@ mod tests {
/// the desktop minimum so the tableau fills the available vertical space. /// the desktop minimum so the tableau fills the available vertical space.
#[test] #[test]
fn portrait_phone_expands_tableau_fan_frac() { fn portrait_phone_expands_tableau_fan_frac() {
let desktop = compute_layout(Vec2::new(1280.0, 800.0)); let desktop = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
let phone = compute_layout(Vec2::new(360.0, 800.0)); let phone = compute_layout(Vec2::new(360.0, 800.0), 0.0);
assert!( assert!(
phone.tableau_fan_frac > desktop.tableau_fan_frac, phone.tableau_fan_frac > desktop.tableau_fan_frac,
"portrait phone fan_frac ({:.3}) should exceed desktop ({:.3})", "portrait phone fan_frac ({:.3}) should exceed desktop ({:.3})",
@@ -445,7 +451,7 @@ mod tests {
#[test] #[test]
fn expanded_fan_fits_phone_viewport() { fn expanded_fan_fits_phone_viewport() {
let window = Vec2::new(360.0, 800.0); let window = Vec2::new(360.0, 800.0);
let layout = compute_layout(window); let layout = compute_layout(window, 0.0);
let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y; let tableau_y = layout.pile_positions[&PileType::Tableau(0)].y;
let card_h = layout.card_size.y; let card_h = layout.card_size.y;
let h_gap = layout.card_size.x / 4.0; let h_gap = layout.card_size.x / 4.0;
@@ -462,7 +468,7 @@ mod tests {
/// existing worst-case-fits-vertically invariant is preserved. /// existing worst-case-fits-vertically invariant is preserved.
#[test] #[test]
fn desktop_tableau_fan_frac_is_minimum() { fn desktop_tableau_fan_frac_is_minimum() {
let layout = compute_layout(Vec2::new(1280.0, 800.0)); let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0);
assert!( assert!(
(layout.tableau_fan_frac - TABLEAU_FAN_FRAC).abs() < 1e-3, (layout.tableau_fan_frac - TABLEAU_FAN_FRAC).abs() < 1e-3,
"desktop fan_frac should stay at minimum {TABLEAU_FAN_FRAC}, got {:.4}", "desktop fan_frac should stay at minimum {TABLEAU_FAN_FRAC}, got {:.4}",
@@ -477,7 +483,7 @@ mod tests {
Vec2::new(1280.0, 800.0), Vec2::new(1280.0, 800.0),
Vec2::new(1920.0, 1080.0), Vec2::new(1920.0, 1080.0),
] { ] {
let layout = compute_layout(window); let layout = compute_layout(window, 0.0);
let half_w = window.x / 2.0; let half_w = window.x / 2.0;
let half_card = layout.card_size.x / 2.0; let half_card = layout.card_size.x / 2.0;
for (pile, pos) in &layout.pile_positions { for (pile, pos) in &layout.pile_positions {
@@ -496,4 +502,47 @@ mod tests {
} }
} }
} }
/// A non-zero `safe_area_top` must shift both the top row and the tableau
/// downward by the same amount — so the first card row stays below the
/// status-bar band and the tableau tracks it proportionally.
#[test]
fn safe_area_top_shifts_top_row_downward() {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0);
let with_inset = compute_layout(window, 32.0);
let stock_no_inset = without.pile_positions[&PileType::Stock].y;
let stock_with_inset = with_inset.pile_positions[&PileType::Stock].y;
assert!(
stock_with_inset < stock_no_inset,
"safe_area_top=32 must shift stock pile down (y decreased): {} → {}",
stock_no_inset,
stock_with_inset,
);
assert!(
(stock_no_inset - stock_with_inset - 32.0).abs() < 1e-3,
"stock pile must shift by exactly safe_area_top (32 dp): delta was {:.3}",
stock_no_inset - stock_with_inset,
);
}
/// With a safe-area inset the card grid must still fit horizontally —
/// safe_area_top only affects the vertical budget.
#[test]
fn safe_area_top_does_not_affect_horizontal_layout() {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0);
let with_inset = compute_layout(window, 32.0);
for pile in [
PileType::Stock,
PileType::Waste,
PileType::Tableau(0),
PileType::Tableau(6),
] {
assert!(
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
"{pile:?} x-position must not change with safe_area_top",
);
}
}
} }
+6 -6
View File
@@ -801,7 +801,7 @@ mod tests {
fn install_resources(app: &mut App, state: GameState, layout_window: Vec2, cursor: Vec2) { fn install_resources(app: &mut App, state: GameState, layout_window: Vec2, cursor: Vec2) {
app.insert_resource(GameStateResource(state)); app.insert_resource(GameStateResource(state));
app.insert_resource(LayoutResource(compute_layout(layout_window))); app.insert_resource(LayoutResource(compute_layout(layout_window, 0.0)));
app.world_mut().resource_mut::<RadialCursorOverride>().0 = Some(cursor); app.world_mut().resource_mut::<RadialCursorOverride>().0 = Some(cursor);
} }
@@ -913,7 +913,7 @@ mod tests {
fn right_click_press_on_face_up_card_opens_radial() { fn right_click_press_on_face_up_card_opens_radial() {
let mut app = radial_test_app(); let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0); let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window); let layout = compute_layout(layout_window, 0.0);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)]; let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos); install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
@@ -950,7 +950,7 @@ mod tests {
fn right_click_release_over_destination_fires_move_request() { fn right_click_release_over_destination_fires_move_request() {
let mut app = radial_test_app(); let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0); let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window); let layout = compute_layout(layout_window, 0.0);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)]; let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos); install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
@@ -989,7 +989,7 @@ mod tests {
fn right_click_release_outside_any_destination_cancels() { fn right_click_release_outside_any_destination_cancels() {
let mut app = radial_test_app(); let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0); let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window); let layout = compute_layout(layout_window, 0.0);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)]; let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos); install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
@@ -1016,7 +1016,7 @@ mod tests {
fn escape_cancels_active_radial() { fn escape_cancels_active_radial() {
let mut app = radial_test_app(); let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0); let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window); let layout = compute_layout(layout_window, 0.0);
let ace_pos = layout.pile_positions[&PileType::Tableau(0)]; let ace_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, ace_only_state(), layout_window, ace_pos); install_resources(&mut app, ace_only_state(), layout_window, ace_pos);
@@ -1039,7 +1039,7 @@ mod tests {
fn right_click_on_face_down_card_does_not_open_radial() { fn right_click_on_face_down_card_does_not_open_radial() {
let mut app = radial_test_app(); let mut app = radial_test_app();
let layout_window = Vec2::new(1280.0, 800.0); let layout_window = Vec2::new(1280.0, 800.0);
let layout = compute_layout(layout_window); let layout = compute_layout(layout_window, 0.0);
let king_pos = layout.pile_positions[&PileType::Tableau(0)]; let king_pos = layout.pile_positions[&PileType::Tableau(0)];
install_resources(&mut app, face_down_only_state(), layout_window, king_pos); install_resources(&mut app, face_down_only_state(), layout_window, king_pos);
+7 -1
View File
@@ -71,13 +71,19 @@ impl Plugin for SafeAreaInsetsPlugin {
/// a session. /// a session.
fn apply_safe_area_anchors( fn apply_safe_area_anchors(
insets: Res<SafeAreaInsets>, insets: Res<SafeAreaInsets>,
windows: Query<&Window>,
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>, mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
) { ) {
if !insets.is_changed() { if !insets.is_changed() {
return; return;
} }
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
// expects logical pixels (≈ dp). Divide by the window scale factor so
// the HUD band shifts by the correct number of dp on high-DPI devices.
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let top_logical = insets.top / scale;
for (anchor, mut node) in &mut q { for (anchor, mut node) in &mut q {
node.top = Val::Px(anchor.base_top + insets.top); node.top = Val::Px(anchor.base_top + top_logical);
} }
} }
+44 -6
View File
@@ -11,6 +11,7 @@ use solitaire_core::pile::PileType;
use crate::events::{HintVisualEvent, StateChangedEvent}; use crate::events::{HintVisualEvent, StateChangedEvent};
use crate::layout::{compute_layout, Layout, LayoutResource, LayoutSystem}; use crate::layout::{compute_layout, Layout, LayoutResource, LayoutSystem};
use crate::safe_area::SafeAreaInsets;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
#[cfg(test)] #[cfg(test)]
use crate::layout::TABLE_COLOUR; use crate::layout::TABLE_COLOUR;
@@ -82,6 +83,7 @@ impl Plugin for TablePlugin {
.add_systems( .add_systems(
Update, Update,
( (
on_safe_area_changed.before(LayoutSystem::UpdateOnResize),
on_window_resized.in_set(LayoutSystem::UpdateOnResize), on_window_resized.in_set(LayoutSystem::UpdateOnResize),
apply_theme_on_settings_change, apply_theme_on_settings_change,
apply_hint_pile_highlight, apply_hint_pile_highlight,
@@ -146,6 +148,7 @@ fn setup_table(
existing_camera: Query<(), With<Camera>>, existing_camera: Query<(), With<Camera>>,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
bg_images: Option<Res<BackgroundImageSet>>, bg_images: Option<Res<BackgroundImageSet>>,
safe_area: Option<Res<SafeAreaInsets>>,
) { ) {
// Only spawn a camera if one does not already exist (e.g. a parent app // Only spawn a camera if one does not already exist (e.g. a parent app
// may have added one in tests). // may have added one in tests).
@@ -153,11 +156,15 @@ fn setup_table(
commands.spawn(Camera2d); commands.spawn(Camera2d);
} }
let window_size = windows let (window_size, scale) = windows.iter().next().map_or(
.iter() (Vec2::new(1280.0, 800.0), 1.0f32),
.next() |w| (default_window_size(w), w.scale_factor()),
.map_or(Vec2::new(1280.0, 800.0), default_window_size); );
let layout = compute_layout(window_size); // Safe-area insets arrive from JNI asynchronously; they are almost always
// 0 here (populated ~frame 2-3). on_safe_area_changed fires when they
// arrive and issues a synthetic WindowResized to re-snap all game objects.
let safe_area_top = safe_area.as_deref().copied().unwrap_or_default().top / scale;
let layout = compute_layout(window_size, safe_area_top);
let selected_bg = settings.as_ref().map_or(0, |s| s.0.selected_background); let selected_bg = settings.as_ref().map_or(0, |s| s.0.selected_background);
@@ -279,6 +286,8 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) {
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
fn on_window_resized( fn on_window_resized(
mut events: MessageReader<WindowResized>, mut events: MessageReader<WindowResized>,
safe_area: Option<Res<SafeAreaInsets>>,
windows: Query<&Window>,
mut layout_res: Option<ResMut<LayoutResource>>, mut layout_res: Option<ResMut<LayoutResource>>,
mut backgrounds: Query< mut backgrounds: Query<
(&mut Sprite, &mut Transform), (&mut Sprite, &mut Transform),
@@ -290,7 +299,9 @@ fn on_window_resized(
return; return;
}; };
let window_size = Vec2::new(ev.width, ev.height); let window_size = Vec2::new(ev.width, ev.height);
let new_layout = compute_layout(window_size); let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let safe_area_top = safe_area.as_deref().copied().unwrap_or_default().top / scale;
let new_layout = compute_layout(window_size, safe_area_top);
if let Some(layout_res) = layout_res.as_deref_mut() { if let Some(layout_res) = layout_res.as_deref_mut() {
layout_res.0 = new_layout.clone(); layout_res.0 = new_layout.clone();
@@ -318,6 +329,33 @@ fn on_window_resized(
// and forth" jitter). // and forth" jitter).
} }
/// Bridges the asynchronous safe-area inset update into the synchronous
/// window-resize pipeline. When Android's JNI delivers the real inset values
/// (typically frame 2-3 of a fresh launch), this system writes a synthetic
/// `WindowResized` event carrying the current window size. `on_window_resized`
/// (which runs in `LayoutSystem::UpdateOnResize`) will then recompute the
/// layout with the correct `safe_area_top`, update `LayoutResource` and the
/// pile markers, and `snap_cards_on_window_resize` (running after the set)
/// will snap card sprites to the corrected positions.
fn on_safe_area_changed(
safe_area: Option<Res<SafeAreaInsets>>,
windows: Query<(Entity, &Window)>,
mut resize_events: MessageWriter<WindowResized>,
) {
let Some(safe_area) = safe_area else { return; };
if !safe_area.is_changed() {
return;
}
let Some((entity, window)) = windows.iter().next() else {
return;
};
resize_events.write(WindowResized {
window: entity,
width: window.resolution.width(),
height: window.resolution.height(),
});
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task #6 — Hint pile-marker highlight // Task #6 — Hint pile-marker highlight
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------