diff --git a/lib/gears/color.lua b/lib/gears/color.lua index a615431f72..9d4b23686f 100644 --- a/lib/gears/color.lua +++ b/lib/gears/color.lua @@ -108,47 +108,226 @@ function color.parse_color(col) return unpack(rgb) end ---- Interpolate between two colors --- Note: This does interpolate the alpha channel if there is one in a or b. --- @tparam number val Value between 0 and 1 --- @param a Starting color point --- @param b Ending color point +--- Mix two colors +-- Note: This does mix the alpha channel if there is one in a or b. +-- @param col1 An RGB/RGBA color string able to be parsed by `gears.color.parse_color` +-- @param col2 An RGB/RGBA color string able to be parsed by `gears.color.parse_color` +-- @tparam[opt=0.5] number val Value between 0 and 1. 0.5 means use half first, and half second. 0.25 means use 1/4 first, 3/4 second. -- @treturn table 4 values representing color in RGBA format (each of them in -- [0, 1] range) or nil if input is incorrect. -function color.interpolate(val, a, b) - local ar,ag,ab,aa = color.parse_color(a) - local br,bg,bb,ba = color.parse_color(b) - if ar == nil or br == nil then return nil end +function color.mix(col1, col2, val) + local ar,ag,ab,aa = color.parse_color(col1) + if ar == nil then return nil end + local br,bg,bb,ba = color.parse_color(col2) + if br == nil then return nil end + local p = 0.5 + if val ~= nil and 0 <= val and val <= 1 then p = (1 - val) end local function interp(v,s,e) return v * (e - s) + s end return unpack({ - interp(val,ar,br), - interp(val,ag,bg), - interp(val,ab,bb), - interp(val,aa,ba) + interp(p,ar,br), + interp(p,ag,bg), + interp(p,ab,bb), + interp(p,aa,ba) + }) +end + +--- Convert an RGBA color to HSLA +-- @param col An RGBA color string able to be parsed by `gears.color.parse_color` +-- @treturn table 4 values representing color in HSLA format or nil if input is incorrect. +function color.rgba_to_hsla(col) + -- parse_color returns in normalized form + local rp,gp,bp,a = color.parse_color(col) + if rp == nil then return nil end + -- ignoring luacheck next line because it says s and l are unused + local h,s,l = 0,0,0 -- luacheck: ignore + local M = math.max(rp, gp, bp) + local m = math.min(rp, gp, bp) + local C = M - m + -- get Hue + if C == 0 then + h = 0 -- undefined technically + elseif M == rp then + h = ((gp - bp) / C) % 6 + elseif M == gp then + h = ((bp - rp) / C) + 2 + elseif M == bp then + h = ((rp - gp) / C) + 4 + end + h = 60 * h + -- Lightness/Luminosity + l = (M + m) / 2 + -- Saturation + if l == 1 or l == 0 then + s = 0 + else + s = C / (1 - math.abs((2 * l) - 1)) + end + return unpack({h, s, l, a}) +end + +--- Convert an RGB color to HSL +-- Note: This calls color.rgba_to_hsla which returns a 1 for Opacity +-- @param col An RGB color string able to be parsed by `gears.color.parse_color` +-- @treturn table 4 values representing color in HSLA format or nil if input is incorrect +function color.rgb_to_hsl(col) + return color.rgba_to_hsla({col[1], col[2], col[3], 1}) +end + +--- Convert an HSLA color to RGBA +-- Note: This does round the final RGB numbers to remove the possible decimal point. +-- @tparam table col A table of 3 or 4 values describing an HSL or HSLA color +-- @treturn table 4 values representing color in RGBA format or nil if input is incorrect +function color.hsla_to_rgba(col) + local h,s,l,a = col[1], col[2], col[3], col[4] + if h == nil or s == nil or l == nil or a == nil then return nil end + a = round(a * 255) + -- If s = 0 then it's a grey + if s == 0 then + local v = round(l * 255) + return unpack({v, v, v, a}) + end + if h > 360 then h = 0 end + local C = (1 - math.abs(2 * l - 1)) * s + local X = C * (1 - math.abs((h / 60) % 2 - 1)) + local m = l - (C / 2) + local r,g,b = 0,0,0 + if 0 <= h and h < 60 then + r,g,b = C,X,0 + elseif 60 <= h and h < 120 then + r,g,b = X,C,0 + elseif 120 <= h and h < 180 then + r,g,b = 0,C,X + elseif 180 <= h and h < 240 then + r,g,b = 0,X,C + elseif 240 <= h and h < 300 then + r,g,b = X,0,C + elseif 300 <= h and h < 360 then + r,g,b = C,0,X + end + return unpack({ + round((r+m) * 255), + round((g+m) * 255), + round((b+m) * 255), + a + }) +end + +--- Convert an HSL color to RGB +-- Note: This calls color.hsla_to_rgba with a 255 Opacity value +-- @treturn table 4 values representing color in RGBA format or nil if input is incorrect +function color.hsl_to_rgb(col) + return color.hsla_to_rgba({col[1], col[2], col[3], 255}) +end + +-- Keep a value between a low and high bound +local function adjust(val, lbound, hbound) + if val < lbound then return lbound end + if val > hbound then return hbound end + return val +end + +--- Darken a color +-- @param col An RGB/RGBA color string able to be parsed by `gears.color.parse_color` +-- @tparam number val Percentage to darken [0,1] +-- @treturn table 4 values representing color in RGBA format or nil if input is incorrect. +function color.darken(col, val) + if val == nil or val < 0 or val > 1 then return nil end + local h, s, l, a = color.rgba_to_hsla(col) + if l == nil then return nil end + l = adjust(l - val, 0, 1) + return color.hsla_to_rgba({h, s, l, a}) +end + +--- Lighten a color +-- @param col An RGB/RGBA color string able to be parsed by `gears.color.parse_color` +-- @tparam number val Percentage to light [0,1] +-- @treturn table 4 values representing color in RGBA format or nil if input is incorrect. +function color.lighten(col, val) + if val == nil or val < 0 or val > 1 then return nil end + local h, s, l, a = color.rgba_to_hsla(col) + if l == nil then return nil end + l = adjust(l + val, 0, 1) + return color.hsla_to_rgba({h, s, l, a}) +end + +--- Invert a color +-- Does not affect alpha channel. +-- @param col An RGB/RGBA color string able to be parsed by `gears.color.parse_color` +-- @treturn table 4 values representing color in RGBA format (each of them in +-- [0, 1] range) or nil if input is incorrect. +function color.invert(col) + local ar,ag,ab,aa = color.parse_color(col) + if ar == nil then return nil end + return unpack({ + 1 - ar, + 1 - ag, + 1 - ab, + aa + }) +end + +--- Normalizes RGBA colors from 0-depth to 0-1 +-- @tparam table col A table of 3 or 4 values +-- @tparam[opt=8] number depth 8, 12, or 16 bit depth to return (2, 3, or 4 +-- chars per channel, respectively) +-- @treturn table 4 values representing color in RGBA format (each of them in +-- [0, 1] range) or nil if input is incorrect. +function color.normalize(col, depth) + depth = depth or 8 + local tdepth = { [8] = 0xff, [12] = 0xfff, [16] = 0xfff } + local range = tdepth[depth] + if range == nil then return nil end + return unpack({ + col[1] / range, + col[2] / range, + col[3] / range, + col[4] and col[4] / range or 1 + }) +end + +--- Unnormalizes RGBA colors from 0-1 to 0-range +-- @tparam table col A table of 3 or 4 values +-- @tparam[opt=8] number depth 8, 12, or 16 bit depth to return (2, 3, or 4 +-- chars per channel, respectively) +function color.unnormalize(col, depth) + depth = depth or 8 + local tdepth = { [8] = 0xff, [12] = 0xfff, [16] = 0xfff } + local range = tdepth[depth] + if range == nil then return nil end + return unpack({ + round(col[1] * range), + round(col[2] * range), + round(col[3] * range), + col[4] and round(col[4] * range) or range }) end --- Stringify a color table --- --- @tparam table col A table of 3 or 4 values in range [0, 1] +-- @tparam table col A table of 3 or 4 values +-- @tparam[opt=true] bool normalized The values in col are normalized (between 0 and 1) or not -- @tparam[opt=8] number depth 8, 12, or 16 bit depth to return (2, 3, or 4 -- chars per channel, respectively) -- @treturn string String representing the color, or nil if table is invalid -function color.stringify(col, depth) +function color.stringify(col, normalized, depth) if #col < 3 or #col > 4 then return nil end - depth = depth or 8 + if normalized == nil then normalized = true end local tdepth = { [8] = { 0xff, "#%02x%02x%02x%02x" }, [12] = { 0xfff, "#%03x%03x%03x%03x" }, [16] = { 0xffff, "#%04x%04x%04x%04x" }, } + depth = depth or 8 + if tdepth[depth] == nil then return nil end local range = tdepth[depth][1] - local r = round(col[1] * range) - local g = round(col[2] * range) - local b = round(col[3] * range) - local a = col[4] and round(col[4] * range) or 1 + local r,g,b,a = col[1], col[2], col[3], col[4] or range + if normalized then + r = round(col[1] * range) + g = round(col[2] * range) + b = round(col[3] * range) + a = col[4] and round(col[4] * range) or range + end return string.format(tdepth[depth][2],r,g,b,a) end diff --git a/spec/gears/color_spec.lua b/spec/gears/color_spec.lua index ed550c7fdf..415a1b1340 100644 --- a/spec/gears/color_spec.lua +++ b/spec/gears/color_spec.lua @@ -4,6 +4,7 @@ --------------------------------------------------------------------------- local color = require("gears.color") +local round = require("gears.math").round local cairo = require("lgi").cairo local say = require("say") @@ -291,6 +292,233 @@ describe("gears.color", function() assert.is.same("zzz", color.ensure_pango_color("#abz", "zzz")) end) end) + + local colors = { + -- { + -- hex str, + -- hsl, + -- lighten 25%, + -- darken 25%, + -- invert, + -- mix 25%, + -- mix 75% + -- } + -- + -- For HSL we use the 0-100 range and convert the 0-1 range returned, + -- this will help prevent some floating point errors + -- + -- a few random colors + { + "#dbf7a6", -- originally #daf7a6, bumped red channel for floating precision error + {81, 84, 81}, + "#ffffff", + "#acec31", + "#240859", + "#52446c", + "#adbb93", + }, + { + "#944fb0", + {283, 38, 50}, + "#caa7d8", + "#4a2858", + "#6bb04f", + "#759867", + "#8a6798", + }, + { + "#ff5833", -- originally #ff5733, bumped green channel for floating precision error + {11, 100, 60}, + "#ffc0b3", + "#b32000", + "#00a7cc", + "#4093a6", + "#bf6c59", + }, + { + "#c70038", + {343, 100, 39}, + "#ff477b", -- scss lighten returns #ff487b, rounding error + "#480014", + "#38ffc7", + "#5cbfa3", + "#a3405c", + }, + { + "#920c3f", -- originally #900c3f, bumped red channel for floating precision error + {337, 85, 31}, + "#ee3078", + "#1c020c", + "#6df3c0", + "#76b9a0", + "#89465f", + }, + { + "#581845", + {318, 57, 22}, + "#bc3394", + "#000000", + "#a7e7ba", + "#93b39d", + "#6c4c62", + }, + -- red + { + "#ff0000", + {0, 100, 50}, + "#ff8080", + "#800000", + "#00ffff", + "#40bfbf", + "#bf4040", + }, + -- cyan + { + "#00ffff", + {180, 100, 50}, + "#80ffff", + "#008080", + "#ff0000", + "#bf4040", + "#40bfbf", + }, + -- white + { + "#ffffff", + {0, 0, 100}, -- if saturation == 0 white/black/grey + "#ffffff", + "#bfbfbf", + "#000000", + "#404040", + "#bfbfbf", + }, + -- black + { + "#000000", + {0, 0, 0}, -- if saturation == 0 white/black/grey + "#404040", + "#000000", + "#ffffff", + "#bfbfbf", + "#404040", + }, + -- grey + { + "#808080", + {0, 0, 50}, -- if saturation == 0 white/black/grey + "#c0c0c0", + "#404040", + "#7f7f7f", + "#7f7f7f", + "#808080", + }, + } + + describe("stringify", function() + for _,col in ipairs(colors) do + -- append alpha channel to match stringify + assert.is.same(col[1] .. "ff", color.stringify({color.parse_color(col[1])}, true, 8)) + end + end) + + -- This covers rgb_to_hsl since it calls rgba_to_hsla. + describe("rgba_to_hsla", function() + local function hsla_match(exp, pass) + -- hue + assert.is.same(exp[1], round(pass[1])) + -- saturation + assert.is.same(exp[2], round(pass[2] * 100)) + -- luminosity + assert.is.same(exp[3], round(pass[3] * 100)) + -- we're ignoring alpha here since it's a passthrough + end + for _,col in ipairs(colors) do + it(col[1], function() + hsla_match(col[2], {color.rgba_to_hsla(col[1])}) + end) + end + end) + + -- This section is sort of patchy as HSLA to RGBA isn't an exact number + -- because of rounding and floating point inprecision as a whole. I've + -- adjusted the random colors to be able to work back and forth, but a + -- few of a single RGB channel had to be adjusted by 1 or 2. + -- + -- This covers hsl_to_rgb since it calls hsla_to_rgba. + describe("hsla_to_rgba", function() + local function rgba_match(exp, pass) + -- we're going to match the strings here since stringify should be + -- working as tested above + assert.is.same(exp, color.stringify(pass, false, 8)) + end + for _,col in ipairs(colors) do + it(col[1], function() + local h,s,l = col[2][1], col[2][2] / 100, col[2][3] / 100 + rgba_match(col[1] .. "ff", {color.hsla_to_rgba({h,s,l,1})}) + end) + end + end) + + describe("lighten 25%", function() + for _,col in ipairs(colors) do + it(col[1], function() + assert.is.same(col[3] .. "ff", color.stringify({color.lighten(col[1], 0.25)}, false, 8)) + end) + end + end) + + describe("darken 25%", function() + for _,col in ipairs(colors) do + it(col[1], function() + assert.is.same(col[4] .. "ff", color.stringify({color.darken(col[1], 0.25)}, false, 8)) + end) + end + end) + + describe("invert", function() + for _,col in ipairs(colors) do + it(col[1], function() + assert.is.same(col[5] .. "ff", color.stringify({color.invert(col[1])}, true, 8)) + end) + end + end) + + -- 0 of first, 100 of second + describe("mix 0%", function() + for _,col in ipairs(colors) do + it(col[1], function() + assert.is.same(col[5] .. "ff", color.stringify({color.mix(col[1], col[5], 0)}, true, 8)) + end) + end + end) + + -- 1/4 of first, 3/4 of second + describe("mix 25%", function() + for _,col in ipairs(colors) do + it(col[1], function() + assert.is.same(col[6] .. "ff", color.stringify({color.mix(col[1], col[5], 0.25)}, true, 8)) + end) + end + end) + + -- 3/4 of first, 1/4 of second + describe("mix 75%", function() + for _,col in ipairs(colors) do + it(col[1], function() + assert.is.same(col[7] .. "ff", color.stringify({color.mix(col[1], col[5], 0.75)}, true, 8)) + end) + end + end) + + -- 100 of first, 0 of second + describe("mix 100%", function() + for _,col in ipairs(colors) do + it(col[1], function() + assert.is.same(col[1] .. "ff", color.stringify({color.mix(col[1], col[5], 1)}, true, 8)) + end) + end + end) + end) -- vim: filetype=lua:expandtab:shiftwidth=4:tabstop=8:softtabstop=4:textwidth=80