Merge pull request 'refactor(engine): split settings_plugin runtime code into submodules' (#127) from refactor/settings-plugin-submodules into master
This commit was merged in pull request #127.
This commit is contained in:
@@ -0,0 +1,627 @@
|
|||||||
|
//! Input handling for the Settings panel: button presses, keyboard
|
||||||
|
//! accelerators, focus attachment, and scrolling.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
|
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||||
|
use solitaire_core::DrawStockConfig;
|
||||||
|
use solitaire_data::{
|
||||||
|
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS,
|
||||||
|
settings::Theme,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::events::{
|
||||||
|
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
|
||||||
|
SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
|
||||||
|
};
|
||||||
|
use crate::ui_focus::{FocusGroup, Focusable, FocusedButton};
|
||||||
|
use crate::ui_modal::{ModalButton, ModalScrim};
|
||||||
|
use crate::ui_theme::SPACE_2;
|
||||||
|
|
||||||
|
pub(super) fn handle_volume_keys(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
mut settings: ResMut<SettingsResource>,
|
||||||
|
path: Res<SettingsStoragePath>,
|
||||||
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
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()));
|
||||||
|
toast.write(InfoToastEvent(format!(
|
||||||
|
"SFX volume: {}%",
|
||||||
|
(after * 100.0).round() as i32
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens or closes the Settings panel — `O` keyboard accelerator or
|
||||||
|
/// `ToggleSettingsRequestEvent` from the HUD Menu popover.
|
||||||
|
pub(super) 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reacts to button presses inside the Settings panel.
|
||||||
|
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||||
|
pub(super) 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 sfx_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut music_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<MusicVolumeText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut draw_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<DrawModeText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut theme_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<ThemeText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut anim_speed_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<AnimSpeedText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut color_blind_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<ColorBlindText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut high_contrast_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HighContrastText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut reduce_motion_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<ReduceMotionText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
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 {
|
||||||
|
DrawStockConfig::DrawOne => DrawStockConfig::DrawThree,
|
||||||
|
DrawStockConfig::DrawThree => DrawStockConfig::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::TooltipDelayDown => {
|
||||||
|
let before = settings.0.tooltip_delay_secs;
|
||||||
|
let after = settings.0.adjust_tooltip_delay(-TOOLTIP_DELAY_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by `update_tooltip_delay_text`
|
||||||
|
// on the next frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::TooltipDelayUp => {
|
||||||
|
let before = settings.0.tooltip_delay_secs;
|
||||||
|
let after = settings.0.adjust_tooltip_delay(TOOLTIP_DELAY_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::TimeBonusDown => {
|
||||||
|
let before = settings.0.time_bonus_multiplier;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_time_bonus_multiplier(-TIME_BONUS_MULTIPLIER_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by
|
||||||
|
// `update_time_bonus_multiplier_text` on the next
|
||||||
|
// frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::TimeBonusUp => {
|
||||||
|
let before = settings.0.time_bonus_multiplier;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_time_bonus_multiplier(TIME_BONUS_MULTIPLIER_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ReplayMoveIntervalDown => {
|
||||||
|
let before = settings.0.replay_move_interval_secs;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_replay_move_interval(-REPLAY_MOVE_INTERVAL_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by
|
||||||
|
// `update_replay_move_interval_text` on the next
|
||||||
|
// frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ReplayMoveIntervalUp => {
|
||||||
|
let before = settings.0.replay_move_interval_secs;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_replay_move_interval(REPLAY_MOVE_INTERVAL_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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::ToggleHighContrast => {
|
||||||
|
settings.0.high_contrast_mode = !settings.0.high_contrast_mode;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = high_contrast_text.single_mut() {
|
||||||
|
**t = on_off_label(settings.0.high_contrast_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleReduceMotion => {
|
||||||
|
settings.0.reduce_motion_mode = !settings.0.reduce_motion_mode;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = reduce_motion_text.single_mut() {
|
||||||
|
**t = on_off_label(settings.0.reduce_motion_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleTouchInputMode => {
|
||||||
|
use solitaire_data::settings::TouchInputMode;
|
||||||
|
settings.0.touch_input_mode = match settings.0.touch_input_mode {
|
||||||
|
TouchInputMode::OneTap => TouchInputMode::TapToSelect,
|
||||||
|
TouchInputMode::TapToSelect => TouchInputMode::OneTap,
|
||||||
|
};
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// Text refreshed by `update_touch_input_mode_text` next frame.
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleWinnableDealsOnly => {
|
||||||
|
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by `update_winnable_deals_only_text`
|
||||||
|
// on the next frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleAnalytics => {
|
||||||
|
settings.0.analytics_enabled = !settings.0.analytics_enabled;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// Text refreshed by `update_analytics_enabled_text` next frame.
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleSmartDefaultSize => {
|
||||||
|
settings.0.disable_smart_default_size = !settings.0.disable_smart_default_size;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by
|
||||||
|
// `update_smart_default_size_text` next frame. The
|
||||||
|
// sizer system is gated only at startup, so flipping
|
||||||
|
// this mid-session takes effect on the next launch —
|
||||||
|
// documented on the field in `solitaire_data::Settings`.
|
||||||
|
}
|
||||||
|
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::SelectTheme(theme_id) => {
|
||||||
|
if settings.0.selected_theme_id != *theme_id {
|
||||||
|
settings.0.selected_theme_id = theme_id.clone();
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
SettingsButton::ScanThemes => {
|
||||||
|
// Handled by `handle_scan_themes`.
|
||||||
|
}
|
||||||
|
SettingsButton::SyncNow
|
||||||
|
| SettingsButton::ConnectSync
|
||||||
|
| SettingsButton::DisconnectSync
|
||||||
|
| SettingsButton::DeleteAccount => {
|
||||||
|
// Handled by `handle_sync_buttons`.
|
||||||
|
}
|
||||||
|
SettingsButton::Done => {
|
||||||
|
screen.0 = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
|
||||||
|
/// and Delete Account. Split from `handle_settings_buttons` to stay within
|
||||||
|
/// Bevy's 16-parameter system limit.
|
||||||
|
pub(super) fn handle_sync_buttons(
|
||||||
|
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||||||
|
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
||||||
|
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||||
|
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
||||||
|
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
||||||
|
mut screen: ResMut<SettingsScreen>,
|
||||||
|
) {
|
||||||
|
for (interaction, button) in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match button {
|
||||||
|
SettingsButton::SyncNow => {
|
||||||
|
manual_sync.write(ManualSyncRequestEvent);
|
||||||
|
}
|
||||||
|
SettingsButton::ConnectSync => {
|
||||||
|
// Close settings before the sync-setup modal opens so the
|
||||||
|
// guard in open_sync_setup_modal doesn't block on our own scrim.
|
||||||
|
screen.0 = false;
|
||||||
|
configure_sync.write(SyncConfigureRequestEvent);
|
||||||
|
}
|
||||||
|
SettingsButton::DisconnectSync => {
|
||||||
|
logout_sync.write(SyncLogoutRequestEvent);
|
||||||
|
}
|
||||||
|
SettingsButton::DeleteAccount => {
|
||||||
|
delete_account.write(DeleteAccountRequestEvent);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
||||||
|
pub(super) 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)]
|
||||||
|
pub(super) 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.
|
||||||
|
pub(super) 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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::ui_focus::{FocusRow, Focusable};
|
||||||
|
use crate::ui_modal::ModalButton;
|
||||||
|
use crate::ui_tooltip::Tooltip;
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -326,9 +329,7 @@ fn settings_buttons_carry_tooltip() {
|
|||||||
.world_mut()
|
.world_mut()
|
||||||
.query::<(&SettingsButton, &Tooltip)>()
|
.query::<(&SettingsButton, &Tooltip)>()
|
||||||
.iter(app.world())
|
.iter(app.world())
|
||||||
.find_map(|(btn, tip)| {
|
.find_map(|(btn, tip)| matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone()))
|
||||||
matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone())
|
|
||||||
})
|
|
||||||
.expect("Connect button should spawn with a Tooltip when backend is Local");
|
.expect("Connect button should spawn with a Tooltip when backend is Local");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
connect_tip.as_ref(),
|
connect_tip.as_ref(),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,487 @@
|
|||||||
|
//! Per-frame value-text updater systems and their label helpers.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use solitaire_core::DrawStockConfig;
|
||||||
|
use solitaire_data::{AnimSpeed, settings::Theme};
|
||||||
|
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::progress_plugin::ProgressResource;
|
||||||
|
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||||
|
use crate::theme::ThemeThumbnailCache;
|
||||||
|
use crate::ui_modal::ModalScrim;
|
||||||
|
use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder};
|
||||||
|
|
||||||
|
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
|
||||||
|
/// despawns it when it becomes `false`.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn sync_settings_panel_visibility(
|
||||||
|
screen: Res<SettingsScreen>,
|
||||||
|
panels: Query<Entity, With<SettingsPanel>>,
|
||||||
|
other_modal_scrims: Query<(), (With<ModalScrim>, Without<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>>,
|
||||||
|
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
||||||
|
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
|
||||||
|
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
||||||
|
) {
|
||||||
|
if !screen.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if screen.0 {
|
||||||
|
if panels.is_empty() && other_modal_scrims.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());
|
||||||
|
// Snapshot themes by id, display_name and (optional)
|
||||||
|
// thumbnail pair so spawn_settings_panel doesn't have to
|
||||||
|
// know about the registry / cache shapes. Empty when
|
||||||
|
// ThemeRegistryPlugin isn't installed (tests under
|
||||||
|
// MinimalPlugins) — the picker row simply won't render.
|
||||||
|
// Missing thumbnails (cache not ready, or partial user
|
||||||
|
// theme) leave `thumbnails: None` so the chip renders its
|
||||||
|
// plain-text fallback instead of a broken sprite.
|
||||||
|
let themes: Vec<ThemePickerEntry> = theme_registry
|
||||||
|
.as_deref()
|
||||||
|
.map(|r| {
|
||||||
|
r.iter()
|
||||||
|
.map(|e| ThemePickerEntry {
|
||||||
|
id: e.id.clone(),
|
||||||
|
display_name: e.display_name.clone(),
|
||||||
|
thumbnails: theme_thumbs
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|c| c.get(&e.id))
|
||||||
|
.filter(|p| p.is_fully_populated())
|
||||||
|
.cloned(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
// The active card-art theme can supply its own back image —
|
||||||
|
// see `card_plugin::CardImageSet::theme_back`. When that is
|
||||||
|
// populated the legacy "Card Back" picker has no visible
|
||||||
|
// effect, so we render it muted with an explanatory caption
|
||||||
|
// rather than letting the player click swatches that do
|
||||||
|
// nothing. Absent under `MinimalPlugins`; treated as
|
||||||
|
// "no override" in that case.
|
||||||
|
let theme_overrides_back = card_images
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|cs| cs.theme_back.is_some());
|
||||||
|
spawn_settings_panel(
|
||||||
|
&mut commands,
|
||||||
|
&settings.0,
|
||||||
|
&status_label,
|
||||||
|
unlocked_backs,
|
||||||
|
unlocked_bgs,
|
||||||
|
&themes,
|
||||||
|
scroll_pos.0,
|
||||||
|
font_res.as_deref(),
|
||||||
|
theme_overrides_back,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the sync-status text node current while the panel is open.
|
||||||
|
pub(super) 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_high_contrast_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<HighContrastText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = on_off_label(settings.0.high_contrast_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repaints `BorderColor` on every entity tagged with
|
||||||
|
/// [`HighContrastBorder`] based on `Settings::high_contrast_mode`.
|
||||||
|
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
|
||||||
|
/// (`#a0a0a0`). Compares against the current border colour and
|
||||||
|
/// only mutates when different so Bevy's change-detection
|
||||||
|
/// doesn't trigger repaints every frame.
|
||||||
|
///
|
||||||
|
/// Spec at `design-system.md` §Accessibility (#2): under HC,
|
||||||
|
/// outlines boost from `#505050` (BORDER_STRONG) to `#a0a0a0` so
|
||||||
|
/// modal panels, popover edges, and focus-ring carriers stay
|
||||||
|
/// legible on low-quality displays / for low-vision users.
|
||||||
|
///
|
||||||
|
/// Tagged sites in v0.21.x: the modal scaffold's card border
|
||||||
|
/// (`ui_modal::spawn_modal`). More sites can be tagged in
|
||||||
|
/// follow-ups by adding `HighContrastBorder::with_default(...)`
|
||||||
|
/// to their spawn tuple.
|
||||||
|
pub(super) fn update_high_contrast_borders(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut borders: Query<(&HighContrastBorder, &mut BorderColor)>,
|
||||||
|
) {
|
||||||
|
let high_contrast = settings.0.high_contrast_mode;
|
||||||
|
for (marker, mut border) in borders.iter_mut() {
|
||||||
|
let target = if high_contrast {
|
||||||
|
BORDER_SUBTLE_HC
|
||||||
|
} else {
|
||||||
|
marker.default_color
|
||||||
|
};
|
||||||
|
// Only mutate when actually different — avoids per-frame
|
||||||
|
// change-detection churn. `border.left` is representative
|
||||||
|
// because every tagged site uses `BorderColor::all(...)`.
|
||||||
|
if border.left != target {
|
||||||
|
*border = BorderColor::all(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repaints `BackgroundColor` on every entity tagged with
|
||||||
|
/// [`HighContrastBackground`] based on `Settings::high_contrast_mode`.
|
||||||
|
/// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC`
|
||||||
|
/// (`#a0a0a0`). Compares against the current background and only
|
||||||
|
/// mutates when different so Bevy's change-detection doesn't trigger
|
||||||
|
/// repaints every frame.
|
||||||
|
///
|
||||||
|
/// Parallel to [`update_high_contrast_borders`]. Same on/off rule,
|
||||||
|
/// same change-suppression idiom, different colour channel —
|
||||||
|
/// `BackgroundColor` for tick marks, decorative strips, fine
|
||||||
|
/// separators that paint their shape directly rather than via a
|
||||||
|
/// `BorderColor` on a wider Node.
|
||||||
|
///
|
||||||
|
/// Tagged sites in v0.21.x: the replay overlay's 1 px scrub track
|
||||||
|
/// + 5 quarter-mark notch ticks (`replay_overlay::spawn_overlay`).
|
||||||
|
///
|
||||||
|
/// More sites can be tagged in follow-ups by adding
|
||||||
|
/// `HighContrastBackground::with_default(...)` to their spawn tuple.
|
||||||
|
pub(super) fn update_high_contrast_backgrounds(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut backgrounds: Query<(&HighContrastBackground, &mut BackgroundColor)>,
|
||||||
|
) {
|
||||||
|
let high_contrast = settings.0.high_contrast_mode;
|
||||||
|
for (marker, mut bg) in backgrounds.iter_mut() {
|
||||||
|
let target = if high_contrast {
|
||||||
|
marker.hc_color
|
||||||
|
} else {
|
||||||
|
marker.default_color
|
||||||
|
};
|
||||||
|
if bg.0 != target {
|
||||||
|
*bg = BackgroundColor(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_reduce_motion_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<ReduceMotionText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = on_off_label(settings.0.reduce_motion_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_touch_input_mode_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<TouchInputModeText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = touch_input_mode_label(&settings.0.touch_input_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live "Winnable deals only" toggle value in the
|
||||||
|
/// Gameplay section whenever `SettingsResource` changes (button click,
|
||||||
|
/// hand-edited `settings.json` reload, etc.).
|
||||||
|
pub(super) fn update_winnable_deals_only_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<WinnableDealsOnlyText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = winnable_deals_only_label(settings.0.winnable_deals_only);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live "Share usage data" toggle value in the Privacy section
|
||||||
|
/// whenever `SettingsResource` changes.
|
||||||
|
pub(super) fn update_analytics_enabled_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<AnalyticsEnabledText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = on_off_label(settings.0.analytics_enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live "Smart window size" toggle value whenever
|
||||||
|
/// `SettingsResource` changes. The flag is stored negatively as
|
||||||
|
/// `disable_smart_default_size`, so the label inverts.
|
||||||
|
pub(super) fn update_smart_default_size_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<SmartDefaultSizeText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = smart_default_size_label(!settings.0.disable_smart_default_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live tooltip-delay value in the Gameplay section
|
||||||
|
/// whenever `SettingsResource` changes (slider buttons, hand-edited
|
||||||
|
/// settings.json reload, etc.).
|
||||||
|
pub(super) fn update_tooltip_delay_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<TooltipDelayText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = tooltip_delay_label(settings.0.tooltip_delay_secs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live time-bonus-multiplier value in the Gameplay
|
||||||
|
/// section whenever `SettingsResource` changes.
|
||||||
|
pub(super) fn update_time_bonus_multiplier_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<TimeBonusMultiplierText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = time_bonus_label(settings.0.time_bonus_multiplier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live replay-playback per-move-interval value in the
|
||||||
|
/// Gameplay section whenever `SettingsResource` changes (slider buttons,
|
||||||
|
/// hand-edited settings.json reload, etc.).
|
||||||
|
pub(super) fn update_replay_move_interval_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<ReplayMoveIntervalText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = replay_move_interval_label(settings.0.replay_move_interval_secs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn card_back_label(idx: usize) -> String {
|
||||||
|
if idx == 0 {
|
||||||
|
"Default".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Style {idx}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn background_label(idx: usize) -> String {
|
||||||
|
if idx == 0 {
|
||||||
|
"Default".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Style {idx}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) 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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn draw_mode_label(mode: &DrawStockConfig) -> String {
|
||||||
|
match mode {
|
||||||
|
DrawStockConfig::DrawOne => "Draw 1".into(),
|
||||||
|
DrawStockConfig::DrawThree => "Draw 3".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn anim_speed_label(speed: &AnimSpeed) -> String {
|
||||||
|
match speed {
|
||||||
|
AnimSpeed::Normal => "Normal".into(),
|
||||||
|
AnimSpeed::Fast => "Fast".into(),
|
||||||
|
AnimSpeed::Instant => "Instant".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn theme_label(theme: &Theme) -> String {
|
||||||
|
match theme {
|
||||||
|
Theme::Green => "Green".into(),
|
||||||
|
Theme::Blue => "Blue".into(),
|
||||||
|
Theme::Dark => "Dark".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn color_blind_label(enabled: bool) -> String {
|
||||||
|
if enabled { "ON".into() } else { "OFF".into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic ON/OFF label shared by the high-contrast and reduce-
|
||||||
|
/// motion accessibility toggles. Same format as
|
||||||
|
/// [`color_blind_label`] / [`winnable_deals_only_label`] —
|
||||||
|
/// keeping all simple boolean toggle rows visually uniform.
|
||||||
|
pub(super) fn on_off_label(enabled: bool) -> String {
|
||||||
|
if enabled { "ON".into() } else { "OFF".into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display string for the "Winnable deals only" toggle. Mirrors
|
||||||
|
/// [`color_blind_label`] — "ON" / "OFF" — so the layout is uniform
|
||||||
|
/// with the rest of the Gameplay-section toggles.
|
||||||
|
pub(super) fn winnable_deals_only_label(enabled: bool) -> String {
|
||||||
|
if enabled { "ON".into() } else { "OFF".into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn touch_input_mode_label(mode: &solitaire_data::settings::TouchInputMode) -> String {
|
||||||
|
use solitaire_data::settings::TouchInputMode;
|
||||||
|
match mode {
|
||||||
|
TouchInputMode::OneTap => "One-tap".into(),
|
||||||
|
TouchInputMode::TapToSelect => "Tap to select".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display string for the "Smart window size" toggle. The argument
|
||||||
|
/// is the *enabled* state (i.e. the inverse of the underlying
|
||||||
|
/// `disable_smart_default_size` field) so reading the label gives
|
||||||
|
/// the player intuitive ON/OFF semantics.
|
||||||
|
pub(super) fn smart_default_size_label(enabled: bool) -> String {
|
||||||
|
if enabled { "ON".into() } else { "OFF".into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats the tooltip-hover delay for display in the Settings panel.
|
||||||
|
/// `0.0` reads as `"Instant"` so the zero-delay case has a name; any
|
||||||
|
/// other value prints as `"{n:.1} s"` (e.g. `"0.5 s"`, `"1.2 s"`).
|
||||||
|
pub(super) fn tooltip_delay_label(secs: f32) -> String {
|
||||||
|
if secs <= 0.0 {
|
||||||
|
"Instant".into()
|
||||||
|
} else {
|
||||||
|
format!("{secs:.1} s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats the cosmetic time-bonus multiplier for display in the
|
||||||
|
/// Settings panel. `0.0` reads as `"Off"` so the player understands the
|
||||||
|
/// time-bonus row will be hidden; any other value prints as
|
||||||
|
/// `"{n:.1}×"` (e.g. `"1.0×"`, `"1.5×"`).
|
||||||
|
pub(super) fn time_bonus_label(value: f32) -> String {
|
||||||
|
if value <= 0.0 {
|
||||||
|
"Off".into()
|
||||||
|
} else {
|
||||||
|
format!("{value:.1}×")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats the replay-playback per-move interval for display in the
|
||||||
|
/// Settings panel. Mirrors [`tooltip_delay_label`] for parity — the
|
||||||
|
/// readout is `"{n:.2} s/move"` (e.g. `"0.45 s/move"`, `"0.10 s/move"`),
|
||||||
|
/// using two decimal places because the step is 0.05 s.
|
||||||
|
pub(super) fn replay_move_interval_label(secs: f32) -> String {
|
||||||
|
format!("{secs:.2} s/move")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user