feat(engine): replace docked touch bar with floating glass tab bar
Test / fmt (pull_request) Successful in 7s
Test / test (pull_request) Successful in 4m50s

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>
This commit is contained in:
funman300
2026-07-15 13:57:21 -07:00
parent 7c2daa1e32
commit c8fc3e7a09
8 changed files with 598 additions and 135 deletions
+470
View File
@@ -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,
};
}
}