Compare commits

..

2 Commits

Author SHA1 Message Date
funman300 1719fdada0 perf(engine): in-place resize updates and 50ms throttle eliminate drag lag
CI / Test & Lint (push) Failing after 24s
CI / Release Build (push) Has been skipped
Smoke-test report: dragging the window edge to resize was sluggish.
Profiling showed each WindowResized event triggered ~170 entity
mutations across all 52 cards: full Sprite regeneration via
card_sprite plus despawn_related on each card's CardLabel children
followed by a fresh with_children spawn — and WindowResized fires per
pixel of drag, multiplying the cost.

Three fixes layered together:

1. resize_cards_in_place is a new function the resize handler calls
   instead of sync_cards. It mutates Sprite.custom_size, the card's
   Transform.translation, and existing CardLabel TextFont.font_size
   directly — no Sprite replacement, no despawn_related, no child
   rebuild. update_card_entity stays unchanged for non-resize callers
   (deals, moves, flips, settings changes) so the full-repaint path
   they need is preserved.

2. collect_resize_events reads events.read().last() and stashes only
   the latest size into a ResizeThrottle resource each frame, so
   multiple WindowResized events in one frame collapse to one apply.

3. snap_cards_on_window_resize is gated by a 50ms throttle
   (RESIZE_THROTTLE_SECS): work runs at ~20 Hz during a sustained
   drag instead of ~120 Hz. When the user stops resizing the next
   frame flushes the final pending size, so the steady state always
   matches the released window dimensions. should_apply_resize is a
   pure helper unit-tested for the threshold-and-baseline contract.

apply_stock_empty_indicator gained a QueryFilter generic so the new
resize handler can pass a Without<CardEntity> filter — the resize
query already takes &mut Sprite on cards, so the indicator query had
to disjoin to avoid aliasing.

Five new tests pin the contract: should_apply_resize at three
threshold boundaries, plus integration tests that fire WindowResized
and assert no CardLabel entities were despawned and that
TextFont.font_size shrinks in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 01:06:14 +00:00
funman300 8dda9541a3 fix(engine): constrain card size so worst-case tableau fits vertically
The previous formula card_width = window.x / 9 with card_height = 1.4 *
card_width ignored the window height entirely. On a 1920×1080 window a
13-card face-up tableau column extended ~377 px below the viewport
bottom — visible reproduction in the smoke test.

compute_layout now derives two card_width candidates: one from the
horizontal grid budget (window.x / 9, unchanged) and one from the
vertical budget needed to seat 13 fanned cards plus the foundation
row, vertical_gap, and h_gap bottom margin. The smaller of the two
wins, so width remains the limiter on standard landscape windows and
height takes over on tall or short-wide aspect ratios. The math is
solved algebraically in a single substitution to avoid iteration.

When height is the limiter the original layout would have squished the
grid against the left edge; col_x now folds in a horizontal centring
offset that collapses to the existing geometry whenever width is the
limiter, so no other module needed an update.

Adds MAX_TABLEAU_CARDS = 13.0 (King-down-to-Ace worst case) and a
locally mirrored TABLEAU_FAN_FRAC = 0.25 — the original lives in
card_plugin and importing it would have created a circular dep with
layout. The duplication is doc-flagged so future drift gets noticed.

