use serde::{Deserialize, Serialize};
use teksilo_canvas::{Rect, Vec2};
use teksilo_tokens::{BorderRole, Color, ColorTokens, CornerRadius, SurfaceRole, TextRole};
use crate::styles::Theme;
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Default, Serialize, Deserialize)]
pub enum WidgetState {
#[default]
Idle,
Hovered,
Pressed,
Focused,
Disabled,
}
#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum RecipeColor {
Static(Color),
Surface(SurfaceRole),
Border(BorderRole),
Text(TextRole),
}
impl RecipeColor {
pub fn resolve(self, theme: &Theme) -> Color {
self.resolve_with(&theme.colors)
}
pub fn resolve_with(self, colors: &ColorTokens) -> Color {
match self {
RecipeColor::Static(c) => c,
RecipeColor::Surface(r) => r.resolve(colors),
RecipeColor::Border(r) => r.resolve(colors),
RecipeColor::Text(r) => r.resolve(colors),
}
}
}
impl From<Color> for RecipeColor {
fn from(c: Color) -> Self {
Self::Static(c)
}
}
impl From<SurfaceRole> for RecipeColor {
fn from(r: SurfaceRole) -> Self {
Self::Surface(r)
}
}
impl From<BorderRole> for RecipeColor {
fn from(r: BorderRole) -> Self {
Self::Border(r)
}
}
impl From<TextRole> for RecipeColor {
fn from(r: TextRole) -> Self {
Self::Text(r)
}
}
#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum ShapeRecipe {
Rect { corner_radius: CornerRadius },
Pill,
Circle,
}
impl ShapeRecipe {
pub fn rounded(radius: f32) -> Self {
Self::Rect {
corner_radius: CornerRadius::uniform(radius),
}
}
pub fn rect() -> Self {
Self::Rect {
corner_radius: CornerRadius::uniform(0.0),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum FillRecipe {
Solid(RecipeColor),
StateLayer {
base: RecipeColor,
overlay: RecipeColor,
alpha: f32,
},
LinearGradient {
stops: Vec<GradientStop>,
angle_deg: f32,
},
RadialGradient {
stops: Vec<GradientStop>,
center: (f32, f32),
radius: f32,
},
None,
}
#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientStop {
pub offset: f32,
pub color: RecipeColor,
}
impl FillRecipe {
pub fn solid(color: impl Into<RecipeColor>) -> Self {
Self::Solid(color.into())
}
pub fn state_layer(
base: impl Into<RecipeColor>,
overlay: impl Into<RecipeColor>,
alpha: f32,
) -> Self {
Self::StateLayer {
base: base.into(),
overlay: overlay.into(),
alpha: alpha.clamp(0.0, 1.0),
}
}
pub fn resolve_flat(&self, colors: &ColorTokens) -> Option<Color> {
match self {
FillRecipe::Solid(c) => Some(c.resolve_with(colors)),
FillRecipe::StateLayer {
base,
overlay,
alpha,
} => Some(
base.resolve_with(colors)
.mix(overlay.resolve_with(colors), *alpha),
),
FillRecipe::None => Some(Color::TRANSPARENT),
FillRecipe::LinearGradient { .. } | FillRecipe::RadialGradient { .. } => None,
}
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub enum BorderStyle {
#[default]
Solid,
Dashed {
dash: f32,
gap: f32,
},
Dotted {
gap: f32,
},
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum BorderPosition {
#[default]
Inside,
Center,
Outside,
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct BorderSides {
pub top: f32,
pub trailing: f32,
pub bottom: f32,
pub leading: f32,
}
impl BorderSides {
pub fn uniform(w: f32) -> Self {
Self {
top: w,
trailing: w,
bottom: w,
leading: w,
}
}
pub fn bottom(w: f32) -> Self {
Self {
bottom: w,
..Self::default()
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BorderRecipe {
pub width: f32,
pub color: RecipeColor,
pub style: BorderStyle,
pub position: BorderPosition,
#[serde(default)]
pub sides: Option<BorderSides>,
}
impl BorderRecipe {
pub fn solid(width: f32, color: impl Into<RecipeColor>) -> Self {
Self {
width,
color: color.into(),
style: BorderStyle::Solid,
position: BorderPosition::Inside,
sides: None,
}
}
pub fn none() -> Self {
Self::solid(0.0, RecipeColor::Static(Color::TRANSPARENT))
}
pub fn underline(width: f32, color: impl Into<RecipeColor>) -> Self {
Self {
width,
color: color.into(),
style: BorderStyle::Solid,
position: BorderPosition::Inside,
sides: Some(BorderSides::bottom(width)),
}
}
}
pub fn apply_border_position(bounds: Rect, width: f32, position: BorderPosition) -> Rect {
let offset = match position {
BorderPosition::Inside => width / 2.0,
BorderPosition::Center => 0.0,
BorderPosition::Outside => -width / 2.0,
};
Rect::new(
bounds.x + offset,
bounds.y + offset,
bounds.width - offset * 2.0,
bounds.height - offset * 2.0,
)
}
#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ShadowRecipe {
pub offset: Vec2,
pub blur: f32,
pub spread: f32,
pub color: RecipeColor,
}
impl ShadowRecipe {
pub fn drop(offset: Vec2, blur: f32, color: impl Into<RecipeColor>) -> Self {
Self {
offset,
blur,
spread: 0.0,
color: color.into(),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PerStateRecipe<T> {
pub idle: T,
pub hover: Option<T>,
pub pressed: Option<T>,
pub focused: Option<T>,
pub disabled: Option<T>,
}
impl<T> PerStateRecipe<T> {
pub fn uniform(value: T) -> Self
where
T: Clone,
{
Self {
idle: value,
hover: None,
pressed: None,
focused: None,
disabled: None,
}
}
pub fn resolve(&self, state: WidgetState) -> &T {
match state {
WidgetState::Idle => &self.idle,
WidgetState::Hovered => self.hover.as_ref().unwrap_or(&self.idle),
WidgetState::Pressed => self
.pressed
.as_ref()
.or(self.hover.as_ref())
.unwrap_or(&self.idle),
WidgetState::Focused => self
.focused
.as_ref()
.or(self.hover.as_ref())
.unwrap_or(&self.idle),
WidgetState::Disabled => self.disabled.as_ref().unwrap_or(&self.idle),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::presets::intui;
#[test]
fn recipe_color_static_round_trips() {
let theme = intui::light();
let red = RecipeColor::Static(Color::from_hex("#FF0000"));
assert_eq!(red.resolve(&theme), Color::from_hex("#FF0000"));
}
#[test]
fn recipe_color_surface_resolves_against_theme() {
let light = intui::light();
let dark = intui::dark();
let main = RecipeColor::Surface(SurfaceRole::Main);
assert_ne!(main.resolve(&light), main.resolve(&dark));
}
#[test]
fn per_state_resolves_idle_fallback() {
let r = PerStateRecipe::<u32>::uniform(7);
assert_eq!(*r.resolve(WidgetState::Idle), 7);
assert_eq!(*r.resolve(WidgetState::Hovered), 7);
assert_eq!(*r.resolve(WidgetState::Pressed), 7);
assert_eq!(*r.resolve(WidgetState::Focused), 7);
assert_eq!(*r.resolve(WidgetState::Disabled), 7);
}
#[test]
fn pressed_falls_back_to_hover_then_idle() {
let r = PerStateRecipe {
idle: 1,
hover: Some(2),
pressed: None,
focused: None,
disabled: None,
};
assert_eq!(*r.resolve(WidgetState::Pressed), 2); let r2 = PerStateRecipe {
idle: 1,
hover: None,
pressed: None,
focused: None,
disabled: None,
};
assert_eq!(*r2.resolve(WidgetState::Pressed), 1); }
#[test]
fn focused_falls_back_to_hover_then_idle() {
let r = PerStateRecipe {
idle: 1,
hover: Some(2),
pressed: None,
focused: None,
disabled: None,
};
assert_eq!(*r.resolve(WidgetState::Focused), 2);
}
#[test]
fn disabled_falls_back_to_idle_directly() {
let r = PerStateRecipe {
idle: 1,
hover: Some(2), pressed: None,
focused: None,
disabled: None,
};
assert_eq!(*r.resolve(WidgetState::Disabled), 1);
}
#[test]
fn fill_recipe_solid_constructor() {
let f = FillRecipe::solid(SurfaceRole::Accent);
assert!(matches!(f, FillRecipe::Solid(RecipeColor::Surface(_))));
}
#[test]
fn state_layer_composites_overlay_over_base() {
let colors = intui::light().colors;
let f = FillRecipe::state_layer(
RecipeColor::Static(Color::BLACK),
RecipeColor::Static(Color::WHITE),
0.5,
);
let c = f.resolve_flat(&colors).unwrap();
assert!((c.r() - 0.5).abs() < 1e-6);
assert!((c.g() - 0.5).abs() < 1e-6);
assert!((c.b() - 0.5).abs() < 1e-6);
let f0 = FillRecipe::state_layer(Color::BLACK, Color::WHITE, 0.0);
assert_eq!(f0.resolve_flat(&colors).unwrap(), Color::BLACK);
}
#[test]
fn state_layer_clamps_alpha() {
let f = FillRecipe::state_layer(Color::BLACK, Color::WHITE, 5.0);
match f {
FillRecipe::StateLayer { alpha, .. } => assert_eq!(alpha, 1.0),
_ => panic!("expected StateLayer"),
}
}
#[test]
fn gradient_has_no_flat_color() {
let colors = intui::light().colors;
let g = FillRecipe::LinearGradient {
stops: vec![],
angle_deg: 0.0,
};
assert!(g.resolve_flat(&colors).is_none());
}
#[test]
fn underline_is_bottom_only() {
let b = BorderRecipe::underline(2.0, BorderRole::Focused);
let sides = b.sides.expect("underline sets per-side widths");
assert_eq!(sides.bottom, 2.0);
assert_eq!(sides.top, 0.0);
assert_eq!(sides.leading, 0.0);
assert_eq!(sides.trailing, 0.0);
}
#[test]
fn solid_border_has_no_per_side() {
assert!(
BorderRecipe::solid(1.0, BorderRole::Default)
.sides
.is_none()
);
}
#[test]
fn border_position_offsets_stroke_rect() {
let bounds = Rect::new(0.0, 0.0, 100.0, 40.0);
let inside = apply_border_position(bounds, 4.0, BorderPosition::Inside);
assert_eq!(
(inside.x, inside.y, inside.width, inside.height),
(2.0, 2.0, 96.0, 36.0)
);
let center = apply_border_position(bounds, 4.0, BorderPosition::Center);
assert_eq!((center.x, center.width), (0.0, 100.0));
let outside = apply_border_position(bounds, 4.0, BorderPosition::Outside);
assert_eq!(
(outside.x, outside.y, outside.width, outside.height),
(-2.0, -2.0, 104.0, 44.0)
);
}
#[test]
fn shape_recipe_rounded_constructor() {
let s = ShapeRecipe::rounded(4.0);
match s {
ShapeRecipe::Rect { corner_radius } => {
assert_eq!(corner_radius.top_left, 4.0);
assert_eq!(corner_radius.bottom_right, 4.0);
}
_ => panic!("expected Rect"),
}
}
#[test]
fn recipes_are_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<ShapeRecipe>();
assert_send_sync::<FillRecipe>();
assert_send_sync::<BorderRecipe>();
assert_send_sync::<ShadowRecipe>();
assert_send_sync::<PerStateRecipe<FillRecipe>>();
assert_send_sync::<RecipeColor>();
assert_send_sync::<WidgetState>();
}
}