|
| 1 | +-- Credit EdenEast |
| 2 | +-- https://github.com/EdenEast/nightfox.nvim/blob/12e0ca70e978f58318e7f0279bb7b243ababbd49/lua/nightfox/lib/color.lua |
| 3 | + |
| 4 | +---Round float to nearest int |
| 5 | +---@param x number Float |
| 6 | +---@return number |
| 7 | +local function round(x) |
| 8 | + return x >= 0 and math.floor(x + 0.5) or math.ceil(x - 0.5) |
| 9 | +end |
| 10 | + |
| 11 | +---Clamp value between the min and max values. |
| 12 | +---@param value number |
| 13 | +---@param min number |
| 14 | +---@param max number |
| 15 | +local function clamp(value, min, max) |
| 16 | + if value < min then |
| 17 | + return min |
| 18 | + elseif value > max then |
| 19 | + return max |
| 20 | + end |
| 21 | + return value |
| 22 | +end |
| 23 | + |
| 24 | +--#region Types ---------------------------------------------------------------- |
| 25 | + |
| 26 | +---RGBA color representation stored in float [0,1] |
| 27 | +---@class RGBA |
| 28 | +---@field red number [0,255] |
| 29 | +---@field green number [0,255] |
| 30 | +---@field blue number [0,255] |
| 31 | +---@field alpha number [0,1] |
| 32 | + |
| 33 | +---@class HSL |
| 34 | +---@field hue number Float [0,360) |
| 35 | +---@field saturation number Float [0,100] |
| 36 | +---@field lightness number Float [0,100] |
| 37 | + |
| 38 | +---@class HSV |
| 39 | +---@field hue number Float [0,360) |
| 40 | +---@field saturation number Float [0,100] |
| 41 | +---@field value number Float [0,100] |
| 42 | + |
| 43 | +--#endregion |
| 44 | + |
| 45 | +--#region Helpers -------------------------------------------------------------- |
| 46 | + |
| 47 | +local function calc_hue(r, g, b) |
| 48 | + local max = math.max(r, g, b) |
| 49 | + local min = math.min(r, g, b) |
| 50 | + local delta = max - min |
| 51 | + local h = 0 |
| 52 | + |
| 53 | + if max == min then |
| 54 | + h = 0 |
| 55 | + elseif max == r then |
| 56 | + h = 60 * ((g - b) / delta) |
| 57 | + elseif max == g then |
| 58 | + h = 60 * ((b - r) / delta + 2) |
| 59 | + elseif max == b then |
| 60 | + h = 60 * ((r - g) / delta + 4) |
| 61 | + end |
| 62 | + |
| 63 | + if h < 0 then |
| 64 | + h = h + 360 |
| 65 | + end |
| 66 | + |
| 67 | + return { hue = h, max = max, min = min, delta = delta } |
| 68 | +end |
| 69 | + |
| 70 | +--#endregion |
| 71 | + |
| 72 | +local Color = setmetatable({}, {}) |
| 73 | +Color.__index = Color |
| 74 | + |
| 75 | +function Color.__tostring(self) |
| 76 | + return self:to_css() |
| 77 | +end |
| 78 | + |
| 79 | +function Color.new(opts) |
| 80 | + if type(opts) == 'string' or type(opts) == 'number' then |
| 81 | + return Color.from_hex(opts) |
| 82 | + end |
| 83 | + if opts.red then |
| 84 | + return Color.from_rgba(opts.red, opts.green, opts.blue, opts.alpha) |
| 85 | + end |
| 86 | + if opts.value then |
| 87 | + return Color.from_hsv(opts.hue, opts.saturation, opts.value) |
| 88 | + end |
| 89 | + if opts.lightness then |
| 90 | + return Color.from_hsv(opts.hue, opts.saturation, opts.lightness) |
| 91 | + end |
| 92 | +end |
| 93 | + |
| 94 | +function Color.init(r, g, b, a) |
| 95 | + local self = setmetatable({}, Color) |
| 96 | + self.red = clamp(r, 0, 1) |
| 97 | + self.green = clamp(g, 0, 1) |
| 98 | + self.blue = clamp(b, 0, 1) |
| 99 | + self.alpha = clamp(a or 1, 0, 1) |
| 100 | + return self |
| 101 | +end |
| 102 | + |
| 103 | +--#region from_* --------------------------------------------------------------- |
| 104 | + |
| 105 | +---Create color from RGBA 0,255 |
| 106 | +---@param r number Integer [0,255] |
| 107 | +---@param g number Integer [0,255] |
| 108 | +---@param b number Integer [0,255] |
| 109 | +---@param a number Float [0,1] |
| 110 | +---@return Color |
| 111 | +function Color.from_rgba(r, g, b, a) |
| 112 | + return Color.init(r / 0xff, g / 0xff, b / 0xff, a or 1) |
| 113 | +end |
| 114 | + |
| 115 | +---Create a color from a hex number |
| 116 | +---@param c number|string Either a literal number or a css-style hex string ('#RRGGBB[AA]') |
| 117 | +---@return Color |
| 118 | +function Color.from_hex(c) |
| 119 | + local n = c |
| 120 | + if type(c) == 'string' then |
| 121 | + local s = c:lower():match('#?([a-f0-9]+)') |
| 122 | + n = tonumber(s, 16) |
| 123 | + if #s <= 6 then |
| 124 | + n = bit.lshift(n, 8) + 0xff |
| 125 | + end |
| 126 | + end |
| 127 | + |
| 128 | + return Color.init(bit.rshift(n, 24) / 0xff, bit.band(bit.rshift(n, 16), 0xff) / 0xff, bit.band(bit.rshift(n, 8), 0xff) / 0xff, bit.band(n, 0xff) / 0xff) |
| 129 | +end |
| 130 | + |
| 131 | +---Create a Color from HSV value |
| 132 | +---@param h number Hue. Float [0,360] |
| 133 | +---@param s number Saturation. Float [0,100] |
| 134 | +---@param v number Value. Float [0,100] |
| 135 | +---@param a number? (Optional) Alpha. Float [0,1] |
| 136 | +---@return Color |
| 137 | +function Color.from_hsv(h, s, v, a) |
| 138 | + h = h % 360 |
| 139 | + s = clamp(s, 0, 100) / 100 |
| 140 | + v = clamp(v, 0, 100) / 100 |
| 141 | + a = clamp(a or 1, 0, 1) |
| 142 | + |
| 143 | + local function f(n) |
| 144 | + local k = (n + h / 60) % 6 |
| 145 | + return v - v * s * math.max(math.min(k, 4 - k, 1), 0) |
| 146 | + end |
| 147 | + |
| 148 | + return Color.init(f(5), f(3), f(1), a) |
| 149 | +end |
| 150 | + |
| 151 | +---Create a Color from HSL value |
| 152 | +---@param h number Hue. Float [0,360] |
| 153 | +---@param s number Saturation. Float [0,100] |
| 154 | +---@param l number Lightness. Float [0,100] |
| 155 | +---@param a number? (Optional) Alpha. Float [0,1] |
| 156 | +---@return Color |
| 157 | +function Color.from_hsl(h, s, l, a) |
| 158 | + h = h % 360 |
| 159 | + s = clamp(s, 0, 100) / 100 |
| 160 | + l = clamp(l, 0, 100) / 100 |
| 161 | + a = clamp(a or 1, 0, 1) |
| 162 | + local _a = s * math.min(l, 1 - l) |
| 163 | + |
| 164 | + local function f(n) |
| 165 | + local k = (n + h / 30) % 12 |
| 166 | + return l - _a * math.max(math.min(k - 3, 9 - k, 1), -1) |
| 167 | + end |
| 168 | + |
| 169 | + return Color.init(f(0), f(8), f(4), a) |
| 170 | +end |
| 171 | + |
| 172 | +--#endregion |
| 173 | + |
| 174 | +--#region to_* ----------------------------------------------------------------- |
| 175 | + |
| 176 | +---Convert Color to RGBA |
| 177 | +---@return RGBA |
| 178 | +function Color:to_rgba() |
| 179 | + return { |
| 180 | + red = round(self.red * 0xff), |
| 181 | + green = round(self.green * 0xff), |
| 182 | + blue = round(self.blue * 0xff), |
| 183 | + alpha = self.alpha, |
| 184 | + } |
| 185 | +end |
| 186 | + |
| 187 | +---Convert Color to HSV |
| 188 | +---@return HSV |
| 189 | +function Color:to_hsv() |
| 190 | + local res = calc_hue(self.red, self.green, self.blue) |
| 191 | + local h, min, max = res.hue, res.min, res.max |
| 192 | + local s, v = 0, max |
| 193 | + |
| 194 | + if max ~= 0 then |
| 195 | + s = (max - min) / max |
| 196 | + end |
| 197 | + |
| 198 | + return { hue = h, saturation = s * 100, value = v * 100 } |
| 199 | +end |
| 200 | + |
| 201 | +---Convert the color to HSL. |
| 202 | +---@return HSL |
| 203 | +function Color:to_hsl() |
| 204 | + local res = calc_hue(self.red, self.green, self.blue) |
| 205 | + local h, min, max = res.hue, res.min, res.max |
| 206 | + local s, l = 0, (max + min) / 2 |
| 207 | + |
| 208 | + if max ~= 0 and min ~= 1 then |
| 209 | + s = (max - l) / math.min(l, 1 - l) |
| 210 | + end |
| 211 | + |
| 212 | + return { hue = h, saturation = s * 100, lightness = l * 100 } |
| 213 | +end |
| 214 | + |
| 215 | +---Convert the color to a hex number representation (`0xRRGGBB[AA]`). |
| 216 | +---@param with_alpha boolean Include the alpha component. |
| 217 | +---@return integer |
| 218 | +function Color:to_hex(with_alpha) |
| 219 | + local ls, bor, fl = bit.lshift, bit.bor, math.floor |
| 220 | + local n = bor(bor(ls(fl((self.red * 0xff) + 0.5), 16), ls(fl((self.green * 0xff) + 0.5), 8)), fl((self.blue * 0xff) + 0.5)) |
| 221 | + return with_alpha and bit.lshift(n, 8) + (self.alpha * 0xff) or n |
| 222 | +end |
| 223 | + |
| 224 | +---Convert the color to a css hex color (`#RRGGBB[AA]`). |
| 225 | +---@param with_alpha boolean Include the alpha component. |
| 226 | +---@return string |
| 227 | +function Color:to_css(with_alpha) |
| 228 | + local n = self:to_hex(with_alpha) |
| 229 | + local l = with_alpha and 8 or 6 |
| 230 | + return string.format('#%0' .. l .. 'x', n) |
| 231 | +end |
| 232 | + |
| 233 | +---Calculate the relative lumanance of the color |
| 234 | +---https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef |
| 235 | +---@return number |
| 236 | +function Color:lumanance() |
| 237 | + local r, g, b = self.red, self.green, self.blue |
| 238 | + r = (r > 0.04045) and ((r + 0.055) / 1.055) ^ 2.4 or (r / 12.92) |
| 239 | + g = (g > 0.04045) and ((g + 0.055) / 1.055) ^ 2.4 or (g / 12.92) |
| 240 | + b = (b > 0.04045) and ((b + 0.055) / 1.055) ^ 2.4 or (b / 12.92) |
| 241 | + |
| 242 | + return 0.2126 * r + 0.7152 * g + 0.0722 * b |
| 243 | +end |
| 244 | + |
| 245 | +--#endregion |
| 246 | + |
| 247 | +--#region Manipulate ----------------------------------------------------------- |
| 248 | + |
| 249 | +---Returns a new color that a linear blend between two colors |
| 250 | +---@param other Color |
| 251 | +---@param f number Float [0,1]. 0 being this and 1 being other |
| 252 | +---@return Color |
| 253 | +function Color:blend(other, f) |
| 254 | + return Color.init((other.red - self.red) * f + self.red, (other.green - self.green) * f + self.green, (other.blue - self.blue) * f + self.blue, self.alpha) |
| 255 | +end |
| 256 | + |
| 257 | +---Returns a new shaded color. |
| 258 | +---@param f number Amount. Float [-1,1]. -1 is black, 1 is white |
| 259 | +---@return Color |
| 260 | +function Color:shade(f) |
| 261 | + local t = f < 0 and 0 or 1.0 |
| 262 | + local p = f < 0 and f * -1.0 or f |
| 263 | + |
| 264 | + return Color.init((t - self.red) * p + self.red, (t - self.green) * p + self.green, (t - self.blue) * p + self.blue, self.alpha) |
| 265 | +end |
| 266 | + |
| 267 | +---Adds value of `v` to the `value` of the current color. This returns either |
| 268 | +---a brighter version if +v and darker if -v. |
| 269 | +---@param v number Value. Float [-100,100]. |
| 270 | +---@return Color |
| 271 | +function Color:brighten(v) |
| 272 | + local hsv = self:to_hsv() |
| 273 | + local value = clamp(hsv.value + v, 0, 100) |
| 274 | + return Color.from_hsv(hsv.hue, hsv.saturation, value) |
| 275 | +end |
| 276 | + |
| 277 | +---Adds value of `v` to the `lightness` of the current color. This returns |
| 278 | +---either a lighter version if +v and darker if -v. |
| 279 | +---@param v number Lightness. Float [-100,100]. |
| 280 | +---@return Color |
| 281 | +function Color:lighten(v) |
| 282 | + local hsl = self:to_hsl() |
| 283 | + local lightness = clamp(hsl.lightness + v, 0, 100) |
| 284 | + return Color.from_hsl(hsl.hue, hsl.saturation, lightness) |
| 285 | +end |
| 286 | + |
| 287 | +---Adds value of `v` to the `saturation` of the current color. This returns |
| 288 | +---either a more or less saturated version depending of +/- v. |
| 289 | +---@param v number Saturation. Float [-100,100]. |
| 290 | +---@return Color |
| 291 | +function Color:saturate(v) |
| 292 | + local hsv = self:to_hsv() |
| 293 | + local saturation = clamp(hsv.saturation + v, 0, 100) |
| 294 | + return Color.from_hsv(hsv.hue, saturation, hsv.value) |
| 295 | +end |
| 296 | + |
| 297 | +---Adds value of `v` to the `hue` of the current color. This returns a rotation of |
| 298 | +---hue based on +/- of v. Resulting `hue` is wrapped [0,360] |
| 299 | +---@return Color |
| 300 | +function Color:rotate(v) |
| 301 | + local hsv = self:to_hsv() |
| 302 | + local hue = (hsv.hue + v) % 360 |
| 303 | + return Color.from_hsv(hue, hsv.saturation, hsv.value) |
| 304 | +end |
| 305 | + |
| 306 | +--#endregion |
| 307 | + |
| 308 | +--#region Constants ------------------------------------------------------------ |
| 309 | + |
| 310 | +Color.WHITE = Color.init(1, 1, 1, 1) |
| 311 | +Color.BLACK = Color.init(0, 0, 0, 1) |
| 312 | +Color.BG = Color.init(0, 0, 0, 1) |
| 313 | + |
| 314 | +--#endregion |
| 315 | + |
| 316 | +--#region ty -------------------------------------------------------------- |
| 317 | + |
| 318 | +---Returns the contrast ratio of the other against another |
| 319 | +---@param other Color |
| 320 | +function Color:contrast(other) |
| 321 | + local l1 = self:lumanance() |
| 322 | + local l2 = other:lumanance() |
| 323 | + if l2 > l1 then |
| 324 | + l1, l2 = l2, l1 |
| 325 | + end |
| 326 | + return (l1 + 0.05) / (l2 + 0.05) |
| 327 | +end |
| 328 | + |
| 329 | +---Check if color passes WCAG AA |
| 330 | +---https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html |
| 331 | +---@param background Color background to check against |
| 332 | +---@return boolean, number |
| 333 | +function Color:valid_wcag_aa(background) |
| 334 | + local ratio = self:contrast(background) |
| 335 | + return ratio >= 4.5, ratio |
| 336 | +end |
| 337 | + |
| 338 | +--#endregion |
| 339 | + |
| 340 | +local mt = getmetatable(Color) |
| 341 | +function mt.__call(_, opts) |
| 342 | + if type(opts) == 'string' or type(opts) == 'number' then |
| 343 | + return Color.from_hex(opts) |
| 344 | + end |
| 345 | + if opts.red then |
| 346 | + return Color.from_rgba(opts.red, opts.green, opts.blue, opts.alpha) |
| 347 | + end |
| 348 | + if opts.value then |
| 349 | + return Color.from_hsv(opts.hue, opts.saturation, opts.value) |
| 350 | + end |
| 351 | + if opts.lightness then |
| 352 | + return Color.from_hsl(opts.hue, opts.saturation, opts.lightness) |
| 353 | + end |
| 354 | +end |
| 355 | + |
| 356 | +return Color |
0 commit comments