From 37919cfe17e6c4c46f37cecc043d4d0f999626ce Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 15 Jul 2026 13:40:39 -0700 Subject: [PATCH 1/3] feat(engine): add ui_glass liquid-glass material module Layered bevy_ui treatment (sheen gradient, specular BorderGradient rim, BoxShadow) approximating frosted glass without shaders or backdrop sampling - WebGL2-safe by construction. Layout callers depend only on glass_surface() + GLASS_BORDER_PX so the material can be swapped later. Co-Authored-By: Claude Fable 5 --- solitaire_engine/src/lib.rs | 1 + solitaire_engine/src/ui_glass.rs | 146 +++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 solitaire_engine/src/ui_glass.rs diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 35f7fd2..0ec1a4b 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -63,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; diff --git a/solitaire_engine/src/ui_glass.rs b/solitaire_engine/src/ui_glass.rs new file mode 100644 index 0000000..4a773c8 --- /dev/null +++ b/solitaire_engine/src/ui_glass.rs @@ -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::(e).is_some()); + assert!(world.get::(e).is_some()); + assert!(world.get::(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()); + } +} From 7c2daa1e3221e2d6767b14128e8726fc66a0f1a8 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 15 Jul 2026 13:42:37 -0700 Subject: [PATCH 2/3] feat(engine): add stroke-based SVG icon set for the touch tab bar Five white 24-box stroke icons (undo/draw/hint/pause/menu) rasterised through the existing usvg/resvg pipeline; tinted at spawn time via ImageNode::color. Avoids FiraMono's missing icon glyphs on Android. Co-Authored-By: Claude Fable 5 --- solitaire_engine/src/assets/hud_icon_svg.rs | 130 ++++++++++++++++++++ solitaire_engine/src/assets/mod.rs | 1 + 2 files changed, 131 insertions(+) create mode 100644 solitaire_engine/src/assets/hud_icon_svg.rs diff --git a/solitaire_engine/src/assets/hud_icon_svg.rs b/solitaire_engine/src/assets/hud_icon_svg.rs new file mode 100644 index 0000000..b8a7e40 --- /dev/null +++ b/solitaire_engine/src/assets/hud_icon_svg.rs @@ -0,0 +1,130 @@ +//! 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##""##, + r##""##, + ), + // Back card peeks out behind the front card; the front card is + // drawn second so its stroke reads on top. + HudIcon::Draw => concat!( + r##""##, + r##""##, + ), + // Bulb dome with a flat lamp base beneath. + HudIcon::Hint => concat!( + r##""##, + r##""##, + ), + HudIcon::Pause => concat!( + r##""##, + r##""##, + ), + HudIcon::Menu => concat!( + r##""##, + r##""##, + r##""##, + ), + }; + format!( + concat!( + r##""##, + r##"{body}"##, + r##""##, + ), + body = body + ) + } + + /// Rasterise this icon into a Bevy [`Image`] at + /// [`HUD_ICON_TEXTURE_PX`] square. + pub fn rasterize(self) -> Result { + 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" + ); + } + } +} diff --git a/solitaire_engine/src/assets/mod.rs b/solitaire_engine/src/assets/mod.rs index 7b11934..21d946a 100644 --- a/solitaire_engine/src/assets/mod.rs +++ b/solitaire_engine/src/assets/mod.rs @@ -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; From c8fc3e7a0982d06d5464c8f0a7851e83132fbe5a Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 15 Jul 2026 13:57:21 -0700 Subject: [PATCH 3/3] 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 --- solitaire_engine/src/animation_plugin.rs | 11 +- solitaire_engine/src/assets/hud_icon_svg.rs | 5 +- solitaire_engine/src/hud_plugin/fx.rs | 11 +- solitaire_engine/src/hud_plugin/mod.rs | 8 + solitaire_engine/src/hud_plugin/spawn.rs | 120 +---- solitaire_engine/src/hud_plugin/tab_bar.rs | 470 ++++++++++++++++++++ solitaire_engine/src/hud_plugin/tests.rs | 94 +++- solitaire_engine/src/hud_plugin/updates.rs | 14 - 8 files changed, 598 insertions(+), 135 deletions(-) create mode 100644 solitaire_engine/src/hud_plugin/tab_bar.rs diff --git a/solitaire_engine/src/animation_plugin.rs b/solitaire_engine/src/animation_plugin.rs index b71c432..b1d5bef 100644 --- a/solitaire_engine/src/animation_plugin.rs +++ b/solitaire_engine/src/animation_plugin.rs @@ -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) { diff --git a/solitaire_engine/src/assets/hud_icon_svg.rs b/solitaire_engine/src/assets/hud_icon_svg.rs index b8a7e40..3e5713c 100644 --- a/solitaire_engine/src/assets/hud_icon_svg.rs +++ b/solitaire_engine/src/assets/hud_icon_svg.rs @@ -82,10 +82,7 @@ impl HudIcon { /// Rasterise this icon into a Bevy [`Image`] at /// [`HUD_ICON_TEXTURE_PX`] square. pub fn rasterize(self) -> Result { - rasterize_svg( - self.svg().as_bytes(), - UVec2::splat(HUD_ICON_TEXTURE_PX), - ) + rasterize_svg(self.svg().as_bytes(), UVec2::splat(HUD_ICON_TEXTURE_PX)) } } diff --git a/solitaire_engine/src/hud_plugin/fx.rs b/solitaire_engine/src/hud_plugin/fx.rs index d58ec9c..5308103 100644 --- a/solitaire_engine/src/hud_plugin/fx.rs +++ b/solitaire_engine/src/hud_plugin/fx.rs @@ -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, Changed), + ( + With, + Without, + Changed, + ), >, ) { for (interaction, mut bg) in &mut buttons { diff --git a/solitaire_engine/src/hud_plugin/mod.rs b/solitaire_engine/src/hud_plugin/mod.rs index ffd8448..a2ef864 100644 --- a/solitaire_engine/src/hud_plugin/mod.rs +++ b/solitaire_engine/src/hud_plugin/mod.rs @@ -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) diff --git a/solitaire_engine/src/hud_plugin/spawn.rs b/solitaire_engine/src/hud_plugin/spawn.rs index d07758c..1d227fb 100644 --- a/solitaire_engine/src/hud_plugin/spawn.rs +++ b/solitaire_engine/src/hud_plugin/spawn.rs @@ -360,6 +360,7 @@ pub(super) fn handle_avatar_button( pub(super) fn spawn_action_buttons( font_res: Option>, windows: Query<&Window>, + images: Option>>, 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` 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( ); } -/// 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( - 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( row: &mut ChildSpawnerCommands, diff --git a/solitaire_engine/src/hud_plugin/tab_bar.rs b/solitaire_engine/src/hud_plugin/tab_bar.rs new file mode 100644 index 0000000..5d3ef8c --- /dev/null +++ b/solitaire_engine/src/hud_plugin/tab_bar.rs @@ -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, + 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