4b9d008be2
Conservative cleanup pass — applied only the high-signal pedantic lints whose fixes either remove genuine waste or read more naturally, skipping anything stylistic that would bloat the diff. - map_unwrap_or: 29 .map(...).unwrap_or(...) sites collapsed to .map_or / .is_some_and / .map_or_else equivalents - uninlined_format_args: 7 production format!/write!/println! sites rewritten to the inline-argument style; assert! sites in test code intentionally untouched - match_same_arms: 2 redundant arms collapsed where the bodies were identical and the merger didn't obscure intent Public API is unchanged. No dependencies added or removed. The pedantic warning count dropped from 840 to 807 (-33). Out-of-scope findings — needless_pass_by_value on Bevy Res params, false-positive explicit_iter_loop on Bevy Query iterators, items_after_statements inside test mods, and the "ask before changing" merge logic in solitaire_sync — were intentionally deferred. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1854 lines
66 KiB
Rust
1854 lines
66 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::{MouseScrollUnit, MouseWheel};
|
||
use bevy::prelude::*;
|
||
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||
use bevy::window::{WindowMoved, WindowResized};
|
||
use solitaire_core::game_state::DrawMode;
|
||
use solitaire_data::{
|
||
load_settings_from, save_settings_to, settings_file_path, settings::Theme, AnimSpeed, Settings,
|
||
WindowGeometry,
|
||
};
|
||
|
||
use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
||
use crate::font_plugin::FontResource;
|
||
use crate::progress_plugin::ProgressResource;
|
||
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
|
||
use crate::ui_modal::{
|
||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||
ModalButton, ModalScrim,
|
||
};
|
||
use crate::ui_tooltip::Tooltip;
|
||
use crate::ui_theme::{
|
||
BG_BASE, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY,
|
||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
||
};
|
||
|
||
/// 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);
|
||
|
||
/// Marker on the root Settings panel entity.
|
||
#[derive(Component, Debug)]
|
||
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 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;
|
||
|
||
/// Tags interactive buttons inside the Settings panel.
|
||
#[derive(Component, Debug)]
|
||
enum SettingsButton {
|
||
SfxDown,
|
||
SfxUp,
|
||
MusicDown,
|
||
MusicUp,
|
||
ToggleDrawMode,
|
||
CycleAnimSpeed,
|
||
ToggleTheme,
|
||
ToggleColorBlind,
|
||
SyncNow,
|
||
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),
|
||
}
|
||
|
||
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::CycleAnimSpeed => 40,
|
||
// Cosmetic section
|
||
SettingsButton::ToggleTheme => 50,
|
||
SettingsButton::ToggleColorBlind => 60,
|
||
// 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,
|
||
// Sync section
|
||
SettingsButton::SyncNow => 90,
|
||
// 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::<PendingWindowGeometry>()
|
||
.add_message::<SettingsChangedEvent>()
|
||
.add_message::<ManualSyncRequestEvent>()
|
||
.add_message::<ToggleSettingsRequestEvent>()
|
||
.add_message::<bevy::input::mouse::MouseWheel>()
|
||
// `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>()
|
||
.add_systems(
|
||
Update,
|
||
(
|
||
handle_volume_keys,
|
||
toggle_settings_screen,
|
||
scroll_settings_panel,
|
||
record_window_geometry_changes,
|
||
persist_window_geometry_after_debounce,
|
||
),
|
||
);
|
||
|
||
if self.ui_enabled {
|
||
app.add_systems(
|
||
Update,
|
||
(
|
||
sync_settings_panel_visibility,
|
||
handle_settings_buttons,
|
||
update_sync_status_text,
|
||
update_card_back_text,
|
||
update_background_text,
|
||
update_anim_speed_text,
|
||
update_color_blind_text,
|
||
attach_focusable_to_settings_buttons,
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn handle_volume_keys(
|
||
keys: Res<ButtonInput<KeyCode>>,
|
||
mut settings: ResMut<SettingsResource>,
|
||
path: Res<SettingsStoragePath>,
|
||
mut changed: MessageWriter<SettingsChangedEvent>,
|
||
) {
|
||
let mut delta = 0.0_f32;
|
||
if keys.just_pressed(KeyCode::BracketLeft) {
|
||
delta -= SFX_STEP;
|
||
}
|
||
if keys.just_pressed(KeyCode::BracketRight) {
|
||
delta += SFX_STEP;
|
||
}
|
||
if delta == 0.0 {
|
||
return;
|
||
}
|
||
let before = settings.0.sfx_volume;
|
||
let after = settings.0.adjust_sfx_volume(delta);
|
||
if (before - after).abs() < f32::EPSILON {
|
||
return;
|
||
}
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
}
|
||
|
||
/// Opens or closes the Settings panel — `O` keyboard accelerator or
|
||
/// `ToggleSettingsRequestEvent` from the HUD Menu popover.
|
||
fn toggle_settings_screen(
|
||
keys: Res<ButtonInput<KeyCode>>,
|
||
mut requests: MessageReader<ToggleSettingsRequestEvent>,
|
||
mut screen: ResMut<SettingsScreen>,
|
||
) {
|
||
let button_clicked = requests.read().count() > 0;
|
||
if keys.just_pressed(KeyCode::KeyO) || button_clicked {
|
||
screen.0 = !screen.0;
|
||
}
|
||
}
|
||
|
||
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
|
||
/// despawns it when it becomes `false`.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn sync_settings_panel_visibility(
|
||
screen: Res<SettingsScreen>,
|
||
panels: Query<Entity, With<SettingsPanel>>,
|
||
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
|
||
mut scroll_pos: ResMut<SettingsScrollPos>,
|
||
mut commands: Commands,
|
||
settings: Res<SettingsResource>,
|
||
sync_status: Option<Res<SyncStatusResource>>,
|
||
progress: Option<Res<ProgressResource>>,
|
||
font_res: Option<Res<FontResource>>,
|
||
) {
|
||
if !screen.is_changed() {
|
||
return;
|
||
}
|
||
if screen.0 {
|
||
if panels.is_empty() {
|
||
let status_label = sync_status
|
||
.map_or_else(|| "Status: local only".to_string(), |s| sync_status_label(&s.0));
|
||
let unlocked_backs = progress
|
||
.as_ref()
|
||
.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
|
||
let unlocked_bgs = progress
|
||
.as_ref()
|
||
.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
|
||
spawn_settings_panel(
|
||
&mut commands,
|
||
&settings.0,
|
||
&status_label,
|
||
unlocked_backs,
|
||
unlocked_bgs,
|
||
scroll_pos.0,
|
||
font_res.as_deref(),
|
||
);
|
||
}
|
||
} else {
|
||
// Save the current scroll offset before despawning the panel.
|
||
if let Ok(sp) = scroll_nodes.single() {
|
||
scroll_pos.0 = sp.0.y;
|
||
}
|
||
for entity in &panels {
|
||
commands.entity(entity).despawn();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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()]
|
||
}
|
||
|
||
/// Keeps the sync-status text node current while the panel is open.
|
||
fn update_sync_status_text(
|
||
sync_status: Option<Res<SyncStatusResource>>,
|
||
mut text_nodes: Query<&mut Text, With<SyncStatusText>>,
|
||
) {
|
||
let Some(status) = sync_status else {
|
||
return;
|
||
};
|
||
if !status.is_changed() {
|
||
return;
|
||
}
|
||
let label = sync_status_label(&status.0);
|
||
for mut text in &mut text_nodes {
|
||
**text = label.clone();
|
||
}
|
||
}
|
||
|
||
fn update_card_back_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<CardBackText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = card_back_label(settings.0.selected_card_back);
|
||
}
|
||
}
|
||
|
||
fn update_background_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<BackgroundText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = background_label(settings.0.selected_background);
|
||
}
|
||
}
|
||
|
||
fn update_anim_speed_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<AnimSpeedText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = anim_speed_label(&settings.0.animation_speed);
|
||
}
|
||
}
|
||
|
||
fn update_color_blind_text(
|
||
settings: Res<SettingsResource>,
|
||
mut text_nodes: Query<&mut Text, With<ColorBlindText>>,
|
||
) {
|
||
if !settings.is_changed() {
|
||
return;
|
||
}
|
||
for mut text in &mut text_nodes {
|
||
**text = color_blind_label(settings.0.color_blind_mode);
|
||
}
|
||
}
|
||
|
||
fn card_back_label(idx: usize) -> String {
|
||
if idx == 0 {
|
||
"Default".to_string()
|
||
} else {
|
||
format!("Style {idx}")
|
||
}
|
||
}
|
||
|
||
fn background_label(idx: usize) -> String {
|
||
if idx == 0 {
|
||
"Default".to_string()
|
||
} else {
|
||
format!("Style {idx}")
|
||
}
|
||
}
|
||
|
||
fn sync_status_label(status: &SyncStatus) -> String {
|
||
match status {
|
||
SyncStatus::Idle => "Status: idle".to_string(),
|
||
SyncStatus::Syncing => "Status: syncing…".to_string(),
|
||
SyncStatus::LastSynced(t) => {
|
||
let secs = chrono::Utc::now()
|
||
.signed_duration_since(*t)
|
||
.num_seconds()
|
||
.max(0);
|
||
if secs < 60 {
|
||
format!("Last synced: {secs}s ago")
|
||
} else {
|
||
format!("Last synced: {}m ago", secs / 60)
|
||
}
|
||
}
|
||
SyncStatus::Error(e) => format!("Sync error: {e}"),
|
||
}
|
||
}
|
||
|
||
/// Reacts to button presses inside the Settings panel.
|
||
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||
fn handle_settings_buttons(
|
||
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||
mut settings: ResMut<SettingsResource>,
|
||
mut screen: ResMut<SettingsScreen>,
|
||
path: Res<SettingsStoragePath>,
|
||
mut changed: MessageWriter<SettingsChangedEvent>,
|
||
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
||
mut sfx_text: Query<&mut Text, (With<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
|
||
mut music_text: Query<&mut Text, (With<MusicVolumeText>, Without<SfxVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
|
||
mut draw_text: Query<&mut Text, (With<DrawModeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<ThemeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
|
||
mut theme_text: Query<&mut Text, (With<ThemeText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<AnimSpeedText>, Without<ColorBlindText>)>,
|
||
mut anim_speed_text: Query<&mut Text, (With<AnimSpeedText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<ColorBlindText>)>,
|
||
mut color_blind_text: Query<&mut Text, (With<ColorBlindText>, Without<SfxVolumeText>, Without<MusicVolumeText>, Without<DrawModeText>, Without<ThemeText>, Without<AnimSpeedText>)>,
|
||
) {
|
||
for (interaction, button) in &interaction_query {
|
||
if *interaction != Interaction::Pressed {
|
||
continue;
|
||
}
|
||
match button {
|
||
SettingsButton::SfxDown => {
|
||
let before = settings.0.sfx_volume;
|
||
let after = settings.0.adjust_sfx_volume(-SFX_STEP);
|
||
if (before - after).abs() > f32::EPSILON {
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
if let Ok(mut t) = sfx_text.single_mut() {
|
||
**t = format!("{after:.2}");
|
||
}
|
||
}
|
||
}
|
||
SettingsButton::SfxUp => {
|
||
let before = settings.0.sfx_volume;
|
||
let after = settings.0.adjust_sfx_volume(SFX_STEP);
|
||
if (before - after).abs() > f32::EPSILON {
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
if let Ok(mut t) = sfx_text.single_mut() {
|
||
**t = format!("{after:.2}");
|
||
}
|
||
}
|
||
}
|
||
SettingsButton::MusicDown => {
|
||
let before = settings.0.music_volume;
|
||
let after = settings.0.adjust_music_volume(-SFX_STEP);
|
||
if (before - after).abs() > f32::EPSILON {
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
if let Ok(mut t) = music_text.single_mut() {
|
||
**t = format!("{after:.2}");
|
||
}
|
||
}
|
||
}
|
||
SettingsButton::MusicUp => {
|
||
let before = settings.0.music_volume;
|
||
let after = settings.0.adjust_music_volume(SFX_STEP);
|
||
if (before - after).abs() > f32::EPSILON {
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
if let Ok(mut t) = music_text.single_mut() {
|
||
**t = format!("{after:.2}");
|
||
}
|
||
}
|
||
}
|
||
SettingsButton::ToggleDrawMode => {
|
||
settings.0.draw_mode = match settings.0.draw_mode {
|
||
DrawMode::DrawOne => DrawMode::DrawThree,
|
||
DrawMode::DrawThree => DrawMode::DrawOne,
|
||
};
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
if let Ok(mut t) = draw_text.single_mut() {
|
||
**t = draw_mode_label(&settings.0.draw_mode);
|
||
}
|
||
}
|
||
SettingsButton::CycleAnimSpeed => {
|
||
settings.0.animation_speed = match settings.0.animation_speed {
|
||
AnimSpeed::Normal => AnimSpeed::Fast,
|
||
AnimSpeed::Fast => AnimSpeed::Instant,
|
||
AnimSpeed::Instant => AnimSpeed::Normal,
|
||
};
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
if let Ok(mut t) = anim_speed_text.single_mut() {
|
||
**t = anim_speed_label(&settings.0.animation_speed);
|
||
}
|
||
}
|
||
SettingsButton::ToggleTheme => {
|
||
settings.0.theme = match settings.0.theme {
|
||
Theme::Green => Theme::Blue,
|
||
Theme::Blue => Theme::Dark,
|
||
Theme::Dark => Theme::Green,
|
||
};
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
if let Ok(mut t) = theme_text.single_mut() {
|
||
**t = theme_label(&settings.0.theme);
|
||
}
|
||
}
|
||
SettingsButton::ToggleColorBlind => {
|
||
settings.0.color_blind_mode = !settings.0.color_blind_mode;
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
if let Ok(mut t) = color_blind_text.single_mut() {
|
||
**t = color_blind_label(settings.0.color_blind_mode);
|
||
}
|
||
}
|
||
SettingsButton::SelectCardBack(idx) => {
|
||
settings.0.selected_card_back = *idx;
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
}
|
||
SettingsButton::SelectBackground(idx) => {
|
||
settings.0.selected_background = *idx;
|
||
persist(&path, &settings.0);
|
||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||
}
|
||
SettingsButton::SyncNow => {
|
||
manual_sync.write(ManualSyncRequestEvent);
|
||
}
|
||
SettingsButton::Done => {
|
||
screen.0 = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn draw_mode_label(mode: &DrawMode) -> String {
|
||
match mode {
|
||
DrawMode::DrawOne => "Draw 1".into(),
|
||
DrawMode::DrawThree => "Draw 3".into(),
|
||
}
|
||
}
|
||
|
||
fn anim_speed_label(speed: &AnimSpeed) -> String {
|
||
match speed {
|
||
AnimSpeed::Normal => "Normal".into(),
|
||
AnimSpeed::Fast => "Fast".into(),
|
||
AnimSpeed::Instant => "Instant".into(),
|
||
}
|
||
}
|
||
|
||
fn theme_label(theme: &Theme) -> String {
|
||
match theme {
|
||
Theme::Green => "Green".into(),
|
||
Theme::Blue => "Blue".into(),
|
||
Theme::Dark => "Dark".into(),
|
||
}
|
||
}
|
||
|
||
fn color_blind_label(enabled: bool) -> String {
|
||
if enabled { "ON".into() } else { "OFF".into() }
|
||
}
|
||
|
||
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
|
||
/// buttons (volume +/−, toggle, cycle), swatch buttons (card-back,
|
||
/// background pickers), and the "Sync Now" button. The "Done" button is
|
||
/// already tagged by `attach_focusable_to_modal_buttons` (it carries
|
||
/// [`ModalButton`]) and is filtered out here.
|
||
///
|
||
/// Walks ancestors via [`ChildOf`] to find the [`ModalScrim`] that owns
|
||
/// the panel so the new [`Focusable`]'s group is bound to that scrim —
|
||
/// same defensive shape as the Phase 1 / 2 attach systems.
|
||
#[allow(clippy::type_complexity)]
|
||
fn attach_focusable_to_settings_buttons(
|
||
mut commands: Commands,
|
||
new_buttons: Query<
|
||
(Entity, &SettingsButton),
|
||
(With<Button>, Without<Focusable>, Without<ModalButton>),
|
||
>,
|
||
parents: Query<&ChildOf>,
|
||
scrims: Query<(), With<ModalScrim>>,
|
||
) {
|
||
for (button, settings_button) in &new_buttons {
|
||
let mut current = button;
|
||
let mut scrim_entity: Option<Entity> = None;
|
||
for _ in 0..32 {
|
||
if scrims.get(current).is_ok() {
|
||
scrim_entity = Some(current);
|
||
break;
|
||
}
|
||
match parents.get(current) {
|
||
Ok(parent) => current = parent.parent(),
|
||
Err(_) => break,
|
||
}
|
||
}
|
||
if let Some(scrim) = scrim_entity {
|
||
commands.entity(button).insert(Focusable {
|
||
group: FocusGroup::Modal(scrim),
|
||
order: settings_button.focus_order(),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Vertical padding (logical px) added around the focused button when
|
||
/// scrolling it into view. Keeps the focus ring's halo visible above /
|
||
/// below the viewport edge.
|
||
const FOCUS_SCROLL_PADDING: f32 = SPACE_2;
|
||
|
||
/// When the focused entity sits outside the visible Settings scroll
|
||
/// viewport, adjust the viewport's [`ScrollPosition`] so the button is
|
||
/// fully visible. No-op when:
|
||
///
|
||
/// - `FocusedButton` is `None`
|
||
/// - the focused entity has no [`UiGlobalTransform`] / [`ComputedNode`]
|
||
/// (e.g. a freshly-spawned modal hasn't laid out yet)
|
||
/// - the focused entity is not a descendant of the
|
||
/// [`SettingsPanelScrollable`] container
|
||
///
|
||
/// The viewport's visible Y range is `[scroll_y, scroll_y +
|
||
/// viewport_height]` in physical pixels (matching `ComputedNode.size`).
|
||
/// The focused button's vertical extent is computed from its
|
||
/// `UiGlobalTransform.translation.y` (centre, physical) ± half its
|
||
/// `ComputedNode.size.y`. Because the scroll container's local
|
||
/// coordinates run [0, content_height] and the visible window is
|
||
/// [scroll_y, scroll_y + viewport], we convert the button's window-
|
||
/// space Y to container-local Y by subtracting the container's window-
|
||
/// space top and adding the current scroll offset.
|
||
#[allow(clippy::type_complexity)]
|
||
fn scroll_focus_into_view(
|
||
focused: Res<FocusedButton>,
|
||
parents: Query<&ChildOf>,
|
||
nodes: Query<(&UiGlobalTransform, &ComputedNode)>,
|
||
mut containers: Query<
|
||
(&mut ScrollPosition, &UiGlobalTransform, &ComputedNode),
|
||
With<SettingsPanelScrollable>,
|
||
>,
|
||
) {
|
||
let Some(target) = focused.0 else { return };
|
||
// Gather button geometry.
|
||
let Ok((target_transform, target_node)) = nodes.get(target) else {
|
||
return;
|
||
};
|
||
|
||
// Walk ancestors looking for the scroll container. Bounded to keep
|
||
// a malformed hierarchy from hanging the system.
|
||
let mut current = target;
|
||
let mut container_entity: Option<Entity> = None;
|
||
for _ in 0..32 {
|
||
if containers.get(current).is_ok() {
|
||
container_entity = Some(current);
|
||
break;
|
||
}
|
||
match parents.get(current) {
|
||
Ok(parent) => current = parent.parent(),
|
||
Err(_) => break,
|
||
}
|
||
}
|
||
let Some(container) = container_entity else { return };
|
||
|
||
let Ok((mut scroll, container_transform, container_node)) =
|
||
containers.get_mut(container)
|
||
else {
|
||
return;
|
||
};
|
||
|
||
// Geometry is reported in physical pixels by `ComputedNode.size` and
|
||
// `UiGlobalTransform.translation`. `ScrollPosition` is in logical px,
|
||
// so convert via `inverse_scale_factor` before we write.
|
||
let inv = target_node.inverse_scale_factor;
|
||
let target_height = target_node.size().y;
|
||
let target_centre_y = target_transform.translation.y;
|
||
let target_top = target_centre_y - target_height * 0.5;
|
||
let target_bottom = target_centre_y + target_height * 0.5;
|
||
|
||
let container_height = container_node.size().y;
|
||
let container_top = container_transform.translation.y - container_height * 0.5;
|
||
|
||
// Convert button window-space Y to container-local Y. The container
|
||
// is currently scrolled by `scroll.0.y` *logical* pixels — multiply
|
||
// by physical-per-logical to compare with physical pixel extents.
|
||
let scroll_phys = scroll.0.y / inv.max(f32::EPSILON);
|
||
let viewport_top = container_top + scroll_phys;
|
||
let viewport_bottom = viewport_top + container_height;
|
||
|
||
// Layout may not have run yet (zero size on first frame) — no
|
||
// sensible scroll target until the container has dimensions.
|
||
if container_height <= 0.0 {
|
||
return;
|
||
}
|
||
|
||
let pad_phys = FOCUS_SCROLL_PADDING / inv.max(f32::EPSILON);
|
||
if target_top < viewport_top {
|
||
// Button extends above the viewport — scroll up.
|
||
let new_top = target_top - pad_phys;
|
||
let delta = new_top - viewport_top;
|
||
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
|
||
} else if target_bottom > viewport_bottom {
|
||
// Button extends below the viewport — scroll down.
|
||
let new_bottom = target_bottom + pad_phys;
|
||
let delta = new_bottom - viewport_bottom;
|
||
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
|
||
}
|
||
}
|
||
|
||
/// Scrolls the settings panel inner card in response to mouse-wheel events.
|
||
///
|
||
/// `offset_y` increases downward (0 = top of content). Scrolling down (ev.y < 0)
|
||
/// adds to the offset; scrolling up subtracts. Clamped to >= 0 so it never
|
||
/// scrolls past the top.
|
||
fn scroll_settings_panel(
|
||
mut scroll_evr: MessageReader<MouseWheel>,
|
||
screen: Res<SettingsScreen>,
|
||
mut scrollables: Query<&mut ScrollPosition, With<SettingsPanelScrollable>>,
|
||
) {
|
||
if !screen.0 {
|
||
scroll_evr.clear();
|
||
return;
|
||
}
|
||
let delta_y: f32 = scroll_evr
|
||
.read()
|
||
.map(|ev| match ev.unit {
|
||
MouseScrollUnit::Line => ev.y * 50.0,
|
||
MouseScrollUnit::Pixel => ev.y,
|
||
})
|
||
.sum();
|
||
if delta_y == 0.0 {
|
||
return;
|
||
}
|
||
for mut sp in scrollables.iter_mut() {
|
||
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Window geometry persistence
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn spawn_settings_panel(
|
||
commands: &mut Commands,
|
||
settings: &Settings,
|
||
sync_status: &str,
|
||
unlocked_card_backs: &[usize],
|
||
unlocked_backgrounds: &[usize],
|
||
scroll_offset: f32,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
|
||
spawn_modal_header(card, "Settings", font_res);
|
||
|
||
// Scrollable body — contains every section so tall content stays
|
||
// reachable on short windows. The Done button below stays fixed
|
||
// outside the scroll so it's always one click away.
|
||
card.spawn((
|
||
SettingsPanelScrollable,
|
||
SettingsScrollNode,
|
||
ScrollPosition(Vec2::new(0.0, scroll_offset)),
|
||
Node {
|
||
flex_direction: FlexDirection::Column,
|
||
row_gap: VAL_SPACE_3,
|
||
max_height: Val::Vh(60.0),
|
||
overflow: Overflow::scroll_y(),
|
||
..default()
|
||
},
|
||
))
|
||
.with_children(|body| {
|
||
// --- Audio ---
|
||
section_label(body, "Audio", font_res);
|
||
volume_row(
|
||
body,
|
||
"SFX Volume",
|
||
settings.sfx_volume,
|
||
SfxVolumeText,
|
||
SettingsButton::SfxDown,
|
||
SettingsButton::SfxUp,
|
||
"Lower sound effects volume.",
|
||
"Raise sound effects volume.",
|
||
font_res,
|
||
);
|
||
volume_row(
|
||
body,
|
||
"Music Volume",
|
||
settings.music_volume,
|
||
MusicVolumeText,
|
||
SettingsButton::MusicDown,
|
||
SettingsButton::MusicUp,
|
||
"Lower music and ambience volume.",
|
||
"Raise music and ambience volume.",
|
||
font_res,
|
||
);
|
||
|
||
// --- Gameplay ---
|
||
section_label(body, "Gameplay", font_res);
|
||
toggle_row(
|
||
body,
|
||
"Draw Mode",
|
||
DrawModeText,
|
||
draw_mode_label(&settings.draw_mode),
|
||
SettingsButton::ToggleDrawMode,
|
||
"Switch between Draw 1 and Draw 3. Takes effect next deal.",
|
||
font_res,
|
||
);
|
||
toggle_row(
|
||
body,
|
||
"Anim Speed",
|
||
AnimSpeedText,
|
||
anim_speed_label(&settings.animation_speed),
|
||
SettingsButton::CycleAnimSpeed,
|
||
"Cycle animation speed: Normal, Fast, Instant.",
|
||
font_res,
|
||
);
|
||
|
||
// --- Cosmetic ---
|
||
section_label(body, "Cosmetic", font_res);
|
||
toggle_row(
|
||
body,
|
||
"Theme",
|
||
ThemeText,
|
||
theme_label(&settings.theme),
|
||
SettingsButton::ToggleTheme,
|
||
"Cycle felt color: Green, Blue, Dark.",
|
||
font_res,
|
||
);
|
||
toggle_row(
|
||
body,
|
||
"Color-blind Mode",
|
||
ColorBlindText,
|
||
color_blind_label(settings.color_blind_mode),
|
||
SettingsButton::ToggleColorBlind,
|
||
"Show shape glyphs alongside suit colors. Suit-blind friendly.",
|
||
font_res,
|
||
);
|
||
picker_row(
|
||
body,
|
||
"Card Back",
|
||
unlocked_card_backs,
|
||
settings.selected_card_back,
|
||
SettingsButton::SelectCardBack,
|
||
"Choose your deck art. New backs unlock at higher levels.",
|
||
font_res,
|
||
);
|
||
picker_row(
|
||
body,
|
||
"Background",
|
||
unlocked_backgrounds,
|
||
settings.selected_background,
|
||
SettingsButton::SelectBackground,
|
||
"Choose your felt art. New felts unlock at higher levels.",
|
||
font_res,
|
||
);
|
||
|
||
// --- Sync ---
|
||
section_label(body, "Sync", font_res);
|
||
sync_row(body, sync_status, font_res);
|
||
});
|
||
|
||
// Done is the only action — primary so the player always knows
|
||
// how to leave the modal. `O` toggles it the same way.
|
||
spawn_modal_actions(card, |actions| {
|
||
spawn_modal_button(
|
||
actions,
|
||
SettingsButton::Done,
|
||
"Done",
|
||
Some("O"),
|
||
ButtonVariant::Primary,
|
||
font_res,
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// Section divider — small lavender label inside the scrollable body.
|
||
fn section_label(parent: &mut ChildSpawnerCommands, title: &str, font_res: Option<&FontResource>) {
|
||
let font = TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_BODY,
|
||
..default()
|
||
};
|
||
parent.spawn((Text::new(title), font, TextColor(TEXT_SECONDARY)));
|
||
}
|
||
|
||
/// `Label 0.80 [−] [+]` — used for SFX and Music volume rows.
|
||
///
|
||
/// `tooltip_down` / `tooltip_up` are attached to the `−` / `+` buttons
|
||
/// respectively so each glyph carries a one-line reminder of which channel
|
||
/// it adjusts.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn volume_row<Marker: Component>(
|
||
parent: &mut ChildSpawnerCommands,
|
||
label: &str,
|
||
value: f32,
|
||
marker: Marker,
|
||
btn_down: SettingsButton,
|
||
btn_up: SettingsButton,
|
||
tooltip_down: &'static str,
|
||
tooltip_up: &'static str,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
let label_font = label_text_font(font_res);
|
||
let value_font = value_text_font(font_res);
|
||
parent
|
||
.spawn(Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
column_gap: VAL_SPACE_2,
|
||
..default()
|
||
})
|
||
.with_children(|row| {
|
||
row.spawn((
|
||
Text::new(label.to_string()),
|
||
label_font,
|
||
TextColor(TEXT_SECONDARY),
|
||
));
|
||
row.spawn((
|
||
marker,
|
||
Text::new(format!("{value:.2}")),
|
||
value_font,
|
||
TextColor(TEXT_PRIMARY),
|
||
));
|
||
icon_button(row, "−", btn_down, tooltip_down, font_res);
|
||
icon_button(row, "+", btn_up, tooltip_up, font_res);
|
||
});
|
||
}
|
||
|
||
/// `Label Value [⇄]` — used for cycle/toggle rows (draw mode, theme,
|
||
/// anim speed, colour-blind).
|
||
///
|
||
/// `tooltip` is attached to the `⇄` button so the cycle glyph carries a
|
||
/// one-line reminder of what it iterates through.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn toggle_row<Marker: Component>(
|
||
parent: &mut ChildSpawnerCommands,
|
||
label: &str,
|
||
marker: Marker,
|
||
value: String,
|
||
action: SettingsButton,
|
||
tooltip: &'static str,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
let label_font = label_text_font(font_res);
|
||
let value_font = value_text_font(font_res);
|
||
parent
|
||
.spawn(Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
column_gap: VAL_SPACE_2,
|
||
..default()
|
||
})
|
||
.with_children(|row| {
|
||
row.spawn((
|
||
Text::new(label.to_string()),
|
||
label_font,
|
||
TextColor(TEXT_SECONDARY),
|
||
));
|
||
row.spawn((marker, Text::new(value), value_font, TextColor(TEXT_PRIMARY)));
|
||
icon_button(row, "⇄", action, tooltip, font_res);
|
||
});
|
||
}
|
||
|
||
/// Wrapping row of indexed swatch buttons — used for card-back and
|
||
/// background pickers. The currently-selected swatch is tinted with
|
||
/// `STATE_SUCCESS` so the user can see it without reading a label.
|
||
///
|
||
/// `tooltip` is attached to every swatch in the row so hovering any chip
|
||
/// reveals what the picker controls and how new entries unlock.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn picker_row(
|
||
parent: &mut ChildSpawnerCommands,
|
||
label: &str,
|
||
unlocked: &[usize],
|
||
selected: usize,
|
||
make_button: impl Fn(usize) -> SettingsButton,
|
||
tooltip: &'static str,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
let label_font = label_text_font(font_res);
|
||
let chip_font = TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_BODY,
|
||
..default()
|
||
};
|
||
parent
|
||
.spawn((
|
||
// The row container is a `FocusRow` so Left / Right arrow
|
||
// keys cycle within its swatch children. Tab still escapes
|
||
// the row to the next focusable in the modal.
|
||
FocusRow,
|
||
Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
column_gap: VAL_SPACE_2,
|
||
flex_wrap: FlexWrap::Wrap,
|
||
..default()
|
||
},
|
||
))
|
||
.with_children(|row| {
|
||
row.spawn((
|
||
Text::new(label.to_string()),
|
||
label_font,
|
||
TextColor(TEXT_SECONDARY),
|
||
));
|
||
// Always show at least swatch 0 (default).
|
||
let entries: &[usize] = if unlocked.is_empty() { &[0] } else { unlocked };
|
||
for &idx in entries {
|
||
let is_selected = idx == selected;
|
||
let bg = if is_selected { STATE_SUCCESS } else { BG_ELEVATED_HI };
|
||
row.spawn((
|
||
make_button(idx),
|
||
Button,
|
||
Tooltip::new(tooltip),
|
||
Node {
|
||
width: Val::Px(SWATCH_PX),
|
||
height: Val::Px(SWATCH_PX),
|
||
justify_content: JustifyContent::Center,
|
||
align_items: AlignItems::Center,
|
||
border: UiRect::all(Val::Px(1.0)),
|
||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||
..default()
|
||
},
|
||
BackgroundColor(bg),
|
||
BorderColor::all(BORDER_SUBTLE),
|
||
))
|
||
.with_children(|b| {
|
||
let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY };
|
||
b.spawn((
|
||
Text::new(format!("{}", idx + 1)),
|
||
chip_font.clone(),
|
||
TextColor(text_color),
|
||
));
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Status text + manual "Sync Now" button.
|
||
fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Option<&FontResource>) {
|
||
let status_font = TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_BODY,
|
||
..default()
|
||
};
|
||
let button_font = TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_CAPTION,
|
||
..default()
|
||
};
|
||
parent
|
||
.spawn(Node {
|
||
flex_direction: FlexDirection::Row,
|
||
align_items: AlignItems::Center,
|
||
column_gap: VAL_SPACE_3,
|
||
..default()
|
||
})
|
||
.with_children(|row| {
|
||
row.spawn((
|
||
SyncStatusText,
|
||
Text::new(status_text.to_string()),
|
||
status_font,
|
||
TextColor(TEXT_SECONDARY),
|
||
));
|
||
// ManualSyncRequestEvent is always registered, so this
|
||
// button is safe to show even when SyncPlugin is absent.
|
||
row.spawn((
|
||
SettingsButton::SyncNow,
|
||
Button,
|
||
Tooltip::new(
|
||
"Push and pull stats now. Runs automatically on launch and exit.",
|
||
),
|
||
Node {
|
||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
||
justify_content: JustifyContent::Center,
|
||
border: UiRect::all(Val::Px(1.0)),
|
||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||
..default()
|
||
},
|
||
BackgroundColor(BG_ELEVATED_HI),
|
||
BorderColor::all(BORDER_SUBTLE),
|
||
))
|
||
.with_children(|b| {
|
||
b.spawn((
|
||
Text::new("Sync Now"),
|
||
button_font,
|
||
TextColor(TEXT_PRIMARY),
|
||
));
|
||
});
|
||
});
|
||
}
|
||
|
||
fn label_text_font(font_res: Option<&FontResource>) -> TextFont {
|
||
TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_BODY_LG,
|
||
..default()
|
||
}
|
||
}
|
||
|
||
fn value_text_font(font_res: Option<&FontResource>) -> TextFont {
|
||
TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_BODY_LG,
|
||
..default()
|
||
}
|
||
}
|
||
|
||
/// Spawns a small square icon button (volume +/−, toggle, cycle).
|
||
///
|
||
/// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every
|
||
/// Settings icon button ships with one because the glyph alone (`+`, `−`,
|
||
/// `⇄`) does not name what it adjusts; the tooltip carries that meaning.
|
||
fn icon_button(
|
||
parent: &mut ChildSpawnerCommands,
|
||
label: &str,
|
||
action: SettingsButton,
|
||
tooltip: &'static str,
|
||
font_res: Option<&FontResource>,
|
||
) {
|
||
let glyph_font = TextFont {
|
||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||
font_size: TYPE_BODY_LG,
|
||
..default()
|
||
};
|
||
parent
|
||
.spawn((
|
||
action,
|
||
Button,
|
||
Tooltip::new(tooltip),
|
||
Node {
|
||
width: Val::Px(ICON_BUTTON_PX),
|
||
height: Val::Px(ICON_BUTTON_PX),
|
||
justify_content: JustifyContent::Center,
|
||
align_items: AlignItems::Center,
|
||
border: UiRect::all(Val::Px(1.0)),
|
||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||
..default()
|
||
},
|
||
BackgroundColor(BG_ELEVATED_HI),
|
||
BorderColor::all(BORDER_SUBTLE),
|
||
))
|
||
.with_children(|b| {
|
||
b.spawn((Text::new(label.to_string()), glyph_font, TextColor(TEXT_PRIMARY)));
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn headless_app() -> App {
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins)
|
||
.add_plugins(SettingsPlugin::headless());
|
||
app.init_resource::<ButtonInput<KeyCode>>();
|
||
app.update();
|
||
app
|
||
}
|
||
|
||
fn press(app: &mut App, key: KeyCode) {
|
||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||
input.release(key);
|
||
input.clear();
|
||
input.press(key);
|
||
}
|
||
|
||
#[test]
|
||
fn defaults_are_loaded() {
|
||
let app = headless_app();
|
||
assert_eq!(
|
||
app.world().resource::<SettingsResource>().0,
|
||
Settings::default()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn pressing_left_bracket_decreases_volume_and_emits_event() {
|
||
let mut app = headless_app();
|
||
let before = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||
|
||
press(&mut app, KeyCode::BracketLeft);
|
||
app.update();
|
||
|
||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||
assert!(after < before);
|
||
|
||
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||
let mut cursor = events.get_cursor();
|
||
assert_eq!(cursor.read(events).count(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn pressing_right_bracket_increases_volume() {
|
||
let mut app = headless_app();
|
||
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 0.5;
|
||
|
||
press(&mut app, KeyCode::BracketRight);
|
||
app.update();
|
||
|
||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||
assert!((after - 0.6).abs() < 1e-3);
|
||
}
|
||
|
||
#[test]
|
||
fn clamped_change_does_not_emit_event() {
|
||
let mut app = headless_app();
|
||
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 1.0;
|
||
|
||
press(&mut app, KeyCode::BracketRight);
|
||
app.update();
|
||
|
||
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||
let mut cursor = events.get_cursor();
|
||
assert_eq!(cursor.read(events).count(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn volume_clamped_at_zero_does_not_emit_event() {
|
||
let mut app = headless_app();
|
||
app.world_mut().resource_mut::<SettingsResource>().0.sfx_volume = 0.0;
|
||
|
||
press(&mut app, KeyCode::BracketLeft);
|
||
app.update();
|
||
|
||
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||
assert!(after >= 0.0, "volume must not go below zero");
|
||
|
||
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
|
||
let mut cursor = events.get_cursor();
|
||
assert_eq!(cursor.read(events).count(), 0, "no event when clamped at floor");
|
||
}
|
||
|
||
#[test]
|
||
fn pressing_o_toggles_settings_screen_flag() {
|
||
let mut app = headless_app();
|
||
assert!(!app.world().resource::<SettingsScreen>().0, "screen is closed initially");
|
||
|
||
press(&mut app, KeyCode::KeyO);
|
||
app.update();
|
||
assert!(app.world().resource::<SettingsScreen>().0, "O opens settings");
|
||
|
||
press(&mut app, KeyCode::KeyO);
|
||
app.update();
|
||
assert!(!app.world().resource::<SettingsScreen>().0, "second O closes settings");
|
||
}
|
||
|
||
// cycle_unlocked pure-function tests
|
||
#[test]
|
||
fn cycle_unlocked_wraps_at_end() {
|
||
// [0, 1, 2] → cycling from 2 wraps to 0
|
||
assert_eq!(cycle_unlocked(&[0, 1, 2], 2), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn cycle_unlocked_advances_normally() {
|
||
assert_eq!(cycle_unlocked(&[0, 1, 2], 0), 1);
|
||
assert_eq!(cycle_unlocked(&[0, 1, 2], 1), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn cycle_unlocked_single_element_stays() {
|
||
// Only one unlockable — cycling always returns it.
|
||
assert_eq!(cycle_unlocked(&[0], 0), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn cycle_unlocked_current_not_in_list_falls_back_to_second() {
|
||
// current=5 is not in [0,1,2]; falls back to pos=0, so next = unlocked[1] = 1
|
||
assert_eq!(cycle_unlocked(&[0, 1, 2], 5), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn cycle_unlocked_empty_returns_zero() {
|
||
assert_eq!(cycle_unlocked(&[], 0), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_is_noop_when_settings_panel_closed() {
|
||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||
let mut app = headless_app();
|
||
// Panel starts closed (SettingsScreen(false)); spawn a scrollable entity.
|
||
let entity = app
|
||
.world_mut()
|
||
.spawn((SettingsPanelScrollable, ScrollPosition::default()))
|
||
.id();
|
||
// Send a downward scroll event while the panel is closed.
|
||
app.world_mut().write_message(MouseWheel {
|
||
unit: MouseScrollUnit::Line,
|
||
x: 0.0,
|
||
y: -3.0,
|
||
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||
});
|
||
app.update();
|
||
// ScrollPosition must remain at 0.0 — panel was closed.
|
||
let offset = app
|
||
.world()
|
||
.entity(entity)
|
||
.get::<ScrollPosition>()
|
||
.unwrap()
|
||
.0.y;
|
||
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_moves_offset_when_panel_open() {
|
||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||
let mut app = headless_app();
|
||
// Open the panel.
|
||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||
// Spawn a scrollable entity with an existing offset so we can distinguish clamping.
|
||
let entity = app
|
||
.world_mut()
|
||
.spawn((
|
||
SettingsPanelScrollable,
|
||
ScrollPosition(Vec2::new(0.0, 100.0)),
|
||
))
|
||
.id();
|
||
// Scroll down by 2 lines (50 px/line → +100 px added to offset_y).
|
||
app.world_mut().write_message(MouseWheel {
|
||
unit: MouseScrollUnit::Line,
|
||
x: 0.0,
|
||
y: -2.0,
|
||
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||
});
|
||
app.update();
|
||
let offset = app
|
||
.world()
|
||
.entity(entity)
|
||
.get::<ScrollPosition>()
|
||
.unwrap()
|
||
.0.y;
|
||
assert!((offset - 200.0).abs() < 1e-3, "scrolling down should increase offset_y; got {offset}");
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Phase 3 — keyboard focus ring, Settings buttons + FocusRow
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// Headless app that runs the *real* (UI-enabled) `SettingsPlugin`
|
||
/// alongside `UiModalPlugin` and `UiFocusPlugin`, so the spawn /
|
||
/// auto-tag systems fire end-to-end without writing to disk.
|
||
fn headless_app_with_focus() -> App {
|
||
use crate::ui_focus::UiFocusPlugin;
|
||
use crate::ui_modal::UiModalPlugin;
|
||
|
||
let mut app = App::new();
|
||
app.add_plugins(MinimalPlugins)
|
||
.add_plugins(UiModalPlugin)
|
||
.add_plugins(UiFocusPlugin)
|
||
.add_plugins(SettingsPlugin {
|
||
// No persistence — keep the test isolated.
|
||
storage_path: None,
|
||
ui_enabled: true,
|
||
});
|
||
app.init_resource::<ButtonInput<KeyCode>>();
|
||
app.update();
|
||
app
|
||
}
|
||
|
||
#[test]
|
||
fn settings_buttons_get_focusable_marker() {
|
||
let mut app = headless_app_with_focus();
|
||
|
||
// Open the panel.
|
||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||
app.update();
|
||
// Two more ticks: the first runs `sync_settings_panel_visibility`
|
||
// and queues the spawn commands; the second flushes them and
|
||
// runs `attach_focusable_to_settings_buttons`.
|
||
app.update();
|
||
app.update();
|
||
|
||
// Every bespoke `SettingsButton` (not `Done`, which is also a
|
||
// `ModalButton`) must carry a `Focusable`.
|
||
let untagged: Vec<&SettingsButton> = app
|
||
.world_mut()
|
||
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
|
||
.iter(app.world())
|
||
.collect();
|
||
|
||
assert!(
|
||
untagged.is_empty(),
|
||
"every bespoke Settings button must carry Focusable; missing: {:?}",
|
||
untagged
|
||
);
|
||
|
||
// And there must be at least one tagged `SettingsButton` so the
|
||
// assertion above isn't vacuously true (the panel really did
|
||
// spawn).
|
||
let tagged_count = app
|
||
.world_mut()
|
||
.query_filtered::<&SettingsButton, With<Focusable>>()
|
||
.iter(app.world())
|
||
.count();
|
||
assert!(
|
||
tagged_count >= 6,
|
||
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
|
||
);
|
||
}
|
||
|
||
/// Every bespoke `SettingsButton` (volume +/−, toggles, swatches,
|
||
/// Sync Now) must spawn with a `Tooltip` so the glyph-only icons and
|
||
/// indexed swatches carry hover-reveal context. Mirrors
|
||
/// `settings_buttons_get_focusable_marker` (Phase 3 focus test) so
|
||
/// the invariant — every interactive Settings element except the
|
||
/// `Done` modal button has a tooltip — is asserted consistently.
|
||
#[test]
|
||
fn settings_buttons_carry_tooltip() {
|
||
let mut app = headless_app_with_focus();
|
||
|
||
// Open the panel and let spawn + child-flush run.
|
||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||
app.update();
|
||
app.update();
|
||
app.update();
|
||
|
||
// No bespoke `SettingsButton` (i.e. excluding `Done`, which is
|
||
// also a `ModalButton`) may be missing a `Tooltip`.
|
||
let untipped: Vec<&SettingsButton> = app
|
||
.world_mut()
|
||
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
|
||
.iter(app.world())
|
||
.collect();
|
||
assert!(
|
||
untipped.is_empty(),
|
||
"every bespoke Settings button must carry Tooltip; missing: {:?}",
|
||
untipped
|
||
);
|
||
|
||
// And there must be at least 6 tipped buttons so the assertion
|
||
// above isn't vacuously true: SFX +/−, Music +/−, Draw Mode,
|
||
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
|
||
// card-back and one background swatch — well over the floor.
|
||
let tipped_count = app
|
||
.world_mut()
|
||
.query_filtered::<&SettingsButton, With<Tooltip>>()
|
||
.iter(app.world())
|
||
.count();
|
||
assert!(
|
||
tipped_count >= 6,
|
||
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
|
||
);
|
||
|
||
// Spot-check: the Sync Now button's tooltip text is the
|
||
// canonical microcopy. We find it via the `SettingsButton`
|
||
// discriminant — there is exactly one Sync Now entity per panel.
|
||
let sync_tip = app
|
||
.world_mut()
|
||
.query::<(&SettingsButton, &Tooltip)>()
|
||
.iter(app.world())
|
||
.find_map(|(btn, tip)| matches!(btn, SettingsButton::SyncNow).then(|| tip.0.clone()))
|
||
.expect("Sync Now button should spawn with a Tooltip");
|
||
assert_eq!(
|
||
sync_tip.as_ref(),
|
||
"Push and pull stats now. Runs automatically on launch and exit.",
|
||
"Sync Now tooltip must use the canonical microcopy"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn settings_picker_rows_get_focus_row_marker() {
|
||
let mut app = headless_app_with_focus();
|
||
|
||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||
app.update();
|
||
app.update();
|
||
app.update();
|
||
|
||
// Two picker rows are spawned (card-back + background); each
|
||
// must carry the FocusRow marker.
|
||
let row_count = app
|
||
.world_mut()
|
||
.query_filtered::<Entity, With<FocusRow>>()
|
||
.iter(app.world())
|
||
.count();
|
||
assert!(
|
||
row_count >= 2,
|
||
"expected at least two FocusRow containers (card-back + background); got {row_count}"
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Window geometry persistence
|
||
// -----------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn should_persist_geometry_respects_debounce_window() {
|
||
// Within the debounce window: not yet.
|
||
assert!(!should_persist_geometry(10.0, 9.7));
|
||
assert!(!should_persist_geometry(
|
||
10.0,
|
||
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS + 0.01
|
||
));
|
||
// Exactly the debounce window: allowed (>= comparison).
|
||
assert!(should_persist_geometry(
|
||
10.0,
|
||
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS
|
||
));
|
||
// Well past the debounce window: allowed.
|
||
assert!(should_persist_geometry(20.0, 10.0));
|
||
}
|
||
|
||
#[test]
|
||
fn merge_geometry_uses_existing_when_event_components_missing() {
|
||
let existing = WindowGeometry { width: 1280, height: 800, x: 100, y: 50 };
|
||
// Position-only event keeps existing size.
|
||
let merged = merge_geometry(Some(existing), None, Some((200, 75))).unwrap();
|
||
assert_eq!(merged.width, 1280);
|
||
assert_eq!(merged.height, 800);
|
||
assert_eq!(merged.x, 200);
|
||
assert_eq!(merged.y, 75);
|
||
// Size-only event keeps existing position.
|
||
let merged = merge_geometry(Some(existing), Some((1024, 768)), None).unwrap();
|
||
assert_eq!(merged.width, 1024);
|
||
assert_eq!(merged.height, 768);
|
||
assert_eq!(merged.x, 100);
|
||
assert_eq!(merged.y, 50);
|
||
}
|
||
|
||
#[test]
|
||
fn merge_geometry_returns_none_when_size_unknown() {
|
||
// No existing geometry, no size in the event → can't fabricate one.
|
||
assert!(merge_geometry(None, None, Some((10, 20))).is_none());
|
||
}
|
||
|
||
/// Drives `app.update()` past [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] using
|
||
/// `TimeUpdateStrategy::ManualDuration`. `Time<Virtual>` clamps each
|
||
/// frame's delta to `max_delta` (default 250 ms), so we step in 150 ms
|
||
/// slices and run enough ticks to comfortably exceed the debounce
|
||
/// window after the first record tick has set `last_changed_secs`.
|
||
fn advance_past_geometry_debounce(app: &mut App) {
|
||
use bevy::time::TimeUpdateStrategy;
|
||
use std::time::Duration;
|
||
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
|
||
0.15,
|
||
)));
|
||
// Tick 1 sets last_changed_secs from any pending events. Each
|
||
// subsequent tick advances the clock by 150 ms; five ticks total
|
||
// buys 0.75 s of elapsed time relative to the record tick — well
|
||
// past the 0.5 s debounce window.
|
||
for _ in 0..5 {
|
||
app.update();
|
||
}
|
||
}
|
||
|
||
fn fire_resize(app: &mut App, width: f32, height: f32) {
|
||
app.world_mut().write_message(WindowResized {
|
||
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||
width,
|
||
height,
|
||
});
|
||
}
|
||
|
||
fn fire_move(app: &mut App, x: i32, y: i32) {
|
||
app.world_mut().write_message(WindowMoved {
|
||
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||
position: IVec2::new(x, y),
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn resize_event_then_quiet_persists_window_geometry() {
|
||
let mut app = headless_app();
|
||
// Sanity: geometry starts unset (default).
|
||
assert!(
|
||
app.world()
|
||
.resource::<SettingsResource>()
|
||
.0
|
||
.window_geometry
|
||
.is_none()
|
||
);
|
||
|
||
// Fire a resize, then go quiet for past the debounce.
|
||
fire_resize(&mut app, 1500.0, 950.0);
|
||
advance_past_geometry_debounce(&mut app);
|
||
|
||
let geom = app
|
||
.world()
|
||
.resource::<SettingsResource>()
|
||
.0
|
||
.window_geometry
|
||
.expect("geometry should be persisted after debounce");
|
||
assert_eq!(geom.width, 1500);
|
||
assert_eq!(geom.height, 950);
|
||
// Position not yet observed → defaults to 0, 0 since there was
|
||
// no existing geometry to fall back on.
|
||
assert_eq!(geom.x, 0);
|
||
assert_eq!(geom.y, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn move_event_after_resize_updates_position_only() {
|
||
let mut app = headless_app();
|
||
|
||
// First, establish a baseline geometry via a resize event.
|
||
fire_resize(&mut app, 1280.0, 800.0);
|
||
advance_past_geometry_debounce(&mut app);
|
||
let baseline = app
|
||
.world()
|
||
.resource::<SettingsResource>()
|
||
.0
|
||
.window_geometry
|
||
.unwrap();
|
||
assert_eq!(baseline.width, 1280);
|
||
|
||
// Now fire a move-only event — size must be preserved from the
|
||
// existing geometry.
|
||
fire_move(&mut app, 250, 175);
|
||
advance_past_geometry_debounce(&mut app);
|
||
|
||
let geom = app
|
||
.world()
|
||
.resource::<SettingsResource>()
|
||
.0
|
||
.window_geometry
|
||
.unwrap();
|
||
assert_eq!(geom.width, 1280, "size must be preserved across a move-only update");
|
||
assert_eq!(geom.height, 800);
|
||
assert_eq!(geom.x, 250);
|
||
assert_eq!(geom.y, 175);
|
||
}
|
||
|
||
#[test]
|
||
fn rapid_resize_storm_only_persists_final_size() {
|
||
let mut app = headless_app();
|
||
|
||
// Burst of resize events on a single frame — only the last one
|
||
// should be the eventually-persisted size.
|
||
fire_resize(&mut app, 900.0, 600.0);
|
||
fire_resize(&mut app, 1100.0, 700.0);
|
||
fire_resize(&mut app, 1400.0, 850.0);
|
||
advance_past_geometry_debounce(&mut app);
|
||
|
||
let geom = app
|
||
.world()
|
||
.resource::<SettingsResource>()
|
||
.0
|
||
.window_geometry
|
||
.unwrap();
|
||
assert_eq!((geom.width, geom.height), (1400, 850));
|
||
}
|
||
|
||
#[test]
|
||
fn no_window_events_no_geometry_change() {
|
||
let mut app = headless_app();
|
||
// Just advance time — without any events, settings must stay clean.
|
||
advance_past_geometry_debounce(&mut app);
|
||
assert!(
|
||
app.world()
|
||
.resource::<SettingsResource>()
|
||
.0
|
||
.window_geometry
|
||
.is_none()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn scroll_clamps_offset_to_zero_at_top() {
|
||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||
let mut app = headless_app();
|
||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||
// Entity starts at 10 px offset.
|
||
let entity = app
|
||
.world_mut()
|
||
.spawn((
|
||
SettingsPanelScrollable,
|
||
ScrollPosition(Vec2::new(0.0, 10.0)),
|
||
))
|
||
.id();
|
||
// Scroll up by 5 lines → would subtract 250 px, but must clamp to 0.
|
||
app.world_mut().write_message(MouseWheel {
|
||
unit: MouseScrollUnit::Line,
|
||
x: 0.0,
|
||
y: 5.0,
|
||
window: bevy::ecs::entity::Entity::PLACEHOLDER,
|
||
});
|
||
app.update();
|
||
let offset = app
|
||
.world()
|
||
.entity(entity)
|
||
.get::<ScrollPosition>()
|
||
.unwrap()
|
||
.0.y;
|
||
assert_eq!(offset, 0.0, "scrolling past top must clamp to 0, got {offset}");
|
||
}
|
||
}
|