feat(app): Wayland support + monitor-relative default window size

Two related platform-fit fixes for desktop launch:

1. Wayland session compatibility. The workspace Cargo.toml's
   Bevy feature list previously enabled only `x11`, leaving
   winit-on-Wayland to fall through to XWayland — the game
   rendered inside an X11 frame stitched into the Wayland
   compositor instead of as a native Wayland client. Adding
   the `wayland` feature lets winit prefer Wayland when
   WAYLAND_DISPLAY is set on the session, falling back to X11
   when it isn't. Costs a few hundred KB of binary for the
   libwayland-client bindings; comment in Cargo.toml explains
   the trade.

2. Smart default window sizing. The fallback window size for
   first launches (no saved geometry) was a fixed 1280x800. On
   a 4K monitor that's a comparatively tiny window in one
   corner; the game's cards then occupy a small physical area
   even though the screen has plenty of room. New
   `apply_smart_default_window_size` Update system queries
   `Monitor` (with the `PrimaryMonitor` marker) and resizes the
   primary window to ~70% of the monitor's *logical* size on
   the first frame. Logical size already factors in the OS's
   HiDPI scale factor, so:

   - 1920x1080 / 1.0 scale → 1344x756 target
   - 2560x1440 / 1.0 scale → 1792x1008 target
   - 3840x2160 / 1.0 scale → 2688x1512 target
   - 2880x1800 / 2.0 scale (Retina) → 1008x630 target
                  (same physical size as 1080p)

   Clamped to the existing 800x600 minimum so old systems
   don't get sub-minimum windows. Skipped entirely when saved
   geometry was applied — the player's chosen size always
   wins. Uses `Local<bool>` for one-shot semantics; the early-
   exit per tick costs nothing once `*applied` is true.

Workspace: 1170 passing tests / 0 failing. cargo clippy
--workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-06 19:49:52 -07:00
parent 0b3140ad6d
commit b57db017d3
3 changed files with 321 additions and 17 deletions
+79 -5
View File
@@ -3,7 +3,9 @@ use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*;
use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
use bevy::window::{
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, WindowPosition,
};
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
@@ -43,8 +45,10 @@ fn main() {
// Restore the previous window geometry if the player has one saved.
// Otherwise open at the platform default (1280×800, centred on the
// primary monitor). The window_geometry field is None on first run
// and after upgrading from a build that didn't persist geometry.
// primary monitor) — `apply_smart_default_window_size` will resize
// up to a monitor-relative target on the first frame so HiDPI / 4K
// sessions don't end up with a comparatively tiny window.
let had_saved_geometry = settings.window_geometry.is_some();
let (window_resolution, window_position) = match settings.window_geometry {
Some(geom) => (
(geom.width, geom.height).into(),
@@ -141,8 +145,78 @@ fn main() {
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(UiTooltipPlugin)
.add_plugins(SplashPlugin)
.run();
.add_plugins(SplashPlugin);
// Smart default window sizing: when no saved geometry was loaded,
// resize the freshly-opened 1280×800 window to ~70 % of the primary
// monitor's logical size on the first frame. Without this, a 4K
// monitor opens the same 1280×800 window that a 1080p monitor
// does — visually tiny relative to screen. Skipped entirely when
// saved geometry was applied; the player's preference always wins.
if !had_saved_geometry {
app.add_systems(Update, apply_smart_default_window_size);
}
app.run();
}
/// One-shot Update system that runs only on launches without saved
/// window geometry. Resizes the primary window to a fraction of the
/// primary monitor's *logical* size — bigger monitors get bigger
/// windows automatically. Logical size already accounts for the OS's
/// HiDPI scale factor, so a 2880×1800 Retina display reporting
/// scale_factor 2.0 yields a 1440×900 logical size and a 1008×630
/// target window — same physical inches as a 1920×1080 monitor with
/// scale_factor 1.0 yielding 1344×756.
///
/// Uses `Local<bool>` to make itself one-shot rather than introducing
/// a dedicated resource. The Update tick is necessary because Bevy
/// populates the `Monitor` entities asynchronously after winit's
/// Resumed event fires; they may not exist on the first Startup pass.
fn apply_smart_default_window_size(
mut applied: Local<bool>,
monitors: Query<&Monitor, With<PrimaryMonitor>>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
) {
if *applied {
return;
}
let Ok(monitor) = monitors.single() else {
// Primary monitor not yet spawned by bevy_winit. Try again
// next frame; the cost is one early-exit per tick until
// monitors arrive (typically frame 1 or 2).
return;
};
let Ok(mut window) = windows.single_mut() else {
return;
};
let scale = monitor.scale_factor as f32;
if scale <= 0.0 {
// Defensive: a zero or negative scale factor would NaN the
// arithmetic below. Bail and accept the default size.
*applied = true;
return;
}
let logical_w = monitor.physical_width as f32 / scale;
let logical_h = monitor.physical_height as f32 / scale;
// Target 70 % of monitor in each dimension, clamped to the
// existing 800×600 minimum and the monitor's own logical size
// (so we never request a window larger than the screen).
let target_w = (logical_w * 0.7).clamp(800.0, logical_w);
let target_h = (logical_h * 0.7).clamp(600.0, logical_h);
// Resize only when the change is meaningful — at exactly 1280×800
// on a 1920×1080 monitor the new target is 1344×756 (only ~5 %
// wider), worth the resize; at the same default on an 800×600
// monitor the clamp pins us at 800×600 and we shouldn't resize.
let curr_w = window.resolution.width();
let curr_h = window.resolution.height();
if (curr_w - target_w).abs() > 8.0 || (curr_h - target_h).abs() > 8.0 {
window.resolution.set(target_w, target_h);
}
*applied = true;
}
/// Wraps the default panic hook with one that also appends a crash log