From e9af14434454e8512e99612271b557789f28deeb Mon Sep 17 00:00:00 2001 From: pml68 Date: Mon, 7 Apr 2025 02:05:39 +0200 Subject: refactor: move custom theme into its separate crate --- material_theme/src/button.rs | 209 ++++++++++++++++++++++++++++++++++++ material_theme/src/container.rs | 173 ++++++++++++++++++++++++++++++ material_theme/src/lib.rs | 229 ++++++++++++++++++++++++++++++++++++++++ material_theme/src/text.rs | 86 +++++++++++++++ material_theme/src/utils.rs | 61 +++++++++++ 5 files changed, 758 insertions(+) create mode 100644 material_theme/src/button.rs create mode 100644 material_theme/src/container.rs create mode 100644 material_theme/src/lib.rs create mode 100644 material_theme/src/text.rs create mode 100644 material_theme/src/utils.rs (limited to 'material_theme/src') diff --git a/material_theme/src/button.rs b/material_theme/src/button.rs new file mode 100644 index 0000000..051d6c9 --- /dev/null +++ b/material_theme/src/button.rs @@ -0,0 +1,209 @@ +#![allow(dead_code)] +use iced_widget::button::{Catalog, Status, Style, StyleFn}; +use iced_widget::core::{Background, Border, Color, Shadow, Vector}; + +use crate::Theme; +use crate::utils::{elevation, mix}; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(filled) + } + + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { + class(self, status) + } +} + +fn button( + foreground: Color, + background: Color, + tone_overlay: Color, + disabled: Color, + shadow_color: Color, + shadow_elevation: u8, + status: Status, +) -> Style { + let border = Border { + radius: 400.into(), + ..Default::default() + }; + + let active = Style { + background: Some(Background::Color(background)), + text_color: foreground, + border, + shadow: Shadow { + color: shadow_color, + offset: Vector { + x: 0.0, + y: elevation(shadow_elevation), + }, + blur_radius: elevation(shadow_elevation) + * (1.0 + 0.4_f32.powf(elevation(shadow_elevation))), + }, + }; + + match status { + Status::Active => active, + Status::Pressed => Style { + background: Some(Background::Color(mix( + background, + tone_overlay, + 0.08, + ))), + ..active + }, + Status::Hovered => Style { + background: Some(Background::Color(mix( + background, + tone_overlay, + 0.1, + ))), + text_color: foreground, + border, + shadow: Shadow { + color: shadow_color, + offset: Vector { + x: 0.0, + y: elevation(shadow_elevation + 1), + }, + blur_radius: (elevation(shadow_elevation + 1)) + * (1.0 + 0.4_f32.powf(elevation(shadow_elevation + 1))), + }, + }, + Status::Disabled => Style { + background: Some(Background::Color(Color { + a: 0.12, + ..disabled + })), + text_color: Color { + a: 0.38, + ..disabled + }, + border, + ..Default::default() + }, + } +} + +pub fn elevated(theme: &Theme, status: Status) -> Style { + let surface_colors = theme.colorscheme.surface; + + let foreground = theme.colorscheme.primary.color; + let background = surface_colors.surface_container.low; + let disabled = surface_colors.on_surface; + + let shadow_color = theme.colorscheme.shadow; + + button( + foreground, + background, + foreground, + disabled, + shadow_color, + 1, + status, + ) +} + +pub fn filled(theme: &Theme, status: Status) -> Style { + let primary_colors = theme.colorscheme.primary; + + let foreground = primary_colors.on_primary; + let background = primary_colors.color; + let disabled = theme.colorscheme.surface.on_surface; + + let shadow_color = theme.colorscheme.shadow; + + button( + foreground, + background, + foreground, + disabled, + shadow_color, + 0, + status, + ) +} + +pub fn filled_tonal(theme: &Theme, status: Status) -> Style { + let secondary_colors = theme.colorscheme.secondary; + + let foreground = secondary_colors.on_secondary_container; + let background = secondary_colors.secondary_container; + let disabled = theme.colorscheme.surface.on_surface; + let shadow_color = theme.colorscheme.shadow; + + button( + foreground, + background, + foreground, + disabled, + shadow_color, + 0, + status, + ) +} + +pub fn outlined(theme: &Theme, status: Status) -> Style { + let foreground = theme.colorscheme.primary.color; + let background = Color::TRANSPARENT; + let disabled = theme.colorscheme.surface.on_surface; + + let outline = theme.colorscheme.outline.color; + + let border = match status { + Status::Active | Status::Pressed | Status::Hovered => Border { + color: outline, + width: 1.0, + radius: 400.0.into(), + }, + Status::Disabled => Border { + color: Color { + a: 0.12, + ..disabled + }, + width: 1.0, + radius: 400.0.into(), + }, + }; + + let style = button( + foreground, + background, + foreground, + disabled, + Color::TRANSPARENT, + 0, + status, + ); + + Style { border, ..style } +} + +pub fn text(theme: &Theme, status: Status) -> Style { + let foreground = theme.colorscheme.primary.color; + let background = Color::TRANSPARENT; + let disabled = theme.colorscheme.surface.on_surface; + + let style = button( + foreground, + background, + foreground, + disabled, + Color::TRANSPARENT, + 0, + status, + ); + + match status { + Status::Hovered | Status::Pressed => style, + _ => Style { + background: None, + ..style + }, + } +} diff --git a/material_theme/src/container.rs b/material_theme/src/container.rs new file mode 100644 index 0000000..a14cfd5 --- /dev/null +++ b/material_theme/src/container.rs @@ -0,0 +1,173 @@ +use iced_widget::container::{Catalog, Style, StyleFn}; +use iced_widget::core::{Background, border}; + +use super::Theme; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(transparent) + } + + fn style(&self, class: &Self::Class<'_>) -> Style { + class(self) + } +} + +pub fn transparent(_theme: &Theme) -> Style { + Style { + border: border::rounded(4), + ..Style::default() + } +} + +pub fn primary(theme: &Theme) -> Style { + let colors = theme.colorscheme.primary; + Style { + background: Some(Background::Color(colors.color)), + text_color: Some(colors.on_primary), + border: border::rounded(4), + ..Style::default() + } +} + +pub fn primary_container(theme: &Theme) -> Style { + let colors = theme.colorscheme.primary; + Style { + background: Some(Background::Color(colors.primary_container)), + text_color: Some(colors.on_primary_container), + border: border::rounded(8), + ..Style::default() + } +} + +pub fn secondary(theme: &Theme) -> Style { + let colors = theme.colorscheme.secondary; + Style { + background: Some(Background::Color(colors.color)), + text_color: Some(colors.on_secondary), + border: border::rounded(4), + ..Style::default() + } +} + +pub fn secondary_container(theme: &Theme) -> Style { + let colors = theme.colorscheme.secondary; + Style { + background: Some(Background::Color(colors.secondary_container)), + text_color: Some(colors.on_secondary_container), + border: border::rounded(8), + ..Style::default() + } +} + +pub fn tertiary(theme: &Theme) -> Style { + let colors = theme.colorscheme.tertiary; + Style { + background: Some(Background::Color(colors.color)), + text_color: Some(colors.on_tertiary), + border: border::rounded(4), + ..Style::default() + } +} + +pub fn tertiary_container(theme: &Theme) -> Style { + let colors = theme.colorscheme.tertiary; + Style { + background: Some(Background::Color(colors.tertiary_container)), + text_color: Some(colors.on_tertiary_container), + border: border::rounded(8), + ..Style::default() + } +} + +pub fn error(theme: &Theme) -> Style { + let colors = theme.colorscheme.error; + Style { + background: Some(Background::Color(colors.color)), + text_color: Some(colors.on_error), + border: border::rounded(4), + ..Style::default() + } +} + +pub fn error_container(theme: &Theme) -> Style { + let colors = theme.colorscheme.error; + Style { + background: Some(Background::Color(colors.error_container)), + text_color: Some(colors.on_error_container), + border: border::rounded(8), + ..Style::default() + } +} + +pub fn surface(theme: &Theme) -> Style { + let colors = theme.colorscheme.surface; + Style { + background: Some(Background::Color(colors.color)), + text_color: Some(colors.on_surface), + border: border::rounded(4), + ..Style::default() + } +} + +pub fn surface_container_lowest(theme: &Theme) -> Style { + let colors = theme.colorscheme.surface; + Style { + background: Some(Background::Color(colors.surface_container.lowest)), + text_color: Some(colors.on_surface), + border: border::rounded(8), + ..Style::default() + } +} + +pub fn surface_container_low(theme: &Theme) -> Style { + let colors = theme.colorscheme.surface; + Style { + background: Some(Background::Color(colors.surface_container.low)), + text_color: Some(colors.on_surface), + border: border::rounded(8), + ..Style::default() + } +} + +pub fn surface_container(theme: &Theme) -> Style { + let colors = theme.colorscheme.surface; + Style { + background: Some(Background::Color(colors.surface_container.base)), + text_color: Some(colors.on_surface), + border: border::rounded(8), + ..Style::default() + } +} + +pub fn surface_container_high(theme: &Theme) -> Style { + let colors = theme.colorscheme.surface; + Style { + background: Some(Background::Color(colors.surface_container.high)), + text_color: Some(colors.on_surface), + border: border::rounded(8), + ..Style::default() + } +} + +pub fn surface_container_highest(theme: &Theme) -> Style { + let colors = theme.colorscheme.surface; + Style { + background: Some(Background::Color(colors.surface_container.highest)), + text_color: Some(colors.on_surface), + border: border::rounded(8), + ..Style::default() + } +} + +pub fn inverse_surface(theme: &Theme) -> Style { + let colors = theme.colorscheme.inverse; + Style { + background: Some(Background::Color(colors.inverse_surface)), + text_color: Some(colors.inverse_on_surface), + border: border::rounded(4), + ..Style::default() + } +} diff --git a/material_theme/src/lib.rs b/material_theme/src/lib.rs new file mode 100644 index 0000000..6641b74 --- /dev/null +++ b/material_theme/src/lib.rs @@ -0,0 +1,229 @@ +use std::sync::LazyLock; + +use iced_widget::core::Color; +use iced_widget::core::theme::{Base, Style}; +use serde::Deserialize; + +pub mod button; +pub mod container; +pub mod text; +pub mod utils; + +const DARK_THEME_CONTENT: &str = include_str!("../assets/themes/dark.toml"); +const LIGHT_THEME_CONTENT: &str = include_str!("../assets/themes/light.toml"); + +#[derive(Debug, PartialEq, Deserialize)] +pub struct Theme { + pub name: String, + #[serde(flatten)] + pub colorscheme: ColorScheme, +} + +impl Theme { + pub fn new(name: impl Into, colorscheme: ColorScheme) -> Self { + Self { + name: name.into(), + colorscheme, + } + } +} + +impl Clone for Theme { + fn clone(&self) -> Self { + Self { + name: self.name.clone(), + colorscheme: self.colorscheme, + } + } + + fn clone_from(&mut self, source: &Self) { + self.name = source.name.clone(); + self.colorscheme = source.colorscheme; + } +} + +impl Default for Theme { + fn default() -> Self { + match dark_light::detect().unwrap_or(dark_light::Mode::Unspecified) { + dark_light::Mode::Dark | dark_light::Mode::Unspecified => { + DARK.clone() + } + dark_light::Mode::Light => LIGHT.clone(), + } + } +} + +impl Base for Theme { + fn base(&self) -> Style { + Style { + background_color: self.colorscheme.surface.color, + text_color: self.colorscheme.surface.on_surface, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +pub struct ColorScheme { + pub primary: Primary, + pub secondary: Secondary, + pub tertiary: Tertiary, + pub error: Error, + pub surface: Surface, + pub inverse: Inverse, + pub outline: Outline, + #[serde(with = "color_serde")] + pub shadow: Color, +} + +pub static DARK: LazyLock = LazyLock::new(|| { + toml::from_str(DARK_THEME_CONTENT).expect("parse dark theme") +}); + +pub static LIGHT: LazyLock = LazyLock::new(|| { + toml::from_str(LIGHT_THEME_CONTENT).expect("parse light theme") +}); + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +pub struct Primary { + #[serde(with = "color_serde")] + pub color: Color, + #[serde(with = "color_serde")] + pub on_primary: Color, + #[serde(with = "color_serde")] + pub primary_container: Color, + #[serde(with = "color_serde")] + pub on_primary_container: Color, +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +pub struct Secondary { + #[serde(with = "color_serde")] + pub color: Color, + #[serde(with = "color_serde")] + pub on_secondary: Color, + #[serde(with = "color_serde")] + pub secondary_container: Color, + #[serde(with = "color_serde")] + pub on_secondary_container: Color, +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +pub struct Tertiary { + #[serde(with = "color_serde")] + pub color: Color, + #[serde(with = "color_serde")] + pub on_tertiary: Color, + #[serde(with = "color_serde")] + pub tertiary_container: Color, + #[serde(with = "color_serde")] + pub on_tertiary_container: Color, +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +pub struct Error { + #[serde(with = "color_serde")] + pub color: Color, + #[serde(with = "color_serde")] + pub on_error: Color, + #[serde(with = "color_serde")] + pub error_container: Color, + #[serde(with = "color_serde")] + pub on_error_container: Color, +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +pub struct Surface { + #[serde(with = "color_serde")] + pub color: Color, + #[serde(with = "color_serde")] + pub on_surface: Color, + #[serde(with = "color_serde")] + pub on_surface_variant: Color, + pub surface_container: SurfaceContainer, +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +pub struct SurfaceContainer { + #[serde(with = "color_serde")] + pub lowest: Color, + #[serde(with = "color_serde")] + pub low: Color, + #[serde(with = "color_serde")] + pub base: Color, + #[serde(with = "color_serde")] + pub high: Color, + #[serde(with = "color_serde")] + pub highest: Color, +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +pub struct Inverse { + #[serde(with = "color_serde")] + pub inverse_surface: Color, + #[serde(with = "color_serde")] + pub inverse_on_surface: Color, + #[serde(with = "color_serde")] + pub inverse_primary: Color, +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +pub struct Outline { + #[serde(with = "color_serde")] + pub color: Color, + #[serde(with = "color_serde")] + pub variant: Color, +} + +pub fn parse_argb(s: &str) -> Option { + let hex = s.strip_prefix('#').unwrap_or(s); + + let parse_channel = |from: usize, to: usize| { + let num = + usize::from_str_radix(&hex[from..=to], 16).ok()? as f32 / 255.0; + + // If we only got half a byte (one letter), expand it into a full byte (two letters) + Some(if from == to { num + num * 16.0 } else { num }) + }; + + Some(match hex.len() { + 3 => Color::from_rgb( + parse_channel(0, 0)?, + parse_channel(1, 1)?, + parse_channel(2, 2)?, + ), + 4 => Color::from_rgba( + parse_channel(1, 1)?, + parse_channel(2, 2)?, + parse_channel(3, 3)?, + parse_channel(0, 0)?, + ), + 6 => Color::from_rgb( + parse_channel(0, 1)?, + parse_channel(2, 3)?, + parse_channel(4, 5)?, + ), + 8 => Color::from_rgba( + parse_channel(2, 3)?, + parse_channel(4, 5)?, + parse_channel(6, 7)?, + parse_channel(0, 1)?, + ), + _ => None?, + }) +} + +mod color_serde { + use iced_widget::core::Color; + use serde::{Deserialize, Deserializer}; + + use super::parse_argb; + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(String::deserialize(deserializer) + .map(|hex| parse_argb(&hex))? + .unwrap_or(Color::TRANSPARENT)) + } +} diff --git a/material_theme/src/text.rs b/material_theme/src/text.rs new file mode 100644 index 0000000..10b2e65 --- /dev/null +++ b/material_theme/src/text.rs @@ -0,0 +1,86 @@ +#![allow(dead_code)] +use iced_widget::text::{Catalog, Style, StyleFn}; + +use crate::Theme; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(none) + } + + fn style(&self, class: &Self::Class<'_>) -> Style { + class(self) + } +} + +pub fn none(_: &Theme) -> Style { + Style { color: None } +} + +pub fn primary(theme: &Theme) -> Style { + Style { + color: Some(theme.colorscheme.primary.on_primary), + } +} + +pub fn primary_container(theme: &Theme) -> Style { + Style { + color: Some(theme.colorscheme.primary.on_primary_container), + } +} + +pub fn secondary(theme: &Theme) -> Style { + Style { + color: Some(theme.colorscheme.secondary.on_secondary), + } +} + +pub fn secondary_container(theme: &Theme) -> Style { + Style { + color: Some(theme.colorscheme.secondary.on_secondary_container), + } +} + +pub fn tertiary(theme: &Theme) -> Style { + Style { + color: Some(theme.colorscheme.tertiary.on_tertiary), + } +} + +pub fn tertiary_container(theme: &Theme) -> Style { + Style { + color: Some(theme.colorscheme.tertiary.on_tertiary_container), + } +} + +pub fn error(theme: &Theme) -> Style { + Style { + color: Some(theme.colorscheme.error.on_error), + } +} + +pub fn error_container(theme: &Theme) -> Style { + Style { + color: Some(theme.colorscheme.error.on_error_container), + } +} + +pub fn surface(theme: &Theme) -> Style { + Style { + color: Some(theme.colorscheme.surface.on_surface), + } +} + +pub fn surface_variant(theme: &Theme) -> Style { + Style { + color: Some(theme.colorscheme.surface.on_surface_variant), + } +} + +pub fn inverse_surface(theme: &Theme) -> Style { + Style { + color: Some(theme.colorscheme.inverse.inverse_on_surface), + } +} diff --git a/material_theme/src/utils.rs b/material_theme/src/utils.rs new file mode 100644 index 0000000..c9eb78e --- /dev/null +++ b/material_theme/src/utils.rs @@ -0,0 +1,61 @@ +use iced_widget::core::Color; + +pub fn elevation(elevation_level: u8) -> f32 { + (match elevation_level { + 0 => 0.0, + 1 => 1.0, + 2 => 3.0, + 3 => 6.0, + 4 => 8.0, + _ => 12.0, + } as f32) +} + +pub fn mix(color1: Color, color2: Color, p2: f32) -> Color { + if p2 <= 0.0 { + return color1; + } else if p2 >= 1.0 { + return color2; + } + + let p1 = 1.0 - p2; + + if color1.a != 1.0 || color2.a != 1.0 { + let a = color1.a * p1 + color2.a * p2; + if a > 0.0 { + let c1 = color1.into_linear().map(|c| c * color1.a * p1); + let c2 = color2.into_linear().map(|c| c * color2.a * p2); + + let [r, g, b] = + [c1[0] + c2[0], c1[1] + c2[1], c1[2] + c2[2]].map(|u| u / a); + + return Color::from_linear_rgba(r, g, b, a); + } + } + + let c1 = color1.into_linear().map(|c| c * p1); + let c2 = color2.into_linear().map(|c| c * p2); + + Color::from_linear_rgba( + c1[0] + c2[0], + c1[1] + c2[1], + c1[2] + c2[2], + c1[3] + c2[3], + ) +} + +#[cfg(test)] +mod tests { + use super::{Color, mix}; + + #[test] + fn mixing_works() { + let base = Color::from_rgba(1.0, 0.0, 0.0, 0.7); + let overlay = Color::from_rgba(0.0, 1.0, 0.0, 0.2); + + assert_eq!( + mix(base, overlay, 0.75).into_rgba8(), + Color::from_linear_rgba(0.53846, 0.46154, 0.0, 0.325).into_rgba8() + ); + } +} -- cgit v1.2.3