From b688a2c61fea276f2cb620760b6b0533fc86655d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sindre=20T=2E=20Str=C3=B8m?= Date: Tue, 26 Oct 2021 22:26:11 +0200 Subject: [PATCH] feat: Dynamically generate highlight groups based on color scheme. When Neogit highlight groups are not defined by the active color scheme, they'll be generated. The color generation will take properties such as 'lightness' into account, such that the resulting highlight groups will work well for both light and dark color schemes. * chore(color): Correct some code documentation. * chore: Please linter. --- lua/neogit.lua | 2 + lua/neogit/lib/color.lua | 423 ++++++++++++++++++++++++++++++++++++ lua/neogit/lib/hl.lua | 186 ++++++++++++++++ lua/neogit/lib/util.lua | 14 ++ plugin/neogit.vim | 1 + selene/globals.toml | 3 + syntax/NeogitCommitView.vim | 5 - syntax/NeogitStatus.vim | 6 - 8 files changed, 629 insertions(+), 11 deletions(-) create mode 100644 lua/neogit/lib/color.lua create mode 100644 lua/neogit/lib/hl.lua diff --git a/lua/neogit.lua b/lua/neogit.lua index 6a8baa30..5d4b8d75 100644 --- a/lua/neogit.lua +++ b/lua/neogit.lua @@ -1,6 +1,7 @@ local config = require("neogit.config") local lib = require("neogit.lib") local signs = require("neogit.lib.signs") +local hl = require("neogit.lib.hl") local status = require("neogit.status") local neogit = { @@ -47,6 +48,7 @@ local neogit = { if not config.values.disable_signs then signs.setup() end + hl.setup() end } diff --git a/lua/neogit/lib/color.lua b/lua/neogit/lib/color.lua new file mode 100644 index 00000000..fd0440b5 --- /dev/null +++ b/lua/neogit/lib/color.lua @@ -0,0 +1,423 @@ +local util = require("neogit.lib.util") +local M = {} + +--#region TYPES + +---@class RGBA +---@field red number Float [0,1] +---@field green number Float [0,1] +---@field blue number Float [0,1] +---@field alpha number Float [0,1] + +---@class HSV +---@field hue number Float [0,360) +---@field saturation number Float [0,1] +---@field value number Float [0,1] + +---@class HSL +---@field hue number Float [0,360) +---@field saturation number Float [0,1] +---@field lightness number Float [0,1] + +--#endregion + +---@class Color +---@field red number +---@field green number +---@field blue number +---@field alpha number +---@field hue number +---@field saturation number +---@field value number +---@field lightness number +local Color = setmetatable({}, {}) + +function Color:init(r, g, b, a) + self:set_red(r) + self:set_green(g) + self:set_blue(b) + self:set_alpha(a) +end + +---@param c Color +---@return Color +function Color.from_color(c) + return Color(c.red, c.green, c.blue, c.alpha) +end + +---@param h number Hue. Float [0,360) +---@param s number Saturation. Float [0,1] +---@param v number Value. Float [0,1] +---@param a number (Optional) Alpha. Float [0,1] +---@return Color +function Color.from_hsv(h, s, v, a) + h = h % 360 + s = util.clamp(s, 0, 1) + v = util.clamp(v, 0, 1) + a = util.clamp(a or 1, 0, 1) + + local function f(n) + local k = (n + h / 60) % 6 + return v - v * s * math.max(math.min(k, 4 - k, 1), 0) + end + + return Color(f(5), f(3), f(1), a) +end + +---@param h number Hue. Float [0,360) +---@param s number Saturation. Float [0,1] +---@param l number Lightness. Float [0,1] +---@param a number (Optional) Alpha. Float [0,1] +---@return Color +function Color.from_hsl(h, s, l, a) + h = h % 360 + s = util.clamp(s, 0, 1) + l = util.clamp(l, 0, 1) + a = util.clamp(a or 1, 0, 1) + local _a = s * math.min(l, 1 - l) + + local function f(n) + local k = (n + h / 30) % 12 + return l - _a * math.max(math.min(k - 3, 9 - k, 1), -1) + end + + return Color(f(0), f(8), f(4), a) +end + +---Create a color from a hex number. +---@param c number|string Either a literal number or a css-style hex string (`#RRGGBB[AA]`). +---@return Color +function Color.from_hex(c) + local n = c + if type(c) == "string" then + local s = c:lower():match("#?([a-f0-9]+)") + n = tonumber(s, 16) + if #s <= 6 then + n = bit.lshift(n, 8) + 0xff + end + end + + return Color( + 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 + ) +end + +---@return Color +function Color:clone() + return Color(self.red, self.green, self.blue, self.alpha) +end + +---Returns a new shaded color. +---@param f number Amount. Float [-1,1]. +---@return Color +function Color:shade(f) + local t = f < 0 and 0 or 1.0 + local p = f < 0 and f * -1.0 or f + + return Color( + (t - self.red) * p + self.red, + (t - self.green) * p + self.green, + (t - self.blue) * p + self.blue, + self.alpha + ) +end + +---Returns a new color that's a linear blend between two colors. +---@param other Color +---@param f number Amount. Float [0,1]. +function Color:blend(other, f) + return Color( + (other.red - self.red) * f + self.red, + (other.green - self.green) * f + self.green, + (other.blue - self.blue) * f + self.blue, + self.alpha + ) +end + +function Color.test_shade() + print("-- SHADE TEST -- ") + local c = Color.from_hex("#98c379") + + for i = 0, 10 do + local f = (1 / 5) * i - 1 + print(string.format("%-8.1f%s", f, c:shade(f):to_css())) + end +end + +function Color.test_blend() + print("-- BLEND TEST -- ") + local c0 = Color.from_hex("#98c379") + local c1 = Color.from_hex("#61afef") + + for i = 0, 10 do + local f = (1 / 10) * i + print(string.format("%-8.1f%s", f, c0:blend(c1, f):to_css())) + end +end + +---Return the RGBA values in a new table. +---@return RGBA +function Color:to_rgba() + return { red = self.red, green = self.green, blue = self.blue, alpha = self.alpha } +end + +---Convert the color to HSV. +---@return HSV +function Color:to_hsv() + local r = self.red + local g = self.green + local b = self.blue + local max = math.max(r, g, b) + local min = math.min(r, g, b) + local delta = max - min + local h = 0 + local s = 0 + local v = max + + if max == min then + h = 0 + elseif max == r then + h = 60 * ((g - b) / delta) + elseif max == g then + h = 60 * ((b - r) / delta + 2) + elseif max == b then + h = 60 * ((r - g) / delta + 4) + end + + if h < 0 then + h = h + 360 + end + if max ~= 0 then + s = (max - min) / max + end + + return { hue = h, saturation = s, value = v } +end + +---Convert the color to HSL. +---@return HSL +function Color:to_hsl() + local r = self.red + local g = self.green + local b = self.blue + local max = math.max(r, g, b) + local min = math.min(r, g, b) + local delta = max - min + local h = 0 + local s = 0 + local l = (max + min) / 2 + + if max == min then + h = 0 + elseif max == r then + h = 60 * ((g - b) / delta) + elseif max == g then + h = 60 * ((b - r) / delta + 2) + elseif max == b then + h = 60 * ((r - g) / delta + 4) + end + + if h < 0 then + h = h + 360 + end + if max ~= 0 and min ~= 1 then + s = (max - l) / math.min(l, 1 - l) + end + + return { hue = h, saturation = s, lightness = l } +end + +---Convert the color to a hex number representation (`0xRRGGBB[AA]`). +---@param with_alpha boolean Include the alpha component. +---@return integer +function Color:to_hex(with_alpha) + local n = bit.bor( + bit.bor((self.blue * 0xff), bit.lshift((self.green * 0xff), 8)), + bit.lshift((self.red * 0xff), 16) + ) + return with_alpha and bit.lshift(n, 8) + (self.alpha * 0xff) or n +end + +---Convert the color to a css hex color (`#RRGGBB[AA]`). +---@param with_alpha boolean Include the alpha component. +---@return string +function Color:to_css(with_alpha) + local n = self:to_hex(with_alpha) + local l = with_alpha and 8 or 6 + return string.format("#%0" .. l .. "x", n) +end + +---@param v number Float [0,1]. +function Color:set_red(v) + self._red = util.clamp(v or 1.0, 0, 1) +end + +---@param v number Float [0,1]. +function Color:set_green(v) + self._green = util.clamp(v or 1.0, 0, 1) +end + +---@param v number Float [0,1]. +function Color:set_blue(v) + self._blue = util.clamp(v or 1.0, 0, 1) +end + +---@param v number Float [0,1]. +function Color:set_alpha(v) + self._alpha = util.clamp(v or 1.0, 0, 1) +end + +---@param v number Hue. Float [0,360). +function Color:set_hue(v) + local hsv = self:to_hsv() + hsv.hue = v % 360 + local c = Color.from_hsv(hsv.hue, hsv.saturation, hsv.value) + self._red = c.red + self._green = c.green + self._blue = c.blue +end + +---@param v number Float [0,1]. +function Color:set_saturation(v) + local hsv = self:to_hsv() + hsv.saturation = util.clamp(v, 0, 1) + local c = Color.from_hsv(hsv.hue, hsv.saturation, hsv.value) + self._red = c.red + self._green = c.green + self._blue = c.blue +end + +---@param v number Float [0,1]. +function Color:set_value(v) + local hsv = self:to_hsv() + hsv.value = util.clamp(v, 0, 1) + local c = Color.from_hsv(hsv.hue, hsv.saturation, hsv.value) + self._red = c.red + self._green = c.green + self._blue = c.blue +end + +---@param v number Float [0,1]. +function Color:set_lightness(v) + local hsl = self:to_hsl() + hsl.lightness = util.clamp(v, 0, 1) + local c = Color.from_hsl(hsl.hue, hsl.saturation, hsl.lightness) + self._red = c.red + self._green = c.green + self._blue = c.blue +end + +---Copy the values from another color. +---@param c Color +function Color:set_from_color(c) + self._red = c.red + self._green = c.green + self._blue = c.blue + self._alpha = c.alpha +end + +---@param x RGBA|number[]|number Either an RGBA struct, or a vector, or the value for red. +---@param g number (Optional) Green. Float [0,1]. +---@param b number (Optional) Blue. Float [0,1]. +---@param a number (Optional) Alpha. Float [0,1]. +function Color:set_from_rgba(x, g, b, a) + if type(x) == "number" then + self.red = x + self.green = g + self.blue = b + self.alpha = a or self.alpha + elseif type(x.red) == "number" then + self.red = x.red + self.green = x.green + self.blue = x.blue + self.alpha = x.alpha + else + self.red = x[1] + self.green = x[2] + self.blue = x[3] + self.alpha = x[4] or self.alpha + end +end + +---@param x HSV|number[]|number Either an HSV struct, or a vector, or the value for hue. +---@param s number (Optional) Saturation. Float [0,1]. +---@param v number (Optional) Value Float [0,1]. +function Color:set_from_hsv(x, s, v) + local c + if type(x) == "number" then + c = Color.from_hsv(x, s, v, self.alpha) + elseif type(x.hue) == "number" then + c = Color.from_hsv(x.hue, x.saturation, x.value, self.alpha) + else + c = Color.from_hsv(x[1], x[2], x[3], self.alpha) + end + self:set_from_color(c) +end + +---@param x HSL|number[]|number Either an HSL struct, or a vector, or the value for hue. +---@param s number (Optional) Saturation. Float [0,1]. +---@param l number (Optional) Lightness. Float [0,1]. +function Color:set_from_hsl(x, s, l) + local c + if type(x) == "number" then + c = Color.from_hsl(x, s, l, self.alpha) + elseif type(x.hue) == "number" then + c = Color.from_hsl(x.hue, x.saturation, x.lightness, self.alpha) + else + c = Color.from_hsl(x[1], x[2], x[3], self.alpha) + end + self:set_from_color(c) +end + +do + -- stylua: ignore start + local getters = { + red = function(self) return self._red end, + green = function(self) return self._green end, + blue = function(self) return self._blue end, + alpha = function(self) return self._alpha end, + hue = function (self) return self:to_hsv().hue end, + saturation = function (self) return self:to_hsv().saturation end, + value = function (self) return self:to_hsv().value end, + lightness = function (self) return self:to_hsl().lightness end, + } + local setters = { + red = function(self, v) self:set_red(v) end, + green = function(self, v) self:set_green(v) end, + blue = function(self, v) self:set_blue(v) end, + alpha = function(self, v) self:set_alpha(v) end, + hue = function(self, v) self:set_hue(v) end, + saturation = function(self, v) self:set_saturation(v) end, + value = function(self, v) self:set_value(v) end, + lightness = function(self, v) self:set_lightness(v) end, + } + -- stylua: ignore end + + function Color.__index(self, k) + if getters[k] then + return getters[k](self) + end + return Color[k] + end + + function Color.__newindex(self, k, v) + if setters[k] then + setters[k](self, v) + else + rawset(self, k, v) + end + end + + local mt = getmetatable(Color) + function mt.__call(_, ...) + local this = setmetatable({}, Color) + this:init(...) + return this + end +end + +M.Color = Color +return M diff --git a/lua/neogit/lib/hl.lua b/lua/neogit/lib/hl.lua new file mode 100644 index 00000000..7e1a56bf --- /dev/null +++ b/lua/neogit/lib/hl.lua @@ -0,0 +1,186 @@ +--#region TYPES + +---@class HiSpec +---@field fg string +---@field bg string +---@field gui string +---@field sp string +---@field blend integer +---@field default boolean + +---@class HiLinkSpec +---@field force boolean +---@field default boolean + +--#endregion + +local Color = require("neogit.lib.color").Color +local api = vim.api +local hl_store +local M = {} + +---@param group string Syntax group name. +---@param opt HiSpec +function M.hi(group, opt) + vim.cmd(string.format( + "hi %s %s guifg=%s guibg=%s gui=%s guisp=%s blend=%s", + opt.default and "default" or "", + group, + opt.fg or "NONE", + opt.bg or "NONE", + opt.gui or "NONE", + opt.sp or "NONE", + opt.blend or "NONE" + )) +end + +---@param from string Syntax group name. +---@param to string Syntax group name. +---@param opt HiLinkSpec +function M.hi_link(from, to, opt) + vim.cmd(string.format( + "hi%s %s link %s %s", + opt.force and "!" or "", + opt.default and "default" or "", + from, + to or "" + )) +end + +---@param name string Syntax group name. +---@param attr string Attribute name. +---@param trans boolean Translate the syntax group (follows links). +function M.get_hl_attr(name, attr, trans) + local id = api.nvim_get_hl_id_by_name(name) + if id and trans then + id = vim.fn.synIDtrans(id) + end + if not id then + return + end + + local value = vim.fn.synIDattr(id, attr) + if not value or value == "" then + return + end + + return value +end + +---@param group_name string Syntax group name. +---@param trans boolean Translate the syntax group (follows links). True by default. +function M.get_fg(group_name, trans) + if type(trans) ~= "boolean" then + trans = true + end + return M.get_hl_attr(group_name, "fg", trans) +end + +---@param group_name string Syntax group name. +---@param trans boolean Translate the syntax group (follows links). True by default. +function M.get_bg(group_name, trans) + if type(trans) ~= "boolean" then + trans = true + end + return M.get_hl_attr(group_name, "bg", trans) +end + +---@param group_name string Syntax group name. +---@param trans boolean Translate the syntax group (follows links). True by default. +function M.get_gui(group_name, trans) + if type(trans) ~= "boolean" then + trans = true + end + local hls = {} + local attributes = { + "bold", + "italic", + "reverse", + "standout", + "underline", + "undercurl", + "strikethrough" + } + + for _, attr in ipairs(attributes) do + if M.get_hl_attr(group_name, attr, trans) == "1" then + table.insert(hls, attr) + end + end + + if #hls > 0 then + return table.concat(hls, ",") + end +end + +local function get_cur_hl() + return { + NeogitHunkHeader = { bg = M.get_bg("NeogitHunkHeader", false) }, + NeogitHunkHeaderHighlight = { bg = M.get_bg("NeogitHunkHeaderHighlight", false) }, + NeogitDiffContextHighlight = { bg = M.get_bg("NeogitDiffContextHighlight", false) }, + NeogitDiffAddHighlight = { + bg = M.get_bg("NeogitDiffAddHighlight", false), + fg = M.get_fg("NeogitDiffAddHighlight", false), + gui = M.get_gui("NeogitDiffAddHighlight", false), + }, + NeogitDiffDeleteHighlight = { + bg = M.get_bg("NeogitDiffDeleteHighlight", false), + fg = M.get_fg("NeogitDiffDeleteHighlight", false), + gui = M.get_gui("NeogitDiffDeleteHighlight", false), + }, + } +end + +local function is_hl_cleared(hl_map) + local keys = { "fg", "bg", "gui", "sp", "blend" } + for _, hl in pairs(hl_map) do + for _, k in ipairs(keys) do + if hl[k] then + return false + end + end + end + return true +end + +function M.setup() + local cur_hl = get_cur_hl() + if hl_store and not is_hl_cleared(cur_hl) and not vim.deep_equal(hl_store, cur_hl) then + -- Highlights have been modified somewhere else. Return. + hl_store = cur_hl + return + end + + local hl_fg_normal = M.get_fg("Normal") + local hl_bg_normal = M.get_bg("Normal") + + -- Generate highlights by lightening for dark color schemes, and darkening + -- for light color schemes. + local bg_normal = Color.from_hex(hl_bg_normal) + local sign = bg_normal.lightness >= 0.5 and -1 or 1 + + local bg_hunk_header_hl = bg_normal:shade(0.15 * sign) + local bg_diff_context_hl = bg_normal:shade(0.075 * sign) + + hl_store = { + NeogitHunkHeader = { bg = bg_diff_context_hl:to_css() }, + NeogitHunkHeaderHighlight = { bg = bg_hunk_header_hl:to_css() }, + NeogitDiffContextHighlight = { bg = bg_diff_context_hl:to_css() }, + NeogitDiffAddHighlight = { + bg = M.get_bg("DiffAdd", false) or bg_diff_context_hl:to_css(), + fg = M.get_fg("DiffAdd", false) or M.get_fg("diffAdded") or hl_fg_normal, + gui = M.get_gui("DiffAdd", false), + }, + NeogitDiffDeleteHighlight = { + bg = M.get_bg("DiffDelete", false) or bg_diff_context_hl:to_css(), + fg = M.get_fg("DiffDelete", false) or M.get_fg("diffRemoved") or hl_fg_normal, + gui = M.get_gui("DiffDelete", false), + }, + } + + for group, hl in pairs(hl_store) do + M.hi(group, hl) + end +end + +return M diff --git a/lua/neogit/lib/util.lua b/lua/neogit/lib/util.lua index ee723c2a..1826fe1f 100644 --- a/lua/neogit/lib/util.lua +++ b/lua/neogit/lib/util.lua @@ -8,6 +8,19 @@ local function map(tbl, f) return t end +---@param value number +---@param min number +---@param max number +---@return number +local function clamp(value, min, max) + if value < min then + return min + elseif value > max then + return max + end + return value +end + local function trim(s) return (string.gsub(s, "^%s*(.-)%s*$", "%1")) end @@ -147,6 +160,7 @@ end return { time = time, time_async = time_async, + clamp = clamp, slice = slice, map = map, range = range, diff --git a/plugin/neogit.vim b/plugin/neogit.vim index dd30b8f6..78d23948 100644 --- a/plugin/neogit.vim +++ b/plugin/neogit.vim @@ -18,6 +18,7 @@ augroup Neogit au! au BufWritePost,BufEnter,FocusGained,ShellCmdPost,VimResume * call refresh(expand('')) au DirChanged * lua vim.defer_fn(function() require 'neogit.status'.dispatch_reset() end, 0) + au ColorScheme * lua require'neogit.lib.hl'.setup() augroup END command! -nargs=* Neogit lua require'neogit'.open(require'neogit.lib.util'.parse_command_args()) diff --git a/selene/globals.toml b/selene/globals.toml index 85de278c..f1b9939c 100644 --- a/selene/globals.toml +++ b/selene/globals.toml @@ -4,3 +4,6 @@ name = "globals" [vim] any = true + +[bit] +any = true diff --git a/syntax/NeogitCommitView.vim b/syntax/NeogitCommitView.vim index 3604d7c2..596c1f7d 100644 --- a/syntax/NeogitCommitView.vim +++ b/syntax/NeogitCommitView.vim @@ -8,11 +8,6 @@ syn match NeogitDiffDelete /.*/ contained hi def link NeogitDiffAdd DiffAdd hi def link NeogitDiffDelete DiffDelete -hi def NeogitDiffAddHighlight guibg=#404040 guifg=#859900 -hi def NeogitDiffDeleteHighlight guibg=#404040 guifg=#dc322f -hi def NeogitDiffContextHighlight guibg=#333333 guifg=#b2b2b2 -hi def NeogitHunkHeader guifg=#cccccc guibg=#404040 -hi def NeogitHunkHeaderHighlight guifg=#cccccc guibg=#4d4d4d hi def NeogitFilePath guifg=#798bf2 hi def NeogitCommitViewHeader guifg=#ffffff guibg=#94bbd1 diff --git a/syntax/NeogitStatus.vim b/syntax/NeogitStatus.vim index 8077a5ad..b669ee81 100644 --- a/syntax/NeogitStatus.vim +++ b/syntax/NeogitStatus.vim @@ -40,12 +40,6 @@ hi def link NeogitUnpulledFrom Function hi def link NeogitStash Comment -hi def NeogitDiffAddHighlight guibg=#404040 guifg=#859900 -hi def NeogitDiffDeleteHighlight guibg=#404040 guifg=#dc322f -hi def NeogitDiffContextHighlight guibg=#333333 guifg=#b2b2b2 -hi def NeogitHunkHeader guifg=#cccccc guibg=#404040 -hi def NeogitHunkHeaderHighlight guifg=#cccccc guibg=#4d4d4d - hi def NeogitFold guifg=None guibg=None sign define NeogitDiffContextHighlight linehl=NeogitDiffContextHighlight