9f038250d9
Test / test (pull_request) Failing after 8m41s
Phase E of docs/ui-redesign-2026-07.md. New you_hub_plugin owns the modal shell (header, shared tab chips, single Done); each tab's content is a body builder extracted from its original plugin with every marker component unchanged, so per-row update/scroll/selector systems keep working. The replay selector gets its own Replays tab (Watch/Copy buttons move into the tab body). - Toggle*RequestEvents + P/S/A accelerators open the hub on the right tab, switch tabs in place, or toggle closed on a same-tab request; Esc/Done/scrim-click close - Legacy ProfileScreen/StatsScreen/AchievementsScreen markers ride the hub scrim for the active tab — external queries and tests keep their meaning - Standalone toggle/close systems and per-screen Done buttons removed (ProfileCloseButton, StatsCloseButton, AchievementsCloseButton) - Tests: 2 new hub lifecycle tests; profile/stats/achievements modal tests adapted (fixtures add YouHubPlugin; selector tests target the Replays tab). Engine suite 916 green, clippy -D warnings, fmt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
487 lines
16 KiB
Rust
487 lines
16 KiB
Rust
//! The "You" hub — Profile · Stats · Achievements · Replays folded
|
|
//! into one tabbed modal (Phase E of `docs/ui-redesign-2026-07.md`).
|
|
//!
|
|
//! The four screens were previously standalone modals reached one at a
|
|
//! time through the HUD popover. The hub owns the modal shell (header,
|
|
//! tab chips via the shared [`spawn_tab_chip`] widget, a single Done
|
|
//! button); each tab's content is a body builder that lives in its
|
|
//! original plugin (`spawn_profile_body`, `spawn_stats_body`,
|
|
//! `spawn_achievements_body`, `spawn_replays_body`) so every marker
|
|
//! component and per-row update system keeps working unchanged.
|
|
//!
|
|
//! Legacy screen markers (`ProfileScreen`, `StatsScreen`,
|
|
//! `AchievementsScreen`) are inserted on the hub scrim while their tab
|
|
//! is active, so existing queries and tests keep their meaning.
|
|
//!
|
|
//! Open paths: `ToggleProfileRequestEvent` / `ToggleStatsRequestEvent`
|
|
//! / `ToggleAchievementsRequestEvent` (HUD popover) and the P / S / A
|
|
//! accelerators — each opens the hub pre-selected to its tab, toggles
|
|
//! the hub closed when its tab is already showing, or switches tabs
|
|
//! when a different tab is showing. Esc, Done, and scrim-click close.
|
|
|
|
use bevy::ecs::system::SystemParam;
|
|
use bevy::prelude::*;
|
|
|
|
use solitaire_data::StatsSnapshot;
|
|
|
|
use crate::achievement_plugin::{AchievementsResource, AchievementsScreen};
|
|
use crate::events::{
|
|
ToggleAchievementsRequestEvent, ToggleProfileRequestEvent, ToggleStatsRequestEvent,
|
|
};
|
|
use crate::font_plugin::FontResource;
|
|
use crate::profile_plugin::{AvatarResource, ProfileScreen};
|
|
use crate::progress_plugin::ProgressResource;
|
|
use crate::resources::SyncStatusResource;
|
|
use crate::settings_plugin::SettingsResource;
|
|
use crate::stats_plugin::{ReplayHistoryResource, SelectedReplayIndex, StatsResource, StatsScreen};
|
|
use crate::time_attack_plugin::TimeAttackResource;
|
|
use crate::ui_focus::FocusRow;
|
|
use crate::ui_modal::{
|
|
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
|
spawn_modal_button, spawn_modal_header, spawn_tab_chip,
|
|
};
|
|
use crate::ui_theme::{VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL};
|
|
|
|
/// Which tab of the You hub is showing.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum YouTab {
|
|
#[default]
|
|
Profile,
|
|
Stats,
|
|
Achievements,
|
|
Replays,
|
|
}
|
|
|
|
impl YouTab {
|
|
/// Every tab, in display order.
|
|
pub const ALL: [Self; 4] = [
|
|
Self::Profile,
|
|
Self::Stats,
|
|
Self::Achievements,
|
|
Self::Replays,
|
|
];
|
|
|
|
/// Chip label.
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Profile => "Profile",
|
|
Self::Stats => "Stats",
|
|
Self::Achievements => "Awards",
|
|
Self::Replays => "Replays",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The active hub tab. Session-only; reopening returns to the last tab
|
|
/// unless an open request names a different one.
|
|
#[derive(Resource, Debug, Default)]
|
|
pub struct ActiveYouTab(pub YouTab);
|
|
|
|
/// Marker on the hub modal's scrim root.
|
|
#[derive(Component)]
|
|
pub struct YouHubScreen;
|
|
|
|
/// Per-chip tab selector button.
|
|
#[derive(Component, Debug)]
|
|
struct YouHubTabButton(YouTab);
|
|
|
|
/// Marker on the hub's Done button.
|
|
#[derive(Component)]
|
|
struct YouHubCloseButton;
|
|
|
|
/// Read-only bundle of everything the tab bodies render from. Split
|
|
/// out as a [`SystemParam`] because the open + rebuild systems both
|
|
/// need the full set and Bevy caps systems at 16 parameters.
|
|
#[derive(SystemParam)]
|
|
struct YouHubContext<'w> {
|
|
settings: Option<Res<'w, SettingsResource>>,
|
|
sync_status: Option<Res<'w, SyncStatusResource>>,
|
|
progress: Option<Res<'w, ProgressResource>>,
|
|
achievements: Option<Res<'w, AchievementsResource>>,
|
|
stats: Option<Res<'w, StatsResource>>,
|
|
avatar: Option<Res<'w, AvatarResource>>,
|
|
time_attack: Option<Res<'w, TimeAttackResource>>,
|
|
replay_history: Option<Res<'w, ReplayHistoryResource>>,
|
|
selected_replay: Option<Res<'w, SelectedReplayIndex>>,
|
|
font_res: Option<Res<'w, FontResource>>,
|
|
}
|
|
|
|
/// Bevy plugin owning the You hub lifecycle. Requires the profile,
|
|
/// stats, and achievement plugins for live data; degrades to empty tab
|
|
/// bodies without them (headless tests).
|
|
pub struct YouHubPlugin;
|
|
|
|
impl Plugin for YouHubPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<ActiveYouTab>()
|
|
.add_message::<ToggleProfileRequestEvent>()
|
|
.add_message::<ToggleStatsRequestEvent>()
|
|
.add_message::<ToggleAchievementsRequestEvent>()
|
|
.add_systems(
|
|
Update,
|
|
// Chained: a toggle/chip press must be observed before
|
|
// the rebuild, and the rebuild before close — total
|
|
// order prevents double-spawns within one frame.
|
|
(
|
|
open_or_toggle_you_hub,
|
|
handle_tab_buttons,
|
|
rebuild_on_tab_change,
|
|
handle_close_button,
|
|
)
|
|
.chain(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Maps this frame's toggle events + accelerator keys to a requested
|
|
/// tab, mirroring the semantics the three standalone screens had.
|
|
fn requested_tab(
|
|
profile_events: &mut MessageReader<ToggleProfileRequestEvent>,
|
|
stats_events: &mut MessageReader<ToggleStatsRequestEvent>,
|
|
achievements_events: &mut MessageReader<ToggleAchievementsRequestEvent>,
|
|
keys: &ButtonInput<KeyCode>,
|
|
) -> Option<YouTab> {
|
|
let profile = profile_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyP);
|
|
let stats = stats_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyS);
|
|
let achievements = achievements_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyA);
|
|
if profile {
|
|
Some(YouTab::Profile)
|
|
} else if stats {
|
|
Some(YouTab::Stats)
|
|
} else if achievements {
|
|
Some(YouTab::Achievements)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Opens the hub on the requested tab, switches tabs when it's already
|
|
/// open on a different one, toggles it closed on a same-tab request or
|
|
/// Esc.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn open_or_toggle_you_hub(
|
|
mut commands: Commands,
|
|
keys: Res<ButtonInput<KeyCode>>,
|
|
mut profile_events: MessageReader<ToggleProfileRequestEvent>,
|
|
mut stats_events: MessageReader<ToggleStatsRequestEvent>,
|
|
mut achievements_events: MessageReader<ToggleAchievementsRequestEvent>,
|
|
screens: Query<Entity, With<YouHubScreen>>,
|
|
other_modal_scrims: Query<(), (With<ModalScrim>, Without<YouHubScreen>)>,
|
|
mut active: ResMut<ActiveYouTab>,
|
|
ctx: YouHubContext,
|
|
) {
|
|
let requested = requested_tab(
|
|
&mut profile_events,
|
|
&mut stats_events,
|
|
&mut achievements_events,
|
|
&keys,
|
|
);
|
|
let open = !screens.is_empty();
|
|
|
|
if open {
|
|
// Esc closes the topmost modal — the hub, when it's showing.
|
|
if keys.just_pressed(KeyCode::Escape) {
|
|
for entity in &screens {
|
|
commands.entity(entity).despawn();
|
|
}
|
|
return;
|
|
}
|
|
match requested {
|
|
Some(tab) if tab == active.0 => {
|
|
// Same-tab request toggles closed (P opens Profile,
|
|
// P again closes — parity with the old screens).
|
|
for entity in &screens {
|
|
commands.entity(entity).despawn();
|
|
}
|
|
}
|
|
Some(tab) => {
|
|
// Different tab: switch in place. The rebuild system
|
|
// (next in the chain) observes the change.
|
|
active.0 = tab;
|
|
}
|
|
None => {}
|
|
}
|
|
return;
|
|
}
|
|
|
|
let Some(tab) = requested else { return };
|
|
if !other_modal_scrims.is_empty() {
|
|
return; // Another modal is already visible (§14.2).
|
|
}
|
|
if active.0 != tab {
|
|
// Written before the spawn; the rebuild system also runs this
|
|
// frame but sees no live hub (the spawn below is deferred), so
|
|
// no double-spawn.
|
|
active.0 = tab;
|
|
}
|
|
spawn_you_hub(&mut commands, tab, &ctx);
|
|
}
|
|
|
|
/// Switches the active tab when a chip is pressed.
|
|
fn handle_tab_buttons(
|
|
interactions: Query<(&Interaction, &YouHubTabButton), Changed<Interaction>>,
|
|
mut active: ResMut<ActiveYouTab>,
|
|
) {
|
|
for (interaction, chip) in &interactions {
|
|
if *interaction != Interaction::Pressed {
|
|
continue;
|
|
}
|
|
if active.0 != chip.0 {
|
|
active.0 = chip.0;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Rebuilds the open hub when [`ActiveYouTab`] changes.
|
|
fn rebuild_on_tab_change(
|
|
active: Res<ActiveYouTab>,
|
|
screens: Query<Entity, With<YouHubScreen>>,
|
|
mut commands: Commands,
|
|
ctx: YouHubContext,
|
|
) {
|
|
if !active.is_changed() || active.is_added() {
|
|
return;
|
|
}
|
|
if screens.is_empty() {
|
|
return;
|
|
}
|
|
for entity in &screens {
|
|
commands.entity(entity).despawn();
|
|
}
|
|
spawn_you_hub(&mut commands, active.0, &ctx);
|
|
}
|
|
|
|
/// Despawns the hub when Done is pressed.
|
|
fn handle_close_button(
|
|
interactions: Query<&Interaction, (Changed<Interaction>, With<YouHubCloseButton>)>,
|
|
screens: Query<Entity, With<YouHubScreen>>,
|
|
mut commands: Commands,
|
|
) {
|
|
for interaction in &interactions {
|
|
if *interaction != Interaction::Pressed {
|
|
continue;
|
|
}
|
|
for entity in &screens {
|
|
commands.entity(entity).despawn();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Spawns the hub modal showing `tab`, and stamps the scrim with the
|
|
/// tab's legacy screen marker so pre-hub queries keep working.
|
|
fn spawn_you_hub(commands: &mut Commands, tab: YouTab, ctx: &YouHubContext) {
|
|
let font_res = ctx.font_res.as_deref();
|
|
let scrim = spawn_modal(commands, YouHubScreen, Z_MODAL_PANEL, |card| {
|
|
spawn_modal_header(card, "You", font_res);
|
|
|
|
// Tab chips — shared widget with the Settings panel.
|
|
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 chip_tab in YouTab::ALL {
|
|
spawn_tab_chip(
|
|
row,
|
|
YouHubTabButton(chip_tab),
|
|
chip_tab.label(),
|
|
chip_tab == tab,
|
|
font_res,
|
|
);
|
|
}
|
|
});
|
|
|
|
match tab {
|
|
YouTab::Profile => crate::profile_plugin::spawn_profile_body(
|
|
card,
|
|
ctx.settings.as_deref(),
|
|
ctx.sync_status.as_deref(),
|
|
ctx.progress.as_deref(),
|
|
ctx.achievements.as_deref(),
|
|
ctx.stats.as_deref(),
|
|
font_res,
|
|
ctx.avatar.as_deref(),
|
|
),
|
|
YouTab::Stats => {
|
|
let default_stats = StatsSnapshot::default();
|
|
crate::stats_plugin::spawn_stats_body(
|
|
card,
|
|
ctx.stats.as_deref().map_or(&default_stats, |s| &s.0),
|
|
ctx.progress.as_deref().map(|p| &p.0),
|
|
ctx.time_attack.as_deref(),
|
|
font_res,
|
|
);
|
|
}
|
|
YouTab::Achievements => crate::achievement_plugin::spawn_achievements_body(
|
|
card,
|
|
ctx.achievements
|
|
.as_deref()
|
|
.map_or(&[][..], |a| a.0.as_slice()),
|
|
font_res,
|
|
),
|
|
YouTab::Replays => crate::stats_plugin::spawn_replays_body(
|
|
card,
|
|
ctx.replay_history
|
|
.as_deref()
|
|
.map_or(&[][..], |h| h.0.replays.as_slice()),
|
|
ctx.selected_replay.as_deref().map_or(0, |s| s.0),
|
|
font_res,
|
|
),
|
|
}
|
|
|
|
spawn_modal_actions(card, |actions| {
|
|
spawn_modal_button(
|
|
actions,
|
|
YouHubCloseButton,
|
|
"Done",
|
|
None,
|
|
ButtonVariant::Primary,
|
|
font_res,
|
|
);
|
|
});
|
|
});
|
|
let mut scrim_commands = commands.entity(scrim);
|
|
scrim_commands.insert(ScrimDismissible);
|
|
// Legacy markers: pre-hub code and tests query these.
|
|
match tab {
|
|
YouTab::Profile => {
|
|
scrim_commands.insert(ProfileScreen);
|
|
}
|
|
YouTab::Stats | YouTab::Replays => {
|
|
scrim_commands.insert(StatsScreen);
|
|
}
|
|
YouTab::Achievements => {
|
|
scrim_commands.insert(AchievementsScreen);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn headless_app() -> App {
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins).add_plugins(YouHubPlugin);
|
|
app.init_resource::<ButtonInput<KeyCode>>();
|
|
app.update();
|
|
app
|
|
}
|
|
|
|
fn hub_count(app: &mut App) -> usize {
|
|
app.world_mut()
|
|
.query::<&YouHubScreen>()
|
|
.iter(app.world())
|
|
.count()
|
|
}
|
|
|
|
/// Each Toggle*RequestEvent opens the hub pre-selected to its tab,
|
|
/// stamped with the tab's legacy screen marker.
|
|
#[test]
|
|
fn toggle_events_open_hub_on_their_tab() {
|
|
use bevy::ecs::message::Messages;
|
|
|
|
let mut app = headless_app();
|
|
app.world_mut()
|
|
.resource_mut::<Messages<ToggleStatsRequestEvent>>()
|
|
.write(ToggleStatsRequestEvent);
|
|
app.update();
|
|
app.update();
|
|
|
|
assert_eq!(hub_count(&mut app), 1, "stats request must open the hub");
|
|
assert_eq!(app.world().resource::<ActiveYouTab>().0, YouTab::Stats);
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&StatsScreen>()
|
|
.iter(app.world())
|
|
.count(),
|
|
1,
|
|
"legacy StatsScreen marker must ride the hub scrim"
|
|
);
|
|
|
|
// A different tab's request switches in place — still one hub.
|
|
app.world_mut()
|
|
.resource_mut::<Messages<ToggleAchievementsRequestEvent>>()
|
|
.write(ToggleAchievementsRequestEvent);
|
|
app.update();
|
|
app.update();
|
|
assert_eq!(hub_count(&mut app), 1, "tab switch must not stack hubs");
|
|
assert_eq!(
|
|
app.world().resource::<ActiveYouTab>().0,
|
|
YouTab::Achievements
|
|
);
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&AchievementsScreen>()
|
|
.iter(app.world())
|
|
.count(),
|
|
1
|
|
);
|
|
|
|
// Same-tab request toggles the hub closed.
|
|
app.world_mut()
|
|
.resource_mut::<Messages<ToggleAchievementsRequestEvent>>()
|
|
.write(ToggleAchievementsRequestEvent);
|
|
app.update();
|
|
app.update();
|
|
assert_eq!(hub_count(&mut app), 0, "same-tab request must close");
|
|
}
|
|
|
|
/// Chip-driven tab switches rebuild the single hub with the new
|
|
/// tab's body (Profile scrollable swaps for the Stats one).
|
|
#[test]
|
|
fn tab_switch_swaps_bodies_without_stacking() {
|
|
use crate::profile_plugin::ProfileScrollable;
|
|
use crate::stats_plugin::StatsScrollable;
|
|
use bevy::ecs::message::Messages;
|
|
|
|
let mut app = headless_app();
|
|
app.world_mut()
|
|
.resource_mut::<Messages<ToggleProfileRequestEvent>>()
|
|
.write(ToggleProfileRequestEvent);
|
|
app.update();
|
|
app.update();
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&ProfileScrollable>()
|
|
.iter(app.world())
|
|
.count(),
|
|
1,
|
|
"profile body must spawn on the Profile tab"
|
|
);
|
|
|
|
app.world_mut().resource_mut::<ActiveYouTab>().0 = YouTab::Stats;
|
|
app.update();
|
|
app.update();
|
|
|
|
assert_eq!(hub_count(&mut app), 1, "rebuild must not stack scrims");
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&ProfileScrollable>()
|
|
.iter(app.world())
|
|
.count(),
|
|
0,
|
|
"profile body must despawn when leaving the tab"
|
|
);
|
|
assert_eq!(
|
|
app.world_mut()
|
|
.query::<&StatsScrollable>()
|
|
.iter(app.world())
|
|
.count(),
|
|
1,
|
|
"stats body must spawn on the Stats tab"
|
|
);
|
|
}
|
|
}
|