From 28be65f0923c556925f0c65cd9bb2598e2679551 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 13 Jul 2026 18:33:06 -0700 Subject: [PATCH] =?UTF-8?q?feat(engine):=20UI=20scale=20setting=20?= =?UTF-8?q?=E2=80=94=2090/100/115/130%=20(Phase=20K)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings -> Accessibility gains a UI Scale row cycling four steps, applied live through bevy::ui::UiScale: every menu, modal, and HUD element scales while the table itself stays window-fit via compute_layout. New Settings::ui_scale (serde default 1.0, sanitized clamp to [0.9, 1.3]). Safe-area anchors and modal scrim padding pre-divide physical insets by the UI scale so post-multiplication lands exactly on the system bars — without this, 90% would sink the bottom action bar into the Android gesture zone. The anchor systems also re-run on UiScale changes, not just inset changes. Deferred from Phase K (noted in the doc): seeding the setting from the Android system font scale on first run (needs JNI), and the 44px touch-target audit (on-device). Co-Authored-By: Claude Fable 5 --- solitaire_data/src/settings.rs | 20 +++++++ solitaire_engine/src/safe_area.rs | 35 +++++++++--- solitaire_engine/src/settings_plugin/input.rs | 8 +++ solitaire_engine/src/settings_plugin/mod.rs | 15 +++++ solitaire_engine/src/settings_plugin/tests.rs | 57 +++++++++++++++++++ solitaire_engine/src/settings_plugin/ui.rs | 9 +++ .../src/settings_plugin/updates.rs | 52 +++++++++++++++++ 7 files changed, 189 insertions(+), 7 deletions(-) diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 4f70e23..1201c1e 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -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 } diff --git a/solitaire_engine/src/safe_area.rs b/solitaire_engine/src/safe_area.rs index ebfcc79..17f67b5 100644 --- a/solitaire_engine/src/safe_area.rs +++ b/solitaire_engine/src/safe_area.rs @@ -101,9 +101,10 @@ impl Plugin for SafeAreaInsetsPlugin { fn apply_safe_area_anchors( insets: Res, windows: Query<&Window>, + ui_scale: Option>, 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, windows: Query<&Window>, + ui_scale: Option>, 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, windows: Query<&Window>, + ui_scale: Option>, mut scrims: Query<&mut Node, With>, new_scrims: Query<(), (With, Added)>, ) { 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 diff --git a/solitaire_engine/src/settings_plugin/input.rs b/solitaire_engine/src/settings_plugin/input.rs index 72c3676..680e914 100644 --- a/solitaire_engine/src/settings_plugin/input.rs +++ b/solitaire_engine/src/settings_plugin/input.rs @@ -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); diff --git a/solitaire_engine/src/settings_plugin/mod.rs b/solitaire_engine/src/settings_plugin/mod.rs index 5a87c2e..9ed84f2 100644 --- a/solitaire_engine/src/settings_plugin/mod.rs +++ b/solitaire_engine/src/settings_plugin/mod.rs @@ -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::), 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, diff --git a/solitaire_engine/src/settings_plugin/tests.rs b/solitaire_engine/src/settings_plugin/tests.rs index 9916dfd..6c57043 100644 --- a/solitaire_engine/src/settings_plugin/tests.rs +++ b/solitaire_engine/src/settings_plugin/tests.rs @@ -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::() + .0 + .ui_scale = 1.3; + app.update(); + + assert!( + (app.world().resource::().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); +} diff --git a/solitaire_engine/src/settings_plugin/ui.rs b/solitaire_engine/src/settings_plugin/ui.rs index 2b7d068..89f286f 100644 --- a/solitaire_engine/src/settings_plugin/ui.rs +++ b/solitaire_engine/src/settings_plugin/ui.rs @@ -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); } diff --git a/solitaire_engine/src/settings_plugin/updates.rs b/solitaire_engine/src/settings_plugin/updates.rs index c784854..3363bfe 100644 --- a/solitaire_engine/src/settings_plugin/updates.rs +++ b/solitaire_engine/src/settings_plugin/updates.rs @@ -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, + mut text_nodes: Query<&mut Text, With>, +) { + 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, + ui_scale: Option>, +) { + 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