use crate::core::{Color, Rect};
use crate::render::bevel::{Bevel, BevelDirection};
use alloc::vec::Vec;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Hash)]
pub enum Elevation {
#[default]
Flat,
Level1,
Level2,
Level3,
Level4,
}
impl Elevation {
pub const fn level(self) -> u8 {
match self {
Self::Flat => 0,
Self::Level1 => 1,
Self::Level2 => 2,
Self::Level3 => 3,
Self::Level4 => 4,
}
}
pub const fn from_level(level: u8) -> Self {
match level {
0 => Self::Flat,
1 => Self::Level1,
2 => Self::Level2,
3 => Self::Level3,
_ => Self::Level4,
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Flat => "flat",
Self::Level1 => "level1",
Self::Level2 => "level2",
Self::Level3 => "level3",
Self::Level4 => "level4",
}
}
pub fn parse(token: &str) -> Option<Self> {
match token {
"flat" | "none" | "0" => Some(Self::Flat),
"level1" | "1" => Some(Self::Level1),
"level2" | "2" => Some(Self::Level2),
"level3" | "3" => Some(Self::Level3),
"level4" | "4" => Some(Self::Level4),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ElevationShadow {
pub y: i32,
pub blur: u32,
pub alpha: u8,
}
pub const fn default_shadow(elevation: Elevation) -> Option<ElevationShadow> {
match elevation {
Elevation::Flat => None,
Elevation::Level1 => Some(ElevationShadow { y: 1, blur: 3, alpha: 40 }),
Elevation::Level2 => Some(ElevationShadow { y: 2, blur: 6, alpha: 60 }),
Elevation::Level3 => Some(ElevationShadow { y: 4, blur: 12, alpha: 70 }),
Elevation::Level4 => Some(ElevationShadow { y: 8, blur: 24, alpha: 85 }),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Material {
#[default]
Solid,
Translucent,
}
impl Material {
pub const fn as_str(self) -> &'static str {
match self {
Self::Solid => "solid",
Self::Translucent => "translucent",
}
}
pub fn parse(token: &str) -> Option<Self> {
match token {
"solid" | "opaque" => Some(Self::Solid),
"translucent" | "translucent_material" => Some(Self::Translucent),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Hairline {
#[default]
Outline,
Shadow,
None,
}
impl Hairline {
pub const fn as_str(self) -> &'static str {
match self {
Self::Outline => "outline",
Self::Shadow => "shadow",
Self::None => "none",
}
}
pub fn parse(token: &str) -> Option<Self> {
match token {
"outline" | "stroke" => Some(Self::Outline),
"shadow" => Some(Self::Shadow),
"none" => Some(Self::None),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BevelSpec {
pub direction: BevelDirection,
pub base: Option<Color>,
}
impl BevelSpec {
pub const fn new(direction: BevelDirection) -> Self {
Self { direction, base: None }
}
pub const fn from_base(direction: BevelDirection, base: Color) -> Self {
Self { direction, base: Some(base) }
}
pub fn resolve(self, border: Color) -> Bevel {
let base = self.base.unwrap_or(border);
Bevel::from_base(base).with_direction(self.direction)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SurfaceStyle {
pub elevation: Elevation,
pub bevel: Option<BevelSpec>,
pub material: Material,
pub hairline: Hairline,
}
impl SurfaceStyle {
pub const fn solid() -> Self {
Self {
elevation: Elevation::Flat,
bevel: None,
material: Material::Solid,
hairline: Hairline::Outline,
}
}
pub const fn elevated(mut self, elevation: Elevation) -> Self {
self.elevation = elevation;
self
}
pub const fn beveled(mut self, direction: BevelDirection) -> Self {
self.bevel = Some(BevelSpec::new(direction));
self
}
pub const fn beveled_from(mut self, direction: BevelDirection, base: Color) -> Self {
self.bevel = Some(BevelSpec::from_base(direction, base));
self
}
pub const fn translucent(mut self) -> Self {
self.material = Material::Translucent;
self
}
pub const fn hairline_shadow(mut self) -> Self {
self.hairline = Hairline::Shadow;
self
}
pub const fn is_identity(&self) -> bool {
self.elevation.level() == 0
&& self.bevel.is_none()
&& matches!(self.material, Material::Solid)
&& matches!(self.hairline, Hairline::Outline)
}
pub const fn is_raised(&self) -> bool {
self.elevation.level() != 0
}
pub const fn fill_alpha(&self) -> u8 {
match self.material {
Material::Solid => u8::MAX,
Material::Translucent => 230,
}
}
pub fn apply_fill(&self, color: Color) -> Color {
match self.material {
Material::Solid => color,
Material::Translucent => color.with_alpha(self.fill_alpha()),
}
}
pub const fn draws_outline(&self) -> bool {
matches!(self.hairline, Hairline::Outline)
}
pub const fn draws_shadow(&self) -> bool {
self.is_raised()
}
pub fn parse_elevation(token: &str) -> Option<Elevation> {
Elevation::parse(token)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SurfaceShadow {
pub x: i32,
pub y: i32,
pub blur: u32,
pub color: Color,
}
impl SurfaceStyle {
pub fn shadow(&self, tint: Color) -> Option<SurfaceShadow> {
if !self.draws_shadow() {
return None;
}
default_shadow(self.elevation).map(|s| SurfaceShadow {
x: 0,
y: s.y,
blur: s.blur,
color: tint.with_alpha(s.alpha),
})
}
}
pub fn role_surface_style(kind_name: &str) -> SurfaceStyle {
let normalized: Vec<u8> = kind_name
.bytes()
.filter(|b| !matches!(b, b'_' | b'-' | b' '))
.map(|b| b.to_ascii_lowercase())
.collect();
let name = core::str::from_utf8(&normalized).unwrap_or("");
match name {
"card" => SurfaceStyle::solid().elevated(Elevation::Level1),
"toast" => SurfaceStyle::solid().elevated(Elevation::Level3).translucent(),
"tooltip" => SurfaceStyle::solid().elevated(Elevation::Level4),
"popup" | "popover" | "menu" | "contextmenu" | "dropdown" | "combobox" => {
SurfaceStyle::solid().elevated(Elevation::Level2)
}
"dialog" | "modal" | "drawer" | "sheet" => {
SurfaceStyle::solid().elevated(Elevation::Level3).hairline_shadow()
}
"button" | "pushbutton" | "togglebutton" | "toolbutton" => {
SurfaceStyle::solid().beveled(BevelDirection::Raised)
}
"lineedit" | "textedit" | "textarea" | "spinbox" | "combobox_edit" => {
SurfaceStyle::solid().beveled(BevelDirection::Inset)
}
_ => SurfaceStyle::solid().elevated(Elevation::Level2),
}
}
pub fn bevel_fits(rect: Rect) -> bool {
rect.width >= 4 && rect.height >= 4
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn solid_is_the_identity_element() {
let s = SurfaceStyle::solid();
assert_eq!(s.elevation, Elevation::Flat);
assert!(s.bevel.is_none());
assert_eq!(s.material, Material::Solid);
assert_eq!(s.hairline, Hairline::Outline);
assert!(s.is_identity(), "solid() must be the identity");
assert!(!s.is_raised());
assert!(!s.draws_shadow());
assert!(s.draws_outline());
assert_eq!(s.fill_alpha(), u8::MAX, "a solid face is not faded");
}
#[test]
fn default_is_the_identity_element() {
assert_eq!(SurfaceStyle::default(), SurfaceStyle::solid());
assert!(SurfaceStyle::default().is_identity());
}
#[test]
fn builders_are_orthogonal() {
let raised = SurfaceStyle::solid().elevated(Elevation::Level2);
assert!(raised.is_raised() && raised.bevel.is_none());
assert!(raised.draws_shadow() && raised.draws_outline());
assert_eq!(raised.material, Material::Solid);
let beveled = SurfaceStyle::solid().beveled(BevelDirection::Raised);
assert!(!beveled.is_raised(), "a bevel alone does not raise a face");
assert!(!beveled.draws_shadow(), "so it casts no shadow");
assert_eq!(
beveled.bevel.map(|b| b.direction),
Some(BevelDirection::Raised),
"but the direction is recorded"
);
let glassy = SurfaceStyle::solid().translucent();
assert_eq!(glassy.material, Material::Translucent);
assert!(!glassy.is_raised() && glassy.bevel.is_none());
assert!(!glassy.is_identity(), "a material change is not the identity");
let edgeless = SurfaceStyle::solid().hairline_shadow();
assert_eq!(edgeless.hairline, Hairline::Shadow);
assert!(!edgeless.draws_outline());
assert!(!edgeless.is_identity());
}
#[test]
fn the_identity_casts_no_shadow() {
assert!(SurfaceStyle::solid().shadow(Color::BLACK).is_none());
assert!(
SurfaceStyle::solid().beveled(BevelDirection::Raised).shadow(Color::BLACK).is_none(),
"a flat beveled face is still flat — the bevel is not a raise"
);
}
#[test]
fn the_elevation_ladder_is_monotone() {
let levels = [Elevation::Level1, Elevation::Level2, Elevation::Level3, Elevation::Level4];
let mut previous = default_shadow(Elevation::Flat);
assert!(previous.is_none(), "flat casts nothing");
for level in levels {
let shadow = default_shadow(level).expect("a raised level casts a shadow");
if let Some(prev) = previous {
assert!(shadow.y > prev.y, "{level:?} must sit further down than the level below");
assert!(shadow.blur > prev.blur, "{level:?} must blur more than the level below");
assert!(shadow.alpha > prev.alpha, "{level:?} must be more opaque than below");
}
previous = Some(shadow);
}
}
#[test]
fn translucent_surfaces_share_one_alpha() {
let glassy = SurfaceStyle::solid().translucent();
assert!(glassy.fill_alpha() < u8::MAX);
let a = glassy.apply_fill(Color::WHITE);
let b = glassy.apply_fill(Color::BLACK);
assert_eq!(a.to_i32().3, b.to_i32().3);
assert_eq!(a.to_i32().3, glassy.fill_alpha() as i32);
let solid = SurfaceStyle::solid();
assert_eq!(solid.apply_fill(Color::WHITE).to_i32(), Color::WHITE.to_i32());
}
#[test]
fn unknown_tokens_are_rejected() {
assert_eq!(Elevation::parse("sunken"), None);
assert_eq!(Elevation::parse(""), None);
assert_eq!(Elevation::parse("Level1"), None, "tokens are lower-case");
assert_eq!(Material::parse("frosted"), None);
assert_eq!(Hairline::parse("double"), None);
}
#[test]
fn tokens_round_trip() {
for level in [
Elevation::Flat,
Elevation::Level1,
Elevation::Level2,
Elevation::Level3,
Elevation::Level4,
] {
assert_eq!(Elevation::parse(level.as_str()), Some(level));
assert_eq!(Elevation::from_level(level.level()), level);
}
for material in [Material::Solid, Material::Translucent] {
assert_eq!(Material::parse(material.as_str()), Some(material));
}
for hairline in [Hairline::Outline, Hairline::Shadow, Hairline::None] {
assert_eq!(Hairline::parse(hairline.as_str()), Some(hairline));
}
}
#[test]
fn from_level_saturates() {
assert_eq!(Elevation::from_level(9), Elevation::Level4);
assert_eq!(Elevation::from_level(u8::MAX), Elevation::Level4);
assert_eq!(Elevation::from_level(0), Elevation::Flat);
}
#[test]
fn flat_and_skeuomorphic_differ_in_geometry_not_only_colour() {
let flat_card = SurfaceStyle::solid().elevated(Elevation::Level1);
let beveled_button = SurfaceStyle::solid().beveled(BevelDirection::Raised);
let card_shadow = flat_card.shadow(Color::BLACK);
let button_shadow = beveled_button.shadow(Color::BLACK);
assert!(card_shadow.is_some(), "the card casts a shadow");
assert!(button_shadow.is_none(), "the button does not: it is flush");
assert!(flat_card.bevel.is_none());
assert!(beveled_button.bevel.is_some());
}
#[test]
fn a_bevel_spec_resolves_against_the_border_colour() {
let border = Color::rgb(64, 64, 64);
let derived = BevelSpec::new(BevelDirection::Raised).resolve(border);
assert!(derived.light.luminance() > border.luminance());
assert!(derived.shade.luminance() < border.luminance());
assert_eq!(derived.direction, BevelDirection::Raised);
let explicit = Color::rgb(200, 0, 0);
let stated = BevelSpec::from_base(BevelDirection::Inset, explicit).resolve(border);
assert_eq!(stated.base.to_i32(), explicit.to_i32());
}
#[test]
fn role_defaults_classify_and_default_to_the_historical_shadow() {
assert_eq!(role_surface_style("card").elevation, Elevation::Level1);
assert_eq!(role_surface_style("Card").elevation, Elevation::Level1, "names normalise");
assert_eq!(role_surface_style("toast").material, Material::Translucent);
assert!(role_surface_style("dialog").draws_shadow());
assert_eq!(
role_surface_style("button").bevel.map(|b| b.direction),
Some(BevelDirection::Raised)
);
assert_eq!(
role_surface_style("line_edit").bevel.map(|b| b.direction),
Some(BevelDirection::Inset),
"a field is a well, spelled both ways"
);
for unknown in ["gizmo", "", "some_third_party_widget"] {
let s = role_surface_style(unknown);
assert_eq!(
s.elevation,
Elevation::Level2,
"{unknown:?} must keep the theme's historical shadow, not gain or lose one"
);
assert!(s.bevel.is_none(), "and gain no bevel it never had");
}
}
#[test]
fn the_identity_is_reachable_even_though_the_role_default_is_not_it() {
assert!(SurfaceStyle::solid().is_identity());
assert!(!role_surface_style("gizmo").is_identity());
assert!(
role_surface_style("gizmo").elevated(Elevation::Flat).is_identity(),
"a theme can flatten a role back to the identity"
);
}
#[test]
fn the_default_role_shadow_is_the_historical_value() {
let shadow = default_shadow(Elevation::Level2).expect("level 2 casts");
assert_eq!(shadow.y, 2);
assert_eq!(shadow.blur, 6);
assert_eq!(shadow.alpha, 60);
}
#[test]
fn there_are_exactly_four_dimensions() {
let s = SurfaceStyle::solid();
let _ = (s.elevation, s.bevel, s.material, s.hairline);
}
#[test]
fn a_bevel_needs_room() {
assert!(!bevel_fits(Rect::new(0, 0, 3, 20)));
assert!(!bevel_fits(Rect::new(0, 0, 20, 3)));
assert!(!bevel_fits(Rect::new(0, 0, 0, 0)));
assert!(bevel_fits(Rect::new(0, 0, 4, 4)));
assert!(bevel_fits(Rect::new(0, 0, 120, 40)));
}
}