diff --git a/Cargo.toml b/Cargo.toml index 69305c9..c17374e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,9 +27,9 @@ pkg-url = "{ repo }/releases/download/v{ version }/whiskers-{ target }{ archive- pkg-fmt = "bin" [lints.clippy] -all = "warn" -pedantic = "warn" -nursery = "warn" +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } unwrap_used = "warn" missing_errors_doc = "allow" implicit_hasher = "allow" diff --git a/README.md b/README.md index 2f830df..7976e52 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,13 @@ into. You can install Whiskers using one of the methods below: -| Installation Method | Instructions | -| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| crates.io | `cargo install catppuccin-whiskers` | -| Source | `cargo install --git https://github.com/catppuccin/whiskers catppuccin-whiskers` | -| Homebrew | `brew install catppuccin/tap/whiskers` | -| Nix | `nix profile install github:catppuccin/whiskers`
`nix run github:catppuccin/whiskers -- ` | -| Binaries
(Windows, MacOS & Linux) | Available from the [latest GitHub release](https://github.com/catppuccin/whiskers/releases?q=whiskers). | +| Installation Method | Instructions | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| crates.io | `cargo install catppuccin-whiskers` | +| Source | `cargo install --git https://github.com/catppuccin/whiskers catppuccin-whiskers` | +| Homebrew | `brew install catppuccin/tap/whiskers` | +| Nix | `nix profile install github:catppuccin/whiskers`
`nix run github:catppuccin/whiskers -- ` | +| Binaries
(Windows, MacOS & Linux) | Available from the [latest GitHub release](https://github.com/catppuccin/whiskers/releases?q=whiskers). | ## Usage @@ -50,7 +50,7 @@ Arguments: Options: -f, --flavor Render a single flavor instead of all four - + [possible values: latte, frappe, macchiato, mocha] --color-overrides @@ -61,7 +61,7 @@ Options: --check [] Instead of creating an output, check it against an example - + In single-output mode, a path to the example file must be provided. In multi-output mode, no path is required and, if one is provided, it will be ignored. --dry-run @@ -78,7 +78,7 @@ Options: -o, --output-format Output format of --list-functions - + [default: json] [possible values: json, yaml, markdown, markdown-table, plain] @@ -111,18 +111,18 @@ The following variables are available for use in your templates: #### Single-Flavor Mode -| Variable | Description | -| - | - | -| `flavor` ([`Flavor`](#flavor)) | The flavor being templated. | -| `rosewater`, `flamingo`, `pink`, [etc.](https://github.com/catppuccin/catppuccin#-palette) ([`Color`](#color)) | All colors of the flavor being templated. | -| Any Frontmatter | All frontmatter variables as described in the [Frontmatter](#Frontmatter) section. | +| Variable | Description | +| -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `flavor` ([`Flavor`](#flavor)) | The flavor being templated. | +| `rosewater`, `flamingo`, `pink`, [etc.](https://github.com/catppuccin/catppuccin#-palette) ([`Color`](#color)) | All colors of the flavor being templated. | +| Any Frontmatter | All frontmatter variables as described in the [Frontmatter](#Frontmatter) section. | #### Multi-Flavor Mode -| Variable | Description | -| - | - | -| `flavors` (Map\) | An array containing all of the named flavors, with every other context variable. | -| Any Frontmatter | All frontmatter variables as described in the [Frontmatter](#Frontmatter) section. | +| Variable | Description | +| --------------------------------------------- | ---------------------------------------------------------------------------------- | +| `flavors` (Map\) | An array containing all of the named flavors, with every other context variable. | +| Any Frontmatter | All frontmatter variables as described in the [Frontmatter](#Frontmatter) section. | #### Types @@ -130,71 +130,71 @@ These types are designed to closely match the [palette.json](https://github.com/ ##### Flavor -| Field | Type | Description | Examples | -| - | - | - | - | -| `name` | `String` | The name of the flavor. | `"Latte"`, `"FrappΓ©"`, `"Macchiato"`, `"Mocha"` | -| `identifier` | `String` | The identifier of the flavor. | `"latte"`, `"frappe"`, `"macchiato"`, `"mocha"` | -| `emoji` | `char` | Emoji associated with the flavor. | `'🌻'`, `'πŸͺ΄'`, `'🌺'`, `'🌿'` | -| `order` | `u32` | Order of the flavor in the palette spec. | `0` to `3` | -| `dark` | `bool` | Whether the flavor is dark. | `false` for Latte, `true` for others | -| `light` | `bool` | Whether the flavor is light. | `true` for Latte, `false` for others | -| `colors` | `Map` | A map of color identifiers to their respective values. | | +| Field | Type | Description | Examples | +| ------------ | -------------------- | ------------------------------------------------------ | ----------------------------------------------- | +| `name` | `String` | The name of the flavor. | `"Latte"`, `"FrappΓ©"`, `"Macchiato"`, `"Mocha"` | +| `identifier` | `String` | The identifier of the flavor. | `"latte"`, `"frappe"`, `"macchiato"`, `"mocha"` | +| `emoji` | `char` | Emoji associated with the flavor. | `'🌻'`, `'πŸͺ΄'`, `'🌺'`, `'🌿'` | +| `order` | `u32` | Order of the flavor in the palette spec. | `0` to `3` | +| `dark` | `bool` | Whether the flavor is dark. | `false` for Latte, `true` for others | +| `light` | `bool` | Whether the flavor is light. | `true` for Latte, `false` for others | +| `colors` | `Map` | A map of color identifiers to their respective values. | | ##### Color -| Field | Type | Description | Examples | -| - | - | - | - | -| `name` | `String` | The name of the color. | `"Rosewater"`, `"Surface 0"`, `"Base"` | -| `identifier` | `String` | The identifier of the color. | `"rosewater"`, `"surface0"`, `"base"` | -| `order` | `u32` | Order of the color in the palette spec. | `0` to `25` | -| `accent` | `bool` | Whether the color is an accent color. | | -| `hex` | `String` | The color in hexadecimal format. | `"1e1e2e"` | -| `rgb` | `RGB` | The color in RGB format. | | -| `hsl` | `HSL` | The color in HSL format. | | -| `opacity` | `u8` | The opacity of the color. | `0` to `255` | +| Field | Type | Description | Examples | +| ------------ | -------- | --------------------------------------- | -------------------------------------- | +| `name` | `String` | The name of the color. | `"Rosewater"`, `"Surface 0"`, `"Base"` | +| `identifier` | `String` | The identifier of the color. | `"rosewater"`, `"surface0"`, `"base"` | +| `order` | `u32` | Order of the color in the palette spec. | `0` to `25` | +| `accent` | `bool` | Whether the color is an accent color. | | +| `hex` | `String` | The color in hexadecimal format. | `"1e1e2e"` | +| `rgb` | `RGB` | The color in RGB format. | | +| `hsl` | `HSL` | The color in HSL format. | | +| `opacity` | `u8` | The opacity of the color. | `0` to `255` | ##### RGB -| Field | Type | Description | -| - | - | - | -| `r` | `u8` | The red channel of the color. | -| `g` | `u8` | The green channel of the color. | -| `b` | `u8` | The blue channel of the color. | +| Field | Type | Description | +| ----- | ---- | ------------------------------- | +| `r` | `u8` | The red channel of the color. | +| `g` | `u8` | The green channel of the color. | +| `b` | `u8` | The blue channel of the color. | ##### HSL -| Field | Type | Description | -| - | - | - | -| `h` | `u16` | The hue of the color. | -| `s` | `u8` | The saturation of the color. | -| `l` | `u8` | The lightness of the color. | +| Field | Type | Description | +| ----- | ----- | ---------------------------- | +| `h` | `u16` | The hue of the color. | +| `s` | `u8` | The saturation of the color. | +| `l` | `u8` | The lightness of the color. | ### Functions -| Name | Description | Examples | -| ----------- | ------------------------------------------------------------------------------ | ----------------------------------------------------- | -| `if` | Return one value if a condition is true, and another if it's false | `if(cond=true, t=1, f=0)` β‡’ `1` | -| `object` | Create an object from the input | `object(a=1, b=2)` β‡’ `{a: 1, b: 2}` | -| `css_rgb` | Convert a color to an RGB CSS string | `css_rgb(color=red)` β‡’ `rgb(210, 15, 57)` | -| `css_rgba` | Convert a color to an RGBA CSS string | `css_rgba(color=red)` β‡’ `rgba(210, 15, 57, 1.00)` | -| `css_hsl` | Convert a color to an HSL CSS string | `css_hsl(color=red)` β‡’ `hsl(347, 87%, 44%)` | -| `css_hsla` | Convert a color to an HSLA CSS string | `css_hsla(color=red)` β‡’ `hsla(347, 87%, 44%, 1.00)` | -| `read_file` | Read and include the contents of a file, path is relative to the template file | `read_file(path="abc.txt")` β‡’ `abc` | +| Name | Description | Examples | +| ----------- | ------------------------------------------------------------------------------ | --------------------------------------------------- | +| `if` | Return one value if a condition is true, and another if it's false | `if(cond=true, t=1, f=0)` β‡’ `1` | +| `object` | Create an object from the input | `object(a=1, b=2)` β‡’ `{a: 1, b: 2}` | +| `css_rgb` | Convert a color to an RGB CSS string | `css_rgb(color=red)` β‡’ `rgb(210, 15, 57)` | +| `css_rgba` | Convert a color to an RGBA CSS string | `css_rgba(color=red)` β‡’ `rgba(210, 15, 57, 1.00)` | +| `css_hsl` | Convert a color to an HSL CSS string | `css_hsl(color=red)` β‡’ `hsl(347, 87%, 44%)` | +| `css_hsla` | Convert a color to an HSLA CSS string | `css_hsla(color=red)` β‡’ `hsla(347, 87%, 44%, 1.00)` | +| `read_file` | Read and include the contents of a file, path is relative to the template file | `read_file(path="abc.txt")` β‡’ `abc` | ### Filters -| Name | Description | Examples | -| ---------------- | ---------------------------------------------------------------- | -------------------------------------------------- | -| `add` | Add a value to a color | `red \| add(hue=30)` β‡’ `#ff6666` | -| `sub` | Subtract a value from a color | `red \| sub(hue=30)` β‡’ `#d30f9b` | -| `mod` | Modify a color | `red \| mod(lightness=80)` β‡’ `#f8a0b3` | -| `mix` | Mix two colors together | `red \| mix(color=base, amount=0.5)` β‡’ `#e08097` | -| `urlencode_lzma` | Serialize an object into a URL-safe string with LZMA compression | `red \| urlencode_lzma` β‡’ `#ff6666` | -| `trunc` | Truncate a number to a certain number of places | `1.123456 \| trunc(places=3)` β‡’ `1.123` | -| `css_rgb` | Convert a color to an RGB CSS string | `red \| css_rgb` β‡’ `rgb(210, 15, 57)` | -| `css_rgba` | Convert a color to an RGBA CSS string | `red \| css_rgba` β‡’ `rgba(210, 15, 57, 1.00)` | -| `css_hsl` | Convert a color to an HSL CSS string | `red \| css_hsl` β‡’ `hsl(347, 87%, 44%)` | -| `css_hsla` | Convert a color to an HSLA CSS string | `red \| css_hsla` β‡’ `hsla(347, 87%, 44%, 1.00)` | +| Name | Description | Examples | +| ---------------- | ---------------------------------------------------------------- | ------------------------------------------------ | +| `add` | Add a value to a color | `red \| add(hue=30)` β‡’ `#ff6666` | +| `sub` | Subtract a value from a color | `red \| sub(hue=30)` β‡’ `#d30f9b` | +| `mod` | Modify a color | `red \| mod(lightness=80)` β‡’ `#f8a0b3` | +| `mix` | Mix two colors together | `red \| mix(color=base, amount=0.5)` β‡’ `#e08097` | +| `urlencode_lzma` | Serialize an object into a URL-safe string with LZMA compression | `red \| urlencode_lzma` β‡’ `#ff6666` | +| `trunc` | Truncate a number to a certain number of places | `1.123456 \| trunc(places=3)` β‡’ `1.123` | +| `css_rgb` | Convert a color to an RGB CSS string | `red \| css_rgb` β‡’ `rgb(210, 15, 57)` | +| `css_rgba` | Convert a color to an RGBA CSS string | `red \| css_rgba` β‡’ `rgba(210, 15, 57, 1.00)` | +| `css_hsl` | Convert a color to an HSL CSS string | `red \| css_hsl` β‡’ `hsl(347, 87%, 44%)` | +| `css_hsla` | Convert a color to an HSLA CSS string | `red \| css_hsla` β‡’ `hsla(347, 87%, 44%, 1.00)` | > [!NOTE] > You also have access to all of Tera's own built-in filters and functions. @@ -228,6 +228,32 @@ If the version key is not present, Whiskers will display a warning and attempt to render the template anyway. However, it is recommended to always include the version key to ensure compatibility with future versions of Whiskers. +### Hex Format + +The format used for rendering colors in hexadecimal can be customised with the `hex_format` frontmatter variable. + +This string is rendered as a Tera template with the following context variables: + +- `r`, `g`, `b`, `a`: The red, green, blue, and alpha channels of the color as lowercase 2-digit hexadecimal strings. +- `R`, `G`, `B`, `A`: As above, but uppercase. +- `z`: The same as `a` if the color is not fully opaque, otherwise an empty string. +- `Z`: As above, but uppercase. + +The default value of `hex_format` is `{{r}}{{g}}{{b}}{{z}}`. + +Example: + +``` +--- +whiskers: + version: "2.0.0" + hex_format: "0x{{B}}{{G}}{{R}}{{A}}" +--- +{{red.hex}} +``` + +Running `whiskers example.tera -f mocha` produces the following output: `0xA88BF3FF` + ### Frontmatter Variables You can also include additional context variables in the templating process by @@ -237,8 +263,8 @@ As a simple example, given the following template (`example.tera`): ```yaml --- -app: 'Pepperjack' -author: 'winston' +app: "Pepperjack" +author: "winston" --- # Catppuccin for {{app}} # by {{author}} @@ -312,7 +338,7 @@ flag. This flag takes a JSON string like the following: "mocha": { "base": "000000", "mantle": "010101", - "crust": "020202", + "crust": "020202" } } ``` @@ -405,7 +431,7 @@ for each combination of the matrix iterables. ## Check Mode -You can use Whiskers as a linter with *check mode*. To do so, set the `--check` +You can use Whiskers as a linter with _check mode_. To do so, set the `--check` option. Whiskers will render your template as per usual, but then instead of printing the result it will check it against the expected output and fail with exit code 1 if they differ. diff --git a/src/filters.rs b/src/filters.rs index 025220f..08ce5d7 100644 --- a/src/filters.rs +++ b/src/filters.rs @@ -23,7 +23,7 @@ pub fn mix( .as_f64() .ok_or_else(|| tera::Error::msg("blend amount must be a number"))?; - let result = Color::mix(&base, &blend, amount); + let result = Color::mix(&base, &blend, amount)?; Ok(tera::to_value(result)?) } @@ -35,16 +35,16 @@ pub fn modify( let color: Color = tera::from_value(value.clone())?; if let Some(hue) = args.get("hue") { let hue = tera::from_value(hue.clone())?; - Ok(tera::to_value(color.mod_hue(hue))?) + Ok(tera::to_value(color.mod_hue(hue)?)?) } else if let Some(saturation) = args.get("saturation") { let saturation = tera::from_value(saturation.clone())?; - Ok(tera::to_value(color.mod_saturation(saturation))?) + Ok(tera::to_value(color.mod_saturation(saturation)?)?) } else if let Some(lightness) = args.get("lightness") { let lightness = tera::from_value(lightness.clone())?; - Ok(tera::to_value(color.mod_lightness(lightness))?) + Ok(tera::to_value(color.mod_lightness(lightness)?)?) } else if let Some(opacity) = args.get("opacity") { let opacity = tera::from_value(opacity.clone())?; - Ok(tera::to_value(color.mod_opacity(opacity))?) + Ok(tera::to_value(color.mod_opacity(opacity)?)?) } else { Ok(value.clone()) } @@ -57,16 +57,16 @@ pub fn add( let color: Color = tera::from_value(value.clone())?; if let Some(hue) = args.get("hue") { let hue = tera::from_value(hue.clone())?; - Ok(tera::to_value(color.add_hue(hue))?) + Ok(tera::to_value(color.add_hue(hue)?)?) } else if let Some(saturation) = args.get("saturation") { let saturation = tera::from_value(saturation.clone())?; - Ok(tera::to_value(color.add_saturation(saturation))?) + Ok(tera::to_value(color.add_saturation(saturation)?)?) } else if let Some(lightness) = args.get("lightness") { let lightness = tera::from_value(lightness.clone())?; - Ok(tera::to_value(color.add_lightness(lightness))?) + Ok(tera::to_value(color.add_lightness(lightness)?)?) } else if let Some(opacity) = args.get("opacity") { let opacity = tera::from_value(opacity.clone())?; - Ok(tera::to_value(color.add_opacity(opacity))?) + Ok(tera::to_value(color.add_opacity(opacity)?)?) } else { Ok(value.clone()) } @@ -79,16 +79,16 @@ pub fn sub( let color: Color = tera::from_value(value.clone())?; if let Some(hue) = args.get("hue") { let hue = tera::from_value(hue.clone())?; - Ok(tera::to_value(color.sub_hue(hue))?) + Ok(tera::to_value(color.sub_hue(hue)?)?) } else if let Some(saturation) = args.get("saturation") { let saturation = tera::from_value(saturation.clone())?; - Ok(tera::to_value(color.sub_saturation(saturation))?) + Ok(tera::to_value(color.sub_saturation(saturation)?)?) } else if let Some(lightness) = args.get("lightness") { let lightness = tera::from_value(lightness.clone())?; - Ok(tera::to_value(color.sub_lightness(lightness))?) + Ok(tera::to_value(color.sub_lightness(lightness)?)?) } else if let Some(opacity) = args.get("opacity") { let opacity = tera::from_value(opacity.clone())?; - Ok(tera::to_value(color.sub_opacity(opacity))?) + Ok(tera::to_value(color.sub_opacity(opacity)?)?) } else { Ok(value.clone()) } diff --git a/src/main.rs b/src/main.rs index df6ef34..93457d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,19 +16,22 @@ use whiskers::{ context::merge_values, frontmatter, markdown, matrix::{self, Matrix}, - models, templating, + models::{self, HEX_FORMAT}, + templating, }; const FRONTMATTER_OPTIONS_SECTION: &str = "whiskers"; +fn default_hex_format() -> String { + "{{r}}{{g}}{{b}}{{z}}".to_string() +} + #[derive(Default, Debug, serde::Deserialize)] struct TemplateOptions { version: Option, matrix: Option, filename: Option, - hex_prefix: Option, - #[serde(default)] - capitalize_hex: bool, + hex_format: String, } impl TemplateOptions { @@ -42,6 +45,7 @@ impl TemplateOptions { version: Option, matrix: Option>, filename: Option, + hex_format: Option, hex_prefix: Option, #[serde(default)] capitalize_hex: bool, @@ -50,20 +54,47 @@ impl TemplateOptions { if let Some(opts) = frontmatter.get(FRONTMATTER_OPTIONS_SECTION) { let opts: RawTemplateOptions = tera::from_value(opts.clone()) .context("Frontmatter `whiskers` section is invalid")?; + let matrix = opts .matrix .map(|m| matrix::from_values(m, only_flavor)) .transpose() .context("Frontmatter matrix is invalid")?; + + // if there's no hex_format but there is hex_prefix and/or capitalize_hex, + // we can construct a hex_format from those. + let hex_format = if let Some(hex_format) = opts.hex_format { + hex_format + } else { + // throw a deprecation warning for hex_prefix and capitalize_hex + if opts.hex_prefix.is_some() { + eprintln!("Warning: `hex_prefix` is deprecated and will be removed in a future version. Use `hex_format` instead."); + } + + if opts.capitalize_hex { + eprintln!("Warning: `capitalize_hex` is deprecated and will be removed in a future version. Use `hex_format` instead."); + } + + let prefix = opts.hex_prefix.unwrap_or_default(); + let components = default_hex_format(); + if opts.capitalize_hex { + format!("{prefix}{}", components.to_uppercase()) + } else { + format!("{prefix}{components}") + } + }; + Ok(Self { version: opts.version, matrix, filename: opts.filename, - hex_prefix: opts.hex_prefix, - capitalize_hex: opts.capitalize_hex, + hex_format, }) } else { - Ok(Self::default()) + Ok(Self { + hex_format: default_hex_format(), + ..Default::default() + }) } } } @@ -126,13 +157,13 @@ fn main() -> anyhow::Result<()> { ctx.insert(key, &value); } + HEX_FORMAT + .set(template_opts.hex_format) + .expect("can always set HEX_FORMAT"); + // build the palette and add it to the templating context - let palette = models::build_palette( - template_opts.capitalize_hex, - template_opts.hex_prefix.as_deref(), - args.color_overrides.as_ref(), - ) - .context("Palette context cannot be built")?; + let palette = models::build_palette(args.color_overrides.as_ref()) + .context("Palette context cannot be built")?; ctx.insert("flavors", &palette.flavors); if let Some(flavor) = args.flavor { diff --git a/src/models.rs b/src/models.rs index b084de1..cdaa791 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,5 +1,9 @@ +use std::sync::OnceLock; + use css_colors::Color as _; use indexmap::IndexMap; +use serde_json::json; +use tera::Tera; use crate::cli::ColorOverrides; @@ -49,39 +53,63 @@ pub struct HSL { #[derive(Debug, thiserror::Error)] pub enum Error { + #[error("Hex formatting failed: {0}")] + HexFormat(#[from] tera::Error), #[error("Failed to parse hex color: {0}")] ParseHex(#[from] std::num::ParseIntError), } -/// attempt to canonicalize a hex string, optionally capitalizing it and adding a prefix. -fn format_hex(hex: &str, capitalize_hex_strings: bool, hex_prefix: Option<&str>) -> String { - let hex = hex.trim_start_matches('#'); - let hex = if capitalize_hex_strings { - hex.to_uppercase() - } else { - hex.to_string() +// we have many functions that need to know how to format hex colors. +// they can't know this at build time, as the format may be provided by the template. +// we have little to no available state in many of these functions to store this information. +// these possible solutions were evaluated: +// 1. pass the format string to every function that needs it. this is cumbersome and error-prone. +// 2. store the format string in the `Colour` struct, thus duplicating it for every color. this is wasteful. +// 3. store it in a global static, and initialize it when the template frontmatter is read. +// we opted for the third option, with a convenience macro for accessing it. +pub static HEX_FORMAT: OnceLock = OnceLock::new(); +macro_rules! format_hex { + ($r:expr, $g:expr, $b: expr, $a: expr) => { + format_hex( + $r, + $g, + $b, + $a, + &*HEX_FORMAT.get().expect("HEX_FORMAT was never set"), + ) }; - if let Some(prefix) = hex_prefix { - format!("{prefix}{hex}") - } else { - hex - } } -fn color_from_hex( - hex: &str, - blueprint: &catppuccin::Color, - capitalize_hex_strings: bool, - hex_prefix: Option<&str>, -) -> Result { +/// attempt to canonicalize a hex string, using the provided format string. +fn format_hex(r: u8, g: u8, b: u8, a: u8, hex_format: &str) -> tera::Result { + Tera::one_off( + hex_format, + &tera::Context::from_serialize(json!({ + "r": format!("{r:02x}"), + "g": format!("{g:02x}"), + "b": format!("{b:02x}"), + "a": format!("{a:02x}"), + "z": if a == 0xFF { String::new() } else { format!("{a:02x}") }, + "R": format!("{r:02X}"), + "G": format!("{g:02X}"), + "B": format!("{b:02X}"), + "A": format!("{a:02X}"), + "Z": if a == 0xFF { String::new() } else { format!("{a:02X}") }, + })) + .expect("hardcoded context is always valid"), + true, + ) +} + +fn color_from_hex_override(hex: &str, blueprint: &catppuccin::Color) -> Result { let i = u32::from_str_radix(hex, 16)?; let rgb = RGB { - r: ((i >> 16) & 0xff) as u8, - g: ((i >> 8) & 0xff) as u8, - b: (i & 0xff) as u8, + r: ((i >> 16) & 0xFF) as u8, + g: ((i >> 8) & 0xFF) as u8, + b: (i & 0xFF) as u8, }; let hsl = css_colors::rgb(rgb.r, rgb.g, rgb.b).to_hsl(); - let hex = format_hex(hex, capitalize_hex_strings, hex_prefix); + let hex = format_hex!(rgb.r, rgb.g, rgb.b, 0xFF)?; Ok(Color { name: blueprint.name.to_string(), identifier: blueprint.name.identifier().to_string(), @@ -94,17 +122,13 @@ fn color_from_hex( s: hsl.s.as_f32(), l: hsl.l.as_f32(), }, - opacity: 255, + opacity: 0xFF, }) } -fn color_from_catppuccin( - color: &catppuccin::Color, - capitalize_hex_strings: bool, - hex_prefix: Option<&str>, -) -> Color { - let hex = format_hex(&color.hex.to_string(), capitalize_hex_strings, hex_prefix); - Color { +fn color_from_catppuccin(color: &catppuccin::Color) -> tera::Result { + let hex = format_hex!(color.rgb.r, color.rgb.g, color.rgb.b, 0xFF)?; + Ok(Color { name: color.name.to_string(), identifier: color.name.identifier().to_string(), order: color.order, @@ -121,15 +145,11 @@ fn color_from_catppuccin( l: color.hsl.l as f32, }, opacity: 255, - } + }) } /// Build a [`Palette`] from [`catppuccin::PALETTE`], optionally applying color overrides. -pub fn build_palette( - capitalize_hex_strings: bool, - hex_prefix: Option<&str>, - color_overrides: Option<&ColorOverrides>, -) -> Result { +pub fn build_palette(color_overrides: Option<&ColorOverrides>) -> Result { // make a `Color` from a `catppuccin::Color`, taking into account `color_overrides`. // overrides apply in this order: // 1. base color @@ -145,17 +165,17 @@ pub fn build_palette( catppuccin::FlavorName::Mocha => &co.mocha, }) .and_then(|o| o.get(color.name.identifier()).cloned()) - .map(|s| color_from_hex(&s, color, capitalize_hex_strings, hex_prefix)) + .map(|s| color_from_hex_override(&s, color)) .transpose()?; let all_override = color_overrides .and_then(|co| co.all.get(color.name.identifier()).cloned()) - .map(|s| color_from_hex(&s, color, capitalize_hex_strings, hex_prefix)) + .map(|s| color_from_hex_override(&s, color)) .transpose()?; - Ok(flavor_override.or(all_override).unwrap_or_else(|| { - color_from_catppuccin(color, capitalize_hex_strings, hex_prefix) - })) + let base_color = color_from_catppuccin(color)?; + + Ok(flavor_override.or(all_override).unwrap_or(base_color)) }; let mut flavors = IndexMap::new(); @@ -215,16 +235,12 @@ impl<'a> IntoIterator for &'a Flavor { } } -fn rgb_to_hex(rgb: &RGB, opacity: u8) -> String { - if opacity < 255 { - format!("{:02x}{:02x}{:02x}{:02x}", rgb.r, rgb.g, rgb.b, opacity) - } else { - format!("{:02x}{:02x}{:02x}", rgb.r, rgb.g, rgb.b) - } +fn rgb_to_hex(rgb: &RGB, opacity: u8) -> tera::Result { + format_hex!(rgb.r, rgb.g, rgb.b, opacity) } impl Color { - fn from_hsla(hsla: css_colors::HSLA, blueprint: &Self) -> Self { + fn from_hsla(hsla: css_colors::HSLA, blueprint: &Self) -> tera::Result { let rgb = hsla.to_rgb(); let rgb = RGB { r: rgb.r.as_u8(), @@ -237,19 +253,19 @@ impl Color { l: hsla.l.as_f32(), }; let opacity = hsla.a.as_u8(); - Self { + Ok(Self { name: blueprint.name.clone(), identifier: blueprint.identifier.clone(), order: blueprint.order, accent: blueprint.accent, - hex: rgb_to_hex(&rgb, opacity), + hex: rgb_to_hex(&rgb, opacity)?, rgb, hsl, opacity, - } + }) } - fn from_rgba(rgba: css_colors::RGBA, blueprint: &Self) -> Self { + fn from_rgba(rgba: css_colors::RGBA, blueprint: &Self) -> tera::Result { let hsl = rgba.to_hsl(); let rgb = RGB { r: rgba.r.as_u8(), @@ -262,20 +278,19 @@ impl Color { l: hsl.l.as_f32(), }; let opacity = rgba.a.as_u8(); - Self { + Ok(Self { name: blueprint.name.clone(), identifier: blueprint.identifier.clone(), order: blueprint.order, accent: blueprint.accent, - hex: rgb_to_hex(&rgb, opacity), + hex: rgb_to_hex(&rgb, opacity)?, rgb, hsl, opacity, - } + }) } - #[must_use] - pub fn mix(base: &Self, blend: &Self, amount: f64) -> Self { + pub fn mix(base: &Self, blend: &Self, amount: f64) -> tera::Result { let amount = (amount * 100.0).clamp(0.0, 100.0).round() as u8; let blueprint = base; let base: css_colors::RGBA = base.into(); @@ -285,99 +300,87 @@ impl Color { Self::from_rgba(result, blueprint) } - #[must_use] - pub fn mod_hue(&self, hue: i32) -> Self { + pub fn mod_hue(&self, hue: i32) -> tera::Result { let mut hsl: css_colors::HSL = self.into(); hsl.h = css_colors::deg(hue); Self::from_hsla(hsl.to_hsla(), self) } - #[must_use] - pub fn add_hue(&self, hue: i32) -> Self { + pub fn add_hue(&self, hue: i32) -> tera::Result { let hsl: css_colors::HSL = self.into(); let hsl = hsl.spin(css_colors::deg(hue)); Self::from_hsla(hsl.to_hsla(), self) } - #[must_use] - pub fn sub_hue(&self, hue: i32) -> Self { + pub fn sub_hue(&self, hue: i32) -> tera::Result { let hsl: css_colors::HSL = self.into(); let hsl = hsl.spin(-css_colors::deg(hue)); Self::from_hsla(hsl.to_hsla(), self) } - #[must_use] - pub fn mod_saturation(&self, saturation: u8) -> Self { + pub fn mod_saturation(&self, saturation: u8) -> tera::Result { let mut hsl: css_colors::HSL = self.into(); hsl.s = css_colors::percent(saturation); Self::from_hsla(hsl.to_hsla(), self) } - #[must_use] - pub fn add_saturation(&self, saturation: u8) -> Self { + pub fn add_saturation(&self, saturation: u8) -> tera::Result { let hsl: css_colors::HSL = self.into(); let hsl = hsl.saturate(css_colors::percent(saturation)); Self::from_hsla(hsl.to_hsla(), self) } - #[must_use] - pub fn sub_saturation(&self, saturation: u8) -> Self { + pub fn sub_saturation(&self, saturation: u8) -> tera::Result { let hsl: css_colors::HSL = self.into(); let hsl = hsl.desaturate(css_colors::percent(saturation)); Self::from_hsla(hsl.to_hsla(), self) } - #[must_use] - pub fn mod_lightness(&self, lightness: u8) -> Self { + pub fn mod_lightness(&self, lightness: u8) -> tera::Result { let mut hsl: css_colors::HSL = self.into(); hsl.l = css_colors::percent(lightness); Self::from_hsla(hsl.to_hsla(), self) } - #[must_use] - pub fn add_lightness(&self, lightness: u8) -> Self { + pub fn add_lightness(&self, lightness: u8) -> tera::Result { let hsl: css_colors::HSL = self.into(); let hsl = hsl.lighten(css_colors::percent(lightness)); Self::from_hsla(hsl.to_hsla(), self) } - #[must_use] - pub fn sub_lightness(&self, lightness: u8) -> Self { + pub fn sub_lightness(&self, lightness: u8) -> tera::Result { let hsl: css_colors::HSL = self.into(); let hsl = hsl.darken(css_colors::percent(lightness)); Self::from_hsla(hsl.to_hsla(), self) } - #[must_use] - pub fn mod_opacity(&self, opacity: f32) -> Self { + pub fn mod_opacity(&self, opacity: f32) -> tera::Result { let opacity = (opacity * 255.0).round() as u8; - Self { + Ok(Self { opacity, - hex: rgb_to_hex(&self.rgb, opacity), + hex: rgb_to_hex(&self.rgb, opacity)?, ..self.clone() - } + }) } - #[must_use] - pub fn add_opacity(&self, opacity: f32) -> Self { + pub fn add_opacity(&self, opacity: f32) -> tera::Result { let opacity = (opacity * 255.0).round() as u8; let opacity = self.opacity.saturating_add(opacity); - Self { + Ok(Self { opacity, - hex: rgb_to_hex(&self.rgb, opacity), + hex: rgb_to_hex(&self.rgb, opacity)?, ..self.clone() - } + }) } - #[must_use] - pub fn sub_opacity(&self, opacity: f32) -> Self { + pub fn sub_opacity(&self, opacity: f32) -> tera::Result { let opacity = (opacity * 255.0).round() as u8; let opacity = self.opacity.saturating_sub(opacity); - Self { + Ok(Self { opacity, - hex: rgb_to_hex(&self.rgb, opacity), + hex: rgb_to_hex(&self.rgb, opacity)?, ..self.clone() - } + }) } } diff --git a/tests/cli.rs b/tests/cli.rs index 26570b6..930c50d 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -88,6 +88,28 @@ mod happy_path { .success() .stdout(predicate::str::contains("it worked!")); } + + /// Test that the default hex format is rrggbb and full alpha is hidden + #[test] + fn test_default_hex_format() { + let mut cmd = Command::cargo_bin("whiskers").expect("binary exists"); + let assert = cmd.args(["tests/fixtures/hexformat/default.tera"]).assert(); + assert + .success() + .stdout(include_str!("fixtures/hexformat/default.txt")); + } + + /// Test that the CLI can render a template with a custom hex format + #[test] + fn test_custom_hex_format() { + let mut cmd = Command::cargo_bin("whiskers").expect("binary exists"); + let assert = cmd + .args(["tests/fixtures/hexformat/custom.tera", "-f", "latte"]) + .assert(); + assert + .success() + .stdout(include_str!("fixtures/hexformat/custom.txt")); + } } #[cfg(test)] diff --git a/tests/fixtures/hexformat/custom.tera b/tests/fixtures/hexformat/custom.tera new file mode 100644 index 0000000..9b732db --- /dev/null +++ b/tests/fixtures/hexformat/custom.tera @@ -0,0 +1,10 @@ +--- +# test a custom hex format +whiskers: + version: "2" + hex_format: "0x{{B}}{{G}}{{R}}{{A}}" +--- +{%- set translucent_red = flavors.latte.colors.red | mod(opacity=0.5) -%} +{{flavors.latte.colors.red.hex}} == 0x390FD2FF +{{flavors.macchiato.colors.sky.hex}} == 0xE3D791FF +{{translucent_red.hex}} == 0x390FD280 diff --git a/tests/fixtures/hexformat/custom.txt b/tests/fixtures/hexformat/custom.txt new file mode 100644 index 0000000..29614e9 --- /dev/null +++ b/tests/fixtures/hexformat/custom.txt @@ -0,0 +1,3 @@ +0x390FD2FF == 0x390FD2FF +0xE3D791FF == 0xE3D791FF +0x390FD280 == 0x390FD280 diff --git a/tests/fixtures/hexformat/default.tera b/tests/fixtures/hexformat/default.tera new file mode 100644 index 0000000..fbbb300 --- /dev/null +++ b/tests/fixtures/hexformat/default.tera @@ -0,0 +1,9 @@ +--- +# test the default hex format +whiskers: + version: "2" +--- +{%- set translucent_red = flavors.latte.colors.red | mod(opacity=0.5) -%} +{{flavors.latte.colors.red.hex}} == d20f39 +{{flavors.macchiato.colors.sky.hex}} == 91d7e3 +{{translucent_red.hex}} == d20f3980 diff --git a/tests/fixtures/hexformat/default.txt b/tests/fixtures/hexformat/default.txt new file mode 100644 index 0000000..dcfa168 --- /dev/null +++ b/tests/fixtures/hexformat/default.txt @@ -0,0 +1,3 @@ +d20f39 == d20f39 +91d7e3 == 91d7e3 +d20f3980 == d20f3980