Merge pull request 'feat(engine): UI scale setting — 90/100/115/130% (Phase K)' (#182) from feat/ui-scale into master
Test / fmt (push) Successful in 5s
Test / test (push) Successful in 4m46s
Build and Deploy / build-and-push (push) Failing after 6m38s
Web E2E / web-e2e (push) Successful in 8m23s

This commit was merged in pull request #182.
This commit is contained in:
2026-07-14 01:45:01 +00:00
7 changed files with 189 additions and 7 deletions
+20
View File
@@ -275,6 +275,14 @@ pub struct Settings {
/// which marks the tip as unnecessary without showing it.
#[serde(default)]
pub shown_radial_menu_tip: bool,
/// Global UI scale multiplier applied to all UI chrome (HUD, modals,
/// action bar) — the table itself stays window-fit via
/// `compute_layout`. Cycles through 0.9 / 1.0 / 1.15 / 1.3 in
/// Settings → Accessibility; clamped to `[UI_SCALE_MIN,
/// UI_SCALE_MAX]` by [`Settings::sanitized`]. Older `settings.json`
/// files deserialize cleanly to `1.0` via the serde default.
#[serde(default = "default_ui_scale")]
pub ui_scale: f32,
/// Custom public name displayed on the leaderboard. When `None`, the
/// player's server `username` is used instead. Trimmed to 32 characters
/// before submission. Older `settings.json` files written before this
@@ -383,6 +391,16 @@ fn default_replay_move_interval_secs() -> f32 {
0.45
}
/// Lowest / highest UI scale the settings row offers; anything outside
/// (hand-edited settings.json) clamps here on load.
pub const UI_SCALE_MIN: f32 = 0.9;
/// See [`UI_SCALE_MIN`].
pub const UI_SCALE_MAX: f32 = 1.3;
fn default_ui_scale() -> f32 {
1.0
}
fn default_matomo_site_id() -> u32 {
1
}
@@ -446,6 +464,7 @@ impl Default for Settings {
last_seen_whats_new: String::new(),
shown_stall_hint_tip: false,
shown_radial_menu_tip: false,
ui_scale: default_ui_scale(),
leaderboard_display_name: None,
leaderboard_opted_in: false,
take_from_foundation: true,
@@ -481,6 +500,7 @@ impl Settings {
replay_move_interval_secs: self
.replay_move_interval_secs
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
ui_scale: self.ui_scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX),
selected_theme_id,
..self
}
+28 -7
View File
@@ -101,9 +101,10 @@ impl Plugin for SafeAreaInsetsPlugin {
fn apply_safe_area_anchors(
insets: Res<SafeAreaInsets>,
windows: Query<&Window>,
ui_scale: Option<Res<UiScale>>,
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
) {
if !insets.is_changed() {
if !insets.is_changed() && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
return;
}
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
@@ -119,19 +120,31 @@ fn apply_safe_area_anchors(
);
}
let top_logical = raw_top.min(max_inset);
// `bevy::ui::UiScale` (Phase K) multiplies every Val::Px at layout
// time. `base_top` is UI chrome and SHOULD scale with the rest of
// the interface, but the system-bar inset is physical reality —
// pre-divide it so the post-multiplication offset stays exact.
let ui = effective_ui_scale(ui_scale.as_deref());
for (anchor, mut node) in &mut q {
node.top = Val::Px(anchor.base_top + top_logical);
node.top = Val::Px(anchor.base_top + top_logical / ui);
}
}
/// The live UI scale, defensively clamped — a zero or negative scale
/// would flip or destroy the inset math.
fn effective_ui_scale(ui_scale: Option<&UiScale>) -> f32 {
ui_scale.map_or(1.0, |s| s.0.max(0.1))
}
/// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
fn apply_safe_area_bottom_anchors(
insets: Res<SafeAreaInsets>,
windows: Query<&Window>,
ui_scale: Option<Res<UiScale>>,
mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
) {
if !insets.is_changed() {
if !insets.is_changed() && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
return;
}
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
@@ -144,8 +157,12 @@ fn apply_safe_area_bottom_anchors(
);
}
let bottom_logical = raw_bottom.min(max_inset);
// See `apply_safe_area_anchors`: the physical inset is pre-divided
// by the UI scale so a 90% setting can't sink the action bar into
// the gesture zone (and 130% doesn't over-inset it).
let ui = effective_ui_scale(ui_scale.as_deref());
for (anchor, mut node) in &mut q {
node.bottom = Val::Px(anchor.base_bottom + bottom_logical);
node.bottom = Val::Px(anchor.base_bottom + bottom_logical / ui);
}
}
@@ -163,19 +180,23 @@ fn apply_safe_area_bottom_anchors(
fn apply_safe_area_to_modal_scrims(
insets: Res<SafeAreaInsets>,
windows: Query<&Window>,
ui_scale: Option<Res<UiScale>>,
mut scrims: Query<&mut Node, With<ModalScrim>>,
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
) {
let has_new = !new_scrims.is_empty();
if !insets.is_changed() && !has_new {
if !insets.is_changed() && !has_new && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
return;
}
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let window_height = windows.iter().next().map_or(800.0, |w| w.height());
// Clamp each inset to 25% of screen height so an unexpectedly large OS
// value can't push the modal card off the visible area entirely.
let top_logical = (insets.top / scale).min(window_height * 0.25);
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25);
// Physical insets are pre-divided by the UI scale — see
// `apply_safe_area_anchors`.
let ui = effective_ui_scale(ui_scale.as_deref());
let top_logical = (insets.top / scale).min(window_height * 0.25) / ui;
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25) / ui;
for mut node in &mut scrims {
// Set both edges so the scrim's content box equals the usable area
// between the status bar and the gesture/navigation bar. With
@@ -363,6 +363,14 @@ pub(super) fn handle_settings_buttons(
changed.write(SettingsChangedEvent(settings.0.clone()));
// Text refreshed by `update_touch_input_mode_text` next frame.
}
SettingsButton::CycleUiScale => {
settings.0.ui_scale = next_ui_scale(settings.0.ui_scale);
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// Text refreshed by `update_ui_scale_text`; the live
// `bevy::ui::UiScale` resource follows via
// `sync_ui_scale_resource` next frame.
}
SettingsButton::ToggleWinnableDealsOnly => {
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
persist(&path, &settings.0);
@@ -139,6 +139,10 @@ struct ReduceMotionText;
#[derive(Component, Debug)]
struct TouchInputModeText;
/// Marks the `Text` node showing the current UI scale percentage.
#[derive(Component, Debug)]
struct UiScaleText;
/// Marks the `Text` node showing the live tooltip-delay value.
#[derive(Component, Debug)]
struct TooltipDelayText;
@@ -277,6 +281,10 @@ enum SettingsButton {
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
/// a card/stack, second tap on a target pile moves it).
ToggleTouchInputMode,
/// Cycle [`Settings::ui_scale`] through 90 % → 100 % → 115 % →
/// 130 % → 90 %. Applied live via `bevy::ui::UiScale`; the table
/// stays window-fit (Phase K).
CycleUiScale,
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
/// random Classic-mode deals are filtered through
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
@@ -354,6 +362,7 @@ impl SettingsButton {
SettingsButton::ToggleHighContrast => 61,
SettingsButton::ToggleReduceMotion => 62,
SettingsButton::ToggleTouchInputMode => 63,
SettingsButton::CycleUiScale => 64,
// Picker rows — every swatch in a row shares the row's
// priority so entity-index tiebreaking yields left → right.
SettingsButton::SelectCardBack(_) => 70,
@@ -449,6 +458,11 @@ impl Plugin for SettingsPlugin {
handle_volume_keys,
record_window_geometry_changes,
persist_window_geometry_after_debounce,
// State sync, not UI — runs even under `headless()`
// so the live `bevy::ui::UiScale` always mirrors the
// setting; rides the mutator spine so it is ordered
// after every settings writer this frame.
sync_ui_scale_resource,
)
.chain()
.in_set(SettingsMutation),
@@ -492,6 +506,7 @@ impl Plugin for SettingsPlugin {
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
update_reduce_motion_text,
update_touch_input_mode_text,
update_ui_scale_text,
update_tooltip_delay_text,
update_time_bonus_multiplier_text,
update_replay_move_interval_text,
@@ -736,3 +736,60 @@ fn scroll_clamps_offset_to_zero_at_top() {
"scrolling past top must clamp to 0, got {offset}"
);
}
// ---------------------------------------------------------------------------
// Phase K: UI scale
// ---------------------------------------------------------------------------
#[test]
fn ui_scale_steps_cycle_and_wrap() {
assert_eq!(next_ui_scale(0.9), 1.0);
assert_eq!(next_ui_scale(1.0), 1.15);
assert_eq!(next_ui_scale(1.15), 1.3);
assert_eq!(next_ui_scale(1.3), 0.9, "the cycle must wrap");
// A hand-edited in-between value advances to the next larger step.
assert_eq!(next_ui_scale(1.05), 1.15);
}
#[test]
fn ui_scale_label_formats_as_percent() {
assert_eq!(ui_scale_label(0.9), "90%");
assert_eq!(ui_scale_label(1.0), "100%");
assert_eq!(ui_scale_label(1.15), "115%");
assert_eq!(ui_scale_label(1.3), "130%");
}
#[test]
fn ui_scale_setting_syncs_the_bevy_resource() {
let mut app = headless_app();
app.insert_resource(UiScale(1.0));
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.ui_scale = 1.3;
app.update();
assert!(
(app.world().resource::<UiScale>().0 - 1.3).abs() < f32::EPSILON,
"Settings::ui_scale must drive bevy::ui::UiScale"
);
}
#[test]
fn ui_scale_out_of_range_sanitizes_on_load() {
use solitaire_data::settings::{UI_SCALE_MAX, UI_SCALE_MIN};
let wild = Settings {
ui_scale: 5.0,
..Settings::default()
}
.sanitized();
assert_eq!(wild.ui_scale, UI_SCALE_MAX);
let tiny = Settings {
ui_scale: 0.1,
..Settings::default()
}
.sanitized();
assert_eq!(tiny.ui_scale, UI_SCALE_MIN);
}
@@ -326,6 +326,15 @@ fn spawn_accessibility_tab(
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
font_res,
);
toggle_row(
body,
"UI Scale",
UiScaleText,
ui_scale_label(settings.ui_scale),
SettingsButton::CycleUiScale,
"Scales all menus, buttons, and HUD text. The table itself always fits the screen.",
font_res,
);
tooltip_delay_row(body, settings.tooltip_delay_secs, font_res);
}
@@ -515,6 +515,58 @@ pub(super) fn touch_input_mode_label(mode: &solitaire_data::settings::TouchInput
}
}
/// The four UI-scale steps the settings row cycles through (Phase K).
pub(super) const UI_SCALE_STEPS: [f32; 4] = [0.9, 1.0, 1.15, 1.3];
/// The next UI-scale step after `current`, wrapping 130 % → 90 %. A
/// hand-edited value between steps advances to the first step larger
/// than it, so the cycle always makes visible progress.
pub(super) fn next_ui_scale(current: f32) -> f32 {
for step in UI_SCALE_STEPS {
if step > current + 0.001 {
return step;
}
}
UI_SCALE_STEPS[0]
}
/// Display string for the UI-scale row, e.g. `"115%"`.
pub(super) fn ui_scale_label(scale: f32) -> String {
format!("{:.0}%", scale * 100.0)
}
/// Refreshes the live UI-scale value text whenever settings change.
pub(super) fn update_ui_scale_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<UiScaleText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = ui_scale_label(settings.0.ui_scale);
}
}
/// Applies `Settings::ui_scale` to the live [`bevy::ui::UiScale`]
/// resource — at startup (first change tick) and whenever the setting
/// changes. Absent under `MinimalPlugins` (no `bevy_ui`), hence the
/// `Option`; the table is unaffected either way (`compute_layout`
/// owns world-space sizing, not UI scale).
pub(super) fn sync_ui_scale_resource(
settings: Res<SettingsResource>,
ui_scale: Option<ResMut<UiScale>>,
) {
let Some(mut ui_scale) = ui_scale else { return };
if !settings.is_changed() {
return;
}
let target = settings.0.ui_scale;
if (ui_scale.0 - target).abs() > f32::EPSILON {
ui_scale.0 = target;
}
}
/// Display string for the "Smart window size" toggle. The argument
/// is the *enabled* state (i.e. the inverse of the underlying
/// `disable_smart_default_size` field) so reading the label gives