--[[
Colour swatches: shows a small box filled with the colour next to colour
literals, like VSCode's colour decorators or Vim's hexokinase plug-in.
Recognized literals (in code and strings, but not in comments):
#rgb, #rgba, #rrggbb, #rrggbbaa
rgb(r, g, b), rgba(r, g, b, a), rgb(r g b / a) (numbers or percentages)
Supported output formats: HTML, XHTML, SVG, Pango, BBCode, Truecolor and
xterm256 escape sequences.
Can be combined with other plug-ins which define a Decorate function
(e.g. rainbow_brackets.lua).
Note: highlight copies the Decorate function without its upvalues, so all
state is kept in the global table ColorSwatches instead of local variables.
]]
Description="Adds colour swatches next to hex and rgb() colour literals (HTML, SVG, Pango, BBCode, Truecolor, xterm256)."
Categories = {"format", "html", "svg", "pango", "bbcode", "truecolor", "xterm256", "usability" }
function syntaxUpdate(desc)
if HL_OUTPUT ~= HL_FORMAT_HTML and HL_OUTPUT ~= HL_FORMAT_XHTML
and HL_OUTPUT ~= HL_FORMAT_SVG and HL_OUTPUT ~= HL_FORMAT_PANGO
and HL_OUTPUT ~= HL_FORMAT_BBCODE
and HL_OUTPUT ~= HL_FORMAT_TRUECOLOR and HL_OUTPUT ~= HL_FORMAT_XTERM256 then
return
end
-- do not chain twice if the chunk is evaluated again in the same state
if ColorSwatches then
return
end
ColorSwatches = {
SWATCH_CHAR = "\226\150\160", -- U+25A0 BLACK SQUARE
isEscOutput = (HL_OUTPUT == HL_FORMAT_TRUECOLOR or HL_OUTPUT == HL_FORMAT_XTERM256),
-- hex literal inside strings: split into "#", "00", "ff00", ... tokens
hexBuf = nil, -- collected hex digits after a "#", nil if inactive
hexNextCol = 0, -- column where the next adjacent hex token has to start
-- rgb()/rgba(): split into name, parentheses, numbers and separators
fnState = nil, -- nil, "name" (rgb/rgba seen), "args" (inside parentheses)
fnArgs = {}, -- collected arguments as { value, isPercent }
prevDecorate = Decorate,
prevDecorateLineEnd = DecorateLineEnd,
}
function ColorSwatches.resetFunction()
ColorSwatches.fnState = nil
ColorSwatches.fnArgs = {}
end
function ColorSwatches.clamp(v, lo, hi)
if v < lo then return lo end
if v > hi then return hi end
return v
end
-- returns r, g, b (0-255) and alpha (0-1), or nil
function ColorSwatches.parseHex(hex)
local n = #hex
if string.find(hex, "^%x+$") == nil or (n ~= 3 and n ~= 4 and n ~= 6 and n ~= 8) then
return nil
end
if n <= 4 then
hex = string.gsub(hex, "(%x)", "%1%1")
end
local r = tonumber(string.sub(hex, 1, 2), 16)
local g = tonumber(string.sub(hex, 3, 4), 16)
local b = tonumber(string.sub(hex, 5, 6), 16)
local a = 1
if #hex == 8 then
a = tonumber(string.sub(hex, 7, 8), 16) / 255
end
return r, g, b, a
end
function ColorSwatches.rgbFromArgs(args)
local clamp = ColorSwatches.clamp
if #args ~= 3 and #args ~= 4 then
return nil
end
local c = {}
for i = 1, 3 do
local v = args[i].value
if args[i].isPercent then v = v * 2.55 end
c[i] = math.floor(clamp(v, 0, 255) + 0.5)
end
local a = 1
if args[4] then
a = args[4].value
if args[4].isPercent then a = a / 100 end
a = clamp(a, 0, 1)
end
return c[1], c[2], c[3], a
end
function ColorSwatches.swatch(r, g, b, a)
local box = ColorSwatches.SWATCH_CHAR
if HL_OUTPUT == HL_FORMAT_HTML or HL_OUTPUT == HL_FORMAT_XHTML then
return string.format('<span style="display:inline-block;width:0.8em;height:0.8em;'
..'margin:0 0.25em;vertical-align:middle;border:1px solid #808080;'
..'background-color:rgba(%d,%d,%d,%.3g)"></span>', r, g, b, a)
end
local hex = string.format("#%02x%02x%02x", r, g, b)
if HL_OUTPUT == HL_FORMAT_SVG then
return ' <tspan fill="'..hex..'">'..box..'</tspan> '
elseif HL_OUTPUT == HL_FORMAT_PANGO then
return ' <span foreground="'..hex..'">'..box..'</span> '
elseif HL_OUTPUT == HL_FORMAT_BBCODE then
return ' [color='..hex..']'..box..'[/color] '
elseif HL_OUTPUT == HL_FORMAT_TRUECOLOR then
return string.format(" \x1B[38;2;%d;%d;%dm", r, g, b)..box
elseif HL_OUTPUT == HL_FORMAT_XTERM256 then
local idx = 16 + 36 * math.floor(r * 5 / 255 + 0.5)
+ 6 * math.floor(g * 5 / 255 + 0.5)
+ math.floor(b * 5 / 255 + 0.5)
return " \x1B[38;5;"..idx.."m"..box
end
return ""
end
-- tokens returned by Decorate are not escaped by highlight
function ColorSwatches.escapeToken(token)
if HL_OUTPUT == HL_FORMAT_HTML or HL_OUTPUT == HL_FORMAT_XHTML
or HL_OUTPUT == HL_FORMAT_SVG or HL_OUTPUT == HL_FORMAT_PANGO then
token = string.gsub(token, "&", "&")
token = string.gsub(token, "<", "<")
token = string.gsub(token, ">", ">")
token = string.gsub(token, '"', """)
end
return token
end
-- finishes a pending hex literal; returns its swatch or nil
function ColorSwatches.flushHex()
local cs = ColorSwatches
local buf = cs.hexBuf
cs.hexBuf = nil
if buf then
local r, g, b, a = cs.parseHex(buf)
if r then
return cs.swatch(r, g, b, a)
end
end
return nil
end
-- handles rgb()/rgba(); returns a swatch if the closing parenthesis completes a colour
function ColorSwatches.handleFunction(token)
local cs = ColorSwatches
if cs.fnState == nil then
local name = string.lower(token)
if name == "rgb" or name == "rgba" then
cs.fnState = "name"
end
return nil
end
if cs.fnState == "name" then
if token == "(" then
cs.fnState = "args"
else
cs.resetFunction()
end
return nil
end
-- fnState == "args"
local num, pct = string.match(token, "^(%d*%.?%d+)(%%?)$")
if num then
cs.fnArgs[#cs.fnArgs+1] = { value = tonumber(num), isPercent = (pct == "%") }
elseif token == "%" and #cs.fnArgs > 0 then
cs.fnArgs[#cs.fnArgs].isPercent = true
elseif token == ")" then
local r, g, b, a = cs.rgbFromArgs(cs.fnArgs)
cs.resetFunction()
if r then
return cs.swatch(r, g, b, a)
end
elseif token ~= "," and token ~= "/" then
cs.resetFunction()
end
return nil
end
-- returns prefix and suffix to be placed around the current token
function ColorSwatches.getSwatches(token, state, column)
local cs = ColorSwatches
if state == HL_BLOCK_COMMENT or state == HL_LINE_COMMENT then
cs.hexBuf = nil
cs.resetFunction()
return nil, nil
end
-- complete hex literal in a single token, e.g. CSS numbers
local hex = string.match(token, "^#(%x+)$")
if hex then
local pending = cs.flushHex()
cs.resetFunction()
local r, g, b, a = cs.parseHex(hex)
return pending, r and cs.swatch(r, g, b, a) or nil
end
-- collect adjacent hex digit tokens after a "#"
if cs.hexBuf and column == cs.hexNextCol and string.find(token, "^%x+$") then
cs.hexBuf = cs.hexBuf..token
cs.hexNextCol = column + #token
return nil, nil
end
-- any other token terminates a pending hex literal
local pending = cs.flushHex()
if token == "#" then
cs.hexBuf = ""
cs.hexNextCol = column + 1
cs.resetFunction()
return pending, nil
end
return pending, cs.handleFunction(token)
end
function Decorate(token, state, kwGroupID, lineHasStmt, lineno, column)
local cs = ColorSwatches
local prevRes
if cs.prevDecorate then
prevRes = cs.prevDecorate(token, state, kwGroupID, lineHasStmt, lineno, column)
end
-- whitespace is reported as empty tokens and must not break up literals
if #token > 0 and string.find(token, "^%s*$") == nil then
local prefix, suffix = cs.getSwatches(token, state, column or -1)
-- terminal colours cannot be restored after a prefix, so append it instead
if prefix and cs.isEscOutput then
suffix = prefix..(suffix or "")
prefix = nil
end
if prefix or suffix then
return (prefix or "")..(prevRes or cs.escapeToken(token))..(suffix or "")
end
end
if prevRes then
return prevRes
end
end
-- a hex literal may end the line (e.g. unterminated strings)
function DecorateLineEnd(lineNumber)
local cs = ColorSwatches
local res = cs.flushHex() or ""
if cs.prevDecorateLineEnd then
res = res..(cs.prevDecorateLineEnd(lineNumber) or "")
end
if #res > 0 then
return res
end
end
end
Plugins={
{ Type="lang", Chunk=syntaxUpdate },
}