From bf1c474d0846e31c647444ff7bd5ecb7f361efcd Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Mon, 22 May 2023 13:05:33 -0400 Subject: [PATCH] feat: support for custom & system themes + move cosmic-theme to libcosmic --- Cargo.toml | 3 +- cosmic-config-derive/src/lib.rs | 37 +- cosmic-theme/Cargo.toml | 32 ++ cosmic-theme/README.md | 1 + cosmic-theme/src/color_picker/exact.rs | 170 ++++++++ cosmic-theme/src/color_picker/mod.rs | 280 +++++++++++++ cosmic-theme/src/config/mod.rs | 196 +++++++++ cosmic-theme/src/hex_color.rs | 35 ++ cosmic-theme/src/lib.rs | 77 ++++ cosmic-theme/src/model/constraint.rs | 26 ++ cosmic-theme/src/model/cosmic_palette.rs | 252 ++++++++++++ cosmic-theme/src/model/dark.ron | 95 +++++ cosmic-theme/src/model/derivation.rs | 480 +++++++++++++++++++++++ cosmic-theme/src/model/light.ron | 95 +++++ cosmic-theme/src/model/mod.rs | 14 + cosmic-theme/src/model/selection.rs | 99 +++++ cosmic-theme/src/model/theme.rs | 419 ++++++++++++++++++++ cosmic-theme/src/output/gtk4_output.rs | 187 +++++++++ cosmic-theme/src/output/mod.rs | 8 + cosmic-theme/src/theme_provider/mod.rs | 1 + cosmic-theme/src/util.rs | 59 +++ examples/cosmic/Cargo.toml | 1 + examples/cosmic/src/window.rs | 35 +- examples/cosmic/src/window/demo.rs | 56 ++- src/lib.rs | 2 +- src/theme/mod.rs | 23 +- 26 files changed, 2639 insertions(+), 44 deletions(-) create mode 100644 cosmic-theme/Cargo.toml create mode 100644 cosmic-theme/README.md create mode 100644 cosmic-theme/src/color_picker/exact.rs create mode 100644 cosmic-theme/src/color_picker/mod.rs create mode 100644 cosmic-theme/src/config/mod.rs create mode 100644 cosmic-theme/src/hex_color.rs create mode 100644 cosmic-theme/src/lib.rs create mode 100644 cosmic-theme/src/model/constraint.rs create mode 100644 cosmic-theme/src/model/cosmic_palette.rs create mode 100644 cosmic-theme/src/model/dark.ron create mode 100644 cosmic-theme/src/model/derivation.rs create mode 100644 cosmic-theme/src/model/light.ron create mode 100644 cosmic-theme/src/model/mod.rs create mode 100644 cosmic-theme/src/model/selection.rs create mode 100644 cosmic-theme/src/model/theme.rs create mode 100644 cosmic-theme/src/output/gtk4_output.rs create mode 100644 cosmic-theme/src/output/mod.rs create mode 100644 cosmic-theme/src/theme_provider/mod.rs create mode 100644 cosmic-theme/src/util.rs diff --git a/Cargo.toml b/Cargo.toml index acccb0ff..4d10e528 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ cosmic-config = { path = "cosmic-config" } freedesktop-icons = "0.2.2" [dependencies.cosmic-theme] -git = "https://github.com/pop-os/cosmic-theme.git" +path = "cosmic-theme" [dependencies.iced] path = "iced" @@ -78,6 +78,7 @@ optional = true members = [ "cosmic-config", "cosmic-config-derive", + "cosmic-theme", "examples/*", ] exclude = [ diff --git a/cosmic-config-derive/src/lib.rs b/cosmic-config-derive/src/lib.rs index 36370d75..7d5fa701 100644 --- a/cosmic-config-derive/src/lib.rs +++ b/cosmic-config-derive/src/lib.rs @@ -1,6 +1,6 @@ use proc_macro::TokenStream; use quote::quote; -use syn::{self, parse_quote}; +use syn::{self}; #[proc_macro_derive(CosmicConfigEntry)] pub fn cosmic_config_entry_derive(input: TokenStream) -> TokenStream { @@ -14,7 +14,7 @@ pub fn cosmic_config_entry_derive(input: TokenStream) -> TokenStream { fn impl_cosmic_config_entry_macro(ast: &syn::DeriveInput) -> TokenStream { let name = &ast.ident; - let generics = &ast.generics; + // let generics = &ast.generics; // Get the fields of the struct let fields = match ast.data { @@ -43,29 +43,30 @@ fn impl_cosmic_config_entry_macro(ast: &syn::DeriveInput) -> TokenStream { } }); - // Get the existing where clause or create a new one if it doesn't exist - let mut where_clause = ast - .generics - .where_clause - .clone() - .unwrap_or_else(|| parse_quote!(where)); + // // Get the existing where clause or create a new one if it doesn't exist + // let mut where_clause = ast + // .generics + // .where_clause + // .clone() + // .unwrap_or_else(|| parse_quote!(where)); - // Add your additional constraints to the where clause - // Here, we add the constraint 'T: Debug' to all generic parameters - for param in ast.generics.params.iter() { - where_clause - .predicates - .push(parse_quote!(#param: ::std::default::Default + ::serde::Serialize + ::serde::de::DeserializeOwned)); - } + // // Add your additional constraints to the where clause + // // Here, we add the constraint 'T: Debug' to all generic parameters + // for param in ast.generics.params.iter() { + // where_clause + // .predicates + // .push(parse_quote!(#param: ::std::default::Default + ::serde::Serialize + ::serde::de::DeserializeOwned)); + // } let gen = quote! { - impl #generics CosmicConfigEntry for #name #generics #where_clause { + impl CosmicConfigEntry for #name { fn write_entry(&self, config: &Config) -> Result<(), cosmic_config::Error> { + let tx = config.transaction(); #(#write_each_config_field)* - Ok(()) + tx.commit() } - fn get_entry(config: &Config) -> Result, Self)> { + fn get_entry(config: &Config) -> Result, Self)> { let mut default = Self::default(); let mut errors = Vec::new(); diff --git a/cosmic-theme/Cargo.toml b/cosmic-theme/Cargo.toml new file mode 100644 index 00000000..7f745b64 --- /dev/null +++ b/cosmic-theme/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "cosmic-theme" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[package.metadata.docs.rs] +features = ["test_all_features"] +rustdoc-args = ["--cfg", "docsrs"] + +[features] +default = [] +no-default = [] +contrast-derivation = ["float-cmp"] +theme-from-image = ["kmeans_colors", "contrast-derivation", "float-cmp", "image"] +hex-color = ["hex"] + +[dependencies] +palette = {version = "0.6", features = ["serializing"] } +anyhow = "1.0" +hex = {version = "0.4.3", optional = true} +kmeans_colors = { version = "0.5", features = ["palette_color"], default-features = false, optional = true } +image = {version = "0.24.1", optional = true } +float-cmp = { version = "0.9.0", optional = true } +serde = { version = "1.0.129", features = ["derive"] } +ron = "0.8" +lazy_static = "1.4.0" +csscolorparser = {version = "0.6.2", features = ["serde"]} +directories = { git = "https://github.com/edfloreshz/directories-rs", version = "4.0.1" } +cosmic-config = { path = "../cosmic-config/", default-features = false, features = ["subscription"] } + diff --git a/cosmic-theme/README.md b/cosmic-theme/README.md new file mode 100644 index 00000000..1a1aebed --- /dev/null +++ b/cosmic-theme/README.md @@ -0,0 +1 @@ +# WIP \ No newline at end of file diff --git a/cosmic-theme/src/color_picker/exact.rs b/cosmic-theme/src/color_picker/exact.rs new file mode 100644 index 00000000..2e29c265 --- /dev/null +++ b/cosmic-theme/src/color_picker/exact.rs @@ -0,0 +1,170 @@ +use super::ColorPicker; +use crate::{Selection, ThemeConstraints}; +use anyhow::{anyhow, bail, Result}; +use float_cmp::approx_eq; +use palette::{Clamp, IntoColor, Lch, RelativeContrast, Srgba}; +use serde::{de::DeserializeOwned, Serialize}; +use std::fmt; + +/// Implementation of a Cosmic color chooser which exactly meets constraints +#[derive(Debug, Default, Clone)] +pub struct Exact { + selection: Selection, + constraints: ThemeConstraints, +} + +impl Exact +where + C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, +{ + /// create a new Exact color picker + pub fn new(selection: Selection, constraints: ThemeConstraints) -> Self { + Self { + selection, + constraints, + } + } +} + +impl ColorPicker for Exact +where + C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, +{ + fn get_constraints(&self) -> ThemeConstraints { + self.constraints + } + + fn get_selection(&self) -> Selection { + self.selection.clone() + } + + fn pick_color_graphic( + &self, + color: C, + contrast: f32, + grayscale: bool, + lighten: Option, + ) -> (C, Option) { + let mut err = None; + + let res = self.pick_color(color.clone(), Some(contrast), grayscale, lighten); + if let Ok(c) = res { + return (c, err); + } else if let Err(e) = res { + err = Some(anyhow!("Graphic contrast {} failed: {}", contrast, e)); + } + + let res = self.pick_color(color.clone(), None, grayscale, lighten); + if let Ok(c) = res { + return (c, err); + } else if let Err(e) = res { + err = Some(e); + } + + // return same color if no other color possible + (color, err) + } + + fn pick_color_text( + &self, + color: C, + grayscale: bool, + lighten: Option, + ) -> (C, Option) { + let mut err = None; + + // AAA + let res = self.pick_color(color.clone(), Some(7.0), grayscale, lighten); + if let Ok(c) = res { + return (c, err); + } else if let Err(e) = res { + err = Some(anyhow!("AAA text contrast failed: {}", e)); + } + + // AA + let res = self.pick_color(color.clone(), Some(4.5), grayscale, lighten); + if let Ok(c) = res { + return (c, err); + } else if let Err(e) = res { + err = Some(anyhow!("AA text contrast failed: {}", e)); + } + + let res = self.pick_color(color.clone(), None, grayscale, lighten); + if let Ok(c) = res { + return (c, err); + } else if let Err(e) = res { + err = Some(e); + } + + (color, err) + } + + fn pick_color( + &self, + color: C, + contrast: Option, + grayscale: bool, + lighten: Option, + ) -> Result { + let srgba: Srgba = color.clone().into(); + let mut lch_color: Lch = srgba.into_color(); + + // set to grayscale + if grayscale { + lch_color.chroma = 0.0; + } + + // lighten or darken + // TODO closed form solution using Lch color space contrast formula? + // for now do binary search... + + if let Some(contrast) = contrast { + let (min, max) = match lighten { + Some(b) if b => (lch_color.l, 100.0), + Some(_) => (0.0, lch_color.l), + None => (0.0, 100.0), + }; + let (mut l, mut r) = (min, max); + + for _ in 0..100 { + let cur_guess_lightness = (l + r) / 2.0; + let mut cur_guess = lch_color; + cur_guess.l = cur_guess_lightness; + let cur_contrast = srgba.get_contrast_ratio(&cur_guess.into_color()); + let contrast_dir = contrast > cur_contrast; + let lightness_dir = lch_color.l < cur_guess.l; + if approx_eq!(f32, contrast, cur_contrast, ulps = 4) { + lch_color = cur_guess; + break; + // TODO fix + } else if lightness_dir && contrast_dir || !lightness_dir && !contrast_dir { + l = cur_guess_lightness; + } else { + r = cur_guess_lightness; + } + } + + // clamp to valid value in range + lch_color.clamp_self(); + + // verify contrast + let actual_contrast = srgba.get_contrast_ratio(&lch_color.into_color()); + if !approx_eq!(f32, contrast, actual_contrast, ulps = 4) { + bail!( + "Failed to derive color with contrast {} from {:?}", + contrast, + color + ); + } + + Ok(C::from(lch_color.into_color())) + } else { + // maximize contrast if no constraint is given + if lch_color.l > 50.0 { + Ok(C::from(palette::named::BLACK.into_format().into_color())) + } else { + Ok(C::from(palette::named::WHITE.into_format().into_color())) + } + } + } +} diff --git a/cosmic-theme/src/color_picker/mod.rs b/cosmic-theme/src/color_picker/mod.rs new file mode 100644 index 00000000..b5bf4ee7 --- /dev/null +++ b/cosmic-theme/src/color_picker/mod.rs @@ -0,0 +1,280 @@ +use crate::{Component, Container, ContainerType, Derivation, Selection, Theme, ThemeConstraints}; +use anyhow::{anyhow, Result}; +use palette::{IntoColor, Lcha, Shade, Srgba}; +use serde::{de::DeserializeOwned, Serialize}; +use std::fmt; + +pub use exact::*; +mod exact; + +// TODO derive palette from Selection? +/// Color picker derives colors and theme elements +pub trait ColorPicker< + C: Into + From + Clone + fmt::Debug + Default + Serialize + DeserializeOwned, +> +{ + /// try to derive a color with a given contrast, grayscale setting, and lightness direction + fn pick_color( + &self, + color: C, + contrast: Option, + grayscale: bool, + lighten: Option, + ) -> Result; + + /// try to derive a text color with a given grayscale setting, and lightness direction + fn pick_color_text( + &self, + color: C, + grayscale: bool, + lighten: Option, + ) -> (C, Option); + + /// try to derive a graphic color with a given contrast, grayscale setting, and lightness direction + fn pick_color_graphic( + &self, + color: C, + contrast: f32, + grayscale: bool, + lighten: Option, + ) -> (C, Option); + + /// get the selection for this color picker + fn get_selection(&self) -> Selection; + + /// get the constraints for this color picker + fn get_constraints(&self) -> ThemeConstraints; + + /// derive a theme from the selection and constraints + fn theme_derivation(&self) -> Derivation> { + let mut theme_errors = Vec::new(); + + let Derivation { + derived: background, + errors: mut errs, + } = self.container_derivation(ContainerType::Background); + theme_errors.append(&mut errs); + + let Derivation { + derived: primary, + errors: mut errs, + } = self.container_derivation(ContainerType::Primary); + theme_errors.append(&mut errs); + + let Derivation { + derived: secondary, + mut errors, + } = self.container_derivation(ContainerType::Secondary); + theme_errors.append(&mut errors); + + let Derivation { + derived: accent, + mut errors, + } = self.widget_derivation(self.get_selection().accent); + theme_errors.append(&mut errors); + + let Derivation { + derived: destructive, + mut errors, + } = self.widget_derivation(self.get_selection().destructive); + theme_errors.append(&mut errors); + + let Derivation { + derived: warning, + mut errors, + } = self.widget_derivation(self.get_selection().warning); + theme_errors.append(&mut errors); + + let Derivation { + derived: success, + mut errors, + } = self.widget_derivation(self.get_selection().success); + theme_errors.append(&mut errors); + + Derivation { + derived: Theme::new( + background, + primary, + secondary, + accent, + destructive, + warning, + success, + ), + errors: theme_errors, + } + } + + /// derive a container element + fn container_derivation(&self, container_type: ContainerType) -> Derivation> { + let selection = self.get_selection(); + let constraints = self.get_constraints(); + + let mut errors = Vec::new(); + + let Selection { + background, + primary_container, + secondary_container, + .. + } = selection; + + let ThemeConstraints { + elevated_contrast_ratio, + divider_contrast_ratio, + divider_gray_scale, + lighten, + .. + } = constraints; + + let container = match container_type { + ContainerType::Background => background, + ContainerType::Primary => primary_container, + ContainerType::Secondary => secondary_container, + }; + let (container_divider, err) = self.pick_color_graphic( + container.clone(), + divider_contrast_ratio, + divider_gray_scale, + Some(lighten), + ); + if let Some(e) = err { + errors.push(e); + }; + + let (container_fg, err) = self.pick_color_text(container.clone(), true, None); + if let Some(err) = err { + let err = anyhow!("{} => \"container text\" failed: {}", container_type, err); + errors.push(err); + }; + + // TODO revisit this and adjust constraints for transparency + let mut container_fg_opacity_80: Srgba = container_fg.clone().into(); + container_fg_opacity_80.alpha *= 0.8; + + let (component_default, err) = self.pick_color_graphic( + container.clone(), + elevated_contrast_ratio, + false, + Some(lighten), + ); + if let Some(e) = err { + let err = anyhow!( + "{} => \"container component\" failed: {}", + container_type, + e + ); + errors.push(err); + }; + + let Derivation { + derived: container_component, + errors: errs, + } = self.widget_derivation(component_default); + for e in errs { + let err = anyhow!( + "{} => \"container component derivation\" failed: {}", + container_type, + e + ); + errors.push(err); + } + + Derivation { + derived: Container { + base: container, + divider: container_divider, + on: container_fg, + component: container_component, + }, + errors, + } + } + + /// derive a widget + fn widget_derivation(&self, default: C) -> Derivation> { + let ThemeConstraints { + divider_contrast_ratio, + divider_gray_scale, + lighten, + .. + } = self.get_constraints(); + + let mut errors = Vec::new(); + + let rgba: Srgba = default.clone().into(); + let lch = Lcha { + color: rgba.color.into_color(), + alpha: rgba.alpha, + }; + + // TODO define constraints for different states... + // & add color self methods and errors if these fail + let hover = if lighten { + lch.lighten(0.1) + } else { + lch.darken(0.1) + }; + + let pressed = if lighten { + hover.lighten(0.1) + } else { + hover.darken(0.1) + }; + let pressed = C::from(Srgba { + color: pressed.color.into_color(), + alpha: pressed.alpha, + }); + + // TODO is this actually a different color? or just outlined? + let selected = default.clone(); + + let mut disabled: Srgba = default.clone().into(); + disabled.alpha = 0.5; + + let (divider, error) = self.pick_color_graphic( + pressed.clone(), + divider_contrast_ratio, + divider_gray_scale, + Some(lighten), + ); + if let Some(error) = error { + errors.push(error); + } + + let (text, error) = self.pick_color_text(pressed.clone(), true, None); + if let Some(error) = error { + errors.push(error); + } + + let (selected_text, error) = self.pick_color_text(selected.clone(), true, None); + if let Some(error) = error { + errors.push(error); + } + + let mut text_opacity_80: Srgba = text.clone().into(); + text_opacity_80.alpha = 0.8; + + let mut disabled_fg = text.clone().into(); + disabled_fg.alpha = 0.5; + + Derivation { + derived: Component { + base: default, + hover: C::from(Srgba { + color: hover.color.into_color(), + alpha: hover.alpha, + }), + pressed, + selected: selected.clone(), + selected_text: selected_text, + focus: selected.clone(), // FIXME + divider, + on: text, + disabled: disabled.into(), + on_disabled: disabled_fg.into(), + }, + errors, + } + } +} diff --git a/cosmic-theme/src/config/mod.rs b/cosmic-theme/src/config/mod.rs new file mode 100644 index 00000000..a558fcf5 --- /dev/null +++ b/cosmic-theme/src/config/mod.rs @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MPL-2.0-only + +use crate::{util::CssColor, Theme, NAME, THEME_DIR}; +use anyhow::{bail, Context, Result}; +use directories::{BaseDirsExt, ProjectDirsExt}; +use palette::Srgba; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::{ + fmt, + fs::File, + io::{prelude::*, BufReader}, + path::PathBuf, +}; + +/// Cosmic Theme config +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct Config { + /// whether high contrast mode is activated + pub is_high_contrast: bool, + /// active + pub is_dark: bool, + /// Selected light theme name + pub light: String, + /// Selected dark theme name + pub dark: String, +} + +impl Default for Config { + fn default() -> Self { + Self { + is_dark: true, + light: "cosmic-light".to_string(), + dark: "cosmic-dark".to_string(), + is_high_contrast: false, + } + } +} + +/// name of the config file +pub const CONFIG_NAME: &str = "config"; + +impl Config { + /// create a new cosmic theme config + pub fn new(is_dark: bool, high_contrast: bool, light: String, dark: String) -> Self { + Self { + is_dark, + light, + dark, + is_high_contrast: high_contrast, + } + } + + /// save the cosmic theme config + pub fn save(&self) -> Result<()> { + let xdg_dirs = directories::ProjectDirs::from_path(PathBuf::from(NAME)) + .context("Failed to find project directory.")?; + if let Ok(path) = xdg_dirs.place_config_file(PathBuf::from(format!("{CONFIG_NAME}.ron"))) { + let mut f = File::create(path)?; + let ron = ron::ser::to_string_pretty(&self, Default::default())?; + f.write_all(ron.as_bytes())?; + Ok(()) + } else { + bail!("failed to save theme config") + } + } + + /// init the config directory + pub fn init() -> anyhow::Result { + let base_dirs = directories::BaseDirs::new().context("Failed to get base directories.")?; + let res = Ok(base_dirs.create_config_directory(NAME)?); + Theme::::init()?; + + if Self::load().is_ok() { + res + } else { + Self::default().save()?; + Theme::dark_default().save()?; + Theme::light_default().save()?; + res + } + } + + /// load the cosmic theme config + pub fn load() -> Result { + let xdg_dirs = directories::ProjectDirs::from_path(PathBuf::from(NAME)) + .context("Failed to find project directory.")?; + let path = xdg_dirs.config_dir(); + std::fs::create_dir_all(&path)?; + let path = xdg_dirs.find_config_file(PathBuf::from(format!("{CONFIG_NAME}.ron"))); + if path.is_none() { + let s = Self::default(); + s.save()?; + } + if let Some(path) = xdg_dirs.find_config_file(PathBuf::from(format!("{CONFIG_NAME}.ron"))) { + let mut f = File::open(&path)?; + let mut s = String::new(); + f.read_to_string(&mut s)?; + Ok(ron::from_str(s.as_str())?) + } else { + anyhow::bail!("Failed to load config") + } + } + + /// get the name of the active theme + pub fn active_name(&self) -> Option { + if self.is_dark && self.dark.is_empty() { + Some(self.dark.clone()) + } else if !self.is_dark && !self.light.is_empty() { + Some(self.light.clone()) + } else { + None + } + // if *high_contrast { + // if let Some(palette) = palette.take() { + // // TODO enforce high contrast constraints + // *palette = palette.to_high_contrast(); + // todo!() + // } + // } + } + + /// get the active theme + pub fn get_active(&self) -> anyhow::Result> { + let active = match self.active_name() { + Some(n) => n, + _ => anyhow::bail!("No configured active overrides"), + }; + let css_path: PathBuf = [NAME, THEME_DIR].iter().collect(); + let css_dirs = directories::ProjectDirs::from_path(PathBuf::from(css_path)) + .context("Failed to find project directory.")?; + let active_theme_path = match css_dirs.find_data_file(format!("{active}.ron")) { + Some(p) => p, + _ => anyhow::bail!("Could not find theme"), + }; + match File::open(active_theme_path) { + Ok(active_theme_file) => { + let reader = BufReader::new(active_theme_file); + Ok(ron::de::from_reader::<_, Theme>(reader)?) + } + Err(_) => { + if self.is_dark { + Ok(Theme::dark_default()) + } else { + Ok(Theme::light_default()) + } + } + } + } + + /// set the name of the active light theme + pub fn set_active_light(new: &str) -> Result<()> { + let mut self_ = Self::load()?; + + self_.light = new.to_string(); + + self_.save() + } + + /// set the name of the active dark theme + pub fn set_active_dark(new: &str) -> Result<()> { + let mut self_ = Self::load()?; + + self_.dark = new.to_string(); + + self_.save() + } +} + +impl From<(Theme, Theme)> for Config +where + C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, +{ + fn from((light, dark): (Theme, Theme)) -> Self { + Self { + light: light.name, + dark: dark.name, + is_dark: true, + is_high_contrast: false, + } + } +} + +impl From> for Config +where + C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, +{ + fn from(t: Theme) -> Self { + Self { + light: t.clone().name, + dark: t.name, + is_dark: true, + is_high_contrast: true, + } + } +} diff --git a/cosmic-theme/src/hex_color.rs b/cosmic-theme/src/hex_color.rs new file mode 100644 index 00000000..bf04f216 --- /dev/null +++ b/cosmic-theme/src/hex_color.rs @@ -0,0 +1,35 @@ +use hex::encode; +use palette::{Pixel, Srgba}; +use std::fmt; + +/// Wrapper type for Hex color strings +#[derive(Debug, Clone)] +pub struct Hex { + hex_string: String, +} + +impl> From for Hex { + fn from(c: C) -> Self { + let srgba: Srgba = c.into(); + let hex_string = encode::<[u8; 4]>(Srgba::into_raw(srgba.into_format())); + Hex { hex_string } + } +} + +impl Into for Hex { + fn into(self) -> String { + self.hex_string + } +} + +impl fmt::Display for Hex { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "#{}", self) + } +} + +/// Create a hex String from an Srgba +pub fn hex_from_rgba(rgba: &Srgba) -> String { + let hex = encode::<[u8; 4]>(Srgba::into_raw(rgba.into_format())); + format!("#{hex}") +} diff --git a/cosmic-theme/src/lib.rs b/cosmic-theme/src/lib.rs new file mode 100644 index 00000000..d574bed3 --- /dev/null +++ b/cosmic-theme/src/lib.rs @@ -0,0 +1,77 @@ +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![warn(missing_docs, missing_debug_implementations, rust_2018_idioms)] + +//! Cosmic theme library. +//! +//! Provides utilities for creating custom cosmic themes. +//! + +#[cfg(feature = "contrast-derivation")] +pub use color_picker::*; +pub use config::*; +#[cfg(feature = "hex-color")] +pub use hex_color::*; +pub use model::*; +pub use output::*; +pub use theme_provider::*; +#[cfg(feature = "contrast-derivation")] +mod color_picker; +mod config; +#[cfg(feature = "hex-color")] +mod hex_color; +mod model; +mod output; +mod theme_provider; +/// utilities +pub mod util; + +/// name of cosmic theme +pub const NAME: &'static str = "com.system76.CosmicTheme"; +/// Name of the theme directory +pub const THEME_DIR: &str = "themes"; +/// name of the palette directory +pub const PALETTE_DIR: &str = "palettes"; + +/// theme derivation from an image +#[cfg(feature = "theme-from-image")] +pub mod theme_from_image { + use image::EncodableLayout; + use kmeans_colors::{get_kmeans_hamerly, Kmeans, Sort}; + use palette::{rgb::Srgba, Pixel}; + use palette::{IntoColor, Lab}; + use std::path::Path; + + /// Create a palette from an image + /// The palette is sorted by how often a color occurs in the image, most often first + pub fn theme_from_image>(path: P) -> Option> { + // calculate kmeans colors from file + // let pixbuf = Pixbuf::from_file(path); + let img = image::open(path); + match img { + Ok(img) => { + let lab: Vec = Srgba::from_raw_slice(img.to_rgba8().into_raw().as_bytes()) + .iter() + .map(|x| x.color.into_format().into_color()) + .collect(); + + let mut result = Kmeans::new(); + + // TODO random seed + for i in 0..2 { + let run_result = get_kmeans_hamerly(5, 20, 5.0, false, &lab, i as u64); + if run_result.score < result.score { + result = run_result; + } + } + let mut res = Lab::sort_indexed_colors(&result.centroids, &result.indices); + res.sort_unstable_by(|a, b| (b.percentage).partial_cmp(&a.percentage).unwrap()); + let colors: Vec = res.iter().map(|x| x.centroid.into_color()).collect(); + Some(colors) + } + Err(err) => { + eprintln!("{}", err); + None + } + } + } +} diff --git a/cosmic-theme/src/model/constraint.rs b/cosmic-theme/src/model/constraint.rs new file mode 100644 index 00000000..45132494 --- /dev/null +++ b/cosmic-theme/src/model/constraint.rs @@ -0,0 +1,26 @@ +/// Cosmic theme custom constraints which are used to pick colors +#[derive(Copy, Clone, Debug)] +pub struct ThemeConstraints { + /// requested contrast ratio for elevated surfaces + pub elevated_contrast_ratio: f32, + /// requested contrast ratio for dividers + pub divider_contrast_ratio: f32, + /// requested contrast ratio for text + pub text_contrast_ratio: f32, + /// gray scale or color for dividers + pub divider_gray_scale: bool, + /// elevated surfaces are lightened or darkened + pub lighten: bool, +} + +impl Default for ThemeConstraints { + fn default() -> Self { + Self { + elevated_contrast_ratio: 1.1, + divider_contrast_ratio: 1.51, + text_contrast_ratio: 7.0, + divider_gray_scale: true, + lighten: true, + } + } +} diff --git a/cosmic-theme/src/model/cosmic_palette.rs b/cosmic-theme/src/model/cosmic_palette.rs new file mode 100644 index 00000000..622627c1 --- /dev/null +++ b/cosmic-theme/src/model/cosmic_palette.rs @@ -0,0 +1,252 @@ +use std::{ + fmt, + fs::File, + io::Write, + path::{Path, PathBuf}, +}; + +use anyhow::Context; +use directories::{BaseDirsExt, ProjectDirsExt}; +use lazy_static::lazy_static; +use palette::Srgba; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +use crate::{util::CssColor, NAME, PALETTE_DIR}; + +lazy_static! { + /// built in light palette + pub static ref LIGHT_PALETTE: CosmicPalette = + ron::from_str(include_str!("light.ron")).unwrap(); + /// built in dark palette + pub static ref DARK_PALETTE: CosmicPalette = + ron::from_str(include_str!("dark.ron")).unwrap(); +} + +/// Palette type +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum CosmicPalette { + /// Dark mode + Dark(CosmicPaletteInner), + /// Light mode + Light(CosmicPaletteInner), + /// High contrast light mode + HighContrastLight(CosmicPaletteInner), + /// High contrast dark mode + HighContrastDark(CosmicPaletteInner), +} + +impl AsRef> for CosmicPalette +where + C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, +{ + fn as_ref(&self) -> &CosmicPaletteInner { + match self { + CosmicPalette::Dark(p) => p, + CosmicPalette::Light(p) => p, + CosmicPalette::HighContrastLight(p) => p, + CosmicPalette::HighContrastDark(p) => p, + } + } +} + +impl CosmicPalette +where + C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, +{ + /// check if the palette is dark + pub fn is_dark(&self) -> bool { + match self { + CosmicPalette::Dark(_) | CosmicPalette::HighContrastDark(_) => true, + CosmicPalette::Light(_) | CosmicPalette::HighContrastLight(_) => false, + } + } + + /// check if the palette is high_contrast + pub fn is_high_contrast(&self) -> bool { + match self { + CosmicPalette::HighContrastLight(_) | CosmicPalette::HighContrastDark(_) => true, + CosmicPalette::Light(_) | CosmicPalette::Dark(_) => false, + } + } +} + +impl Default for CosmicPalette +where + C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, +{ + fn default() -> Self { + CosmicPalette::Dark(Default::default()) + } +} + +/// The palette for Cosmic Theme, from which all color properties are derived +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct CosmicPaletteInner { + /// name of the palette + pub name: String, + + /// basic palette + /// blue: colors used for various points of emphasis in the UI + pub blue: C, + /// red: colors used for various points of emphasis in the UI + pub red: C, + /// green: colors used for various points of emphasis in the UI + pub green: C, + /// yellow: colors used for various points of emphasis in the UI + pub yellow: C, + + /// surface grays + /// colors used for three levels of surfaces in the UI + pub gray_1: C, + /// colors used for three levels of surfaces in the UI + pub gray_2: C, + /// colors used for three levels of surfaces in the UI + pub gray_3: C, + + /// System Neutrals + /// A wider spread of dark colors for more general use. + pub neutral_1: C, + /// A wider spread of dark colors for more general use. + pub neutral_2: C, + /// A wider spread of dark colors for more general use. + pub neutral_3: C, + /// A wider spread of dark colors for more general use. + pub neutral_4: C, + /// A wider spread of dark colors for more general use. + pub neutral_5: C, + /// A wider spread of dark colors for more general use. + pub neutral_6: C, + /// A wider spread of dark colors for more general use. + pub neutral_7: C, + /// A wider spread of dark colors for more general use. + pub neutral_8: C, + /// A wider spread of dark colors for more general use. + pub neutral_9: C, + /// A wider spread of dark colors for more general use. + pub neutral_10: C, + + /// Extended Color Palette + /// Colors used for themes, app icons, illustrations, and other brand purposes. + pub ext_warm_grey: C, + /// Colors used for themes, app icons, illustrations, and other brand purposes. + pub ext_orange: C, + /// Colors used for themes, app icons, illustrations, and other brand purposes. + pub ext_yellow: C, + /// Colors used for themes, app icons, illustrations, and other brand purposes. + pub ext_blue: C, + /// Colors used for themes, app icons, illustrations, and other brand purposes. + pub ext_purple: C, + /// Colors used for themes, app icons, illustrations, and other brand purposes. + pub ext_pink: C, + /// Colors used for themes, app icons, illustrations, and other brand purposes. + pub ext_indigo: C, + + /// Potential Accent Color Combos + pub accent_warm_grey: C, + /// Potential Accent Color Combos + pub accent_orange: C, + /// Potential Accent Color Combos + pub accent_yellow: C, + /// Potential Accent Color Combos + pub accent_purple: C, + /// Potential Accent Color Combos + pub accent_pink: C, + /// Potential Accent Color Combos + pub accent_indigo: C, +} + +impl From> for CosmicPaletteInner { + fn from(p: CosmicPaletteInner) -> Self { + CosmicPaletteInner { + name: p.name, + blue: p.blue.into(), + red: p.red.into(), + green: p.green.into(), + yellow: p.yellow.into(), + gray_1: p.gray_1.into(), + gray_2: p.gray_2.into(), + gray_3: p.gray_3.into(), + neutral_1: p.neutral_1.into(), + neutral_2: p.neutral_2.into(), + neutral_3: p.neutral_3.into(), + neutral_4: p.neutral_4.into(), + neutral_5: p.neutral_5.into(), + neutral_6: p.neutral_6.into(), + neutral_7: p.neutral_7.into(), + neutral_8: p.neutral_8.into(), + neutral_9: p.neutral_9.into(), + neutral_10: p.neutral_10.into(), + ext_warm_grey: p.ext_warm_grey.into(), + ext_orange: p.ext_orange.into(), + ext_yellow: p.ext_yellow.into(), + ext_blue: p.ext_blue.into(), + ext_purple: p.ext_purple.into(), + ext_pink: p.ext_pink.into(), + ext_indigo: p.ext_indigo.into(), + accent_warm_grey: p.accent_warm_grey.into(), + accent_orange: p.accent_orange.into(), + accent_yellow: p.accent_yellow.into(), + accent_purple: p.accent_purple.into(), + accent_pink: p.accent_pink.into(), + accent_indigo: p.accent_indigo.into(), + } + } +} + +impl CosmicPalette +where + C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, +{ + /// name of the palette + pub fn name(&self) -> &str { + match &self { + CosmicPalette::Dark(p) => &p.name, + CosmicPalette::Light(p) => &p.name, + CosmicPalette::HighContrastLight(p) => &p.name, + CosmicPalette::HighContrastDark(p) => &p.name, + } + } + /// save the theme to the theme directory + pub fn save(&self) -> anyhow::Result<()> { + let ron_path: PathBuf = [NAME, PALETTE_DIR].iter().collect(); + let ron_dirs = directories::ProjectDirs::from_path(ron_path) + .context("Failed to get project directories.")?; + let ron_name = format!("{}.ron", self.name()); + + if let Ok(p) = ron_dirs.place_config_file(ron_name) { + let mut f = File::create(p)?; + f.write_all(ron::ser::to_string_pretty(self, Default::default())?.as_bytes())?; + } else { + anyhow::bail!("Failed to write RON theme."); + } + Ok(()) + } + + /// init the theme directory + pub fn init() -> anyhow::Result { + let ron_path: PathBuf = [NAME, PALETTE_DIR].iter().collect(); + let base_dirs = directories::BaseDirs::new().context("Failed to get base directories.")?; + Ok(base_dirs.create_config_directory(ron_path)?) + } + + /// load a theme by name + pub fn load_from_name(name: &str) -> anyhow::Result { + let ron_path: PathBuf = [NAME, PALETTE_DIR].iter().collect(); + let ron_dirs = directories::ProjectDirs::from_path(ron_path) + .context("Failed to get project directories.")?; + + let ron_name = format!("{}.ron", name); + if let Some(p) = ron_dirs.find_config_file(ron_name) { + let f = File::open(p)?; + Ok(ron::de::from_reader(f)?) + } else { + anyhow::bail!("Failed to write RON theme."); + } + } + + /// load a theme by path + pub fn load(p: &dyn AsRef) -> anyhow::Result { + let f = File::open(p)?; + Ok(ron::de::from_reader(f)?) + } +} diff --git a/cosmic-theme/src/model/dark.ron b/cosmic-theme/src/model/dark.ron new file mode 100644 index 00000000..d8d0ba8f --- /dev/null +++ b/cosmic-theme/src/model/dark.ron @@ -0,0 +1,95 @@ +Dark ( + ( + name: "cosmic-dark", + blue: ( + c: "#94EBEB", + ), + red: ( + c: "#FFB5B5", + ), + green: ( + c: "#ACF7D2", + ), + yellow: ( + c: "#FFF19E", + ), + gray_1: ( + c: "#1E1E1E", + ), + gray_2: ( + c: "#292929", + ), + gray_3: ( + c: "#2E2E2E", + ), + neutral_1: ( + c: "#000000", + ), + neutral_2: ( + c: "#272727", + ), + neutral_3: ( + c: "#424242", + ), + neutral_4: ( + c: "#5D5D5D", + ), + neutral_5: ( + c: "#787878", + ), + neutral_6: ( + c: "#939393", + ), + neutral_7: ( + c: "#AEAEAE", + ), + neutral_8: ( + c: "#C9C9C9", + ), + neutral_9: ( + c: "#E4E4E4", + ), + neutral_10: ( + c: "#FFFFFF", + ), + ext_warm_grey: ( + c: "#9B8E8A", + ), + ext_orange: ( + c: "#FFAD00", + ), + ext_yellow: ( + c: "#FEDB40", + ), + ext_blue: ( + c: "#48B9C7", + ), + ext_purple: ( + c: "#CF7DFF", + ), + ext_pink: ( + c: "#F93A83", + ), + ext_indigo: ( + c: "#3E88FF", + ), + accent_warm_grey: ( + c: "#554742", + ), + accent_orange: ( + c: "#AF5C02", + ), + accent_yellow: ( + c: "#966800", + ), + accent_purple: ( + c: "#813FFF", + ), + accent_pink: ( + c: "#F93A83", + ), + accent_indigo: ( + c: "#3E88FF", + ), + ) +) \ No newline at end of file diff --git a/cosmic-theme/src/model/derivation.rs b/cosmic-theme/src/model/derivation.rs new file mode 100644 index 00000000..da5f1ec2 --- /dev/null +++ b/cosmic-theme/src/model/derivation.rs @@ -0,0 +1,480 @@ +use palette::Srgba; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::fmt; + +use crate::{util::over, CosmicPalette}; + +/// Theme Container colors of a theme, can be a theme background container, primary container, or secondary container +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct Container { + /// the color of the container + pub base: C, + /// the color of components in the container + pub component: Component, + /// the color of dividers in the container + pub divider: C, + /// the color of text in the container + pub on: C, +} + +impl Container +where + C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, +{ + /// convert to srgba + pub fn into_srgba(self) -> Container { + Container { + base: self.base.into(), + component: self.component.into_srgba(), + divider: self.divider.into(), + on: self.on.into(), + } + } + + pub(crate) fn new( + palette: CosmicPalette, + container_type: ComponentType, + bg: C, + on_bg: C, + ) -> Self { + let mut divider_c: Srgba = on_bg.clone().into(); + divider_c.alpha = 0.2; + + let divider = over(divider_c.clone(), bg.clone()); + Self { + base: bg, + component: (palette, container_type).into(), + divider: divider.into(), + on: on_bg, + } + } +} + +impl From<(CosmicPalette, ContainerType)> for Container +where + C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, +{ + fn from((p, t): (CosmicPalette, ContainerType)) -> Self { + match (p, t) { + (CosmicPalette::Dark(p), ContainerType::Background) => Self::new( + CosmicPalette::Dark(p.clone()), + ComponentType::Background, + p.gray_1.clone(), + p.neutral_7.clone(), + ), + (CosmicPalette::Dark(p), ContainerType::Primary) => Self::new( + CosmicPalette::Dark(p.clone()), + ComponentType::Primary, + p.gray_2.clone(), + p.neutral_8.clone(), + ), + (CosmicPalette::Dark(p), ContainerType::Secondary) => Self::new( + CosmicPalette::Dark(p.clone()), + ComponentType::Secondary, + p.gray_3.clone(), + p.neutral_8.clone(), + ), + (CosmicPalette::HighContrastDark(p), ContainerType::Background) => Self::new( + CosmicPalette::HighContrastDark(p.clone()), + ComponentType::Background, + p.gray_1.clone(), + p.neutral_8.clone(), + ), + (CosmicPalette::HighContrastDark(p), ContainerType::Primary) => Self::new( + CosmicPalette::HighContrastDark(p.clone()), + ComponentType::Primary, + p.gray_2.clone(), + p.neutral_9.clone(), + ), + (CosmicPalette::HighContrastDark(p), ContainerType::Secondary) => Self::new( + CosmicPalette::HighContrastDark(p.clone()), + ComponentType::Secondary, + p.gray_3.clone(), + p.neutral_9.clone(), + ), + (CosmicPalette::Light(p), ContainerType::Background) => Self::new( + CosmicPalette::Light(p.clone()), + ComponentType::Background, + p.gray_1.clone(), + p.neutral_9.clone(), + ), + (CosmicPalette::Light(p), ContainerType::Primary) => Self::new( + CosmicPalette::Light(p.clone()), + ComponentType::Primary, + p.gray_2.clone(), + p.neutral_8.clone(), + ), + (CosmicPalette::Light(p), ContainerType::Secondary) => Self::new( + CosmicPalette::Light(p.clone()), + ComponentType::Secondary, + p.gray_3.clone(), + p.neutral_8.clone(), + ), + (CosmicPalette::HighContrastLight(p), ContainerType::Background) => Self::new( + CosmicPalette::HighContrastLight(p.clone()), + ComponentType::Background, + p.gray_1.clone(), + p.neutral_10.clone(), + ), + (CosmicPalette::HighContrastLight(p), ContainerType::Primary) => Self::new( + CosmicPalette::HighContrastLight(p.clone()), + ComponentType::Primary, + p.gray_2.clone(), + p.neutral_9.clone(), + ), + (CosmicPalette::HighContrastLight(p), ContainerType::Secondary) => Self::new( + CosmicPalette::HighContrastLight(p.clone()), + ComponentType::Secondary, + p.gray_3.clone(), + p.neutral_9.clone(), + ), + } + } +} + +/// The type of the container +#[derive(Copy, Clone, PartialEq, Debug, Deserialize, Serialize)] +pub enum ContainerType { + /// Background type + Background, + /// Primary type + Primary, + /// Secondary type + Secondary, +} + +impl Default for ContainerType { + fn default() -> Self { + Self::Background + } +} + +impl fmt::Display for ContainerType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + ContainerType::Background => write!(f, "Background"), + ContainerType::Primary => write!(f, "Primary Container"), + ContainerType::Secondary => write!(f, "Secondary Container"), + } + } +} + +/// The colors for a widget of the Cosmic theme +#[derive(Clone, PartialEq, Debug, Default, Deserialize, Serialize)] +pub struct Component { + /// The base color of the widget + pub base: C, + /// The color of the widget when it is hovered + pub hover: C, + /// the color of the widget when it is pressed + pub pressed: C, + /// the color of the widget when it is selected + pub selected: C, + /// the color of the widget when it is selected + pub selected_text: C, + /// the color of the widget when it is focused + pub focus: C, + /// the color of dividers for this widget + pub divider: C, + /// the color of text for this widget + pub on: C, + // the color of text with opacity 80 for this widget + // pub text_opacity_80: C, + /// the color of the widget when it is disabled + pub disabled: C, + /// the color of text in the widget when it is disabled + pub on_disabled: C, +} + +impl Component +where + C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, +{ + /// get @hover_state_color + pub fn hover_state_color(&self) -> Srgba { + self.hover.clone().into() + } + /// get @pressed_state_color + pub fn pressed_state_color(&self) -> Srgba { + self.pressed.clone().into() + } + /// get @selected_state_color + pub fn selected_state_color(&self) -> Srgba { + self.selected.clone().into() + } + /// get @selected_state_text_color + pub fn selected_state_text_color(&self) -> Srgba { + self.selected_text.clone().into() + } + /// get @focus_color + pub fn focus_color(&self) -> Srgba { + self.focus.clone().into() + } + /// convert to srgba + pub fn into_srgba(self) -> Component { + Component { + base: self.base.into(), + hover: self.hover.into(), + pressed: self.pressed.into(), + selected: self.selected.into(), + selected_text: self.selected_text.into(), + focus: self.focus.into(), + divider: self.divider.into(), + on: self.on.into(), + disabled: self.disabled.into(), + on_disabled: self.on_disabled.into(), + } + } + + /// helper for producing a component from a base color a neutral and an accent + pub fn colored_component(base: C, neutral: C, accent: C) -> Self { + let neutral = neutral.clone().into(); + let mut neutral_05 = neutral.clone(); + let mut neutral_10 = neutral.clone(); + let mut neutral_20 = neutral.clone(); + neutral_05.alpha = 0.05; + neutral_10.alpha = 0.1; + neutral_20.alpha = 0.2; + + let base: Srgba = base.into(); + let mut base_50 = base.clone(); + base_50.alpha = 0.5; + + let on_20 = neutral.clone(); + let mut on_50 = on_20.clone(); + + on_50.alpha = 0.5; + + Component { + base: base.clone().into(), + hover: over(neutral_10, base).into(), + pressed: over(neutral_20, base).into(), + selected: over(neutral_10, base).into(), + selected_text: accent.clone(), + divider: on_20.into(), + on: neutral.into(), + disabled: base_50.into(), + on_disabled: on_50.into(), + focus: accent, + } + } + + /// helper for producing a component color theme + pub fn component( + base: C, + component_state_overlay: C, + base_overlay: C, + base_overlay_alpha: f32, + accent: C, + on_component: C, + is_high_contrast: bool, + ) -> Self { + let component_state_overlay = component_state_overlay.clone().into(); + let mut component_state_overlay_10 = component_state_overlay.clone(); + let mut component_state_overlay_20 = component_state_overlay.clone(); + component_state_overlay_10.alpha = 0.1; + component_state_overlay_20.alpha = 0.2; + + let base = base.into(); + let mut base_overlay = base_overlay.into(); + base_overlay.alpha = base_overlay_alpha; + let base = over(base_overlay, base); + let mut base_50 = base.clone(); + base_50.alpha = 0.5; + + let mut on_20 = on_component.clone().into(); + let mut on_50 = on_20.clone(); + + on_20.alpha = 0.2; + on_50.alpha = 0.5; + + Component { + base: base.clone().into(), + hover: over(component_state_overlay_10, base).into(), + pressed: over(component_state_overlay_20, base).into(), + selected: over(component_state_overlay_10, base).into(), + selected_text: accent.clone(), + focus: accent.clone(), + divider: if is_high_contrast { + on_50.clone().into() + } else { + on_20.into() + }, + on: on_component.clone(), + disabled: base_50.into(), + on_disabled: on_50.into(), + } + } +} + +/// Derived theme element from a palette and constraints +#[derive(Debug)] +pub struct Derivation { + /// Derived theme element + pub derived: E, + /// Derivation errors (Failed constraints) + pub errors: Vec, +} + +pub(crate) enum ComponentType { + Background, + Primary, + Secondary, + Destructive, + Warning, + Success, + Accent, +} + +impl From<(CosmicPalette, ComponentType)> for Component +where + C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, +{ + fn from((p, t): (CosmicPalette, ComponentType)) -> Self { + match (p, t) { + (CosmicPalette::Dark(p), ComponentType::Background) => Self::component( + p.gray_1, + p.neutral_1, + p.neutral_10, + 0.08, + p.blue, + p.neutral_8, + false, + ), + + (CosmicPalette::Dark(p), ComponentType::Primary) => Self::component( + p.gray_2, + p.neutral_1, + p.neutral_10, + 0.08, + p.blue, + p.neutral_8, + false, + ), + + (CosmicPalette::Dark(p), ComponentType::Secondary) => Self::component( + p.gray_3, + p.neutral_1, + p.neutral_10, + 0.08, + p.blue, + p.neutral_9, + false, + ), + (CosmicPalette::HighContrastDark(p), ComponentType::Background) => Self::component( + p.gray_1, + p.neutral_1, + p.neutral_10, + 0.08, + p.blue, + p.neutral_9, + true, + ), + (CosmicPalette::HighContrastDark(p), ComponentType::Primary) => Self::component( + p.gray_2, + p.neutral_1, + p.neutral_10, + 0.08, + p.blue, + p.neutral_9, + true, + ), + (CosmicPalette::HighContrastDark(p), ComponentType::Secondary) => Self::component( + p.gray_3, + p.neutral_1, + p.neutral_10.clone(), + 0.08, + p.blue, + p.neutral_10, + true, + ), + + (CosmicPalette::Light(p), ComponentType::Background) => Component::component( + p.gray_1.clone(), + p.neutral_1.clone(), + p.neutral_1, + 0.75, + p.blue.clone(), + p.neutral_8, + false, + ), + (CosmicPalette::Light(p), ComponentType::Primary) => Component::component( + p.gray_2.clone(), + p.neutral_1.clone(), + p.neutral_1, + 0.9, + p.blue.clone(), + p.neutral_8, + false, + ), + (CosmicPalette::Light(p), ComponentType::Secondary) => Component::component( + p.gray_3.clone(), + p.neutral_1.clone(), + p.neutral_1, + 1.0, + p.blue.clone(), + p.neutral_8, + false, + ), + (CosmicPalette::HighContrastLight(p), ComponentType::Background) => { + Component::component( + p.gray_1.clone(), + p.neutral_1.clone(), + p.neutral_1, + 0.75, + p.blue.clone(), + p.neutral_9, + true, + ) + } + (CosmicPalette::HighContrastLight(p), ComponentType::Primary) => Component::component( + p.gray_2.clone(), + p.neutral_1.clone(), + p.neutral_1, + 0.9, + p.blue.clone(), + p.neutral_9, + true, + ), + (CosmicPalette::HighContrastLight(p), ComponentType::Secondary) => { + Component::component( + p.gray_3.clone(), + p.neutral_1.clone(), + p.neutral_1, + 1.0, + p.blue.clone(), + p.neutral_9, + true, + ) + } + + (CosmicPalette::Dark(p), ComponentType::Destructive) + | (CosmicPalette::Light(p), ComponentType::Destructive) + | (CosmicPalette::HighContrastLight(p), ComponentType::Destructive) + | (CosmicPalette::HighContrastDark(p), ComponentType::Destructive) => { + Component::colored_component(p.red.clone(), p.neutral_1.clone(), p.blue.clone()) + } + + (CosmicPalette::Dark(p), ComponentType::Warning) + | (CosmicPalette::Light(p), ComponentType::Warning) + | (CosmicPalette::HighContrastLight(p), ComponentType::Warning) + | (CosmicPalette::HighContrastDark(p), ComponentType::Warning) => { + Component::colored_component(p.yellow.clone(), p.neutral_1, p.blue.clone()) + } + + (CosmicPalette::Dark(p), ComponentType::Success) + | (CosmicPalette::Light(p), ComponentType::Success) + | (CosmicPalette::HighContrastLight(p), ComponentType::Success) + | (CosmicPalette::HighContrastDark(p), ComponentType::Success) => { + Component::colored_component(p.green.clone(), p.neutral_1, p.blue.clone()) + } + + (CosmicPalette::Dark(p), ComponentType::Accent) + | (CosmicPalette::Light(p), ComponentType::Accent) + | (CosmicPalette::HighContrastDark(p), ComponentType::Accent) + | (CosmicPalette::HighContrastLight(p), ComponentType::Accent) => { + Component::colored_component(p.blue.clone(), p.neutral_1, p.blue.clone()) + } + } + } +} diff --git a/cosmic-theme/src/model/light.ron b/cosmic-theme/src/model/light.ron new file mode 100644 index 00000000..92951bb7 --- /dev/null +++ b/cosmic-theme/src/model/light.ron @@ -0,0 +1,95 @@ +Light ( + ( + name: "cosmic-light", + blue: ( + c: "#00496D", + ), + red: ( + c: "#A0252B", + ), + green: ( + c: "#3B6E43", + ), + yellow: ( + c: "#966800", + ), + gray_1: ( + c: "#DEDEDE", + ), + gray_2: ( + c: "#E9E9E9", + ), + gray_3: ( + c: "#F4F4F4", + ), + neutral_1: ( + c: "#FFFFFF", + ), + neutral_2: ( + c: "#E4E4E4", + ), + neutral_3: ( + c: "#C9C9C9", + ), + neutral_4: ( + c: "#AEAEAE", + ), + neutral_5: ( + c: "#939393", + ), + neutral_6: ( + c: "#787878", + ), + neutral_7: ( + c: "#5D5D5D", + ), + neutral_8: ( + c: "#424242", + ), + neutral_9: ( + c: "#272727", + ), + neutral_10: ( + c: "#000000", + ), + ext_warm_grey: ( + c: "#9B8E8A", + ), + ext_orange: ( + c: "#FBB86C", + ), + ext_yellow: ( + c: "#F7E062", + ), + ext_blue: ( + c: "#6ACAD8", + ), + ext_purple: ( + c: "#D58CFF", + ), + ext_pink: ( + c: "#FF9CDD", + ), + ext_indigo: ( + c: "#95C4FC", + ), + accent_warm_grey: ( + c: "#ADA29E", + ), + accent_orange: ( + c: "#FFD7A1", + ), + accent_yellow: ( + c: "#FFF19E", + ), + accent_purple: ( + c: "#D58CFF", + ), + accent_pink: ( + c: "#FF9CDD", + ), + accent_indigo: ( + c: "#95C4FC", + ), + ) +) \ No newline at end of file diff --git a/cosmic-theme/src/model/mod.rs b/cosmic-theme/src/model/mod.rs new file mode 100644 index 00000000..684df0b8 --- /dev/null +++ b/cosmic-theme/src/model/mod.rs @@ -0,0 +1,14 @@ +#[cfg(feature = "contrast-derivation")] +pub use constraint::*; +pub use cosmic_palette::*; +pub use derivation::*; +#[cfg(feature = "contrast-derivation")] +pub use selection::*; +pub use theme::*; +#[cfg(feature = "contrast-derivation")] +mod constraint; +mod cosmic_palette; +mod derivation; +#[cfg(feature = "contrast-derivation")] +mod selection; +mod theme; diff --git a/cosmic-theme/src/model/selection.rs b/cosmic-theme/src/model/selection.rs new file mode 100644 index 00000000..a4120c48 --- /dev/null +++ b/cosmic-theme/src/model/selection.rs @@ -0,0 +1,99 @@ +use palette::{named, IntoColor, Lch, Srgba}; +use std::convert::TryFrom; + +/// A Selection is a group of colors from which a cosmic palette can be derived +#[derive(Copy, Clone, Debug, Default)] +pub struct Selection { + /// base background container color + pub background: C, + /// base primary container color + pub primary_container: C, + /// base secondary container color + pub secondary_container: C, + /// base accent color + pub accent: C, + /// custom accent color (overrides base) + pub accent_fg: Option, + /// custom accent nav handle text color (overrides base) + pub accent_nav_handle_fg: Option, + /// base destructive element color + pub destructive: C, + /// base destructive element color + pub warning: C, + /// base destructive element color + pub success: C, +} + +// vector should be in order of most common +impl TryFrom> for Selection +where + C: Clone + From, +{ + type Error = anyhow::Error; + + fn try_from(mut colors: Vec) -> Result { + if colors.len() < 8 { + anyhow::bail!("length of inputted vector must be at least 8.") + } else { + let lch_colors: Vec = colors + .iter() + .map(|x| { + let srgba: Srgba = x.clone().into(); + srgba.color.into_format().into_color() + }) + .collect(); + + let red_lch: Lch = named::CRIMSON.into_format().into_color(); + let mut reddest_i = 1; + for (i, c) in lch_colors[1..].iter().enumerate() { + let d_cur = (c.hue.to_degrees() - red_lch.hue.to_degrees()).abs(); + let reddest_d = (lch_colors[reddest_i].hue.to_degrees().abs() + - red_lch.hue.to_degrees().abs()) + .abs(); + if d_cur < reddest_d { + reddest_i = i; + } + } + + let yellow_lch: Lch = named::YELLOW.into_format().into_color(); + let mut yellow_i = 1; + for (i, c) in lch_colors[1..].iter().enumerate() { + let d_cur = (c.hue.to_degrees() - yellow_lch.hue.to_degrees()).abs(); + let reddest_d = (lch_colors[yellow_i].hue.to_degrees().abs() + - yellow_lch.hue.to_degrees().abs()) + .abs(); + if d_cur < reddest_d { + yellow_i = i; + } + } + + let green_lch: Lch = named::GREEN.into_format().into_color(); + let mut green_i = 1; + for (i, c) in lch_colors[1..].iter().enumerate() { + let d_cur = (c.hue.to_degrees() - green_lch.hue.to_degrees()).abs(); + let reddest_d = (lch_colors[green_i].hue.to_degrees().abs() + - green_lch.hue.to_degrees().abs()) + .abs(); + if d_cur < reddest_d { + green_i = i; + } + } + + let red = colors.remove(reddest_i); + let green = colors.remove(green_i); + let yellow = colors.remove(yellow_i); + + Ok(Self { + background: colors[0].into(), + primary_container: colors[1].into(), + secondary_container: colors[3].into(), + accent: colors[2].into(), + accent_fg: Some(colors[2].into()), + accent_nav_handle_fg: Some(colors[2].into()), + destructive: red.into(), + warning: yellow.into(), + success: green.into(), + }) + } + } +} diff --git a/cosmic-theme/src/model/theme.rs b/cosmic-theme/src/model/theme.rs new file mode 100644 index 00000000..a9e0b13e --- /dev/null +++ b/cosmic-theme/src/model/theme.rs @@ -0,0 +1,419 @@ +use crate::{ + util::CssColor, Component, ComponentType, Container, ContainerType, CosmicPalette, + CosmicPaletteInner, DARK_PALETTE, LIGHT_PALETTE, NAME, THEME_DIR, +}; +use anyhow::Context; +use cosmic_config::{Config, ConfigGet, ConfigSet, CosmicConfigEntry}; +use directories::{BaseDirsExt, ProjectDirsExt}; +use palette::Srgba; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::{ + fmt, + fs::File, + io::Write, + path::{Path, PathBuf}, +}; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +/// Theme layer type +pub enum Layer { + /// Background layer + #[default] + Background, + /// Primary Layer + Primary, + /// Secondary Layer + Secondary, +} + +/// Cosmic Theme data structure with all colors and its name +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Theme { + /// name of the theme + pub name: String, + /// background element colors + pub background: Container, + /// primary element colors + pub primary: Container, + /// secondary element colors + pub secondary: Container, + /// accent element colors + pub accent: Component, + /// suggested element colors + pub success: Component, + /// destructive element colors + pub destructive: Component, + /// warning element colors + pub warning: Component, + /// palette + pub palette: CosmicPaletteInner, + /// is dark + pub is_dark: bool, + /// is high contrast + pub is_high_contrast: bool, +} + +impl CosmicConfigEntry for Theme { + fn write_entry(&self, config: &Config) -> Result<(), cosmic_config::Error> { + let self_ = self.clone(); + // TODO do as transaction + let tx = config.transaction(); + + tx.set("name", self_.name)?; + tx.set("background", self_.background)?; + tx.set("primary", self_.primary)?; + tx.set("secondary", self_.secondary)?; + tx.set("accent", self_.accent)?; + tx.set("success", self_.success)?; + tx.set("destructive", self_.destructive)?; + tx.set("warning", self_.warning)?; + tx.set("palette", self_.palette)?; + tx.set("is_dark", self_.is_dark)?; + tx.set("is_high_contrast", self_.is_high_contrast)?; + + tx.commit() + } + + fn get_entry(config: &Config) -> Result, Self)> { + let mut default = Self::default(); + let mut errors = Vec::new(); + + match config.get::("name") { + Ok(name) => default.name = name, + Err(e) => errors.push(e), + } + match config.get::>("background") { + Ok(background) => default.background = background, + Err(e) => errors.push(e), + } + match config.get::>("primary") { + Ok(primary) => default.primary = primary, + Err(e) => errors.push(e), + } + match config.get::>("secondary") { + Ok(secondary) => default.secondary = secondary, + Err(e) => errors.push(e), + } + match config.get::>("accent") { + Ok(accent) => default.accent = accent, + Err(e) => errors.push(e), + } + match config.get::>("success") { + Ok(success) => default.success = success, + Err(e) => errors.push(e), + } + match config.get::>("destructive") { + Ok(destructive) => default.destructive = destructive, + Err(e) => errors.push(e), + } + match config.get::>("warning") { + Ok(warning) => default.warning = warning, + Err(e) => errors.push(e), + } + match config.get::>("palette") { + Ok(palette) => default.palette = palette, + Err(e) => errors.push(e), + } + match config.get::("is_dark") { + Ok(is_dark) => default.is_dark = is_dark, + Err(e) => errors.push(e), + } + match config.get::("is_high_contrast") { + Ok(is_high_contrast) => default.is_high_contrast = is_high_contrast, + Err(e) => errors.push(e), + } + + if errors.is_empty() { + Ok(default) + } else { + Err((errors, default)) + } + } +} + +impl Default for Theme { + fn default() -> Self { + Theme::::dark_default().into_srgba() + } +} + +impl Default for Theme { + fn default() -> Self { + Self::dark_default() + } +} + +/// Trait for layered themes +pub trait LayeredTheme { + /// Set the layer of the theme + fn set_layer(&mut self, layer: Layer); +} + +// TODO better eq check +impl PartialEq for Theme +where + C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, +{ + fn eq(&self, other: &Self) -> bool { + self.name == other.name + } +} + +impl Eq for Theme where + C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned +{ +} + +impl Theme +where + C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, +{ + /// Convert the theme to a high-contrast variant + pub fn to_high_contrast(&self) -> Self { + todo!(); + } + + /// save the theme to the theme directory + pub fn save(&self) -> anyhow::Result<()> { + let ron_path: PathBuf = [NAME, THEME_DIR].iter().collect(); + let ron_dirs = directories::ProjectDirs::from_path(ron_path) + .context("Failed to get project directories.")?; + let ron_name = format!("{}.ron", &self.name); + + if let Ok(p) = ron_dirs.place_config_file(ron_name) { + let mut f = File::create(p)?; + f.write_all(ron::ser::to_string_pretty(self, Default::default())?.as_bytes())?; + } else { + anyhow::bail!("Failed to write RON theme."); + } + Ok(()) + } + + /// init the theme directory + pub fn init() -> anyhow::Result { + let ron_path: PathBuf = [NAME, THEME_DIR].iter().collect(); + let base_dirs = directories::BaseDirs::new().context("Failed to get base directories.")?; + Ok(base_dirs.create_config_directory(ron_path)?) + } + + /// load a theme by name + pub fn load_from_name(name: &str) -> anyhow::Result { + let ron_path: PathBuf = [NAME, THEME_DIR].iter().collect(); + let ron_dirs = directories::ProjectDirs::from_path(ron_path) + .context("Failed to get project directories.")?; + + let ron_name = format!("{}.ron", name); + if let Some(p) = ron_dirs.find_config_file(ron_name) { + let f = File::open(p)?; + Ok(ron::de::from_reader(f)?) + } else { + anyhow::bail!("Failed to write RON theme."); + } + } + + /// load a theme by path + pub fn load(p: &dyn AsRef) -> anyhow::Result { + let f = File::open(p)?; + Ok(ron::de::from_reader(f)?) + } + + // TODO convenient getter functions for each named color variable + /// get @accent_color + pub fn accent_color(&self) -> Srgba { + self.accent.base.clone().into() + } + /// get @success_color + pub fn success_color(&self) -> Srgba { + self.success.base.clone().into() + } + /// get @destructive_color + pub fn destructive_color(&self) -> Srgba { + self.destructive.base.clone().into() + } + /// get @warning_color + pub fn warning_color(&self) -> Srgba { + self.warning.base.clone().into() + } + + // Containers + /// get @bg_color + pub fn bg_color(&self) -> Srgba { + self.background.base.clone().into() + } + /// get @bg_component_color + pub fn bg_component_color(&self) -> Srgba { + self.background.component.base.clone().into() + } + /// get @primary_container_color + pub fn primary_container_color(&self) -> Srgba { + self.primary.base.clone().into() + } + /// get @primary_component_color + pub fn primary_component_color(&self) -> Srgba { + self.primary.component.base.clone().into() + } + /// get @secondary_container_color + pub fn secondary_container_color(&self) -> Srgba { + self.secondary.base.clone().into() + } + /// get @secondary_component_color + pub fn secondary_component_color(&self) -> Srgba { + self.secondary.component.base.clone().into() + } + + // Text + /// get @on_bg_color + pub fn on_bg_color(&self) -> Srgba { + self.background.on.clone().into() + } + /// get @on_bg_component_color + pub fn on_bg_component_color(&self) -> Srgba { + self.background.component.on.clone().into() + } + /// get @on_primary_color + pub fn on_primary_container_color(&self) -> Srgba { + self.primary.on.clone().into() + } + /// get @on_primary_component_color + pub fn on_primary_component_color(&self) -> Srgba { + self.primary.component.on.clone().into() + } + /// get @on_secondary_color + pub fn on_secondary_container_color(&self) -> Srgba { + self.secondary.on.clone().into() + } + /// get @on_secondary_component_color + pub fn on_secondary_component_color(&self) -> Srgba { + self.secondary.component.on.clone().into() + } + /// get @accent_text_color + pub fn accent_text_color(&self) -> Srgba { + self.accent.base.clone().into() + } + /// get @success_text_color + pub fn success_text_color(&self) -> Srgba { + self.success.base.clone().into() + } + /// get @warning_text_color + pub fn warning_text_color(&self) -> Srgba { + self.warning.base.clone().into() + } + /// get @destructive_text_color + pub fn destructive_text_color(&self) -> Srgba { + self.destructive.base.clone().into() + } + /// get @on_accent_color + pub fn on_accent_color(&self) -> Srgba { + self.accent.on.clone().into() + } + /// get @on_success_color + pub fn on_success_color(&self) -> Srgba { + self.success.on.clone().into() + } + /// get @oon_warning_color + pub fn on_warning_color(&self) -> Srgba { + self.warning.on.clone().into() + } + /// get @on_destructive_color + pub fn on_destructive_color(&self) -> Srgba { + self.destructive.on.clone().into() + } + + // Borders and Dividers + /// get @bg_divider + pub fn bg_divider(&self) -> Srgba { + self.background.divider.clone().into() + } + /// get @bg_component_divider + pub fn bg_component_divider(&self) -> Srgba { + self.background.component.divider.clone().into() + } + /// get @primary_container_divider + pub fn primary_container_divider(&self) -> Srgba { + self.primary.divider.clone().into() + } + /// get @primary_component_divider + pub fn primary_component_divider(&self) -> Srgba { + self.primary.component.divider.clone().into() + } + /// get @secondary_container_divider + pub fn secondary_container_divider(&self) -> Srgba { + self.secondary.divider.clone().into() + } + /// get @secondary_component_divider + pub fn secondary_component_divider(&self) -> Srgba { + self.secondary.component.divider.clone().into() + } + + /// get @window_header_bg + pub fn window_header_bg(&self) -> Srgba { + self.background.base.clone().into() + } +} + +impl Theme { + /// get the built in light theme + pub fn light_default() -> Self { + LIGHT_PALETTE.clone().into() + } + + /// get the built in dark theme + pub fn dark_default() -> Self { + DARK_PALETTE.clone().into() + } + + /// get the built in high contrast dark theme + pub fn high_contrast_dark_default() -> Self { + CosmicPalette::HighContrastDark(DARK_PALETTE.as_ref().clone()).into() + } + + /// get the built in high contrast light theme + pub fn high_contrast_light_default() -> Self { + CosmicPalette::HighContrastLight(LIGHT_PALETTE.as_ref().clone()).into() + } + + /// convert to srgba + pub fn into_srgba(self) -> Theme { + Theme { + name: self.name, + background: self.background.into_srgba(), + primary: self.primary.into_srgba(), + secondary: self.secondary.into_srgba(), + accent: self.accent.into_srgba(), + success: self.success.into_srgba(), + destructive: self.destructive.into_srgba(), + warning: self.warning.into_srgba(), + palette: self.palette.into(), + is_dark: self.is_dark, + is_high_contrast: self.is_high_contrast, + } + } +} + +impl From> for Theme +where + C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, +{ + fn from(p: CosmicPalette) -> Self { + let is_dark = p.is_dark(); + let is_high_contrast = p.is_high_contrast(); + Self { + name: p.name().to_string(), + background: (p.clone(), ContainerType::Background).into(), + primary: (p.clone(), ContainerType::Primary).into(), + secondary: (p.clone(), ContainerType::Secondary).into(), + accent: (p.clone(), ComponentType::Accent).into(), + success: (p.clone(), ComponentType::Success).into(), + destructive: (p.clone(), ComponentType::Destructive).into(), + warning: (p.clone(), ComponentType::Warning).into(), + palette: match p { + CosmicPalette::Dark(p) => p.into(), + CosmicPalette::Light(p) => p.into(), + CosmicPalette::HighContrastLight(p) => p.into(), + CosmicPalette::HighContrastDark(p) => p.into(), + }, + is_dark, + is_high_contrast, + } + } +} diff --git a/cosmic-theme/src/output/gtk4_output.rs b/cosmic-theme/src/output/gtk4_output.rs new file mode 100644 index 00000000..43fb498c --- /dev/null +++ b/cosmic-theme/src/output/gtk4_output.rs @@ -0,0 +1,187 @@ +use crate::{ + model::{Accent, Container, ContainerType, Destructive, Widget}, + Hex, Theme, NAME, +}; +use anyhow::{bail, Result}; +use palette::Srgba; +use serde::{de::DeserializeOwned, Serialize}; +use std::{fmt, fs::File, io::prelude::*, path::PathBuf}; + +pub(crate) const CSS_DIR: &'static str = "css"; +pub(crate) const THEME_DIR: &'static str = "themes"; + +/// Trait for outputting the Theme as Gtk4CSS +pub trait Gtk4Output { + /// turn the theme into css + fn as_css(&self) -> String; + /// Serialize the theme as RON and write the CSS to the appropriate directories + /// Should be written in the XDG data directory for cosmic-theme + fn write(&self) -> Result<()>; +} + +impl Gtk4Output for Theme +where + C: Clone + + fmt::Debug + + Default + + Into + + Into + + From + + Serialize + + DeserializeOwned, +{ + fn as_css(&self) -> String { + let Self { + background, + primary, + secondary, + accent, + destructive, + .. + } = self; + let mut css = String::new(); + + css.push_str(&background.as_css()); + css.push_str(&primary.as_css()); + css.push_str(&secondary.as_css()); + css.push_str(&accent.as_css()); + css.push_str(&destructive.as_css()); + + css + } + + fn write(&self) -> Result<()> { + // TODO sass -> css + let ron_str = ron::ser::to_string_pretty(self, Default::default())?; + let css_str = self.as_css(); + + let ron_path: PathBuf = [NAME, THEME_DIR].iter().collect(); + let css_path: PathBuf = [NAME, CSS_DIR].iter().collect(); + + let ron_dirs = xdg::BaseDirectories::with_prefix(ron_path)?; + let css_dirs = xdg::BaseDirectories::with_prefix(css_path)?; + + let ron_name = format!("{}.ron", &self.name); + let css_name = format!("{}.css", &self.name); + + if let Ok(p) = ron_dirs.place_data_file(ron_name) { + let mut f = File::create(p)?; + f.write_all(ron_str.as_bytes())?; + } else { + bail!("Failed to write RON theme.") + } + + if let Ok(p) = css_dirs.place_data_file(css_name) { + let mut f = File::create(p)?; + f.write_all(css_str.as_bytes())?; + } else { + bail!("Failed to write RON theme.") + } + + Ok(()) + } +} + +/// Trait for converting theme data into gtk4 CSS +pub trait AsGtk4Css +where + C: Copy + Into + From, +{ + /// function for converting theme data into gtk4 CSS + fn as_css(&self) -> String; +} + +impl AsGtk4Css for Container +where + C: Copy + Clone + fmt::Debug + Default + Into + From + fmt::Display, +{ + fn as_css(&self) -> String { + let Self { + prefix, + container, + container_component, + container_divider, + container_fg, + .. + } = self; + + let prefix_lower = match prefix { + ContainerType::Background => "background", + ContainerType::Primary => "primary", + ContainerType::Secondary => "secondary", + }; + let component = widget_gtk4_css(prefix_lower, container_component); + + format!( + r#" +@define-color {prefix_lower}_container #{{{container}}}; +@define-color {prefix_lower}_container_divider #{{{container_divider}}}; +@define-color {prefix_lower}_container_fg #{{{container_fg}}}; +{component} +"# + ) + } +} + +impl AsGtk4Css for Accent +where + C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, +{ + fn as_css(&self) -> String { + let Accent { + accent, + accent_fg, + accent_nav_handle_fg, + suggested, + } = self; + let suggested = widget_gtk4_css("suggested", suggested); + + format!( + r#" +@define-color accent #{{{accent}}}; +@define-color accent_fg #{{{accent_fg}}}; +@define-color accent_nav_handle_fg #{{{accent_nav_handle_fg}}}; +{suggested} +"# + ) + } +} + +impl AsGtk4Css for Destructive +where + C: Clone + fmt::Debug + Default + Into + From + Serialize + DeserializeOwned, +{ + fn as_css(&self) -> String { + let Destructive { destructive } = &self; + widget_gtk4_css("destructive", destructive) + } +} + +fn widget_gtk4_css( + prefix: &str, + Widget { + base, + hover, + pressed, + focused, + divider, + text, + text_opacity_80, + disabled, + disabled_fg, + }: &Widget, +) -> String { + format!( + r#" +@define-color {prefix}_widget_base #{{{base}}}; +@define-color {prefix}_widget_hover #{{{hover}}}; +@define-color {prefix}_widget_pressed #{{{pressed}}}; +@define-color {prefix}_widget_focused #{{{focused}}}; +@define-color {prefix}_widget_divider #{{{divider}}}; +@define-color {prefix}_widget_fg #{{{text}}}; +@define-color {prefix}_widget_fg_opacity_80 #{{{text_opacity_80}}}; +@define-color {prefix}_widget_disabled #{{{disabled}}}; +@define-color {prefix}_widget_disabled_fg #{{{disabled_fg}}}; +"# + ) +} diff --git a/cosmic-theme/src/output/mod.rs b/cosmic-theme/src/output/mod.rs new file mode 100644 index 00000000..31307629 --- /dev/null +++ b/cosmic-theme/src/output/mod.rs @@ -0,0 +1,8 @@ +#[cfg(feature = "gtk4-theme")] +/// Module for outputting the Cosmic gtk4 theme type as CSS +pub mod gtk4_output; +#[cfg(feature = "gtk4-theme")] +pub use gtk4_output::*; + +#[cfg(feature = "ron-serialization")] +pub use ron::*; diff --git a/cosmic-theme/src/theme_provider/mod.rs b/cosmic-theme/src/theme_provider/mod.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/cosmic-theme/src/theme_provider/mod.rs @@ -0,0 +1 @@ + diff --git a/cosmic-theme/src/util.rs b/cosmic-theme/src/util.rs new file mode 100644 index 00000000..afbf98c6 --- /dev/null +++ b/cosmic-theme/src/util.rs @@ -0,0 +1,59 @@ +use csscolorparser::Color; +use palette::Srgba; +use serde::{Deserialize, Serialize}; + +/// utility wrapper for serializing and deserializing colors with arbitrary CSS +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct CssColor { + c: Color, +} + +impl From for CssColor { + fn from(c: Srgba) -> Self { + Self { + c: Color { + r: c.red as f64, + g: c.green as f64, + b: c.blue as f64, + a: c.alpha as f64, + }, + } + } +} + +impl Into for CssColor { + fn into(self) -> Srgba { + Srgba::new( + self.c.r as f32, + self.c.g as f32, + self.c.b as f32, + self.c.a as f32, + ) + } +} + +/// straight alpha "A over B" operator on non-linear srgba +pub fn over, B: Into>(a: A, b: B) -> Srgba { + let a = a.into(); + let b = b.into(); + let o_a = (alpha_over(a.alpha, b.alpha)).max(0.0).min(1.0); + let o_r = (c_over(a.red, b.red, a.alpha, b.alpha, o_a)) + .max(0.0) + .min(1.0); + let o_g = (c_over(a.green, b.green, a.alpha, b.alpha, o_a)) + .max(0.0) + .min(1.0); + let o_b = (c_over(a.blue, b.blue, a.alpha, b.alpha, o_a)) + .max(0.0) + .min(1.0); + + Srgba::new(o_r, o_g, o_b, o_a) +} + +fn alpha_over(a: f32, b: f32) -> f32 { + a + b * (1.0 - a) +} + +fn c_over(a: f32, b: f32, a_alpha: f32, b_alpha: f32, o_alpha: f32) -> f32 { + a * a_alpha + b * b_alpha * (1.0 - a_alpha) / o_alpha +} diff --git a/examples/cosmic/Cargo.toml b/examples/cosmic/Cargo.toml index 7be94b52..feb41ae5 100644 --- a/examples/cosmic/Cargo.toml +++ b/examples/cosmic/Cargo.toml @@ -12,3 +12,4 @@ libcosmic = { path = "../..", default-features = false, features = ["debug", "wi once_cell = "1.15" slotmap = "1.0.6" env_logger = "0.10" +log = "0.4.17" diff --git a/examples/cosmic/src/window.rs b/examples/cosmic/src/window.rs index de94840c..948d5c30 100644 --- a/examples/cosmic/src/window.rs +++ b/examples/cosmic/src/window.rs @@ -1,6 +1,7 @@ /// Copyright 2022 System76 // SPDX-License-Identifier: MPL-2.0 use cosmic::{ + cosmic_config::config_subscription, font::load_fonts, iced::{self, Application, Command, Length, Subscription}, iced::{ @@ -9,15 +10,20 @@ use cosmic::{ window::{self, close, drag, minimize, toggle_maximize}, }, keyboard_nav, - theme::{self, Theme}, + theme::{self, CosmicTheme, CosmicThemeCss, Theme}, widget::{ header_bar, icon, list, nav_bar, nav_bar_toggle, scrollable, segmented_button, settings, warning, IconSource, }, Element, ElementExt, }; +use log::error; use std::{ - sync::atomic::{AtomicU32, Ordering}, + borrow::Cow, + sync::{ + atomic::{AtomicU32, Ordering}, + Arc, + }, vec, }; @@ -154,6 +160,7 @@ pub struct Window { warning_message: String, scale_factor: f64, scale_factor_string: String, + system_theme: Arc, } impl Window { @@ -198,6 +205,7 @@ pub enum Message { ToggleNavBarCondensed, ToggleWarning, FontsLoaded, + SystemTheme(CosmicTheme), } impl From for Message { @@ -375,6 +383,16 @@ impl Application for Window { Subscription::batch(vec![ window_break.map(|_| Message::CondensedViewToggle), keyboard_nav::subscription().map(Message::KeyboardNav), + config_subscription::<_, CosmicThemeCss>(0, Cow::from("com.system76.CosmicTheme"), 1) + .map(|(_, update)| match update { + Ok(t) => Message::SystemTheme(t.into_srgba()), + Err((errors, t)) => { + for error in errors { + error!("{:?}", error); + } + Message::SystemTheme(t.into_srgba()) + } + }), ]) } @@ -395,7 +413,13 @@ impl Application for Window { Some(demo::Output::Debug(debug)) => self.debug = debug, Some(demo::Output::ScalingFactor(factor)) => self.set_scale_factor(factor), Some(demo::Output::ThemeChanged(theme)) => { - self.theme = theme; + self.theme = match theme { + demo::ThemeVariant::Light => Theme::light(), + demo::ThemeVariant::Dark => Theme::dark(), + demo::ThemeVariant::HighContrastDark => Theme::dark_hc(), + demo::ThemeVariant::HighContrastLight => Theme::light_hc(), + demo::ThemeVariant::Custom => Theme::custom(self.system_theme.clone()), + }; } Some(demo::Output::ToggleWarning) => self.toggle_warning(), None => (), @@ -425,6 +449,9 @@ impl Application for Window { }, Message::ToggleWarning => self.toggle_warning(), Message::FontsLoaded => {} + Message::SystemTheme(t) => { + self.system_theme = Arc::new(t); + } } ret } @@ -572,6 +599,6 @@ impl Application for Window { } fn theme(&self) -> Theme { - self.theme + self.theme.clone() } } diff --git a/examples/cosmic/src/window/demo.rs b/examples/cosmic/src/window/demo.rs index a177fcbf..243ba077 100644 --- a/examples/cosmic/src/window/demo.rs +++ b/examples/cosmic/src/window/demo.rs @@ -1,9 +1,9 @@ use apply::Apply; use cosmic::{ cosmic_theme, - iced::widget::{checkbox, pick_list, progress_bar, radio, row, slider, text, text_input}, + iced::widget::{checkbox, column, pick_list, progress_bar, radio, slider, text, text_input}, iced::{id, Alignment, Length}, - theme::{self, Button as ButtonTheme, Theme}, + theme::{self, Button as ButtonTheme, Theme, ThemeType}, widget::{ button, container, icon, segmented_button, segmented_selection, settings, spin_button, toggler, view_switcher, @@ -15,6 +15,33 @@ use once_cell::sync::Lazy; use super::{Page, Window}; +#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Ord, Eq)] +pub enum ThemeVariant { + Light, + Dark, + HighContrastDark, + HighContrastLight, + Custom, +} + +impl From<&ThemeType> for ThemeVariant { + fn from(theme: &ThemeType) -> Self { + match theme { + ThemeType::Light => ThemeVariant::Light, + ThemeType::Dark => ThemeVariant::Dark, + ThemeType::HighContrastDark => ThemeVariant::HighContrastDark, + ThemeType::HighContrastLight => ThemeVariant::HighContrastLight, + ThemeType::Custom(_) => ThemeVariant::Custom, + } + } +} + +impl From for ThemeVariant { + fn from(theme: ThemeType) -> Self { + ThemeVariant::from(&theme) + } +} + pub enum DemoView { TabA, TabB, @@ -44,7 +71,7 @@ pub enum Message { Selection(segmented_button::Entity), SliderChanged(f32), SpinButton(spin_button::Message), - ThemeChanged(Theme), + ThemeChanged(ThemeVariant), ToggleWarning, TogglerToggled(bool), ViewSwitcher(segmented_button::Entity), @@ -54,7 +81,7 @@ pub enum Message { pub enum Output { Debug(bool), ScalingFactor(f32), - ThemeChanged(Theme), + ThemeChanged(ThemeVariant), ToggleWarning, } @@ -151,20 +178,21 @@ impl State { pub(super) fn view<'a>(&'a self, window: &'a Window) -> Element<'a, Message> { let choose_theme = [ - Theme::light(), - Theme::dark(), - Theme::light_hc(), - Theme::dark_hc(), + ThemeVariant::Light, + ThemeVariant::Dark, + ThemeVariant::HighContrastLight, + ThemeVariant::HighContrastLight, + ThemeVariant::Custom, ] - .iter() + .into_iter() .fold( - row![].spacing(10).align_items(Alignment::Center), + column![].spacing(10).align_items(Alignment::Center), |row, theme| { row.push(radio( - format!("{:?}", theme.theme_type), - *theme, - if window.theme == *theme { - Some(*theme) + format!("{:?}", theme), + theme, + if ThemeVariant::from(&window.theme.theme_type) == theme { + Some(theme) } else { None }, diff --git a/src/lib.rs b/src/lib.rs index 96f60865..2f4784a1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ #![allow(clippy::module_name_repetitions)] +pub use cosmic_config; pub use cosmic_theme; pub use iced; pub use iced_runtime; @@ -12,7 +13,6 @@ pub use iced_style; pub use iced_widget; #[cfg(feature = "winit")] pub use iced_winit; - #[cfg(feature = "applet")] pub mod applet; pub mod executor; diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 6b9130db..b16cd2af 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -7,6 +7,7 @@ mod segmented_button; use std::hash::Hash; use std::hash::Hasher; use std::rc::Rc; +use std::sync::Arc; pub use self::segmented_button::SegmentedButton; @@ -32,10 +33,10 @@ use iced_style::toggler; use iced_core::{Background, Color}; use palette::Srgba; -type CosmicColor = ::palette::rgb::Srgba; -type CosmicComponent = cosmic_theme::Component; -type CosmicTheme = cosmic_theme::Theme; -type CosmicThemeCss = cosmic_theme::Theme; +pub type CosmicColor = ::palette::rgb::Srgba; +pub type CosmicComponent = cosmic_theme::Component; +pub type CosmicTheme = cosmic_theme::Theme; +pub type CosmicThemeCss = cosmic_theme::Theme; lazy_static::lazy_static! { pub static ref COSMIC_DARK: CosmicTheme = CosmicThemeCss::dark_default().into_srgba(); @@ -56,16 +57,17 @@ lazy_static::lazy_static! { }; } -#[derive(Debug, Copy, Clone, Eq, PartialEq, Default)] +#[derive(Debug, Clone, Eq, PartialEq, Default)] pub enum ThemeType { #[default] Dark, Light, HighContrastDark, HighContrastLight, + Custom(Arc), } -#[derive(Debug, Copy, Clone, Eq, PartialEq, Default)] +#[derive(Debug, Clone, Eq, PartialEq, Default)] pub struct Theme { pub theme_type: ThemeType, pub layer: cosmic_theme::Layer, @@ -79,6 +81,7 @@ impl Theme { ThemeType::Light => &COSMIC_LIGHT, ThemeType::HighContrastDark => &COSMIC_HC_DARK, ThemeType::HighContrastLight => &COSMIC_HC_LIGHT, + ThemeType::Custom(ref t) => t.as_ref(), } } @@ -114,6 +117,14 @@ impl Theme { } } + #[must_use] + pub fn custom(theme: Arc) -> Self { + Self { + theme_type: ThemeType::Custom(theme), + ..Default::default() + } + } + /// get current container /// can be used in a component that is intended to be a child of a `CosmicContainer` #[must_use]