refactor(engine): split settings_plugin runtime code into submodules

Second phase of #118 for settings_plugin: the 2,857-line mod.rs becomes
four focused submodules along existing system boundaries —

  mod.rs     561  types, markers, SettingsButton, plugin build, persistence
  input.rs   632  button/hotkey handlers, focus attachment, scrolling
  updates.rs 495  per-frame value-text updater systems + label helpers
  ui.rs    1,231  spawn_settings_panel + row builders + thumbnails

Moved items are pub(super); mod.rs glob-imports the submodules so
system registration and tests keep their bare names. No behaviour
change; all 29 settings tests pass unchanged.

Refs #118

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-06 13:45:57 -07:00
parent 8274581edf
commit 1ca1efb3b6
5 changed files with 2367 additions and 2314 deletions
@@ -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")
}