From 92a5ebb15ed3a3aeaea39a9153a54f9be213e45b Mon Sep 17 00:00:00 2001 From: funman300 Date: Sun, 10 May 2026 20:48:56 -0700 Subject: [PATCH] fix(android): lower MIN_WINDOW floor so phone viewports lay out correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compute_layout` runs `window.max(MIN_WINDOW)`, which acts as a component-wise floor: any window smaller than MIN_WINDOW on either axis gets clamped up. The previous floor of 800x600 was set with desktop in mind, but on Android the OS-provided window size is the device resolution (~360 dp wide on a typical phone) and the clamp silently re-laid the board for an 800 dp width. Side effect: total grid width (9 * card_width) became ~800 px on a 360 dp viewport, so the leftmost foundation x-position fell past -180 and the rightmost tableau pile past +180 — both clipped at the visible edges, matching the v0.22.3 hardware screenshot. Lowered MIN_WINDOW to 320x400, below the smallest reasonable phone (~360x640), so every real device flows through compute_layout unclamped. The floor is preserved as a sentinel against degenerate windows (Bevy can briefly report 0-size during startup or after minimisation on some compositors). Desktop's "minimum supported playable size" is enforced separately via WindowResizeConstraints in solitaire_app. Updates `layout_below_minimum_clamps_to_minimum` to use values below the new floor, and adds a new regression test `phone_portrait_layout_fits_horizontally` that asserts all 13 piles fit inside a 360 x 800 dp viewport. Closes P0 #4 of docs/android/PLAYABILITY_TODO.md. 855 engine tests pass; clippy clean. Co-Authored-By: Claude Sonnet 4.6 --- docs/android/PLAYABILITY_TODO.md | 13 +++++--- solitaire_engine/src/layout.rs | 52 +++++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/docs/android/PLAYABILITY_TODO.md b/docs/android/PLAYABILITY_TODO.md index ecab1b9..0d26b7a 100644 --- a/docs/android/PLAYABILITY_TODO.md +++ b/docs/android/PLAYABILITY_TODO.md @@ -54,10 +54,15 @@ rewrites required. 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"))]`. -- [ ] **Viewport overflow.** Leftmost foundation and rightmost tableau - pile clipped. `LayoutResource` must recompute on Android using - actual surface size (post-inset) instead of any desktop default - width assumption. +- [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 diff --git a/solitaire_engine/src/layout.rs b/solitaire_engine/src/layout.rs index 33f2444..4788ddc 100644 --- a/solitaire_engine/src/layout.rs +++ b/solitaire_engine/src/layout.rs @@ -21,9 +21,25 @@ pub enum LayoutSystem { UpdateOnResize, } -/// Minimum supported window dimensions. Layout is still computed below this -/// size but cards will be small. -pub const MIN_WINDOW: Vec2 = Vec2::new(800.0, 600.0); +/// Minimum window dimensions used as a layout floor. +/// +/// `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. /// @@ -205,11 +221,39 @@ mod tests { #[test] 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); 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] fn tableau_columns_are_sorted_left_to_right() { let layout = compute_layout(Vec2::new(1280.0, 800.0));