c8fc3e7a09
Touch layout only (desktop bar unchanged): a floating glass pill (Undo / Draw / Hint / Pause) plus a detached circular Menu button, margin above the bottom edge on top of the safe-area inset. Draw is the persistently expanded accent pill (icon + label); other buttons are icon-only and slide their label out while pressed, snapping under reduce-motion. Buttons keep the existing marker components so click handlers, tooltips, focus ring, and chrome toggle work unchanged. Icons: None degrades to text-fallback labels (MinimalPlugins tests / SVG regression), keeping the bar fully usable. Toast stack clearance now derives from TAB_BAR_CLEARANCE_PX; the legacy touch bar spawn path and primary-button metrics are removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
471 lines
18 KiB
Rust
471 lines
18 KiB
Rust
//! 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,
|
||
};
|
||
}
|
||
}
|