summaryrefslogtreecommitdiff
path: root/lua/blink/cmp/completion/windows/render
diff options
context:
space:
mode:
Diffstat (limited to 'lua/blink/cmp/completion/windows/render')
-rw-r--r--lua/blink/cmp/completion/windows/render/column.lua120
-rw-r--r--lua/blink/cmp/completion/windows/render/context.lua85
-rw-r--r--lua/blink/cmp/completion/windows/render/init.lua146
-rw-r--r--lua/blink/cmp/completion/windows/render/tailwind.lua31
-rw-r--r--lua/blink/cmp/completion/windows/render/text.lua72
-rw-r--r--lua/blink/cmp/completion/windows/render/treesitter.lua70
-rw-r--r--lua/blink/cmp/completion/windows/render/types.lua24
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