From 463ca2fa29c4cff381e4c7a391d451c48b8222f9 Mon Sep 17 00:00:00 2001 From: Wez Furlong Date: Sun, 10 Jul 2022 23:41:18 -0700 Subject: [PATCH] add wezterm.color module for working with colors wezterm.color.parse() returns a color object that can be assigned in the wezterm color config, and that can be used to adjust hue, saturation and lightness, as well as calculate harmonizing colors (complements, triads, squares) from the RGB/HSL color wheel. --- .gitignore | 1 + Cargo.lock | 18 +++ ci/generate-docs.py | 4 + color-types/src/lib.rs | 123 +++++++++++++++++++ docs/changelog.md | 1 + docs/config/lua/wezterm.color/index.markdown | 6 + docs/config/lua/wezterm.color/parse.md | 117 ++++++++++++++++++ env-bootstrap/Cargo.toml | 1 + env-bootstrap/src/lib.rs | 1 + lua-api-crates/color-funcs/Cargo.toml | 12 ++ lua-api-crates/color-funcs/src/lib.rs | 89 ++++++++++++++ 11 files changed, 373 insertions(+) create mode 100644 docs/config/lua/wezterm.color/index.markdown create mode 100644 docs/config/lua/wezterm.color/parse.md create mode 100644 lua-api-crates/color-funcs/Cargo.toml create mode 100644 lua-api-crates/color-funcs/src/lib.rs diff --git a/.gitignore b/.gitignore index 694a9c496..29ad49c8b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ /docs/config/lua/mux-events/index.md /docs/config/lua/mux-window/index.md /docs/config/lua/pane/index.md +/docs/config/lua/wezterm.color/index.md /docs/config/lua/wezterm.gui/index.md /docs/config/lua/wezterm.mux/index.md /docs/config/lua/wezterm/index.md diff --git a/Cargo.lock b/Cargo.lock index f88264f25..926e4751f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -607,6 +607,16 @@ dependencies = [ "zstd", ] +[[package]] +name = "color-funcs" +version = "0.1.0" +dependencies = [ + "anyhow", + "config", + "csscolorparser", + "luahelper", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -851,6 +861,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841475b11553e394b89eebb5cdfe8ca0b960005ad464bca369ade02a62da053e" dependencies = [ + "lab", "phf 0.10.1", ] @@ -1092,6 +1103,7 @@ dependencies = [ "battery", "chrono", "cocoa", + "color-funcs", "config", "dirs-next", "env_logger", @@ -1898,6 +1910,12 @@ dependencies = [ "log", ] +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.4.0" diff --git a/ci/generate-docs.py b/ci/generate-docs.py index 0c6750111..aa38b1f33 100644 --- a/ci/generate-docs.py +++ b/ci/generate-docs.py @@ -316,6 +316,10 @@ TOC = [ "module: wezterm", "config/lua/wezterm", ), + Gen( + "module: wezterm.color", + "config/lua/wezterm.color", + ), Gen( "module: wezterm.gui", "config/lua/wezterm.gui", diff --git a/color-types/src/lib.rs b/color-types/src/lib.rs index cfafe63f8..42f62f062 100644 --- a/color-types/src/lib.rs +++ b/color-types/src/lib.rs @@ -333,6 +333,129 @@ impl SrgbaTuple { (self.2 * 65535.) as u16 ) } + + pub fn to_hsla(self) -> (f64, f64, f64, f64) { + csscolorparser::Color::from_rgba(self.0.into(), self.1.into(), self.2.into(), self.3.into()) + .to_hsla() + } + + pub fn from_hsla(h: f64, s: f64, l: f64, a: f64) -> Self { + let (r, g, b, a) = csscolorparser::Color::from_hsla(h, s, l, a).rgba(); + Self(r as f32, g as f32, b as f32, a as f32) + } + + /// Scale the color towards the maximum saturation by factor, a value ranging from 0.0 to 1.0. + pub fn saturate(&self, factor: f64) -> Self { + let (h, s, l, a) = self.to_hsla(); + let s = apply_scale(s, factor); + Self::from_hsla(h, s, l, a) + } + + /// Increase the saturation by amount, a value ranging from 0.0 to 1.0. + pub fn saturate_fixed(&self, amount: f64) -> Self { + let (h, s, l, a) = self.to_hsla(); + let s = apply_fixed(s, amount); + Self::from_hsla(h, s, l, a) + } + + /// Scale the color towards the maximum lightness by factor, a value ranging from 0.0 to 1.0 + pub fn lighten(&self, factor: f64) -> Self { + let (h, s, l, a) = self.to_hsla(); + let l = apply_scale(l, factor); + Self::from_hsla(h, s, l, a) + } + + /// Lighten the color by amount, a value ranging from 0.0 to 1.0 + pub fn lighten_fixed(&self, amount: f64) -> Self { + let (h, s, l, a) = self.to_hsla(); + let l = apply_fixed(l, amount); + Self::from_hsla(h, s, l, a) + } + + /// Find the complementary color: the one opposite it on the color wheel + pub fn adjust_hue_fixed(&self, amount: f64) -> Self { + let (h, s, l, a) = self.to_hsla(); + let h = normalize_angle(h + amount); + Self::from_hsla(h, s, l, a) + } + + pub fn complement(&self) -> Self { + self.adjust_hue_fixed(180.) + } + + pub fn triad(&self) -> (Self, Self) { + (self.adjust_hue_fixed(120.), self.adjust_hue_fixed(-120.)) + } + + pub fn square(&self) -> (Self, Self, Self) { + ( + self.adjust_hue_fixed(90.), + self.adjust_hue_fixed(270.), + self.adjust_hue_fixed(180.), + ) + } +} + +/// From "Paint Inspired Color Compositing" by Gosset and Chen +/// has a python +/// implementation +/// has a copy of the paper +/// itself at +pub fn ryb_to_rgb(r: f32, y: f32, b: f32) -> (f32, f32, f32) { + fn cubic(t: f32, a: f32, b: f32) -> f32 { + let weight = t * t * (3. - 2. * t); + a + weight * (b - a) + } + + let red = { + let x0 = cubic(b, 1.0, 0.163); + let x1 = cubic(b, 1.0, 0.0); + let x2 = cubic(b, 1.0, 0.5); + let x3 = cubic(b, 1.0, 0.2); + let y0 = cubic(y, x0, x1); + let y1 = cubic(y, x2, x3); + cubic(r, y0, y1) + }; + + let green = { + let x0 = cubic(b, 1.0, 0.373); + let x1 = cubic(b, 1.0, 0.66); + let x2 = cubic(b, 0.0, 0.0); + let x3 = cubic(b, 0.5, 0.094); + let y0 = cubic(y, x0, x1); + let y1 = cubic(y, x2, x3); + cubic(r, y0, y1) + }; + + let blue = { + let x0 = cubic(b, 1.0, 0.6); + let x1 = cubic(b, 0.0, 0.2); + let x2 = cubic(b, 0.0, 0.5); + let x3 = cubic(b, 0.0, 0.0); + let y0 = cubic(y, x0, x1); + let y1 = cubic(y, x2, x3); + cubic(r, y0, y1) + }; + + (red, green, blue) +} + +fn normalize_angle(t: f64) -> f64 { + let mut t = t % 360.0; + if t < 0.0 { + t += 360.0; + } + t +} + +fn apply_scale(current: f64, factor: f64) -> f64 { + let difference = if factor >= 0. { 1.0 - current } else { current }; + let delta = difference.max(0.) * factor; + (current + delta).max(0.) +} + +fn apply_fixed(current: f64, amount: f64) -> f64 { + (current + amount).max(0.) } impl Hash for SrgbaTuple { diff --git a/docs/changelog.md b/docs/changelog.md index 210c19c25..f62e2197f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -18,6 +18,7 @@ As features stabilize some brief notes about them will accumulate here. * [window:set_position](config/lua/window/set_position.md) method for controlling window position. * [window:maximize](config/lua/window/maximize.md) and [window:restore](config/lua/window/restore.md) methods for controlling window maximization state. * [window:get_selection_escapes_for_pane](config/lua/window/get_selection_escapes_for_pane.md) method for getting the current selection including escape sequences. [#2223](https://github.com/wez/wezterm/issues/2223) +* New [wezterm.color](config/lua/wezterm.color/index.md) module for working with colors. * New [wezterm.gui](config/lua/wezterm.gui/index.md) module and [mux_window:gui_window](config/lua/mux-window/gui_window.md) method. * New [wezterm.gui.screens()](config/lua/wezterm.gui/screens.md) function for getting information about the available screens/monitors/displays * You may now use [wezterm.format](config/lua/wezterm/format.md) (or otherwise use strings with escape sequences) in the labels of the [Launcher Menu](config/launch.md#the-launcher-menu). diff --git a/docs/config/lua/wezterm.color/index.markdown b/docs/config/lua/wezterm.color/index.markdown new file mode 100644 index 000000000..089555642 --- /dev/null +++ b/docs/config/lua/wezterm.color/index.markdown @@ -0,0 +1,6 @@ +*Since: nightly builds only* + +The `wezterm.color` module exposes functions that work with colors. + +## Available functions, constants + diff --git a/docs/config/lua/wezterm.color/parse.md b/docs/config/lua/wezterm.color/parse.md new file mode 100644 index 000000000..45d4b424d --- /dev/null +++ b/docs/config/lua/wezterm.color/parse.md @@ -0,0 +1,117 @@ +# wezterm.color.parse(string) + +*Since: nightly builds only* + +Parses the passed color and returns an `RgbaColor` object. +`RgbaColor` objects evaluate as strings but have a number of methods +that allow transforming colors. + +``` +> wezterm.color.parse("black") +#000000 +``` + +This example picks a foreground color, computes its complement +and darkens it to use it as a background color: + +```lua +local wezterm = require 'wezterm' + +local fg = wezterm.color.parse("yellow") +local bg = fg:complement():darken(0.2) + +return { + colors = { + foreground = fg, + background = bg, + } +} +``` + +## `color:complement()` + +*Since: nightly builds only* + +Returns the complement of the color. The complement is computed +by converting to HSL, rotating by 180 degrees and converting back +to RGBA. + +## `color:triad()` + +*Since: nightly builds only* + +Returns the other two colors that form a triad. The other colors +are at +/- 120 degrees in the HSL color wheel. + +```lua +local a, b = wezterm:color.parse("yellow"):triad() +``` + +## `color:square()` + +*Since: nightly builds only* + +Returns the other three colors that form a square. The other colors +are 90 degrees apart on the HSL color wheel. + +```lua +local a, b, = wezterm:color.parse("yellow"):square() +``` + +## `color:saturate(factor)` + +*Since: nightly builds only* + +Scales the color towards the maximum saturation by the provided factor, which +should be in the range `0.0` through `1.0`. + +## `color:saturate_fixed(amount)` + +*Since: nightly builds only* + +Increase the saturation by amount, a value ranging from `0.0` to `1.0`. + +## `color:desaturate(factor)` + +*Since: nightly builds only* + +Scales the color towards the minimum saturation by the provided factor, which +should be in the range `0.0` through `1.0`. + +## `color:desaturate_fixed(amount)` + +*Since: nightly builds only* + +Decrease the saturation by amount, a value ranging from `0.0` to `1.0`. + +## `color:lighten(factor)` + +*Since: nightly builds only* + +Scales the color towards the maximum lightness by the provided factor, which +should be in the range `0.0` through `1.0`. + +## `color:lighten_fixed(amount)` + +*Since: nightly builds only* + +Increase the lightness by amount, a value ranging from `0.0` to `1.0`. + +## `color:darken(factor)` + +*Since: nightly builds only* + +Scales the color towards the minimum lightness by the provided factor, which +should be in the range `0.0` through `1.0`. + +## `color:darken_fixed(amount)` + +*Since: nightly builds only* + +Decrease the lightness by amount, a value ranging from `0.0` to `1.0`. + +## `color:adjust_hue_fixed(degrees)` + +*Since: nightly builds only* + +Adjust the hue angle by the specified number of degrees. diff --git a/env-bootstrap/Cargo.toml b/env-bootstrap/Cargo.toml index ae5c17a62..3a125de1c 100644 --- a/env-bootstrap/Cargo.toml +++ b/env-bootstrap/Cargo.toml @@ -17,6 +17,7 @@ log = "0.4" env_logger = "0.9" termwiz = { path = "../termwiz" } battery = { path = "../lua-api-crates/battery" } +color-funcs = { path = "../lua-api-crates/color-funcs" } termwiz-funcs = { path = "../lua-api-crates/termwiz-funcs" } logging = { path = "../lua-api-crates/logging" } mux-lua = { path = "../lua-api-crates/mux" } diff --git a/env-bootstrap/src/lib.rs b/env-bootstrap/src/lib.rs index 0805ec53b..d32bcb2f0 100644 --- a/env-bootstrap/src/lib.rs +++ b/env-bootstrap/src/lib.rs @@ -161,6 +161,7 @@ fn register_panic_hook() { fn register_lua_modules() { for func in [ battery::register, + color_funcs::register, termwiz_funcs::register, logging::register, mux_lua::register, diff --git a/lua-api-crates/color-funcs/Cargo.toml b/lua-api-crates/color-funcs/Cargo.toml new file mode 100644 index 000000000..06a02307e --- /dev/null +++ b/lua-api-crates/color-funcs/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "color-funcs" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0" +config = { path = "../../config" } +csscolorparser = {version="0.6", features=["lab"]} +luahelper = { path = "../../luahelper" } diff --git a/lua-api-crates/color-funcs/src/lib.rs b/lua-api-crates/color-funcs/src/lib.rs new file mode 100644 index 000000000..8bf2a1b97 --- /dev/null +++ b/lua-api-crates/color-funcs/src/lib.rs @@ -0,0 +1,89 @@ +use config::lua::get_or_create_sub_module; +use config::lua::mlua::{self, Lua, MetaMethod, UserData, UserDataMethods}; +use config::RgbaColor; + +#[derive(Clone)] +struct ColorWrap(RgbaColor); + +impl ColorWrap { + pub fn complement(&self) -> Self { + Self(self.0.complement().into()) + } + pub fn triad(&self) -> (Self, Self) { + let (a, b) = self.0.triad(); + (Self(a.into()), Self(b.into())) + } + pub fn square(&self) -> (Self, Self, Self) { + let (a, b, c) = self.0.square(); + (Self(a.into()), Self(b.into()), Self(c.into())) + } + pub fn saturate(&self, factor: f64) -> Self { + Self(self.0.saturate(factor).into()) + } + pub fn saturate_fixed(&self, amount: f64) -> Self { + Self(self.0.saturate_fixed(amount).into()) + } + pub fn lighten(&self, factor: f64) -> Self { + Self(self.0.lighten(factor).into()) + } + pub fn lighten_fixed(&self, amount: f64) -> Self { + Self(self.0.lighten_fixed(amount).into()) + } + pub fn adjust_hue_fixed(&self, amount: f64) -> Self { + Self(self.0.adjust_hue_fixed(amount).into()) + } +} + +impl UserData for ColorWrap { + fn add_methods<'lua, M: UserDataMethods<'lua, Self>>(methods: &mut M) { + methods.add_meta_method(MetaMethod::ToString, |_, this, _: ()| { + let s: String = this.0.into(); + Ok(s) + }); + methods.add_meta_method(MetaMethod::Eq, |_, this, other: ColorWrap| { + Ok(this.0 == other.0) + }); + methods.add_method("complement", |_, this, _: ()| Ok(this.complement())); + methods.add_method("triad", |_, this, _: ()| Ok(this.triad())); + methods.add_method("square", |_, this, _: ()| Ok(this.square())); + methods.add_method("saturate", |_, this, factor: f64| Ok(this.saturate(factor))); + + methods.add_method("desaturate", |_, this, factor: f64| { + Ok(this.saturate(-factor)) + }); + + methods.add_method("saturate_fixed", |_, this, amount: f64| { + Ok(this.saturate_fixed(amount)) + }); + methods.add_method("desaturate_fixed", |_, this, amount: f64| { + Ok(this.saturate_fixed(-amount)) + }); + + methods.add_method("lighten", |_, this, factor: f64| Ok(this.lighten(factor))); + + methods.add_method("darken", |_, this, factor: f64| Ok(this.lighten(-factor))); + + methods.add_method("lighten_fixed", |_, this, amount: f64| { + Ok(this.lighten_fixed(amount)) + }); + methods.add_method("darken_fixed", |_, this, amount: f64| { + Ok(this.lighten_fixed(-amount)) + }); + + methods.add_method("adjust_hue_fixed", |_, this, amount: f64| { + Ok(this.adjust_hue_fixed(amount)) + }); + } +} + +pub fn register(lua: &Lua) -> anyhow::Result<()> { + let color = get_or_create_sub_module(lua, "color")?; + color.set("parse", lua.create_function(parse_color)?)?; + Ok(()) +} + +fn parse_color<'lua>(_: &'lua Lua, spec: String) -> mlua::Result { + let color = + RgbaColor::try_from(spec).map_err(|err| mlua::Error::external(format!("{err:#}")))?; + Ok(ColorWrap(color)) +}