//! 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, draw: Handle, hint: Handle, pause: Handle, menu: Handle, } 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) -> Option { 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( pill: &mut ChildSpawnerCommands, marker: M, label: &str, tooltip: &'static str, font: &TextFont, order: i32, icon: Option>, 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, 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>, 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