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,
|
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
|
||||||
/// and Delete Account. Split from `handle_settings_buttons` to stay within
|
/// and Delete Account. Split from `handle_settings_buttons` to stay within
|
||||||
/// Bevy's 16-parameter system limit.
|
/// Bevy's 16-parameter system limit.
|
||||||
|
|||||||
@@ -170,6 +170,51 @@ struct SmartDefaultSizeText;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct AnalyticsEnabledText;
|
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.
|
/// Marks the scrollable inner card so the mouse-wheel system can target it.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct SettingsPanelScrollable;
|
struct SettingsPanelScrollable;
|
||||||
@@ -369,6 +414,7 @@ impl Plugin for SettingsPlugin {
|
|||||||
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
||||||
.init_resource::<SettingsScreen>()
|
.init_resource::<SettingsScreen>()
|
||||||
.init_resource::<SettingsScrollPos>()
|
.init_resource::<SettingsScrollPos>()
|
||||||
|
.init_resource::<ActiveSettingsTab>()
|
||||||
.init_resource::<PendingWindowGeometry>()
|
.init_resource::<PendingWindowGeometry>()
|
||||||
.add_message::<SettingsChangedEvent>()
|
.add_message::<SettingsChangedEvent>()
|
||||||
.add_message::<ManualSyncRequestEvent>()
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
@@ -423,7 +469,15 @@ impl Plugin for SettingsPlugin {
|
|||||||
app.add_systems(
|
app.add_systems(
|
||||||
Update,
|
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_settings_buttons,
|
||||||
handle_sync_buttons,
|
handle_sync_buttons,
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[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]
|
#[test]
|
||||||
fn pressing_o_toggles_settings_screen_flag() {
|
fn pressing_o_toggles_settings_screen_flag() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
@@ -243,40 +262,40 @@ fn headless_app_with_focus() -> App {
|
|||||||
fn settings_buttons_get_focusable_marker() {
|
fn settings_buttons_get_focusable_marker() {
|
||||||
let mut app = headless_app_with_focus();
|
let mut app = headless_app_with_focus();
|
||||||
|
|
||||||
// Open the panel.
|
// Walk every tab: each rebuild must leave no bespoke
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
// `SettingsButton` (not `Done`, which is also a `ModalButton`)
|
||||||
app.update();
|
// without a `Focusable`.
|
||||||
// Two more ticks: the first runs `sync_settings_panel_visibility`
|
let mut total_tagged = 0usize;
|
||||||
// and queues the spawn commands; the second flushes them and
|
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
|
||||||
// runs `attach_focusable_to_settings_buttons`.
|
if i == 0 {
|
||||||
app.update();
|
open_settings_on(&mut app, tab);
|
||||||
app.update();
|
} else {
|
||||||
|
switch_settings_tab(&mut app, tab);
|
||||||
|
}
|
||||||
|
|
||||||
// Every bespoke `SettingsButton` (not `Done`, which is also a
|
let untagged: Vec<&SettingsButton> = app
|
||||||
// `ModalButton`) must carry a `Focusable`.
|
.world_mut()
|
||||||
let untagged: Vec<&SettingsButton> = app
|
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
|
||||||
.world_mut()
|
.iter(app.world())
|
||||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
|
.collect();
|
||||||
.iter(app.world())
|
assert!(
|
||||||
.collect();
|
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!(
|
assert!(
|
||||||
untagged.is_empty(),
|
total_tagged >= 20,
|
||||||
"every bespoke Settings button must carry Focusable; missing: {:?}",
|
"expected the five tabs to spawn many bespoke buttons; got {total_tagged}"
|
||||||
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}"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,41 +309,45 @@ fn settings_buttons_get_focusable_marker() {
|
|||||||
fn settings_buttons_carry_tooltip() {
|
fn settings_buttons_carry_tooltip() {
|
||||||
let mut app = headless_app_with_focus();
|
let mut app = headless_app_with_focus();
|
||||||
|
|
||||||
// Open the panel and let spawn + child-flush run.
|
// Walk every tab: no bespoke `SettingsButton` (i.e. excluding
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
// `Done`, which is also a `ModalButton`) may be missing a
|
||||||
app.update();
|
// `Tooltip` on any of them.
|
||||||
app.update();
|
let mut total_tipped = 0usize;
|
||||||
app.update();
|
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
|
let untipped: Vec<&SettingsButton> = app
|
||||||
// also a `ModalButton`) may be missing a `Tooltip`.
|
.world_mut()
|
||||||
let untipped: Vec<&SettingsButton> = app
|
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
|
||||||
.world_mut()
|
.iter(app.world())
|
||||||
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
|
.collect();
|
||||||
.iter(app.world())
|
assert!(
|
||||||
.collect();
|
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!(
|
assert!(
|
||||||
untipped.is_empty(),
|
total_tipped >= 20,
|
||||||
"every bespoke Settings button must carry Tooltip; missing: {:?}",
|
"expected the five tabs to spawn many tooltipped buttons; got {total_tipped}"
|
||||||
untipped
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// And there must be at least 6 tipped buttons so the assertion
|
// The loop ends on the Account tab, where with default (Local)
|
||||||
// above isn't vacuously true: SFX +/−, Music +/−, Draw Mode,
|
// settings the Connect button spawns.
|
||||||
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
|
|
||||||
// card-back and one background swatch — well over the floor.
|
|
||||||
let tipped_count = app
|
|
||||||
.world_mut()
|
|
||||||
.query_filtered::<&SettingsButton, With<Tooltip>>()
|
|
||||||
.iter(app.world())
|
|
||||||
.count();
|
|
||||||
assert!(
|
|
||||||
tipped_count >= 6,
|
|
||||||
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Spot-check: with default (Local) settings the Connect button
|
// We verify its tooltip carries the canonical microcopy.
|
||||||
// spawns. We verify its tooltip carries the canonical microcopy.
|
|
||||||
let connect_tip = app
|
let connect_tip = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.query::<(&SettingsButton, &Tooltip)>()
|
.query::<(&SettingsButton, &Tooltip)>()
|
||||||
@@ -342,10 +365,8 @@ fn settings_buttons_carry_tooltip() {
|
|||||||
fn settings_picker_rows_get_focus_row_marker() {
|
fn settings_picker_rows_get_focus_row_marker() {
|
||||||
let mut app = headless_app_with_focus();
|
let mut app = headless_app_with_focus();
|
||||||
|
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
// Picker rows live on the Appearance tab.
|
||||||
app.update();
|
open_settings_on(&mut app, SettingsTab::Appearance);
|
||||||
app.update();
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
// Two picker rows are spawned (card-back + background); each
|
// Two picker rows are spawned (card-back + background); each
|
||||||
// must carry the FocusRow marker.
|
// 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
|
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
|
||||||
/// at least one theme and the [`ThemeThumbnailCache`] holds a
|
/// at least one theme and the [`ThemeThumbnailCache`] holds a
|
||||||
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
|
/// 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);
|
app.insert_resource(cache);
|
||||||
|
|
||||||
// Open the panel and let the spawn + child-flush systems run.
|
// Open the panel on the Appearance tab (the theme picker's home)
|
||||||
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
// and let the spawn + child-flush systems run.
|
||||||
app.update();
|
open_settings_on(&mut app, SettingsTab::Appearance);
|
||||||
app.update();
|
|
||||||
app.update();
|
|
||||||
|
|
||||||
// Find every ImageNode tagged with ThemeThumbnailMarker — the
|
// Find every ImageNode tagged with ThemeThumbnailMarker — the
|
||||||
// theme picker chip for "test_theme" must contribute exactly
|
// theme picker chip for "test_theme" must contribute exactly
|
||||||
|
|||||||
@@ -45,13 +45,34 @@ pub(super) fn spawn_settings_panel(
|
|||||||
scroll_offset: f32,
|
scroll_offset: f32,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
theme_overrides_back: bool,
|
theme_overrides_back: bool,
|
||||||
|
active_tab: SettingsTab,
|
||||||
) {
|
) {
|
||||||
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
|
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
|
||||||
spawn_modal_header(card, "Settings", font_res);
|
spawn_modal_header(card, "Settings", font_res);
|
||||||
|
|
||||||
// Scrollable body — contains every section so tall content stays
|
// Tab chips — one group of rows on screen at a time, no
|
||||||
// reachable on short windows. The Done button below stays fixed
|
// scroll-hunting (Phase A, docs/ui-redesign-2026-07.md).
|
||||||
// outside the scroll so it's always one click away.
|
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((
|
card.spawn((
|
||||||
SettingsPanelScrollable,
|
SettingsPanelScrollable,
|
||||||
SettingsScrollNode,
|
SettingsScrollNode,
|
||||||
@@ -65,200 +86,20 @@ pub(super) fn spawn_settings_panel(
|
|||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
.with_children(|body| {
|
.with_children(|body| match active_tab {
|
||||||
// --- Audio ---
|
SettingsTab::Audio => spawn_audio_tab(body, settings, font_res),
|
||||||
section_label(body, "Audio", font_res);
|
SettingsTab::Gameplay => spawn_gameplay_tab(body, settings, font_res),
|
||||||
volume_row(
|
SettingsTab::Appearance => spawn_appearance_tab(
|
||||||
body,
|
body,
|
||||||
"SFX Volume",
|
settings,
|
||||||
settings.sfx_volume,
|
unlocked_card_backs,
|
||||||
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",
|
|
||||||
unlocked_backgrounds,
|
unlocked_backgrounds,
|
||||||
settings.selected_background,
|
themes,
|
||||||
SettingsButton::SelectBackground,
|
theme_overrides_back,
|
||||||
"Choose your felt art. New felts unlock at higher levels.",
|
|
||||||
font_res,
|
font_res,
|
||||||
);
|
),
|
||||||
// Card-art theme picker — only renders when the registry has
|
SettingsTab::Accessibility => spawn_accessibility_tab(body, settings, font_res),
|
||||||
// entries (production: always; tests: only when
|
SettingsTab::Account => spawn_account_tab(body, settings, sync_status, font_res),
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Done is the only action — primary so the player always knows
|
// 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.
|
/// Section divider — small lavender label inside the scrollable body.
|
||||||
pub(super) fn section_label(
|
pub(super) fn section_label(
|
||||||
parent: &mut ChildSpawnerCommands,
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBord
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(super) fn sync_settings_panel_visibility(
|
pub(super) fn sync_settings_panel_visibility(
|
||||||
screen: Res<SettingsScreen>,
|
screen: Res<SettingsScreen>,
|
||||||
|
active_tab: Res<ActiveSettingsTab>,
|
||||||
panels: Query<Entity, With<SettingsPanel>>,
|
panels: Query<Entity, With<SettingsPanel>>,
|
||||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
|
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
|
||||||
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
|
scroll_nodes: Query<&ScrollPosition, With<SettingsScrollNode>>,
|
||||||
@@ -35,60 +36,17 @@ pub(super) fn sync_settings_panel_visibility(
|
|||||||
}
|
}
|
||||||
if screen.0 {
|
if screen.0 {
|
||||||
if panels.is_empty() && other_modal_scrims.is_empty() {
|
if panels.is_empty() && other_modal_scrims.is_empty() {
|
||||||
let status_label = sync_status.map_or_else(
|
build_panel(
|
||||||
|| "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,
|
&mut commands,
|
||||||
&settings.0,
|
&settings.0,
|
||||||
&status_label,
|
sync_status.as_deref(),
|
||||||
unlocked_backs,
|
progress.as_deref(),
|
||||||
unlocked_bgs,
|
|
||||||
&themes,
|
|
||||||
scroll_pos.0,
|
|
||||||
font_res.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 {
|
} 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.
|
/// Keeps the sync-status text node current while the panel is open.
|
||||||
pub(super) fn update_sync_status_text(
|
pub(super) fn update_sync_status_text(
|
||||||
sync_status: Option<Res<SyncStatusResource>>,
|
sync_status: Option<Res<SyncStatusResource>>,
|
||||||
|
|||||||
Reference in New Issue
Block a user