28be65f092
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 <noreply@anthropic.com>
663 lines
25 KiB
Rust
663 lines
25 KiB
Rust
//! Persists `solitaire_data::Settings`, exposes hotkeys for live tuning,
|
||
//! and renders a Bevy UI Settings panel.
|
||
//!
|
||
//! Hotkeys (always active, no overlay required):
|
||
//! - `[` — decrease SFX volume by `SFX_STEP`
|
||
//! - `]` — increase SFX volume by `SFX_STEP`
|
||
//! - `O` — open / close the Settings panel
|
||
//!
|
||
//! On change, the plugin persists `settings.json` and fires
|
||
//! `SettingsChangedEvent` so dependents (e.g. `AudioPlugin`) can react.
|
||
|
||
use std::path::PathBuf;
|
||
|
||
use bevy::input::mouse::MouseWheel;
|
||
use bevy::prelude::*;
|
||
use bevy::window::{WindowMoved, WindowResized};
|
||
use solitaire_data::{
|
||
Settings, WindowGeometry, load_settings_from, save_settings_to, settings_file_path,
|
||
};
|
||
|
||
use crate::events::{
|
||
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
|
||
SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
|
||
};
|
||
use crate::resources::SettingsScrollPos;
|
||
use crate::theme::ThemeThumbnailPair;
|
||
|
||
mod input;
|
||
mod ui;
|
||
mod updates;
|
||
|
||
use input::*;
|
||
use ui::*;
|
||
use updates::*;
|
||
|
||
/// Side length of a swatch button in the card-back / background pickers.
|
||
/// Smaller than the smallest spacing rung so it stays a literal.
|
||
const SWATCH_PX: f32 = 40.0;
|
||
|
||
/// Side length of a small toggle / cycle button (e.g. the "⇄" affordances).
|
||
/// Sub-rung sizing — kept as a literal, see SWATCH_PX. 32 px meets the
|
||
/// minimum desktop hit-target threshold while staying smaller than `SWATCH_PX`.
|
||
const ICON_BUTTON_PX: f32 = 32.0;
|
||
|
||
/// Volume adjustment step applied by the `[` / `]` hotkeys.
|
||
pub const SFX_STEP: f32 = 0.1;
|
||
|
||
/// Bevy resource wrapping the current `Settings`.
|
||
#[derive(Resource, Debug, Clone)]
|
||
pub struct SettingsResource(pub Settings);
|
||
|
||
/// Persistence path for `SettingsResource`. `None` disables I/O (used in tests).
|
||
#[derive(Resource, Debug, Clone)]
|
||
pub struct SettingsStoragePath(pub Option<PathBuf>);
|
||
|
||
/// Whether the Settings panel is currently visible. Toggle with `O`.
|
||
#[derive(Resource, Debug, Clone, Default)]
|
||
pub struct SettingsScreen(pub bool);
|
||
|
||
/// Debounce window for persisting window-geometry changes, in seconds.
|
||
///
|
||
/// `WindowResized` and `WindowMoved` fire continuously during a resize/
|
||
/// move drag, so writing to disk on every event would thrash the file
|
||
/// system. Instead the geometry-watch system records the pending value
|
||
/// and waits this long after the *last* event before saving.
|
||
pub const WINDOW_GEOMETRY_DEBOUNCE_SECS: f32 = 0.5;
|
||
|
||
/// Tracks a pending window-geometry change so the saver can debounce
|
||
/// `WindowResized` / `WindowMoved` storms during a resize / move drag.
|
||
#[derive(Resource, Debug, Default, Clone, Copy)]
|
||
pub struct PendingWindowGeometry {
|
||
/// Most recent observed geometry. `None` when nothing is pending.
|
||
pub geometry: Option<WindowGeometry>,
|
||
/// `Time::elapsed_secs()` value at which `geometry` was last updated.
|
||
pub last_changed_secs: f32,
|
||
}
|
||
|
||
/// Fired whenever settings change so consumers (audio, UI) can react.
|
||
#[derive(Message, Debug, Clone)]
|
||
pub struct SettingsChangedEvent(pub Settings);
|
||
|
||
/// System set for the systems that mutate [`SettingsResource`] every frame
|
||
/// (hotkeys and window-geometry persistence). Ordered before
|
||
/// [`crate::game_plugin::GameMutation`]; readers of settings should sit
|
||
/// after this set (directly, or transitively via `.after(GameMutation)`)
|
||
/// so they observe the current frame's settings deterministically (#143).
|
||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||
pub struct SettingsMutation;
|
||
|
||
/// Marker on the root Settings panel entity.
|
||
#[derive(Component, Debug)]
|
||
pub struct SettingsPanel;
|
||
|
||
/// Marks the `Text` node showing the live SFX volume value.
|
||
#[derive(Component, Debug)]
|
||
struct SfxVolumeText;
|
||
|
||
/// Marks the `Text` node showing the live music volume value.
|
||
#[derive(Component, Debug)]
|
||
struct MusicVolumeText;
|
||
|
||
/// Marks the `Text` node showing the current draw mode.
|
||
#[derive(Component, Debug)]
|
||
struct DrawModeText;
|
||
|
||
/// Marks the `Text` node showing the current theme.
|
||
#[derive(Component, Debug)]
|
||
struct ThemeText;
|
||
|
||
/// Marks the `Text` node showing the live sync status.
|
||
#[derive(Component, Debug)]
|
||
struct SyncStatusText;
|
||
|
||
/// Marks the `Text` node showing the active card-back index.
|
||
#[derive(Component, Debug)]
|
||
struct CardBackText;
|
||
|
||
/// Marks the `Text` node showing the current animation speed.
|
||
#[derive(Component, Debug)]
|
||
struct AnimSpeedText;
|
||
|
||
/// Marks the `Text` node showing the active background index.
|
||
#[derive(Component, Debug)]
|
||
struct BackgroundText;
|
||
|
||
/// Marks the `Text` node showing the current color-blind mode state.
|
||
#[derive(Component, Debug)]
|
||
struct ColorBlindText;
|
||
|
||
/// Marks the `Text` node showing the current high-contrast mode state.
|
||
#[derive(Component, Debug)]
|
||
struct HighContrastText;
|
||
|
||
/// Marks the `Text` node showing the current reduce-motion mode state.
|
||
#[derive(Component, Debug)]
|
||
struct ReduceMotionText;
|
||
|
||
/// Marks the `Text` node showing the current touch input mode state.
|
||
#[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;
|
||
|
||
/// Marks the `Text` node showing the live time-bonus-multiplier value.
|
||
#[derive(Component, Debug)]
|
||
struct TimeBonusMultiplierText;
|
||
|
||
/// Marks the `Text` node showing the live replay-playback per-move
|
||
/// interval value. The Gameplay-section row beside this label lets the
|
||
/// player tune `Settings::replay_move_interval_secs`.
|
||
#[derive(Component, Debug)]
|
||
struct ReplayMoveIntervalText;
|
||
|
||
/// Marks the `Text` node showing the current "Winnable deals only"
|
||
/// state ("ON" / "OFF") in the Gameplay section.
|
||
#[derive(Component, Debug)]
|
||
struct WinnableDealsOnlyText;
|
||
|
||
/// Marks the `Text` node showing the current "Smart window size"
|
||
/// state ("ON" / "OFF") in the Gameplay section. The flag is stored
|
||
/// negatively in `Settings::disable_smart_default_size`, so the
|
||
/// label inverts: "ON" = smart sizing enabled (the default).
|
||
#[derive(Component, Debug)]
|
||
struct SmartDefaultSizeText;
|
||
|
||
/// Marks the `Text` node showing the current "Share usage data" (analytics)
|
||
/// state ("ON" / "OFF") in the Privacy section.
|
||
#[derive(Component, Debug)]
|
||
struct AnalyticsEnabledText;
|
||
|
||
/// Which tab of the Settings panel is showing. Tabs replace the old
|
||
/// single-scroll five-section layout (Phase A of
|
||
/// `docs/ui-redesign-2026-07.md`) so each group fits on screen without
|
||
/// scroll-hunting.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub(super) enum SettingsTab {
|
||
#[default]
|
||
Audio,
|
||
Gameplay,
|
||
Appearance,
|
||
Accessibility,
|
||
Account,
|
||
}
|
||
|
||
impl SettingsTab {
|
||
/// Every tab, in display order.
|
||
pub(super) const ALL: [Self; 5] = [
|
||
Self::Audio,
|
||
Self::Gameplay,
|
||
Self::Appearance,
|
||
Self::Accessibility,
|
||
Self::Account,
|
||
];
|
||
|
||
/// Chip label.
|
||
pub(super) fn label(self) -> &'static str {
|
||
match self {
|
||
Self::Audio => "Audio",
|
||
Self::Gameplay => "Gameplay",
|
||
Self::Appearance => "Appearance",
|
||
Self::Accessibility => "Access",
|
||
Self::Account => "Account",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The active Settings tab. Session-only — deliberately not persisted;
|
||
/// reopening Settings within a session returns to the last tab.
|
||
#[derive(Resource, Debug, Default)]
|
||
pub(super) struct ActiveSettingsTab(pub(super) SettingsTab);
|
||
|
||
/// Marker on each tab chip button in the Settings header row.
|
||
#[derive(Component, Debug)]
|
||
pub(super) struct SettingsTabButton(pub(super) SettingsTab);
|
||
|
||
/// Marks the scrollable inner card so the mouse-wheel system can target it.
|
||
#[derive(Component, Debug)]
|
||
struct SettingsPanelScrollable;
|
||
|
||
/// Marks the scrollable inner card so its `ScrollPosition` can be read before despawn.
|
||
#[derive(Component, Debug)]
|
||
struct SettingsScrollNode;
|
||
|
||
/// Snapshot row used by [`spawn_settings_panel`] to render the card-art
|
||
/// theme picker. Carries the `ThemeRegistry` entry's display fields plus
|
||
/// the (optional) thumbnail pair from [`ThemeThumbnailCache`]. A `None`
|
||
/// thumbnail means the picker should render a placeholder swatch — used
|
||
/// when the cache hasn't generated handles yet, or when a user theme
|
||
/// is missing one of the required preview SVGs.
|
||
#[derive(Debug, Clone)]
|
||
struct ThemePickerEntry {
|
||
/// Stable theme id (matches `ThemeMeta::id`).
|
||
id: String,
|
||
/// Player-facing label.
|
||
display_name: String,
|
||
/// Pre-generated picker preview pair, when ready. `None` collapses
|
||
/// the chip to its plain-text fallback.
|
||
thumbnails: Option<ThemeThumbnailPair>,
|
||
}
|
||
|
||
/// Tags interactive buttons inside the Settings panel.
|
||
#[derive(Component, Debug)]
|
||
enum SettingsButton {
|
||
SfxDown,
|
||
SfxUp,
|
||
MusicDown,
|
||
MusicUp,
|
||
ToggleDrawMode,
|
||
CycleAnimSpeed,
|
||
/// Decrement the tooltip-hover dwell delay by one step.
|
||
TooltipDelayDown,
|
||
/// Increment the tooltip-hover dwell delay by one step.
|
||
TooltipDelayUp,
|
||
/// Decrement the cosmetic time-bonus multiplier by one step.
|
||
TimeBonusDown,
|
||
/// Increment the cosmetic time-bonus multiplier by one step.
|
||
TimeBonusUp,
|
||
/// Decrement the replay-playback per-move interval by one step
|
||
/// (i.e. speed playback up).
|
||
ReplayMoveIntervalDown,
|
||
/// Increment the replay-playback per-move interval by one step
|
||
/// (i.e. slow playback down).
|
||
ReplayMoveIntervalUp,
|
||
ToggleTheme,
|
||
ToggleColorBlind,
|
||
/// Toggle the [`Settings::high_contrast_mode`] flag — boosts
|
||
/// foreground / suit-red glyphs to higher-luminance variants per
|
||
/// `design-system.md` §Accessibility (#2).
|
||
ToggleHighContrast,
|
||
/// Toggle the [`Settings::reduce_motion_mode`] flag — suppresses
|
||
/// non-essential motion (card-slide animations become instant
|
||
/// snaps) per `design-system.md` §Accessibility (#3).
|
||
ToggleReduceMotion,
|
||
/// Toggle [`Settings::touch_input_mode`] between `OneTap`
|
||
/// (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
|
||
/// winnable (or the retry cap is hit). Off by default.
|
||
ToggleWinnableDealsOnly,
|
||
/// Toggle the inverse of [`Settings::disable_smart_default_size`].
|
||
/// When the visible label reads "ON", the launch-time window
|
||
/// sizer scales the window to ~70 % of the primary monitor on a
|
||
/// fresh install; "OFF" pins the literal 1280×800 baseline. The
|
||
/// flag only affects launches without saved geometry — the
|
||
/// player's last window size always wins.
|
||
ToggleSmartDefaultSize,
|
||
/// Toggle [`Settings::analytics_enabled`]. Only rendered when a
|
||
/// Matomo URL is configured.
|
||
ToggleAnalytics,
|
||
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
ScanThemes,
|
||
/// Open the in-game theme-store modal (closes Settings first).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
OpenThemeStore,
|
||
SyncNow,
|
||
/// Open the sync-server Connect modal (shown when backend = Local).
|
||
ConnectSync,
|
||
/// Disconnect from the sync server (shown when backend = SolitaireServer).
|
||
DisconnectSync,
|
||
/// Open the account-deletion confirmation modal.
|
||
DeleteAccount,
|
||
Done,
|
||
/// Select a specific card-back by index from the picker row.
|
||
SelectCardBack(usize),
|
||
/// Select a specific background by index from the picker row.
|
||
SelectBackground(usize),
|
||
/// Select a specific card-art theme by `meta.id` from the
|
||
/// `ThemeRegistry`. The string is owned so the click handler can
|
||
/// hand it directly to `Settings::selected_theme_id`.
|
||
SelectTheme(String),
|
||
}
|
||
|
||
impl SettingsButton {
|
||
/// Tab-walk priority — lower numbers visited first. Visual reading
|
||
/// order is top-to-bottom by section, left-to-right inside each row.
|
||
/// Two buttons in the same picker row receive the same `order`;
|
||
/// `handle_focus_keys` then breaks ties by entity index, which
|
||
/// matches `Children` spawn order inside each row.
|
||
fn focus_order(&self) -> i32 {
|
||
match self {
|
||
// Audio section
|
||
SettingsButton::SfxDown => 10,
|
||
SettingsButton::SfxUp => 11,
|
||
SettingsButton::MusicDown => 20,
|
||
SettingsButton::MusicUp => 21,
|
||
// Gameplay section
|
||
SettingsButton::ToggleDrawMode => 30,
|
||
SettingsButton::ToggleWinnableDealsOnly => 35,
|
||
SettingsButton::CycleAnimSpeed => 40,
|
||
SettingsButton::TooltipDelayDown => 45,
|
||
SettingsButton::TooltipDelayUp => 46,
|
||
SettingsButton::TimeBonusDown => 47,
|
||
SettingsButton::TimeBonusUp => 48,
|
||
// Replay-speed slider — last Gameplay-section row, so it
|
||
// sits between TimeBonusUp (48) and the Cosmetic section.
|
||
SettingsButton::ReplayMoveIntervalDown => 49,
|
||
SettingsButton::ReplayMoveIntervalUp => 49,
|
||
// Smart-default-size toggle — sits at the end of Gameplay.
|
||
SettingsButton::ToggleSmartDefaultSize => 50,
|
||
// Privacy section — just before Sync.
|
||
SettingsButton::ToggleAnalytics => 89,
|
||
// Cosmetic section
|
||
SettingsButton::ToggleTheme => 55,
|
||
SettingsButton::ToggleColorBlind => 60,
|
||
// Accessibility-section toggles sit alongside Color-blind so
|
||
// tab-walk visits all three a11y flags in the same vertical
|
||
// run before continuing to the picker rows.
|
||
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,
|
||
SettingsButton::SelectBackground(_) => 80,
|
||
SettingsButton::SelectTheme(_) => 85,
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
SettingsButton::ScanThemes => 86,
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
SettingsButton::OpenThemeStore => 87,
|
||
// Sync section
|
||
SettingsButton::SyncNow => 90,
|
||
SettingsButton::ConnectSync => 91,
|
||
SettingsButton::DisconnectSync => 92,
|
||
SettingsButton::DeleteAccount => 93,
|
||
// Done is tagged by `attach_focusable_to_modal_buttons` and
|
||
// never reaches `attach_focusable_to_settings_buttons`; the
|
||
// value here is only a fallback for completeness.
|
||
SettingsButton::Done => 100,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Plugin that owns the settings lifecycle.
|
||
pub struct SettingsPlugin {
|
||
/// Path to `settings.json`. `None` in headless/test mode.
|
||
pub storage_path: Option<PathBuf>,
|
||
/// When `false`, panel spawn/despawn systems are not registered.
|
||
/// Use [`SettingsPlugin::headless`] for tests running under `MinimalPlugins`.
|
||
pub ui_enabled: bool,
|
||
}
|
||
|
||
impl Default for SettingsPlugin {
|
||
fn default() -> Self {
|
||
Self {
|
||
storage_path: settings_file_path(),
|
||
ui_enabled: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl SettingsPlugin {
|
||
/// No persistence, no UI — safe to use under `MinimalPlugins` in tests.
|
||
pub fn headless() -> Self {
|
||
Self {
|
||
storage_path: None,
|
||
ui_enabled: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Plugin for SettingsPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
let loaded = match &self.storage_path {
|
||
Some(path) => load_settings_from(path),
|
||
None => Settings::default(),
|
||
};
|
||
app.insert_resource(SettingsResource(loaded))
|
||
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
||
.init_resource::<SettingsScreen>()
|
||
.init_resource::<SettingsScrollPos>()
|
||
.init_resource::<ActiveSettingsTab>()
|
||
.init_resource::<PendingWindowGeometry>()
|
||
.add_message::<SettingsChangedEvent>()
|
||
.add_message::<ManualSyncRequestEvent>()
|
||
.add_message::<SyncConfigureRequestEvent>()
|
||
.add_message::<SyncLogoutRequestEvent>()
|
||
.add_message::<DeleteAccountRequestEvent>()
|
||
.add_message::<ToggleSettingsRequestEvent>()
|
||
.add_message::<crate::events::ThemeStoreOpenRequestEvent>()
|
||
.add_message::<InfoToastEvent>()
|
||
.add_message::<MouseWheel>()
|
||
.add_message::<TouchInput>()
|
||
// `WindowResized` / `WindowMoved` are real Bevy window events
|
||
// and emitted by the windowing backend under `DefaultPlugins`,
|
||
// but we register them explicitly here so the geometry watcher
|
||
// also runs cleanly under `MinimalPlugins` (tests).
|
||
.add_message::<WindowResized>()
|
||
.add_message::<WindowMoved>()
|
||
// Settings changes land before game logic runs: the mutator
|
||
// chain (volume keys → geometry record → geometry persist) is a
|
||
// deterministic spine, and the whole set precedes GameMutation so
|
||
// every reader already ordered after GameMutation sees this
|
||
// frame's settings transitively (ambiguity burn-down, #143).
|
||
.configure_sets(
|
||
Update,
|
||
SettingsMutation
|
||
.after(crate::layout::LayoutSystem::UpdateOnResize)
|
||
.before(crate::game_plugin::GameMutation),
|
||
)
|
||
.add_systems(
|
||
Update,
|
||
(
|
||
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),
|
||
)
|
||
.add_systems(
|
||
Update,
|
||
(
|
||
toggle_settings_screen
|
||
.before(crate::ui_focus::FocusKeys)
|
||
.ambiguous_with(crate::hud_plugin::HudButtons),
|
||
scroll_settings_panel,
|
||
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>,
|
||
)
|
||
.chain(),
|
||
);
|
||
|
||
if self.ui_enabled {
|
||
app.add_systems(
|
||
Update,
|
||
(
|
||
// Chained: a tab click must observe this frame's
|
||
// visibility state, and the rebuild must observe the
|
||
// click — total order avoids panel double-spawns.
|
||
(
|
||
sync_settings_panel_visibility,
|
||
handle_tab_buttons,
|
||
rebuild_panel_on_tab_change,
|
||
)
|
||
.chain(),
|
||
handle_settings_buttons,
|
||
handle_sync_buttons,
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
handle_scan_themes,
|
||
update_sync_status_text,
|
||
update_card_back_text,
|
||
update_background_text,
|
||
update_anim_speed_text,
|
||
update_color_blind_text,
|
||
update_high_contrast_text,
|
||
update_high_contrast_borders.run_if(resource_changed::<SettingsResource>),
|
||
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,
|
||
update_winnable_deals_only_text,
|
||
update_smart_default_size_text,
|
||
),
|
||
);
|
||
app.add_systems(
|
||
Update,
|
||
(
|
||
update_analytics_enabled_text,
|
||
attach_focusable_to_settings_buttons,
|
||
),
|
||
);
|
||
app.add_systems(Update, scroll_focus_into_view);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Internal helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn persist(path: &SettingsStoragePath, settings: &Settings) {
|
||
let Some(target) = &path.0 else { return };
|
||
if let Err(e) = save_settings_to(target, settings) {
|
||
warn!("failed to save settings: {e}");
|
||
}
|
||
}
|
||
|
||
/// Pure helper: returns `true` when a pending geometry change has sat
|
||
/// quietly long enough to flush to disk.
|
||
///
|
||
/// Extracted so the debounce condition can be unit-tested without
|
||
/// spinning up a Bevy app.
|
||
fn should_persist_geometry(now_secs: f32, last_changed_secs: f32) -> bool {
|
||
(now_secs - last_changed_secs) >= WINDOW_GEOMETRY_DEBOUNCE_SECS
|
||
}
|
||
|
||
/// Returns the geometry implied by an event pair `(width, height, x, y)`,
|
||
/// using each component from `existing` when the corresponding event-derived
|
||
/// value is `None`. Returns `None` when neither side supplies width/height.
|
||
///
|
||
/// Pure helper so the merge logic can be unit-tested without an `App`.
|
||
fn merge_geometry(
|
||
existing: Option<WindowGeometry>,
|
||
new_size: Option<(u32, u32)>,
|
||
new_pos: Option<(i32, i32)>,
|
||
) -> Option<WindowGeometry> {
|
||
let (width, height) = new_size.or_else(|| existing.map(|g| (g.width, g.height)))?;
|
||
let (x, y) = new_pos
|
||
.or_else(|| existing.map(|g| (g.x, g.y)))
|
||
.unwrap_or((0, 0));
|
||
Some(WindowGeometry {
|
||
width,
|
||
height,
|
||
x,
|
||
y,
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Systems
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Returns the next unlocked index after `current` in the sorted `unlocked` list.
|
||
/// Wraps around. Falls back to `unlocked[0]` if `current` is not found.
|
||
#[cfg(test)]
|
||
fn cycle_unlocked(unlocked: &[usize], current: usize) -> usize {
|
||
if unlocked.is_empty() {
|
||
return 0;
|
||
}
|
||
let pos = unlocked.iter().position(|&i| i == current).unwrap_or(0);
|
||
unlocked[(pos + 1) % unlocked.len()]
|
||
}
|
||
|
||
/// Records `WindowResized` and `WindowMoved` events into
|
||
/// [`PendingWindowGeometry`], coalescing every event arriving this frame
|
||
/// into the latest pending geometry.
|
||
///
|
||
/// The actual disk write is debounced — see
|
||
/// [`persist_window_geometry_after_debounce`] — so the file system isn't
|
||
/// hit on every pixel of a resize / move drag.
|
||
fn record_window_geometry_changes(
|
||
time: Res<Time>,
|
||
mut resized: MessageReader<WindowResized>,
|
||
mut moved: MessageReader<WindowMoved>,
|
||
settings: Res<SettingsResource>,
|
||
mut pending: ResMut<PendingWindowGeometry>,
|
||
) {
|
||
// Read .last() — only the final event matters for persistence; the
|
||
// intermediate sizes/positions are noise during a drag.
|
||
let new_size = resized.read().last().map(|ev| {
|
||
(
|
||
ev.width.round().max(0.0) as u32,
|
||
ev.height.round().max(0.0) as u32,
|
||
)
|
||
});
|
||
let new_pos = moved.read().last().map(|ev| (ev.position.x, ev.position.y));
|
||
|
||
if new_size.is_none() && new_pos.is_none() {
|
||
return;
|
||
}
|
||
|
||
// Fold the new components into the existing pending value (if any),
|
||
// otherwise into the persisted geometry from settings.
|
||
let baseline = pending.geometry.or(settings.0.window_geometry);
|
||
let Some(geometry) = merge_geometry(baseline, new_size, new_pos) else {
|
||
return;
|
||
};
|
||
|
||
pending.geometry = Some(geometry);
|
||
pending.last_changed_secs = time.elapsed_secs();
|
||
}
|
||
|
||
/// After [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] of quiet (no `WindowResized` or
|
||
/// `WindowMoved` events arriving), commits the pending geometry to
|
||
/// `SettingsResource` and writes `settings.json`. Skips the write when the
|
||
/// pending value already matches the settings (e.g. a resize that was
|
||
/// reverted, or a synthetic event with no geometry change).
|
||
fn persist_window_geometry_after_debounce(
|
||
time: Res<Time>,
|
||
mut pending: ResMut<PendingWindowGeometry>,
|
||
mut settings: ResMut<SettingsResource>,
|
||
path: Res<SettingsStoragePath>,
|
||
mut changed: MessageWriter<SettingsChangedEvent>,
|
||
) {
|
||
let Some(new_geom) = pending.geometry else {
|
||
return;
|
||
};
|
||
if !should_persist_geometry(time.elapsed_secs(), pending.last_changed_secs) {
|
||
return;
|
||
}
|
||
|
||
// Always clear the pending slot regardless of whether we end up
|
||
// writing — otherwise an idempotent change would re-trigger this
|
||
// system every tick.
|
||
pending.geometry = None;
|
||
|
||
if settings.0.window_geometry == Some(new_geom) {
|
||
return;
|
||
}
|
||
settings.0.window_geometry = Some(new_geom);
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// UI construction
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|