use std::collections::HashMap;
use std::rc::Rc;
use teksilo_canvas::{EdgeInsets, Size};
use teksilo_core::build_context::BuildContext;
use teksilo_core::color_prop::ColorProp;
use teksilo_core::paint_prop::PaintProp;
use teksilo_core::signal::Signal;
use teksilo_core::styles::{
BorderRecipe, ButtonRecipe, ButtonStyle, ButtonStyleConfig, ButtonVariant, FillRecipe,
PerStateRecipe, RecipeColor, ShapeRecipe, WidgetState,
};
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::{BorderRole, Color, CornerRadius, SurfaceRole, TextRole};
use super::window_resolution_colors;
use crate::primitives::{MinSize, Padding, RectWidget, ZStack};
pub const BUTTON_HEIGHT: f32 = 24.0;
pub const BUTTON_MIN_WIDTH: f32 = 72.0;
pub const BUTTON_PADDING_HORIZONTAL: f32 = 14.0;
pub const BUTTON_PADDING_VERTICAL: f32 = 0.0;
pub const BUTTON_CORNER_RADIUS: f32 = 4.0;
pub const BUTTON_BORDER_WIDTH: f32 = 1.0;
pub const BUTTON_ICON_SIZE: f32 = 16.0;
pub const BUTTON_ICON_LABEL_GAP: f32 = 4.0;
#[derive(Debug, Clone)]
pub struct RecipeButtonStyle {
pub recipes: HashMap<ButtonVariant, ButtonRecipe>,
pub label_roles: HashMap<ButtonVariant, TextRole>,
}
impl RecipeButtonStyle {
pub fn intui() -> Self {
let mut recipes = HashMap::new();
recipes.insert(ButtonVariant::Filled, intui_filled_recipe());
recipes.insert(ButtonVariant::Destructive, intui_filled_recipe());
recipes.insert(ButtonVariant::Plain, intui_plain_recipe());
recipes.insert(ButtonVariant::Tinted, intui_plain_recipe());
recipes.insert(ButtonVariant::Outlined, intui_plain_recipe());
recipes.insert(ButtonVariant::Ghost, intui_ghost_recipe());
recipes.insert(ButtonVariant::Link, intui_ghost_recipe());
Self {
recipes,
label_roles: HashMap::new(),
}
}
}
impl Default for RecipeButtonStyle {
fn default() -> Self {
Self::intui()
}
}
impl ButtonStyle for RecipeButtonStyle {
fn make_body(&self, cfg: &ButtonStyleConfig, ctx: &mut BuildContext) -> WidgetId {
let recipe = self
.recipes
.get(&cfg.variant)
.or_else(|| self.recipes.get(&ButtonVariant::Plain))
.expect("RecipeButtonStyle must define at least Plain")
.clone();
let state_signal = derive_state_signal(cfg);
let bg_color = bind_fill(&state_signal, &recipe.fill, ctx);
let (border_color, border_width) = bind_border(&state_signal, &recipe.border, ctx);
let radius = match recipe.shape {
ShapeRecipe::Rect { corner_radius } => corner_radius,
ShapeRecipe::Pill | ShapeRecipe::Circle => CornerRadius::uniform(9999.0),
};
let padding_id = ctx.add(
Padding::new(
recipe.padding.top,
recipe.padding.trailing,
recipe.padding.bottom,
recipe.padding.leading,
)
.child_id(cfg.label),
);
let border_position = recipe.border.idle.position;
let border_sides = recipe.border.idle.sides;
let mut rect = RectWidget::new()
.background(bg_color)
.border_color(border_color)
.border_width(border_width)
.corner_radius(radius)
.border_position(border_position);
if border_sides.is_some() {
rect = rect.border_sides(border_sides);
}
let rect_id = ctx.add(rect);
let zstack_id = ctx.add(ZStack::new().add_child(rect_id).add_child(padding_id));
ctx.add(MinSize::new(recipe.min_size.width, recipe.min_size.height).child_id(zstack_id))
}
fn label_text_role(&self, variant: ButtonVariant) -> Option<TextRole> {
self.label_roles.get(&variant).copied()
}
}
fn derive_state_signal(cfg: &ButtonStyleConfig) -> Signal<WidgetState> {
cfg.is_disabled
.zip3(&cfg.is_pressed, &cfg.is_hovered)
.zip(&cfg.is_focused)
.map(|((disabled, pressed, hovered), focused)| {
if *disabled {
WidgetState::Disabled
} else if *pressed {
WidgetState::Pressed
} else if *focused {
WidgetState::Focused
} else if *hovered {
WidgetState::Hovered
} else {
WidgetState::Idle
}
})
}
fn bind_fill(
state: &Signal<WidgetState>,
recipe: &PerStateRecipe<FillRecipe>,
ctx: &BuildContext,
) -> PaintProp {
let is_gradient = |f: &FillRecipe| {
matches!(
f,
FillRecipe::LinearGradient { .. } | FillRecipe::RadialGradient { .. }
)
};
let any_gradient = is_gradient(&recipe.idle)
|| recipe.hover.as_ref().is_some_and(is_gradient)
|| recipe.pressed.as_ref().is_some_and(is_gradient)
|| recipe.focused.as_ref().is_some_and(is_gradient)
|| recipe.disabled.as_ref().is_some_and(is_gradient);
if any_gradient {
let colors = if ctx.window_active() {
ctx.theme().colors.clone()
} else {
ctx.theme().colors.for_inactive_window()
};
return PaintProp::from_fill(&recipe.idle, &colors);
}
let recipe = recipe.clone();
let colors_sig = window_resolution_colors(ctx);
let sig: Signal<Color> = state
.zip(&colors_sig)
.map(move |(s, colors)| resolve_fill_to_color(recipe.resolve(*s), colors));
PaintProp::Solid(ColorProp::Bound(sig))
}
fn bind_border(
state: &Signal<WidgetState>,
recipe: &PerStateRecipe<BorderRecipe>,
ctx: &BuildContext,
) -> (ColorProp, Signal<f32>) {
let recipe_for_color = recipe.clone();
let recipe_for_width = recipe.clone();
let colors_sig = window_resolution_colors(ctx);
let color: ColorProp = state
.zip(&colors_sig)
.map(move |(s, colors)| recipe_for_color.resolve(*s).color.resolve_with(colors))
.into();
let width = state.map(move |s| recipe_for_width.resolve(*s).width);
(color, width)
}
fn resolve_fill_to_color(fill: &FillRecipe, colors: &teksilo_tokens::ColorTokens) -> Color {
fill.resolve_flat(colors).unwrap_or(Color::TRANSPARENT)
}
fn intui_filled_recipe() -> ButtonRecipe {
ButtonRecipe {
shape: ShapeRecipe::rounded(4.0),
fill: PerStateRecipe {
idle: FillRecipe::solid(SurfaceRole::Accent),
hover: Some(FillRecipe::solid(SurfaceRole::AccentHover)),
pressed: None,
focused: None,
disabled: Some(FillRecipe::solid(SurfaceRole::AccentDisabled)),
},
border: PerStateRecipe {
idle: BorderRecipe::solid(0.0, RecipeColor::Border(BorderRole::Transparent)),
hover: None,
pressed: None,
focused: Some(BorderRecipe::solid(
2.0,
RecipeColor::Border(BorderRole::Focused),
)),
disabled: None,
},
shadow: PerStateRecipe::uniform(None),
padding: EdgeInsets::symmetric(14.0, 0.0),
min_size: Size::new(72.0, 24.0),
}
}
fn intui_plain_recipe() -> ButtonRecipe {
ButtonRecipe {
shape: ShapeRecipe::rounded(4.0),
fill: PerStateRecipe {
idle: FillRecipe::solid(SurfaceRole::Main),
hover: Some(FillRecipe::solid(SurfaceRole::Hover)),
pressed: None,
focused: None,
disabled: None,
},
border: PerStateRecipe {
idle: BorderRecipe::solid(1.0, RecipeColor::Border(BorderRole::Default)),
hover: Some(BorderRecipe::solid(
1.0,
RecipeColor::Border(BorderRole::Strong),
)),
pressed: None,
focused: Some(BorderRecipe::solid(
2.0,
RecipeColor::Border(BorderRole::Focused),
)),
disabled: None,
},
shadow: PerStateRecipe::uniform(None),
padding: EdgeInsets::symmetric(14.0, 0.0),
min_size: Size::new(72.0, 24.0),
}
}
fn intui_ghost_recipe() -> ButtonRecipe {
ButtonRecipe {
shape: ShapeRecipe::rounded(4.0),
fill: PerStateRecipe {
idle: FillRecipe::solid(SurfaceRole::Transparent),
hover: Some(FillRecipe::solid(SurfaceRole::Hover)),
pressed: None,
focused: None,
disabled: None,
},
border: PerStateRecipe {
idle: BorderRecipe::solid(0.0, RecipeColor::Border(BorderRole::Transparent)),
hover: None,
pressed: None,
focused: Some(BorderRecipe::solid(
2.0,
RecipeColor::Border(BorderRole::Focused),
)),
disabled: None,
},
shadow: PerStateRecipe::uniform(None),
padding: EdgeInsets::symmetric(14.0, 0.0),
min_size: Size::new(72.0, 24.0),
}
}
pub fn shared_intui() -> Rc<dyn ButtonStyle> {
Rc::new(RecipeButtonStyle::default())
}