Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28be65f092 |
@@ -275,6 +275,14 @@ pub struct Settings {
|
|||||||
/// which marks the tip as unnecessary without showing it.
|
/// which marks the tip as unnecessary without showing it.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub shown_radial_menu_tip: bool,
|
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
|
/// Custom public name displayed on the leaderboard. When `None`, the
|
||||||
/// player's server `username` is used instead. Trimmed to 32 characters
|
/// player's server `username` is used instead. Trimmed to 32 characters
|
||||||
/// before submission. Older `settings.json` files written before this
|
/// before submission. Older `settings.json` files written before this
|
||||||
@@ -383,6 +391,16 @@ fn default_replay_move_interval_secs() -> f32 {
|
|||||||
0.45
|
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 {
|
fn default_matomo_site_id() -> u32 {
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
@@ -446,6 +464,7 @@ impl Default for Settings {
|
|||||||
last_seen_whats_new: String::new(),
|
last_seen_whats_new: String::new(),
|
||||||
shown_stall_hint_tip: false,
|
shown_stall_hint_tip: false,
|
||||||
shown_radial_menu_tip: false,
|
shown_radial_menu_tip: false,
|
||||||
|
ui_scale: default_ui_scale(),
|
||||||
leaderboard_display_name: None,
|
leaderboard_display_name: None,
|
||||||
leaderboard_opted_in: false,
|
leaderboard_opted_in: false,
|
||||||
take_from_foundation: true,
|
take_from_foundation: true,
|
||||||
@@ -481,6 +500,7 @@ impl Settings {
|
|||||||
replay_move_interval_secs: self
|
replay_move_interval_secs: self
|
||||||
.replay_move_interval_secs
|
.replay_move_interval_secs
|
||||||
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_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,
|
selected_theme_id,
|
||||||
..self
|
..self
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,9 +101,10 @@ impl Plugin for SafeAreaInsetsPlugin {
|
|||||||
fn apply_safe_area_anchors(
|
fn apply_safe_area_anchors(
|
||||||
insets: Res<SafeAreaInsets>,
|
insets: Res<SafeAreaInsets>,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
|
ui_scale: Option<Res<UiScale>>,
|
||||||
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
|
// 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);
|
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 {
|
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
|
/// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying
|
||||||
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
|
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
|
||||||
fn apply_safe_area_bottom_anchors(
|
fn apply_safe_area_bottom_anchors(
|
||||||
insets: Res<SafeAreaInsets>,
|
insets: Res<SafeAreaInsets>,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
|
ui_scale: Option<Res<UiScale>>,
|
||||||
mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
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);
|
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 {
|
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(
|
fn apply_safe_area_to_modal_scrims(
|
||||||
insets: Res<SafeAreaInsets>,
|
insets: Res<SafeAreaInsets>,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
|
ui_scale: Option<Res<UiScale>>,
|
||||||
mut scrims: Query<&mut Node, With<ModalScrim>>,
|
mut scrims: Query<&mut Node, With<ModalScrim>>,
|
||||||
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
|
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
|
||||||
) {
|
) {
|
||||||
let has_new = !new_scrims.is_empty();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
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());
|
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
|
// 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.
|
// value can't push the modal card off the visible area entirely.
|
||||||
let top_logical = (insets.top / scale).min(window_height * 0.25);
|
// Physical insets are pre-divided by the UI scale — see
|
||||||
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25);
|
// `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 {
|
for mut node in &mut scrims {
|
||||||
// Set both edges so the scrim's content box equals the usable area
|
// Set both edges so the scrim's content box equals the usable area
|
||||||
// between the status bar and the gesture/navigation bar. With
|
// 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()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
// Text refreshed by `update_touch_input_mode_text` next frame.
|
// 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 => {
|
SettingsButton::ToggleWinnableDealsOnly => {
|
||||||
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
|
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
|
|||||||
@@ -139,6 +139,10 @@ struct ReduceMotionText;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct TouchInputModeText;
|
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.
|
/// Marks the `Text` node showing the live tooltip-delay value.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct TooltipDelayText;
|
struct TooltipDelayText;
|
||||||
@@ -277,6 +281,10 @@ enum SettingsButton {
|
|||||||
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
|
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
|
||||||
/// a card/stack, second tap on a target pile moves it).
|
/// a card/stack, second tap on a target pile moves it).
|
||||||
ToggleTouchInputMode,
|
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
|
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
||||||
/// random Classic-mode deals are filtered through
|
/// random Classic-mode deals are filtered through
|
||||||
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
|
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
|
||||||
@@ -354,6 +362,7 @@ impl SettingsButton {
|
|||||||
SettingsButton::ToggleHighContrast => 61,
|
SettingsButton::ToggleHighContrast => 61,
|
||||||
SettingsButton::ToggleReduceMotion => 62,
|
SettingsButton::ToggleReduceMotion => 62,
|
||||||
SettingsButton::ToggleTouchInputMode => 63,
|
SettingsButton::ToggleTouchInputMode => 63,
|
||||||
|
SettingsButton::CycleUiScale => 64,
|
||||||
// Picker rows — every swatch in a row shares the row's
|
// Picker rows — every swatch in a row shares the row's
|
||||||
// priority so entity-index tiebreaking yields left → right.
|
// priority so entity-index tiebreaking yields left → right.
|
||||||
SettingsButton::SelectCardBack(_) => 70,
|
SettingsButton::SelectCardBack(_) => 70,
|
||||||
@@ -449,6 +458,11 @@ impl Plugin for SettingsPlugin {
|
|||||||
handle_volume_keys,
|
handle_volume_keys,
|
||||||
record_window_geometry_changes,
|
record_window_geometry_changes,
|
||||||
persist_window_geometry_after_debounce,
|
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()
|
.chain()
|
||||||
.in_set(SettingsMutation),
|
.in_set(SettingsMutation),
|
||||||
@@ -492,6 +506,7 @@ impl Plugin for SettingsPlugin {
|
|||||||
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
|
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
|
||||||
update_reduce_motion_text,
|
update_reduce_motion_text,
|
||||||
update_touch_input_mode_text,
|
update_touch_input_mode_text,
|
||||||
|
update_ui_scale_text,
|
||||||
update_tooltip_delay_text,
|
update_tooltip_delay_text,
|
||||||
update_time_bonus_multiplier_text,
|
update_time_bonus_multiplier_text,
|
||||||
update_replay_move_interval_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}"
|
"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.",
|
"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,
|
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);
|
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
|
/// Display string for the "Smart window size" toggle. The argument
|
||||||
/// is the *enabled* state (i.e. the inverse of the underlying
|
/// is the *enabled* state (i.e. the inverse of the underlying
|
||||||
/// `disable_smart_default_size` field) so reading the label gives
|
/// `disable_smart_default_size` field) so reading the label gives
|
||||||
|
|||||||
Reference in New Issue
Block a user