Files
Ferrous-Solitaire/solitaire_engine/src/hud_plugin/tab_bar.rs
T
funman300 c8fc3e7a09
Test / fmt (pull_request) Successful in 7s
Test / test (pull_request) Successful in 4m50s
feat(engine): replace docked touch bar with floating glass tab bar
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>
2026-07-15 13:57:21 -07:00

471 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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,
};
}
}