Four new tests pin both regimes: the height-limiter activates on a
1920×1080 window, stays inactive on a 900×1600 portrait window, and
the worst-case 13-card column fits on both 1280×800 and 1920×1080
within the bottom margin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 01:05:57 +00:00
2 changed files with 418 additions and 43 deletions
+302 -37
View File
@@ -161,6 +161,40 @@ const FLIP_HALF_SECS: f32 = 0.08;
#[derive(Component, Debug)]
pub struct ShadowEntity;
/// Throttle interval for resize-driven card snap work, in seconds.
///
/// `WindowResized` fires once per pixel of drag, so a fast corner-drag can
/// produce dozens of events per frame. Re-running the per-card snap logic
/// (52 cards × sprite/transform/font_size touches) for every event is the
/// dominant cost of resize lag. We coalesce pending work and apply it at most
/// once per [`RESIZE_THROTTLE_SECS`] (~20 Hz). The user still sees updates
/// during a sustained drag, and the layout always catches up to the final
/// size when the drag stops because the pending size is held until applied.
const RESIZE_THROTTLE_SECS: f32 = 0.05;
/// Holds the latest pending window size from `WindowResized` events plus a
/// timestamp for the last applied snap, so the resize-snap work can be
/// rate-limited to ~20 Hz during sustained drags.
#[derive(Resource, Debug, Default)]
pub struct ResizeThrottle {
/// Latest unapplied window size from `WindowResized`. `None` when there is
/// nothing to apply.
pub pending: Option<Vec2>,
/// `Time::elapsed_secs()` value at the moment of the most recent applied
/// snap. `0.0` until the first apply.
pub last_applied_secs: f32,
}
/// Pure helper used by the throttled resize-snap system: returns `true` when
/// a pending resize should be flushed given the current `now_secs` and the
/// last-applied timestamp. Throttle interval is [`RESIZE_THROTTLE_SECS`].
///
/// Extracted so the rate-limit logic can be unit-tested without spinning up
/// a full Bevy app.
fn should_apply_resize(now_secs: f32, last_applied_secs: f32) -> bool {
(now_secs - last_applied_secs) >= RESIZE_THROTTLE_SECS
}
/// Renders cards by reading `GameStateResource` on `StateChangedEvent`.
pub struct CardPlugin;
@@ -173,6 +207,7 @@ impl Plugin for CardPlugin {
// `MinimalPlugins` (tests) this resource is absent by default, so we
// ensure it exists here. Under `DefaultPlugins` the call is a no-op.
app.init_resource::<ButtonInput<MouseButton>>()
.init_resource::<ResizeThrottle>()
.add_message::<SettingsChangedEvent>()
.add_message::<CardFlippedEvent>()
.add_message::<CardFaceRevealedEvent>()
@@ -192,7 +227,8 @@ impl Plugin for CardPlugin {
clear_right_click_highlights_on_state_change.after(GameMutation),
clear_right_click_highlights_on_pause,
update_stock_empty_indicator.after(GameMutation),
snap_cards_on_window_resize.after(LayoutSystem::UpdateOnResize),
collect_resize_events.after(LayoutSystem::UpdateOnResize),
snap_cards_on_window_resize.after(collect_resize_events),
),
);
}
@@ -1023,10 +1059,10 @@ const STOCK_NORMAL_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
/// spawned (if not already present). When the stock is non-empty the marker is
/// restored to `STOCK_NORMAL_COLOUR` and any `StockEmptyLabel` children are
/// despawned.
fn apply_stock_empty_indicator(
fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
commands: &mut Commands,
game: &GameState,
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite), F>,
label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
layout: &Layout,
) {
@@ -1116,61 +1152,89 @@ fn update_stock_empty_indicator(
);
}
/// Snaps every card sprite to its target position and size when the window
/// is resized.
/// Coalesces every `WindowResized` event arriving this frame into the latest
/// pending size on [`ResizeThrottle`].
///
/// This replaces the old "fire `StateChangedEvent` from `on_window_resized`"
/// path. That path went through `sync_cards_on_change` → `update_card_entity`,
/// which inserts a `CardAnim` slide tween whenever the card moves more than
/// 1 unit. During a corner drag, every frame's `WindowResized` event
/// retargeted the tween from the card's mid-slide position, so cards never
/// reached steady state — the visible "snap back and forth" jitter.
/// `WindowResized` fires per pixel of resize drag, so a fast corner drag can
/// emit many events per frame. Reading `.last()` keeps only the final size —
/// every frame's snap target is the most recent window size, never a stale
/// one. Pending stays set across frames until the throttled applier consumes
/// it; that's how we still flush the final "release" position when the user
/// stops dragging.
fn collect_resize_events(
mut events: MessageReader<WindowResized>,
mut throttle: ResMut<ResizeThrottle>,
) {
if let Some(ev) = events.read().last() {
throttle.pending = Some(Vec2::new(ev.width, ev.height));
}
}
/// Snaps every card sprite to its target position, size, and (in the
/// fallback Text2d label path) font size when the window is resized.
///
/// Calls `sync_cards` with `slide_secs = 0.0` so `update_card_entity` snaps
/// instantly (line `(cur - target).length() > 1.0 && slide_secs > 0.0` falls
/// to the snap branch), refreshes the `Sprite` with the new
/// `layout.card_size` (so cards visibly resize, not just reposition), and
/// removes any in-flight `CardAnim`.
/// **In-place mutation only.** Resize is the hot path — events fire per
/// pixel of drag, so this system cannot afford the despawn/respawn churn
/// `update_card_entity` does. We mutate `Sprite.custom_size`, `Transform`,
/// and child `TextFont.font_size` directly, leaving the card image handle,
/// suit/rank, and `CardLabel` entity untouched. Cards keep their identity
/// across resizes; only their size and position change. The full repaint
/// path lives in [`update_card_entity`] and is still used by every non-resize
/// caller (deals, moves, flips, settings toggles).
///
/// **Throttled to ~20 Hz.** [`ResizeThrottle::pending`] is consumed at most
/// once per [`RESIZE_THROTTLE_SECS`]. When events stop arriving, the next
/// tick past the throttle window flushes the final size and clears
/// `pending`, so the steady-state always matches the user's release size.
///
/// **Cancels in-flight slides.** Any `CardAnim` is removed so a mid-slide
/// tween is not retargeted relative to the previous card-size's position.
///
/// The "↺" stock-empty label's `font_size` is derived from
/// `layout.card_size.x`, so this system also reapplies the stock indicator —
/// otherwise the label would not rescale on resize once
/// `update_stock_empty_indicator` stopped firing on resize.
/// otherwise the label would not rescale on resize.
///
/// Scheduled `.after(LayoutSystem::UpdateOnResize)` so `LayoutResource` has
/// been refreshed by `table_plugin::on_window_resized` before this runs.
/// Scheduled after [`collect_resize_events`] (which itself runs after
/// `LayoutSystem::UpdateOnResize`) so `LayoutResource` reflects the latest
/// window size before we read it.
#[allow(clippy::too_many_arguments)]
fn snap_cards_on_window_resize(
mut events: MessageReader<WindowResized>,
mut commands: Commands,
time: Res<Time>,
mut throttle: ResMut<ResizeThrottle>,
game: Option<Res<GameStateResource>>,
layout: Option<Res<LayoutResource>>,
settings: Option<Res<SettingsResource>>,
card_images: Option<Res<CardImageSet>>,
entities: Query<(Entity, &CardEntity, &Transform)>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
entities: Query<(Entity, &CardEntity, &mut Sprite, &mut Transform), Without<CardLabel>>,
label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite), Without<CardEntity>>,
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
) {
if events.read().next().is_none() {
if throttle.pending.is_none() {
return;
}
let now = time.elapsed_secs();
if !should_apply_resize(now, throttle.last_applied_secs) {
return;
}
let Some(game) = game else { return };
let Some(layout) = layout else { return };
let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
let back_colour = card_back_colour(selected_back);
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
let Some(game) = game else {
// Nothing to apply — clear pending so we don't busy-loop.
throttle.pending = None;
return;
};
let Some(layout) = layout else {
throttle.pending = None;
return;
};
sync_cards(
commands.reborrow(),
resize_cards_in_place(
&mut commands,
&game.0,
&layout.0,
0.0,
back_colour,
color_blind,
&entities,
card_images.as_deref(),
selected_back,
entities,
label_query,
);
apply_stock_empty_indicator(
@@ -1180,6 +1244,59 @@ fn snap_cards_on_window_resize(
&label_children,
&layout.0,
);
throttle.last_applied_secs = now;
throttle.pending = None;
}
/// In-place "size-only" sibling of [`sync_cards`]: walks every existing card
/// entity, updates `Sprite.custom_size` and the snap-`Transform` to match the
/// fresh layout, and (in fallback solid-colour mode) also updates the child
/// `TextFont.font_size` of any `CardLabel`. No despawning, no `Sprite`
/// replacement, no children rebuild — that's the entire point of this path.
///
/// Called only from the resize handler. Game-state changes (deals, moves,
/// flips, settings toggles) still flow through [`sync_cards`] /
/// [`update_card_entity`], which handle add/remove/repaint correctly.
///
/// Any in-flight `CardAnim` slide is removed so a mid-tween card is not
/// retargeted relative to the previous card-size's position.
fn resize_cards_in_place(
commands: &mut Commands,
game: &GameState,
layout: &Layout,
card_images: Option<&CardImageSet>,
mut entities: Query<(Entity, &CardEntity, &mut Sprite, &mut Transform), Without<CardLabel>>,
mut label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
) {
let positions = card_positions(game, layout);
let pos_by_id: HashMap<u32, (Vec2, f32)> = positions
.into_iter()
.map(|(c, p, z)| (c.id, (p, z)))
.collect();
for (entity, marker, mut sprite, mut transform) in entities.iter_mut() {
let Some(&(pos, z)) = pos_by_id.get(&marker.card_id) else {
continue;
};
sprite.custom_size = Some(layout.card_size);
transform.translation.x = pos.x;
transform.translation.y = pos.y;
transform.translation.z = z;
// Cancel any in-flight slide so it doesn't retarget from a stale
// mid-animation position computed against the previous card size.
commands.entity(entity).remove::<CardAnim>();
}
// Only the solid-colour fallback path uses CardLabel/Text2d overlays;
// when PNG faces are loaded the rank/suit are baked into the image and
// there is nothing to resize on the label side.
if card_images.is_none() {
let new_font_size = layout.card_size.x * FONT_SIZE_FRAC;
for mut font in label_query.iter_mut() {
font.font_size = new_font_size;
}
}
}
#[cfg(test)]
@@ -1654,4 +1771,152 @@ mod tests {
"tighter face-down fan should reduce column span ({actual_span:.1} >= uniform {uniform_span:.1})"
);
}
// -----------------------------------------------------------------------
// Resize-lag fix — throttle helper + in-place mutation regression tests
// -----------------------------------------------------------------------
#[test]
fn should_apply_resize_returns_false_below_threshold() {
// 0 elapsed since last apply: still inside the throttle window.
assert!(!should_apply_resize(0.0, 0.0));
// Just under the threshold: still throttled.
assert!(!should_apply_resize(RESIZE_THROTTLE_SECS - 0.001, 0.0));
}
#[test]
fn should_apply_resize_returns_true_at_or_past_threshold() {
// Exactly at the threshold the work should fire.
assert!(should_apply_resize(RESIZE_THROTTLE_SECS, 0.0));
// Comfortably past the threshold: definitely fire.
assert!(should_apply_resize(1.0, 0.0));
}
#[test]
fn should_apply_resize_uses_last_applied_as_baseline() {
// After an apply at t=10.0, a subsequent check at t=10.04 is still
// throttled (under the 50 ms window).
assert!(!should_apply_resize(10.04, 10.0));
// At t=10.05 the next apply is allowed.
assert!(should_apply_resize(10.05, 10.0));
}
/// Helper: drive enough `app.update()` ticks at 200 ms each to comfortably
/// exceed the throttle window. `Time<Virtual>` clamps each delta to
/// `max_delta` (default 250 ms) regardless of the requested step, so we
/// step in 200 ms slices.
fn advance_past_resize_throttle(app: &mut App) {
use bevy::time::TimeUpdateStrategy;
use std::time::Duration;
app.insert_resource(TimeUpdateStrategy::ManualDuration(
Duration::from_secs_f32(0.2),
));
// One tick to advance Time, plus one extra so the snap system runs
// after the throttle window has elapsed.
app.update();
app.update();
}
fn fire_window_resize(app: &mut App, width: f32, height: f32) {
// Any Entity will do — the snap system reads only width/height.
let window = bevy::ecs::entity::Entity::from_raw_u32(0)
.expect("Entity::from_raw_u32(0) is a valid placeholder");
app.world_mut().write_message(WindowResized {
window,
width,
height,
});
}
#[test]
fn resize_does_not_despawn_card_labels() {
// Spawn a fresh app, capture the current set of CardLabel entity IDs,
// fire a WindowResized, run the throttled snap, and assert *every*
// captured label still exists. The whole point of the in-place resize
// path is that it doesn't despawn-and-respawn label children — old
// entity IDs must remain alive.
let mut app = app();
let labels_before: std::collections::HashSet<bevy::prelude::Entity> = app
.world_mut()
.query_filtered::<bevy::prelude::Entity, With<CardLabel>>()
.iter(app.world())
.collect();
assert!(
!labels_before.is_empty(),
"fixture should have spawned CardLabel children in the fallback solid-colour path"
);
fire_window_resize(&mut app, 1024.0, 768.0);
advance_past_resize_throttle(&mut app);
let labels_after: std::collections::HashSet<bevy::prelude::Entity> = app
.world_mut()
.query_filtered::<bevy::prelude::Entity, With<CardLabel>>()
.iter(app.world())
.collect();
// Same set of entities — no entity was despawned. (Bevy reuses
// indices but bumps generations on despawn, so direct Entity equality
// is sufficient here.)
for e in &labels_before {
assert!(
labels_after.contains(e),
"CardLabel entity {e:?} was despawned by the resize handler — \
expected the in-place path to leave label entities untouched"
);
}
}
#[test]
fn resize_in_place_updates_card_label_font_size() {
// Capture an arbitrary CardLabel's TextFont.font_size before resize,
// fire a WindowResized to a *smaller* window, run the throttled snap,
// and assert the font_size shrank. This proves the in-place path
// actually mutates the existing TextFont (rather than skipping it or
// falling back to despawn/respawn).
let mut app = app();
// Read the first CardLabel's font size.
let mut q = app
.world_mut()
.query_filtered::<&TextFont, With<CardLabel>>();
let before = q
.iter(app.world())
.next()
.expect("fixture should have at least one CardLabel")
.font_size;
assert!(before > 0.0, "baseline font size must be positive, got {before}");
// Resize to a window smaller than the default fixture so the
// computed font size is unambiguously smaller.
fire_window_resize(&mut app, 800.0, 600.0);
advance_past_resize_throttle(&mut app);
let mut q = app
.world_mut()
.query_filtered::<&TextFont, With<CardLabel>>();
let after = q
.iter(app.world())
.next()
.expect("CardLabel must still exist after in-place resize")
.font_size;
assert!(
after < before,
"smaller window should shrink CardLabel font size in place \
(before={before}, after={after})"
);
// 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 = expected_layout.card_size.x * FONT_SIZE_FRAC;
assert!(
(after - expected).abs() < 1e-3,
"after-resize font size should equal layout.card_size.x * FONT_SIZE_FRAC \
(got {after}, expected {expected})"
);
}
}
+116 -6
View File
@@ -33,6 +33,16 @@ const CARD_ASPECT: f32 = 1.4;
/// the tableau row.
const VERTICAL_GAP_FRAC: f32 = 0.2;
/// Fraction of card height contributed by each additional face-up tableau card
/// when fanned. Mirrors `card_plugin::TABLEAU_FAN_FRAC` so layout sizing can
/// solve for a worst-case column without depending on `card_plugin`.
const TABLEAU_FAN_FRAC: f32 = 0.25;
/// Largest possible face-up tableau column in Klondike: a King down to an Ace
/// after every face-down card has flipped on column 7. Layout sizing must keep
/// this column inside the visible window.
const MAX_TABLEAU_CARDS: f32 = 13.0;
/// Table background colour (dark green felt).
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
@@ -55,8 +65,13 @@ pub struct Layout {
/// Compute the board layout from a window size.
///
/// # Geometry
/// - `card_width = window.x / 9.0` — seven tableau columns with eight gaps
/// (two outer margins + six inner).
/// - `card_width` is the smaller of:
/// - `window.x / 9.0` — seven tableau columns with eight gaps (two outer
/// margins + six inner). This is the limiter on landscape windows.
/// - the height-based candidate that keeps a worst-case fanned tableau
/// column (13 face-up cards, see [`MAX_TABLEAU_CARDS`]) inside the
/// window with a bottom margin equal to `h_gap`. Limiter on tall/narrow
/// windows.
/// - `card_height = card_width * 1.4`.
/// - Horizontal gap `h_gap = card_width / 4.0`.
/// - Top row (stock, waste, 4 foundations) aligns with tableau columns
@@ -65,16 +80,43 @@ pub struct Layout {
pub fn compute_layout(window: Vec2) -> Layout {
let window = window.max(MIN_WINDOW);
let card_width = window.x / 9.0;
// Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width.
let card_width_width_based = window.x / 9.0;
// Height-based candidate. The vertical budget below the top row must hold
// a worst-case fanned tableau column plus a bottom margin equal to h_gap.
//
// Letting w = card_width and h = w * CARD_ASPECT, the vertical layout is:
// top edge of window = +window.y / 2
// top of top-row card = window.y/2 - h_gap (h_gap top margin)
// centre of top-row card = window.y/2 - h_gap - h/2
// centre of tableau card = top centre - h - vertical_gap (vertical_gap = VERTICAL_GAP_FRAC * h)
// bottom of last fanned = tableau_centre + h/2 - fan_factor * h
// where fan_factor = 1 + (MAX_TABLEAU_CARDS - 1) * TABLEAU_FAN_FRAC
// bottom of window = -window.y / 2; require bottom-of-fanned >= -window.y/2 + h_gap
//
// Substituting h_gap = w/4 and h = CARD_ASPECT * w and solving for the
// largest w that fits gives:
// window.y = 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 / height_denom;
let card_width = card_width_width_based.min(card_width_height_based);
let card_height = card_width * CARD_ASPECT;
let card_size = Vec2::new(card_width, card_height);
let h_gap = card_width / 4.0;
// With h_gap = card_width/4, total width = 7*card_width + 8*h_gap = 9*card_width.
// Leftmost card's centre sits at: -window.x/2 + h_gap + card_width/2.
// Total occupied width = 7*card_width + 8*h_gap = 9*card_width. When card
// sizing is height-limited (tall/narrow windows), this is smaller than
// window.x, so the grid is centred horizontally; otherwise side_margin
// collapses to h_gap and the geometry matches the original width-based
// layout exactly.
let total_grid_width = 9.0 * card_width;
let side_margin = (window.x - total_grid_width) / 2.0 + h_gap;
let left_edge = -window.x / 2.0;
let col_x = |col: usize| -> f32 {
left_edge + h_gap + card_width / 2.0 + (col as f32) * (card_width + h_gap)
left_edge + side_margin + card_width / 2.0 + (col as f32) * (card_width + h_gap)
};
let vertical_gap = card_height * VERTICAL_GAP_FRAC;
@@ -202,6 +244,74 @@ mod tests {
}
}
#[test]
fn short_wide_window_constrains_card_width_via_height() {
// Short wide window: vertical budget is the bottleneck, so card_width
// must be strictly smaller than the naive window.x / 9 candidate to
// 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 width_based = window.x / 9.0;
assert!(
layout.card_size.x < width_based,
"expected height to be the limiter (card_width {} should be < width-based candidate {})",
layout.card_size.x,
width_based
);
}
#[test]
fn tall_narrow_window_keeps_width_based_sizing() {
// Tall narrow window: there's plenty of vertical budget, so width is
// 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 width_based = window.x / 9.0;
assert!(
(layout.card_size.x - width_based).abs() < 1e-3,
"expected width-based sizing (card_width {} should equal {})",
layout.card_size.x,
width_based
);
}
#[test]
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 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.
let bottom_edge = tableau_y - 12.0 * card_h * TABLEAU_FAN_FRAC - card_h / 2.0;
// Bottom of the visible window with the same h_gap-sized margin used at
// the top.
let h_gap = layout.card_size.x / 4.0;
let window_bottom_with_margin = -window.y / 2.0 + h_gap;
assert!(
bottom_edge >= window_bottom_with_margin - 1e-3,
"worst-case tableau bottom {bottom_edge} overflows window margin {window_bottom_with_margin}"
);
}
#[test]
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 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;
let h_gap = layout.card_size.x / 4.0;
let window_bottom_with_margin = -window.y / 2.0 + h_gap;
assert!(
bottom_edge >= window_bottom_with_margin - 1e-3,
"worst-case tableau bottom {bottom_edge} overflows window margin {window_bottom_with_margin}"
);
}
#[test]
fn all_piles_fit_inside_window_horizontally() {
for window in [