feat(engine): tabbed Settings panel — Phase A of the menu redesign
Test / test (pull_request) Failing after 7m15s
Test / test (pull_request) Failing after 7m15s
Replaces the 37-row single-scroll settings modal with five tabs (Audio, Gameplay, Appearance, Accessibility, Account); only the active tab's rows spawn, so every tab fits without scroll-hunting. - SettingsTab + ActiveSettingsTab (session-only) + SettingsTabButton chips under the modal header; rebuild-on-switch mirrors the leaderboard despawn/respawn pattern via a shared build_panel helper - Accessibility tab gathers color-blind, high-contrast, reduce-motion, touch-input, and tooltip-delay from the old Gameplay/Cosmetic mix; Account = Sync + Privacy - SettingsButton enum, input handlers, and persistence untouched - Tests: per-tab Focusable/Tooltip sweeps, tab-switch rebuild test, picker/thumbnail tests pinned to the Appearance tab Plan: docs/ui-redesign-2026-07.md (also added, phases A–M) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBord
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn sync_settings_panel_visibility(
|
||||
screen: Res<SettingsScreen>,
|
||||
active_tab: Res<ActiveSettingsTab>,
|
||||
panels: Query<Entity, With<SettingsPanel>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
|
||||
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
|
||||
@@ -35,60 +36,17 @@ pub(super) fn sync_settings_panel_visibility(
|
||||
}
|
||||
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(
|
||||
build_panel(
|
||||
&mut commands,
|
||||
&settings.0,
|
||||
&status_label,
|
||||
unlocked_backs,
|
||||
unlocked_bgs,
|
||||
&themes,
|
||||
scroll_pos.0,
|
||||
sync_status.as_deref(),
|
||||
progress.as_deref(),
|
||||
font_res.as_deref(),
|
||||
theme_overrides_back,
|
||||
theme_registry.as_deref(),
|
||||
theme_thumbs.as_deref(),
|
||||
card_images.as_deref(),
|
||||
scroll_pos.0,
|
||||
active_tab.0,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -102,6 +60,116 @@ pub(super) fn sync_settings_panel_visibility(
|
||||
}
|
||||
}
|
||||
|
||||
/// Gathers the spawn context (sync label, unlocks, theme snapshot,
|
||||
/// back-override flag) and spawns the panel showing `active_tab`.
|
||||
/// Shared by [`sync_settings_panel_visibility`] (open) and
|
||||
/// [`rebuild_panel_on_tab_change`] (tab switch).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_panel(
|
||||
commands: &mut Commands,
|
||||
settings: &Settings,
|
||||
sync_status: Option<&SyncStatusResource>,
|
||||
progress: Option<&ProgressResource>,
|
||||
font_res: Option<&FontResource>,
|
||||
theme_registry: Option<&crate::theme::ThemeRegistry>,
|
||||
theme_thumbs: Option<&ThemeThumbnailCache>,
|
||||
card_images: Option<&crate::card_plugin::CardImageSet>,
|
||||
scroll_offset: f32,
|
||||
active_tab: SettingsTab,
|
||||
) {
|
||||
let status_label = sync_status.map_or_else(
|
||||
|| "Status: local only".to_string(),
|
||||
|s| sync_status_label(&s.0),
|
||||
);
|
||||
let unlocked_backs = progress.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice());
|
||||
let unlocked_bgs = progress.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
|
||||
.map(|r| {
|
||||
r.iter()
|
||||
.map(|e| ThemePickerEntry {
|
||||
id: e.id.clone(),
|
||||
display_name: e.display_name.clone(),
|
||||
thumbnails: theme_thumbs
|
||||
.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.is_some_and(|cs| cs.theme_back.is_some());
|
||||
spawn_settings_panel(
|
||||
commands,
|
||||
settings,
|
||||
&status_label,
|
||||
unlocked_backs,
|
||||
unlocked_bgs,
|
||||
&themes,
|
||||
scroll_offset,
|
||||
font_res,
|
||||
theme_overrides_back,
|
||||
active_tab,
|
||||
);
|
||||
}
|
||||
|
||||
/// Rebuilds the open panel when [`ActiveSettingsTab`] changes (tab chip
|
||||
/// clicked). No-op while the panel is closed — the next open reads the
|
||||
/// resource directly.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn rebuild_panel_on_tab_change(
|
||||
active_tab: Res<ActiveSettingsTab>,
|
||||
panels: Query<Entity, With<SettingsPanel>>,
|
||||
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 !active_tab.is_changed() || active_tab.is_added() {
|
||||
return;
|
||||
}
|
||||
if panels.is_empty() {
|
||||
return;
|
||||
}
|
||||
for entity in &panels {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
// A fresh tab starts at the top; per-tab scroll memory isn't worth
|
||||
// the bookkeeping now that no tab needs much scrolling.
|
||||
scroll_pos.0 = 0.0;
|
||||
build_panel(
|
||||
&mut commands,
|
||||
&settings.0,
|
||||
sync_status.as_deref(),
|
||||
progress.as_deref(),
|
||||
font_res.as_deref(),
|
||||
theme_registry.as_deref(),
|
||||
theme_thumbs.as_deref(),
|
||||
card_images.as_deref(),
|
||||
0.0,
|
||||
active_tab.0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Keeps the sync-status text node current while the panel is open.
|
||||
pub(super) fn update_sync_status_text(
|
||||
sync_status: Option<Res<SyncStatusResource>>,
|
||||
|
||||
Reference in New Issue
Block a user