diff options
| author | pml68 <contact@pml68.dev> | 2025-06-26 11:41:52 +0200 |
|---|---|---|
| committer | pml68 <contact@pml68.dev> | 2025-06-26 11:41:52 +0200 |
| commit | 602565f98f3a22fa1c39e054c6a88b3c31a36599 (patch) | |
| tree | 8aea60f9cfdc48a8e04651a7e809bb70ef4bd712 | |
| download | iced_material-602565f98f3a22fa1c39e054c6a88b3c31a36599.tar.gz | |
feat: initial commit
| -rw-r--r-- | .cargo/config.toml | 3 | ||||
| -rw-r--r-- | .gitattributes | 6 | ||||
| -rw-r--r-- | .github/workflows/ci.yml | 29 | ||||
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | Cargo.toml | 69 | ||||
| -rw-r--r-- | LICENSE | 21 | ||||
| -rw-r--r-- | README.md | 3 | ||||
| -rw-r--r-- | src/button.rs | 157 | ||||
| -rw-r--r-- | src/checkbox.rs | 115 | ||||
| -rw-r--r-- | src/combo_box.rs | 5 | ||||
| -rw-r--r-- | src/container.rs | 200 | ||||
| -rw-r--r-- | src/dialog.rs | 41 | ||||
| -rw-r--r-- | src/lib.rs | 483 | ||||
| -rw-r--r-- | src/markdown.rs | 10 | ||||
| -rw-r--r-- | src/menu.rs | 33 | ||||
| -rw-r--r-- | src/pane_grid.rs | 38 | ||||
| -rw-r--r-- | src/pick_list.rs | 45 | ||||
| -rw-r--r-- | src/progress_bar.rs | 26 | ||||
| -rw-r--r-- | src/qr_code.rs | 24 | ||||
| -rw-r--r-- | src/radio.rs | 62 | ||||
| -rw-r--r-- | src/rule.rs | 35 | ||||
| -rw-r--r-- | src/scrollable.rs | 146 | ||||
| -rw-r--r-- | src/slider.rs | 63 | ||||
| -rw-r--r-- | src/svg.rs | 15 | ||||
| -rw-r--r-- | src/text.rs | 86 | ||||
| -rw-r--r-- | src/text_editor.rs | 67 | ||||
| -rw-r--r-- | src/text_input.rs | 63 | ||||
| -rw-r--r-- | src/toggler.rs | 73 | ||||
| -rw-r--r-- | src/utils.rs | 149 |
29 files changed, 2069 insertions, 0 deletions
diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..feb6aad --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[alias] +lint = "clippy --no-deps -- -D warnings" +lint-all = "clippy --no-deps -- -D clippy::pedantic" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2742e4d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +* text=auto eol=lf + +# Older git versions try to fix line endings on images, this prevents it. +*.png binary +*.jpg binary +*.ico binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..283de2c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI +on: [push, pull_request] +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: hecrj/setup-rust-action@v2 + with: + components: clippy + - uses: actions/checkout@master + - name: Install dependencies + run: | + export DEBIAN_FRONTED=noninteractive + sudo apt-get -qq update + sudo apt-get install -y libxkbcommon-dev + - name: Check lints + run: cargo lint + test: + runs-on: ubuntu-latest + steps: + - uses: hecrj/setup-rust-action@v2 + - uses: actions/checkout@master + - name: Install dependencies + run: | + export DEBIAN_FRONTED=noninteractive + sudo apt-get -qq update + sudo apt-get install -y libxkbcommon-dev + - name: Run tests + run: cargo test --verbose diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c96eb1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/ +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3c6ddd8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,69 @@ +[package] +name = "iced_material" +description = "A Material3 inspired theme for `iced`" +authors = ["pml68 <contact@pml68.dev>"] +version = "0.14.0-dev" +edition = "2024" +license = "MIT" +repository = "https://github.com/pml68/iced_builder" +categories = ["gui"] +keywords = ["gui", "ui", "graphics", "interface", "widgets"] +rust-version = "1.85" + +[features] +default = ["system-theme"] +# Adds a `System` variant that follows the system theme mode. +system-theme = ["dep:dark-light"] +# Provides `serde` support. +serde = ["dep:serde"] +# Provides support for animating with `iced_anim`. +animate = ["dep:iced_anim"] +# Enables pixel snapping for crisp edges by default (can cause jitter!). +crisp = ["iced_widget/crisp"] +# Provides support for `iced_dialog`. +dialog = ["dep:iced_dialog"] +# Provides support for the markdown widget. +markdown = ["iced_widget/markdown"] +# Provides support for the SVG widget. +svg = ["iced_widget/svg"] +# Provides support for the QR code widget. +qr_code = ["iced_widget/qr_code"] + +[dependencies] +iced_widget = { git = "https://github.com/iced-rs/iced", branch = "master" } +dark-light = { version = "2.0", optional = true } +serde = { version = "1.0", optional = true } + +[dependencies.iced_dialog] +git = "https://github.com/pml68/iced_dialog" +branch = "master" +optional = true + +[dependencies.iced_anim] +git = "https://github.com/bradysimon/iced_anim" +branch = "iced/master" +features = ["derive"] +optional = true + +[lints.rust] +missing_debug_implementations = "deny" +unsafe_code = "deny" +unused_results = "deny" + +[lints.clippy] +type-complexity = "allow" +semicolon_if_nothing_returned = "deny" +trivially-copy-pass-by-ref = "deny" +default_trait_access = "deny" +match-wildcard-for-single-variants = "deny" +redundant-closure-for-method-calls = "deny" +filter_map_next = "deny" +manual_let_else = "deny" +unused_async = "deny" +from_over_into = "deny" +needless_borrow = "deny" +new_without_default = "deny" +useless_conversion = "deny" + +[lints.rustdoc] +broken_intra_doc_links = "forbid" @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Polesznyák Márk + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..da5a1ec --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# material_theme + +## A [Material3](https://m3.material.io) inspired custom theme for [`iced`](https://iced.rs) diff --git a/src/button.rs b/src/button.rs new file mode 100644 index 0000000..2800ac4 --- /dev/null +++ b/src/button.rs @@ -0,0 +1,157 @@ +use iced_widget::button::{Catalog, Status, Style, StyleFn}; +use iced_widget::core::{Background, Border, Color, border}; + +use crate::Theme; +use crate::utils::{ + HOVERED_LAYER_OPACITY, PRESSED_LAYER_OPACITY, disabled_container, + disabled_text, elevation, mix, shadow_from_elevation, +}; + +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( + background: Color, + foreground: Color, + disabled: Color, + shadow_color: Color, + elevation_level: u8, + status: Status, +) -> Style { + let active = Style { + background: Some(Background::Color(background)), + text_color: foreground, + border: border::rounded(400), + shadow: shadow_from_elevation(elevation(elevation_level), shadow_color), + snap: cfg!(feature = "crisp"), + }; + + match status { + Status::Active => active, + Status::Pressed => Style { + background: Some(Background::Color(mix( + background, + foreground, + HOVERED_LAYER_OPACITY, + ))), + ..active + }, + Status::Hovered => Style { + background: Some(Background::Color(mix( + background, + foreground, + PRESSED_LAYER_OPACITY, + ))), + shadow: shadow_from_elevation( + elevation(elevation_level + 1), + shadow_color, + ), + ..active + }, + Status::Disabled => Style { + background: Some(Background::Color(disabled_container(disabled))), + text_color: disabled_text(disabled), + border: border::rounded(400), + ..Default::default() + }, + } +} + +pub fn elevated(theme: &Theme, status: Status) -> Style { + let surface = theme.colors().surface; + + let foreground = theme.colors().primary.color; + let background = surface.surface_container.low; + let disabled = surface.on_surface; + + let shadow_color = theme.colors().shadow; + + button(background, foreground, disabled, shadow_color, 1, status) +} + +pub fn filled(theme: &Theme, status: Status) -> Style { + let primary = theme.colors().primary; + + let foreground = primary.on_primary; + let background = primary.color; + let disabled = theme.colors().surface.on_surface; + + let shadow_color = theme.colors().shadow; + + button(background, foreground, disabled, shadow_color, 0, status) +} + +pub fn filled_tonal(theme: &Theme, status: Status) -> Style { + let secondary = theme.colors().secondary; + + let foreground = secondary.on_secondary_container; + let background = secondary.secondary_container; + let disabled = theme.colors().surface.on_surface; + let shadow_color = theme.colors().shadow; + + button(background, foreground, disabled, shadow_color, 0, status) +} + +pub fn outlined(theme: &Theme, status: Status) -> Style { + let foreground = theme.colors().primary.color; + let background = Color::TRANSPARENT; + let disabled = theme.colors().surface.on_surface; + + let outline = theme.colors().outline.color; + + let border = match status { + Status::Active | Status::Pressed | Status::Hovered => Border { + color: outline, + width: 1.0, + radius: 400.into(), + }, + Status::Disabled => Border { + color: disabled_container(disabled), + width: 1.0, + radius: 400.into(), + }, + }; + + let style = button( + background, + foreground, + disabled, + Color::TRANSPARENT, + 0, + status, + ); + + Style { border, ..style } +} + +pub fn text(theme: &Theme, status: Status) -> Style { + let foreground = theme.colors().primary.color; + let background = Color::TRANSPARENT; + let disabled = theme.colors().surface.on_surface; + + let style = button( + background, + foreground, + disabled, + Color::TRANSPARENT, + 0, + status, + ); + + match status { + Status::Hovered | Status::Pressed => style, + Status::Active | Status::Disabled => Style { + background: None, + ..style + }, + } +} diff --git a/src/checkbox.rs b/src/checkbox.rs new file mode 100644 index 0000000..7a3729c --- /dev/null +++ b/src/checkbox.rs @@ -0,0 +1,115 @@ +use iced_widget::checkbox::{Catalog, Status, Style, StyleFn}; +use iced_widget::core::{Background, Border, Color, border}; + +use super::Theme; +use crate::utils::{HOVERED_LAYER_OPACITY, disabled_text, mix}; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(default) + } + + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { + class(self, status) + } +} + +pub fn styled( + background_color: Color, + background_unchecked: Option<Color>, + icon_color: Color, + border_color: Color, + text_color: Option<Color>, + is_checked: bool, +) -> Style { + Style { + background: Background::Color(if is_checked { + background_color + } else { + background_unchecked.unwrap_or(Color::TRANSPARENT) + }), + icon_color, + border: if is_checked { + border::rounded(2) + } else { + Border { + color: border_color, + width: 2.0, + radius: 2.into(), + } + }, + text_color, + } +} + +pub fn default(theme: &Theme, status: Status) -> Style { + let surface = theme.colors().surface; + let primary = theme.colors().primary; + + match status { + Status::Active { is_checked } => styled( + primary.color, + None, + primary.on_primary, + surface.on_surface_variant, + Some(surface.on_surface), + is_checked, + ), + Status::Hovered { is_checked } => styled( + mix(primary.color, surface.on_surface, HOVERED_LAYER_OPACITY), + Some(Color { + a: HOVERED_LAYER_OPACITY, + ..surface.on_surface + }), + primary.on_primary, + surface.on_surface_variant, + Some(surface.on_surface), + is_checked, + ), + Status::Disabled { is_checked } => styled( + disabled_text(surface.on_surface), + None, + surface.color, + disabled_text(surface.on_surface), + Some(surface.on_surface), + is_checked, + ), + } +} + +pub fn error(theme: &Theme, status: Status) -> Style { + let surface = theme.colors().surface; + let error = theme.colors().error; + + match status { + Status::Active { is_checked } => styled( + error.color, + None, + error.on_error, + error.color, + Some(error.color), + is_checked, + ), + Status::Hovered { is_checked } => styled( + mix(error.color, surface.on_surface, HOVERED_LAYER_OPACITY), + Some(Color { + a: HOVERED_LAYER_OPACITY, + ..error.color + }), + error.on_error, + error.color, + Some(error.color), + is_checked, + ), + Status::Disabled { is_checked } => styled( + disabled_text(surface.on_surface), + None, + surface.color, + disabled_text(surface.on_surface), + Some(surface.on_surface), + is_checked, + ), + } +} diff --git a/src/combo_box.rs b/src/combo_box.rs new file mode 100644 index 0000000..3024176 --- /dev/null +++ b/src/combo_box.rs @@ -0,0 +1,5 @@ +use iced_widget::combo_box::Catalog; + +use super::Theme; + +impl Catalog for Theme {} diff --git a/src/container.rs b/src/container.rs new file mode 100644 index 0000000..5c253ad --- /dev/null +++ b/src/container.rs @@ -0,0 +1,200 @@ +use iced_widget::container::{Catalog, Style, StyleFn}; +use iced_widget::core::{Background, Border, 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 primary = theme.colors().primary; + + Style { + background: Some(Background::Color(primary.color)), + text_color: Some(primary.on_primary), + border: border::rounded(4), + ..Style::default() + } +} + +pub fn primary_container(theme: &Theme) -> Style { + let primary = theme.colors().primary; + + Style { + background: Some(Background::Color(primary.primary_container)), + text_color: Some(primary.on_primary_container), + border: border::rounded(8), + ..Style::default() + } +} + +pub fn secondary(theme: &Theme) -> Style { + let secondary = theme.colors().secondary; + + Style { + background: Some(Background::Color(secondary.color)), + text_color: Some(secondary.on_secondary), + border: border::rounded(4), + ..Style::default() + } +} + +pub fn secondary_container(theme: &Theme) -> Style { + let secondary = theme.colors().secondary; + + Style { + background: Some(Background::Color(secondary.secondary_container)), + text_color: Some(secondary.on_secondary_container), + border: border::rounded(8), + ..Style::default() + } +} + +pub fn tertiary(theme: &Theme) -> Style { + let tertiary = theme.colors().tertiary; + + Style { + background: Some(Background::Color(tertiary.color)), + text_color: Some(tertiary.on_tertiary), + border: border::rounded(4), + ..Style::default() + } +} + +pub fn tertiary_container(theme: &Theme) -> Style { + let tertiary = theme.colors().tertiary; + + Style { + background: Some(Background::Color(tertiary.tertiary_container)), + text_color: Some(tertiary.on_tertiary_container), + border: border::rounded(8), + ..Style::default() + } +} + +pub fn error(theme: &Theme) -> Style { + let error = theme.colors().error; + + Style { + background: Some(Background::Color(error.color)), + text_color: Some(error.on_error), + border: border::rounded(4), + ..Style::default() + } +} + +pub fn error_container(theme: &Theme) -> Style { + let error = theme.colors().error; + + Style { + background: Some(Background::Color(error.error_container)), + text_color: Some(error.on_error_container), + border: border::rounded(8), + ..Style::default() + } +} + +pub fn surface(theme: &Theme) -> Style { + let surface = theme.colors().surface; + + Style { + background: Some(Background::Color(surface.color)), + text_color: Some(surface.on_surface), + border: border::rounded(4), + ..Style::default() + } +} + +pub fn surface_container_lowest(theme: &Theme) -> Style { + let surface = theme.colors().surface; + + Style { + background: Some(Background::Color(surface.surface_container.lowest)), + text_color: Some(surface.on_surface), + border: border::rounded(8), + ..Style::default() + } +} + +pub fn surface_container_low(theme: &Theme) -> Style { + let surface = theme.colors().surface; + + Style { + background: Some(Background::Color(surface.surface_container.low)), + text_color: Some(surface.on_surface), + border: border::rounded(8), + ..Style::default() + } +} + +pub fn surface_container(theme: &Theme) -> Style { + let surface = theme.colors().surface; + + Style { + background: Some(Background::Color(surface.surface_container.base)), + text_color: Some(surface.on_surface), + border: border::rounded(8), + ..Style::default() + } +} + +pub fn surface_container_high(theme: &Theme) -> Style { + let surface = theme.colors().surface; + + Style { + background: Some(Background::Color(surface.surface_container.high)), + text_color: Some(surface.on_surface), + border: border::rounded(8), + ..Style::default() + } +} + +pub fn surface_container_highest(theme: &Theme) -> Style { + let surface = theme.colors().surface; + + Style { + background: Some(Background::Color(surface.surface_container.highest)), + text_color: Some(surface.on_surface), + border: border::rounded(8), + ..Style::default() + } +} + +pub fn inverse_surface(theme: &Theme) -> Style { + let inverse = theme.colors().inverse; + + Style { + background: Some(Background::Color(inverse.inverse_surface)), + text_color: Some(inverse.inverse_on_surface), + border: border::rounded(4), + ..Style::default() + } +} + +pub fn outlined(theme: &Theme) -> Style { + let base = transparent(theme); + + Style { + border: Border { + color: theme.colors().outline.color, + ..base.border + }, + ..base + } +} diff --git a/src/dialog.rs b/src/dialog.rs new file mode 100644 index 0000000..c839948 --- /dev/null +++ b/src/dialog.rs @@ -0,0 +1,41 @@ +use iced_dialog::dialog::{Catalog, Style, StyleFn}; +use iced_widget::container; +use iced_widget::core::{Background, border}; + +use super::{Theme, text}; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> <Self as Catalog>::Class<'a> { + Box::new(default) + } + + fn default_container<'a>() -> <Self as container::Catalog>::Class<'a> { + Box::new(default_container) + } + + fn default_title<'a>() -> <Self as iced_widget::text::Catalog>::Class<'a> { + Box::new(text::surface) + } + + fn style(&self, class: &<Self as Catalog>::Class<'_>) -> Style { + class(self) + } +} + +pub fn default_container(theme: &Theme) -> container::Style { + let colors = theme.colors().surface; + container::Style { + background: Some(Background::Color(colors.surface_container.high)), + text_color: Some(colors.on_surface_variant), + border: border::rounded(28), + ..container::Style::default() + } +} + +pub fn default(theme: &Theme) -> Style { + Style { + backdrop_color: theme.colors().scrim, + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..0cf1f13 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,483 @@ +use std::borrow::Cow; + +use iced_widget::core::theme::{Base, Style}; +use iced_widget::core::{color, Color}; +use utils::{lightness, mix}; + +pub mod button; +pub mod checkbox; +pub mod combo_box; +pub mod container; +#[cfg(feature = "dialog")] +pub mod dialog; +#[cfg(feature = "markdown")] +pub mod markdown; +pub mod menu; +pub mod pane_grid; +pub mod pick_list; +pub mod progress_bar; +#[cfg(feature = "qr_code")] +pub mod qr_code; +pub mod radio; +pub mod rule; +pub mod scrollable; +pub mod slider; +#[cfg(feature = "svg")] +pub mod svg; +pub mod text; +pub mod text_editor; +pub mod text_input; +pub mod toggler; +pub mod utils; + +#[allow(clippy::cast_precision_loss)] +macro_rules! from_argb { + ($hex:expr) => {{ + let hex = $hex as u32; + + let a = ((hex & 0xff000000) >> 24) as f32 / 255.0; + let r = (hex & 0x00ff0000) >> 16; + let g = (hex & 0x0000ff00) >> 8; + let b = (hex & 0x000000ff); + + ::iced_widget::core::color!(r as u8, g as u8, b as u8, a) + }}; +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(from = "Custom", into = "Custom"))] +pub enum Theme { + Dark, + Light, + #[cfg(feature = "system-theme")] + System, + Custom(Custom), +} + +impl Theme { + pub const ALL: &'static [Self] = &[Self::Dark, Self::Light]; + + pub fn new(name: impl Into<Cow<'static, str>>, colorscheme: ColorScheme) -> Self { + Self::Custom(Custom { + name: name.into(), + is_dark: lightness(colorscheme.surface.color) <= 0.5, + colorscheme, + }) + } + + pub const fn new_const(name: &'static str, colorscheme: ColorScheme) -> Self { + Self::Custom(Custom { + name: Cow::Borrowed(name), + is_dark: lightness(colorscheme.surface.color) <= 0.5, + colorscheme, + }) + } + + pub fn name(&self) -> Cow<'static, str> { + match self { + Self::Dark => "Dark".into(), + Self::Light => "Light".into(), + #[cfg(feature = "system-theme")] + Self::System => "System".into(), + Self::Custom(custom) => custom.name.clone(), + } + } + + pub fn is_dark(&self) -> bool { + match self { + Self::Dark => true, + Self::Light => false, + #[cfg(feature = "system-theme")] + Self::System => !dark_light::detect() + .ok() + .is_some_and(|mode| mode == dark_light::Mode::Light), + Self::Custom(custom) => custom.is_dark, + } + } + + pub fn colors(&self) -> ColorScheme { + match self { + Self::Dark => ColorScheme::DARK, + Self::Light => ColorScheme::LIGHT, + #[cfg(feature = "system-theme")] + Self::System => { + if dark_light::detect() + .ok() + .is_some_and(|mode| mode == dark_light::Mode::Light) + { + ColorScheme::LIGHT + } else { + ColorScheme::DARK + } + } + Self::Custom(custom) => custom.colorscheme, + } + } +} + +impl Default for Theme { + fn default() -> Self { + #[cfg(feature = "system-theme")] + { + Self::System + } + + #[cfg(not(feature = "system-theme"))] + { + Self::Dark + } + } +} + +impl std::fmt::Display for Theme { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name()) + } +} + +impl Base for Theme { + fn base(&self) -> Style { + Style { + background_color: self.colors().surface.color, + text_color: self.colors().surface.on_surface, + } + } + + fn palette(&self) -> Option<iced_widget::theme::Palette> { + let colors = self.colors(); + + Some(iced_widget::theme::Palette { + background: colors.surface.color, + text: colors.surface.on_surface, + primary: colors.primary.color, + success: colors.primary.primary_container, + warning: mix(from_argb!(0xffffff00), colors.primary.color, 0.25), + danger: colors.error.color, + }) + } +} + +#[cfg(feature = "animate")] +impl iced_anim::Animate for Theme { + fn components() -> usize { + ColorScheme::components() + } + + fn update(&mut self, components: &mut impl Iterator<Item = f32>) { + let mut colorscheme = self.colors(); + colorscheme.update(components); + *self = Self::Custom(Custom { + name: "Animating Theme".into(), + is_dark: self.is_dark(), + colorscheme, + }); + } + + fn distance_to(&self, end: &Self) -> Vec<f32> { + self.colors().distance_to(&end.colors()) + } + + fn lerp(&mut self, start: &Self, end: &Self, progress: f32) { + let mut colorscheme = self.colors(); + colorscheme.lerp(&start.colors(), &end.colors(), progress); + *self = Self::Custom(Custom { + name: "Animating Theme".into(), + is_dark: self.is_dark(), + colorscheme, + }); + } +} + +#[derive(Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Custom { + pub name: Cow<'static, str>, + pub is_dark: bool, + #[cfg_attr(feature = "serde", serde(flatten))] + pub colorscheme: ColorScheme, +} + +impl From<Custom> for Theme { + fn from(custom: Custom) -> Self { + Self::Custom(custom) + } +} + +impl From<Theme> for Custom { + fn from(theme: Theme) -> Self { + Self { + name: theme.name(), + is_dark: theme.is_dark(), + colorscheme: theme.colors(), + } + } +} + +impl Clone for Custom { + fn clone(&self) -> Self { + Self { + name: self.name.clone(), + is_dark: self.is_dark, + colorscheme: self.colorscheme, + } + } + + fn clone_from(&mut self, source: &Self) { + self.name.clone_from(&source.name); + self.is_dark = source.is_dark; + self.colorscheme = source.colorscheme; + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "animate", derive(iced_anim::Animate))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::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, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub shadow: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub scrim: Color, +} + +#[allow(clippy::cast_precision_loss)] +impl ColorScheme { + const DARK: Self = Self { + primary: Primary { + color: color!(0x9bd4a1), + on_primary: color!(0x003916), + primary_container: color!(0x1b5129), + on_primary_container: color!(0xb6f1bb), + }, + secondary: Secondary { + color: color!(0xb8ccb6), + on_secondary: color!(0x233425), + secondary_container: color!(0x394b3a), + on_secondary_container: color!(0xd3e8d1), + }, + tertiary: Tertiary { + color: color!(0xa1ced7), + on_tertiary: color!(0x00363e), + tertiary_container: color!(0x1f4d55), + on_tertiary_container: color!(0xbdeaf4), + }, + error: Error { + color: color!(0xffb4ab), + on_error: color!(0x690005), + error_container: color!(0x93000a), + on_error_container: color!(0xffdad6), + }, + surface: Surface { + color: color!(0x101510), + on_surface: color!(0xe0e4dc), + on_surface_variant: color!(0xc1c9be), + surface_container: SurfaceContainer { + lowest: color!(0x0b0f0b), + low: color!(0x181d18), + base: color!(0x1c211c), + high: color!(0x262b26), + highest: color!(0x313631), + }, + }, + inverse: Inverse { + inverse_surface: color!(0xe0e4dc), + inverse_on_surface: color!(0x2d322c), + inverse_primary: color!(0x34693f), + }, + outline: Outline { + color: color!(0x8b9389), + variant: color!(0x414941), + }, + shadow: color!(0x000000), + scrim: from_argb!(0x4d000000), + }; + + const LIGHT: Self = Self { + primary: Primary { + color: color!(0x34693f), + on_primary: color!(0xffffff), + primary_container: color!(0xb6f1bb), + on_primary_container: color!(0x1b5129), + }, + secondary: Secondary { + color: color!(0x516351), + on_secondary: color!(0xffffff), + secondary_container: color!(0xd3e8d1), + on_secondary_container: color!(0x394b3a), + }, + tertiary: Tertiary { + color: color!(0x39656d), + on_tertiary: color!(0xffffff), + tertiary_container: color!(0xbdeaf4), + on_tertiary_container: color!(0x1f4d55), + }, + error: Error { + color: color!(0xba1a1a), + on_error: color!(0xffffff), + error_container: color!(0xffdad6), + on_error_container: color!(0x93000a), + }, + surface: Surface { + color: color!(0xf7fbf2), + on_surface: color!(0x181d18), + on_surface_variant: color!(0x414941), + surface_container: SurfaceContainer { + lowest: color!(0xffffff), + low: color!(0xf1f5ed), + base: color!(0xebefe7), + high: color!(0xe5e9e1), + highest: color!(0xe0e4dc), + }, + }, + inverse: Inverse { + inverse_surface: color!(0x2d322c), + inverse_on_surface: color!(0xeef2ea), + inverse_primary: color!(0x9bd4a1), + }, + outline: Outline { + color: color!(0x727970), + variant: color!(0xc1c9be), + }, + shadow: color!(0x000000), + scrim: from_argb!(0x4d000000), + }; +} + +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "animate", derive(iced_anim::Animate))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Primary { + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub color: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub on_primary: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub primary_container: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub on_primary_container: Color, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "animate", derive(iced_anim::Animate))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Secondary { + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub color: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub on_secondary: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub secondary_container: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub on_secondary_container: Color, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "animate", derive(iced_anim::Animate))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Tertiary { + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub color: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub on_tertiary: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub tertiary_container: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub on_tertiary_container: Color, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "animate", derive(iced_anim::Animate))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Error { + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub color: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub on_error: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub error_container: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub on_error_container: Color, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "animate", derive(iced_anim::Animate))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Surface { + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub color: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub on_surface: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub on_surface_variant: Color, + pub surface_container: SurfaceContainer, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "animate", derive(iced_anim::Animate))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct SurfaceContainer { + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub lowest: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub low: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub base: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub high: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub highest: Color, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "animate", derive(iced_anim::Animate))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Inverse { + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub inverse_surface: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub inverse_on_surface: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub inverse_primary: Color, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "animate", derive(iced_anim::Animate))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Outline { + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub color: Color, + #[cfg_attr(feature = "serde", serde(with = "color_serde"))] + pub variant: Color, +} + +#[cfg(feature = "serde")] +mod color_serde { + use iced_widget::core::Color; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + use super::utils::{color_to_argb, parse_argb}; + + pub fn deserialize<'de, D>(deserializer: D) -> Result<Color, D::Error> + where + D: Deserializer<'de>, + { + Ok(String::deserialize(deserializer) + .map(|hex| parse_argb(&hex))? + .unwrap_or(Color::TRANSPARENT)) + } + + pub fn serialize<S>(color: &Color, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + color_to_argb(*color).serialize(serializer) + } +} diff --git a/src/markdown.rs b/src/markdown.rs new file mode 100644 index 0000000..bc14ffe --- /dev/null +++ b/src/markdown.rs @@ -0,0 +1,10 @@ +use iced_widget::markdown::Catalog; + +use super::{Theme, container}; + +impl Catalog for Theme { + fn code_block<'a>() -> <Self as iced_widget::container::Catalog>::Class<'a> + { + Box::new(container::surface_container_highest) + } +} diff --git a/src/menu.rs b/src/menu.rs new file mode 100644 index 0000000..d595c2f --- /dev/null +++ b/src/menu.rs @@ -0,0 +1,33 @@ +use iced_widget::core::{Background, border}; +use iced_widget::overlay::menu::{Catalog, Style, StyleFn}; + +use super::Theme; +use crate::utils::{HOVERED_LAYER_OPACITY, mix}; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> <Self as Catalog>::Class<'a> { + Box::new(default) + } + + fn style(&self, class: &<Self as Catalog>::Class<'_>) -> Style { + class(self) + } +} + +pub fn default(theme: &Theme) -> Style { + let colors = theme.colors().surface; + + Style { + border: border::rounded(4), + background: Background::Color(colors.surface_container.base), + text_color: colors.on_surface, + selected_background: Background::Color(mix( + colors.surface_container.base, + colors.on_surface, + HOVERED_LAYER_OPACITY, + )), + selected_text_color: colors.on_surface, + } +} diff --git a/src/pane_grid.rs b/src/pane_grid.rs new file mode 100644 index 0000000..fb69a32 --- /dev/null +++ b/src/pane_grid.rs @@ -0,0 +1,38 @@ +use iced_widget::core::{Background, border}; +use iced_widget::pane_grid::{Catalog, Highlight, Line, Style, StyleFn}; + +use super::Theme; +use crate::utils::{HOVERED_LAYER_OPACITY, mix}; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> <Self as Catalog>::Class<'a> { + Box::new(default) + } + + fn style(&self, class: &<Self as Catalog>::Class<'_>) -> Style { + class(self) + } +} + +pub fn default(theme: &Theme) -> Style { + Style { + hovered_region: Highlight { + background: Background::Color(mix( + theme.colors().tertiary.tertiary_container, + theme.colors().surface.on_surface, + HOVERED_LAYER_OPACITY, + )), + border: border::rounded(12), + }, + picked_split: Line { + color: theme.colors().outline.variant, + width: 2.0, + }, + hovered_split: Line { + color: theme.colors().surface.on_surface, + width: 6.0, + }, + } +} diff --git a/src/pick_list.rs b/src/pick_list.rs new file mode 100644 index 0000000..1fe015e --- /dev/null +++ b/src/pick_list.rs @@ -0,0 +1,45 @@ +use iced_widget::core::{Background, border}; +use iced_widget::pick_list::{Catalog, Status, Style, StyleFn}; + +use super::Theme; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> <Self as Catalog>::Class<'a> { + Box::new(default) + } + + fn style( + &self, + class: &<Self as Catalog>::Class<'_>, + status: Status, + ) -> Style { + class(self, status) + } +} + +pub fn default(theme: &Theme, status: Status) -> Style { + let surface = theme.colors().surface; + + let active = Style { + text_color: surface.on_surface, + placeholder_color: surface.on_surface_variant, + handle_color: surface.on_surface_variant, + background: Background::Color(surface.surface_container.highest), + border: border::rounded(4), + }; + + match status { + Status::Active => active, + Status::Hovered => Style { + background: Background::Color(surface.surface_container.highest), + ..active + }, + Status::Opened { .. } => Style { + background: Background::Color(surface.surface_container.highest), + border: border::rounded(4), + ..active + }, + } +} diff --git a/src/progress_bar.rs b/src/progress_bar.rs new file mode 100644 index 0000000..9b4e844 --- /dev/null +++ b/src/progress_bar.rs @@ -0,0 +1,26 @@ +use iced_widget::core::{Background, border}; +use iced_widget::progress_bar::{Catalog, Style, StyleFn}; + +use super::Theme; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(default) + } + + fn style(&self, class: &Self::Class<'_>) -> Style { + class(self) + } +} + +pub fn default(theme: &Theme) -> Style { + Style { + background: Background::Color( + theme.colors().secondary.secondary_container, + ), + bar: Background::Color(theme.colors().primary.color), + border: border::rounded(400), + } +} diff --git a/src/qr_code.rs b/src/qr_code.rs new file mode 100644 index 0000000..f603440 --- /dev/null +++ b/src/qr_code.rs @@ -0,0 +1,24 @@ +use iced_widget::qr_code::{Catalog, Style, StyleFn}; + +use super::Theme; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(default) + } + + fn style(&self, class: &Self::Class<'_>) -> Style { + class(self) + } +} + +pub fn default(theme: &Theme) -> Style { + let surface = theme.colors().surface; + + Style { + cell: surface.on_surface, + background: surface.color, + } +} diff --git a/src/radio.rs b/src/radio.rs new file mode 100644 index 0000000..7fb7a3f --- /dev/null +++ b/src/radio.rs @@ -0,0 +1,62 @@ +use iced_widget::core::{Background, Color}; +use iced_widget::radio::{Catalog, Status, Style, StyleFn}; + +use super::Theme; +use crate::utils::{HOVERED_LAYER_OPACITY, disabled_text, mix}; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(default) + } + + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { + class(self, status) + } +} + +pub fn default(theme: &Theme, status: Status) -> Style { + let surface = theme.colors().surface; + let primary = theme.colors().primary; + + let active = Style { + background: Color::TRANSPARENT.into(), + dot_color: primary.color, + border_width: 1.0, + border_color: primary.color, + text_color: None, + }; + + match status { + Status::Active { is_selected } => Style { + border_color: if is_selected { + active.border_color + } else { + surface.on_surface + }, + ..active + }, + Status::Hovered { is_selected } => Style { + dot_color: mix( + primary.color, + surface.on_surface, + HOVERED_LAYER_OPACITY, + ), + border_color: if is_selected { + mix(primary.color, surface.on_surface, HOVERED_LAYER_OPACITY) + } else { + disabled_text(surface.on_surface) + }, + background: Background::Color(if is_selected { + Color { + a: HOVERED_LAYER_OPACITY, + ..surface.on_surface + } + } else { + Color::TRANSPARENT + }), + ..active + }, + } +} diff --git a/src/rule.rs b/src/rule.rs new file mode 100644 index 0000000..77cb0ef --- /dev/null +++ b/src/rule.rs @@ -0,0 +1,35 @@ +use iced_widget::core::border::Radius; +use iced_widget::rule::{Catalog, FillMode, Style, StyleFn}; + +use super::Theme; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(inset) + } + + fn style(&self, class: &Self::Class<'_>) -> Style { + class(self) + } +} + +pub fn inset(theme: &Theme) -> Style { + Style { + color: theme.colors().outline.variant, + fill_mode: FillMode::Padded(8), + width: 1, + radius: Radius::default(), + snap: cfg!(feature = "crisp"), + } +} +pub fn full_width(theme: &Theme) -> Style { + Style { + color: theme.colors().outline.variant, + fill_mode: FillMode::Full, + width: 1, + radius: Radius::default(), + snap: cfg!(feature = "crisp"), + } +} diff --git a/src/scrollable.rs b/src/scrollable.rs new file mode 100644 index 0000000..341f047 --- /dev/null +++ b/src/scrollable.rs @@ -0,0 +1,146 @@ +use iced_widget::core::{Background, Border, border}; +use iced_widget::scrollable::{ + Catalog, Rail, Scroller, Status, Style, StyleFn, +}; + +use super::Theme; +use super::container::surface_container; +use super::utils::mix; +use crate::utils::{ + HOVERED_LAYER_OPACITY, PRESSED_LAYER_OPACITY, disabled_container, + disabled_text, +}; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(default) + } + + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { + class(self, status) + } +} + +pub fn default(theme: &Theme, status: Status) -> Style { + let surface = theme.colors().surface; + + let active = Rail { + background: None, + scroller: Scroller { + color: surface.on_surface, + border: border::rounded(400), + }, + border: Border::default(), + }; + + let disabled = Rail { + background: Some(Background::Color(disabled_container( + surface.on_surface, + ))), + scroller: Scroller { + color: disabled_text(surface.on_surface), + border: border::rounded(400), + }, + ..active + }; + + let style = Style { + container: surface_container(theme), + vertical_rail: active, + horizontal_rail: active, + gap: None, + }; + + match status { + Status::Active { + is_horizontal_scrollbar_disabled, + is_vertical_scrollbar_disabled, + } => Style { + horizontal_rail: if is_horizontal_scrollbar_disabled { + disabled + } else { + active + }, + vertical_rail: if is_vertical_scrollbar_disabled { + disabled + } else { + active + }, + ..style + }, + Status::Hovered { + is_horizontal_scrollbar_hovered, + is_vertical_scrollbar_hovered, + is_horizontal_scrollbar_disabled, + is_vertical_scrollbar_disabled, + } => { + let hovered_rail = Rail { + scroller: Scroller { + color: mix( + surface.on_surface, + surface.color, + HOVERED_LAYER_OPACITY, + ), + border: border::rounded(400), + }, + ..active + }; + + Style { + horizontal_rail: if is_horizontal_scrollbar_disabled { + disabled + } else if is_horizontal_scrollbar_hovered { + hovered_rail + } else { + active + }, + vertical_rail: if is_vertical_scrollbar_disabled { + disabled + } else if is_vertical_scrollbar_hovered { + hovered_rail + } else { + active + }, + ..style + } + } + Status::Dragged { + is_horizontal_scrollbar_dragged, + is_vertical_scrollbar_dragged, + is_horizontal_scrollbar_disabled, + is_vertical_scrollbar_disabled, + } => { + let dragged_rail = Rail { + scroller: Scroller { + color: mix( + surface.on_surface, + surface.color, + PRESSED_LAYER_OPACITY, + ), + border: border::rounded(400), + }, + ..active + }; + + Style { + horizontal_rail: if is_horizontal_scrollbar_disabled { + disabled + } else if is_horizontal_scrollbar_dragged { + dragged_rail + } else { + active + }, + vertical_rail: if is_vertical_scrollbar_disabled { + disabled + } else if is_vertical_scrollbar_dragged { + dragged_rail + } else { + active + }, + ..style + } + } + } +} diff --git a/src/slider.rs b/src/slider.rs new file mode 100644 index 0000000..ae9ee4b --- /dev/null +++ b/src/slider.rs @@ -0,0 +1,63 @@ +use iced_widget::core::{Background, Color, border}; +use iced_widget::slider::{ + Catalog, Handle, HandleShape, Rail, Status, Style, StyleFn, +}; + +use super::Theme; +use crate::utils::{HOVERED_LAYER_OPACITY, PRESSED_LAYER_OPACITY, mix}; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> <Self as Catalog>::Class<'a> { + Box::new(default) + } + + fn style( + &self, + class: &<Self as Catalog>::Class<'_>, + status: Status, + ) -> Style { + class(self, status) + } +} + +pub fn styled(left: Color, right: Color, handle_radius: f32) -> Style { + Style { + rail: Rail { + backgrounds: (left.into(), right.into()), + width: 8.0, + border: border::rounded(400), + }, + handle: Handle { + shape: HandleShape::Circle { + radius: handle_radius, + }, + background: Background::Color(left), + border_width: 0.0, + border_color: Color::TRANSPARENT, + }, + } +} + +pub fn default(theme: &Theme, status: Status) -> Style { + let surface = theme.colors().surface; + let primary = theme.colors().primary; + let secondary = theme.colors().secondary; + + match status { + Status::Active => { + styled(primary.color, secondary.secondary_container, 12.0) + } + Status::Hovered => styled( + mix(primary.color, surface.on_surface, HOVERED_LAYER_OPACITY), + secondary.secondary_container, + 12.0, + ), + Status::Dragged => styled( + mix(primary.color, surface.on_surface, PRESSED_LAYER_OPACITY), + secondary.secondary_container, + 11.0, + ), + } +} diff --git a/src/svg.rs b/src/svg.rs new file mode 100644 index 0000000..885d743 --- /dev/null +++ b/src/svg.rs @@ -0,0 +1,15 @@ +use iced_widget::svg::{Catalog, Status, Style, StyleFn}; + +use super::Theme; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(|_theme, _status| Style::default()) + } + + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { + class(self, status) + } +} diff --git a/src/text.rs b/src/text.rs new file mode 100644 index 0000000..8da3cdf --- /dev/null +++ b/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.colors().primary.on_primary), + } +} + +pub fn primary_container(theme: &Theme) -> Style { + Style { + color: Some(theme.colors().primary.on_primary_container), + } +} + +pub fn secondary(theme: &Theme) -> Style { + Style { + color: Some(theme.colors().secondary.on_secondary), + } +} + +pub fn secondary_container(theme: &Theme) -> Style { + Style { + color: Some(theme.colors().secondary.on_secondary_container), + } +} + +pub fn tertiary(theme: &Theme) -> Style { + Style { + color: Some(theme.colors().tertiary.on_tertiary), + } +} + +pub fn tertiary_container(theme: &Theme) -> Style { + Style { + color: Some(theme.colors().tertiary.on_tertiary_container), + } +} + +pub fn error(theme: &Theme) -> Style { + Style { + color: Some(theme.colors().error.on_error), + } +} + +pub fn error_container(theme: &Theme) -> Style { + Style { + color: Some(theme.colors().error.on_error_container), + } +} + +pub fn surface(theme: &Theme) -> Style { + Style { + color: Some(theme.colors().surface.on_surface), + } +} + +pub fn surface_variant(theme: &Theme) -> Style { + Style { + color: Some(theme.colors().surface.on_surface_variant), + } +} + +pub fn inverse_surface(theme: &Theme) -> Style { + Style { + color: Some(theme.colors().inverse.inverse_on_surface), + } +} diff --git a/src/text_editor.rs b/src/text_editor.rs new file mode 100644 index 0000000..14d7104 --- /dev/null +++ b/src/text_editor.rs @@ -0,0 +1,67 @@ +use iced_widget::core::{Background, Border, Color, border}; +use iced_widget::text_editor::{Catalog, Status, Style, StyleFn}; + +use super::Theme; +use crate::utils::{disabled_container, disabled_text}; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(default) + } + + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { + class(self, status) + } +} + +pub fn default(theme: &Theme, status: Status) -> Style { + let surface = theme.colors().surface; + let primary = theme.colors().primary; + + let active = Style { + background: Background::Color(surface.surface_container.highest), + border: Border { + color: theme.colors().outline.color, + width: 1.0, + radius: 4.into(), + }, + icon: surface.on_surface_variant, + placeholder: surface.on_surface_variant, + value: surface.on_surface, + selection: disabled_text(primary.color), + }; + + match status { + Status::Active => active, + Status::Hovered => Style { + border: Border { + color: surface.on_surface, + ..active.border + }, + ..active + }, + Status::Focused { .. } => Style { + border: Border { + color: primary.color, + width: 2.0, + ..active.border + }, + placeholder: primary.color, + ..active + }, + Status::Disabled => Style { + background: Color::TRANSPARENT.into(), + border: Border { + color: disabled_container(surface.on_surface), + width: 1.0, + radius: border::radius(4), + }, + icon: disabled_text(surface.on_surface), + placeholder: disabled_text(surface.on_surface), + value: disabled_text(surface.on_surface), + selection: disabled_text(surface.on_surface), + }, + } +} diff --git a/src/text_input.rs b/src/text_input.rs new file mode 100644 index 0000000..4db220b --- /dev/null +++ b/src/text_input.rs @@ -0,0 +1,63 @@ +use iced_widget::core::{Background, Border, Color}; +use iced_widget::text_input::{Catalog, Status, Style, StyleFn}; + +use super::Theme; +use crate::utils::{disabled_container, disabled_text}; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(default) + } + + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { + class(self, status) + } +} + +pub fn default(theme: &Theme, status: Status) -> Style { + let surface = theme.colors().surface; + let primary = theme.colors().primary; + + let active = Style { + background: Background::Color(surface.surface_container.highest), + border: Border { + color: theme.colors().outline.color, + width: 1.0, + radius: 4.into(), + }, + icon: surface.on_surface_variant, + placeholder: surface.on_surface_variant, + value: surface.on_surface, + selection: disabled_text(primary.color), + }; + + match status { + Status::Active => active, + Status::Hovered => Style { + border: active.border.color(surface.on_surface), + ..active + }, + Status::Disabled => Style { + background: Color::TRANSPARENT.into(), + border: Border { + color: disabled_container(surface.on_surface), + ..active.border + }, + icon: disabled_text(surface.on_surface), + placeholder: disabled_text(surface.on_surface), + value: disabled_text(surface.on_surface), + selection: disabled_text(surface.on_surface), + }, + Status::Focused { .. } => Style { + border: Border { + color: primary.color, + width: 2.0, + ..active.border + }, + placeholder: primary.color, + ..active + }, + } +} diff --git a/src/toggler.rs b/src/toggler.rs new file mode 100644 index 0000000..5cebc88 --- /dev/null +++ b/src/toggler.rs @@ -0,0 +1,73 @@ +use iced_widget::core::Color; +use iced_widget::toggler::{Catalog, Status, Style, StyleFn}; + +use super::Theme; +use crate::utils::{ + HOVERED_LAYER_OPACITY, disabled_container, disabled_text, mix, +}; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(default) + } + + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { + class(self, status) + } +} + +pub fn styled( + background: Color, + foreground: Color, + border: Option<Color>, +) -> Style { + Style { + background, + background_border_width: if border.is_some() { 2.0 } else { 0.0 }, + background_border_color: border.unwrap_or(Color::TRANSPARENT), + foreground, + foreground_border_width: 0.0, + foreground_border_color: Color::TRANSPARENT, + } +} + +pub fn default(theme: &Theme, status: Status) -> Style { + let surface = theme.colors().surface; + let primary = theme.colors().primary; + + match status { + Status::Active { is_toggled } => { + if is_toggled { + styled(primary.color, primary.on_primary, None) + } else { + styled( + surface.surface_container.highest, + theme.colors().outline.color, + Some(theme.colors().outline.color), + ) + } + } + Status::Hovered { is_toggled } => { + if is_toggled { + styled(primary.color, primary.primary_container, None) + } else { + styled( + mix( + surface.surface_container.highest, + surface.on_surface, + HOVERED_LAYER_OPACITY, + ), + surface.on_surface_variant, + Some(theme.colors().outline.color), + ) + } + } + Status::Disabled => styled( + disabled_container(surface.surface_container.highest), + disabled_text(surface.on_surface), + Some(disabled_text(surface.on_surface)), + ), + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..7da755a --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,149 @@ +use iced_widget::core::{Color, Shadow, Vector}; + +const COLOR_ERROR_MARGIN: f32 = 0.0001; + +pub const HOVERED_LAYER_OPACITY: f32 = 0.08; +pub const PRESSED_LAYER_OPACITY: f32 = 0.1; + +pub const DISABLED_TEXT_OPACITY: f32 = 0.38; +pub const DISABLED_CONTAINER_OPACITY: f32 = 0.12; + +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 shadow_from_elevation(elevation: f32, color: Color) -> Shadow { + Shadow { + color, + offset: Vector { + x: 0.0, + y: elevation, + }, + blur_radius: (elevation) * (1.0 + 0.4_f32.powf(elevation)), + } +} + +pub fn disabled_text(color: Color) -> Color { + Color { + a: DISABLED_TEXT_OPACITY, + ..color + } +} + +pub fn disabled_container(color: Color) -> Color { + Color { + a: DISABLED_CONTAINER_OPACITY, + ..color + } +} + +pub fn parse_argb(s: &str) -> Option<Color> { + 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?, + }) +} + +pub fn color_to_argb(color: Color) -> String { + use std::fmt::Write; + + let mut hex = String::with_capacity(9); + + let [r, g, b, a] = color.into_rgba8(); + + let _ = write!(&mut hex, "#"); + + if a < u8::MAX { + let _ = write!(&mut hex, "{a:02X}"); + } + + let _ = write!(&mut hex, "{r:02X}"); + let _ = write!(&mut hex, "{g:02X}"); + let _ = write!(&mut hex, "{b:02X}"); + + hex +} + +pub const fn lightness(color: Color) -> f32 { + color.r * 0.299 + color.g * 0.587 + color.b * 0.114 +} + +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).abs() > COLOR_ERROR_MARGIN || (color2.a - 1.0).abs() > COLOR_ERROR_MARGIN { + 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::{mix, Color}; + + #[test] + fn mixing() { + 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() + ); + } +} |
