Files
Ferrous-Solitaire/solitaire_engine/src/ui_glass.rs
T
funman300 c2256582cd feat(engine): reduce-transparency + high-contrast support for glass chrome
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>
2026-07-15 15:16:32 -07:00

282 lines
11 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.
//! "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.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());
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());
}
}