Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8fc3e7a09 | |||
| 7c2daa1e32 | |||
| 37919cfe17 | |||
| 9081a9916c | |||
| 5430d4c3ab | |||
| fc4ea17789 | |||
| 02bcc8b4af | |||
| d4d0bde0c0 | |||
| cbbab3d09f | |||
| 36605751cd | |||
| 4d9a07727a | |||
| 513dee8fa9 | |||
| 19ddf86c7b | |||
| 28be65f092 |
@@ -6,6 +6,25 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -275,6 +275,14 @@ pub struct Settings {
|
||||
/// which marks the tip as unnecessary without showing it.
|
||||
#[serde(default)]
|
||||
pub shown_radial_menu_tip: bool,
|
||||
/// Global UI scale multiplier applied to all UI chrome (HUD, modals,
|
||||
/// action bar) — the table itself stays window-fit via
|
||||
/// `compute_layout`. Cycles through 0.9 / 1.0 / 1.15 / 1.3 in
|
||||
/// Settings → Accessibility; clamped to `[UI_SCALE_MIN,
|
||||
/// UI_SCALE_MAX]` by [`Settings::sanitized`]. Older `settings.json`
|
||||
/// files deserialize cleanly to `1.0` via the serde default.
|
||||
#[serde(default = "default_ui_scale")]
|
||||
pub ui_scale: f32,
|
||||
/// Custom public name displayed on the leaderboard. When `None`, the
|
||||
/// player's server `username` is used instead. Trimmed to 32 characters
|
||||
/// before submission. Older `settings.json` files written before this
|
||||
@@ -383,6 +391,16 @@ fn default_replay_move_interval_secs() -> f32 {
|
||||
0.45
|
||||
}
|
||||
|
||||
/// Lowest / highest UI scale the settings row offers; anything outside
|
||||
/// (hand-edited settings.json) clamps here on load.
|
||||
pub const UI_SCALE_MIN: f32 = 0.9;
|
||||
/// See [`UI_SCALE_MIN`].
|
||||
pub const UI_SCALE_MAX: f32 = 1.3;
|
||||
|
||||
fn default_ui_scale() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
fn default_matomo_site_id() -> u32 {
|
||||
1
|
||||
}
|
||||
@@ -446,6 +464,7 @@ impl Default for Settings {
|
||||
last_seen_whats_new: String::new(),
|
||||
shown_stall_hint_tip: false,
|
||||
shown_radial_menu_tip: false,
|
||||
ui_scale: default_ui_scale(),
|
||||
leaderboard_display_name: None,
|
||||
leaderboard_opted_in: false,
|
||||
take_from_foundation: true,
|
||||
@@ -481,6 +500,7 @@ impl Settings {
|
||||
replay_move_interval_secs: self
|
||||
.replay_move_interval_secs
|
||||
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
|
||||
ui_scale: self.ui_scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX),
|
||||
selected_theme_id,
|
||||
..self
|
||||
}
|
||||
|
||||
@@ -179,10 +179,13 @@ pub struct ToastStackRoot;
|
||||
pub struct ToastNode;
|
||||
|
||||
/// Logical-pixel gap between the screen bottom and the toast stack,
|
||||
/// before safe-area insets. Clears the Phase F bottom action bar on
|
||||
/// touch (compact 44px buttons + primary 64px trio + bar padding);
|
||||
/// desktop's shorter bar needs less.
|
||||
const TOAST_STACK_BASE_BOTTOM_PX: f32 = if USE_TOUCH_UI_LAYOUT { 112.0 } else { 72.0 };
|
||||
/// before safe-area insets. Clears the floating glass tab bar on touch
|
||||
/// (its margin + height + breathing room); desktop's docked bar needs less.
|
||||
const TOAST_STACK_BASE_BOTTOM_PX: f32 = if USE_TOUCH_UI_LAYOUT {
|
||||
crate::hud_plugin::tab_bar::TAB_BAR_CLEARANCE_PX
|
||||
} else {
|
||||
72.0
|
||||
};
|
||||
|
||||
/// Spawns the persistent [`ToastStackRoot`] container at startup.
|
||||
fn spawn_toast_stack_root(mut commands: Commands) {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! SVG builders for the floating tab bar's action icons.
|
||||
//!
|
||||
//! The touch tab bar (`hud_plugin::tab_bar`) is icon-first, but the bundled
|
||||
//! FiraMono face has no icon glyphs (and the Geometric Shapes block U+25xx
|
||||
//! is missing on Android entirely), so the icons are authored here as tiny
|
||||
//! stroke-based SVGs and rasterised through the same `usvg`/`resvg` pipeline
|
||||
//! as the card themes (`assets::rasterize_svg`).
|
||||
//!
|
||||
//! Icons are drawn in **white** on a transparent background inside a
|
||||
//! 24-unit logical box; the spawn site tints them to the theme colour via
|
||||
//! `ImageNode::color` (a per-node multiply), so one texture serves every
|
||||
//! button state.
|
||||
|
||||
use bevy::image::Image;
|
||||
use bevy::math::UVec2;
|
||||
|
||||
use super::svg_loader::{SvgLoaderError, rasterize_svg};
|
||||
|
||||
/// Rasterisation size for one tab-bar icon texture. Icons display at
|
||||
/// 24 logical px; 96 px texels keep them crisp at the Fold's 1.2 scale
|
||||
/// factor (and any future 3× phone) while staying trivially small.
|
||||
pub const HUD_ICON_TEXTURE_PX: u32 = 96;
|
||||
|
||||
/// The five actions the floating touch tab bar can show.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HudIcon {
|
||||
/// Counter-clockwise "take back" arrow.
|
||||
Undo,
|
||||
/// Two offset card outlines — draw from the stock.
|
||||
Draw,
|
||||
/// Lightbulb — highlight a suggested move.
|
||||
Hint,
|
||||
/// Two vertical bars.
|
||||
Pause,
|
||||
/// Three-line hamburger — the floating menu button.
|
||||
Menu,
|
||||
}
|
||||
|
||||
impl HudIcon {
|
||||
/// Build the icon's SVG document. All icons share one visual language:
|
||||
/// 2-unit white strokes, round caps, transparent fill, 24-unit box.
|
||||
pub fn svg(self) -> String {
|
||||
let body = match self {
|
||||
// Arrow head pointing left into a half-circle sweeping back
|
||||
// over the top — the conventional undo mark.
|
||||
HudIcon::Undo => concat!(
|
||||
r##"<path d="M 8 5 L 4 9 L 8 13"/>"##,
|
||||
r##"<path d="M 4 9 H 14 A 5.5 5.5 0 0 1 14 20 H 8"/>"##,
|
||||
),
|
||||
// Back card peeks out behind the front card; the front card is
|
||||
// drawn second so its stroke reads on top.
|
||||
HudIcon::Draw => concat!(
|
||||
r##"<rect x="8.5" y="3" width="11" height="15" rx="2"/>"##,
|
||||
r##"<rect x="4" y="6.5" width="11" height="15" rx="2"/>"##,
|
||||
),
|
||||
// Bulb dome with a flat lamp base beneath.
|
||||
HudIcon::Hint => concat!(
|
||||
r##"<path d="M 9 15.5 A 6.5 6.5 0 1 1 15 15.5 V 18 H 9 Z"/>"##,
|
||||
r##"<path d="M 10 21 H 14"/>"##,
|
||||
),
|
||||
HudIcon::Pause => concat!(
|
||||
r##"<path d="M 9 6 V 18"/>"##,
|
||||
r##"<path d="M 15 6 V 18"/>"##,
|
||||
),
|
||||
HudIcon::Menu => concat!(
|
||||
r##"<path d="M 4 6.5 H 20"/>"##,
|
||||
r##"<path d="M 4 12 H 20"/>"##,
|
||||
r##"<path d="M 4 17.5 H 20"/>"##,
|
||||
),
|
||||
};
|
||||
format!(
|
||||
concat!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">"##,
|
||||
r##"<g fill="none" stroke="#ffffff" stroke-width="2" "##,
|
||||
r##"stroke-linecap="round" stroke-linejoin="round">{body}</g>"##,
|
||||
r##"</svg>"##,
|
||||
),
|
||||
body = body
|
||||
)
|
||||
}
|
||||
|
||||
/// Rasterise this icon into a Bevy [`Image`] at
|
||||
/// [`HUD_ICON_TEXTURE_PX`] square.
|
||||
pub fn rasterize(self) -> Result<Image, SvgLoaderError> {
|
||||
rasterize_svg(self.svg().as_bytes(), UVec2::splat(HUD_ICON_TEXTURE_PX))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ALL: [HudIcon; 5] = [
|
||||
HudIcon::Undo,
|
||||
HudIcon::Draw,
|
||||
HudIcon::Hint,
|
||||
HudIcon::Pause,
|
||||
HudIcon::Menu,
|
||||
];
|
||||
|
||||
/// Every icon must survive the real usvg/resvg pipeline at the real
|
||||
/// texture size — a malformed path here would otherwise only surface
|
||||
/// as a blank button on device.
|
||||
#[test]
|
||||
fn every_icon_rasterizes_at_texture_size() {
|
||||
for icon in ALL {
|
||||
let img = icon.rasterize().expect("icon SVG must rasterise");
|
||||
assert_eq!(img.width(), HUD_ICON_TEXTURE_PX, "{icon:?} width");
|
||||
assert_eq!(img.height(), HUD_ICON_TEXTURE_PX, "{icon:?} height");
|
||||
}
|
||||
}
|
||||
|
||||
/// Rasterised icons must contain visible (non-transparent) pixels —
|
||||
/// guards against a stroke accidentally drawn outside the viewBox.
|
||||
#[test]
|
||||
fn every_icon_has_visible_pixels() {
|
||||
for icon in ALL {
|
||||
let img = icon.rasterize().expect("icon SVG must rasterise");
|
||||
let data = img.data.as_ref().expect("rasterised image has data");
|
||||
let opaque = data.chunks_exact(4).filter(|px| px[3] > 0).count();
|
||||
assert!(
|
||||
opaque > 100,
|
||||
"{icon:?} renders only {opaque} visible pixels"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
//! custom `AssetSource` implementations used by the theme system.
|
||||
|
||||
pub mod card_face_svg;
|
||||
pub mod hud_icon_svg;
|
||||
pub mod icon_svg;
|
||||
pub mod sources;
|
||||
pub mod svg_loader;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Hold-`/` hotkey cheat sheet (Phase J).
|
||||
//!
|
||||
//! While `/` is held on a keyboard platform, a lightweight overlay
|
||||
//! lists every binding from [`crate::hotkeys::HOTKEYS`] — the same
|
||||
//! table the onboarding slide teaches from, so the two can never
|
||||
//! disagree. Releasing the key hides it instantly; it never captures
|
||||
//! input, never pauses the game, and never spawns while a modal owns
|
||||
//! the screen (which also keeps it out of the seed-entry text field's
|
||||
//! way).
|
||||
//!
|
||||
//! Not a `spawn_modal` modal on purpose: modals are sticky and guarded;
|
||||
//! this is a momentary reference card, closer to a tooltip than a
|
||||
//! dialog.
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::hotkeys::HOTKEYS;
|
||||
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
|
||||
use crate::ui_modal::ModalScrim;
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, HighContrastBorder, RADIUS_MD, TEXT_PRIMARY,
|
||||
TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
||||
Z_TOOLTIP,
|
||||
};
|
||||
|
||||
/// Marker on the cheat-sheet overlay root.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct CheatSheetOverlay;
|
||||
|
||||
/// Registers the hold-`/` driver. Inert on touch-first builds.
|
||||
pub struct CheatSheetPlugin;
|
||||
|
||||
impl Plugin for CheatSheetPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<ButtonInput<KeyCode>>()
|
||||
.add_systems(Update, drive_cheat_sheet);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the overlay while `/` is held (and no modal owns the screen);
|
||||
/// hides it the frame the key releases.
|
||||
fn drive_cheat_sheet(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
scrims: Query<(), With<ModalScrim>>,
|
||||
existing: Query<Entity, With<CheatSheetOverlay>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if !SHOW_KEYBOARD_ACCELERATORS {
|
||||
return;
|
||||
}
|
||||
let held = keys.pressed(KeyCode::Slash);
|
||||
if held && existing.is_empty() && scrims.is_empty() {
|
||||
spawn_cheat_sheet(&mut commands, font_res.as_deref());
|
||||
} else if !held {
|
||||
for entity in &existing {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_cheat_sheet(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_header = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
};
|
||||
let font_keys = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
let font_desc = TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
CheatSheetOverlay,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
right: Val::Px(16.0),
|
||||
top: Val::Percent(8.0),
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
padding: UiRect::all(VAL_SPACE_4),
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
max_height: Val::Percent(84.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BG_ELEVATED),
|
||||
BorderColor::all(BORDER_STRONG),
|
||||
HighContrastBorder::with_default(BORDER_STRONG),
|
||||
GlobalZIndex(Z_TOOLTIP),
|
||||
))
|
||||
.with_children(|panel| {
|
||||
panel.spawn((
|
||||
Text::new("Keyboard shortcuts"),
|
||||
font_header.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
Node {
|
||||
margin: UiRect::bottom(VAL_SPACE_2),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
for row in HOTKEYS {
|
||||
panel
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: VAL_SPACE_3,
|
||||
..default()
|
||||
})
|
||||
.with_children(|line| {
|
||||
line.spawn((
|
||||
Text::new(row.keys),
|
||||
font_keys.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
Node {
|
||||
min_width: Val::Px(110.0),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
line.spawn((
|
||||
Text::new(row.description),
|
||||
font_desc.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(CheatSheetPlugin);
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn overlay_count(app: &mut App) -> usize {
|
||||
app.world_mut()
|
||||
.query::<&CheatSheetOverlay>()
|
||||
.iter(app.world())
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holding_slash_shows_and_release_hides() {
|
||||
let mut app = app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::Slash);
|
||||
app.update();
|
||||
assert_eq!(overlay_count(&mut app), 1, "held / must show the sheet");
|
||||
// Still held on later frames: exactly one overlay, no stacking.
|
||||
app.update();
|
||||
assert_eq!(overlay_count(&mut app), 1);
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.release(KeyCode::Slash);
|
||||
app.update();
|
||||
app.update();
|
||||
assert_eq!(overlay_count(&mut app), 0, "release must hide the sheet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppressed_while_a_modal_is_open() {
|
||||
let mut app = app();
|
||||
app.world_mut().spawn(ModalScrim);
|
||||
app.update();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::Slash);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
overlay_count(&mut app),
|
||||
0,
|
||||
"the sheet must not spawn over a modal (or into a text field)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,14 @@ use crate::platform::{
|
||||
};
|
||||
use crate::{
|
||||
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
|
||||
CardAnimationPlugin, CardPlugin, ChallengePlugin, ContextualTipsPlugin, CursorPlugin,
|
||||
DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin,
|
||||
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin,
|
||||
PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin,
|
||||
ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin,
|
||||
SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin, ThemePlugin,
|
||||
ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin,
|
||||
UiTooltipPlugin, WeeklyGoalsPlugin, WhatsNewPlugin, WinSummaryPlugin,
|
||||
CardAnimationPlugin, CardPlugin, ChallengePlugin, CheatSheetPlugin, ContextualTipsPlugin,
|
||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin,
|
||||
PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
|
||||
ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin,
|
||||
SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin,
|
||||
ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin,
|
||||
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WhatsNewPlugin, WinSummaryPlugin,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::{
|
||||
@@ -117,6 +117,7 @@ impl Plugin for CoreGamePlugin {
|
||||
.add_plugins(OnboardingPlugin)
|
||||
.add_plugins(WhatsNewPlugin)
|
||||
.add_plugins(ContextualTipsPlugin)
|
||||
.add_plugins(CheatSheetPlugin)
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Single source of truth for the desktop keyboard bindings (Phase J).
|
||||
//!
|
||||
//! Two static hotkey tables had already diverged (onboarding's slide
|
||||
//! and Help's controls reference); every future drift multiplies. This
|
||||
//! module owns THE table: the onboarding slide renders the
|
||||
//! [`HotkeyRow::essential`] subset, the hold-`/` cheat sheet
|
||||
//! ([`crate::cheat_sheet_plugin`]) renders everything.
|
||||
//!
|
||||
//! The table is hand-maintained but **pinned by test** against the
|
||||
//! handlers that actually consume each key — adding a binding without
|
||||
//! updating this table (or vice versa) is designed to fail review, not
|
||||
//! runtime. A registry generated from the input systems themselves is
|
||||
//! the eventual ideal; this is the honest 90 % at 1 % of the cost.
|
||||
|
||||
/// One row of the hotkey table.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct HotkeyRow {
|
||||
/// Display form of the key(s), e.g. `"D / Space"`.
|
||||
pub keys: &'static str,
|
||||
/// One-line action description.
|
||||
pub description: &'static str,
|
||||
/// `true` for the beginner-relevant subset the onboarding slide
|
||||
/// shows; the cheat sheet always shows every row.
|
||||
pub essential: bool,
|
||||
}
|
||||
|
||||
/// Every desktop keyboard binding, in teaching order.
|
||||
pub const HOTKEYS: &[HotkeyRow] = &[
|
||||
HotkeyRow {
|
||||
keys: "D / Space",
|
||||
description: "Draw from stock",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "U",
|
||||
description: "Undo last move (hold to repeat)",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "H",
|
||||
description: "Hint (repeat to cycle alternatives)",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "Tab → Enter",
|
||||
description: "Pick a card; arrows pick where; Enter to drop",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "N",
|
||||
description: "New Classic game",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "M",
|
||||
description: "Open Home (then 1–6 to pick a mode)",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "Esc",
|
||||
description: "Pause / resume; close the top dialog",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "F1",
|
||||
description: "Help / controls",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "S",
|
||||
description: "Stats & progression",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "A",
|
||||
description: "Achievements",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "O",
|
||||
description: "Settings",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "P",
|
||||
description: "Profile",
|
||||
essential: false,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "L",
|
||||
description: "Leaderboard",
|
||||
essential: false,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "C",
|
||||
description: "Daily Challenge",
|
||||
essential: false,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "Z",
|
||||
description: "Zen mode",
|
||||
essential: false,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "X",
|
||||
description: "Challenge mode",
|
||||
essential: false,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "T",
|
||||
description: "Time Attack",
|
||||
essential: false,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "G",
|
||||
description: "Give up the current deal",
|
||||
essential: false,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "[ / ]",
|
||||
description: "Volume down / up",
|
||||
essential: false,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "F11",
|
||||
description: "Toggle fullscreen",
|
||||
essential: false,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "/ (hold)",
|
||||
description: "This cheat sheet",
|
||||
essential: false,
|
||||
},
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_is_populated_and_well_formed() {
|
||||
assert!(!HOTKEYS.is_empty());
|
||||
for row in HOTKEYS {
|
||||
assert!(!row.keys.trim().is_empty(), "empty keys cell");
|
||||
assert!(
|
||||
!row.description.trim().is_empty(),
|
||||
"empty description for {}",
|
||||
row.keys
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn essential_subset_is_a_teachable_size() {
|
||||
let essentials = HOTKEYS.iter().filter(|r| r.essential).count();
|
||||
assert!(
|
||||
(6..=12).contains(&essentials),
|
||||
"the onboarding slide wants a skimmable essential set, got {essentials}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_cells_are_unique() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for row in HOTKEYS {
|
||||
assert!(seen.insert(row.keys), "duplicate hotkey row: {}", row.keys);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,12 +109,19 @@ pub(super) fn apply_action_fade(
|
||||
/// Visual feedback for every action button — paints idle / hover / pressed
|
||||
/// states by mutating `BackgroundColor` whenever the interaction state
|
||||
/// changes. One query covers all action buttons via the shared
|
||||
/// `ActionButton` marker.
|
||||
/// `ActionButton` marker. Glass tab-bar buttons are excluded: they carry
|
||||
/// their own per-button colour triple and are painted by
|
||||
/// `paint_glass_tab_buttons` instead (this system's opaque `ACTION_BTN_*`
|
||||
/// fills would destroy the translucent glass look).
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn paint_action_buttons(
|
||||
mut buttons: Query<
|
||||
(&Interaction, &mut BackgroundColor),
|
||||
(With<ActionButton>, Changed<Interaction>),
|
||||
(
|
||||
With<ActionButton>,
|
||||
Without<GlassTabButton>,
|
||||
Changed<Interaction>,
|
||||
),
|
||||
>,
|
||||
) {
|
||||
for (interaction, mut bg) in &mut buttons {
|
||||
|
||||
@@ -12,11 +12,13 @@ use bevy::window::WindowResized;
|
||||
mod fx;
|
||||
mod interaction;
|
||||
mod spawn;
|
||||
pub(crate) mod tab_bar;
|
||||
mod updates;
|
||||
|
||||
pub use fx::*;
|
||||
use interaction::*;
|
||||
use spawn::*;
|
||||
use tab_bar::*;
|
||||
use updates::*;
|
||||
|
||||
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
|
||||
@@ -595,6 +597,12 @@ impl Plugin for HudPlugin {
|
||||
handle_menu_backdrop_click,
|
||||
close_menu_popover_on_escape,
|
||||
paint_action_buttons,
|
||||
paint_glass_tab_buttons,
|
||||
drive_tab_expansion,
|
||||
// Writes tab-label TextColor and reads TextFont — entity-
|
||||
// disjoint from the UiTextFx readout writers (tab labels
|
||||
// vs HUD readouts), which Bevy can't prove, so declare it.
|
||||
animate_tab_expansion.ambiguous_with(UiTextFx),
|
||||
)
|
||||
.chain()
|
||||
.in_set(HudButtons)
|
||||
|
||||
@@ -360,6 +360,7 @@ pub(super) fn handle_avatar_button(
|
||||
pub(super) fn spawn_action_buttons(
|
||||
font_res: Option<Res<FontResource>>,
|
||||
windows: Query<&Window>,
|
||||
images: Option<ResMut<Assets<Image>>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let action_font_size =
|
||||
@@ -370,9 +371,18 @@ pub(super) fn spawn_action_buttons(
|
||||
..default()
|
||||
};
|
||||
|
||||
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
|
||||
// Touch layout: the floating glass tab bar (pill + circular Menu).
|
||||
// `Assets<Image>` is absent under `MinimalPlugins`; `rasterize_into`
|
||||
// returning `None` switches the bar to its text-fallback form.
|
||||
if USE_TOUCH_UI_LAYOUT {
|
||||
let icons = images.and_then(|mut imgs| TabIcons::rasterize_into(&mut imgs));
|
||||
spawn_glass_tab_bar(&mut commands, &font, icons.as_ref());
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop bottom bar: full-width, centered, docked to the bottom edge.
|
||||
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
|
||||
// Android reports it (frames 1-3); initial value is 0.0.
|
||||
// the platform reports it; initial value is 0.0.
|
||||
commands
|
||||
.spawn((
|
||||
Node {
|
||||
@@ -400,73 +410,10 @@ pub(super) fn spawn_action_buttons(
|
||||
HudActionBar,
|
||||
))
|
||||
.with_children(|row| {
|
||||
if USE_TOUCH_UI_LAYOUT {
|
||||
spawn_touch_action_bar(row, &font);
|
||||
} else {
|
||||
spawn_desktop_action_bar(row, &font);
|
||||
}
|
||||
spawn_desktop_action_bar(row, &font);
|
||||
});
|
||||
}
|
||||
|
||||
/// Phase F touch action bar — five buttons, three of them big.
|
||||
///
|
||||
/// The core gameplay trio (**Undo · Draw · Hint**) gets enlarged
|
||||
/// thumb-reach targets; Menu and Pause stay compact at the edges. The
|
||||
/// utility actions the desktop bar carries are reachable elsewhere on
|
||||
/// touch and are deliberately absent here: Help lives in Menu → System,
|
||||
/// mode switching lives on the Home screen (Phase B), and New Game is
|
||||
/// Home's hero button. Draw duplicates the stock-pile tap so the most
|
||||
/// frequent action of all no longer requires reaching the top half of a
|
||||
/// tall folded screen.
|
||||
pub(super) fn spawn_touch_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
|
||||
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
|
||||
// so Tab (external keyboard) cycles the bar in visual reading order.
|
||||
spawn_action_button(
|
||||
row,
|
||||
MenuButton,
|
||||
"Menu",
|
||||
None,
|
||||
"Open Home, Stats, Achievements, Profile, Settings, or Leaderboard.",
|
||||
font,
|
||||
0,
|
||||
TEXT_SECONDARY,
|
||||
);
|
||||
spawn_primary_action_button(
|
||||
row,
|
||||
UndoButton,
|
||||
"Undo",
|
||||
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
|
||||
font,
|
||||
1,
|
||||
);
|
||||
spawn_primary_action_button(
|
||||
row,
|
||||
DrawButton,
|
||||
"Draw",
|
||||
"Draw from the stock — same as tapping the deck.",
|
||||
font,
|
||||
2,
|
||||
);
|
||||
spawn_primary_action_button(
|
||||
row,
|
||||
HintButton,
|
||||
TOUCH_HINT_LABEL,
|
||||
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
||||
font,
|
||||
3,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
PauseButton,
|
||||
"Pause",
|
||||
None,
|
||||
"Pause the game and freeze the timer.",
|
||||
font,
|
||||
4,
|
||||
TEXT_SECONDARY,
|
||||
);
|
||||
}
|
||||
|
||||
/// Desktop action bar — unchanged by Phase F (decision 5: the touch bar
|
||||
/// is touch-only). All seven actions, uniform sizing.
|
||||
pub(super) fn spawn_desktop_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
|
||||
@@ -585,44 +532,9 @@ pub(super) fn spawn_action_button<M: Component>(
|
||||
);
|
||||
}
|
||||
|
||||
/// Enlarged variant for the touch bar's core gameplay trio (Phase F):
|
||||
/// bigger padding / minimum target and a scaled-up label so Undo, Draw,
|
||||
/// and Hint read (and hit) as the primary actions. Hotkey chips are
|
||||
/// irrelevant on touch, so the variant takes none.
|
||||
pub(super) fn spawn_primary_action_button<M: Component>(
|
||||
row: &mut ChildSpawnerCommands,
|
||||
marker: M,
|
||||
label: &str,
|
||||
tooltip: &'static str,
|
||||
font: &TextFont,
|
||||
order: i32,
|
||||
) {
|
||||
let (pad, min_w, min_h) = primary_action_button_metrics();
|
||||
let primary_font = TextFont {
|
||||
font: font.font.clone(),
|
||||
// 1.35× the bar's responsive size, capped so landscape tablets
|
||||
// don't blow the row height out.
|
||||
font_size: (font.font_size * 1.35).min(24.0),
|
||||
..default()
|
||||
};
|
||||
spawn_action_button_sized(
|
||||
row,
|
||||
marker,
|
||||
label,
|
||||
None,
|
||||
tooltip,
|
||||
&primary_font,
|
||||
order,
|
||||
TEXT_PRIMARY,
|
||||
pad,
|
||||
min_w,
|
||||
min_h,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared body of [`spawn_action_button`] / [`spawn_primary_action_button`]
|
||||
/// — one place owns the component set so `paint_action_buttons`, tooltips,
|
||||
/// and the focus ring treat every bar button identically.
|
||||
/// Shared body of [`spawn_action_button`] — one place owns the component
|
||||
/// set so `paint_action_buttons`, tooltips, and the focus ring treat every
|
||||
/// bar button identically.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_action_button_sized<M: Component>(
|
||||
row: &mut ChildSpawnerCommands,
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
//! Floating "liquid glass" touch tab bar — layout and animation.
|
||||
//!
|
||||
//! Replaces the docked full-width touch action bar (Phase F) with an
|
||||
//! iOS-26-style floating arrangement: a pill of icon buttons hovering
|
||||
//! above the bottom edge, plus a detached circular Menu button to its
|
||||
//! right. The *visual treatment* (translucent sheen, specular rim, drop
|
||||
//! shadow) lives in [`crate::ui_glass`]; this module owns only geometry,
|
||||
//! button wiring, and the expand/collapse label animation, so the glass
|
||||
//! material can be swapped without touching layout.
|
||||
//!
|
||||
//! Interaction compatibility: every button keeps the same marker
|
||||
//! components as the old bar (`UndoButton`, `DrawButton`, …) plus
|
||||
//! `ActionButton`, `Button`, `Tooltip`, and `Focusable`, so the click
|
||||
//! handlers, tooltips, focus ring, and the tap-to-toggle chrome guard in
|
||||
//! `interaction.rs` all work unchanged.
|
||||
//!
|
||||
//! Expansion model (user decision, 2026-07-15): **Draw** — the most
|
||||
//! frequent action — is the persistently-expanded "active" pill showing
|
||||
//! icon + label; the other buttons are icon-only and reveal their label
|
||||
//! with a width-animated slide while pressed.
|
||||
|
||||
use super::*;
|
||||
use crate::assets::hud_icon_svg::HudIcon;
|
||||
use crate::ui_glass::{GLASS_BORDER_PX, glass_surface};
|
||||
use crate::ui_theme::{ACCENT_PRIMARY_HOVER, SPACE_3};
|
||||
use bevy::text::{LineBreak, TextLayout};
|
||||
use bevy::ui::Overflow;
|
||||
|
||||
/// Height of both the pill and the circular Menu button, in logical px.
|
||||
/// 64 keeps the inner 48-px buttons comfortably past the 44-px touch
|
||||
/// floor with 8 px of glass around them.
|
||||
pub(crate) const TAB_BAR_HEIGHT: f32 = 64.0;
|
||||
|
||||
/// Gap between the bar and the bottom screen edge (the "floating" margin;
|
||||
/// the Android gesture-bar inset is added on top by `SafeAreaAnchoredBottom`).
|
||||
const TAB_BAR_MARGIN: f32 = SPACE_3;
|
||||
|
||||
/// Vertical space UI stacked above the bar (the toast stack) must clear:
|
||||
/// floating margin + bar height + one spacing step of breathing room.
|
||||
pub(crate) const TAB_BAR_CLEARANCE_PX: f32 = TAB_BAR_MARGIN + TAB_BAR_HEIGHT + SPACE_2;
|
||||
|
||||
/// Diameter of a button inside the pill (and its fully-round radius ×2).
|
||||
const TAB_BUTTON_SIZE: f32 = 48.0;
|
||||
|
||||
/// Displayed icon size in logical px (texture is rasterised larger; see
|
||||
/// `assets::hud_icon_svg::HUD_ICON_TEXTURE_PX`).
|
||||
const TAB_ICON_PX: f32 = 24.0;
|
||||
|
||||
/// FiraMono glyph advance as a fraction of font size. The bar is
|
||||
/// monospace-only, so `chars × advance × font_size` is the exact label
|
||||
/// width — the same ratio `action_bar_font_size` documents.
|
||||
const FIRA_ADVANCE: f32 = 0.62;
|
||||
|
||||
/// Gap between an icon and its revealed label, folded into the animated
|
||||
/// wrapper width so it collapses together with the text.
|
||||
const LABEL_LEAD_PX: f32 = SPACE_2;
|
||||
|
||||
/// Idle fill of an inactive icon button — fully transparent so the pill's
|
||||
/// glass shows through untouched.
|
||||
const GLASS_BTN_IDLE: Color = Color::srgba(1.0, 1.0, 1.0, 0.0);
|
||||
/// Hover fill of an inactive icon button (desktop pointer / DPAD focus).
|
||||
const GLASS_BTN_HOVER: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||
/// Pressed fill of an inactive icon button.
|
||||
const GLASS_BTN_PRESSED: Color = Color::srgba(1.0, 1.0, 1.0, 0.14);
|
||||
/// Pressed state of the active (accent-filled) pill — darker than idle so
|
||||
/// the press reads as the surface receding, mirroring `BG_ELEVATED_PRESSED`.
|
||||
const GLASS_ACTIVE_PRESSED: Color = Color::srgb(0.55, 0.22, 0.22);
|
||||
|
||||
/// Idle / hovered / pressed fills for one tab-bar button. Presence of this
|
||||
/// component also routes the button to `paint_glass_tab_buttons` instead of
|
||||
/// the legacy `paint_action_buttons` (which excludes it).
|
||||
#[derive(Component, Debug)]
|
||||
pub struct GlassTabButton {
|
||||
idle: Color,
|
||||
hovered: Color,
|
||||
pressed: Color,
|
||||
}
|
||||
|
||||
/// Animated label-reveal state, on the clipping wrapper node between the
|
||||
/// button and its `Text` label. `progress` ∈ [0, 1] maps to wrapper width
|
||||
/// via [`expansion_width`]; `target` is where it is heading.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct TabLabelWrap {
|
||||
/// Current reveal amount, 0 = collapsed, 1 = fully expanded.
|
||||
pub progress: f32,
|
||||
/// Reveal amount the animation is moving towards.
|
||||
pub target: f32,
|
||||
/// `true` keeps the label expanded regardless of interaction —
|
||||
/// the persistent Draw pill, and every button in text-fallback mode.
|
||||
persistent: bool,
|
||||
/// Label length; width is recomputed from the live font size each
|
||||
/// frame so viewport-driven font rescales stay correct.
|
||||
label_chars: usize,
|
||||
/// Last width written to the node, to skip no-op layout writes.
|
||||
last_width: f32,
|
||||
}
|
||||
|
||||
/// Marker on the label `Text` inside a [`TabLabelWrap`].
|
||||
#[derive(Component, Debug)]
|
||||
pub struct TabLabel;
|
||||
|
||||
/// Rasterised icon textures for the five tab-bar actions.
|
||||
pub(super) struct TabIcons {
|
||||
undo: Handle<Image>,
|
||||
draw: Handle<Image>,
|
||||
hint: Handle<Image>,
|
||||
pause: Handle<Image>,
|
||||
menu: Handle<Image>,
|
||||
}
|
||||
|
||||
impl TabIcons {
|
||||
/// Rasterise all five icons into `images`. Returns `None` (and warns)
|
||||
/// if any icon fails — the caller then falls back to text-only labels,
|
||||
/// so a bad SVG degrades the look, never the playability.
|
||||
pub(super) fn rasterize_into(images: &mut Assets<Image>) -> Option<Self> {
|
||||
let mut add = |icon: HudIcon| match icon.rasterize() {
|
||||
Ok(img) => Some(images.add(img)),
|
||||
Err(e) => {
|
||||
warn!("tab_bar: failed to rasterise {icon:?} icon: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
Some(Self {
|
||||
undo: add(HudIcon::Undo)?,
|
||||
draw: add(HudIcon::Draw)?,
|
||||
hint: add(HudIcon::Hint)?,
|
||||
pause: add(HudIcon::Pause)?,
|
||||
menu: add(HudIcon::Menu)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper width in logical px for a label of `chars` monospace glyphs at
|
||||
/// `font_size`, revealed to `progress`. Pure so the animation curve is
|
||||
/// unit-testable without an app.
|
||||
pub(super) fn expansion_width(chars: usize, font_size: f32, progress: f32) -> f32 {
|
||||
progress.clamp(0.0, 1.0) * (LABEL_LEAD_PX + chars as f32 * font_size * FIRA_ADVANCE)
|
||||
}
|
||||
|
||||
/// Advance `progress` towards `target` by one frame of `dt` seconds.
|
||||
/// Linear tween over `MOTION_SLIDE_SECS`, snapping when reduce-motion is
|
||||
/// active. Pure for the same reason as [`expansion_width`].
|
||||
pub(super) fn step_expansion(progress: f32, target: f32, dt: f32, reduce_motion: bool) -> f32 {
|
||||
if reduce_motion {
|
||||
return target;
|
||||
}
|
||||
let max_step = dt / crate::ui_theme::MOTION_SLIDE_SECS;
|
||||
progress + (target - progress).clamp(-max_step, max_step)
|
||||
}
|
||||
|
||||
/// Spawns the floating glass tab bar: one full-width invisible flex row
|
||||
/// (carrying the `HudActionBar` marker so chrome show/hide keeps working)
|
||||
/// centring the glass pill and the detached circular Menu button.
|
||||
///
|
||||
/// `icons: None` (rasteriser unavailable, e.g. `MinimalPlugins` tests, or
|
||||
/// an SVG regression) switches every button to text-fallback: labels stay
|
||||
/// expanded so the bar remains fully usable without textures.
|
||||
pub(super) fn spawn_glass_tab_bar(
|
||||
commands: &mut Commands,
|
||||
font: &TextFont,
|
||||
icons: Option<&TabIcons>,
|
||||
) {
|
||||
let text_fallback = icons.is_none();
|
||||
commands
|
||||
.spawn((
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
bottom: Val::Px(TAB_BAR_MARGIN),
|
||||
left: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Row,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: VAL_SPACE_3,
|
||||
..default()
|
||||
},
|
||||
ZIndex(Z_HUD),
|
||||
SafeAreaAnchoredBottom {
|
||||
base_bottom: TAB_BAR_MARGIN,
|
||||
},
|
||||
HudActionBar,
|
||||
))
|
||||
.with_children(|bar| {
|
||||
// The main pill: Undo · [Draw] · Hint · Pause.
|
||||
bar.spawn((
|
||||
Node {
|
||||
height: Val::Px(TAB_BAR_HEIGHT),
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
padding: UiRect::axes(Val::Px(SPACE_2), Val::Px(0.0)),
|
||||
column_gap: Val::Px(4.0),
|
||||
border: UiRect::all(Val::Px(GLASS_BORDER_PX)),
|
||||
border_radius: BorderRadius::all(Val::Px(TAB_BAR_HEIGHT / 2.0)),
|
||||
..default()
|
||||
},
|
||||
glass_surface(),
|
||||
))
|
||||
.with_children(|pill| {
|
||||
spawn_tab_button(
|
||||
pill,
|
||||
UndoButton,
|
||||
"Undo",
|
||||
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
|
||||
font,
|
||||
0,
|
||||
icons.map(|i| i.undo.clone()),
|
||||
false,
|
||||
text_fallback,
|
||||
);
|
||||
spawn_tab_button(
|
||||
pill,
|
||||
DrawButton,
|
||||
"Draw",
|
||||
"Draw from the stock — same as tapping the deck.",
|
||||
font,
|
||||
1,
|
||||
icons.map(|i| i.draw.clone()),
|
||||
true,
|
||||
text_fallback,
|
||||
);
|
||||
spawn_tab_button(
|
||||
pill,
|
||||
HintButton,
|
||||
TOUCH_HINT_LABEL,
|
||||
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
||||
font,
|
||||
2,
|
||||
icons.map(|i| i.hint.clone()),
|
||||
false,
|
||||
text_fallback,
|
||||
);
|
||||
spawn_tab_button(
|
||||
pill,
|
||||
PauseButton,
|
||||
"Pause",
|
||||
"Pause the game and freeze the timer.",
|
||||
font,
|
||||
3,
|
||||
icons.map(|i| i.pause.clone()),
|
||||
false,
|
||||
text_fallback,
|
||||
);
|
||||
});
|
||||
|
||||
// The detached circular Menu button, same glass material.
|
||||
bar.spawn((
|
||||
MenuButton,
|
||||
ActionButton,
|
||||
Button,
|
||||
GlassTabButton {
|
||||
idle: GLASS_BTN_IDLE,
|
||||
hovered: GLASS_BTN_HOVER,
|
||||
pressed: GLASS_BTN_PRESSED,
|
||||
},
|
||||
Tooltip::new(
|
||||
"Open Home, Stats, Achievements, Profile, Settings, or Leaderboard.",
|
||||
),
|
||||
Focusable {
|
||||
group: FocusGroup::Hud,
|
||||
order: 4,
|
||||
},
|
||||
Node {
|
||||
width: Val::Px(TAB_BAR_HEIGHT),
|
||||
height: Val::Px(TAB_BAR_HEIGHT),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
border: UiRect::all(Val::Px(GLASS_BORDER_PX)),
|
||||
border_radius: BorderRadius::all(Val::Px(TAB_BAR_HEIGHT / 2.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(GLASS_BTN_IDLE),
|
||||
glass_surface(),
|
||||
))
|
||||
.with_children(|circle| {
|
||||
if let Some(icons) = icons {
|
||||
spawn_tab_icon(circle, icons.menu.clone(), TEXT_PRIMARY);
|
||||
} else {
|
||||
// Text fallback: a menu glyph-free label keeps the
|
||||
// button discoverable without any texture.
|
||||
circle.spawn((
|
||||
Text::new("Menu"),
|
||||
TextLayout::new_with_linebreak(LineBreak::NoWrap),
|
||||
font.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawns one button inside the pill: icon, then the width-animated label
|
||||
/// wrapper. `active` gives the accent-filled persistent pill (Draw);
|
||||
/// `text_fallback` pins every label open when icons are unavailable.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_tab_button<M: Component>(
|
||||
pill: &mut ChildSpawnerCommands,
|
||||
marker: M,
|
||||
label: &str,
|
||||
tooltip: &'static str,
|
||||
font: &TextFont,
|
||||
order: i32,
|
||||
icon: Option<Handle<Image>>,
|
||||
active: bool,
|
||||
text_fallback: bool,
|
||||
) {
|
||||
let (idle, hovered, pressed) = if active {
|
||||
(ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, GLASS_ACTIVE_PRESSED)
|
||||
} else {
|
||||
(GLASS_BTN_IDLE, GLASS_BTN_HOVER, GLASS_BTN_PRESSED)
|
||||
};
|
||||
let icon_tint = if active { TEXT_PRIMARY } else { TEXT_SECONDARY };
|
||||
let persistent = active || text_fallback;
|
||||
let start = if persistent { 1.0 } else { 0.0 };
|
||||
let chars = label.chars().count();
|
||||
|
||||
pill.spawn((
|
||||
marker,
|
||||
ActionButton,
|
||||
Button,
|
||||
GlassTabButton {
|
||||
idle,
|
||||
hovered,
|
||||
pressed,
|
||||
},
|
||||
Tooltip::new(tooltip),
|
||||
Focusable {
|
||||
group: FocusGroup::Hud,
|
||||
order,
|
||||
},
|
||||
Node {
|
||||
height: Val::Px(TAB_BUTTON_SIZE),
|
||||
min_width: Val::Px(TAB_BUTTON_SIZE),
|
||||
flex_direction: FlexDirection::Row,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
padding: UiRect::axes(Val::Px(SPACE_3), Val::Px(0.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(TAB_BUTTON_SIZE / 2.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(idle),
|
||||
))
|
||||
.with_children(|b| {
|
||||
if let Some(handle) = icon {
|
||||
spawn_tab_icon(b, handle, icon_tint);
|
||||
}
|
||||
// The clipping wrapper the animation drives. Label text sits
|
||||
// left-anchored inside, so growing the width reveals it
|
||||
// left-to-right and shrinking clips it from the right.
|
||||
b.spawn((
|
||||
TabLabelWrap {
|
||||
progress: start,
|
||||
target: start,
|
||||
persistent,
|
||||
label_chars: chars,
|
||||
last_width: f32::NAN,
|
||||
},
|
||||
Node {
|
||||
overflow: Overflow::clip(),
|
||||
width: Val::Px(expansion_width(chars, font.font_size, start)),
|
||||
height: Val::Percent(100.0),
|
||||
align_items: AlignItems::Center,
|
||||
justify_content: JustifyContent::FlexEnd,
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|wrap| {
|
||||
let mut color = TEXT_PRIMARY;
|
||||
color.set_alpha(start);
|
||||
wrap.spawn((
|
||||
TabLabel,
|
||||
ActionButtonLabel,
|
||||
Text::new(label),
|
||||
TextLayout::new_with_linebreak(LineBreak::NoWrap),
|
||||
font.clone(),
|
||||
TextColor(color),
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// One fixed-size tinted icon node.
|
||||
fn spawn_tab_icon(parent: &mut ChildSpawnerCommands, handle: Handle<Image>, tint: Color) {
|
||||
parent.spawn((
|
||||
ImageNode {
|
||||
color: tint,
|
||||
..ImageNode::new(handle)
|
||||
},
|
||||
Node {
|
||||
width: Val::Px(TAB_ICON_PX),
|
||||
height: Val::Px(TAB_ICON_PX),
|
||||
flex_shrink: 0.0,
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
/// Sets each label wrapper's animation target from its button's
|
||||
/// interaction state: persistent wrappers pin to 1, the rest expand while
|
||||
/// pressed or hovered and collapse on release.
|
||||
pub(super) fn drive_tab_expansion(
|
||||
buttons: Query<(&Interaction, &Children), With<GlassTabButton>>,
|
||||
mut wraps: Query<&mut TabLabelWrap>,
|
||||
) {
|
||||
for (interaction, children) in &buttons {
|
||||
for child in children.iter() {
|
||||
let Ok(mut wrap) = wraps.get_mut(child) else {
|
||||
continue;
|
||||
};
|
||||
let target = if wrap.persistent || !matches!(interaction, Interaction::None) {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Bypass change detection on no-ops so `animate_tab_expansion`
|
||||
// and layout stay idle while nothing moves.
|
||||
if wrap.target != target {
|
||||
wrap.target = target;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances every label reveal towards its target and writes the wrapper
|
||||
/// width + label alpha. Width is derived from the label's *live* font size,
|
||||
/// so the Fold's posture-change font rescale re-widens expanded labels
|
||||
/// automatically. Node writes are skipped when the width is unchanged to
|
||||
/// avoid dirtying UI layout every frame.
|
||||
pub(super) fn animate_tab_expansion(
|
||||
time: Res<Time>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
mut wraps: Query<(&mut TabLabelWrap, &mut Node, &Children)>,
|
||||
mut labels: Query<(&TextFont, &mut TextColor), With<TabLabel>>,
|
||||
) {
|
||||
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||
let dt = time.delta_secs();
|
||||
for (mut wrap, mut node, children) in &mut wraps {
|
||||
for child in children.iter() {
|
||||
let Ok((font, mut text_color)) = labels.get_mut(child) else {
|
||||
continue;
|
||||
};
|
||||
let next = step_expansion(wrap.progress, wrap.target, dt, reduce_motion);
|
||||
let width = expansion_width(wrap.label_chars, font.font_size, next);
|
||||
// `!=` (not epsilon) is deliberate: last_width starts NaN so the
|
||||
// first frame always writes, and equal floats mean a true no-op.
|
||||
if next != wrap.progress || width != wrap.last_width {
|
||||
wrap.progress = next;
|
||||
wrap.last_width = width;
|
||||
node.width = Val::Px(width);
|
||||
text_color.0.set_alpha(next);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Idle / hover / pressed feedback for glass tab buttons — the glass
|
||||
/// counterpart of `paint_action_buttons`, using each button's own colour
|
||||
/// triple so the accent-filled active pill and the transparent icon
|
||||
/// buttons share one system.
|
||||
pub(super) fn paint_glass_tab_buttons(
|
||||
mut buttons: Query<(&Interaction, &GlassTabButton, &mut BackgroundColor), Changed<Interaction>>,
|
||||
) {
|
||||
for (interaction, style, mut bg) in &mut buttons {
|
||||
bg.0 = match interaction {
|
||||
Interaction::Pressed => style.pressed,
|
||||
Interaction::Hovered => style.hovered,
|
||||
Interaction::None => style.idle,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -907,6 +907,10 @@ fn hud_focus_only_engages_when_button_hovered() {
|
||||
/// Bare app (no `HudPlugin`) so bar-content assertions count only the
|
||||
/// buttons the tested spawn function creates — `headless_app` would
|
||||
/// pre-spawn the platform-default bar and pollute the counts.
|
||||
///
|
||||
/// The touch branch spawns the glass tab bar with `icons: None`
|
||||
/// (`MinimalPlugins` has no `Assets<Image>`), which exercises the
|
||||
/// text-fallback path — same markers, labels pinned expanded.
|
||||
fn bar_only_app(touch: bool) -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
@@ -914,13 +918,13 @@ fn bar_only_app(touch: bool) -> App {
|
||||
let font = TextFont::default();
|
||||
let world = app.world_mut();
|
||||
let mut commands = world.commands();
|
||||
commands.spawn(Node::default()).with_children(|row| {
|
||||
if touch {
|
||||
spawn_touch_action_bar(row, &font);
|
||||
} else {
|
||||
if touch {
|
||||
spawn_glass_tab_bar(&mut commands, &font, None);
|
||||
} else {
|
||||
commands.spawn(Node::default()).with_children(|row| {
|
||||
spawn_desktop_action_bar(row, &font);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
@@ -933,7 +937,7 @@ fn count_buttons<C: Component>(app: &mut App) -> usize {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touch_action_bar_has_thumb_trio_plus_menu_and_pause() {
|
||||
fn touch_tab_bar_has_thumb_trio_plus_menu_and_pause() {
|
||||
let mut app = bar_only_app(true);
|
||||
|
||||
assert_eq!(count_buttons::<UndoButton>(&mut app), 1);
|
||||
@@ -949,6 +953,82 @@ fn touch_action_bar_has_thumb_trio_plus_menu_and_pause() {
|
||||
assert_eq!(count_buttons::<NewGameButton>(&mut app), 0);
|
||||
}
|
||||
|
||||
/// The glass bar floats: its container must carry a bottom margin (not be
|
||||
/// docked at 0) both before and after safe-area insets arrive.
|
||||
#[test]
|
||||
fn glass_tab_bar_floats_above_bottom_edge() {
|
||||
let mut app = bar_only_app(true);
|
||||
let world = app.world_mut();
|
||||
let mut q = world.query_filtered::<(&Node, &SafeAreaAnchoredBottom), With<HudActionBar>>();
|
||||
let (node, anchor) = q.single(world).expect("glass bar container exists");
|
||||
assert!(anchor.base_bottom > 0.0, "bar must float, not dock");
|
||||
assert_eq!(node.bottom, Val::Px(anchor.base_bottom));
|
||||
}
|
||||
|
||||
/// Draw is the persistently-expanded active pill (user decision
|
||||
/// 2026-07-15); with `icons: None` every wrapper is pinned expanded via
|
||||
/// text fallback, so instead assert on the *persistent* flag's effect:
|
||||
/// all wrappers spawn at full reveal and target full reveal.
|
||||
#[test]
|
||||
fn glass_tab_bar_text_fallback_pins_labels_expanded() {
|
||||
let mut app = bar_only_app(true);
|
||||
let world = app.world_mut();
|
||||
let mut q = world.query::<&TabLabelWrap>();
|
||||
let wraps: Vec<_> = q.iter(world).collect();
|
||||
assert_eq!(wraps.len(), 4, "Undo/Draw/Hint/Pause each carry a wrapper");
|
||||
for wrap in wraps {
|
||||
assert_eq!(wrap.progress, 1.0);
|
||||
assert_eq!(wrap.target, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus order must match visual reading order: pill buttons left to
|
||||
/// right, then the detached Menu circle.
|
||||
#[test]
|
||||
fn glass_tab_bar_focus_order_reads_left_to_right() {
|
||||
let mut app = bar_only_app(true);
|
||||
|
||||
for (f, expected) in [
|
||||
(focusable_for::<UndoButton>(&mut app), 0),
|
||||
(focusable_for::<DrawButton>(&mut app), 1),
|
||||
(focusable_for::<HintButton>(&mut app), 2),
|
||||
(focusable_for::<PauseButton>(&mut app), 3),
|
||||
(focusable_for::<MenuButton>(&mut app), 4),
|
||||
] {
|
||||
assert_eq!(f.group, FocusGroup::Hud);
|
||||
assert_eq!(f.order, expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// `expansion_width` is the single source of truth for the label reveal:
|
||||
/// zero when collapsed, lead + monospace advance when expanded, linear
|
||||
/// in between.
|
||||
#[test]
|
||||
fn expansion_width_is_linear_in_progress() {
|
||||
assert_eq!(expansion_width(4, 16.0, 0.0), 0.0);
|
||||
let full = expansion_width(4, 16.0, 1.0);
|
||||
assert!(full > 4.0 * 16.0 * 0.62, "full width includes the lead gap");
|
||||
let half = expansion_width(4, 16.0, 0.5);
|
||||
assert!((half - full / 2.0).abs() < f32::EPSILON * 100.0);
|
||||
// Out-of-range progress clamps rather than extrapolating.
|
||||
assert_eq!(expansion_width(4, 16.0, 1.5), full);
|
||||
}
|
||||
|
||||
/// The tween must move at the `MOTION_SLIDE_SECS` rate, never overshoot,
|
||||
/// and snap instantly under reduce-motion.
|
||||
#[test]
|
||||
fn step_expansion_tweens_and_respects_reduce_motion() {
|
||||
// One 60 fps frame covers 1/(0.18*60) ≈ 9.3% of the reveal.
|
||||
let one_frame = step_expansion(0.0, 1.0, 1.0 / 60.0, false);
|
||||
assert!(one_frame > 0.0 && one_frame < 0.2);
|
||||
// A huge dt clamps at the target instead of overshooting.
|
||||
assert_eq!(step_expansion(0.0, 1.0, 10.0, false), 1.0);
|
||||
// Collapse works symmetrically.
|
||||
assert!(step_expansion(1.0, 0.0, 1.0 / 60.0, false) < 1.0);
|
||||
// Reduce-motion snaps regardless of dt.
|
||||
assert_eq!(step_expansion(0.0, 1.0, 0.0, true), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_action_bar_keeps_all_seven_and_no_draw() {
|
||||
let mut app = bar_only_app(false);
|
||||
|
||||
@@ -573,20 +573,6 @@ pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Metrics for the touch bar's enlarged Undo / Draw / Hint trio (Phase F).
|
||||
/// 96×64 px targets — comfortably past the 44 px accessibility floor and
|
||||
/// big enough to hit one-handed without looking. Only the touch layout
|
||||
/// spawns primary buttons, so no desktop branch is needed; the desktop
|
||||
/// values exist purely so host-side tests can exercise the touch spawn
|
||||
/// path with sensible numbers.
|
||||
pub(super) fn primary_action_button_metrics() -> (UiRect, Val, Val) {
|
||||
(
|
||||
UiRect::axes(Val::Px(10.0), Val::Px(8.0)),
|
||||
Val::Px(96.0),
|
||||
Val::Px(64.0),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn spawn_action_button_label(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
label: &str,
|
||||
|
||||
@@ -215,6 +215,7 @@ fn handle_keyboard_core(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
drag: Res<DragState>,
|
||||
mut ev: CoreKeyboardMessages<'_>,
|
||||
mut time_attack: Option<ResMut<TimeAttackResource>>,
|
||||
selection: Option<Res<SelectionState>>,
|
||||
@@ -227,6 +228,19 @@ fn handle_keyboard_core(
|
||||
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
|
||||
// are suppressed. The replay overlay owns Space (pause/resume) and the
|
||||
// arrow keys (step). Letting game input through would mutate
|
||||
@@ -473,11 +487,19 @@ pub fn emit_hint_visuals(
|
||||
fn handle_keyboard_forfeit(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
drag: Res<DragState>,
|
||||
mut requests: MessageWriter<ForfeitRequestEvent>,
|
||||
) {
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -817,3 +817,123 @@ fn pressing_h_spawns_pending_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::sync_plugin::SyncProviderResource;
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||
spawn_modal_button, spawn_modal_header,
|
||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal,
|
||||
spawn_modal_actions, spawn_modal_button, spawn_modal_header,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY,
|
||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
||||
Z_MODAL_PANEL, Z_PAUSE_DIALOG,
|
||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, TEXT_DISABLED, TEXT_PRIMARY,
|
||||
TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||
Z_PAUSE_DIALOG,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -536,11 +536,6 @@ fn spawn_leaderboard_screen(
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
let font_status = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
};
|
||||
let font_row = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_BODY,
|
||||
@@ -647,30 +642,31 @@ fn spawn_leaderboard_screen(
|
||||
.with_children(|body| {
|
||||
match data {
|
||||
LeaderboardResource::Idle => {
|
||||
body.spawn((
|
||||
Text::new("Fetching\u{2026}"),
|
||||
font_status.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
spawn_empty_state(
|
||||
body,
|
||||
"\u{21BB}",
|
||||
"Fetching the leaderboard\u{2026}",
|
||||
None,
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
LeaderboardResource::Error(_) => {
|
||||
body.spawn((
|
||||
Text::new("Couldn't reach the leaderboard. Try again later."),
|
||||
font_status.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
spawn_empty_state(
|
||||
body,
|
||||
"!",
|
||||
"Couldn't reach the leaderboard.",
|
||||
Some("Try again later."),
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
|
||||
body.spawn((
|
||||
Text::new("Be the first on the leaderboard."),
|
||||
font_status.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
body.spawn((
|
||||
Text::new("Win a game and opt in to appear here."),
|
||||
font_row.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
spawn_empty_state(
|
||||
body,
|
||||
"\u{2660}",
|
||||
"Be the first on the leaderboard.",
|
||||
Some("Win a game and opt in to appear here."),
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
LeaderboardResource::Loaded(rows) => {
|
||||
// Column headers
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod avatar_plugin;
|
||||
pub mod card_animation;
|
||||
pub mod card_plugin;
|
||||
pub mod challenge_plugin;
|
||||
pub mod cheat_sheet_plugin;
|
||||
pub mod contextual_tips_plugin;
|
||||
pub mod core_game_plugin;
|
||||
pub mod cursor_plugin;
|
||||
@@ -27,6 +28,7 @@ pub mod font_plugin;
|
||||
pub mod game_plugin;
|
||||
pub mod help_plugin;
|
||||
pub mod home_plugin;
|
||||
pub mod hotkeys;
|
||||
pub mod hud_plugin;
|
||||
pub mod input_plugin;
|
||||
pub mod layout;
|
||||
@@ -61,6 +63,7 @@ pub mod theme_store_plugin;
|
||||
pub mod time_attack_plugin;
|
||||
pub mod touch_selection_plugin;
|
||||
pub mod ui_focus;
|
||||
pub mod ui_glass;
|
||||
pub mod ui_modal;
|
||||
pub mod ui_theme;
|
||||
pub mod ui_tooltip;
|
||||
@@ -95,6 +98,7 @@ pub use card_plugin::{
|
||||
pub use challenge_plugin::{
|
||||
CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label,
|
||||
};
|
||||
pub use cheat_sheet_plugin::{CheatSheetOverlay, CheatSheetPlugin};
|
||||
pub use contextual_tips_plugin::ContextualTipsPlugin;
|
||||
pub use core_game_plugin::CoreGamePlugin;
|
||||
pub use cursor_plugin::CursorPlugin;
|
||||
|
||||
@@ -84,66 +84,11 @@ struct OnboardingSkipButton;
|
||||
pub struct OnboardingSlideIndex(pub u8);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slide data — hotkey rows are taken verbatim from `help_plugin.rs` so the
|
||||
// two screens stay in sync without a shared abstraction.
|
||||
// Slide data — the hotkey slide renders the essential subset of the
|
||||
// shared table in `crate::hotkeys` (Phase J unified the previously
|
||||
// diverging copies here and in help_plugin).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single `key — description` pair shown on slide 3.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
struct HotkeyRow {
|
||||
keys: &'static str,
|
||||
description: &'static str,
|
||||
}
|
||||
|
||||
/// Most-used shortcuts from the `help_plugin` canonical list.
|
||||
///
|
||||
/// Updating the list in `help_plugin.rs` should be mirrored here. The
|
||||
/// ARCHITECTURE.md decision log calls out that we copy values rather than
|
||||
/// refactor the help plugin.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const HOTKEYS: &[HotkeyRow] = &[
|
||||
HotkeyRow {
|
||||
keys: "D / Space",
|
||||
description: "Draw from stock",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "U",
|
||||
description: "Undo last move",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "Tab → Enter",
|
||||
description: "Pick a card; arrows pick where; Enter to drop",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "N",
|
||||
description: "New Classic game",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "M",
|
||||
description: "Open Mode Launcher (then 1–5 to pick)",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "S",
|
||||
description: "Stats & progression",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "A",
|
||||
description: "Achievements",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "O",
|
||||
description: "Settings",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "Esc",
|
||||
description: "Pause / resume",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "F1",
|
||||
description: "Help / controls",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -446,8 +391,10 @@ fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
||||
spawn_modal_header(card, "Keyboard shortcuts", font_res);
|
||||
|
||||
// Vertical list of `key — description` rows, same chip style as HelpScreen.
|
||||
for row in HOTKEYS {
|
||||
// Vertical list of `key — description` rows, same chip style as
|
||||
// HelpScreen. Essential subset only — the full table lives on
|
||||
// the hold-`/` cheat sheet.
|
||||
for row in crate::hotkeys::HOTKEYS.iter().filter(|r| r.essential) {
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
@@ -826,22 +773,15 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hotkey list is non-empty (guards against accidental truncation)
|
||||
// Hotkey slide renders a non-empty essential subset (the table's own
|
||||
// integrity tests live in `crate::hotkeys`)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn hotkey_list_is_non_empty() {
|
||||
assert!(!HOTKEYS.is_empty(), "HOTKEYS must not be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_hotkey_rows_have_non_empty_fields() {
|
||||
for row in HOTKEYS {
|
||||
assert!(!row.keys.is_empty(), "hotkey key field must not be empty");
|
||||
assert!(
|
||||
!row.description.is_empty(),
|
||||
"hotkey description must not be empty"
|
||||
);
|
||||
}
|
||||
fn hotkey_slide_subset_is_non_empty() {
|
||||
assert!(
|
||||
crate::hotkeys::HOTKEYS.iter().any(|r| r.essential),
|
||||
"the onboarding slide needs at least one essential hotkey"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,9 +101,10 @@ impl Plugin for SafeAreaInsetsPlugin {
|
||||
fn apply_safe_area_anchors(
|
||||
insets: Res<SafeAreaInsets>,
|
||||
windows: Query<&Window>,
|
||||
ui_scale: Option<Res<UiScale>>,
|
||||
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
|
||||
) {
|
||||
if !insets.is_changed() {
|
||||
if !insets.is_changed() && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
|
||||
return;
|
||||
}
|
||||
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
|
||||
@@ -119,19 +120,31 @@ fn apply_safe_area_anchors(
|
||||
);
|
||||
}
|
||||
let top_logical = raw_top.min(max_inset);
|
||||
// `bevy::ui::UiScale` (Phase K) multiplies every Val::Px at layout
|
||||
// time. `base_top` is UI chrome and SHOULD scale with the rest of
|
||||
// the interface, but the system-bar inset is physical reality —
|
||||
// pre-divide it so the post-multiplication offset stays exact.
|
||||
let ui = effective_ui_scale(ui_scale.as_deref());
|
||||
for (anchor, mut node) in &mut q {
|
||||
node.top = Val::Px(anchor.base_top + top_logical);
|
||||
node.top = Val::Px(anchor.base_top + top_logical / ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// The live UI scale, defensively clamped — a zero or negative scale
|
||||
/// would flip or destroy the inset math.
|
||||
fn effective_ui_scale(ui_scale: Option<&UiScale>) -> f32 {
|
||||
ui_scale.map_or(1.0, |s| s.0.max(0.1))
|
||||
}
|
||||
|
||||
/// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying
|
||||
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
|
||||
fn apply_safe_area_bottom_anchors(
|
||||
insets: Res<SafeAreaInsets>,
|
||||
windows: Query<&Window>,
|
||||
ui_scale: Option<Res<UiScale>>,
|
||||
mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
|
||||
) {
|
||||
if !insets.is_changed() {
|
||||
if !insets.is_changed() && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
|
||||
return;
|
||||
}
|
||||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||
@@ -144,8 +157,12 @@ fn apply_safe_area_bottom_anchors(
|
||||
);
|
||||
}
|
||||
let bottom_logical = raw_bottom.min(max_inset);
|
||||
// See `apply_safe_area_anchors`: the physical inset is pre-divided
|
||||
// by the UI scale so a 90% setting can't sink the action bar into
|
||||
// the gesture zone (and 130% doesn't over-inset it).
|
||||
let ui = effective_ui_scale(ui_scale.as_deref());
|
||||
for (anchor, mut node) in &mut q {
|
||||
node.bottom = Val::Px(anchor.base_bottom + bottom_logical);
|
||||
node.bottom = Val::Px(anchor.base_bottom + bottom_logical / ui);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,19 +180,23 @@ fn apply_safe_area_bottom_anchors(
|
||||
fn apply_safe_area_to_modal_scrims(
|
||||
insets: Res<SafeAreaInsets>,
|
||||
windows: Query<&Window>,
|
||||
ui_scale: Option<Res<UiScale>>,
|
||||
mut scrims: Query<&mut Node, With<ModalScrim>>,
|
||||
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
|
||||
) {
|
||||
let has_new = !new_scrims.is_empty();
|
||||
if !insets.is_changed() && !has_new {
|
||||
if !insets.is_changed() && !has_new && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
|
||||
return;
|
||||
}
|
||||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||
let window_height = windows.iter().next().map_or(800.0, |w| w.height());
|
||||
// Clamp each inset to 25% of screen height so an unexpectedly large OS
|
||||
// value can't push the modal card off the visible area entirely.
|
||||
let top_logical = (insets.top / scale).min(window_height * 0.25);
|
||||
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25);
|
||||
// Physical insets are pre-divided by the UI scale — see
|
||||
// `apply_safe_area_anchors`.
|
||||
let ui = effective_ui_scale(ui_scale.as_deref());
|
||||
let top_logical = (insets.top / scale).min(window_height * 0.25) / ui;
|
||||
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25) / ui;
|
||||
for mut node in &mut scrims {
|
||||
// Set both edges so the scrim's content box equals the usable area
|
||||
// between the status bar and the gesture/navigation bar. With
|
||||
|
||||
@@ -363,6 +363,14 @@ pub(super) fn handle_settings_buttons(
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
// Text refreshed by `update_touch_input_mode_text` next frame.
|
||||
}
|
||||
SettingsButton::CycleUiScale => {
|
||||
settings.0.ui_scale = next_ui_scale(settings.0.ui_scale);
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
// Text refreshed by `update_ui_scale_text`; the live
|
||||
// `bevy::ui::UiScale` resource follows via
|
||||
// `sync_ui_scale_resource` next frame.
|
||||
}
|
||||
SettingsButton::ToggleWinnableDealsOnly => {
|
||||
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
|
||||
persist(&path, &settings.0);
|
||||
|
||||
@@ -139,6 +139,10 @@ struct ReduceMotionText;
|
||||
#[derive(Component, Debug)]
|
||||
struct TouchInputModeText;
|
||||
|
||||
/// Marks the `Text` node showing the current UI scale percentage.
|
||||
#[derive(Component, Debug)]
|
||||
struct UiScaleText;
|
||||
|
||||
/// Marks the `Text` node showing the live tooltip-delay value.
|
||||
#[derive(Component, Debug)]
|
||||
struct TooltipDelayText;
|
||||
@@ -277,6 +281,10 @@ enum SettingsButton {
|
||||
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
|
||||
/// a card/stack, second tap on a target pile moves it).
|
||||
ToggleTouchInputMode,
|
||||
/// Cycle [`Settings::ui_scale`] through 90 % → 100 % → 115 % →
|
||||
/// 130 % → 90 %. Applied live via `bevy::ui::UiScale`; the table
|
||||
/// stays window-fit (Phase K).
|
||||
CycleUiScale,
|
||||
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
||||
/// random Classic-mode deals are filtered through
|
||||
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
|
||||
@@ -354,6 +362,7 @@ impl SettingsButton {
|
||||
SettingsButton::ToggleHighContrast => 61,
|
||||
SettingsButton::ToggleReduceMotion => 62,
|
||||
SettingsButton::ToggleTouchInputMode => 63,
|
||||
SettingsButton::CycleUiScale => 64,
|
||||
// Picker rows — every swatch in a row shares the row's
|
||||
// priority so entity-index tiebreaking yields left → right.
|
||||
SettingsButton::SelectCardBack(_) => 70,
|
||||
@@ -449,6 +458,11 @@ impl Plugin for SettingsPlugin {
|
||||
handle_volume_keys,
|
||||
record_window_geometry_changes,
|
||||
persist_window_geometry_after_debounce,
|
||||
// State sync, not UI — runs even under `headless()`
|
||||
// so the live `bevy::ui::UiScale` always mirrors the
|
||||
// setting; rides the mutator spine so it is ordered
|
||||
// after every settings writer this frame.
|
||||
sync_ui_scale_resource,
|
||||
)
|
||||
.chain()
|
||||
.in_set(SettingsMutation),
|
||||
@@ -492,6 +506,7 @@ impl Plugin for SettingsPlugin {
|
||||
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
|
||||
update_reduce_motion_text,
|
||||
update_touch_input_mode_text,
|
||||
update_ui_scale_text,
|
||||
update_tooltip_delay_text,
|
||||
update_time_bonus_multiplier_text,
|
||||
update_replay_move_interval_text,
|
||||
|
||||
@@ -736,3 +736,60 @@ fn scroll_clamps_offset_to_zero_at_top() {
|
||||
"scrolling past top must clamp to 0, got {offset}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase K: UI scale
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ui_scale_steps_cycle_and_wrap() {
|
||||
assert_eq!(next_ui_scale(0.9), 1.0);
|
||||
assert_eq!(next_ui_scale(1.0), 1.15);
|
||||
assert_eq!(next_ui_scale(1.15), 1.3);
|
||||
assert_eq!(next_ui_scale(1.3), 0.9, "the cycle must wrap");
|
||||
// A hand-edited in-between value advances to the next larger step.
|
||||
assert_eq!(next_ui_scale(1.05), 1.15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_scale_label_formats_as_percent() {
|
||||
assert_eq!(ui_scale_label(0.9), "90%");
|
||||
assert_eq!(ui_scale_label(1.0), "100%");
|
||||
assert_eq!(ui_scale_label(1.15), "115%");
|
||||
assert_eq!(ui_scale_label(1.3), "130%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_scale_setting_syncs_the_bevy_resource() {
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(UiScale(1.0));
|
||||
app.world_mut()
|
||||
.resource_mut::<SettingsResource>()
|
||||
.0
|
||||
.ui_scale = 1.3;
|
||||
app.update();
|
||||
|
||||
assert!(
|
||||
(app.world().resource::<UiScale>().0 - 1.3).abs() < f32::EPSILON,
|
||||
"Settings::ui_scale must drive bevy::ui::UiScale"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_scale_out_of_range_sanitizes_on_load() {
|
||||
use solitaire_data::settings::{UI_SCALE_MAX, UI_SCALE_MIN};
|
||||
|
||||
let wild = Settings {
|
||||
ui_scale: 5.0,
|
||||
..Settings::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(wild.ui_scale, UI_SCALE_MAX);
|
||||
|
||||
let tiny = Settings {
|
||||
ui_scale: 0.1,
|
||||
..Settings::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(tiny.ui_scale, UI_SCALE_MIN);
|
||||
}
|
||||
|
||||
@@ -326,6 +326,15 @@ fn spawn_accessibility_tab(
|
||||
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"UI Scale",
|
||||
UiScaleText,
|
||||
ui_scale_label(settings.ui_scale),
|
||||
SettingsButton::CycleUiScale,
|
||||
"Scales all menus, buttons, and HUD text. The table itself always fits the screen.",
|
||||
font_res,
|
||||
);
|
||||
tooltip_delay_row(body, settings.tooltip_delay_secs, font_res);
|
||||
}
|
||||
|
||||
|
||||
@@ -515,6 +515,58 @@ pub(super) fn touch_input_mode_label(mode: &solitaire_data::settings::TouchInput
|
||||
}
|
||||
}
|
||||
|
||||
/// The four UI-scale steps the settings row cycles through (Phase K).
|
||||
pub(super) const UI_SCALE_STEPS: [f32; 4] = [0.9, 1.0, 1.15, 1.3];
|
||||
|
||||
/// The next UI-scale step after `current`, wrapping 130 % → 90 %. A
|
||||
/// hand-edited value between steps advances to the first step larger
|
||||
/// than it, so the cycle always makes visible progress.
|
||||
pub(super) fn next_ui_scale(current: f32) -> f32 {
|
||||
for step in UI_SCALE_STEPS {
|
||||
if step > current + 0.001 {
|
||||
return step;
|
||||
}
|
||||
}
|
||||
UI_SCALE_STEPS[0]
|
||||
}
|
||||
|
||||
/// Display string for the UI-scale row, e.g. `"115%"`.
|
||||
pub(super) fn ui_scale_label(scale: f32) -> String {
|
||||
format!("{:.0}%", scale * 100.0)
|
||||
}
|
||||
|
||||
/// Refreshes the live UI-scale value text whenever settings change.
|
||||
pub(super) fn update_ui_scale_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<UiScaleText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = ui_scale_label(settings.0.ui_scale);
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies `Settings::ui_scale` to the live [`bevy::ui::UiScale`]
|
||||
/// resource — at startup (first change tick) and whenever the setting
|
||||
/// changes. Absent under `MinimalPlugins` (no `bevy_ui`), hence the
|
||||
/// `Option`; the table is unaffected either way (`compute_layout`
|
||||
/// owns world-space sizing, not UI scale).
|
||||
pub(super) fn sync_ui_scale_resource(
|
||||
settings: Res<SettingsResource>,
|
||||
ui_scale: Option<ResMut<UiScale>>,
|
||||
) {
|
||||
let Some(mut ui_scale) = ui_scale else { return };
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
let target = settings.0.ui_scale;
|
||||
if (ui_scale.0 - target).abs() > f32::EPSILON {
|
||||
ui_scale.0 = target;
|
||||
}
|
||||
}
|
||||
|
||||
/// Display string for the "Smart window size" toggle. The argument
|
||||
/// is the *enabled* state (i.e. the inverse of the underlying
|
||||
/// `disable_smart_default_size` field) so reading the label gives
|
||||
|
||||
@@ -30,7 +30,7 @@ use crate::ui_modal::{ButtonVariant, ModalButton, spawn_modal_button};
|
||||
use crate::ui_theme::{
|
||||
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,
|
||||
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.
|
||||
@@ -719,25 +719,17 @@ pub(crate) fn spawn_stats_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
|
||||
// than as broken state.
|
||||
if is_first_launch {
|
||||
body.spawn((
|
||||
Text::new("Play a game to start tracking stats."),
|
||||
TextFont {
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_SECONDARY),
|
||||
Node {
|
||||
margin: UiRect {
|
||||
bottom: VAL_SPACE_2,
|
||||
..default()
|
||||
},
|
||||
..default()
|
||||
},
|
||||
));
|
||||
crate::ui_modal::spawn_empty_state(
|
||||
body,
|
||||
"\u{2663}",
|
||||
"Play a game to start tracking stats.",
|
||||
None,
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
|
||||
// --- primary stat cells grid ---
|
||||
@@ -893,6 +885,20 @@ pub(crate) fn spawn_replays_body(
|
||||
selected_index: usize,
|
||||
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_row = TextFont {
|
||||
font: font_handle,
|
||||
|
||||
@@ -32,8 +32,8 @@ use crate::resources::TokioRuntimeResource;
|
||||
use crate::settings_plugin::{SettingsPanel, SettingsResource};
|
||||
use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry};
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||
spawn_modal_button, spawn_modal_header,
|
||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal,
|
||||
spawn_modal_actions, spawn_modal_button, spawn_modal_header,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
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 {
|
||||
CatalogState::Idle | CatalogState::Loading => {
|
||||
card.spawn((
|
||||
Text::new("Loading catalog…"),
|
||||
body_font.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
spawn_empty_state(
|
||||
card,
|
||||
"\u{21BB}",
|
||||
"Loading the catalog\u{2026}",
|
||||
None,
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
CatalogState::Error(msg) => {
|
||||
card.spawn((
|
||||
Text::new(format!("Could not load the catalog: {msg}")),
|
||||
body_font.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
spawn_empty_state(
|
||||
card,
|
||||
"!",
|
||||
"Couldn't load the catalog.",
|
||||
Some(msg.as_str()),
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
CatalogState::Loaded(entries) if entries.is_empty() => {
|
||||
card.spawn((
|
||||
Text::new("The server has no themes yet."),
|
||||
body_font.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
spawn_empty_state(
|
||||
card,
|
||||
"#",
|
||||
"The server has no themes yet.",
|
||||
Some("Themes dropped into the server's theme_store folder appear here."),
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
CatalogState::Loaded(entries) => {
|
||||
for entry in entries {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! "Liquid glass" material for floating UI surfaces.
|
||||
//!
|
||||
//! Produces the translucent, light-catching treatment used by the floating
|
||||
//! touch tab bar (`hud_plugin::tab_bar`): a semi-transparent fill with a
|
||||
//! vertical sheen, a specular rim highlight around the border, and a soft
|
||||
//! drop shadow underneath. The table shows through the fill *sharp* — Bevy's
|
||||
//! UI pipeline has no backdrop-blur concept, so this module approximates
|
||||
//! frosted glass with layered gradients instead of sampling what's behind
|
||||
//! the node. That keeps it a plain `bevy_ui` bundle: no custom shaders, no
|
||||
//! extra render passes, and identical behaviour on desktop, Android, and
|
||||
//! WebGL2.
|
||||
//!
|
||||
//! # Swapping the treatment
|
||||
//!
|
||||
//! Layout code depends only on [`glass_surface`] and [`GLASS_BORDER_PX`].
|
||||
//! A future real-refraction material (e.g. a `UiMaterial` sampling a blurred
|
||||
//! render target) can replace this module's internals without touching any
|
||||
//! caller — that boundary is the reason this file exists separately from
|
||||
//! `hud_plugin/tab_bar.rs`.
|
||||
//!
|
||||
//! # Departure from the Terminal design system
|
||||
//!
|
||||
//! `ui_theme` documents HUD chrome as opaque "status-line" panels. The glass
|
||||
//! bar is a deliberate, user-approved exception for the floating touch tab
|
||||
//! bar only; do not reuse this material for modals or the top HUD band
|
||||
//! without a matching design decision.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::ui::{
|
||||
BackgroundGradient, BorderGradient, BoxShadow, ColorStop, LinearGradient, ShadowStyle,
|
||||
};
|
||||
|
||||
/// Border width every glass surface must reserve in its `Node::border` so
|
||||
/// the rim gradient has a strip to paint into.
|
||||
pub const GLASS_BORDER_PX: f32 = 1.0;
|
||||
|
||||
/// Base fill at the *bottom* of the glass sheet — near-black at ~62%
|
||||
/// opacity so the felt and cards remain visible through the bar while
|
||||
/// keeping icon/label contrast comfortable.
|
||||
const GLASS_FILL_BOTTOM: Color = Color::srgba(0.055, 0.055, 0.063, 0.62);
|
||||
|
||||
/// Fill at the *top* of the sheet — the same glass lifted towards white,
|
||||
/// reading as the curved surface catching overhead light.
|
||||
const GLASS_FILL_TOP: Color = Color::srgba(0.20, 0.21, 0.23, 0.66);
|
||||
|
||||
/// Rim highlight at the top edge — the bright specular line where a real
|
||||
/// glass pill would catch the light source.
|
||||
const GLASS_RIM_TOP: Color = Color::srgba(1.0, 1.0, 1.0, 0.38);
|
||||
|
||||
/// Rim at the midpoint — almost gone, so the highlight reads as a glint
|
||||
/// rather than a drawn outline.
|
||||
const GLASS_RIM_MID: Color = Color::srgba(1.0, 1.0, 1.0, 0.06);
|
||||
|
||||
/// Rim at the bottom edge — a faint secondary catch-light, as on the
|
||||
/// underside of a curved surface above a bright table.
|
||||
const GLASS_RIM_BOTTOM: Color = Color::srgba(1.0, 1.0, 1.0, 0.16);
|
||||
|
||||
/// Drop shadow under the floating surface.
|
||||
const GLASS_SHADOW: Color = Color::srgba(0.0, 0.0, 0.0, 0.35);
|
||||
|
||||
/// Visual components for a floating glass surface.
|
||||
///
|
||||
/// The caller owns the `Node` and must set two fields for the material to
|
||||
/// render correctly:
|
||||
/// - `border: UiRect::all(Val::Px(GLASS_BORDER_PX))` — the rim gradient
|
||||
/// paints into the border strip and is invisible without it.
|
||||
/// - `border_radius` — half the node height for a fully-round pill.
|
||||
///
|
||||
/// Everything here is fragment-shader work inside Bevy's stock UI pipeline,
|
||||
/// so it is safe on WebGL2 and adds no per-frame cost beyond ordinary nodes.
|
||||
pub fn glass_surface() -> impl Bundle {
|
||||
(
|
||||
// Sheen: one top-to-bottom linear gradient carries both the fill and
|
||||
// the lighting so there is a single source of truth for the surface
|
||||
// colour (a separate `BackgroundColor` would just be painted over).
|
||||
BackgroundGradient(vec![
|
||||
LinearGradient::new(
|
||||
LinearGradient::TO_BOTTOM,
|
||||
vec![
|
||||
ColorStop::new(GLASS_FILL_TOP, Val::Percent(0.0)),
|
||||
ColorStop::new(GLASS_FILL_BOTTOM, Val::Percent(60.0)),
|
||||
],
|
||||
)
|
||||
.into(),
|
||||
]),
|
||||
// Specular rim: bright at the top edge, fading out through the
|
||||
// sides, with a faint return at the bottom.
|
||||
BorderGradient(vec![
|
||||
LinearGradient::new(
|
||||
LinearGradient::TO_BOTTOM,
|
||||
vec![
|
||||
ColorStop::new(GLASS_RIM_TOP, Val::Percent(0.0)),
|
||||
ColorStop::new(GLASS_RIM_MID, Val::Percent(55.0)),
|
||||
ColorStop::new(GLASS_RIM_BOTTOM, Val::Percent(100.0)),
|
||||
],
|
||||
)
|
||||
.into(),
|
||||
]),
|
||||
// Soft shadow underneath sells the "floating above the table" read.
|
||||
BoxShadow(vec![ShadowStyle {
|
||||
color: GLASS_SHADOW,
|
||||
x_offset: Val::Px(0.0),
|
||||
y_offset: Val::Px(6.0),
|
||||
spread_radius: Val::Px(0.0),
|
||||
blur_radius: Val::Px(16.0),
|
||||
}]),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The bundle must insert all three visual components — a regression
|
||||
/// here (e.g. a refactor dropping the rim) would silently flatten the
|
||||
/// glass into a plain translucent box.
|
||||
#[test]
|
||||
fn glass_surface_inserts_sheen_rim_and_shadow() {
|
||||
let mut world = World::new();
|
||||
let e = world.spawn(glass_surface()).id();
|
||||
assert!(world.get::<BackgroundGradient>(e).is_some());
|
||||
assert!(world.get::<BorderGradient>(e).is_some());
|
||||
assert!(world.get::<BoxShadow>(e).is_some());
|
||||
}
|
||||
|
||||
/// The fill must stay translucent (that is the whole point of glass) but
|
||||
/// opaque enough that text keeps contrast over busy card art.
|
||||
#[test]
|
||||
fn glass_fill_alpha_stays_in_readable_band() {
|
||||
for fill in [GLASS_FILL_TOP, GLASS_FILL_BOTTOM] {
|
||||
let a = fill.alpha();
|
||||
assert!(
|
||||
(0.4..=0.85).contains(&a),
|
||||
"glass fill alpha {a} outside readable 0.4–0.85 band"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The rim gradient must actually glint: strictly brightest at the top,
|
||||
/// dimmest in the middle.
|
||||
#[test]
|
||||
fn rim_highlight_peaks_at_top() {
|
||||
assert!(GLASS_RIM_TOP.alpha() > GLASS_RIM_BOTTOM.alpha());
|
||||
assert!(GLASS_RIM_BOTTOM.alpha() > GLASS_RIM_MID.alpha());
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,9 @@ use crate::settings_plugin::SettingsResource;
|
||||
use crate::ui_theme::{
|
||||
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,
|
||||
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_PRIMARY,
|
||||
TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
|
||||
VAL_SPACE_4, VAL_SPACE_5, scaled_duration,
|
||||
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_DISABLED,
|
||||
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_DISPLAY,
|
||||
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)));
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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