use crate::style::{Border, CornerRadius, Paint, Style};
use crate::theme::Theme;
use crate::tokens::{R_FULL, R_LG, R_SM, R_XL, ShadowToken};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Surface {
Card,
Raised,
Popover,
Menu,
Modal,
Thumb,
Tooltip,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum SurfaceRadius {
Uniform(f32),
}
impl SurfaceRadius {
pub const fn outer(self) -> f32 {
match self {
SurfaceRadius::Uniform(r) => r,
}
}
pub fn inner(self, inset: f32) -> f32 {
(self.outer() - inset).max(0.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SurfaceFill {
SurfaceRaised,
Elevated(u8),
Inverted,
}
impl SurfaceFill {
pub fn color(self, theme: &Theme) -> crate::Color {
match self {
SurfaceFill::SurfaceRaised => theme.surface_raised,
SurfaceFill::Elevated(level) => theme.elevated_surface(level),
SurfaceFill::Inverted => theme.neutrals.step(12),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SurfaceBorder {
None,
Subtle,
Default,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SurfaceBundle {
pub radius: SurfaceRadius,
pub fill: SurfaceFill,
pub border: SurfaceBorder,
pub shadow: Option<ShadowToken>,
pub highlight: Option<f32>,
}
impl Surface {
pub const fn bundle(self) -> SurfaceBundle {
match self {
Surface::Card => SurfaceBundle {
radius: SurfaceRadius::Uniform(R_LG),
fill: SurfaceFill::SurfaceRaised,
border: SurfaceBorder::Subtle,
shadow: Some(ShadowToken::Sm),
highlight: None,
},
Surface::Raised => SurfaceBundle {
radius: SurfaceRadius::Uniform(R_LG),
fill: SurfaceFill::SurfaceRaised,
border: SurfaceBorder::Subtle,
shadow: None,
highlight: None,
},
Surface::Popover | Surface::Menu => SurfaceBundle {
radius: SurfaceRadius::Uniform(R_LG),
fill: SurfaceFill::Elevated(2),
border: SurfaceBorder::Subtle,
shadow: Some(ShadowToken::Lg),
highlight: None,
},
Surface::Modal => SurfaceBundle {
radius: SurfaceRadius::Uniform(R_XL),
fill: SurfaceFill::Elevated(2),
border: SurfaceBorder::Subtle,
shadow: Some(ShadowToken::Xl),
highlight: None,
},
Surface::Thumb => SurfaceBundle {
radius: SurfaceRadius::Uniform(R_FULL),
fill: SurfaceFill::SurfaceRaised,
border: SurfaceBorder::Default,
shadow: Some(ShadowToken::Sm),
highlight: None,
},
Surface::Tooltip => SurfaceBundle {
radius: SurfaceRadius::Uniform(R_SM),
fill: SurfaceFill::Inverted,
border: SurfaceBorder::None,
shadow: Some(ShadowToken::Md),
highlight: None,
},
}
}
pub const fn is_floating(self) -> bool {
matches!(self, Surface::Popover | Surface::Menu | Surface::Modal)
}
}
impl SurfaceBundle {
#[must_use]
pub fn apply(self, theme: &Theme, base: Style) -> Style {
let mut style = base;
style.corner_radius = CornerRadius::all(self.radius.outer());
style.fill = Some(Paint::Solid(self.fill.color(theme)));
style.border = match self.border {
SurfaceBorder::None => None,
SurfaceBorder::Subtle => Some(Border {
width: 1.0,
color: theme.border_subtle,
}),
SurfaceBorder::Default => Some(Border {
width: 1.0,
color: theme.border,
}),
};
style.shadow_token = self.shadow;
style.highlight_top = self
.highlight
.map(|a| crate::theme::oklch(1.0, 0.0, 0.0).with_alpha(a));
style
}
}
impl Theme {
pub fn surface_style(&self, role: Surface) -> Style {
role.bundle().apply(self, Style::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::{Border, CornerRadius, Edges, Paint};
use crate::tokens::{R_FULL, R_LG, R_MD, R_XL, SP1, ShadowToken};
#[test]
fn card_bundle_is_resting_raised() {
let b = Surface::Card.bundle();
assert_eq!(b.radius, SurfaceRadius::Uniform(R_LG));
assert_eq!(b.fill, SurfaceFill::SurfaceRaised);
assert_eq!(b.border, SurfaceBorder::Subtle);
assert_eq!(b.shadow, Some(ShadowToken::Sm));
assert_eq!(b.highlight, None);
}
#[test]
fn floating_roles_use_elevated_fill_and_deep_shadows() {
for role in [Surface::Popover, Surface::Menu] {
assert_eq!(role.bundle().fill, SurfaceFill::Elevated(2));
assert_eq!(role.bundle().shadow, Some(ShadowToken::Lg));
}
let m = Surface::Modal.bundle();
assert_eq!(m.fill, SurfaceFill::Elevated(2));
assert_eq!(m.shadow, Some(ShadowToken::Xl));
assert_eq!(m.radius.outer(), R_XL);
}
#[test]
fn ordering_invariant_floating_ge_resting() {
for f in [Surface::Popover, Surface::Menu, Surface::Modal] {
for r in [Surface::Card, Surface::Raised] {
assert!(
f.bundle().radius.outer() >= r.bundle().radius.outer(),
"{f:?} radius must be >= {r:?}"
);
assert!(
f.bundle().shadow >= r.bundle().shadow,
"{f:?} shadow must be >= {r:?}"
);
}
}
}
#[test]
fn resolve_uses_theme_roles_not_literals() {
for t in [Theme::light(), Theme::dark()] {
let card = Surface::Card.bundle().apply(&t, Style::default());
assert_eq!(card.fill, Some(Paint::Solid(t.surface_raised)));
assert_eq!(
card.border,
Some(Border {
width: 1.0,
color: t.border_subtle,
})
);
assert_eq!(card.shadow_token, Some(ShadowToken::Sm));
assert_eq!(card.corner_radius, CornerRadius::all(R_LG));
let menu = Surface::Menu.bundle().apply(&t, Style::default());
assert_eq!(menu.fill, Some(Paint::Solid(t.elevated_surface(2))));
let tip = Surface::Tooltip.bundle().apply(&t, Style::default());
assert_eq!(tip.fill, Some(Paint::Solid(t.neutrals.step(12))));
assert_eq!(tip.border, None);
}
}
#[test]
fn highlight_resolves_to_low_alpha_white() {
let t = Theme::light();
let bundle = SurfaceBundle {
highlight: Some(0.14),
..Surface::Card.bundle()
};
let s = bundle.apply(&t, Style::default());
let c = s.highlight_top.expect("highlight set");
let [r, g, b, a] = c.components;
assert!((a - 0.14).abs() < 1e-4, "alpha {a}");
assert!(
(r - g).abs() < 1e-3 && (g - b).abs() < 1e-3,
"achromatic: {r} {g} {b}"
);
assert!(r > 0.9, "near-white: {r}");
}
#[test]
fn thumb_is_a_pill_with_a_firm_border() {
let b = Surface::Thumb.bundle();
assert_eq!(b.radius.outer(), R_FULL);
assert_eq!(b.border, SurfaceBorder::Default);
assert_eq!(b.shadow, Some(ShadowToken::Sm));
}
#[test]
fn surface_radius_is_concentric_ready() {
assert!(matches!(
Surface::Card.bundle().radius,
SurfaceRadius::Uniform(_)
));
}
#[test]
fn concentric_inner_is_outer_minus_inset() {
assert_eq!(SurfaceRadius::Uniform(14.0).inner(4.0), 10.0);
assert_eq!(SurfaceRadius::Uniform(2.0).inner(8.0), 0.0);
}
#[test]
fn menu_item_radius_is_concentric_with_panel() {
let r = Surface::Menu.bundle().radius;
assert_eq!(r.inner(SP1), r.outer() - SP1);
assert_eq!(r.inner(SP1), R_MD);
}
#[test]
fn theme_surface_style_equals_bundle_apply() {
let t = Theme::light();
assert_eq!(
t.surface_style(Surface::Modal),
Surface::Modal.bundle().apply(&t, Style::default())
);
}
#[test]
fn surface_apply_preserves_layout() {
let t = Theme::light();
let s = Surface::Card.bundle().apply(&t, Style::default().p(8.0));
assert_eq!(s.padding, Edges::all(8.0));
assert!(s.fill.is_some());
}
}