Compare commits

..

6 Commits

Author SHA1 Message Date
funman300 005efa2ee4 Merge pull request 'fix(engine): re-poll safe-area insets after app resume' (#121) from fix/safe-area-resume-repoll into master
Build and Deploy / build-and-push (push) Successful in 3m18s
Web WASM Rebuild / rebuild (push) Successful in 8m43s
2026-07-06 19:52:59 +00:00
funman300 1190ed3ce6 fix(engine): re-poll safe-area insets after app resume
refresh_insets gated its loop on insets.is_populated(), so once insets
resolved at first launch, rearm_on_resumed's poll-counter reset was a
no-op and JNI was never queried again. Insets that changed while the
app was backgrounded (fold/unfold, rotation, gesture/3-button nav
switch) stayed stale until process restart.

Gate the loop on the poll counter alone and settle a cycle by
exhausting it once a populated reading arrives. The cached resource is
rewritten only when the value actually differs, so resumes where
nothing moved trigger no change detection and no relayout — preserving
the no-flash resume behaviour. Also correct the stale on_app_resumed
doc that still described the old inset-zeroing approach.

Closes #116

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:50:01 -07:00
Gitea CI 2869e1c34e chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 5m50s
Web E2E / web-e2e (push) Successful in 4m55s
2026-06-26 21:25:24 +00:00
funman300 783f01628e Merge pull request 'fix(engine): raise dynamic tableau fan cap to fill tall viewports' (#115) from fix/cover-screen-fan-cap into master
Build and Deploy / build-and-push (push) Successful in 2m8s
Web WASM Rebuild / rebuild (push) Successful in 7m24s
Android Release / build-apk (push) Successful in 5m17s
2026-06-26 21:15:53 +00:00
funman300 94a6feb5db fix(engine): raise dynamic tableau fan cap to fill tall viewports
MAX_DYNAMIC_FAN_FRAC 0.6 -> 0.9 so the dynamic tableau fill spreads
further on very tall / narrow viewports (e.g. a foldable cover screen),
which were left ~40% empty at the cap. Fills the unfolded near-square
screen to ~100% and lets the cover screen fill further (and the rest fills
as columns deepen during play). Normal phones are unaffected — their fill
fraction is already below the cap. apply_dynamic_tableau_fan still floors
at TABLEAU_FAN_FRAC and deeper columns drive the fraction down, so nothing
overflows and hit-testing stays in sync.

Vertical centring of the residual was investigated but dropped: a 21:9
phone is aspect-identical to the cover screen, so centring can't be
targeted to foldables without also disconnecting the board from the HUD on
tall phones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:56:31 -07:00
Gitea CI 3daaf47689 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m3s
Web E2E / web-e2e (push) Successful in 4m47s
2026-06-26 20:20:59 +00:00
5 changed files with 51 additions and 39 deletions
+3 -1
View File
@@ -41,7 +41,9 @@ project follows [Semantic Versioning](https://semver.org/).
each column's total depth (face-down cards included) and refills on the
cold-start deal, every move, and on resize (incl. the Android safe-area-inset
resize and fold/unfold), so a near-square viewport such as an unfolded Galaxy
Fold no longer leaves the bottom of the screen empty.
Fold no longer leaves the bottom of the screen empty. The fan spread cap was
raised so very tall / narrow viewports (e.g. a foldable cover screen) fill
further.
- **Card-move animation jank.** A move now rebuilds a card's child visuals only
when its appearance changes (flip / resize / accessibility) instead of
despawning and respawning every card's children each `StateChangedEvent`,
+9 -13
View File
@@ -99,7 +99,7 @@ const MAX_TABLEAU_CARDS: f32 = 13.0;
/// clear overlap (a readable stack) while still filling most of a near-square /
/// unfolded-foldable screen. Tunable purely for feel — no effect on correctness
/// or hit-testing.
pub(crate) const MAX_DYNAMIC_FAN_FRAC: f32 = 0.6;
pub(crate) const MAX_DYNAMIC_FAN_FRAC: f32 = 0.9;
/// Vertical pixel band reserved at the top of the play area for the HUD
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
@@ -285,13 +285,12 @@ pub fn compute_layout(
);
}
// Adaptive tableau fan fraction. On height-limited (desktop) windows the
// height-based sizing already ensures a worst-case 13-card column fits at
// TABLEAU_FAN_FRAC (0.25), so the formula returns ≈0.25 and the clamp
// keeps it there — no change from prior behaviour. On width-limited
// (portrait phone) windows card_size is small and lots of vertical space
// is unused; we solve for the fraction that exactly fills the available
// space to the bottom margin.
// Adaptive tableau fan fraction. On height-limited windows the height-based
// sizing already ensures a worst-case 13-card column fits at TABLEAU_FAN_FRAC,
// so the formula returns the minimum and the clamp keeps it there. On
// width-limited (portrait phone) windows card_size is small and lots of
// vertical space is unused; solve for the fraction that fills the available
// space. `apply_dynamic_tableau_fan` later refines this for the actual deal.
//
// avail = distance from the top of the first tableau card to the bottom
// margin — i.e. the space available for 12 fan steps.
@@ -302,20 +301,17 @@ pub fn compute_layout(
} else {
TABLEAU_FAN_FRAC
};
// Never go below the desktop minimum — avoids shrinking the fan on
// degenerate near-square windows where the formula might undershoot.
let tableau_fan_frac = ideal_fan_frac.max(TABLEAU_FAN_FRAC);
// Scale the face-down fraction proportionally so rendering and hit-testing
// stay in sync (TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC = 0.48 ratio).
let facedown_scale = TABLEAU_FACEDOWN_FAN_FRAC / TABLEAU_FAN_FRAC;
let tableau_facedown_fan_frac = tableau_fan_frac * facedown_scale;
let available_tableau_height = avail;
Layout {
card_size,
pile_positions,
tableau_fan_frac,
tableau_facedown_fan_frac,
available_tableau_height: avail,
available_tableau_height,
}
}
+27 -13
View File
@@ -191,10 +191,12 @@ fn apply_safe_area_to_modal_scrims(
/// whatever `SafeAreaInsets` are current at that moment.
///
/// On Android the `android::rearm_on_resumed` system runs in the same frame
/// and resets both `SafeAreaPollTries` and `SafeAreaInsets` to zero, causing
/// `refresh_insets` to re-poll JNI over the next few frames. When it resolves
/// the correct values, `on_safe_area_changed` in `table_plugin` emits a second
/// synthetic `WindowResized` and the layout converges to the right position.
/// and resets `SafeAreaPollTries` (the cached `SafeAreaInsets` keep their
/// last-known values), causing `refresh_insets` to re-poll JNI over the next
/// few frames. If the insets changed while backgrounded, `on_safe_area_changed`
/// in `table_plugin` emits a second synthetic `WindowResized` and the layout
/// converges to the right position; if they didn't, nothing is rewritten and
/// the layout stays put.
///
/// On non-Android targets this handler still fires — it ensures that a resume
/// event always refreshes the layout (e.g., after a minimise/restore on
@@ -232,9 +234,14 @@ mod android {
pub(super) struct SafeAreaPollTries(pub u32);
/// 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 13 of a fresh launch.
/// reading, then settles until [`rearm_on_resumed`] re-arms it on the
/// next foreground resume — insets can change while backgrounded
/// (rotation, fold/unfold, gesture ↔ 3-button nav). The poll counter
/// (not `insets.is_populated()`) gates the loop, so a re-armed cycle
/// re-queries JNI even though cached values are already populated.
/// `getRootWindowInsets()` returns `null` (or all-zero `Insets`)
/// until the decor view has been laid out, which is typically frame
/// 13 of a fresh launch.
pub(super) fn refresh_insets(
mut insets: ResMut<SafeAreaInsets>,
mut poll: ResMut<SafeAreaPollTries>,
@@ -243,18 +250,25 @@ mod android {
// devices that genuinely report zero insets.
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
if poll.0 >= MAX_TRIES || insets.is_populated() {
if poll.0 >= MAX_TRIES {
return;
}
poll.0 += 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, poll.0
);
*insets = v;
if *insets != v {
info!(
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
v.top, v.bottom, v.left, v.right, poll.0
);
*insets = v;
}
// Settled for this poll cycle; `rearm_on_resumed` re-arms on
// the next resume. Writing `insets` only on an actual change
// keeps resource change detection (and the relayout it
// triggers) quiet on resumes where nothing moved.
poll.0 = MAX_TRIES;
}
Ok(_) => {
// Layout not ready yet; try again next frame.
+12 -12
View File
@@ -1649,62 +1649,62 @@ function __wbg_get_imports() {
return ret;
},
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 114855, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 114846, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hea4b5125da4e5ded);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 9847, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 9838, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0fec466277fb30e8);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_3);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_4);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_5);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_6);
return ret;
},
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_7);
return ret;
},
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_8);
return ret;
},
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 9841, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 9832, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2a0b90ad2a013a3e_9);
return ret;
},
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 9843, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 9834, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf05069bc44820cc5);
return ret;
},
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 9839, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 9830, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h5e26b448f43bfba9);
return ret;
},
Binary file not shown.