diff --git a/solitaire_engine/src/card_plugin.rs b/solitaire_engine/src/card_plugin.rs index a4a980e..ca8cdf3 100644 --- a/solitaire_engine/src/card_plugin.rs +++ b/solitaire_engine/src/card_plugin.rs @@ -1862,7 +1862,7 @@ mod tests { // 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 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); assert_eq!(positions.len(), 52); } @@ -1882,7 +1882,7 @@ mod tests { .collect(); 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); // Filter rendered positions to only waste cards (by card ID). @@ -1911,7 +1911,7 @@ mod tests { let waste_ids: std::collections::HashSet = 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 mut waste_rendered: Vec<_> = positions @@ -1936,7 +1936,7 @@ mod tests { fn card_positions_tableau_cards_are_fanned_downward() { let g = GameState::new(42, solitaire_core::game_state::DrawMode::DrawOne); 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); // Collect positions for Tableau(6) (should have 7 cards). @@ -2248,7 +2248,7 @@ mod tests { #[test] fn facedown_cards_use_tighter_fan_than_uniform_faceup_fan() { 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); // 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 // post-resize card width, so the in-place path is using the // 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; assert!( (after - expected).abs() < 1e-3, diff --git a/solitaire_engine/src/cursor_plugin.rs b/solitaire_engine/src/cursor_plugin.rs index c2bc438..4bccab3 100644 --- a/solitaire_engine/src/cursor_plugin.rs +++ b/solitaire_engine/src/cursor_plugin.rs @@ -604,7 +604,7 @@ mod tests { use crate::layout::compute_layout; 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. assert!(!cursor_over_draggable(Vec2::new(-9999.0, -9999.0), &game, &layout)); } @@ -624,7 +624,7 @@ mod tests { let mut app = App::new(); app.add_plugins(MinimalPlugins) .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()) .add_systems(Update, update_drop_target_overlays); app diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin.rs index e630d06..f54077a 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin.rs @@ -1667,7 +1667,7 @@ mod tests { #[test] fn find_draggable_picks_top_of_tableau() { 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. // Its position: base.y + fan * 6. @@ -1681,7 +1681,7 @@ mod tests { #[test] fn find_draggable_skips_face_down_cards() { 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 // 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 // hit-test box and only the bottom strip of the card responded. 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 // sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at @@ -1741,7 +1741,7 @@ mod tests { 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 // (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 @@ -1773,7 +1773,7 @@ mod tests { 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 // the visually top card (id 201), with count = 1. let pos = card_position(&game, &layout, &PileType::Waste, 0); @@ -1786,7 +1786,7 @@ mod tests { #[test] fn find_drop_target_hits_empty_tableau_pile_marker() { 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. let mut game = game; game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear(); @@ -1798,7 +1798,7 @@ mod tests { #[test] fn find_drop_target_returns_none_for_origin() { 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 target = find_drop_target(pos, &game, &layout, &PileType::Tableau(3)); assert_eq!(target, None); @@ -1807,7 +1807,7 @@ mod tests { #[test] fn pile_drop_rect_extends_for_tableau_with_cards() { 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. let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game); // 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: 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]; // 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; @@ -1848,7 +1848,7 @@ mod tests { #[test] fn find_draggable_returns_none_for_click_on_empty_pile() { 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. game.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear(); let pos = layout.pile_positions[&PileType::Tableau(0)]; @@ -1859,7 +1859,7 @@ mod tests { #[test] fn pile_drop_rect_is_card_sized_for_non_tableau() { 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 [ PileType::Waste, PileType::Foundation(2), @@ -2360,7 +2360,7 @@ mod tests { app.init_resource::(); app.init_resource::>(); 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.add_systems(Update, handle_keyboard_hint); diff --git a/solitaire_engine/src/layout.rs b/solitaire_engine/src/layout.rs index 6d208c5..5f4ae5c 100644 --- a/solitaire_engine/src/layout.rs +++ b/solitaire_engine/src/layout.rs @@ -108,7 +108,13 @@ pub struct Layout { 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 /// - `card_width` is the smaller of: @@ -124,7 +130,7 @@ pub struct Layout { /// - Top row (stock, waste, 4 foundations) aligns with tableau columns /// 0, 1, 3, 4, 5, 6 — column 2 is intentionally empty to separate the /// 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); // 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) 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 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_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 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 mut pile_positions: HashMap = HashMap::with_capacity(13); @@ -247,15 +253,15 @@ mod tests { #[test] 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(800.0, 600.0))); - assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.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), 0.0)); + assert_all_piles_present(&compute_layout(Vec2::new(1920.0, 1080.0), 0.0)); } #[test] fn card_size_scales_with_window_width() { - let small = compute_layout(Vec2::new(800.0, 600.0)); - let large = compute_layout(Vec2::new(1920.0, 1080.0)); + let small = compute_layout(Vec2::new(800.0, 600.0), 0.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.y / large.card_size.x - CARD_ASPECT).abs() < 1e-5, @@ -266,9 +272,9 @@ mod tests { #[test] fn layout_below_minimum_clamps_to_minimum() { // 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); + // 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), 0.0); + let at_min = compute_layout(MIN_WINDOW, 0.0); assert_eq!(below.card_size, at_min.card_size); } @@ -279,7 +285,7 @@ mod tests { #[test] fn phone_portrait_layout_fits_horizontally() { 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_card = layout.card_size.x / 2.0; for (pile, pos) in &layout.pile_positions { @@ -300,7 +306,7 @@ mod tests { #[test] 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 { let lhs = layout.pile_positions[&PileType::Tableau(i)].x; let rhs = layout.pile_positions[&PileType::Tableau(i + 1)].x; @@ -310,7 +316,7 @@ mod tests { #[test] 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 tableau_y = layout.pile_positions[&PileType::Tableau(0)].y; assert!(stock_y > tableau_y); @@ -323,7 +329,7 @@ mod tests { #[test] fn top_row_clears_hud_band() { 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 card_top = stock_y + layout.card_size.y / 2.0; let band_bottom = window.y / 2.0 - HUD_BAND_HEIGHT; @@ -335,7 +341,7 @@ mod tests { #[test] 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 waste_x = layout.pile_positions[&PileType::Waste].x; let t0_x = layout.pile_positions[&PileType::Tableau(0)].x; @@ -346,7 +352,7 @@ mod tests { #[test] 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 { let f_x = layout.pile_positions[&PileType::Foundation(slot)].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 // monitors fall into this regime — e.g. 1280x800, 1920x1080.) 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; assert!( layout.card_size.x < width_based, @@ -381,7 +387,7 @@ mod tests { // the bottleneck and card_width matches the legacy window.x / 9 // derivation exactly. 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; assert!( (layout.card_size.x - width_based).abs() < 1e-3, @@ -395,7 +401,7 @@ mod tests { fn worst_case_tableau_fits_vertically_on_default_resolution() { // Default app resolution (see solitaire_app/src/main.rs). 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 card_h = layout.card_size.y; // Bottom edge of the 13th fanned face-up card. @@ -414,7 +420,7 @@ mod tests { fn worst_case_tableau_fits_vertically_on_full_hd() { // The bug originally reproduced at 1920x1080. Lock in a regression test. 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 card_h = layout.card_size.y; 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. #[test] fn portrait_phone_expands_tableau_fan_frac() { - let desktop = compute_layout(Vec2::new(1280.0, 800.0)); - let phone = compute_layout(Vec2::new(360.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), 0.0); assert!( phone.tableau_fan_frac > desktop.tableau_fan_frac, "portrait phone fan_frac ({:.3}) should exceed desktop ({:.3})", @@ -445,7 +451,7 @@ mod tests { #[test] fn expanded_fan_fits_phone_viewport() { 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 card_h = layout.card_size.y; let h_gap = layout.card_size.x / 4.0; @@ -462,7 +468,7 @@ mod tests { /// existing worst-case-fits-vertically invariant is preserved. #[test] 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!( (layout.tableau_fan_frac - TABLEAU_FAN_FRAC).abs() < 1e-3, "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(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_card = layout.card_size.x / 2.0; 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", + ); + } + } } diff --git a/solitaire_engine/src/radial_menu.rs b/solitaire_engine/src/radial_menu.rs index 11f1642..48665d3 100644 --- a/solitaire_engine/src/radial_menu.rs +++ b/solitaire_engine/src/radial_menu.rs @@ -801,7 +801,7 @@ mod tests { fn install_resources(app: &mut App, state: GameState, layout_window: Vec2, cursor: Vec2) { 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::().0 = Some(cursor); } @@ -913,7 +913,7 @@ mod tests { fn right_click_press_on_face_up_card_opens_radial() { let mut app = radial_test_app(); 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)]; 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() { let mut app = radial_test_app(); 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)]; 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() { let mut app = radial_test_app(); 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)]; install_resources(&mut app, ace_only_state(), layout_window, ace_pos); @@ -1016,7 +1016,7 @@ mod tests { fn escape_cancels_active_radial() { let mut app = radial_test_app(); 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)]; 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() { let mut app = radial_test_app(); 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)]; install_resources(&mut app, face_down_only_state(), layout_window, king_pos); diff --git a/solitaire_engine/src/safe_area.rs b/solitaire_engine/src/safe_area.rs index 8ca40cc..63f6754 100644 --- a/solitaire_engine/src/safe_area.rs +++ b/solitaire_engine/src/safe_area.rs @@ -71,13 +71,19 @@ impl Plugin for SafeAreaInsetsPlugin { /// a session. fn apply_safe_area_anchors( insets: Res, + windows: Query<&Window>, mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>, ) { if !insets.is_changed() { 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 { - node.top = Val::Px(anchor.base_top + insets.top); + node.top = Val::Px(anchor.base_top + top_logical); } } diff --git a/solitaire_engine/src/table_plugin.rs b/solitaire_engine/src/table_plugin.rs index 7841e6a..5ceb5d1 100644 --- a/solitaire_engine/src/table_plugin.rs +++ b/solitaire_engine/src/table_plugin.rs @@ -11,6 +11,7 @@ use solitaire_core::pile::PileType; use crate::events::{HintVisualEvent, StateChangedEvent}; use crate::layout::{compute_layout, Layout, LayoutResource, LayoutSystem}; +use crate::safe_area::SafeAreaInsets; use crate::resources::GameStateResource; #[cfg(test)] use crate::layout::TABLE_COLOUR; @@ -82,6 +83,7 @@ impl Plugin for TablePlugin { .add_systems( Update, ( + on_safe_area_changed.before(LayoutSystem::UpdateOnResize), on_window_resized.in_set(LayoutSystem::UpdateOnResize), apply_theme_on_settings_change, apply_hint_pile_highlight, @@ -146,6 +148,7 @@ fn setup_table( existing_camera: Query<(), With>, settings: Option>, bg_images: Option>, + safe_area: Option>, ) { // Only spawn a camera if one does not already exist (e.g. a parent app // may have added one in tests). @@ -153,11 +156,15 @@ fn setup_table( commands.spawn(Camera2d); } - let window_size = windows - .iter() - .next() - .map_or(Vec2::new(1280.0, 800.0), default_window_size); - let layout = compute_layout(window_size); + let (window_size, scale) = windows.iter().next().map_or( + (Vec2::new(1280.0, 800.0), 1.0f32), + |w| (default_window_size(w), w.scale_factor()), + ); + // 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); @@ -279,6 +286,8 @@ fn spawn_pile_markers(commands: &mut Commands, layout: &Layout) { #[allow(clippy::type_complexity)] fn on_window_resized( mut events: MessageReader, + safe_area: Option>, + windows: Query<&Window>, mut layout_res: Option>, mut backgrounds: Query< (&mut Sprite, &mut Transform), @@ -290,7 +299,9 @@ fn on_window_resized( return; }; 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() { layout_res.0 = new_layout.clone(); @@ -318,6 +329,33 @@ fn on_window_resized( // 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>, + windows: Query<(Entity, &Window)>, + mut resize_events: MessageWriter, +) { + 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 // ---------------------------------------------------------------------------