cp.nvim/lua/cp/ui/ansi.lua

340 lines
8.6 KiB
Lua

---@class AnsiParseResult
---@field lines string[]
---@field highlights Highlight[]
---@class Highlight
---@field line number
---@field col_start number
---@field col_end number
---@field highlight_group string
local M = {}
local logger = require('cp.log')
local dyn_hl_cache = {}
---@param s string|table
---@return string
function M.bytes_to_string(s)
if type(s) == 'string' then
return s
end
return table.concat(vim.tbl_map(string.char, s))
end
---@param fg table|nil
---@param bold boolean
---@param italic boolean
---@return string|nil
local function ensure_hl_for(fg, bold, italic)
if not fg and not bold and not italic then
return nil
end
local base = 'CpAnsi'
local suffix
local opts = {}
if fg and fg.kind == 'named' then
suffix = fg.name
elseif fg and fg.kind == 'xterm' then
suffix = ('X%03d'):format(fg.idx)
local function xterm_to_hex(n)
if n >= 0 and n <= 15 then
local key = 'terminal_color_' .. n
return vim.g[key]
end
if n >= 16 and n <= 231 then
local c = n - 16
local r = math.floor(c / 36) % 6
local g = math.floor(c / 6) % 6
local b = c % 6
local function level(x)
return x == 0 and 0 or 55 + 40 * x
end
return ('#%02x%02x%02x'):format(level(r), level(g), level(b))
end
local l = 8 + 10 * (n - 232)
return ('#%02x%02x%02x'):format(l, l, l)
end
opts.fg = xterm_to_hex(fg.idx) or 'NONE'
elseif fg and fg.kind == 'rgb' then
suffix = ('Rgb%02x%02x%02x'):format(fg.r, fg.g, fg.b)
opts.fg = ('#%02x%02x%02x'):format(fg.r, fg.g, fg.b)
end
local parts = { base }
if bold then
table.insert(parts, 'Bold')
end
if italic then
table.insert(parts, 'Italic')
end
if suffix then
table.insert(parts, suffix)
end
local name = table.concat(parts)
if not dyn_hl_cache[name] then
if bold then
opts.bold = true
end
if italic then
opts.italic = true
end
vim.api.nvim_set_hl(0, name, opts)
dyn_hl_cache[name] = true
end
return name
end
---@param text string
---@return AnsiParseResult
function M.parse_ansi_text(text)
local clean_text = text:gsub('\027%[[%d;]*[a-zA-Z]', '')
local lines = vim.split(clean_text, '\n', { plain = true })
local highlights = {}
local line_num = 0
local col_pos = 0
local ansi_state = {
bold = false,
italic = false,
foreground = nil,
}
local function get_highlight_group()
return ensure_hl_for(ansi_state.foreground, ansi_state.bold, ansi_state.italic)
end
local function apply_highlight(start_line, start_col, end_col)
local hl_group = get_highlight_group()
if hl_group then
table.insert(highlights, {
line = start_line,
col_start = start_col,
col_end = end_col,
highlight_group = hl_group,
})
end
end
local i = 1
while i <= #text do
local ansi_start, ansi_end, code, cmd = text:find('\027%[([%d;]*)([a-zA-Z])', i)
if ansi_start then
if ansi_start > i then
local segment = text:sub(i, ansi_start - 1)
local start_line = line_num
local start_col = col_pos
for char in segment:gmatch('.') do
if char == '\n' then
if col_pos > start_col then
apply_highlight(start_line, start_col, col_pos)
end
line_num = line_num + 1
start_line = line_num
col_pos = 0
start_col = 0
else
col_pos = col_pos + 1
end
end
if col_pos > start_col then
apply_highlight(start_line, start_col, col_pos)
end
end
if cmd == 'm' then
M.update_ansi_state(ansi_state, code)
end
i = ansi_end + 1
else
local segment = text:sub(i)
if segment ~= '' then
local start_line = line_num
local start_col = col_pos
for char in segment:gmatch('.') do
if char == '\n' then
if col_pos > start_col then
apply_highlight(start_line, start_col, col_pos)
end
line_num = line_num + 1
start_line = line_num
col_pos = 0
start_col = 0
else
col_pos = col_pos + 1
end
end
if col_pos > start_col then
apply_highlight(start_line, start_col, col_pos)
end
end
break
end
end
return {
lines = lines,
highlights = highlights,
}
end
---@param ansi_state table
---@param code_string string
---@return nil
function M.update_ansi_state(ansi_state, code_string)
if code_string == '' or code_string == '0' then
ansi_state.bold = false
ansi_state.italic = false
ansi_state.foreground = nil
return
end
local codes = vim.split(code_string, ';', { plain = true })
local idx = 1
while idx <= #codes do
local num = tonumber(codes[idx])
if num == 1 then
ansi_state.bold = true
elseif num == 3 then
ansi_state.italic = true
elseif num == 22 then
ansi_state.bold = false
elseif num == 23 then
ansi_state.italic = false
elseif num and num >= 30 and num <= 37 then
local colors = { 'Black', 'Red', 'Green', 'Yellow', 'Blue', 'Magenta', 'Cyan', 'White' }
ansi_state.foreground = { kind = 'named', name = colors[num - 29] }
elseif num and num >= 90 and num <= 97 then
local colors = {
'BrightBlack',
'BrightRed',
'BrightGreen',
'BrightYellow',
'BrightBlue',
'BrightMagenta',
'BrightCyan',
'BrightWhite',
}
ansi_state.foreground = { kind = 'named', name = colors[num - 89] }
elseif num == 39 then
ansi_state.foreground = nil
elseif num == 38 or num == 48 then
local is_fg = (num == 38)
local mode = tonumber(codes[idx + 1] or '')
if mode == 5 and codes[idx + 2] then
local pal = tonumber(codes[idx + 2]) or 0
if is_fg then
ansi_state.foreground = { kind = 'xterm', idx = pal }
end
idx = idx + 2
elseif mode == 2 and codes[idx + 2] and codes[idx + 3] and codes[idx + 4] then
local r = tonumber(codes[idx + 2]) or 0
local g = tonumber(codes[idx + 3]) or 0
local b = tonumber(codes[idx + 4]) or 0
if is_fg then
ansi_state.foreground = { kind = 'rgb', r = r, g = g, b = b }
end
idx = idx + 4
end
end
idx = idx + 1
end
end
---@return nil
function M.setup_highlight_groups()
local color_map = {
Black = vim.g.terminal_color_0,
Red = vim.g.terminal_color_1,
Green = vim.g.terminal_color_2,
Yellow = vim.g.terminal_color_3,
Blue = vim.g.terminal_color_4,
Magenta = vim.g.terminal_color_5,
Cyan = vim.g.terminal_color_6,
White = vim.g.terminal_color_7,
BrightBlack = vim.g.terminal_color_8,
BrightRed = vim.g.terminal_color_9,
BrightGreen = vim.g.terminal_color_10,
BrightYellow = vim.g.terminal_color_11,
BrightBlue = vim.g.terminal_color_12,
BrightMagenta = vim.g.terminal_color_13,
BrightCyan = vim.g.terminal_color_14,
BrightWhite = vim.g.terminal_color_15,
}
if vim.tbl_count(color_map) < 16 then
logger.log(
'ansi terminal colors (vim.g.terminal_color_*) not configured. ANSI colors will not display properly.',
vim.log.levels.WARN
)
end
local combinations = {
{ bold = false, italic = false },
{ bold = true, italic = false },
{ bold = false, italic = true },
{ bold = true, italic = true },
}
for _, combo in ipairs(combinations) do
for color_name, terminal_color in pairs(color_map) do
local parts = { 'CpAnsi' }
local opts = { fg = terminal_color or 'NONE' }
if combo.bold then
table.insert(parts, 'Bold')
opts.bold = true
end
if combo.italic then
table.insert(parts, 'Italic')
opts.italic = true
end
table.insert(parts, color_name)
local hl_name = table.concat(parts)
vim.api.nvim_set_hl(0, hl_name, opts)
end
end
vim.api.nvim_set_hl(0, 'CpAnsiBold', { bold = true })
vim.api.nvim_set_hl(0, 'CpAnsiItalic', { italic = true })
vim.api.nvim_set_hl(0, 'CpAnsiBoldItalic', { bold = true, italic = true })
end
---@param text string
---@return string[]
function M.debug_ansi_tokens(text)
local out = {}
local i = 1
while true do
local s, e, codes, cmd = text:find('\027%[([%d;]*)([a-zA-Z])', i)
if not s then
break
end
table.insert(out, ('ESC[%s%s'):format(codes, cmd))
i = e + 1
end
return out
end
---@param s string
---@return string
function M.hex_dump(s)
local t = {}
for i = 1, #s do
t[#t + 1] = ('%02X'):format(s:byte(i))
end
return table.concat(t, ' ')
end
return M