Compare commits

...

7 Commits

Author SHA1 Message Date
funman300 56647d7f0d docs: CHANGELOG + SESSION_HANDOFF refresh for v0.16.0
CHANGELOG gains a [0.16.0] section covering the modal-feel polish
round: per-modal Overflow::scroll_y on Achievements / Help / Stats /
Profile / Leaderboard, pointer cursor on hover for every Button,
same-frame focus on modal open (attach + auto_focus moved to
PostUpdate), and click-outside-to-dismiss for the six read-only
modals via a new ScrimDismissible marker.

The bottom-of-file compare links thread the new tag into the chain.
Test count updated to 1196.

SESSION_HANDOFF rewritten for the post-v0.16.0 state. Punch list
collapsed to two release-prep items (smoke-test, desktop packaging)
plus the carryover from v0.15.0's next-round candidates that didn't
ship this round (solver-driven hints, replay-rate slider, solver
progress overlay, async solver, "won previously" indicator, replay
sharing). Resume prompt asks A–E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:52:08 +00:00
funman300 cbf2483028 feat(engine): opt Profile / Leaderboard / Home into scrim-click dismiss
Follow-up to a54201e. The previous commit added ScrimDismissible to
Stats, Achievements, and Help; this one extends the same one-line
opt-in to the remaining three read-only modals so the click-outside-
to-close gesture is consistent across every informational surface.

Each modal now has the same shape: capture the scrim from
spawn_modal, attach ScrimDismissible after the build closure
returns. Three lines per file plus the import; no behaviour change
to the modal content itself.

Settings, Onboarding, Pause, Forfeit confirm, ConfirmNewGame, and
the win/game-over modals continue to opt OUT — all carry unsaved
or destructive state where an accidental scrim click would lose
work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:47:02 +00:00
funman300 a54201e97b feat(engine): click-outside-to-dismiss for read-only modals
Adds a ScrimDismissible marker to ui_modal that opts a modal into
the standard "click outside the card to close" gesture. The new
dismiss_modal_on_scrim_click system fires on a left-mouse press
whose cursor falls on the scrim and outside every ModalCard, then
despawns the topmost dismissible scrim — Bevy's hierarchy despawn
cascades to the card and its children.

Marker design is opt-in per modal so destructive / state-mutating
modals (Settings saves on close, Onboarding requires explicit
acknowledgement, Pause / Forfeit / ConfirmNewGame need confirmed
intent) don't lose work to an accidental scrim click. Three
read-only modals opt in this round:

- Stats — informational; press S or click outside to dismiss.
- Achievements — read-only list.
- Help — keyboard reference.

Profile, Leaderboard, and Home will opt in the same way in a
follow-up; they were left out to keep this commit's scope tight.

