Merge pull request 'feat(engine): tabbed Settings panel — Phase A of the menu redesign' (#160) from feat/settings-tabs into master
This commit was merged in pull request #160.
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
# Menu UX Redesign — July 2026
|
||||
|
||||
Status: PLANNING. Visual identity (Terminal / base16-eighties) is settled and
|
||||
out of scope — this is about **structure and interaction**, not colors or type.
|
||||
|
||||
## Diagnosis (from code survey, 2026-07-07)
|
||||
|
||||
| Surface | Today | Pain |
|
||||
| --- | --- | --- |
|
||||
| Settings | One scrolling modal, 5 section labels, ~37 control rows, single Done | Scroll-hunting for any toggle; endless on the folded (cover) screen; every new feature (theme store, import) stretches it further |
|
||||
| Home | Modal launcher: stats strip, draw-mode row, difficulty rows, 6 equal mode cards, Cancel | No "Continue" primacy; deal options (draw/difficulty/winnable) crowd the mode choice; six cards weighted identically though Classic dominates play |
|
||||
| HUD menu | Popover with 7 destinations (Help, Modes, Stats, Achievements, Profile, Settings, Leaderboard) | "Modes" duplicates Home's job; flat list, no grouping; each item opens another modal — modal-on-modal navigation |
|
||||
| Global | Everything is a modal over the felt | No hierarchy; near-square unfolded Fold renders one narrow wasted column |
|
||||
|
||||
## Phase A — Settings tabs (highest pain, lowest risk)
|
||||
|
||||
- Replace the single scroll with **tabs**: `Audio · Gameplay · Appearance ·
|
||||
Accessibility · Account`.
|
||||
- *Appearance* = today's Cosmetic (card back, background, card theme,
|
||||
theme store, import).
|
||||
- *Accessibility* = extracted from Gameplay/Cosmetic: color-blind,
|
||||
high-contrast, reduce-motion, touch input mode, tooltip delay.
|
||||
- *Account* = Sync + Privacy merged.
|
||||
- Narrow screens: tab chips in a row under the header. Wide (aspect > ~1.2 —
|
||||
unfolded Fold, desktop): left rail, rows in two columns.
|
||||
- Only the active tab's rows are spawned → each tab is ≤ 8 rows and fits the
|
||||
cover screen without scrolling.
|
||||
- **Implementation constraint:** pure `ui.rs` re-layout + one `SettingsTab`
|
||||
resource. The `SettingsButton` enum, input handlers, and persistence are
|
||||
untouched, so the 25 settings tests and the ambiguity gate stay green.
|
||||
|
||||
## Phase B — Home becomes a real home
|
||||
|
||||
- **Hierarchy, top to bottom:**
|
||||
1. `Continue` card (only when a game is in progress) — mode, elapsed, score.
|
||||
2. Hero `New Game` — one tap, reuses last mode + deal options.
|
||||
3. Compact 2×3 mode grid (Classic, Daily, Zen, Challenge, Time Attack,
|
||||
Seed) — smaller cards, glyph + name + one-line description on wide only.
|
||||
4. Stats strip moves to the bottom (or right pane when unfolded).
|
||||
- **Deal options** (draw 1/3, difficulty, winnable-only) move off the top
|
||||
level into a disclosure on the Classic card / New Game hero — they are
|
||||
Classic-mode concerns, not global ones.
|
||||
- Cancel becomes `Back to table` and only renders when a game exists.
|
||||
- Unfolded Fold / desktop: two panes — modes left, Continue + stats +
|
||||
daily/weekly right.
|
||||
|
||||
## Phase C — HUD menu consolidation
|
||||
|
||||
- Drop `Modes` from the popover (Home owns mode selection; HUD's New-Game
|
||||
path opens Home).
|
||||
- Group the remaining six: **Play** (Home) · **You** (Profile, Stats,
|
||||
Achievements) · **Community** (Leaderboard) · **System** (Settings, Help) —
|
||||
section dividers in the existing popover, not a new widget.
|
||||
- Dismissal audit: Esc / scrim-tap / Done must behave identically on every
|
||||
modal (most already do via `ScrimDismissible`; sweep the stragglers).
|
||||
|
||||
## Phase D — Validation gates
|
||||
|
||||
- Optional Stitch mockups (Terminal design-system asset
|
||||
`assets/0f1652274fea460585d0b331a9ddbb06`, project 593811793268308763) for
|
||||
Home + Settings before implementing Phase B. Phase A is safe to build
|
||||
straight in-engine.
|
||||
- Per-phase gates: full test suite, clippy `-D warnings`, ambiguity gate at 0,
|
||||
Android checklist (§15.3), on-device pass on the Fold 7 in BOTH postures.
|
||||
|
||||
## Sequencing & risk
|
||||
|
||||
| Phase | Size | Risk | Depends on |
|
||||
| --- | --- | --- | --- |
|
||||
| A — Settings tabs | ~1 session | Low (layout-only) | — |
|
||||
| B — Home hierarchy | 1–2 sessions | Medium (touches launch flow) | D mockups if wanted |
|
||||
| C — Menu grouping | small | Low | B (Modes removal) |
|
||||
|
||||
## Phase E — "You" hub (reuses the Phase A tab component)
|
||||
|
||||
Profile, Stats, Achievements, and the replay browser are four separate
|
||||
modals today, reached through the HUD popover one at a time. Fold them
|
||||
into ONE tabbed hub — `Profile · Stats · Achievements · Replays` — using
|
||||
the same tab widget Phase A builds for Settings. Cuts the HUD popover to
|
||||
four destinations and makes the tab component pay for itself twice.
|
||||
Existing per-screen systems keep their markers/handlers; only the outer
|
||||
shell changes (same trick as Phase A).
|
||||
|
||||
## Phase F — Mobile ergonomics (Fold-first)
|
||||
|
||||
- **Bottom action bar** on touch: Undo, Hint, Draw within thumb reach at
|
||||
the bottom safe-area edge (via the existing `SafeAreaAnchoredBottom`),
|
||||
score-only band stays on top. Desktop keeps the current top band.
|
||||
Biggest one-handed-play win available; folded posture is tall and
|
||||
top-heavy today.
|
||||
- **Hold-to-repeat Undo** — press-and-hold steps back repeatedly
|
||||
(respecting the existing undo scoring penalty), instead of tap-tap-tap.
|
||||
- Both are additive; game stays fully playable with the top band alone
|
||||
(UI-first rule §3.3 satisfied).
|
||||
|
||||
## Phase G — Post-win flow
|
||||
|
||||
Win summary today is a stats dump with a close. Give it an action
|
||||
hierarchy: **Play again** (same mode/options, primary) · **Share replay**
|
||||
(the upload already returns a share URL; surface it here with copy
|
||||
feedback) · **Watch replay** · quiet stats below. Rematch loop drops from
|
||||
4 taps to 1.
|
||||
|
||||
## Phase H — Feedback polish (small, bundling candidates)
|
||||
|
||||
- Unify queued vs. immediate toast styling (two subtly different styles
|
||||
exist today) and anchor both to one position; consider a 5-item toast
|
||||
history on the stats screen.
|
||||
- Theme-store modal: render the preview PNGs the server already serves
|
||||
(v1 is text-only rows).
|
||||
- Hint: optional ghost-motion preview of the suggested move instead of a
|
||||
static highlight (auto-disabled under reduce-motion).
|
||||
|
||||
## Phase I — Onboarding & discoverability
|
||||
|
||||
The first-run onboarding exists, but everything learned after it is
|
||||
invisible: the radial menu (long-press/right-click), hint cycling,
|
||||
tap-to-toggle HUD chrome, hotkeys. Add **contextual one-time tips** —
|
||||
fired by the situation, not a tour (first stall → hint tip; first
|
||||
long-press-able stack → radial tip; each shows once, stored in
|
||||
Settings like `shown_achievement_onboarding` already is). Plus a
|
||||
one-shot **"What's new"** card on first launch after a version bump —
|
||||
ObtainX updates are silent today, so shipped features go unnoticed
|
||||
(nobody will find the theme store on their own).
|
||||
|
||||
## Phase J — Keyboard & focus completeness (desktop + web)
|
||||
|
||||
`ui_focus` already gives deterministic Esc order and tab-walk
|
||||
(`FocusRow`/`Focusable`), and `KeyboardDragState` exists — but full
|
||||
keyboard-only play has never been audited end to end. Deliverables: every
|
||||
modal reachable/dismissable without a mouse, a visible focus ring styled
|
||||
to the Terminal system (current focus state is subtle), and a hotkey
|
||||
cheat-sheet overlay (hold `?`) generated from the actual bindings instead
|
||||
of the static Help text. Web build (Rhys) benefits most.
|
||||
|
||||
## Phase K — UI scale & touch-target accessibility
|
||||
|
||||
All type sizes flow through `TYPE_*` tokens and layout through
|
||||
`compute_layout` — which makes a **UI scale setting** (90/100/115/130 %)
|
||||
cheap to wire and genuinely useful on the Fold's dense cover screen.
|
||||
Pair with: respect Android system font scale on first run (seed the
|
||||
setting from it), and a one-time audit that every interactive element
|
||||
meets the 44 px logical minimum in BOTH Fold postures (the pill buttons
|
||||
and picker swatches are the suspects). Lands in the Accessibility tab
|
||||
that Phase A creates.
|
||||
|
||||
## Phase L — Empty & loading states, standardized
|
||||
|
||||
Async/empty surfaces each improvise today: leaderboard with no entries,
|
||||
stats with no games, replay browser with no replays, theme store
|
||||
loading/error (just added), sync status text. Define ONE pattern —
|
||||
glyph + one-line explanation + a single next-step action — as a
|
||||
`spawn_empty_state` helper in `ui_modal`, and sweep all five surfaces
|
||||
onto it. Small, mechanical, big perceived-quality win.
|
||||
|
||||
## Phase M — Sync transparency & data stewardship
|
||||
|
||||
The sync layer returns `ConflictReport`s ("data is never silently
|
||||
discarded") — but no UI ever shows them; players can't tell what a merge
|
||||
did. Add: a post-sync summary line ("merged, 2 conflicts kept newer
|
||||
values" → tap for detail in the Account tab), a visible last-synced
|
||||
timestamp (exists as `SyncStatus::LastSynced`, barely surfaced), and
|
||||
local **data export/import** (zip of the JSON saves) for device
|
||||
migrations without a server. Closes the trust loop the server work
|
||||
opened.
|
||||
|
||||
## Roadmap tie-ins (already in flight elsewhere)
|
||||
|
||||
- **Spider mode** (feat/spider-core, PR #157): Home's mode grid gains a
|
||||
7th card once engine work lands — the 2×3 grid in Phase B should be
|
||||
designed as N-card flow from day one. Spider UI also needs positional
|
||||
card→entity keys (documented in spider.rs module docs).
|
||||
- **Show solution** (feat/solution-line, in flight): lands in the pause
|
||||
menu; Phase C's grouping should leave room for it under a "Game"
|
||||
cluster if the pause menu grows.
|
||||
|
||||
## Sequencing (updated)
|
||||
|
||||
| Phase | Size | Risk | Depends on |
|
||||
| --- | --- | --- | --- |
|
||||
| A — Settings tabs | ~1 session | Low | — (DECIDED: tabs) |
|
||||
| E — "You" hub | ~1 session | Low | A's tab component |
|
||||
| C — Menu grouping | small | Low | E (popover shrinks) |
|
||||
| B — Home hierarchy | 1–2 sessions | Medium | D mockups if wanted |
|
||||
| F — Bottom action bar | ~1 session | Medium (input paths) | — |
|
||||
| G — Post-win flow | small | Low | — |
|
||||
| H — Feedback polish | small each | Low | — |
|
||||
| I — Onboarding & discoverability | ~1 session | Low | ships best after B/F land |
|
||||
| J — Keyboard & focus completeness | ~1 session | Low | A/E (fewer modals to audit) |
|
||||
| K — UI scale & touch targets | ~1 session | Medium (layout-wide) | A (Accessibility tab) |
|
||||
| L — Empty-state standardization | small | Low | — |
|
||||
| M — Sync transparency & export | ~1 session | Low–Medium | A (Account tab) |
|
||||
|
||||
Suggested order: **A → E → C** (one arc: the tab component and the menu
|
||||
slimming), then **B**, then **F/G/H** as independent follow-ups.
|
||||
|
||||
## Open decisions
|
||||
|
||||
1. ~~Settings: tabs vs. sub-pages~~ — **DECIDED 2026-07-07: tabs.**
|
||||
2. Deal options: disclosure on Classic card (proposed) vs. keep global row?
|
||||
3. Time Attack + Seed: top-level cards (proposed, grid stays symmetric) vs.
|
||||
tucked under a "More" card?
|
||||
4. Stitch mockups for Phase B, or iterate directly in-engine?
|
||||
5. Phase F bottom bar: touch-only (proposed) or also desktop?
|
||||
@@ -418,6 +418,25 @@ pub(super) fn handle_settings_buttons(
|
||||
}
|
||||
}
|
||||
|
||||
/// Switches the active Settings tab when a chip is pressed. The
|
||||
/// rebuild itself happens in `rebuild_panel_on_tab_change`, driven by
|
||||
/// change detection on [`ActiveSettingsTab`].
|
||||
pub(super) fn handle_tab_buttons(
|
||||
interactions: Query<(&Interaction, &SettingsTabButton), Changed<Interaction>>,
|
||||
mut active: ResMut<ActiveSettingsTab>,
|
||||
) {
|
||||
for (interaction, chip) in &interactions {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
}
|
||||
// Compare before writing so re-clicking the active chip doesn't
|
||||
// trip change detection and rebuild the panel for nothing.
|
||||
if active.0 != chip.0 {
|
||||
active.0 = chip.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -170,6 +170,51 @@ struct SmartDefaultSizeText;
|
||||
#[derive(Component, Debug)]
|
||||
struct AnalyticsEnabledText;
|
||||
|
||||
/// Which tab of the Settings panel is showing. Tabs replace the old
|
||||
/// single-scroll five-section layout (Phase A of
|
||||
/// `docs/ui-redesign-2026-07.md`) so each group fits on screen without
|
||||
/// scroll-hunting.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub(super) enum SettingsTab {
|
||||
#[default]
|
||||
Audio,
|
||||
Gameplay,
|
||||
Appearance,
|
||||
Accessibility,
|
||||
Account,
|
||||
}
|
||||
|
||||
impl SettingsTab {
|
||||
/// Every tab, in display order.
|
||||
pub(super) const ALL: [Self; 5] = [
|
||||
Self::Audio,
|
||||
Self::Gameplay,
|
||||
Self::Appearance,
|
||||
Self::Accessibility,
|
||||
Self::Account,
|
||||
];
|
||||
|
||||
/// Chip label.
|
||||
pub(super) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Audio => "Audio",
|
||||
Self::Gameplay => "Gameplay",
|
||||
Self::Appearance => "Appearance",
|
||||
Self::Accessibility => "Access",
|
||||
Self::Account => "Account",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The active Settings tab. Session-only — deliberately not persisted;
|
||||
/// reopening Settings within a session returns to the last tab.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub(super) struct ActiveSettingsTab(pub(super) SettingsTab);
|
||||
|
||||
/// Marker on each tab chip button in the Settings header row.
|
||||
#[derive(Component, Debug)]
|
||||
pub(super) struct SettingsTabButton(pub(super) SettingsTab);
|
||||
|
||||
/// Marks the scrollable inner card so the mouse-wheel system can target it.
|
||||
#[derive(Component, Debug)]
|
||||
struct SettingsPanelScrollable;
|
||||
@@ -369,6 +414,7 @@ impl Plugin for SettingsPlugin {
|
||||
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
||||
.init_resource::<SettingsScreen>()
|
||||
.init_resource::<SettingsScrollPos>()
|
||||
.init_resource::<ActiveSettingsTab>()
|
||||
.init_resource::<PendingWindowGeometry>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
@@ -423,7 +469,15 @@ impl Plugin for SettingsPlugin {
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
sync_settings_panel_visibility,
|
||||
// Chained: a tab click must observe this frame's
|
||||
// visibility state, and the rebuild must observe the
|
||||
// click — total order avoids panel double-spawns.
|
||||
(
|
||||
sync_settings_panel_visibility,
|
||||
handle_tab_buttons,
|
||||
rebuild_panel_on_tab_change,
|
||||
)
|
||||
.chain(),
|
||||
handle_settings_buttons,
|
||||
handle_sync_buttons,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -98,6 +98,25 @@ fn volume_clamped_at_zero_does_not_emit_event() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Opens the Settings panel showing `tab`, then waits out the
|
||||
/// spawn / child-flush / marker-attach frames.
|
||||
fn open_settings_on(app: &mut App, tab: SettingsTab) {
|
||||
app.world_mut().resource_mut::<ActiveSettingsTab>().0 = tab;
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
}
|
||||
|
||||
/// Switches the already-open panel to `tab` (drives the
|
||||
/// `rebuild_panel_on_tab_change` path) and waits out the rebuild frames.
|
||||
fn switch_settings_tab(app: &mut App, tab: SettingsTab) {
|
||||
app.world_mut().resource_mut::<ActiveSettingsTab>().0 = tab;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_o_toggles_settings_screen_flag() {
|
||||
let mut app = headless_app();
|
||||
@@ -243,40 +262,40 @@ fn headless_app_with_focus() -> App {
|
||||
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();
|
||||
// Walk every tab: each rebuild must leave no bespoke
|
||||
// `SettingsButton` (not `Done`, which is also a `ModalButton`)
|
||||
// without a `Focusable`.
|
||||
let mut total_tagged = 0usize;
|
||||
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
|
||||
if i == 0 {
|
||||
open_settings_on(&mut app, tab);
|
||||
} else {
|
||||
switch_settings_tab(&mut app, tab);
|
||||
}
|
||||
|
||||
// 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();
|
||||
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 on tab {tab:?} must carry Focusable; missing: {untagged:?}"
|
||||
);
|
||||
|
||||
total_tagged += app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, With<Focusable>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
}
|
||||
|
||||
// Floor across all five tabs so the assertion above isn't
|
||||
// vacuously true (audio 4 + gameplay 8 + appearance ≥5 +
|
||||
// accessibility 6 + account ≥1).
|
||||
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}"
|
||||
total_tagged >= 20,
|
||||
"expected the five tabs to spawn many bespoke buttons; got {total_tagged}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -290,41 +309,45 @@ fn settings_buttons_get_focusable_marker() {
|
||||
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();
|
||||
// Walk every tab: no bespoke `SettingsButton` (i.e. excluding
|
||||
// `Done`, which is also a `ModalButton`) may be missing a
|
||||
// `Tooltip` on any of them.
|
||||
let mut total_tipped = 0usize;
|
||||
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
|
||||
if i == 0 {
|
||||
open_settings_on(&mut app, tab);
|
||||
} else {
|
||||
switch_settings_tab(&mut app, tab);
|
||||
}
|
||||
|
||||
// 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();
|
||||
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 on tab {tab:?} must carry Tooltip; missing: {untipped:?}"
|
||||
);
|
||||
|
||||
total_tipped += app
|
||||
.world_mut()
|
||||
.query_filtered::<&SettingsButton, With<Tooltip>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
}
|
||||
|
||||
// Floor across all five tabs so the assertion above isn't
|
||||
// vacuously true.
|
||||
assert!(
|
||||
untipped.is_empty(),
|
||||
"every bespoke Settings button must carry Tooltip; missing: {:?}",
|
||||
untipped
|
||||
total_tipped >= 20,
|
||||
"expected the five tabs to spawn many tooltipped buttons; got {total_tipped}"
|
||||
);
|
||||
|
||||
// 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}"
|
||||
);
|
||||
// The loop ends on the Account tab, where with default (Local)
|
||||
// settings the Connect button spawns.
|
||||
|
||||
// Spot-check: with default (Local) settings the Connect button
|
||||
// spawns. We verify its tooltip carries the canonical microcopy.
|
||||
// We verify its tooltip carries the canonical microcopy.
|
||||
let connect_tip = app
|
||||
.world_mut()
|
||||
.query::<(&SettingsButton, &Tooltip)>()
|
||||
@@ -342,10 +365,8 @@ fn settings_buttons_carry_tooltip() {
|
||||
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();
|
||||
// Picker rows live on the Appearance tab.
|
||||
open_settings_on(&mut app, SettingsTab::Appearance);
|
||||
|
||||
// Two picker rows are spawned (card-back + background); each
|
||||
// must carry the FocusRow marker.
|
||||
@@ -360,6 +381,45 @@ fn settings_picker_rows_get_focus_row_marker() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Switching tabs despawns the old rows and spawns the new tab's —
|
||||
/// exactly one panel exists afterwards (no double-spawn from the
|
||||
/// visibility system racing the rebuild system).
|
||||
#[test]
|
||||
fn switching_tab_rebuilds_panel_with_new_rows() {
|
||||
let mut app = headless_app_with_focus();
|
||||
|
||||
fn has_button(app: &mut App, wanted: fn(&SettingsButton) -> bool) -> bool {
|
||||
app.world_mut()
|
||||
.query::<&SettingsButton>()
|
||||
.iter(app.world())
|
||||
.any(wanted)
|
||||
}
|
||||
|
||||
open_settings_on(&mut app, SettingsTab::Audio);
|
||||
assert!(has_button(&mut app, |b| matches!(b, SettingsButton::SfxUp)));
|
||||
assert!(
|
||||
!has_button(&mut app, |b| matches!(b, SettingsButton::ToggleDrawMode)),
|
||||
"Gameplay rows must not spawn while the Audio tab is active"
|
||||
);
|
||||
|
||||
switch_settings_tab(&mut app, SettingsTab::Gameplay);
|
||||
assert!(
|
||||
!has_button(&mut app, |b| matches!(b, SettingsButton::SfxUp)),
|
||||
"Audio rows must despawn when leaving the Audio tab"
|
||||
);
|
||||
assert!(has_button(&mut app, |b| matches!(
|
||||
b,
|
||||
SettingsButton::ToggleDrawMode
|
||||
)));
|
||||
|
||||
let panel_count = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<SettingsPanel>>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(panel_count, 1, "tab switch must not stack panels");
|
||||
}
|
||||
|
||||
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
|
||||
/// at least one theme and the [`ThemeThumbnailCache`] holds a
|
||||
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
|
||||
@@ -404,11 +464,9 @@ fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
|
||||
);
|
||||
app.insert_resource(cache);
|
||||
|
||||
// Open the panel and let the spawn + child-flush systems run.
|
||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||
app.update();
|
||||
app.update();
|
||||
app.update();
|
||||
// Open the panel on the Appearance tab (the theme picker's home)
|
||||
// and let the spawn + child-flush systems run.
|
||||
open_settings_on(&mut app, SettingsTab::Appearance);
|
||||
|
||||
// Find every ImageNode tagged with ThemeThumbnailMarker — the
|
||||
// theme picker chip for "test_theme" must contribute exactly
|
||||
|
||||
@@ -45,13 +45,34 @@ pub(super) fn spawn_settings_panel(
|
||||
scroll_offset: f32,
|
||||
font_res: Option<&FontResource>,
|
||||
theme_overrides_back: bool,
|
||||
active_tab: SettingsTab,
|
||||
) {
|
||||
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.
|
||||
// Tab chips — one group of rows on screen at a time, no
|
||||
// scroll-hunting (Phase A, docs/ui-redesign-2026-07.md).
|
||||
card.spawn((
|
||||
FocusRow,
|
||||
Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
column_gap: VAL_SPACE_2,
|
||||
row_gap: VAL_SPACE_2,
|
||||
margin: UiRect::bottom(VAL_SPACE_3),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|row| {
|
||||
for tab in SettingsTab::ALL {
|
||||
tab_chip(row, tab, tab == active_tab, font_res);
|
||||
}
|
||||
});
|
||||
|
||||
// Scrollable body — most tabs fit without scrolling, but the
|
||||
// Appearance pickers can still overflow short windows. The Done
|
||||
// button below stays fixed outside the scroll so it's always one
|
||||
// click away.
|
||||
card.spawn((
|
||||
SettingsPanelScrollable,
|
||||
SettingsScrollNode,
|
||||
@@ -65,200 +86,20 @@ pub(super) fn spawn_settings_panel(
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
// --- Audio ---
|
||||
section_label(body, "Audio", font_res);
|
||||
volume_row(
|
||||
.with_children(|body| match active_tab {
|
||||
SettingsTab::Audio => spawn_audio_tab(body, settings, font_res),
|
||||
SettingsTab::Gameplay => spawn_gameplay_tab(body, settings, font_res),
|
||||
SettingsTab::Appearance => spawn_appearance_tab(
|
||||
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,
|
||||
"Winnable deals only",
|
||||
WinnableDealsOnlyText,
|
||||
winnable_deals_only_label(settings.winnable_deals_only),
|
||||
SettingsButton::ToggleWinnableDealsOnly,
|
||||
"When on, fresh Classic deals are filtered through a solver \
|
||||
(may take a moment when on).",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Anim Speed",
|
||||
AnimSpeedText,
|
||||
anim_speed_label(&settings.animation_speed),
|
||||
SettingsButton::CycleAnimSpeed,
|
||||
"Cycle animation speed: Normal, Fast, Instant.",
|
||||
font_res,
|
||||
);
|
||||
tooltip_delay_row(
|
||||
body,
|
||||
settings.tooltip_delay_secs,
|
||||
font_res,
|
||||
);
|
||||
time_bonus_multiplier_row(
|
||||
body,
|
||||
settings.time_bonus_multiplier,
|
||||
font_res,
|
||||
);
|
||||
replay_move_interval_row(
|
||||
body,
|
||||
settings.replay_move_interval_secs,
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Smart window size",
|
||||
SmartDefaultSizeText,
|
||||
smart_default_size_label(!settings.disable_smart_default_size),
|
||||
SettingsButton::ToggleSmartDefaultSize,
|
||||
"When ON, fresh launches resize the window to ~70 % of the \
|
||||
monitor. OFF pins the 1280\u{00D7}800 baseline. Saved \
|
||||
window size always wins.",
|
||||
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,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"High Contrast",
|
||||
HighContrastText,
|
||||
on_off_label(settings.high_contrast_mode),
|
||||
SettingsButton::ToggleHighContrast,
|
||||
"Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Reduce Motion",
|
||||
ReduceMotionText,
|
||||
on_off_label(settings.reduce_motion_mode),
|
||||
SettingsButton::ToggleReduceMotion,
|
||||
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Touch Input Mode",
|
||||
TouchInputModeText,
|
||||
touch_input_mode_label(&settings.touch_input_mode),
|
||||
SettingsButton::ToggleTouchInputMode,
|
||||
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
|
||||
font_res,
|
||||
);
|
||||
if theme_overrides_back {
|
||||
// The active theme provides its own back; the legacy
|
||||
// picker has no visible effect, so we replace its
|
||||
// swatch row with an informational caption. The
|
||||
// player's `selected_card_back` value still
|
||||
// round-trips through `settings.json` — the moment
|
||||
// they switch to a theme without a back, the picker
|
||||
// re-appears with their previous choice intact.
|
||||
picker_row_overridden_by_theme(body, "Card Back", font_res);
|
||||
} else {
|
||||
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",
|
||||
settings,
|
||||
unlocked_card_backs,
|
||||
unlocked_backgrounds,
|
||||
settings.selected_background,
|
||||
SettingsButton::SelectBackground,
|
||||
"Choose your felt art. New felts unlock at higher levels.",
|
||||
themes,
|
||||
theme_overrides_back,
|
||||
font_res,
|
||||
);
|
||||
// Card-art theme picker — only renders when the registry has
|
||||
// entries (production: always; tests: only when
|
||||
// ThemeRegistryPlugin is installed).
|
||||
if !themes.is_empty() {
|
||||
theme_picker_row(
|
||||
body,
|
||||
"Card Theme",
|
||||
themes,
|
||||
&settings.selected_theme_id,
|
||||
"Choose card-face artwork. Imported themes appear here.",
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
import_themes_row(body, font_res);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
theme_store_row(body, font_res);
|
||||
|
||||
// --- Privacy (only shown when a Matomo URL is configured) ---
|
||||
if settings.matomo_url.is_some() {
|
||||
section_label(body, "Privacy", font_res);
|
||||
toggle_row(
|
||||
body,
|
||||
"Share usage data",
|
||||
AnalyticsEnabledText,
|
||||
on_off_label(settings.analytics_enabled),
|
||||
SettingsButton::ToggleAnalytics,
|
||||
"Sends anonymous game events to Matomo for aggregate analytics.",
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sync ---
|
||||
section_label(body, "Sync", font_res);
|
||||
sync_row(body, sync_status, &settings.sync_backend, font_res);
|
||||
),
|
||||
SettingsTab::Accessibility => spawn_accessibility_tab(body, settings, font_res),
|
||||
SettingsTab::Account => spawn_account_tab(body, settings, sync_status, font_res),
|
||||
});
|
||||
|
||||
// Done is the only action — primary so the player always knows
|
||||
@@ -276,6 +117,263 @@ pub(super) fn spawn_settings_panel(
|
||||
});
|
||||
}
|
||||
|
||||
/// One Settings tab chip. The active chip is filled + bright; inactive
|
||||
/// chips are quiet outlines.
|
||||
fn tab_chip(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
tab: SettingsTab,
|
||||
active: bool,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
let font = TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
parent
|
||||
.spawn((
|
||||
SettingsTabButton(tab),
|
||||
Button,
|
||||
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(if active { BG_ELEVATED_HI } else { BG_BASE }),
|
||||
BorderColor::all(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
|
||||
HighContrastBorder::with_default(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new(tab.label()),
|
||||
font,
|
||||
TextColor(if active { TEXT_PRIMARY } else { TEXT_SECONDARY }),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Audio tab: the two volume rows.
|
||||
fn spawn_audio_tab(
|
||||
body: &mut ChildSpawnerCommands,
|
||||
settings: &Settings,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
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 tab: rules and pacing.
|
||||
fn spawn_gameplay_tab(
|
||||
body: &mut ChildSpawnerCommands,
|
||||
settings: &Settings,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
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,
|
||||
"Winnable deals only",
|
||||
WinnableDealsOnlyText,
|
||||
winnable_deals_only_label(settings.winnable_deals_only),
|
||||
SettingsButton::ToggleWinnableDealsOnly,
|
||||
"When on, fresh Classic deals are filtered through a solver \
|
||||
(may take a moment when on).",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Anim Speed",
|
||||
AnimSpeedText,
|
||||
anim_speed_label(&settings.animation_speed),
|
||||
SettingsButton::CycleAnimSpeed,
|
||||
"Cycle animation speed: Normal, Fast, Instant.",
|
||||
font_res,
|
||||
);
|
||||
time_bonus_multiplier_row(body, settings.time_bonus_multiplier, font_res);
|
||||
replay_move_interval_row(body, settings.replay_move_interval_secs, font_res);
|
||||
toggle_row(
|
||||
body,
|
||||
"Smart window size",
|
||||
SmartDefaultSizeText,
|
||||
smart_default_size_label(!settings.disable_smart_default_size),
|
||||
SettingsButton::ToggleSmartDefaultSize,
|
||||
"When ON, fresh launches resize the window to ~70 % of the \
|
||||
monitor. OFF pins the 1280\u{00D7}800 baseline. Saved \
|
||||
window size always wins.",
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
|
||||
/// Appearance tab: felt, deck art, backgrounds, and the theme system.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_appearance_tab(
|
||||
body: &mut ChildSpawnerCommands,
|
||||
settings: &Settings,
|
||||
unlocked_card_backs: &[usize],
|
||||
unlocked_backgrounds: &[usize],
|
||||
themes: &[ThemePickerEntry],
|
||||
theme_overrides_back: bool,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
toggle_row(
|
||||
body,
|
||||
"Theme",
|
||||
ThemeText,
|
||||
theme_label(&settings.theme),
|
||||
SettingsButton::ToggleTheme,
|
||||
"Cycle felt color: Green, Blue, Dark.",
|
||||
font_res,
|
||||
);
|
||||
if theme_overrides_back {
|
||||
// The active theme provides its own back; the legacy
|
||||
// picker has no visible effect, so we replace its
|
||||
// swatch row with an informational caption. The
|
||||
// player's `selected_card_back` value still
|
||||
// round-trips through `settings.json` — the moment
|
||||
// they switch to a theme without a back, the picker
|
||||
// re-appears with their previous choice intact.
|
||||
picker_row_overridden_by_theme(body, "Card Back", font_res);
|
||||
} else {
|
||||
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,
|
||||
);
|
||||
// Card-art theme picker — only renders when the registry has
|
||||
// entries (production: always; tests: only when
|
||||
// ThemeRegistryPlugin is installed).
|
||||
if !themes.is_empty() {
|
||||
theme_picker_row(
|
||||
body,
|
||||
"Card Theme",
|
||||
themes,
|
||||
&settings.selected_theme_id,
|
||||
"Choose card-face artwork. Imported themes appear here.",
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
import_themes_row(body, font_res);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
theme_store_row(body, font_res);
|
||||
}
|
||||
|
||||
/// Accessibility tab: perception and input assists, gathered from the
|
||||
/// old Gameplay/Cosmetic sections so they're findable in one place.
|
||||
fn spawn_accessibility_tab(
|
||||
body: &mut ChildSpawnerCommands,
|
||||
settings: &Settings,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
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,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"High Contrast",
|
||||
HighContrastText,
|
||||
on_off_label(settings.high_contrast_mode),
|
||||
SettingsButton::ToggleHighContrast,
|
||||
"Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Reduce Motion",
|
||||
ReduceMotionText,
|
||||
on_off_label(settings.reduce_motion_mode),
|
||||
SettingsButton::ToggleReduceMotion,
|
||||
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Touch Input Mode",
|
||||
TouchInputModeText,
|
||||
touch_input_mode_label(&settings.touch_input_mode),
|
||||
SettingsButton::ToggleTouchInputMode,
|
||||
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
|
||||
font_res,
|
||||
);
|
||||
tooltip_delay_row(body, settings.tooltip_delay_secs, font_res);
|
||||
}
|
||||
|
||||
/// Account tab: privacy opt-in (when analytics is configured) and the
|
||||
/// sync backend controls.
|
||||
fn spawn_account_tab(
|
||||
body: &mut ChildSpawnerCommands,
|
||||
settings: &Settings,
|
||||
sync_status: &str,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
// --- Privacy (only shown when a Matomo URL is configured) ---
|
||||
if settings.matomo_url.is_some() {
|
||||
section_label(body, "Privacy", font_res);
|
||||
toggle_row(
|
||||
body,
|
||||
"Share usage data",
|
||||
AnalyticsEnabledText,
|
||||
on_off_label(settings.analytics_enabled),
|
||||
SettingsButton::ToggleAnalytics,
|
||||
"Sends anonymous game events to Matomo for aggregate analytics.",
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sync ---
|
||||
section_label(body, "Sync", font_res);
|
||||
sync_row(body, sync_status, &settings.sync_backend, font_res);
|
||||
}
|
||||
|
||||
/// Section divider — small lavender label inside the scrollable body.
|
||||
pub(super) fn section_label(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
|
||||
@@ -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