diff options
Diffstat (limited to 'lua/blink/cmp/completion/windows/render')
| -rw-r--r-- | lua/blink/cmp/completion/windows/render/column.lua | 120 | ||||
| -rw-r--r-- | lua/blink/cmp/completion/windows/render/context.lua | 85 | ||||
| -rw-r--r-- | lua/blink/cmp/completion/windows/render/init.lua | 146 | ||||
| -rw-r--r-- | lua/blink/cmp/completion/windows/render/tailwind.lua | 31 | ||||
| -rw-r--r-- | lua/blink/cmp/completion/windows/render/text.lua | 72 | ||||
| -rw-r--r-- | lua/blink/cmp/completion/windows/render/treesitter.lua | 70 | ||||
| -rw-r--r-- | lua/blink/cmp/completion/windows/render/types.lua | 24 |
7 files changed, 548 insertions, 0 deletions
diff --git a/lua/blink/cmp/completion/windows/render/column.lua b/lua/blink/cmp/completion/windows/render/column.lua new file mode 100644 index 0000000..b9a75d0 --- /dev/null +++ b/lua/blink/cmp/completion/windows/render/column.lua @@ -0,0 +1,120 @@ +--- @class blink.cmp.DrawColumn +--- @field components blink.cmp.DrawComponent[] +--- @field gap number +--- @field lines string[][] +--- @field width number +--- @field ctxs blink.cmp.DrawItemContext[] +--- +--- @field new fun(components: blink.cmp.DrawComponent[], gap: number): blink.cmp.DrawColumn +--- @field render fun(self: blink.cmp.DrawColumn, ctxs: blink.cmp.DrawItemContext[]) +--- @field get_line_text fun(self: blink.cmp.DrawColumn, line_idx: number): string +--- @field get_line_highlights fun(self: blink.cmp.DrawColumn, line_idx: number): blink.cmp.DrawHighlight[] + +local text_lib = require('blink.cmp.completion.windows.render.text') + +--- @type blink.cmp.DrawColumn +--- @diagnostic disable-next-line: missing-fields +local column = {} + +function column.new(components, gap) + local self = setmetatable({}, { __index = column }) + self.components = components + self.gap = gap + self.lines = {} + self.width = 0 + self.ctxs = {} + return self +end + +function column:render(ctxs) + --- render text and get the max widths of each component + --- @type string[][] + local lines = {} + local max_component_widths = {} + for _, ctx in ipairs(ctxs) do + --- @type string[] + local line = {} + for component_idx, component in ipairs(self.components) do + local text = text_lib.apply_component_width(component.text(ctx) or '', component) + table.insert(line, text) + max_component_widths[component_idx] = + math.max(max_component_widths[component_idx] or 0, vim.api.nvim_strwidth(text)) + end + table.insert(lines, line) + end + + --- get the total width of the column + local column_width = 0 + for _, max_component_width in ipairs(max_component_widths) do + if max_component_width > 0 then column_width = column_width + max_component_width + self.gap end + end + column_width = math.max(column_width - self.gap, 0) + + --- find the component that will fill the empty space + local fill_idx = -1 + for component_idx, component in ipairs(self.components) do + if component.width and component.width.fill then + fill_idx = component_idx + break + end + end + if fill_idx == -1 then fill_idx = #self.components end + + --- and add extra spaces until we reach the column width + for _, line in ipairs(lines) do + local line_width = 0 + for _, component_text in ipairs(line) do + if #component_text > 0 then line_width = line_width + vim.api.nvim_strwidth(component_text) + self.gap end + end + line_width = line_width - self.gap + local remaining_width = column_width - line_width + line[fill_idx] = text_lib.pad(line[fill_idx], vim.api.nvim_strwidth(line[fill_idx]) + remaining_width) + end + + -- store results for later + self.width = column_width + self.lines = lines + self.ctxs = ctxs +end + +function column:get_line_text(line_idx) + local text = '' + local line = self.lines[line_idx] + for _, component in ipairs(line) do + if #component > 0 then text = text .. component .. string.rep(' ', self.gap) end + end + return text:sub(1, -self.gap - 1) +end + +function column:get_line_highlights(line_idx) + local ctx = self.ctxs[line_idx] + local offset = 0 + local highlights = {} + + for component_idx, component in ipairs(self.components) do + local text = self.lines[line_idx][component_idx] + if #text > 0 then + local column_highlights = type(component.highlight) == 'function' and component.highlight(ctx, text) + or component.highlight + + if type(column_highlights) == 'string' then + table.insert(highlights, { offset, offset + #text, group = column_highlights }) + elseif type(column_highlights) == 'table' then + for _, highlight in ipairs(column_highlights) do + table.insert(highlights, { + offset + highlight[1], + offset + highlight[2], + group = highlight.group, + params = highlight.params, + }) + end + end + + offset = offset + #text + self.gap + end + end + + return highlights +end + +return column diff --git a/lua/blink/cmp/completion/windows/render/context.lua b/lua/blink/cmp/completion/windows/render/context.lua new file mode 100644 index 0000000..301825f --- /dev/null +++ b/lua/blink/cmp/completion/windows/render/context.lua @@ -0,0 +1,85 @@ +--- @class blink.cmp.DrawItemContext +--- @field self blink.cmp.Draw +--- @field item blink.cmp.CompletionItem +--- @field idx number +--- @field label string +--- @field label_detail string +--- @field label_description string +--- @field label_matched_indices number[] +--- @field kind string +--- @field kind_icon string +--- @field icon_gap string +--- @field deprecated boolean +--- @field source_id string +--- @field source_name string + +local draw_context = {} + +--- @param context blink.cmp.Context +--- @param draw blink.cmp.Draw +--- @param items blink.cmp.CompletionItem[] +--- @return blink.cmp.DrawItemContext[] +function draw_context.get_from_items(context, draw, items) + local matched_indices = require('blink.cmp.fuzzy').fuzzy_matched_indices( + context.get_line(), + context.get_cursor()[2], + vim.tbl_map(function(item) return item.label end, items), + require('blink.cmp.config').completion.keyword.range + ) + + local ctxs = {} + for idx, item in ipairs(items) do + ctxs[idx] = draw_context.new(draw, idx, item, matched_indices[idx]) + end + return ctxs +end + +local config = require('blink.cmp.config').appearance +local kinds = require('blink.cmp.types').CompletionItemKind + +--- @param draw blink.cmp.Draw +--- @param item_idx number +--- @param item blink.cmp.CompletionItem +--- @param matched_indices number[] +--- @return blink.cmp.DrawItemContext +function draw_context.new(draw, item_idx, item, matched_indices) + local kind = kinds[item.kind] or 'Unknown' + local kind_icon = require('blink.cmp.completion.windows.render.tailwind').get_kind_icon(item) + or config.kind_icons[kind] + or config.kind_icons.Field + local icon_spacing = config.nerd_font_variant == 'mono' and '' or ' ' + + -- Some LSPs can return labels with newlines + -- Escape them to avoid errors in nvim_buf_set_lines when rendering the completion menu + local newline_char = '↲' .. icon_spacing + + local label = item.label:gsub('\n', newline_char) .. (kind == 'Snippet' and '~' or '') + if config.nerd_font_variant == 'normal' then label = label:gsub('…', '… ') end + + local label_detail = (item.labelDetails and item.labelDetails.detail or ''):gsub('\n', newline_char) + if config.nerd_font_variant == 'normal' then label_detail = label_detail:gsub('…', '… ') end + + local label_description = (item.labelDetails and item.labelDetails.description or ''):gsub('\n', newline_char) + if config.nerd_font_variant == 'normal' then label_description = label_description:gsub('…', '… ') end + + local source_id = item.source_id + local source_name = item.source_name + + return { + self = draw, + item = item, + idx = item_idx, + label = label, + label_detail = label_detail, + label_description = label_description, + label_matched_indices = matched_indices, + kind = kind, + kind_icon = kind_icon, + icon_gap = config.nerd_font_variant == 'mono' and '' or ' ', + deprecated = item.deprecated or (item.tags and vim.tbl_contains(item.tags, 1)) or false, + source_id = source_id, + source_name = source_name, + } +end + +return draw_context diff --git a/lua/blink/cmp/completion/windows/render/init.lua b/lua/blink/cmp/completion/windows/render/init.lua new file mode 100644 index 0000000..1422ec2 --- /dev/null +++ b/lua/blink/cmp/completion/windows/render/init.lua @@ -0,0 +1,146 @@ +--- @class blink.cmp.Renderer +--- @field def blink.cmp.Draw +--- @field padding number[] +--- @field gap number +--- @field columns blink.cmp.DrawColumn[] +--- +--- @field new fun(draw: blink.cmp.Draw): blink.cmp.Renderer +--- @field draw fun(self: blink.cmp.Renderer, context: blink.cmp.Context, bufnr: number, items: blink.cmp.CompletionItem[]) +--- @field get_component_column_location fun(self: blink.cmp.Renderer, component_name: string): { column_idx: number, component_idx: number } +--- @field get_component_start_col fun(self: blink.cmp.Renderer, component_name: string): number +--- @field get_alignment_start_col fun(self: blink.cmp.Renderer): number + +local ns = vim.api.nvim_create_namespace('blink_cmp_renderer') + +--- @type blink.cmp.Renderer +--- @diagnostic disable-next-line: missing-fields +local renderer = {} + +function renderer.new(draw) + --- Convert the component names in the columns to the component definitions + --- @type blink.cmp.DrawComponent[][] + local columns_definitions = vim.tbl_map(function(column) + local components = {} + for _, component_name in ipairs(column) do + local component = draw.components[component_name] + assert(component ~= nil, 'No component definition found for component: "' .. component_name .. '"') + table.insert(components, draw.components[component_name]) + end + + return { + components = components, + gap = column.gap or 0, + } + end, draw.columns) + + local padding = type(draw.padding) == 'number' and { draw.padding, draw.padding } or draw.padding + --- @cast padding number[] + + local self = setmetatable({}, { __index = renderer }) + self.padding = padding + self.gap = draw.gap + self.def = draw + self.columns = vim.tbl_map( + function(column_definition) + return require('blink.cmp.completion.windows.render.column').new( + column_definition.components, + column_definition.gap + ) + end, + columns_definitions + ) + return self +end + +function renderer:draw(context, bufnr, items) + -- gather contexts + local draw_contexts = require('blink.cmp.completion.windows.render.context').get_from_items(context, self.def, items) + + -- render the columns + for _, column in ipairs(self.columns) do + column:render(draw_contexts) + end + + -- apply to the buffer + local lines = {} + for idx, _ in ipairs(draw_contexts) do + local line = '' + if self.padding[1] > 0 then line = string.rep(' ', self.padding[1]) end + + for _, column in ipairs(self.columns) do + local text = column:get_line_text(idx) + if #text > 0 then line = line .. text .. string.rep(' ', self.gap) end + end + line = line:sub(1, -self.gap - 1) + + if self.padding[2] > 0 then line = line .. string.rep(' ', self.padding[2]) end + + table.insert(lines, line) + end + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.api.nvim_set_option_value('modified', false, { buf = bufnr }) + + -- Setting highlights is slow and we update on every keystroke so we instead use a decoration provider + -- which will only render highlights of the visible lines. This also avoids having to do virtual scroll + -- like nvim-cmp does, which breaks on UIs like neovide + vim.api.nvim_set_decoration_provider(ns, { + on_win = function(_, _, win_bufnr) return bufnr == win_bufnr end, + on_line = function(_, _, _, line) + local offset = self.padding[1] + for _, column in ipairs(self.columns) do + local text = column:get_line_text(line + 1) + if #text > 0 then + local highlights = column:get_line_highlights(line + 1) + for _, highlight in ipairs(highlights) do + local col = offset + highlight[1] + local end_col = offset + highlight[2] + vim.api.nvim_buf_set_extmark(bufnr, ns, line, col, { + end_col = end_col, + hl_group = highlight.group, + hl_mode = 'combine', + hl_eol = true, + ephemeral = true, + }) + end + offset = offset + #text + self.gap + end + end + end, + }) +end + +function renderer:get_component_column_location(component_name) + for column_idx, column in ipairs(self.def.columns) do + for component_idx, other_component_name in ipairs(column) do + if other_component_name == component_name then return { column_idx, component_idx } end + end + end + error('No component found with name: ' .. component_name) +end + +function renderer:get_component_start_col(component_name) + local column_idx, component_idx = unpack(self:get_component_column_location(component_name)) + + -- add previous columns + local start_col = self.padding[1] + for i = 1, column_idx - 1 do + start_col = start_col + self.columns[i].width + self.gap + end + + -- add previous components + local line = self.columns[column_idx].lines[1] + if not line then return start_col end + for i = 1, component_idx - 1 do + start_col = start_col + #line[i] + end + + return start_col +end + +function renderer:get_alignment_start_col() + local component_name = self.def.align_to + if component_name == nil or component_name == 'none' or component_name == 'cursor' then return 0 end + return self:get_component_start_col(component_name) +end + +return renderer diff --git a/lua/blink/cmp/completion/windows/render/tailwind.lua b/lua/blink/cmp/completion/windows/render/tailwind.lua new file mode 100644 index 0000000..0bff6b2 --- /dev/null +++ b/lua/blink/cmp/completion/windows/render/tailwind.lua @@ -0,0 +1,31 @@ +local tailwind = {} + +local kinds = require('blink.cmp.types').CompletionItemKind + +--- @param item blink.cmp.CompletionItem +--- @return string|nil +function tailwind.get_hex_color(item) + local doc = item.documentation + if item.kind ~= kinds.Color or not doc then return end + local content = type(doc) == 'string' and doc or doc.value + if content and content:match('^#%x%x%x%x%x%x$') then return content end +end + +--- @param item blink.cmp.CompletionItem +--- @return string? +function tailwind.get_kind_icon(item) + if tailwind.get_hex_color(item) then return '██' end +end + +--- @param ctx blink.cmp.DrawItemContext +--- @return string|nil +function tailwind.get_hl(ctx) + local hex_color = tailwind.get_hex_color(ctx.item) + if not hex_color then return end + + local hl_name = 'HexColor' .. hex_color:sub(2) + if #vim.api.nvim_get_hl(0, { name = hl_name }) == 0 then vim.api.nvim_set_hl(0, hl_name, { fg = hex_color }) end + return hl_name +end + +return tailwind diff --git a/lua/blink/cmp/completion/windows/render/text.lua b/lua/blink/cmp/completion/windows/render/text.lua new file mode 100644 index 0000000..b614edc --- /dev/null +++ b/lua/blink/cmp/completion/windows/render/text.lua @@ -0,0 +1,72 @@ +local config = require('blink.cmp.config') +local text_lib = {} + +--- Applies the component width settings to the text +--- @param text string +--- @param component blink.cmp.DrawComponent +--- @return string text +function text_lib.apply_component_width(text, component) + local width = component.width or {} + if width.fixed ~= nil then return text_lib.set_width(text, width.fixed, component) end + if width.min ~= nil then text = text_lib.pad(text, width.min) end + if width.max ~= nil then text = text_lib.truncate(text, width.max, component.ellipsis) end + return text +end + +--- Sets the text width to the given width +--- @param text string +--- @param width number +--- @param component blink.cmp.DrawComponent +--- @return string text +function text_lib.set_width(text, width, component) + local length = vim.api.nvim_strwidth(text) + if length > width then + return text_lib.truncate(text, width, component.ellipsis) + elseif length < width then + return text_lib.pad(text, width) + else + return text + end +end + +--- Truncates the text to the given width +--- @param text string +--- @param target_width number +--- @param ellipsis? boolean +--- @return string truncated_text +function text_lib.truncate(text, target_width, ellipsis) + local ellipsis_str = ellipsis ~= false and '…' or '' + if ellipsis ~= false and config.nerd_font_variant == 'normal' then ellipsis_str = ellipsis_str .. ' ' end + + local text_width = vim.api.nvim_strwidth(text) + local ellipsis_width = vim.api.nvim_strwidth(ellipsis_str) + if text_width > target_width then + return vim.fn.strcharpart(text, 0, target_width - ellipsis_width) .. ellipsis_str + end + return text +end + +--- Pads the text to the given width +--- @param text string +--- @param target_width number +--- @return string padded_text The amount of padding added to the left and the padded text +function text_lib.pad(text, target_width) + local text_width = vim.api.nvim_strwidth(text) + if text_width >= target_width then return text end + return text .. string.rep(' ', target_width - text_width) + + -- if alignment == 'left' then + -- return 0, text .. string.rep(' ', target_width - text_width) + -- elseif alignment == 'center' then + -- local extra_space = target_width - text_width + -- local half_width_start = math.floor(extra_space / 2) + -- local half_width_end = math.ceil(extra_space / 2) + -- return half_width_start, string.rep(' ', half_width_start) .. text .. string.rep(' ', half_width_end) + -- elseif alignment == 'right' then + -- return target_width - text_width, string.rep(' ', target_width - text_width) .. text + -- else + -- error('Invalid alignment: ' .. alignment) + -- end +end + +return text_lib diff --git a/lua/blink/cmp/completion/windows/render/treesitter.lua b/lua/blink/cmp/completion/windows/render/treesitter.lua new file mode 100644 index 0000000..901c46a --- /dev/null +++ b/lua/blink/cmp/completion/windows/render/treesitter.lua @@ -0,0 +1,70 @@ +local treesitter = {} + +---@type table<string, blink.cmp.DrawHighlight[]> +local cache = {} +local cache_size = 0 +local MAX_CACHE_SIZE = 1000 + +--- @param ctx blink.cmp.DrawItemContext +--- @param opts? {offset?: number} +function treesitter.highlight(ctx, opts) + local ret = cache[ctx.label] + if not ret then + -- cleanup cache if it's too big + cache_size = cache_size + 1 + if cache_size > MAX_CACHE_SIZE then + cache = {} + cache_size = 0 + end + ret = treesitter._highlight(ctx) + cache[ctx.label] = ret + end + + -- offset highlights if needed + if opts and opts.offset then + ret = vim.deepcopy(ret) + for _, hl in ipairs(ret) do + hl[1] = hl[1] + opts.offset + hl[2] = hl[2] + opts.offset + end + end + return ret +end + +--- @param ctx blink.cmp.DrawItemContext +function treesitter._highlight(ctx) + local ret = {} ---@type blink.cmp.DrawHighlight[] + + local source = ctx.label + local lang = vim.treesitter.language.get_lang(vim.bo.filetype) + if not lang then return ret end + + local ok, parser = pcall(vim.treesitter.get_string_parser, source, lang) + if not ok then return ret end + + parser:parse(true) + + parser:for_each_tree(function(tstree, tree) + if not tstree then return end + local query = vim.treesitter.query.get(tree:lang(), 'highlights') + -- Some injected languages may not have highlight queries. + if not query then return end + + for capture, node in query:iter_captures(tstree:root(), source) do + local _, start_col, _, end_col = node:range() + + ---@type string + local name = query.captures[capture] + if name ~= 'spell' then + ret[#ret + 1] = { + start_col, + end_col, + group = '@' .. name .. '.' .. lang, + } + end + end + end) + return ret +end + +return treesitter diff --git a/lua/blink/cmp/completion/windows/render/types.lua b/lua/blink/cmp/completion/windows/render/types.lua new file mode 100644 index 0000000..186b3dc --- /dev/null +++ b/lua/blink/cmp/completion/windows/render/types.lua @@ -0,0 +1,24 @@ +--- @class blink.cmp.Draw +--- @field align_to? string | 'none' | 'cursor' Align the window to the component with the given name, or to the cursor +--- @field padding? number | number[] Padding on the left and right of the grid +--- @field gap? number Gap between columns +--- @field columns? { [number]: string, gap?: number }[] Components to render, grouped by column +--- @field components? table<string, blink.cmp.DrawComponent> Component definitions +--- @field treesitter? string[] Use treesitter to highlight the label text of completions from these sources +--- +--- @class blink.cmp.DrawHighlight +--- @field [number] number Start and end index of the highlight +--- @field group? string Highlight group +--- @field params? table Additional parameters passed as the `params` field of the highlight +--- +--- @class blink.cmp.DrawWidth +--- @field fixed? number Fixed width +--- @field fill? boolean Fill the remaining space +--- @field min? number Minimum width +--- @field max? number Maximum width +--- +--- @class blink.cmp.DrawComponent +--- @field width? blink.cmp.DrawWidth +--- @field ellipsis? boolean Whether to add an ellipsis when truncating the text +--- @field text? fun(ctx: blink.cmp.DrawItemContext): string? Renders the text of the component +--- @field highlight? string | fun(ctx: blink.cmp.DrawItemContext, text: string): string | blink.cmp.DrawHighlight[] Renders the highlights of the component |
