feat(engine): floating liquid-glass touch tab bar #186

Merged
funman300 merged 3 commits from feat/glass-tab-bar into master 2026-07-15 21:59:21 +00:00
2 changed files with 147 additions and 0 deletions
Showing only changes of commit 37919cfe17 - Show all commits
+1
View File
@@ -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;
+146
View File
@@ -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::<BackgroundGradient>(e).is_some());
assert!(world.get::<BorderGradient>(e).is_some());
assert!(world.get::<BoxShadow>(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.40.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());
}
}