The hit-test path uses each ModalCard's UiGlobalTransform +
ComputedNode bounding box so stacked modals close cleanly: the
topmost dismissible scrim is the only candidate per click. Tests
spawn synthetic ComputedNodes (with bevy::sprite::BorderRect for
the resolved-border slots Bevy's UI module re-exports) so the
geometry hit-tests deterministically without running the full UI
layout pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:32:58 +00:00
funman300 48e412177c fix(engine): focus arrives on the same frame a modal opens
Previously when a click-handler in Update spawned a modal,
attach_focusable_to_modal_buttons and auto_focus_on_modal_open ran
in the same Update — but with no ordering edge to the click handler
the deferred Commands wouldn't materialise in time, so attach saw
no entities, FocusedButton stayed empty, and the very next Tab/Enter
press wasted itself moving focus from None to the primary instead
of activating it.

Moves attach_focusable_to_modal_buttons + auto_focus_on_modal_open
from Update to PostUpdate. The schedule boundary itself supplies
the sync point: every modal spawned anywhere in Update is
materialised before PostUpdate runs, attach can find the new
ModalButtons, and FocusedButton is populated before app.update()
returns. handle_focus_keys stays in Update so it observes input on
the frame it occurs, reading FocusedButton written by the previous
tick's PostUpdate.

Two new tests pin the contract:
- primary_button_is_focused_on_modal_spawn_same_frame uses a
  production-shaped spawner system (no chain edge to UiFocusPlugin)
  and asserts FocusedButton.0 is Some after a single update —
  fails without the fix, passes with it.
- first_tab_after_modal_open_advances_to_secondary guards against a
  regression where focus arrives but the very first Tab moves from
  None to primary instead of from primary to secondary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:32:19 +00:00
funman300 cd54ce1bb0 feat(engine): pointer cursor on hover over interactive buttons
Previously the cursor stayed the default arrow over every clickable
UI element (modal buttons, HUD action bar, mode-launcher cards,
settings toggles). Adds the standard "this is clickable" hand
affordance: while not dragging a card, hovering any entity with
Interaction::Hovered (or Pressed — keeps the pointer through a
click-and-hold) sets the window cursor to SystemCursorIcon::Pointer.

The new branch sits between the existing drag handlers in
update_cursor_icon: Grabbing wins when actively dragging, then
Pointer when a button is hovered, then Grab when a draggable card
is hovered, then Default. Card-drag affordance unchanged.

A pure pick_cursor_icon(is_dragging, any_button_hovered,
any_card_hovered) helper makes the priority logic unit-testable
without standing up a full Window + Camera fixture; four new tests
pin every branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 00:32:04 +00:00
funman300 7a3032b74c fix(engine): scroll the modals whose content overflows the viewport
Smoke-test report: the Achievements list isn't scrollable. With 19
achievements the panel overflows the modal at the 800x600 minimum
window and the bottom rows are clipped. The same problem applies to
several other modals whose content has grown over the v0.13–v0.15
rounds.

Mirrors the existing SettingsPanelScrollable pattern from
settings_plugin: each modal's body Node gets Overflow::scroll_y()
plus a max_height (Val::Vh(70.0) for most, Val::Vh(50.0) for the
leaderboard's variable-length ranking section), a marker component
so the scroll system can find it, and a sibling system that routes
MouseWheel events into the body's ScrollPosition.

Five modals fixed:
- Achievements: 19 rows clearly overflow; AchievementsScrollable +
  scroll_achievements_panel.
- Help: ~28 reference rows overflow at 800x600; HelpScrollable +
  scroll_help_panel.
- Stats: 8-cell primary grid + per-mode bests + progression +
  weekly goals + unlocks + Time Attack readout + replay caption is
  enough content to overflow once the player has any progress;
  StatsScrollable + scroll_stats_panel.
- Profile: Sync + Progression + 14-day calendar + up to 18
  unlocked achievements + Stats summary overflows once a few
  achievements unlock; ProfileScrollable + scroll_profile_panel.
- Leaderboard: 10-row cap is at the edge of overflow on 800x600
  with long display names; LeaderboardScrollable +
  scroll_leaderboard_panel (max_height = 50vh — the ranking section
  is the only variable-length part).

Home modal NOT scrolled — five mode cards plus a Cancel button
were sized to fit at 800x600 by design and adding scroll there
would clutter the launcher.

Five new tests pin the contract: each modal's body has the
scrollable marker, a non-default max_height, and Overflow::scroll_y.

Defer-list (small UX nits surfaced during the sweep, not fixed
here):
- Modal close-on-click-outside is missing across the board; would
  need Interaction on ModalScrim in ui_modal.
- ModalButton hover doesn't set a pointer cursor.
- Tab focus on modal open is initialised on the next frame instead
  of the same frame; first Tab press selects rather than focus
  already being on the primary.

These are bigger touches than the scroll fix and don't fit a
30-LOC budget; surfacing for a follow-up round.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:30:04 +00:00
funman300 89699a8a86 docs: SESSION_HANDOFF refresh for post-v0.15.0 (follow-up)
The previous v0.15.0 doc commit only landed CHANGELOG — the
SESSION_HANDOFF write silently no-op'd due to a Write tool param
mix-up. This commit lands the matching handoff refresh:

- Status block updated to v0.15.0 / HEAD / 1178 tests
- New v0.15.0 changelog table covering the seven feature commits
  (Bevy trim, replay playback core + overlay + Stats wiring,
  rolling replay history, Cinephile achievement, solver + toggle)
- Open punch list collapsed to two release-prep items (smoke-test,
  desktop packaging) and six fresh next-round candidates
  (solver-driven hints — now unblocked, replay-rate slider, solver
  progress overlay, async solver, "won previously" indicator,
  replay sharing)
- Resume prompt asks A–E

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 23:08:46 +00:00
11 changed files with 1718 additions and 589 deletions
+63 -1
View File
@@ -8,6 +8,67 @@ project follows [Semantic Versioning](https://semver.org/).
_Nothing yet._
## [0.16.0] — 2026-05-06
A modal-feel polish round. Every overlay screen now scrolls when its
content overflows the 800×600 minimum window, every clickable button
shows a hand cursor on hover, keyboard focus lands on the primary
button on the same frame the modal opens, and read-only modals
dismiss when the player clicks the scrim outside the card.
### Added
- **Pointer cursor on hover** for every interactive `Button` entity
(modal buttons, HUD action bar, mode-launcher cards, settings
toggles, Stats selectors). `update_cursor_icon` gains a fourth
branch sitting between Grabbing (active drag) and Grab
(draggable card hover): when no drag is active and any
`Interaction::Hovered`/`Pressed` button is detected, the window
cursor swaps to `SystemCursorIcon::Pointer`. A pure
`pick_cursor_icon` helper makes the priority logic
unit-testable.
- **Click-outside-to-dismiss** for the six read-only modals: Stats,
Achievements, Help, Profile, Leaderboard, Home. New
`ScrimDismissible` marker on `ModalScrim` opts a modal in;
`dismiss_modal_on_scrim_click` runs in `Update`, despawns the
topmost dismissible scrim on a left-mouse press whose cursor
lands on the scrim and outside every `ModalCard`. Bevy's
hierarchy despawn cascades to the card and children.
Settings, Onboarding, Pause, Forfeit confirm, and Confirm New
Game intentionally don't opt in — they carry unsaved or
destructive state.
### Fixed
- **Modal content scrolls when it overflows** (Achievements, Help,
Stats, Profile, Leaderboard). Each modal's body Node now
carries `Overflow::scroll_y()` plus a `max_height` constraint
(`Val::Vh(70.0)` for most, `Val::Vh(50.0)` for the
leaderboard's variable-length ranking section) and a marker
component (`AchievementsScrollable`, `HelpScrollable`,
`StatsScrollable`, `ProfileScrollable`,
`LeaderboardScrollable`). A sibling `scroll_*_panel` system
per modal routes `MouseWheel` events into the body's
`ScrollPosition`. Mirrors the existing `SettingsPanelScrollable`
pattern. Home modal intentionally not scrolled — its five
mode cards + Cancel are sized to fit at 800×600 by design.
- **Modal focus arrives on the same frame the modal opens.**
Previously `attach_focusable_to_modal_buttons` and
`auto_focus_on_modal_open` ran in `Update` alongside arbitrary
click-handlers that spawn modals; with no ordering edge,
Bevy's deferred `Commands` queued the new entities but the
attach system couldn't see them on the same tick. Both systems
moved to `PostUpdate` so the schedule boundary itself supplies
the sync point — `FocusedButton` is always populated before
`app.update()` returns. The very next Tab/Enter press lands on
a populated resource instead of wasting itself moving focus
from None to the primary.
### Stats
- 1196 passing tests (was 1178 at v0.15.0 close).
- Zero clippy warnings under `--workspace --all-targets -- -D warnings`.
## [0.15.0] — 2026-05-02
In-engine replay playback, the Klondike solver + "Winnable deals
@@ -465,7 +526,8 @@ with no PNG artwork yet.
CREDITS.md, persistent window geometry, mode-launcher Home repurpose,
client-side sync round-trip integration tests.
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.15.0...HEAD
[Unreleased]: https://github.com/funman300/Rusty_Solitaire/compare/v0.16.0...HEAD
[0.16.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.15.0...v0.16.0
[0.15.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.14.0...v0.15.0
[0.14.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.13.0...v0.14.0
[0.13.0]: https://github.com/funman300/Rusty_Solitaire/compare/v0.12.0...v0.13.0
+40 -73
View File
@@ -1,24 +1,22 @@
# Solitaire Quest — Session Handoff
**Last updated:** 2026-05-02 (session 9, post-v0.14.0 release prep) — v0.14.0 cut. The Quat bug fixes, the rest of the v0.13.0 candidate list, and the entire replay → upload → web-viewer pipeline are all bundled in this release. Direction now opens for the next round.
**Last updated:** 2026-05-06 (post-v0.16.0) — Modal-feel polish round shipped: every overlay scrolls when it overflows, every button shows a pointer cursor on hover, modal focus lands on the same frame, and read-only modals dismiss on scrim click. Direction now opens.
## Status at pause
- **HEAD on origin:** v0.14.0's tag commit (CHANGELOG + handoff refresh).
- **HEAD on origin:** v0.16.0's tag commit.
- **Working tree:** clean apart from untracked `CARD_PLAN.md` (intentional).
- **Build:** `cargo clippy --workspace --all-targets -- -D warnings` clean.
- **Tests:** **1134 passed / 0 failed** across the workspace.
- **Tags on origin:** `v0.9.0`, `v0.10.0`, `v0.11.0`, `v0.12.0`, `v0.13.0`, `v0.14.0`.
- **Tests:** **1196 passed / 0 failed** across the workspace.
- **Tags on origin:** `v0.9.0` through `v0.16.0`.
## Where we are
v0.14.0 is the largest release since the card-theme system. Three threads land together:
v0.16.0 is the smallest meaningful release in a while — a focused round on how modals feel rather than what they contain. The originating bug was "I can't scroll on the Achievements list"; the sweep that followed found four other modals with the same problem plus three smaller modal-feel gaps (no pointer cursor on buttons, focus arriving a frame late, no click-outside-to-dismiss).
1. **The remaining v0.13.0-era UX candidates** — theme thumbnails, daily-challenge calendar, Time Attack auto-save, per-mode bests, time-bonus multiplier slider.
2. **Quat smoke-test bug fixes** — multi-card move validation, softlock detection, deal-tween information leak.
3. **The replay pipeline** — record on win, persist to disk, upload to server, view in browser via a new `solitaire_wasm` crate. The biggest single feature since the card-theme system.
Every overlay screen now: scrolls if its content can overflow at 800×600, shows a hand cursor when you hover any button, has its primary auto-focused the moment the modal appears so the very first Tab/Enter is meaningful, and (for read-only screens) dismisses when you click outside the card.
The card-flight web animations and replay E2E test coverage close out the pipeline.
The post-v0.15.0 next-round candidates are still mostly open — solver-driven hints, replay-rate slider, solver progress overlay, async solver, "won previously" indicator, replay sharing. Direction is open.
### Design direction (unchanged)
@@ -30,64 +28,33 @@ The card-flight web animations and replay E2E test coverage close out the pipeli
`github.com/funman300/Rusty_Solitaire` is the canonical repo. Always push there.
## Session 8 + 9 (shipped 2026-05-02) — v0.14.0
### v0.13.0-era UX candidates (had landed but missed v0.13.0's tag)
## v0.16.0 (shipped 2026-05-06)
| Area | Commit | What landed |
|---|---|---|
| Theme thumbnails | `ba527de` | Each Settings → Cosmetic theme chip renders an Ace + back preview pair via `rasterize_svg`. Cached per theme. Missing-SVG themes show a transparent placeholder rather than crashing. |
| Daily-challenge calendar | `1a10476` | 14-dot horizontal calendar in the Profile modal. Today is ringed, completed days fill `STATE_SUCCESS`, missed days fill `BG_ELEVATED`. Caption: "Current streak: N · Longest: M". `PlayerProgress` gains `daily_challenge_history` (capped at 365) and `daily_challenge_longest_streak`. |
| Time Attack auto-save | `0001432` | New sibling `time_attack_session.json` next to `game_state.json`. Atomic .tmp + rename. 30 s auto-save while active + on `AppExit`. Sessions whose 10-min window expired in real time while the app was closed are discarded on load. |
| Per-mode bests | `3984231` | StatsSnapshot gains six `#[serde(default)]` fields (Classic / Zen / Challenge × best_score + fastest_win_seconds). Stats screen renders a "Per-mode bests" section. Lifetime totals continue to roll all modes together. |
| Time-bonus slider | `89c51ab` | Settings → Gameplay slider 0.02.0, default 1.0, "Off" at zero. Multiplies the time-bonus shown in the win modal. Cosmetic only — does NOT affect achievement unlock thresholds. |
### Quat smoke-test bug fixes
| Area | Commit | What landed |
|---|---|---|
| Move validation (#1) | `f1aeb24` | `solitaire_core::rules::is_valid_tableau_sequence(&[Card]) -> bool` checks every adjacent pair in a moved stack descends one rank with alternating colour. Wired into `move_cards`. Closes the bug where any multi-card lift could be dropped as long as the bottom landed legally. |
| Deal-tween leak (#4) | `3eabc14` | New-game snaps every card sprite to the stock pile position before writing `StateChangedEvent`, so all 52 cards animate from a single deck point during the deal. Previously sprites started from previous-game positions, briefly revealing the prior deal. |
| Softlock detection (#2) | `2716472` | `has_legal_moves` rewritten: walks every potential move source (every stock card, every waste card, the face-up top of every tableau column) against every foundation and every tableau. Previous heuristic returned `true` whenever stock had cards, hiding genuine softlocks. `GameOverScreen` now actually fires for true softlocks. |
| End-game screen (#3) | — | Resolved as downstream of #2. The pre-existing `GameOverScreen` and `WinSummaryOverlay` already cover the close-out paths; the softlock screen just never spawned because the old `has_legal_moves` lied. |
### Replay pipeline (the major feature)
| Area | Commit | What landed |
|---|---|---|
| Replay storage | `42535f5` | `solitaire_data::replay::Replay` (seed + draw_mode + mode + score + time + recorded date + ordered move list) and atomic save/load helpers under `<data_dir>/latest_replay.json`. Schema v1; `load` returns None for any other version. |
| Engine recording | `57d1c58` | `RecordingReplay` resource + `ReplayPath` settings. Every successful `MoveRequestEvent` / `DrawRequestEvent` appends to recording; `GameWonEvent` freezes the recording into a `Replay` and persists. Undo intentionally not recorded. New game clears the recording. |
| Stats button | `d9f36bf` | Stats overlay surfaces a "Latest win:" caption + "Watch replay" button. Loads from disk via `LatestReplayResource`. (Full in-engine playback deferred — button currently fires an `InfoToastEvent` describing the replay.) |
| Server upload + fetch | `93182fa` | `POST /api/replays` accepts a `Replay` JSON; `GET /api/replays/:id` returns it. JWT-gated. SQL migration for the new `replays` table. |
| Engine sync | `23c9704` | Engine uploads winning replays automatically when the player has cloud sync configured. Re-uses the existing JWT/refresh-token flow. |
| WASM crate | `5bed43e` | New workspace member `solitaire_wasm` compiles replay-relevant `solitaire_core` types to WebAssembly so a browser can re-execute a replay client-side. `wasm-bindgen` glue. |
| Web viewer | `07b8ecd` | `GET /replays/:id` returns HTML + CSS + the wasm bundle. Browser fetches the replay JSON, rasterises a deal from the seed, and animates the recorded moves. |
| E2E coverage | `3081505` | Server tests covering the full upload → fetch round-trip via `axum::test`. |
| Web flight anim | `1fcd032` | Card-flight tweens on the web side so the browser viewer reads as a real game replay rather than a static dump. |
| Modal scroll | `7a3032b` | Achievements / Help / Stats / Profile / Leaderboard bodies now carry `Overflow::scroll_y()` + a `max_height` constraint + a per-plugin `*Scrollable` marker. Sibling `scroll_*_panel` systems route `MouseWheel` into the body's `ScrollPosition`. Mirrors the existing `SettingsPanelScrollable` pattern. Home modal not scrolled — five mode cards + Cancel are sized to fit by design. |
| Pointer cursor | `cd54ce1` | `update_cursor_icon` gains a fourth branch: `SystemCursorIcon::Pointer` whenever any `Interaction::Hovered`/`Pressed` button is detected and no card drag is active. Branch order Grabbing → Pointer → Grab → Default. Pure `pick_cursor_icon(is_dragging, any_button_hovered, any_card_hovered)` helper unit-tests the priority. |
| Same-frame focus | `48e4121` | `attach_focusable_to_modal_buttons` and `auto_focus_on_modal_open` moved from `Update` to `PostUpdate`. The schedule boundary supplies the sync point so a click-handler in `Update` that spawns a modal has its `Commands` materialised before attach runs. `FocusedButton` is populated before `app.update()` returns; the very first Tab/Enter after open lands on a populated resource. |
| Scrim dismiss core | `a54201e` | New `ScrimDismissible` marker on `ModalScrim` opts a modal into click-outside-to-close. `dismiss_modal_on_scrim_click` system in `ui_modal` despawns the topmost dismissible scrim on a left-mouse press whose cursor lands on the scrim and outside every `ModalCard`. Stats / Achievements / Help opted in. |
| Scrim dismiss tail | `cbf2483` | One-line opt-in (capture scrim + insert marker) for Profile / Leaderboard / Home, completing all six read-only modals. |
## Open punch list
### Release prep
1. **Smoke-test on the alex machine** after pulling — confirm Quat's three bug fixes hold up in real gameplay, and try the new replay button + web viewer end-to-end.
2. **Desktop packaging** per `ARCHITECTURE.md §17`. The Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
### UX iteration (next-round candidates)
1. **Smoke-test on a real game**: confirm scroll feels right on Achievements (the original bug), pointer cursor changes on every interactive surface, the very first Tab in a modal already activates the primary, and clicking the dimmed area dismisses the read-only modals while NOT dismissing Settings/Pause.
2. **Desktop packaging** per `ARCHITECTURE.md §17`. Arch PKGBUILD exists in `/home/manage/solitaire-quest-pkgbuild/` (separate repo). Pending: app icon, macOS `.icns` + notarisation cert, Windows `.ico` + Authenticode cert, AppImage recipe.
- **Solver-at-deal toggle** (Quat investigation #1, still deferred): add a Settings → Gameplay toggle "Winnable deals only" rather than baking solver-only into every deal. Lightest middle ground.
- **Disable Bevy's default audio feature** (Quat investigation #2, still deferred): one-line `default-features = false` swap on the workspace `bevy =` line, re-enable explicitly the features the engine uses (`render`, `bevy_winit`, `2d`, `bevy_window`, `png`, `bevy_text`, `bevy_ui`, `bevy_log`, `bevy_asset`, `default_font`, `bevy_state`). Drops ~50 transitive crates including the rodio + symphonia stack the project doesn't use (kira handles audio).
- **In-engine replay playback** — promote the "Watch replay" button from a stub toast to a real playback overlay that re-runs the recorded moves with `CardAnimation` tweens. The wasm crate already proves the playback math; the in-engine version reuses the same execute logic against the live game state.
- **Per-replay history** — currently single-slot at `latest_replay.json`. A "best replay per mode" bucket or a recent-N rolling list would let players revisit notable wins.
- **Solver-driven hint system** — extend the existing hint toggle so a deal-time solver provides higher-quality hints (currently a heuristic). Requires the solver from the toggle work above.
- **Achievement: "won via replay path"** — track when a player wins a deal whose previously-saved replay also won the same deal. Mostly fun; trivial scope.
### Carryover from v0.15.0 next-round candidates
## Card-theme system (CARD_PLAN.md, fully shipped)
Still open — would each be ~50200 LOC:
Seven phases landed across `b8fb3fb``924a1e2` in v0.11.0; v0.13.0's `7ed4f2c` consumes the per-theme `back.svg`; v0.14.0's `ba527de` adds preview thumbnails. End-to-end:
- **Bundled default theme** ships in the binary via `embedded://` — 52 hayeah/playing-cards-assets SVGs + a midnight-purple `back.svg`.
- **User themes** under `themes://`. Drop a directory containing `theme.ron` + 53 SVGs.
- **Importer** at `solitaire_engine::theme::import_theme(zip)` validates archives and atomically unpacks.
- **Picker UI** in Settings → Cosmetic; thumbnails + the active theme's `back` override the legacy `back_N.png` picker when present.
- **Solver-driven hints** — the existing hint system uses a heuristic; promote it to ask `try_solve` for the actual best move. Now that the solver is in place this is mostly plumbing.
- **Replay-playback rate slider** — the 0.45 s/move pace is hardcoded; a Settings slider in the same row as tooltip-delay / time-bonus would let power users speed up older replays.
- **Solver progress overlay** — when "Winnable deals only" is on, a brief "checking deal…" toast surfaces after ~500 ms so the player isn't confused by the rare worst-case stall.
- **Solver-on-AsyncComputeTaskPool** — current solver runs synchronously on the main thread. Worst-case 50 attempts × 120 ms = 6 s of UI stall on pathological seeds. Async + cancel button would be safer.
- **Per-deal "won previously" indicator** — the rolling replay history's seeds make this easy: when a new game starts on a seed the player has already won, surface a tiny indicator on the HUD.
- **Replay sharing** — `replays.json` is per-machine. Allow a player to copy a replay's URL (already wired via `solitaire_server`) and post it elsewhere. The web-viewer already exists.
## Resume prompt
@@ -95,17 +62,17 @@ Seven phases landed across `b8fb3fb` → `924a1e2` in v0.11.0; v0.13.0's `7ed4f2
You are a senior Rust + Bevy developer working on Solitaire Quest.
Working directory: <Rusty_Solitaire clone path on this machine — local
directory may still be named Rusty_Solitare from earlier; that's fine>.
Branch: master. Direction is OPEN — v0.14.0 just shipped covering the
Quat bug fixes, the v0.13.0 candidate tail, and the entire
replay-pipeline feature.
Branch: master. Direction is OPEN — v0.16.0 just shipped covering
modal scroll fixes, pointer cursor, same-frame focus, and scrim-click
dismiss across all six read-only modals.
State: HEAD at v0.14.0. Working tree clean apart from untracked
State: HEAD at v0.16.0. Working tree clean apart from untracked
CARD_PLAN.md (intentional).
Build: cargo clippy --workspace --all-targets -- -D warnings clean.
Tests: 1134 passed / 0 failed.
Tests: 1196 passed / 0 failed.
READ FIRST (in order, before doing anything):
1. SESSION_HANDOFF.md — v0.14.0 changelog + open punch list
1. SESSION_HANDOFF.md — v0.16.0 changelog + open punch list
2. CHANGELOG.md — release-by-release record
3. CLAUDE.md — hard rules (UI-first, no panics, etc.)
4. ARCHITECTURE.md — crate responsibilities + data flow
@@ -114,16 +81,16 @@ READ FIRST (in order, before doing anything):
may be missing on a fresh machine)
DECISION TO ASK THE PLAYER FIRST:
A. Smoke-test v0.14.0 on the alex machine first to confirm the
three Quat bug fixes hold up in real gameplay and the replay
pipeline works end-to-end (record → upload → web viewer).
B. Take the deferred Bevy-audio-feature trim (Quat investigation
#2) — one-line workspace edit, ~50 fewer transitive crates.
C. Take the deferred solver toggle (Quat investigation #1): add
"Winnable deals only" Settings toggle. Larger.
D. Promote the in-engine "Watch replay" button to real playback.
E. Pick from the remaining "next-round candidates" in this doc.
F. Take the deferred desktop-packaging item (needs artwork +
A. Smoke-test v0.16.0. Scroll on Achievements, pointer cursor on
buttons, first Tab in a modal activates rather than advances,
scrim click dismisses Stats/Achievements/Help/Profile/
Leaderboard/Home but NOT Settings/Pause/etc.
B. Solver-driven hints — replace heuristic with try_solve's
best-move suggestion. ~100 LOC.
C. Solver-on-AsyncComputeTaskPool with progress toast + cancel.
Eliminates the worst-case 6 s stall.
D. Pick from the remaining "next-round candidates" in this doc.
E. Take the deferred desktop-packaging item (needs artwork +
signing certs from the user).
WORKFLOW NOTES:
@@ -136,5 +103,5 @@ WORKFLOW NOTES:
- Every commit must pass build / clippy / test before pushing.
- Push to GitHub (origin) — that is the canonical remote.
OPEN AT THE START: ask which of AF. Don't pick unilaterally.
OPEN AT THE START: ask which of AE. Don't pick unilaterally.
```
+138 -4
View File
@@ -7,6 +7,7 @@
use std::path::PathBuf;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use chrono::{Local, Timelike, Utc};
use solitaire_core::achievement::{
@@ -31,6 +32,7 @@ use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
@@ -48,6 +50,19 @@ pub struct AchievementsScreen;
#[derive(Component, Debug)]
pub struct AchievementRow;
/// Marker on the scrollable body Node inside the Achievements modal.
///
/// The Achievements list can grow to ~19 rows which overflows the modal at
/// the 800x600 minimum window. This marker tags the inner container that
/// carries `Overflow::scroll_y()` plus a `max_height` constraint so the
/// content scrolls instead of clipping. Mirrors the
/// `SettingsPanelScrollable` pattern in `settings_plugin`.
///
/// `scroll_achievements_panel` reads this marker to route mouse-wheel
/// events into the body's `ScrollPosition`.
#[derive(Component, Debug)]
pub struct AchievementsScrollable;
/// All per-player achievement records (one per known achievement).
#[derive(Resource, Debug, Clone)]
pub struct AchievementsResource(pub Vec<AchievementRecord>);
@@ -96,6 +111,11 @@ impl Plugin for AchievementPlugin {
.add_message::<XpAwardedEvent>()
.add_message::<InfoToastEvent>()
.add_message::<ToggleAchievementsRequestEvent>()
// `MouseWheel` is emitted by Bevy's input plugin under
// `DefaultPlugins`; register it explicitly so the
// achievements-scroll system also runs cleanly under
// `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
// Run after GameMutation (so GameWonEvent is available), after
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
// (so daily_challenge_streak is up to date for daily_devotee).
@@ -118,6 +138,7 @@ impl Plugin for AchievementPlugin {
)
.add_systems(Update, toggle_achievements_screen)
.add_systems(Update, handle_achievements_close_button)
.add_systems(Update, scroll_achievements_panel)
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
// `cinephile` the first time playback runs to natural completion.
// Reads the resource via `Option<Res<_>>` so headless tests that
@@ -395,6 +416,38 @@ fn handle_achievements_close_button(
}
}
/// Routes mouse-wheel events into the Achievements modal's scrollable body
/// while the panel is open.
///
/// `offset_y` increases downward (0 = top). Scrolling down (`ev.y < 0`) adds
/// to the offset; scrolling up subtracts. Clamped to >= 0 so the viewport
/// never scrolls past the top. Mirrors `scroll_settings_panel` in
/// `settings_plugin`. The query is empty when no `AchievementsScrollable`
/// is in the world (modal closed) so this is a no-op outside the open
/// state without an explicit gate resource.
fn scroll_achievements_panel(
mut scroll_evr: MessageReader<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<AchievementsScrollable>>,
) {
if scrollables.is_empty() {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
fn spawn_achievements_screen(
commands: &mut Commands,
records: &[AchievementRecord],
@@ -421,16 +474,35 @@ fn spawn_achievements_screen(
..default()
};
spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, header, font_res);
// Scrollable body — the achievements list grows to ~19 rows which
// overflows the modal on the 800x600 minimum window. Wrapping the
// row list in an `Overflow::scroll_y()` Node with a constrained
// `max_height` keeps every row reachable. The Done button below
// sits outside the scroll so it's always one click away. Mirrors
// the `SettingsPanelScrollable` pattern.
card.spawn((
AchievementsScrollable,
ScrollPosition::default(),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
max_height: Val::Vh(70.0),
overflow: Overflow::scroll_y(),
..default()
},
))
.with_children(|body| {
// Achievement rows — unlocked first, then locked alphabetical.
let mut sorted: Vec<_> = records.iter().collect();
sorted.sort_by_key(|r| (!r.unlocked, r.id.clone()));
for record in &sorted {
let def = achievement_by_id(&record.id);
let (name, description) = def.map_or((record.id.as_str(), ""), |d| (d.name, d.description));
let (name, description) =
def.map_or((record.id.as_str(), ""), |d| (d.name, d.description));
// Hide secret locked achievements so they remain a surprise.
let is_secret = def.is_some_and(|d| d.secret);
@@ -446,7 +518,7 @@ fn spawn_achievements_screen(
let tooltip_text = tooltip_for_row(record.unlocked, def);
card.spawn((
body.spawn((
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
@@ -485,7 +557,7 @@ fn spawn_achievements_screen(
});
// Subtle row separator — keeps the long list scannable.
card.spawn((
body.spawn((
Node {
height: Val::Px(1.0),
..default()
@@ -493,6 +565,7 @@ fn spawn_achievements_screen(
BackgroundColor(BORDER_SUBTLE),
));
}
});
spawn_modal_actions(card, |actions| {
spawn_modal_button(
@@ -505,6 +578,9 @@ fn spawn_achievements_screen(
);
});
});
// Achievements is a read-only list — clicking the scrim outside
// the card dismisses alongside the existing A / Done paths.
commands.entity(scrim).insert(ScrimDismissible);
}
fn format_reward(reward: Reward) -> String {
@@ -895,6 +971,64 @@ mod tests {
assert_eq!(count, 0);
}
// -----------------------------------------------------------------------
// Scrollable body
// -----------------------------------------------------------------------
/// Spawning the modal must place exactly one `AchievementsScrollable`
/// marker in the world so the row list scrolls instead of clipping at
/// the 800x600 minimum window.
#[test]
fn achievements_modal_body_is_scrollable() {
let mut app = headless_app();
press(&mut app, KeyCode::KeyA);
app.update();
let count = app
.world_mut()
.query::<&AchievementsScrollable>()
.iter(app.world())
.count();
assert_eq!(
count, 1,
"Achievements modal must spawn exactly one AchievementsScrollable body"
);
}
/// The scrollable body must constrain its `max_height` so the modal
/// actually engages scrolling on tall content. Without this the inner
/// flex column would expand to fit every row and `Overflow::scroll_y`
/// would have nothing to clip.
#[test]
fn achievements_modal_body_has_max_height() {
let mut app = headless_app();
press(&mut app, KeyCode::KeyA);
app.update();
let mut q = app
.world_mut()
.query_filtered::<&Node, With<AchievementsScrollable>>();
let nodes: Vec<&Node> = q.iter(app.world()).collect();
assert_eq!(nodes.len(), 1, "expected exactly one scrollable body");
let node = nodes[0];
// `Val::Auto` is the default; assert the body's `max_height` was
// explicitly set to something else so scroll engages.
assert_ne!(
node.max_height,
Val::Auto,
"scrollable body must set a non-default max_height; got {:?}",
node.max_height
);
// And the overflow axis must be y-scroll.
assert_eq!(
node.overflow,
Overflow::scroll_y(),
"scrollable body must use Overflow::scroll_y(); got {:?}",
node.overflow
);
}
// -----------------------------------------------------------------------
// format_reward
// -----------------------------------------------------------------------
+102 -13
View File
@@ -2,9 +2,19 @@
//!
//! **Cursor icons** (`update_cursor_icon`)
//! - Cards are being dragged → `Grabbing` (closed hand)
//! - A UI `Button` entity is hovered (and no drag in progress) → `Pointer`
//! (the hand-with-extended-index-finger icon). This telegraphs
//! clickability for every modal button, HUD action, mode-launcher
//! card, settings toggle, etc.
//! - Cursor hovers over a face-up draggable card → `Grab` (open hand)
//! - Otherwise → `Default` (arrow)
//!
//! Priority order: dragging > button-hover > card-hover > default. A
//! button-overlapping-a-card edge case favours `Pointer` because UI
//! elements take precedence over world-space cards; in practice
//! buttons are always on UI nodes and cards are sprites, so they
//! cannot occupy the same hit region simultaneously.
//!
//! **Drop-target highlights** (`update_drop_highlights`)
//! While a drag is in progress every `PileMarker` sprite is tinted:
//! - **Green** if the dragged stack can legally land there.
@@ -70,6 +80,31 @@ impl Plugin for CursorPlugin {
// #31 — Cursor icon
// ---------------------------------------------------------------------------
/// Pure decision function for the cursor icon, separated from the Bevy
/// system so it can be unit-tested without `PrimaryWindow` /
/// `Camera` / `Time` plumbing.
///
/// Priority order (highest first):
/// 1. `is_dragging` → `Grabbing`
/// 2. `any_button_hovered` → `Pointer`
/// 3. `any_card_hovered` → `Grab`
/// 4. otherwise → `Default`
fn pick_cursor_icon(
is_dragging: bool,
any_button_hovered: bool,
any_card_hovered: bool,
) -> SystemCursorIcon {
if is_dragging {
SystemCursorIcon::Grabbing
} else if any_button_hovered {
SystemCursorIcon::Pointer
} else if any_card_hovered {
SystemCursorIcon::Grab
} else {
SystemCursorIcon::Default
}
}
/// Updates the primary-window cursor icon based on drag state and hover.
fn update_cursor_icon(
drag: Res<DragState>,
@@ -77,18 +112,27 @@ fn update_cursor_icon(
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
game: Option<Res<GameStateResource>>,
button_q: Query<&Interaction, With<Button>>,
mut commands: Commands,
) {
let Ok((win_entity, window)) = windows.single() else { return };
if !drag.is_idle() {
commands
.entity(win_entity)
.insert(CursorIcon::from(SystemCursorIcon::Grabbing));
return;
}
let is_dragging = !drag.is_idle();
let hovering = (|| {
// A UI button is "hovered" if any `Button` entity has its
// `Interaction` set to `Hovered` or `Pressed`. We include
// `Pressed` so the pointer icon stays visible while a click is
// being held, matching browser behaviour.
let any_button_hovered = button_q
.iter()
.any(|i| matches!(i, Interaction::Hovered | Interaction::Pressed));
let any_card_hovered = if is_dragging || any_button_hovered {
// No need to do the world-space hit test when a higher
// priority branch already wins.
false
} else {
(|| {
let cursor = window.cursor_position()?;
let (camera, cam_xf) = cameras.single().ok()?;
let world = camera.viewport_to_world_2d(cam_xf, cursor).ok()?;
@@ -96,13 +140,11 @@ fn update_cursor_icon(
let game = game.as_ref()?;
Some(cursor_over_draggable(world, &game.0, &layout))
})()
.unwrap_or(false);
.unwrap_or(false)
};
commands.entity(win_entity).insert(CursorIcon::from(if hovering {
SystemCursorIcon::Grab
} else {
SystemCursorIcon::Default
}));
let icon = pick_cursor_icon(is_dragging, any_button_hovered, any_card_hovered);
commands.entity(win_entity).insert(CursorIcon::from(icon));
}
/// Returns `true` if `cursor` (world-space) is over any face-up draggable card.
@@ -482,6 +524,53 @@ mod tests {
);
}
// -----------------------------------------------------------------------
// pick_cursor_icon priority-order tests
// -----------------------------------------------------------------------
#[test]
fn cursor_picks_grabbing_when_dragging_overrides_button_hover() {
// Dragging always wins regardless of button or card hover state.
assert!(matches!(
pick_cursor_icon(true, true, true),
SystemCursorIcon::Grabbing
));
assert!(matches!(
pick_cursor_icon(true, false, false),
SystemCursorIcon::Grabbing
));
}
#[test]
fn cursor_picks_pointer_when_button_hovered_and_no_drag() {
// Button hover beats card hover when not dragging.
assert!(matches!(
pick_cursor_icon(false, true, false),
SystemCursorIcon::Pointer
));
assert!(matches!(
pick_cursor_icon(false, true, true),
SystemCursorIcon::Pointer
));
}
#[test]
fn cursor_picks_grab_when_card_hovered_and_no_button() {
// Card hover wins only when no drag and no button-hover.
assert!(matches!(
pick_cursor_icon(false, false, true),
SystemCursorIcon::Grab
));
}
#[test]
fn cursor_picks_default_when_nothing_hovered() {
assert!(matches!(
pick_cursor_icon(false, false, false),
SystemCursorIcon::Default
));
}
#[test]
fn cursor_over_draggable_returns_false_for_empty_game() {
use solitaire_core::game_state::{DrawMode, GameState};
+101 -5
View File
@@ -4,12 +4,14 @@
//! is an optional accelerator. Listed shortcuts are grouped by intent —
//! gameplay, modes, and overlays.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use crate::events::HelpRequestEvent;
use crate::font_plugin::FontResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
};
use crate::ui_theme::{
Z_MODAL_PANEL, BORDER_SUBTLE, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
@@ -24,6 +26,16 @@ pub struct HelpScreen;
#[derive(Component, Debug)]
pub struct HelpCloseButton;
/// Marker on the scrollable body Node inside the Help modal.
///
/// The controls reference is six sections totalling ~28 rows, which
/// overflows the modal on the 800x600 minimum window. This marker tags
/// the inner container that carries `Overflow::scroll_y()` plus a
/// `max_height` constraint so every row stays reachable. Mirrors the
/// `SettingsPanelScrollable` pattern.
#[derive(Component, Debug)]
pub struct HelpScrollable;
/// Spawns and despawns the help / controls overlay shown when the player
/// clicks the "Help" HUD button or presses `F1`. All hotkeys and gesture
/// guides live here.
@@ -32,7 +44,14 @@ pub struct HelpPlugin;
impl Plugin for HelpPlugin {
fn build(&self, app: &mut App) {
app.add_message::<HelpRequestEvent>()
.add_systems(Update, (toggle_help_screen, handle_help_close_button));
// `MouseWheel` is emitted by Bevy's input plugin under
// `DefaultPlugins`; register it explicitly so the help-scroll
// system also runs cleanly under `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_systems(
Update,
(toggle_help_screen, handle_help_close_button, scroll_help_panel),
);
}
}
@@ -71,6 +90,32 @@ fn handle_help_close_button(
}
}
/// Routes mouse-wheel events into the Help modal's scrollable body while
/// the panel is open. No-op when no `HelpScrollable` exists in the world
/// (modal closed). Mirrors `scroll_settings_panel`.
fn scroll_help_panel(
mut scroll_evr: MessageReader<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<HelpScrollable>>,
) {
if scrollables.is_empty() {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
/// Each entry in the controls reference table.
struct ControlRow {
keys: &'static str,
@@ -165,12 +210,29 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
..default()
};
spawn_modal(commands, HelpScreen, Z_MODAL_PANEL, |card| {
let scrim = spawn_modal(commands, HelpScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Controls", font_res);
// Scrollable body — the controls reference is six sections totalling
// ~28 rows, which overflows the modal on the 800x600 minimum
// window. Wrapping in an `Overflow::scroll_y()` Node with a
// constrained `max_height` keeps every row reachable; the Done
// button below stays fixed outside the scroll.
card.spawn((
HelpScrollable,
ScrollPosition::default(),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
max_height: Val::Vh(70.0),
overflow: Overflow::scroll_y(),
..default()
},
))
.with_children(|body| {
for section in CONTROL_SECTIONS {
// Section title in muted text — distinguishes from row content.
card.spawn((
body.spawn((
Text::new(section.title),
font_section.clone(),
TextColor(TEXT_SECONDARY),
@@ -178,7 +240,7 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
// Each row is a flex-row: kbd-style chip + description.
for row in section.rows {
card.spawn(Node {
body.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
@@ -216,11 +278,12 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
// Section spacer — small empty box. Keeps each section
// visually grouped.
card.spawn(Node {
body.spawn(Node {
height: Val::Px(SPACE_2),
..default()
});
}
});
spawn_modal_actions(card, |actions| {
spawn_modal_button(
@@ -233,6 +296,9 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
);
});
});
// Help is read-only — clicking the scrim outside the card dismisses
// alongside the existing F1 / Esc / Done paths.
commands.entity(scrim).insert(ScrimDismissible);
}
#[cfg(test)]
@@ -264,6 +330,36 @@ mod tests {
);
}
#[test]
fn help_modal_body_is_scrollable() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::F1);
app.update();
let count = app
.world_mut()
.query::<&HelpScrollable>()
.iter(app.world())
.count();
assert_eq!(
count, 1,
"Help modal must spawn exactly one HelpScrollable body"
);
let mut q = app
.world_mut()
.query_filtered::<&Node, With<HelpScrollable>>();
let nodes: Vec<&Node> = q.iter(app.world()).collect();
assert_ne!(
nodes[0].max_height,
Val::Auto,
"scrollable body must set a non-default max_height"
);
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
}
#[test]
fn pressing_f1_twice_closes_help_screen() {
let mut app = headless_app();
+4 -1
View File
@@ -26,6 +26,7 @@ use crate::progress_plugin::ProgressResource;
use crate::ui_focus::{Disabled, FocusGroup, Focusable};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_STRONG, BORDER_SUBTLE, RADIUS_MD, STATE_INFO,
@@ -359,7 +360,7 @@ fn handle_home_digit_keys(
/// Spawns the Home modal with five mode cards plus a Cancel button.
fn spawn_home_screen(commands: &mut Commands, level: u32, font_res: Option<&FontResource>) {
spawn_modal(commands, HomeScreen, Z_MODAL_PANEL, |card| {
let scrim = spawn_modal(commands, HomeScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Choose a Mode", font_res);
for mode in [
@@ -383,6 +384,8 @@ fn spawn_home_screen(commands: &mut Commands, level: u32, font_res: Option<&Font
);
});
});
// Home is read-only — opt into click-outside-to-dismiss.
commands.entity(scrim).insert(ScrimDismissible);
}
/// Tab-walk order for each mode card, matching the visual top-to-bottom
+103 -8
View File
@@ -9,6 +9,7 @@
//! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`)
//! the panel shows "Not available" immediately.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
use solitaire_data::settings::SyncBackend;
@@ -20,10 +21,11 @@ use crate::settings_plugin::SettingsResource;
use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_4, Z_MODAL_PANEL,
TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_4, Z_MODAL_PANEL,
};
// ---------------------------------------------------------------------------
@@ -66,6 +68,18 @@ struct LeaderboardFetchTask(Option<Task<Result<Vec<LeaderboardEntry>, String>>>)
#[derive(Component, Debug)]
pub struct LeaderboardScreen;
/// Marker on the scrollable body Node inside the Leaderboard modal.
///
/// The leaderboard caps at the top 10 entries today, but rendering the
/// caption + opt-in/opt-out row + 10 data rows on the 800x600 minimum
/// window is right at the edge of overflowing — long display names or
/// future row-count expansion would cut off entries below the fold.
/// Wrapping the data section in an `Overflow::scroll_y()` Node with a
/// constrained `max_height` keeps every row reachable. Mirrors the
/// `SettingsPanelScrollable` pattern.
#[derive(Component, Debug)]
pub struct LeaderboardScrollable;
/// Marker on the "Opt In" button inside the leaderboard panel.
#[derive(Component, Debug)]
struct LeaderboardOptInButton;
@@ -98,6 +112,11 @@ impl Plugin for LeaderboardPlugin {
.init_resource::<OptInTask>()
.init_resource::<OptOutTask>()
.add_message::<ToggleLeaderboardRequestEvent>()
// `MouseWheel` is emitted by Bevy's input plugin under
// `DefaultPlugins`; register it explicitly so the
// leaderboard-scroll system also runs cleanly under
// `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_systems(
Update,
(
@@ -112,7 +131,8 @@ impl Plugin for LeaderboardPlugin {
poll_opt_out_task,
)
.chain(),
);
)
.add_systems(Update, scroll_leaderboard_panel);
}
}
@@ -222,6 +242,33 @@ fn update_leaderboard_panel(
}
/// Click handler for the modal's "Done" button — despawns the overlay.
/// Routes mouse-wheel events into the Leaderboard modal's scrollable
/// data body while the panel is open. No-op when no
/// `LeaderboardScrollable` exists in the world (modal closed). Mirrors
/// `scroll_settings_panel`.
fn scroll_leaderboard_panel(
mut scroll_evr: MessageReader<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<LeaderboardScrollable>>,
) {
if scrollables.is_empty() {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
fn handle_leaderboard_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
@@ -346,7 +393,7 @@ fn spawn_leaderboard_screen(
remote_available: bool,
font_res: Option<&FontResource>,
) {
spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
let scrim = spawn_modal(commands, LeaderboardScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Leaderboard", font_res);
// Subhead — what the screen does + what the buttons control.
@@ -420,23 +467,40 @@ fn spawn_leaderboard_screen(
BackgroundColor(BORDER_SUBTLE),
));
// Scrollable data section — caps at top 10 rows today, but on the
// 800x600 minimum window the header + caption + opt-in row + 10
// entries crowds the modal. Wrapping in `Overflow::scroll_y()`
// with a `max_height` keeps every entry reachable and survives
// any future expansion of the row cap.
card.spawn((
LeaderboardScrollable,
ScrollPosition::default(),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
max_height: Val::Vh(50.0),
overflow: Overflow::scroll_y(),
..default()
},
))
.with_children(|body| {
match data {
LeaderboardResource::Idle => {
card.spawn((
body.spawn((
Text::new("Fetching\u{2026}"),
font_status.clone(),
TextColor(STATE_INFO),
));
}
LeaderboardResource::Error(_) => {
card.spawn((
body.spawn((
Text::new("Couldn't reach the leaderboard. Try again later."),
font_status.clone(),
TextColor(TEXT_SECONDARY),
));
}
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
card.spawn((
body.spawn((
Text::new("No entries yet \u{2014} sync and opt in to appear here."),
font_row.clone(),
TextColor(TEXT_SECONDARY),
@@ -444,7 +508,7 @@ fn spawn_leaderboard_screen(
}
LeaderboardResource::Loaded(rows) => {
// Column headers
card.spawn(Node {
body.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_4,
..default()
@@ -476,7 +540,7 @@ fn spawn_leaderboard_screen(
.best_score
.map_or_else(|| "-".to_string(), |s| s.to_string());
card.spawn(Node {
body.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_4,
..default()
@@ -490,6 +554,7 @@ fn spawn_leaderboard_screen(
}
}
}
});
spawn_modal_actions(card, |actions| {
spawn_modal_button(
@@ -502,6 +567,8 @@ fn spawn_leaderboard_screen(
);
});
});
// Leaderboard is read-only — opt into click-outside-to-dismiss.
commands.entity(scrim).insert(ScrimDismissible);
}
fn header_cell(parent: &mut ChildSpawnerCommands, text: &str, width: f32, font: &TextFont) {
@@ -646,6 +713,34 @@ mod tests {
assert_eq!(count, 1);
}
#[test]
fn leaderboard_modal_body_is_scrollable() {
let mut app = headless_app();
press(&mut app, KeyCode::KeyL);
app.update();
let count = app
.world_mut()
.query::<&LeaderboardScrollable>()
.iter(app.world())
.count();
assert_eq!(
count, 1,
"Leaderboard modal must spawn exactly one LeaderboardScrollable body"
);
let mut q = app
.world_mut()
.query_filtered::<&Node, With<LeaderboardScrollable>>();
let nodes: Vec<&Node> = q.iter(app.world()).collect();
assert_ne!(
nodes[0].max_height,
Val::Auto,
"scrollable body must set a non-default max_height"
);
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
}
#[test]
fn pressing_l_twice_dismisses_screen() {
let mut app = headless_app();
+123 -20
View File
@@ -4,6 +4,7 @@
//! summary in a single scrollable panel. Spawned on the first `P` keypress and
//! despawned on the second.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::input::ButtonInput;
use bevy::prelude::*;
use chrono::{Duration, Local, NaiveDate};
@@ -19,6 +20,7 @@ use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{format_fastest_win, format_win_rate, StatsResource};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY,
@@ -60,10 +62,60 @@ pub struct ProfilePlugin;
#[derive(Component, Debug)]
pub struct ProfileCloseButton;
/// Marker on the scrollable body Node inside the Profile modal.
///
/// The Profile panel renders sync info, progression (incl. 14-day
/// calendar), every unlocked achievement (up to ~18), and a stats
/// summary, which can overflow the modal on the 800x600 minimum window
/// once a player has unlocked several achievements. This marker tags
/// the inner container that carries `Overflow::scroll_y()` plus a
/// `max_height` constraint. Mirrors the `SettingsPanelScrollable`
/// pattern.
#[derive(Component, Debug)]
pub struct ProfileScrollable;
impl Plugin for ProfilePlugin {
fn build(&self, app: &mut App) {
app.add_message::<ToggleProfileRequestEvent>()
.add_systems(Update, (toggle_profile_screen, handle_profile_close_button));
// `MouseWheel` is emitted by Bevy's input plugin under
// `DefaultPlugins`; register it explicitly so the
// profile-scroll system also runs cleanly under
// `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_systems(
Update,
(
toggle_profile_screen,
handle_profile_close_button,
scroll_profile_panel,
),
);
}
}
/// Routes mouse-wheel events into the Profile modal's scrollable body
/// while the panel is open. No-op when no `ProfileScrollable` exists in
/// the world (modal closed). Mirrors `scroll_settings_panel`.
fn scroll_profile_panel(
mut scroll_evr: MessageReader<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<ProfileScrollable>>,
) {
if scrollables.is_empty() {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
@@ -133,9 +185,27 @@ fn spawn_profile_screen(
..default()
};
spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
let scrim = spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Profile", font_res);
// Scrollable body — the Profile panel renders sync info,
// progression (incl. a 14-day calendar), every unlocked
// achievement (up to ~18), and a stats summary, which can
// overflow the modal on the 800x600 minimum window once the
// player has unlocked several achievements. The Done action
// stays fixed outside the scroll.
card.spawn((
ProfileScrollable,
ScrollPosition::default(),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
max_height: Val::Vh(70.0),
overflow: Overflow::scroll_y(),
..default()
},
))
.with_children(|body| {
// First-launch welcome — only when the player has zero XP and
// zero daily streak, so the profile doesn't read as a wall of
// zeros to a brand-new player.
@@ -143,7 +213,7 @@ fn spawn_profile_screen(
&& p.0.total_xp == 0
&& p.0.daily_challenge_streak == 0
{
card.spawn((
body.spawn((
Text::new("Welcome! Play games to earn XP and unlock achievements."),
font_section.clone(),
TextColor(ACCENT_PRIMARY),
@@ -158,14 +228,14 @@ fn spawn_profile_screen(
}
// ── Sync section ────────────────────────────────────────────
card.spawn((
body.spawn((
Text::new("Sync"),
font_section.clone(),
TextColor(STATE_INFO),
));
if let Some(s) = settings {
let (backend_name, username) = sync_info(&s.0.sync_backend);
card.spawn((
body.spawn((
Text::new(format!("Account: {username} | Backend: {backend_name}")),
font_row.clone(),
TextColor(TEXT_PRIMARY),
@@ -180,7 +250,7 @@ fn spawn_profile_screen(
}
SyncStatus::Error(e) => format!("Sync error: {e}"),
};
card.spawn((
body.spawn((
Text::new(status_text),
font_row.clone(),
TextColor(TEXT_SECONDARY),
@@ -188,8 +258,8 @@ fn spawn_profile_screen(
}
// ── Progression section ─────────────────────────────────────
spawn_spacer(card, VAL_SPACE_2);
card.spawn((
spawn_spacer(body, VAL_SPACE_2);
body.spawn((
Text::new("Progression"),
font_section.clone(),
TextColor(STATE_INFO),
@@ -202,7 +272,7 @@ fn spawn_profile_screen(
} else {
xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100)
};
card.spawn((
body.spawn((
Text::new(format!(
"Level {} \u{2014} {} XP ({}/{} to next, {}%)",
prog.level, prog.total_xp, xp_done, xp_span, pct
@@ -210,7 +280,7 @@ fn spawn_profile_screen(
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
card.spawn((
body.spawn((
Text::new(format!(
"Daily streak: {} | Card backs: {} | Backgrounds: {}",
prog.daily_challenge_streak,
@@ -223,7 +293,7 @@ fn spawn_profile_screen(
// 14-day daily-challenge calendar row.
spawn_daily_calendar(
card,
body,
&prog.daily_challenge_history,
prog.daily_challenge_streak,
prog.daily_challenge_longest_streak,
@@ -233,8 +303,8 @@ fn spawn_profile_screen(
}
// ── Achievements section ────────────────────────────────────
spawn_spacer(card, VAL_SPACE_2);
card.spawn((
spawn_spacer(body, VAL_SPACE_2);
body.spawn((
Text::new("Achievements"),
font_section.clone(),
TextColor(STATE_INFO),
@@ -242,7 +312,7 @@ fn spawn_profile_screen(
if let Some(ar) = achievements {
let records = &ar.0;
let unlocked_count = records.iter().filter(|r| r.unlocked).count();
card.spawn((
body.spawn((
Text::new(format!("{unlocked_count} / 18 unlocked")),
font_row.clone(),
TextColor(ACCENT_PRIMARY),
@@ -264,14 +334,14 @@ fn spawn_profile_screen(
Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")),
None => String::new(),
};
card.spawn((
body.spawn((
Text::new(format!(" [x] {name}{date_str}")),
font_row.clone(),
TextColor(STATE_SUCCESS),
));
}
if !any_unlocked {
card.spawn((
body.spawn((
Text::new(" No achievements unlocked yet."),
font_row.clone(),
TextColor(TEXT_SECONDARY),
@@ -280,8 +350,8 @@ fn spawn_profile_screen(
}
// ── Statistics summary section ──────────────────────────────
spawn_spacer(card, VAL_SPACE_2);
card.spawn((
spawn_spacer(body, VAL_SPACE_2);
body.spawn((
Text::new("Statistics Summary"),
font_section.clone(),
TextColor(STATE_INFO),
@@ -293,7 +363,7 @@ fn spawn_profile_screen(
} else {
s.best_single_score.to_string()
};
card.spawn((
body.spawn((
Text::new(format!(
"Played: {} | Won: {} | Win rate: {} | Best time: {}",
s.games_played,
@@ -304,7 +374,7 @@ fn spawn_profile_screen(
font_row.clone(),
TextColor(TEXT_PRIMARY),
));
card.spawn((
body.spawn((
Text::new(format!(
"Win streak: {} current, {} best | Best score: {}",
s.win_streak_current, s.win_streak_best, best_score_str,
@@ -313,6 +383,7 @@ fn spawn_profile_screen(
TextColor(TEXT_PRIMARY),
));
}
});
spawn_modal_actions(card, |actions| {
spawn_modal_button(
@@ -325,6 +396,8 @@ fn spawn_profile_screen(
);
});
});
// Profile is read-only — opt into click-outside-to-dismiss.
commands.entity(scrim).insert(ScrimDismissible);
}
/// Spawn a fixed-height vertical spacer node.
@@ -503,6 +576,36 @@ mod tests {
);
}
#[test]
fn profile_modal_body_is_scrollable() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyP);
app.update();
let count = app
.world_mut()
.query::<&ProfileScrollable>()
.iter(app.world())
.count();
assert_eq!(
count, 1,
"Profile modal must spawn exactly one ProfileScrollable body"
);
let mut q = app
.world_mut()
.query_filtered::<&Node, With<ProfileScrollable>>();
let nodes: Vec<&Node> = q.iter(app.world()).collect();
assert_ne!(
nodes[0].max_height,
Val::Auto,
"scrollable body must set a non-default max_height"
);
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
}
#[test]
fn pressing_p_twice_closes_profile_screen() {
let mut app = headless_app();
+110 -13
View File
@@ -8,6 +8,7 @@
use std::path::PathBuf;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::input::ButtonInput;
use bevy::prelude::*;
use solitaire_data::{
@@ -28,6 +29,7 @@ use crate::resources::GameStateResource;
use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
ScrimDismissible,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES,
@@ -118,6 +120,18 @@ pub struct ReplaySelectorCaption;
#[derive(Component, Debug)]
pub struct PerModeBestsRow;
/// Marker on the scrollable body Node inside the Stats modal.
///
/// The Stats panel renders an 8-cell primary grid, three per-mode bests
/// rows, a five-cell progression grid, weekly goals, an unlocks line,
/// optional Time Attack readout, and the latest replay caption — enough
/// content to overflow the modal on the 800x600 minimum window. This
/// marker tags the inner container that carries `Overflow::scroll_y()`
/// plus a `max_height` constraint. Mirrors the `SettingsPanelScrollable`
/// pattern.
#[derive(Component, Debug)]
pub struct StatsScrollable;
/// Registers stats resources, update systems, and the UI toggle.
pub struct StatsPlugin {
/// Where to persist stats. `None` disables all file I/O (for tests).
@@ -167,6 +181,10 @@ impl Plugin for StatsPlugin {
.add_message::<InfoToastEvent>()
.add_message::<ToggleStatsRequestEvent>()
.add_message::<WinStreakMilestoneEvent>()
// `MouseWheel` is emitted by Bevy's input plugin under
// `DefaultPlugins`; register it explicitly so the stats-scroll
// system also runs cleanly under `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
// record_abandoned must read `move_count` BEFORE handle_new_game
// clobbers it with a fresh game. These are NOT in StatsUpdate because
// StatsUpdate (as a set) is ordered after GameMutation by external
@@ -195,7 +213,34 @@ impl Plugin for StatsPlugin {
.add_systems(
Update,
(handle_replay_selector_buttons, repaint_replay_selector_caption).chain(),
);
)
.add_systems(Update, scroll_stats_panel);
}
}
/// Routes mouse-wheel events into the Stats modal's scrollable body
/// while the panel is open. No-op when no `StatsScrollable` exists in
/// the world (modal closed). Mirrors `scroll_settings_panel`.
fn scroll_stats_panel(
mut scroll_evr: MessageReader<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<StatsScrollable>>,
) {
if scrollables.is_empty() {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
@@ -558,14 +603,33 @@ fn spawn_stats_screen(
..default()
};
spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| {
let scrim = spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Statistics", font_res);
// Scrollable body — the Stats panel renders an 8-cell grid plus
// multiple sections (per-mode bests, progression, weekly goals,
// unlocks, optional Time Attack, latest replay caption) and
// overflows the modal on the 800x600 minimum window. Wrapping
// in an `Overflow::scroll_y()` Node with a constrained
// `max_height` keeps every cell reachable; the Watch Replay /
// Done action row stays fixed outside the scroll.
card.spawn((
StatsScrollable,
ScrollPosition::default(),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_3,
max_height: Val::Vh(70.0),
overflow: Overflow::scroll_y(),
..default()
},
))
.with_children(|body| {
// First-launch caption — sits above the grid as gentle nudge so
// the wall of em-dashes reads as "nothing to track yet" rather
// than as broken state.
if is_first_launch {
card.spawn((
body.spawn((
Text::new("Play a game to start tracking stats."),
TextFont {
font_size: TYPE_CAPTION,
@@ -583,7 +647,7 @@ fn spawn_stats_screen(
}
// --- primary stat cells grid ---
card.spawn(Node {
body.spawn(Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::Center,
@@ -609,12 +673,12 @@ fn spawn_stats_screen(
// scoring (count of wins inside a 10-minute window) so a per-game
// best wouldn't compose; Daily uses Classic scoring and so already
// contributes to the Classic row.
card.spawn((
body.spawn((
Text::new("Per-mode bests"),
font_section.clone(),
TextColor(STATE_INFO),
));
card.spawn(Node {
body.spawn(Node {
flex_direction: FlexDirection::Column,
width: Val::Percent(100.0),
row_gap: VAL_SPACE_2,
@@ -646,7 +710,7 @@ fn spawn_stats_screen(
// --- progression section ---
if let Some(p) = progress {
card.spawn((
body.spawn((
Text::new("Progression"),
font_section.clone(),
TextColor(STATE_INFO),
@@ -658,7 +722,7 @@ fn spawn_stats_screen(
let daily_str = format_stat_value(p.daily_challenge_streak);
let challenge_str = challenge_progress_label(p.challenge_index);
card.spawn(Node {
body.spawn(Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::Center,
@@ -677,14 +741,14 @@ fn spawn_stats_screen(
});
// Weekly goals
card.spawn((
body.spawn((
Text::new("Weekly Goals"),
font_section.clone(),
TextColor(TEXT_SECONDARY),
));
for goal in WEEKLY_GOALS {
let pv = p.weekly_goal_progress.get(goal.id).copied().unwrap_or(0);
card.spawn((
body.spawn((
Text::new(format!(" {}: {}/{}", goal.description, pv, goal.target)),
font_row.clone(),
TextColor(TEXT_PRIMARY),
@@ -692,7 +756,7 @@ fn spawn_stats_screen(
}
// Unlocks line
card.spawn((
body.spawn((
Text::new(format!(
"Card Backs: {} | Backgrounds: {}",
format_id_list(&p.unlocked_card_backs),
@@ -708,7 +772,7 @@ fn spawn_stats_screen(
&& ta.active {
let mins = (ta.remaining_secs / 60.0).floor() as u64;
let secs = (ta.remaining_secs % 60.0).floor() as u64;
card.spawn((
body.spawn((
Text::new(format!(
"Time Attack \u{2014} {mins}m {secs:02}s left | Wins: {}",
ta.wins
@@ -726,11 +790,12 @@ fn spawn_stats_screen(
Some(r) => format!("Latest win: {}", format_replay_caption(r)),
None => "No replay recorded yet \u{2014} win a game first.".to_string(),
};
card.spawn((
body.spawn((
Text::new(replay_caption),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
});
spawn_modal_actions(card, |actions| {
// The Watch Replay button is always rendered so the
@@ -756,6 +821,8 @@ fn spawn_stats_screen(
);
});
});
// Stats is read-only — opt into click-outside-to-dismiss.
commands.entity(scrim).insert(ScrimDismissible);
}
/// Spawn one row of the "Per-mode bests" section: the mode label on the
@@ -1088,6 +1155,36 @@ mod tests {
);
}
#[test]
fn stats_modal_body_is_scrollable() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
let count = app
.world_mut()
.query::<&StatsScrollable>()
.iter(app.world())
.count();
assert_eq!(
count, 1,
"Stats modal must spawn exactly one StatsScrollable body"
);
let mut q = app
.world_mut()
.query_filtered::<&Node, With<StatsScrollable>>();
let nodes: Vec<&Node> = q.iter(app.world()).collect();
assert_ne!(
nodes[0].max_height,
Val::Auto,
"scrollable body must set a non-default max_height"
);
assert_eq!(nodes[0].overflow, Overflow::scroll_y());
}
#[test]
fn stats_screen_renders_three_per_mode_bests_rows() {
// Open the Stats overlay and assert three [`PerModeBestsRow`]
+161 -1
View File
@@ -121,11 +121,34 @@ impl Plugin for UiFocusPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<FocusedButton>()
.add_systems(Startup, spawn_focus_overlay)
// Attach + auto-focus run in `PostUpdate` so they see entities
// a click-handler in `Update` queued via `Commands` earlier in
// the same frame. If they ran in `Update` they'd race the
// click handler: there's no ordering edge between an arbitrary
// modal-spawning system and the focus chain, so Bevy's
// `auto_insert_apply_deferred` pass cannot synchronise them.
// Pushing the attach / auto-focus pair into `PostUpdate` puts
// the natural schedule-boundary sync point between every
// modal spawn and focus arrival — `FocusedButton` is always
// populated before the same `app.update()` returns.
//
// The remaining systems stay in `Update` so they keep
// observing input on the frame it occurs. They read
// `FocusedButton` written during the *previous* tick's
// `PostUpdate`, which is exactly what we want: the very next
// user keypress after a modal opens lands on a populated
// resource.
.add_systems(
Update,
PostUpdate,
(
attach_focusable_to_modal_buttons,
auto_focus_on_modal_open,
)
.chain(),
)
.add_systems(
Update,
(
sync_focus_on_mouse_click,
clear_hud_focus_on_unhover,
handle_focus_keys,
@@ -827,6 +850,143 @@ mod tests {
assert_eq!(focused, Some(a), "Primary button A should auto-focus");
}
/// One-shot trigger resource consumed by the production-shaped test
/// system [`spawn_modal_via_system`]. When set to `true`, the system
/// queues a `spawn_modal` call on the next `Update` and clears the
/// flag. Mirrors the real production flow where a click-handler
/// system queues the modal spawn via `Commands` rather than the
/// test fixture using `world.flush()` ahead of time.
#[derive(Resource, Default)]
struct SpawnModalTrigger(bool);
/// Production-shaped modal spawner: a regular Bevy `System` that
/// reads a trigger flag and queues a 2-button modal via `Commands`.
/// Crucially this system has **no** ordering relationship with
/// `UiFocusPlugin`'s chain — exactly the situation that surfaces the
/// "focus arrives one frame late" bug in production.
fn spawn_modal_via_system(
mut commands: Commands,
mut trigger: ResMut<SpawnModalTrigger>,
) {
if !trigger.0 {
return;
}
trigger.0 = false;
spawn_modal(&mut commands, TestModal, 0, |card| {
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
TestButtonB,
"B",
None,
ButtonVariant::Secondary,
None,
);
spawn_modal_button(
actions,
TestButtonA,
"A",
None,
ButtonVariant::Primary,
None,
);
});
});
}
/// Same-frame-focus contract: when a modal is spawned by an
/// independent system during the same `Update` as the focus chain,
/// `FocusedButton` must be populated with the primary button by the
/// time `handle_focus_keys` runs in that **same** update — so a Tab
/// pressed in the very next tick advances focus rather than
/// landing on "nothing focused → primary".
#[test]
fn primary_button_is_focused_on_modal_spawn_same_frame() {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<SpawnModalTrigger>();
// Register the production-shaped spawn system in `Update` with
// no chain relationship to `UiFocusPlugin`.
app.add_systems(Update, spawn_modal_via_system);
// Initial Startup pass.
app.update();
// Trigger the spawn and run exactly ONE update — the same
// `Update` cycle that the focus chain runs in. By the end of
// this update, `FocusedButton` must already point at the
// primary button.
app.world_mut().resource_mut::<SpawnModalTrigger>().0 = true;
app.update();
let primary = app
.world_mut()
.query_filtered::<Entity, With<TestButtonA>>()
.iter(app.world())
.next()
.expect("Primary button should exist after the spawn update");
assert_eq!(
app.world().resource::<FocusedButton>().0,
Some(primary),
"FocusedButton must be populated with the primary on the same frame the modal spawns"
);
}
/// Tab pressed on the very next tick after a modal opens must
/// advance focus from the primary to the secondary — not from
/// "nothing focused" to the primary. The latter would mean focus
/// arrived a frame late and Tab was wasted on first-focus instead
/// of advancing.
#[test]
fn first_tab_after_modal_open_advances_to_secondary() {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<SpawnModalTrigger>();
app.add_systems(Update, spawn_modal_via_system);
app.update();
// Spawn the modal in update N.
app.world_mut().resource_mut::<SpawnModalTrigger>().0 = true;
app.update();
// Press Tab on update N+1. If focus arrived correctly in N,
// Tab advances primary → secondary. If focus arrived late,
// Tab promotes "no focus" to primary (the bug).
let primary = app
.world_mut()
.query_filtered::<Entity, With<TestButtonA>>()
.iter(app.world())
.next()
.expect("primary spawned");
let secondary = app
.world_mut()
.query_filtered::<Entity, With<TestButtonB>>()
.iter(app.world())
.next()
.expect("secondary spawned");
press_key(&mut app, KeyCode::Tab);
app.update();
let focused_after_tab = app.world().resource::<FocusedButton>().0;
assert_ne!(
focused_after_tab,
Some(primary),
"first Tab after modal open should advance off the primary, not land on it (focus arrived late)"
);
assert_eq!(
focused_after_tab,
Some(secondary),
"first Tab from primary should land on the secondary"
);
}
#[test]
fn tab_advances_focus_in_spawn_order() {
let mut app = headless_app();
+323
View File
@@ -49,6 +49,8 @@
//! ```
use bevy::prelude::*;
use bevy::ui::{ComputedNode, UiGlobalTransform};
use bevy::window::PrimaryWindow;
use solitaire_data::AnimSpeed;
use crate::font_plugin::FontResource;
@@ -74,6 +76,19 @@ pub struct ModalScrim;
#[derive(Component, Debug)]
pub struct ModalCard;
/// Marker on a [`ModalScrim`] entity opting that modal into the
/// click-outside-to-dismiss behaviour.
///
/// When attached, [`dismiss_modal_on_scrim_click`] despawns the scrim
/// (and its hierarchy) on a left mouse press whose cursor falls on the
/// scrim and outside every [`ModalCard`]. Modals with destructive
/// actions or unsaved state (Settings, Onboarding, Pause, Forfeit
/// confirmation, Confirm New Game, etc.) intentionally do not opt in
/// — those require an explicit Cancel / Done / Confirm so an
/// accidental scrim click cannot lose work.
#[derive(Component, Debug, Clone, Copy)]
pub struct ScrimDismissible;
/// Marker on a header `Text` (`TYPE_HEADLINE` + `TEXT_PRIMARY`).
#[derive(Component, Debug)]
pub struct ModalHeader;
@@ -474,6 +489,89 @@ pub fn advance_modal_enter(
}
}
// ---------------------------------------------------------------------------
// Click-outside-to-dismiss
// ---------------------------------------------------------------------------
/// Returns `true` when the cursor at `cursor_logical` falls inside the
/// axis-aligned rectangle described by `centre_logical` (rectangle
/// centre, logical pixels) and `size_logical` (full width × height,
/// logical pixels).
///
/// Pure helper extracted from [`dismiss_modal_on_scrim_click`] so the
/// hit-test decision can be tested without a real `Window` /
/// rendered UI tree.
#[inline]
fn cursor_is_inside_rect(cursor_logical: Vec2, centre_logical: Vec2, size_logical: Vec2) -> bool {
let half = size_logical * 0.5;
cursor_logical.x >= centre_logical.x - half.x
&& cursor_logical.x <= centre_logical.x + half.x
&& cursor_logical.y >= centre_logical.y - half.y
&& cursor_logical.y <= centre_logical.y + half.y
}
/// Despawns the topmost [`ScrimDismissible`] modal when the player
/// presses the left mouse button while the cursor is over the scrim
/// AND outside every [`ModalCard`]. Modals without the marker are
/// untouched, and existing dismiss paths (Cancel / Done / Esc /
/// dedicated buttons) keep working unchanged.
///
/// **Topmost-only.** Stacked dismissible modals would otherwise all
/// dismiss together on a single click. The system processes at most
/// one entity per frame: the first match in the query is taken,
/// matching the click-handler convention used elsewhere in the engine.
/// Spawn order is the practical tiebreaker — dismissible modals are
/// rarely stacked, so picking any one is acceptable.
///
/// **No same-frame dismissal.** `just_pressed` is true only on the
/// frame the button transitions to pressed. The press that *opens* a
/// modal happens on one frame; this system fires on a subsequent
/// press, so a modal can never be opened and dismissed in a single
/// click.
///
/// `cards`/`scrims` queries read [`UiGlobalTransform`] (window-space
/// physical pixels) and [`ComputedNode`] (size in physical pixels);
/// both are converted to logical pixels via
/// `ComputedNode::inverse_scale_factor` so they can be compared with
/// the cursor position from `Window::cursor_position` (logical px).
#[allow(clippy::type_complexity)]
pub fn dismiss_modal_on_scrim_click(
mut commands: Commands,
mouse: Option<Res<ButtonInput<MouseButton>>>,
windows: Query<&Window, With<PrimaryWindow>>,
scrims: Query<Entity, (With<ModalScrim>, With<ScrimDismissible>)>,
cards: Query<(&UiGlobalTransform, &ComputedNode), With<ModalCard>>,
) {
let Some(mouse) = mouse else { return };
if !mouse.just_pressed(MouseButton::Left) {
return;
}
let Ok(window) = windows.single() else {
return;
};
let Some(cursor) = window.cursor_position() else {
return;
};
// Topmost-only: bail after the first dismissible scrim. Stacked
// dismissible modals are not currently a real case, but this guard
// keeps the behaviour predictable if they ever arise.
let Some(scrim_entity) = scrims.iter().next() else {
return;
};
let cursor_over_card = cards.iter().any(|(transform, computed)| {
let inv = computed.inverse_scale_factor;
let size_logical = computed.size() * inv;
let centre_logical = transform.translation * inv;
cursor_is_inside_rect(cursor, centre_logical, size_logical)
});
if !cursor_over_card {
commands.entity(scrim_entity).despawn();
}
}
/// Repaints every `ModalButton` on `Changed<Interaction>` so hover and
/// press states are visible without each overlay registering its own
/// paint system.
@@ -515,6 +613,12 @@ impl Plugin for UiModalPlugin {
Update,
(apply_modal_enter_speed, advance_modal_enter, paint_modal_buttons).chain(),
);
// Click-outside-to-dismiss is independent of the open
// animation chain — it reads `just_pressed(Left)` and runs
// every tick. `just_pressed` is true only on the frame the
// button transitions to pressed, so the press that *opens* a
// modal cannot dismiss the same modal on the next frame.
app.add_systems(Update, dismiss_modal_on_scrim_click);
}
}
@@ -668,5 +772,224 @@ mod tests {
Duration::from_secs_f32(secs),
));
}
// -----------------------------------------------------------------------
// Click-outside-to-dismiss
// -----------------------------------------------------------------------
/// Pure-helper hit-test: cursor inside the rectangle returns true.
#[test]
fn cursor_is_inside_rect_inside_returns_true() {
// 100×60 rectangle centred at (200, 150).
let centre = Vec2::new(200.0, 150.0);
let size = Vec2::new(100.0, 60.0);
// Centre + a few corners just inside.
assert!(cursor_is_inside_rect(centre, centre, size));
assert!(cursor_is_inside_rect(Vec2::new(151.0, 121.0), centre, size));
assert!(cursor_is_inside_rect(Vec2::new(249.0, 179.0), centre, size));
}
/// Pure-helper hit-test: cursor outside the rectangle returns false
/// on every side.
#[test]
fn cursor_is_inside_rect_outside_returns_false() {
let centre = Vec2::new(200.0, 150.0);
let size = Vec2::new(100.0, 60.0);
assert!(!cursor_is_inside_rect(Vec2::new(149.0, 150.0), centre, size)); // left
assert!(!cursor_is_inside_rect(Vec2::new(251.0, 150.0), centre, size)); // right
assert!(!cursor_is_inside_rect(Vec2::new(200.0, 119.0), centre, size)); // above
assert!(!cursor_is_inside_rect(Vec2::new(200.0, 181.0), centre, size)); // below
}
/// Builds a headless app capable of running
/// `dismiss_modal_on_scrim_click`: registers the plugin, primes the
/// `ButtonInput<MouseButton>` resource that `MinimalPlugins`
/// doesn't provide, and spawns a synthetic `PrimaryWindow`.
fn dismiss_test_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(UiModalPlugin);
app.init_resource::<ButtonInput<MouseButton>>();
// Synthetic primary window. `MinimalPlugins` doesn't ship
// `WindowPlugin`, so spawning the entity by hand is fine —
// `dismiss_modal_on_scrim_click` only reads `cursor_position`
// off it, not any platform-backed state.
app.world_mut().spawn((
Window {
resolution: bevy::window::WindowResolution::new(800, 600),
..default()
},
PrimaryWindow,
));
app
}
/// Marker for synthetic-modal tests below.
#[derive(Component, Debug)]
struct DismissTestModal;
/// Spawns a synthetic scrim + card pair pre-populated with
/// `ComputedNode` + `UiGlobalTransform` so the dismiss system has
/// real geometry to hit-test against without running the full UI
/// layout pipeline. `card_centre` and `card_size` are in physical
/// pixels (matching `ComputedNode.size`); the synthetic
/// `inverse_scale_factor` is 1.0 so logical == physical.
fn spawn_synthetic_modal(
app: &mut App,
dismissible: bool,
card_centre: Vec2,
card_size: Vec2,
) -> Entity {
let world = app.world_mut();
let mut scrim = world.spawn((DismissTestModal, ModalScrim));
if dismissible {
scrim.insert(ScrimDismissible);
}
let scrim_entity = scrim.id();
let card_entity = world
.spawn((
ModalCard,
{
let mut node = ComputedNode {
stack_index: 0,
size: card_size,
content_size: card_size,
scrollbar_size: Vec2::ZERO,
scroll_position: Vec2::ZERO,
outline_width: 0.0,
outline_offset: 0.0,
unrounded_size: card_size,
border: bevy::sprite::BorderRect::default(),
border_radius: bevy::ui::ResolvedBorderRadius::default(),
padding: bevy::sprite::BorderRect::default(),
inverse_scale_factor: 1.0,
};
// `is_empty` guard inside Bevy treats zero-size
// nodes as inert; we always pass a non-zero size.
node.size = card_size;
node
},
UiGlobalTransform::from_translation(card_centre),
))
.id();
// Parent the card to the scrim so a `commands.entity(scrim).despawn()`
// also takes the card down — matching the real `spawn_modal` hierarchy.
world.entity_mut(scrim_entity).add_child(card_entity);
scrim_entity
}
/// Sets the synthetic primary window's cursor position (logical px,
/// since we use `inverse_scale_factor = 1.0` everywhere in tests).
fn set_cursor(app: &mut App, position: Option<Vec2>) {
let world = app.world_mut();
let mut q = world.query_filtered::<&mut Window, With<PrimaryWindow>>();
let mut window = q.single_mut(world).expect("primary window");
window.set_cursor_position(position);
}
/// Drives a fresh `just_pressed(Left)` for the next `app.update()`.
/// `MinimalPlugins` doesn't run the input clear pass, so we mark
/// the clear by hand on the resource between presses.
fn press_left_mouse(app: &mut App) {
let mut input = app
.world_mut()
.resource_mut::<ButtonInput<MouseButton>>();
input.clear();
input.press(MouseButton::Left);
}
/// Click outside the card on a dismissible modal despawns it.
#[test]
fn dismissible_scrim_despawns_on_scrim_click_outside_card() {
let mut app = dismiss_test_app();
let scrim = spawn_synthetic_modal(
&mut app,
/* dismissible: */ true,
Vec2::new(400.0, 300.0),
Vec2::new(200.0, 100.0),
);
// Cursor far outside the card — top-left corner of the window.
set_cursor(&mut app, Some(Vec2::new(50.0, 50.0)));
press_left_mouse(&mut app);
app.update();
assert!(
app.world().get_entity(scrim).is_err(),
"dismissible scrim should be despawned on a scrim-area click"
);
}
/// Click *inside* the card area must NOT dismiss the modal — the
/// player intends to interact with the card content.
#[test]
fn dismissible_scrim_does_not_despawn_on_card_click() {
let mut app = dismiss_test_app();
let scrim = spawn_synthetic_modal(
&mut app,
/* dismissible: */ true,
Vec2::new(400.0, 300.0),
Vec2::new(200.0, 100.0),
);
// Cursor at the card centre — definitely inside.
set_cursor(&mut app, Some(Vec2::new(400.0, 300.0)));
press_left_mouse(&mut app);
app.update();
assert!(
app.world().get_entity(scrim).is_ok(),
"click inside the card must not dismiss the modal"
);
}
/// Modals without `ScrimDismissible` ignore scrim clicks entirely.
/// Settings, Onboarding, Pause, etc. rely on this opt-out.
#[test]
fn non_dismissible_scrim_does_not_despawn_on_scrim_click() {
let mut app = dismiss_test_app();
let scrim = spawn_synthetic_modal(
&mut app,
/* dismissible: */ false,
Vec2::new(400.0, 300.0),
Vec2::new(200.0, 100.0),
);
set_cursor(&mut app, Some(Vec2::new(50.0, 50.0)));
press_left_mouse(&mut app);
app.update();
assert!(
app.world().get_entity(scrim).is_ok(),
"non-dismissible scrim must survive a scrim-area click"
);
}
/// Stacked dismissible modals: one click despawns at most one
/// modal per frame (the one the query yields first). The other
/// stays put until the next press.
#[test]
fn stacked_modals_dismiss_at_most_one_per_click() {
let mut app = dismiss_test_app();
let a = spawn_synthetic_modal(
&mut app,
/* dismissible: */ true,
Vec2::new(400.0, 300.0),
Vec2::new(200.0, 100.0),
);
let b = spawn_synthetic_modal(
&mut app,
/* dismissible: */ true,
Vec2::new(400.0, 300.0),
Vec2::new(200.0, 100.0),
);
// Cursor outside both cards.
set_cursor(&mut app, Some(Vec2::new(50.0, 50.0)));
press_left_mouse(&mut app);
app.update();
let a_alive = app.world().get_entity(a).is_ok();
let b_alive = app.world().get_entity(b).is_ok();
assert!(
a_alive ^ b_alive,
"exactly one of the two stacked dismissible modals should remain"
);
}
}