use crate::{design_system::DesignSystemImpl, prelude::*};
use palette::{Hsla, LinSrgb, LinSrgba};
use savory::prelude::DeclarativeConfig;
use savory_style::{
calc::calc,
text::LineHeight,
unit::{px, sec, Length},
values as val, Color, St, Style,
};
pub struct SavoryDS {
default_theme: Theme,
dark_theme: Theme,
primary: LinSrgb,
current_theme: ThemeName,
}
pub enum ThemeName {
Default,
Dark,
}
#[derive(Clone)]
pub struct Theme {
red: LinSrgb,
volcano: LinSrgb,
orange: LinSrgb,
gold: LinSrgb,
yellow: LinSrgb,
lime: LinSrgb,
green: LinSrgb,
cyan: LinSrgb,
blue: LinSrgb,
geek_blue: LinSrgb,
purple: LinSrgb,
magenta: LinSrgb,
info: LinSrgb,
success: LinSrgb,
processing: LinSrgb,
error: LinSrgb,
highlight: LinSrgb,
warning: LinSrgb,
normal: LinSrgb,
white: LinSrgb,
black: LinSrgb,
bg: LinSrgb,
body_bg: LinSrgb,
element_bg: LinSrgb,
border: LinSrgb,
border_split: LinSrgb,
text: LinSrgba,
text_secondary: LinSrgba,
disabled_bg: LinSrgb,
disabled_text: LinSrgba,
font_size: Length,
line_height: LineHeight,
border_radius: Length,
border_width: Length,
height: Length,
}
impl SavoryDS {
pub fn current_theme(&self) -> &Theme {
match self.current_theme {
ThemeName::Default => &self.default_theme,
ThemeName::Dark => &self.dark_theme,
}
}
pub fn generate(&self, color: impl Into<LinSrgb>) -> Vec<LinSrgb> {
let color = color.into();
match self.current_theme {
ThemeName::Default => colors::generate(color, colors::Opts::default()),
ThemeName::Dark => {
let opts = colors::Opts {
dark_theme: true,
background: Some(self.dark_theme.body_bg),
};
colors::generate(color, opts)
}
}
}
}
impl Default for SavoryDS {
fn default() -> Self {
let red = LinSrgb::new(0.96078, 0.13333, 0.17647); let volcano = LinSrgb::new(0.98039, 0.32941, 0.10980); let orange = LinSrgb::new(0.98039, 0.54902, 0.08627); let gold = LinSrgb::new(0.98039, 0.67843, 0.07843); let yellow = LinSrgb::new(0.98039, 0.85882, 0.07843); let lime = LinSrgb::new(0.62745, 0.85098, 0.06667); let green = LinSrgb::new(0.32157, 0.76863, 0.10196); let cyan = LinSrgb::new(0.07451, 0.76078, 0.76078); let blue = LinSrgb::new(0.09412, 0.56471, 1.0); let geek_blue = LinSrgb::new(0.18431, 0.32941, 0.92157); let purple = LinSrgb::new(0.44706, 0.18039, 0.81961); let magenta = LinSrgb::new(0.92157, 0.18431, 0.58824);
let primary = LinSrgb::new(0.09412, 0.56471, 1.0);
let white = LinSrgb::new(1.0, 1.0, 1.0);
let black = LinSrgb::new(0.0, 0.0, 0.0);
let bg = LinSrgb::new(0.96078, 0.96078, 0.96078);
let default_theme = Theme {
red,
volcano,
orange,
gold,
yellow,
lime,
green,
cyan,
blue,
geek_blue,
purple,
magenta,
info: primary,
success: green,
processing: blue,
error: colors::generate(red, colors::Opts::default())[4],
highlight: colors::generate(red, colors::Opts::default())[4],
warning: gold,
normal: LinSrgb::new(0.85098, 0.85098, 0.85098),
white,
black,
bg: bg.clone(),
body_bg: white,
element_bg: white,
border: LinSrgb::new(0.85098, 0.85098, 0.85098),
border_split: LinSrgb::new(0.94118, 0.94118, 0.94118),
text: LinSrgba::new(black.red, black.green, black.blue, 0.85),
text_secondary: LinSrgba::new(black.red, black.green, black.blue, 0.45),
disabled_bg: bg,
disabled_text: LinSrgba::new(black.red, black.green, black.blue, 0.25),
font_size: px(14).into(),
line_height: 1.5715.into(),
border_radius: px(2).into(),
border_width: px(1).into(),
height: px(32).into(),
};
let dark_theme = Theme {
..default_theme.clone()
};
SavoryDS {
default_theme,
dark_theme,
primary,
current_theme: ThemeName::Default,
}
}
}
impl DesignSystemImpl for SavoryDS {
fn text(&self, lens: text::TextLens<'_>) -> Style {
let theme = self.current_theme();
Style::default()
.and_text(|t| {
t.color(lens.color.unwrap_or(theme.text.into()))
.try_letter_spacing(lens.letter_spacing.cloned())
.try_word_spacing(lens.word_spacing.cloned())
.line_height(
lens.lines_spacing
.cloned()
.unwrap_or(theme.line_height.clone()),
)
.try_align(lens.align)
.try_justify(lens.justify_by)
.try_indent(lens.indent.cloned())
.config_if(lens.wrap, |c| c.word_wrap(val::BreakWord))
.try_shadow(lens.shadow.cloned())
})
.and_font(|f| {
f.size(lens.size.cloned().unwrap_or(theme.font_size.clone().into()))
.try_style(lens.style)
.config_if(lens.small_caps, |f| f.variant(val::SmallCaps))
.try_weight(lens.weight)
})
.config_if(lens.disabled, |c| {
c.cursor(val::NotAllowed)
.text(theme.disabled_text)
.push(St::UserSelect, val::None)
})
}
fn button(&self, lens: button::ButtonLens) -> Style {
use button::{ActionType, Kind};
let theme = self.current_theme();
let kind = lens.kind;
let action = lens.action_type;
Style::default()
.and_font(|c| c.weight_400().size(theme.font_size.clone()))
.and_border(|c| {
c.radius(theme.border_radius.clone())
.solid()
.width(theme.border_width.clone())
})
.and_box_shadow(|c| c.y(px(2.0)).color(LinSrgba::new(0., 0., 0., 0.015)))
.and_text(|c| {
c.line_height(theme.line_height.clone())
.white_space(val::Nowrap)
.align(val::Center)
})
.and_padding(|c| c.y(px(4)).x(px(15)))
.and_size(|c| c.height(theme.height.clone()))
.push(St::WebkitAppearance, "button")
.display(val::InlineBlock)
.cursor(val::Pointer)
.and_transition(|c| c.duration(sec(0.3)).cubic_bezier(0.645, 0.045, 0.355, 1.0))
.push(St::UserSelect, val::None)
.push(St::TouchAction, val::Manipulation)
.push(St::Outline, 0)
.config_if_else(
lens.disabled,
|c| {
match kind {
Kind::Default | Kind::Dashed => c
.border(theme.border)
.background(theme.disabled_bg)
.text(theme.disabled_text),
_ => c
.border(Color::Transparent)
.background(Color::Transparent)
.text(theme.disabled_text),
}
.cursor(val::NotAllowed)
},
|c| match kind {
Kind::Default => match action {
ActionType::Default => c
.background(theme.element_bg)
.text(theme.text)
.border(theme.border)
.config_if(lens.ghost, |c| {
c.background(Color::Transparent)
.text(theme.element_bg)
.border(theme.element_bg)
})
.config_if(lens.focused || lens.mouse_over, |c| {
let color = lens.color.unwrap_or(self.primary.into());
c.text(color).border(color)
}),
ActionType::Suggested | ActionType::Destructive => {
let color = lens.color.unwrap_or_else(|| match action {
ActionType::Default => unreachable!("cannot get executed"),
ActionType::Suggested => self.primary.into(),
ActionType::Destructive => theme.red.into(),
});
c.background(color)
.text(lens.text_color.unwrap_or(theme.white.into()))
.border(color)
.config_if(lens.focused || lens.mouse_over, |c| {
let color = self.generate(color)[4];
c.background(color).border(color)
})
}
},
Kind::Dashed => c
.background(theme.element_bg)
.text(theme.text)
.and_border(|b| b.color(theme.border).dashed())
.config_if(lens.ghost, |c| {
c.background(Color::Transparent)
.text(theme.element_bg)
.border(theme.element_bg)
})
.config_if(lens.focused || lens.mouse_over, |c| {
let color = lens.color.unwrap_or(self.primary.into());
c.text(color).border(color)
}),
Kind::TextButton => c
.background(Color::Transparent)
.border(Color::Transparent)
.text(lens.text_color.unwrap_or(theme.text.into()))
.box_shadow(val::None)
.config_if(lens.focused || lens.mouse_over, |c| {
c.background(Hsla::new(0.0, 0.0, 0.0, 0.018))
}),
Kind::LinkButton => {
let color = lens.text_color.unwrap_or(self.primary.into());
c.background(Color::Transparent)
.border(Color::Transparent)
.box_shadow(val::None)
.text(color)
.config_if(lens.focused || lens.mouse_over, |c| {
c.text(self.generate(color)[4])
})
}
},
)
.config_if(lens.ghost, |c| c.background(Color::Transparent))
}
fn switch(&self, lens: switch::SwitchLens<'_>) -> switch::StyleMap {
let theme = self.current_theme();
if lens.checkbox_like {
let size = 16.0;
let switch = Style::default()
.push(St::Appearance, val::None)
.position(val::Relative)
.display(val::InlineBlock)
.push(St::BoxSizing, val::BorderBox)
.push(St::VerticalAlign, val::Middle)
.push(St::UserSelect, val::None)
.push(St::TouchAction, val::Manipulation)
.cursor(val::Pointer)
.margin(px(0))
.padding(px(0))
.size(px(size))
.and_text(|t| t.line_height(1.0))
.and_border(|b| {
b.radius(theme.border_radius.clone())
.solid()
.color(theme.border)
.width(theme.border_width.clone())
})
.background(theme.element_bg)
.and_transition(|t| t.duration(sec(0.3)))
.config_if(lens.toggled, |c| {
let color = lens.color.unwrap_or(self.primary.into());
c.background(color).border(color)
})
.config_if(lens.disabled, |c| {
c.background(theme.disabled_bg)
.border(theme.border)
.cursor(val::NotAllowed)
});
let size = 10.0;
let check_sign = Style::default()
.push(St::BoxSizing, val::BorderBox)
.and_border(|b| {
b.none()
.and_left(|t| t.width(px(2)).solid().color(theme.element_bg))
.and_bottom(|t| t.width(px(2)).solid().color(theme.element_bg))
})
.cursor(val::Pointer)
.and_size(|s| s.width(px(size)).height(px(size / 2.0)))
.push(St::Transition, "all .2s cubic-bezier(.12,.4,.29,1.46) .1s")
.push(St::Transform, "rotate(-45deg) translate(25%, 25%)")
.config_if(lens.disabled, |c| c.border(theme.disabled_text));
let text = Style::default()
.display(val::InlineFlex)
.align_items(val::Center)
.push(St::UserSelect, val::None)
.push(St::VerticalAlign, val::Middle)
.gap(px(8))
.and_text(|t| t.color(theme.text).line_height(theme.line_height.clone()))
.and_font(|f| f.size(theme.font_size.clone()))
.config_if(lens.disabled, |c| {
c.cursor(val::NotAllowed).text(theme.disabled_text)
});
switch::StyleMap {
switch,
check_sign,
text,
}
} else {
let height = 22.0;
let switch = Style::default()
.push(St::Appearance, val::None)
.position(val::Relative)
.display(val::InlineBlock)
.push(St::BoxSizing, val::BorderBox)
.push(St::VerticalAlign, val::Middle)
.push(St::UserSelect, val::None)
.push(St::TouchAction, val::Manipulation)
.cursor(val::Pointer)
.margin(px(0))
.padding(px(0))
.and_size(|s| s.height(px(height)).min_width(px(44)))
.and_text(|t| t.line_height(px(height)))
.and_border(|b| b.radius(px(100)).none())
.background(theme.disabled_text)
.and_transition(|t| t.duration(sec(0.2)))
.config_if(lens.toggled, |c| {
c.background(lens.color.unwrap_or(self.primary.into()))
})
.config_if(lens.disabled, |c| c.opacity(0.4).cursor(val::NotAllowed));
let spaceing = 2.0;
let size = height - (spaceing * 2.0);
let check_sign = Style::default()
.and_position(|p| {
p.absolute().top(px(spaceing)).config_if_else(
lens.toggled,
|c| c.left(calc(1.0, |c| c.sub(px(size + spaceing)))),
|c| c.left(px(spaceing)),
)
})
.push(St::BoxSizing, val::BorderBox)
.push(St::BoxShadow, "0 2px 4px 0 rgba(0,35,11,.2)")
.and_border(|b| b.none().radius(px(size)))
.cursor(val::Pointer)
.size(px(size))
.background(theme.element_bg)
.and_transition(|t| t.duration(sec(0.2)).ease_in_out())
.config_if(lens.disabled, |c| c.cursor(val::NotAllowed));
let text = Style::default()
.display(val::InlineFlex)
.align_items(val::Center)
.push(St::UserSelect, val::None)
.push(St::VerticalAlign, val::Middle)
.gap(px(8))
.and_text(|t| t.color(theme.text).line_height(theme.line_height.clone()))
.and_font(|f| f.size(theme.font_size.clone()))
.config_if(lens.disabled, |c| {
c.cursor(val::NotAllowed).text(theme.disabled_text)
});
switch::StyleMap {
switch,
check_sign,
text,
}
}
}
fn radio(&self, lens: radio::RadioLens<'_>) -> radio::StyleMap {
let theme = self.current_theme();
let size = 16.0;
let radio = Style::default()
.push(St::Appearance, val::None)
.position(val::Relative)
.display(val::InlineBlock)
.push(St::BoxSizing, val::BorderBox)
.push(St::VerticalAlign, val::Middle)
.push(St::UserSelect, val::None)
.push(St::TouchAction, val::Manipulation)
.cursor(val::Pointer)
.margin(px(0))
.padding(px(0))
.size(px(size))
.and_text(|t| t.line_height(1.0))
.and_border(|b| {
b.radius(px(size))
.solid()
.color(theme.border)
.width(theme.border_width.clone())
})
.background(theme.element_bg)
.and_transition(|t| t.duration(sec(0.3)))
.config_if(lens.focused, |c| {
c.border(lens.color.unwrap_or(self.primary.into()))
})
.config_if(lens.toggled, |c| {
c.border(lens.color.unwrap_or(self.primary.into()))
})
.config_if(lens.disabled, |c| {
c.background(theme.disabled_bg)
.border(theme.border)
.cursor(val::NotAllowed)
});
let check_sign = Style::default()
.push(St::BoxSizing, val::BorderBox)
.and_border(|b| b.none().radius(px(size)))
.cursor(val::Pointer)
.margin(val::Auto)
.background(Color::Transparent)
.size(px(size - 8.0))
.and_transition(|t| t.duration(sec(0.3)))
.config_if(lens.toggled, |c| {
c.background(lens.color.unwrap_or(self.primary.into()))
})
.config_if(lens.disabled, |c| {
c.border(theme.disabled_text)
.background(theme.disabled_text)
.cursor(val::NotAllowed)
});
let text = Style::default()
.display(val::InlineFlex)
.align_items(val::Center)
.push(St::UserSelect, val::None)
.push(St::VerticalAlign, val::Middle)
.gap(px(8))
.and_text(|t| t.color(theme.text).line_height(theme.line_height.clone()))
.and_font(|f| f.size(theme.font_size.clone()))
.config_if(lens.disabled, |c| {
c.cursor(val::NotAllowed).text(theme.disabled_text)
});
radio::StyleMap {
radio,
check_sign,
text,
}
}
fn text_input(&self, lens: text_input::TextInputLens) -> Style {
let theme = self.current_theme();
Style::default()
.push(St::Appearance, val::None)
.position(val::Relative)
.display(val::InlineBlock)
.push(St::BoxSizing, val::BorderBox)
.push(St::UserSelect, val::None)
.push(St::TouchAction, val::Manipulation)
.cursor(val::Pointer)
.and_size(|s| s.width(1.0).height(theme.height.clone()))
.and_padding(|p| p.x(px(11)).y(px(4)))
.and_text(|t| t.line_height(theme.height.clone()))
.and_font(|f| f.size(theme.font_size.clone()))
.and_border(|b| {
b.none()
.solid()
.width(theme.border_width.clone())
.color(theme.border)
.radius(theme.border_radius.clone())
})
.background(theme.element_bg)
.config_if(lens.focused || lens.mouse_over, |c| {
c.border(lens.color.unwrap_or(self.primary.into()))
})
.config_if(lens.disabled, |c| {
c.background(theme.disabled_bg)
.text(theme.disabled_text)
.border(theme.border)
.cursor(val::NotAllowed)
})
}
fn progress_bar(&self, lens: progress_bar::ProgressBarLens) -> progress_bar::StyleMap {
let theme = self.current_theme();
let height = 8.0;
let progress_bar = Style::default()
.position(val::Relative)
.display(val::InlineBlock)
.push(St::BoxSizing, val::BorderBox)
.background(theme.bg)
.and_border(|b| b.radius(px(100)))
.and_size(|s| s.width(1.0).height(px(height)));
let indicator = Style::default()
.push(St::BoxSizing, val::BorderBox)
.background(theme.processing)
.and_border(|b| b.radius(px(100)))
.and_size(|s| s.width(lens.value / lens.max).height(px(height)));
progress_bar::StyleMap {
progress_bar,
indicator,
}
}
}
pub mod colors {
use palette::{Hsv, LinSrgb, Mix};
const HUE_STEP: f32 = 2.;
const SATURATION_STEP: f32 = 0.16;
const SATURATION_STEP2: f32 = 0.05;
const BRIGHTNESS_STEP1: f32 = 0.05;
const BRIGHTNESS_STEP2: f32 = 0.15;
const LIGHT_COLOR_COUNT: u8 = 5;
const DARK_COLOR_COUNT: u8 = 4;
const DARK_COLOR_MAP: [(u8, f32); 10] = [
(7, 0.15),
(6, 0.25),
(5, 0.3),
(5, 0.45),
(5, 0.65),
(5, 0.85),
(4, 0.9),
(3, 0.95),
(2, 0.97),
(1, 0.98),
];
fn get_hue(color: Hsv, i: u8, light: bool) -> f32 {
let hue: f32 = color.hue.to_positive_degrees();
let mut hue: f32 = match (light, hue.round()) {
(true, hue) if hue >= 60.0 && hue <= 240.0 => hue.round() - (HUE_STEP * i as f32),
(false, hue) if hue >= 60.0 && hue <= 240.0 => hue.round() + (HUE_STEP * i as f32),
(true, _) => hue.round() + (HUE_STEP * i as f32),
(false, _) => hue.round() - (HUE_STEP * i as f32),
};
if hue < 0.0 {
hue += 360.0
} else if hue >= 360.0 {
hue -= 360.0
}
hue
}
fn get_saturation(color: Hsv, i: u8, light: bool) -> f32 {
if color.hue == 0.0 && color.saturation == 0.0 {
return color.saturation;
}
let mut saturation = if light {
color.saturation - (SATURATION_STEP * i as f32)
} else if i == DARK_COLOR_COUNT {
color.saturation + SATURATION_STEP
} else {
color.saturation + (SATURATION_STEP2 * i as f32)
};
if saturation > 1.0 {
saturation = 1.0
}
if light && i == LIGHT_COLOR_COUNT && saturation > 0.1 {
saturation = 0.1;
}
if saturation < 0.06 {
saturation = 0.06;
}
(saturation * 100.0).round() / 100.0
}
fn get_value(color: Hsv, i: u8, light: bool) -> f32 {
let i = i as f32;
let value = if light {
color.value + (BRIGHTNESS_STEP1 * i)
} else {
color.value - (BRIGHTNESS_STEP2 * i)
};
if value > 1.0 {
1.0
} else {
(value * 100.0).round() / 100.0
}
}
#[derive(Default)]
pub struct Opts {
pub dark_theme: bool,
pub background: Option<LinSrgb>,
}
pub fn generate(color: impl Into<LinSrgb>, opts: Opts) -> Vec<LinSrgb> {
let hsv: Hsv = color.into().into();
let light_colors = (1..=LIGHT_COLOR_COUNT)
.into_iter()
.rev()
.map(|i| {
Hsv::new(
get_hue(hsv, i, true),
get_saturation(hsv, i, true),
get_value(hsv, i, true),
)
})
.collect::<Vec<_>>();
let dark_colors = (1..=DARK_COLOR_COUNT)
.into_iter()
.map(|i| {
Hsv::new(
get_hue(hsv, i, false),
get_saturation(hsv, i, false),
get_value(hsv, i, false),
)
})
.collect::<Vec<_>>();
let patterns = [light_colors, vec![hsv], dark_colors]
.concat()
.into_iter()
.map(|h| h.into())
.collect::<Vec<LinSrgb>>();
if opts.dark_theme {
DARK_COLOR_MAP
.iter()
.map(|(index, opacity)| {
let bg = opts
.background
.unwrap_or_else(|| LinSrgb::new(0.07843, 0.07843, 0.07843));
bg.mix(&patterns[*index as usize], *opacity).into()
})
.collect::<Vec<_>>()
} else {
patterns
}
}
}