Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5430d4c3ab | |||
| fc4ea17789 | |||
| 02bcc8b4af | |||
| d4d0bde0c0 | |||
| cbbab3d09f |
@@ -6,6 +6,25 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.47.0] — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **"What's new" on update.** The first launch after an update shows a
|
||||||
|
one-time card summarising what changed — like this one, right now.
|
||||||
|
Dismiss it and it never repeats; fresh installs never see it. (#180)
|
||||||
|
- **Situational tips.** Two one-time teaches that fire from play, not a
|
||||||
|
tour: staring at a stuck board for a while points you at Hint, and an
|
||||||
|
engaged game quietly mentions the long-press quick-action menu —
|
||||||
|
unless you've already found it yourself. (#181)
|
||||||
|
- **UI scale.** Settings → Accessibility now has a UI Scale control
|
||||||
|
(90% / 100% / 115% / 130%) that resizes every menu, button, and HUD
|
||||||
|
element live. The table always fits your screen regardless. (#182)
|
||||||
|
- **Hold `/` for shortcuts** (desktop). A reference card of every
|
||||||
|
keyboard binding appears while `/` is held. The onboarding tour and
|
||||||
|
this card now share one binding table, so they can never disagree —
|
||||||
|
the tour's copy had drifted from reality. (#183)
|
||||||
|
|
||||||
## [0.46.0] — 2026-07-13
|
## [0.46.0] — 2026-07-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ fn handle_keyboard_core(
|
|||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
progress: Option<Res<ProgressResource>>,
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
drag: Res<DragState>,
|
||||||
mut ev: CoreKeyboardMessages<'_>,
|
mut ev: CoreKeyboardMessages<'_>,
|
||||||
mut time_attack: Option<ResMut<TimeAttackResource>>,
|
mut time_attack: Option<ResMut<TimeAttackResource>>,
|
||||||
selection: Option<Res<SelectionState>>,
|
selection: Option<Res<SelectionState>>,
|
||||||
@@ -227,6 +228,19 @@ fn handle_keyboard_core(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mutual exclusion with pointer drags — mirrors `handle_selection_keys`.
|
||||||
|
// Undo / draw / new-game during an active mouse or touch drag would fire
|
||||||
|
// a StateChangedEvent whose card re-sync inserts a `CardAnim` on the
|
||||||
|
// dragged cards, fighting `follow_drag`'s per-frame Transform writes and
|
||||||
|
// leaving `DragState` origin indices stale against the mutated state.
|
||||||
|
// The keyboard-drag sentinel may proceed: `clear_selection_on_state_change`
|
||||||
|
// already drops that lift cleanly when the state moves.
|
||||||
|
if !drag.is_idle()
|
||||||
|
&& drag.active_touch_id != Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// During replay playback (Playing or Completed) all game-input shortcuts
|
// During replay playback (Playing or Completed) all game-input shortcuts
|
||||||
// are suppressed. The replay overlay owns Space (pause/resume) and the
|
// are suppressed. The replay overlay owns Space (pause/resume) and the
|
||||||
// arrow keys (step). Letting game input through would mutate
|
// arrow keys (step). Letting game input through would mutate
|
||||||
@@ -473,11 +487,19 @@ pub fn emit_hint_visuals(
|
|||||||
fn handle_keyboard_forfeit(
|
fn handle_keyboard_forfeit(
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
|
drag: Res<DragState>,
|
||||||
mut requests: MessageWriter<ForfeitRequestEvent>,
|
mut requests: MessageWriter<ForfeitRequestEvent>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Same pointer-drag exclusion as `handle_keyboard_core` — forfeiting
|
||||||
|
// mid-drag would mutate the game state under the dragged cards.
|
||||||
|
if !drag.is_idle()
|
||||||
|
&& drag.active_touch_id != Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
if !keys.just_pressed(KeyCode::KeyG) {
|
if !keys.just_pressed(KeyCode::KeyG) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -817,3 +817,123 @@ fn pressing_h_spawns_pending_hint_task() {
|
|||||||
"pressing H must spawn an async hint task",
|
"pressing H must spawn an async hint task",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Pointer-drag / keyboard mutual exclusion.
|
||||||
|
//
|
||||||
|
// A state mutation fired while `follow_drag` is writing the dragged cards'
|
||||||
|
// transforms every frame would make the card re-sync insert a `CardAnim`
|
||||||
|
// on the same entities (two systems fighting over `Transform`) and leave
|
||||||
|
// `DragState`'s origin indices stale. `handle_keyboard_core` and
|
||||||
|
// `handle_keyboard_forfeit` therefore swallow game-mutating shortcuts
|
||||||
|
// while a mouse or touch drag is live, mirroring `handle_selection_keys`.
|
||||||
|
// The keyboard-lift sentinel is exempt: `clear_selection_on_state_change`
|
||||||
|
// drops that lift cleanly when the state moves.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn keyboard_core_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
app.add_message::<UndoRequestEvent>();
|
||||||
|
app.add_message::<NewGameRequestEvent>();
|
||||||
|
app.add_message::<InfoToastEvent>();
|
||||||
|
app.add_message::<DrawRequestEvent>();
|
||||||
|
app.add_message::<StartZenRequestEvent>();
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.init_resource::<DragState>();
|
||||||
|
app.add_systems(Update, handle_keyboard_core);
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press_key(app: &mut App, key: KeyCode) {
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(key);
|
||||||
|
input.clear();
|
||||||
|
input.press(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message_count<M: Message>(app: &App) -> usize {
|
||||||
|
let messages = app.world().resource::<Messages<M>>();
|
||||||
|
let mut cursor = messages.get_cursor();
|
||||||
|
cursor.read(messages).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_drag(app: &mut App, active_touch_id: Option<u64>) {
|
||||||
|
let mut drag = app.world_mut().resource_mut::<DragState>();
|
||||||
|
drag.cards = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two)];
|
||||||
|
drag.committed = true;
|
||||||
|
drag.active_touch_id = active_touch_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u_key_fires_undo_when_no_drag_active() {
|
||||||
|
let mut app = keyboard_core_app();
|
||||||
|
press_key(&mut app, KeyCode::KeyU);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<UndoRequestEvent>(&app),
|
||||||
|
1,
|
||||||
|
"U with an idle DragState must fire UndoRequestEvent",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u_key_ignored_during_committed_mouse_drag() {
|
||||||
|
let mut app = keyboard_core_app();
|
||||||
|
set_drag(&mut app, None); // mouse drag
|
||||||
|
press_key(&mut app, KeyCode::KeyU);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<UndoRequestEvent>(&app),
|
||||||
|
0,
|
||||||
|
"U during a mouse drag must be swallowed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_key_ignored_during_touch_drag() {
|
||||||
|
let mut app = keyboard_core_app();
|
||||||
|
set_drag(&mut app, Some(7)); // real touch id
|
||||||
|
press_key(&mut app, KeyCode::KeyD);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<DrawRequestEvent>(&app),
|
||||||
|
0,
|
||||||
|
"D during a touch drag must be swallowed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u_key_allowed_during_keyboard_lift() {
|
||||||
|
let mut app = keyboard_core_app();
|
||||||
|
set_drag(
|
||||||
|
&mut app,
|
||||||
|
Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID),
|
||||||
|
);
|
||||||
|
press_key(&mut app, KeyCode::KeyU);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<UndoRequestEvent>(&app),
|
||||||
|
1,
|
||||||
|
"the keyboard-lift sentinel must not block core shortcuts",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn g_key_forfeit_ignored_during_mouse_drag() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
app.add_message::<ForfeitRequestEvent>();
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.init_resource::<DragState>();
|
||||||
|
app.add_systems(Update, handle_keyboard_forfeit);
|
||||||
|
|
||||||
|
set_drag(&mut app, None);
|
||||||
|
press_key(&mut app, KeyCode::KeyG);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<ForfeitRequestEvent>(&app),
|
||||||
|
0,
|
||||||
|
"G during a mouse drag must be swallowed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ use crate::font_plugin::FontResource;
|
|||||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
use crate::sync_plugin::SyncProviderResource;
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal,
|
||||||
spawn_modal_button, spawn_modal_header,
|
spawn_modal_actions, spawn_modal_button, spawn_modal_header,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY,
|
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, TEXT_DISABLED, TEXT_PRIMARY,
|
||||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||||
Z_MODAL_PANEL, Z_PAUSE_DIALOG,
|
Z_PAUSE_DIALOG,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -536,11 +536,6 @@ fn spawn_leaderboard_screen(
|
|||||||
font_size: TYPE_CAPTION,
|
font_size: TYPE_CAPTION,
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
let font_status = TextFont {
|
|
||||||
font: font_handle.clone(),
|
|
||||||
font_size: TYPE_BODY_LG,
|
|
||||||
..default()
|
|
||||||
};
|
|
||||||
let font_row = TextFont {
|
let font_row = TextFont {
|
||||||
font: font_handle.clone(),
|
font: font_handle.clone(),
|
||||||
font_size: TYPE_BODY,
|
font_size: TYPE_BODY,
|
||||||
@@ -647,30 +642,31 @@ fn spawn_leaderboard_screen(
|
|||||||
.with_children(|body| {
|
.with_children(|body| {
|
||||||
match data {
|
match data {
|
||||||
LeaderboardResource::Idle => {
|
LeaderboardResource::Idle => {
|
||||||
body.spawn((
|
spawn_empty_state(
|
||||||
Text::new("Fetching\u{2026}"),
|
body,
|
||||||
font_status.clone(),
|
"\u{21BB}",
|
||||||
TextColor(STATE_INFO),
|
"Fetching the leaderboard\u{2026}",
|
||||||
));
|
None,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
LeaderboardResource::Error(_) => {
|
LeaderboardResource::Error(_) => {
|
||||||
body.spawn((
|
spawn_empty_state(
|
||||||
Text::new("Couldn't reach the leaderboard. Try again later."),
|
body,
|
||||||
font_status.clone(),
|
"!",
|
||||||
TextColor(TEXT_SECONDARY),
|
"Couldn't reach the leaderboard.",
|
||||||
));
|
Some("Try again later."),
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
|
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
|
||||||
body.spawn((
|
spawn_empty_state(
|
||||||
Text::new("Be the first on the leaderboard."),
|
body,
|
||||||
font_status.clone(),
|
"\u{2660}",
|
||||||
TextColor(TEXT_PRIMARY),
|
"Be the first on the leaderboard.",
|
||||||
));
|
Some("Win a game and opt in to appear here."),
|
||||||
body.spawn((
|
font_res,
|
||||||
Text::new("Win a game and opt in to appear here."),
|
);
|
||||||
font_row.clone(),
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
LeaderboardResource::Loaded(rows) => {
|
LeaderboardResource::Loaded(rows) => {
|
||||||
// Column headers
|
// Column headers
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ use crate::ui_modal::{ButtonVariant, ModalButton, spawn_modal_button};
|
|||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
|
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
|
||||||
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
||||||
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Bevy resource wrapping the current stats.
|
/// Bevy resource wrapping the current stats.
|
||||||
@@ -719,25 +719,17 @@ pub(crate) fn spawn_stats_body(
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
.with_children(|body| {
|
.with_children(|body| {
|
||||||
// First-launch caption — sits above the grid as gentle nudge so
|
// First-launch empty state (Phase L) — sits above the grid so
|
||||||
// the wall of em-dashes reads as "nothing to track yet" rather
|
// the wall of em-dashes reads as "nothing to track yet" rather
|
||||||
// than as broken state.
|
// than as broken state.
|
||||||
if is_first_launch {
|
if is_first_launch {
|
||||||
body.spawn((
|
crate::ui_modal::spawn_empty_state(
|
||||||
Text::new("Play a game to start tracking stats."),
|
body,
|
||||||
TextFont {
|
"\u{2663}",
|
||||||
font_size: TYPE_CAPTION,
|
"Play a game to start tracking stats.",
|
||||||
..default()
|
None,
|
||||||
},
|
font_res,
|
||||||
TextColor(TEXT_SECONDARY),
|
);
|
||||||
Node {
|
|
||||||
margin: UiRect {
|
|
||||||
bottom: VAL_SPACE_2,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- primary stat cells grid ---
|
// --- primary stat cells grid ---
|
||||||
@@ -893,6 +885,20 @@ pub(crate) fn spawn_replays_body(
|
|||||||
selected_index: usize,
|
selected_index: usize,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
|
// Standard empty state (Phase L) — the selector / Watch / Copy
|
||||||
|
// controls only spawn when there is something to select, so their
|
||||||
|
// handlers all no-op via their existing empty-query paths.
|
||||||
|
if replays.is_empty() {
|
||||||
|
crate::ui_modal::spawn_empty_state(
|
||||||
|
card,
|
||||||
|
"\u{2666}",
|
||||||
|
"No replays yet.",
|
||||||
|
Some("Every win records a replay here automatically."),
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
let font_row = TextFont {
|
let font_row = TextFont {
|
||||||
font: font_handle,
|
font: font_handle,
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ use crate::resources::TokioRuntimeResource;
|
|||||||
use crate::settings_plugin::{SettingsPanel, SettingsResource};
|
use crate::settings_plugin::{SettingsPanel, SettingsResource};
|
||||||
use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry};
|
use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry};
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal,
|
||||||
spawn_modal_button, spawn_modal_header,
|
spawn_modal_actions, spawn_modal_button, spawn_modal_header,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
|
BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
@@ -528,25 +528,31 @@ fn spawn_store_modal(
|
|||||||
|
|
||||||
match catalog_state {
|
match catalog_state {
|
||||||
CatalogState::Idle | CatalogState::Loading => {
|
CatalogState::Idle | CatalogState::Loading => {
|
||||||
card.spawn((
|
spawn_empty_state(
|
||||||
Text::new("Loading catalog…"),
|
card,
|
||||||
body_font.clone(),
|
"\u{21BB}",
|
||||||
TextColor(TEXT_SECONDARY),
|
"Loading the catalog\u{2026}",
|
||||||
));
|
None,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
CatalogState::Error(msg) => {
|
CatalogState::Error(msg) => {
|
||||||
card.spawn((
|
spawn_empty_state(
|
||||||
Text::new(format!("Could not load the catalog: {msg}")),
|
card,
|
||||||
body_font.clone(),
|
"!",
|
||||||
TextColor(TEXT_SECONDARY),
|
"Couldn't load the catalog.",
|
||||||
));
|
Some(msg.as_str()),
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
CatalogState::Loaded(entries) if entries.is_empty() => {
|
CatalogState::Loaded(entries) if entries.is_empty() => {
|
||||||
card.spawn((
|
spawn_empty_state(
|
||||||
Text::new("The server has no themes yet."),
|
card,
|
||||||
body_font.clone(),
|
"#",
|
||||||
TextColor(TEXT_SECONDARY),
|
"The server has no themes yet.",
|
||||||
));
|
Some("Themes dropped into the server's theme_store folder appear here."),
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
CatalogState::Loaded(entries) => {
|
CatalogState::Loaded(entries) => {
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ use crate::settings_plugin::SettingsResource;
|
|||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI,
|
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI,
|
||||||
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
|
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
|
||||||
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_PRIMARY,
|
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_DISABLED,
|
||||||
TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
|
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_DISPLAY,
|
||||||
VAL_SPACE_4, VAL_SPACE_5, scaled_duration,
|
TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5, scaled_duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -328,6 +328,80 @@ pub fn spawn_modal_body_text(
|
|||||||
parent.spawn((ModalBody, Text::new(text.into()), font, TextColor(color)));
|
parent.spawn((ModalBody, Text::new(text.into()), font, TextColor(color)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Standard empty / loading / error surface (Phase L): a large glyph,
|
||||||
|
/// a headline, and an optional secondary detail line, centred in the
|
||||||
|
/// available space. Every async or possibly-empty surface (leaderboard,
|
||||||
|
/// replays, theme store, first-launch stats) renders through this so
|
||||||
|
/// "nothing here yet" reads identically everywhere.
|
||||||
|
///
|
||||||
|
/// A next-step action is composition, not configuration: spawn a
|
||||||
|
/// [`spawn_modal_button`] (or any control) in the same parent right
|
||||||
|
/// after this call.
|
||||||
|
///
|
||||||
|
/// `glyph` MUST come from FiraMono-covered ranges — card suits
|
||||||
|
/// (U+2660–2666), Arrows (U+2190–21FF), or ASCII. The Geometric Shapes
|
||||||
|
/// block renders as tofu on Android (§10 / §11).
|
||||||
|
pub fn spawn_empty_state(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
glyph: &str,
|
||||||
|
headline: &str,
|
||||||
|
detail: Option<&str>,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
let font_glyph = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_DISPLAY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_headline = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_detail = TextFont {
|
||||||
|
font: font_handle,
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
parent
|
||||||
|
.spawn((
|
||||||
|
EmptyStateBlock,
|
||||||
|
Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_5),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.with_children(|block| {
|
||||||
|
block.spawn((
|
||||||
|
Text::new(glyph.to_string()),
|
||||||
|
font_glyph,
|
||||||
|
TextColor(TEXT_DISABLED),
|
||||||
|
));
|
||||||
|
block.spawn((
|
||||||
|
Text::new(headline.to_string()),
|
||||||
|
font_headline,
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
if let Some(detail) = detail {
|
||||||
|
block.spawn((
|
||||||
|
Text::new(detail.to_string()),
|
||||||
|
font_detail,
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker on every [`spawn_empty_state`] block, mostly for tests.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct EmptyStateBlock;
|
||||||
|
|
||||||
/// Spawns the bottom actions row — flex-row with primary right-aligned.
|
/// Spawns the bottom actions row — flex-row with primary right-aligned.
|
||||||
/// The closure populates the row's buttons via `spawn_modal_button`.
|
/// The closure populates the row's buttons via `spawn_modal_button`.
|
||||||
///
|
///
|
||||||
@@ -1184,4 +1258,62 @@ mod tests {
|
|||||||
"exactly one of the two stacked dismissible modals should remain"
|
"exactly one of the two stacked dismissible modals should remain"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase L: standard empty state
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The helper spawns glyph + headline (+ detail when given) under one
|
||||||
|
/// marked block, so every empty surface renders identically.
|
||||||
|
#[test]
|
||||||
|
fn empty_state_spawns_expected_structure() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
{
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut commands = world.commands();
|
||||||
|
commands.spawn(Node::default()).with_children(|parent| {
|
||||||
|
spawn_empty_state(
|
||||||
|
parent,
|
||||||
|
"\u{2660}",
|
||||||
|
"Nothing here.",
|
||||||
|
Some("Do the thing."),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
spawn_empty_state(parent, "!", "No detail variant.", None, None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let blocks = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<Entity, With<EmptyStateBlock>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(blocks, 2, "each call spawns exactly one marked block");
|
||||||
|
|
||||||
|
let texts: Vec<String> = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&Text>()
|
||||||
|
.iter(app.world())
|
||||||
|
.map(|t| t.0.clone())
|
||||||
|
.collect();
|
||||||
|
for expected in [
|
||||||
|
"\u{2660}",
|
||||||
|
"Nothing here.",
|
||||||
|
"Do the thing.",
|
||||||
|
"!",
|
||||||
|
"No detail variant.",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
texts.iter().any(|t| t == expected),
|
||||||
|
"missing text node {expected:?}; got {texts:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
texts.len(),
|
||||||
|
5,
|
||||||
|
"the detail-less variant must not spawn a detail node"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user