use std::borrow::Cow;
use serde::{Deserialize, Serialize};
use teksilo_tokens::{
ColorTokens, InputTokens, LayoutTokens, MotionTokens, ShapeTokens, TargetDensity,
TypographyTokens,
};
use crate::styles::component_style_slots::ComponentStyleSlots;
use crate::styles::theme_appearance::ThemeAppearance;
use crate::styles::theme_extension::ThemeExtensions;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ThemeId(Cow<'static, str>);
impl ThemeId {
pub fn new(id: impl Into<Cow<'static, str>>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for ThemeId {
fn default() -> Self {
Self(Cow::Borrowed("custom"))
}
}
impl std::fmt::Display for ThemeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Theme {
#[serde(default)]
pub id: ThemeId,
pub appearance: ThemeAppearance,
pub colors: ColorTokens,
pub layout: LayoutTokens,
pub typography: TypographyTokens,
pub shape: ShapeTokens,
pub motion: MotionTokens,
#[serde(default)]
pub input: InputTokens,
#[serde(skip, default)]
pub style_slots: ComponentStyleSlots,
#[serde(skip, default)]
pub extensions: ThemeExtensions,
}
#[derive(Clone, Copy)]
pub struct DensityProjection(pub fn(&Theme, TargetDensity) -> Theme);
impl std::fmt::Debug for DensityProjection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("DensityProjection(<fn>)")
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PaintState {
pub window_active: bool,
pub high_contrast: bool,
}
#[derive(Clone, Copy)]
pub struct PaintProjection(pub fn(&Theme, PaintState) -> Theme);
impl std::fmt::Debug for PaintProjection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("PaintProjection(<fn>)")
}
}
impl Theme {
pub fn new(
appearance: ThemeAppearance,
colors: ColorTokens,
layout: LayoutTokens,
typography: TypographyTokens,
shape: ShapeTokens,
motion: MotionTokens,
input: InputTokens,
) -> Self {
Self {
id: ThemeId::default(),
appearance,
colors,
layout,
typography,
shape,
motion,
input,
style_slots: ComponentStyleSlots::default(),
extensions: ThemeExtensions::new(),
}
}
pub fn with_density_projection(self, project: fn(&Theme, TargetDensity) -> Theme) -> Self {
self.with_extension(DensityProjection(project))
}
pub fn with_paint_projection(self, project: fn(&Theme, PaintState) -> Theme) -> Self {
self.with_extension(PaintProjection(project))
}
pub fn with_id(mut self, id: impl Into<Cow<'static, str>>) -> Self {
self.id = ThemeId::new(id);
self
}
pub fn is_dark(&self) -> bool {
self.appearance.is_dark()
}
pub fn for_inactive_window(&self) -> Theme {
Theme {
colors: self.colors.for_inactive_window(),
..self.clone()
}
}
pub fn for_high_contrast(&self) -> Theme {
Theme {
colors: self.colors.for_high_contrast(),
..self.clone()
}
}
pub fn for_paint_state(&self, state: PaintState) -> Theme {
if let Some(PaintProjection(project)) = self.extension::<PaintProjection>().copied() {
return project(self, state);
}
Theme {
colors: generic_paint_colors(&self.colors, state),
..self.clone()
}
}
pub fn paint_colors(&self, state: PaintState) -> ColorTokens {
if let Some(PaintProjection(project)) = self.extension::<PaintProjection>().copied() {
return project(self, state).colors;
}
generic_paint_colors(&self.colors, state)
}
pub fn with_density(&self, density: TargetDensity) -> Theme {
if let Some(DensityProjection(project)) = self.extension::<DensityProjection>().copied() {
return project(self, density);
}
Theme {
input: InputTokens::for_density(density),
..self.clone()
}
}
pub fn extension<T: std::any::Any + Send + Sync>(&self) -> Option<&T> {
self.extensions.get::<T>()
}
pub fn with_extension<T: std::any::Any + Send + Sync>(mut self, value: T) -> Self {
self.extensions.insert(value);
self
}
}
fn generic_paint_colors(colors: &ColorTokens, state: PaintState) -> ColorTokens {
match (state.window_active, state.high_contrast) {
(true, false) => colors.clone(),
(false, false) => colors.for_inactive_window(),
(true, true) => colors.for_high_contrast(),
(false, true) => colors.for_inactive_window().for_high_contrast(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::presets::intui;
use teksilo_tokens::TargetDensity;
const ALL_PAINT_STATES: [PaintState; 4] = [
PaintState {
window_active: true,
high_contrast: false,
},
PaintState {
window_active: false,
high_contrast: false,
},
PaintState {
window_active: true,
high_contrast: true,
},
PaintState {
window_active: false,
high_contrast: true,
},
];
fn pre_hook_chain(theme: &Theme, state: PaintState) -> Theme {
let base = if state.window_active {
theme.clone()
} else {
theme.for_inactive_window()
};
if state.high_contrast {
base.for_high_contrast()
} else {
base
}
}
const HOOK_INACTIVE: &str = "#FF00FF";
const HOOK_HIGH_CONTRAST: &str = "#00FF00";
fn marking_projection(theme: &Theme, state: PaintState) -> Theme {
let mut out = theme.clone();
if !state.window_active {
out.colors.accent = teksilo_tokens::Color::from_hex(HOOK_INACTIVE);
}
if state.high_contrast {
out.colors.accent = teksilo_tokens::Color::from_hex(HOOK_HIGH_CONTRAST);
}
out
}
#[test]
fn for_paint_state_without_a_hook_is_the_pre_hook_chain() {
for theme in [intui::light(), intui::dark()] {
assert!(theme.extension::<PaintProjection>().is_none());
for state in ALL_PAINT_STATES {
let expected = pre_hook_chain(&theme, state);
assert_eq!(theme.for_paint_state(state), expected, "{state:?}");
assert_eq!(theme.paint_colors(state), expected.colors, "{state:?}");
}
assert_eq!(theme.for_paint_state(ALL_PAINT_STATES[0]), theme);
}
}
#[test]
fn a_paint_projection_replaces_the_generic_chain() {
let theme = intui::light().with_paint_projection(marking_projection);
for state in ALL_PAINT_STATES {
let expected = marking_projection(&theme, state);
assert_eq!(theme.for_paint_state(state), expected, "{state:?}");
assert_eq!(theme.paint_colors(state), expected.colors, "{state:?}");
}
assert_eq!(
format!("{:?}", theme.extension::<PaintProjection>().unwrap()),
"PaintProjection(<fn>)"
);
}
#[test]
fn the_named_projections_keep_their_documented_behaviour() {
let plain = intui::light();
let hooked = plain.clone().with_paint_projection(marking_projection);
for theme in [&plain, &hooked] {
let inactive = theme.for_inactive_window();
assert_eq!(inactive.colors, theme.colors.for_inactive_window());
assert_ne!(inactive.colors.accent, theme.colors.accent);
let high = theme.for_high_contrast();
assert_eq!(high.colors, theme.colors.for_high_contrast());
for projected in [inactive, high] {
assert_eq!(projected.id, theme.id);
assert_eq!(projected.appearance, theme.appearance);
assert_eq!(projected.layout, theme.layout);
assert_eq!(projected.typography, theme.typography);
assert_eq!(projected.shape, theme.shape);
assert_eq!(projected.motion, theme.motion);
assert_eq!(projected.input, theme.input);
assert_eq!(
projected.extension::<PaintProjection>().is_some(),
theme.extension::<PaintProjection>().is_some(),
);
}
}
}
#[derive(Debug)]
struct AccentProbe;
impl crate::widget::Widget for AccentProbe {
fn layout_response(
&self,
proposal: teksilo_canvas::SizeProposal,
_ctx: &crate::widget::LayoutContext,
) -> crate::widget::LayoutResponse {
proposal.resolve(0.0, 0.0).into()
}
fn paint(
&self,
bounds: teksilo_canvas::Rect,
canvas: &mut teksilo_canvas::Canvas,
ctx: &crate::widget::PaintContext,
) {
canvas.fill_rounded_rect(
bounds,
teksilo_tokens::CornerRadius::ZERO,
ctx.theme.colors.accent,
);
}
}
fn painted_accent(tree: &mut crate::widget_tree::WidgetTree) -> [f32; 4] {
let frame = tree.render();
assert_eq!(frame.shapes.len(), 1);
frame.shapes[0].color
}
#[test]
fn the_paint_walker_paints_the_hooks_colours() {
let theme = intui::light().with_paint_projection(marking_projection);
let active = theme.colors.accent.to_array();
let mut tree = crate::widget_tree::WidgetTree::new().with_theme(theme);
tree.add(AccentProbe);
tree.layout(teksilo_canvas::SizeProposal::exact(40.0, 20.0));
assert_eq!(painted_accent(&mut tree), active);
tree.set_window_active(false);
assert_eq!(
painted_accent(&mut tree),
teksilo_tokens::Color::from_hex(HOOK_INACTIVE).to_array(),
"an inactive window paints the hook's colours, not the generic desaturation"
);
tree.set_window_active(true);
tree.set_accessibility_preferences(true, false, 1.0);
assert_eq!(
painted_accent(&mut tree),
teksilo_tokens::Color::from_hex(HOOK_HIGH_CONTRAST).to_array(),
);
assert_eq!(
tree.paint_state(),
PaintState {
window_active: true,
high_contrast: true,
}
);
}
#[test]
fn the_paint_walker_without_a_hook_paints_the_generic_chain() {
let theme = intui::dark();
let mut tree = crate::widget_tree::WidgetTree::new().with_theme(theme.clone());
tree.add(AccentProbe);
tree.layout(teksilo_canvas::SizeProposal::exact(40.0, 20.0));
tree.set_window_active(false);
tree.set_accessibility_preferences(true, false, 1.0);
assert_eq!(
painted_accent(&mut tree),
pre_hook_chain(&theme, ALL_PAINT_STATES[3])
.colors
.accent
.to_array()
);
}
#[test]
fn a_theme_without_an_input_key_still_deserializes() {
let mut value: serde_json::Value =
serde_json::to_value(intui::light()).expect("theme serializes");
assert!(
value
.as_object_mut()
.expect("theme is a JSON object")
.remove("input")
.is_some(),
"the fixture must actually have had an `input` key to remove"
);
let restored: Theme = serde_json::from_value(value).expect("pre-programme theme loads");
assert_eq!(restored.input, teksilo_tokens::InputTokens::default());
assert_eq!(restored.input.density, TargetDensity::Compact);
assert_eq!(restored.colors, intui::light().colors);
}
#[test]
fn with_density_projects_only_the_input_tokens() {
let base = intui::light();
let touch = base.with_density(TargetDensity::Touch);
assert_eq!(touch.input.density, TargetDensity::Touch);
assert_eq!(touch.input.target_size, 44.0);
assert_eq!(touch.id, base.id);
assert_eq!(touch.appearance, base.appearance);
assert_eq!(touch.colors, base.colors);
assert_eq!(touch.typography, base.typography);
assert_eq!(touch.shape, base.shape);
assert_eq!(touch.motion, base.motion);
}
#[test]
fn presets_start_compact() {
assert_eq!(intui::light().input, teksilo_tokens::InputTokens::default());
assert_eq!(intui::dark().input.density, TargetDensity::Compact);
}
#[test]
fn with_density_preserves_installed_style_slots() {
#[derive(Debug)]
struct TestButtonStyle;
impl crate::styles::ButtonStyle for TestButtonStyle {
fn make_body(
&self,
_cfg: &crate::styles::ButtonStyleConfig,
ctx: &mut crate::BuildContext,
) -> crate::WidgetId {
ctx.add(crate::test_widgets::FillWidget::new())
}
}
let mut base = intui::light();
base.style_slots.button = Some(std::rc::Rc::new(TestButtonStyle));
let touch = base.with_density(TargetDensity::Touch);
assert!(touch.style_slots.button.is_some());
assert_eq!(touch.input.density, TargetDensity::Touch);
}
}