c2256582cd
New Settings::reduce_transparency_mode (serde-default, Accessibility toggle row) swaps every GlassSurface's gradients for a flat opaque BG_ELEVATED fill; high-contrast mode now also boosts the glass fill opacity and rim luminance to BORDER_SUBTLE_HC levels. Both applied by settings_plugin::update_glass_surfaces, which retargets the gradients in place on toggle or on newly spawned glass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
282 lines
11 KiB
Rust
282 lines
11 KiB
Rust
//! "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,
|
||
};
|
||
|
||
use crate::ui_theme::{BG_ELEVATED, BORDER_SUBTLE, BORDER_SUBTLE_HC};
|
||
|
||
/// 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;
|
||
|
||
/// Marker on every node that received [`glass_surface`]. The settings
|
||
/// plugin's `update_glass_surfaces` retargets these when the player
|
||
/// toggles reduce-transparency or high-contrast, swapping the gradients
|
||
/// in place via [`glass_decorations`].
|
||
#[derive(Component, Debug)]
|
||
pub struct GlassSurface;
|
||
|
||
/// 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);
|
||
|
||
/// High-contrast fill — same glass, far less see-through, so text and
|
||
/// icons keep contrast over any card art. Per `design-system.md`
|
||
/// §Accessibility the HC toggle trades aesthetics for legibility.
|
||
const GLASS_FILL_TOP_HC: Color = Color::srgba(0.20, 0.21, 0.23, 0.90);
|
||
/// High-contrast fill at the bottom of the sheet.
|
||
const GLASS_FILL_BOTTOM_HC: Color = Color::srgba(0.055, 0.055, 0.063, 0.88);
|
||
|
||
/// High-contrast rim at the top edge — matches the luminance of
|
||
/// `BORDER_SUBTLE_HC` so the bar's outline reads as strongly as every
|
||
/// other HC-boosted border.
|
||
const GLASS_RIM_TOP_HC: Color = Color::srgba(1.0, 1.0, 1.0, 0.75);
|
||
/// High-contrast rim at the midpoint.
|
||
const GLASS_RIM_MID_HC: Color = Color::srgba(1.0, 1.0, 1.0, 0.35);
|
||
/// High-contrast rim at the bottom edge.
|
||
const GLASS_RIM_BOTTOM_HC: Color = Color::srgba(1.0, 1.0, 1.0, 0.50);
|
||
|
||
/// 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 {
|
||
let (sheen, rim) = glass_decorations(false, false);
|
||
(
|
||
GlassSurface,
|
||
sheen,
|
||
rim,
|
||
// 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),
|
||
}]),
|
||
)
|
||
}
|
||
|
||
/// The sheen + rim pair for the requested accessibility state. The
|
||
/// settings plugin overwrites every [`GlassSurface`]'s components with
|
||
/// these when the relevant toggles change.
|
||
///
|
||
/// - Default: translucent fill with a top-lit sheen and a specular rim.
|
||
/// - `high_contrast`: same shape, near-opaque fill, and a rim boosted to
|
||
/// `BORDER_SUBTLE_HC` luminance so the outline stays legible.
|
||
/// - `reduce_transparency`: flat opaque `BG_ELEVATED` fill and a flat
|
||
/// border — no see-through at all. Combined with `high_contrast` the
|
||
/// flat border brightens to `BORDER_SUBTLE_HC`.
|
||
pub fn glass_decorations(
|
||
reduce_transparency: bool,
|
||
high_contrast: bool,
|
||
) -> (BackgroundGradient, BorderGradient) {
|
||
if reduce_transparency {
|
||
// "Gradients" with a single stop render as flat fills — reusing the
|
||
// same component types means the settings toggle swaps values, not
|
||
// component sets.
|
||
let border = if high_contrast {
|
||
BORDER_SUBTLE_HC
|
||
} else {
|
||
BORDER_SUBTLE
|
||
};
|
||
return (
|
||
BackgroundGradient(vec![
|
||
LinearGradient::new(
|
||
LinearGradient::TO_BOTTOM,
|
||
vec![ColorStop::new(BG_ELEVATED, Val::Percent(0.0))],
|
||
)
|
||
.into(),
|
||
]),
|
||
BorderGradient(vec![
|
||
LinearGradient::new(
|
||
LinearGradient::TO_BOTTOM,
|
||
vec![ColorStop::new(border, Val::Percent(0.0))],
|
||
)
|
||
.into(),
|
||
]),
|
||
);
|
||
}
|
||
|
||
let (fill_top, fill_bottom, rim_top, rim_mid, rim_bottom) = if high_contrast {
|
||
(
|
||
GLASS_FILL_TOP_HC,
|
||
GLASS_FILL_BOTTOM_HC,
|
||
GLASS_RIM_TOP_HC,
|
||
GLASS_RIM_MID_HC,
|
||
GLASS_RIM_BOTTOM_HC,
|
||
)
|
||
} else {
|
||
(
|
||
GLASS_FILL_TOP,
|
||
GLASS_FILL_BOTTOM,
|
||
GLASS_RIM_TOP,
|
||
GLASS_RIM_MID,
|
||
GLASS_RIM_BOTTOM,
|
||
)
|
||
};
|
||
(
|
||
// 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(fill_top, Val::Percent(0.0)),
|
||
ColorStop::new(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(rim_top, Val::Percent(0.0)),
|
||
ColorStop::new(rim_mid, Val::Percent(55.0)),
|
||
ColorStop::new(rim_bottom, Val::Percent(100.0)),
|
||
],
|
||
)
|
||
.into(),
|
||
]),
|
||
)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use bevy::ui::Gradient;
|
||
|
||
/// 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.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());
|
||
assert!(GLASS_RIM_TOP_HC.alpha() > GLASS_RIM_BOTTOM_HC.alpha());
|
||
assert!(GLASS_RIM_BOTTOM_HC.alpha() > GLASS_RIM_MID_HC.alpha());
|
||
}
|
||
|
||
/// Reduce-transparency must produce fully opaque fills — the entire
|
||
/// point of the toggle is that nothing shows through.
|
||
#[test]
|
||
fn reduce_transparency_is_fully_opaque() {
|
||
for high_contrast in [false, true] {
|
||
let (sheen, _) = glass_decorations(true, high_contrast);
|
||
for gradient in &sheen.0 {
|
||
let Gradient::Linear(linear) = gradient else {
|
||
panic!("reduce-transparency sheen must stay linear");
|
||
};
|
||
for stop in &linear.stops {
|
||
assert_eq!(
|
||
stop.color.alpha(),
|
||
1.0,
|
||
"opaque variant leaked translucency (hc={high_contrast})"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// High-contrast glass must be meaningfully less transparent than the
|
||
/// default, and its rim meaningfully brighter — otherwise the toggle
|
||
/// does nothing perceptible on this surface.
|
||
#[test]
|
||
fn high_contrast_boosts_fill_and_rim() {
|
||
assert!(GLASS_FILL_TOP_HC.alpha() >= GLASS_FILL_TOP.alpha() + 0.15);
|
||
assert!(GLASS_FILL_BOTTOM_HC.alpha() >= GLASS_FILL_BOTTOM.alpha() + 0.15);
|
||
assert!(GLASS_RIM_TOP_HC.alpha() >= GLASS_RIM_TOP.alpha() + 0.25);
|
||
}
|
||
|
||
/// `glass_surface()` must carry the marker the settings applier
|
||
/// retargets — without it the accessibility toggles silently skip
|
||
/// the bar.
|
||
#[test]
|
||
fn glass_surface_carries_retarget_marker() {
|
||
let mut world = World::new();
|
||
let e = world.spawn(glass_surface()).id();
|
||
assert!(world.get::<GlassSurface>(e).is_some());
|
||
}
|
||
}
|