diff options
| author | Mike Vink <mike@pionative.com> | 2025-01-19 13:52:52 +0100 |
|---|---|---|
| committer | Mike Vink <mike@pionative.com> | 2025-01-19 13:52:52 +0100 |
| commit | b77413ff8f59f380612074f0c9bd49093d8db695 (patch) | |
| tree | 32c39a811ba96ed4ab0a1c81cce9f8d518ed7e31 /lua/blink/cmp/completion/windows | |
Squashed 'mut/neovim/pack/plugins/start/blink.cmp/' content from commit 1cc3b1a
git-subtree-dir: mut/neovim/pack/plugins/start/blink.cmp
git-subtree-split: 1cc3b1a908fbcfd15451c4772759549724f38524
Diffstat (limited to 'lua/blink/cmp/completion/windows')
| -rw-r--r-- | lua/blink/cmp/completion/windows/documentation.lua | 228 | ||||
| -rw-r--r-- | lua/blink/cmp/completion/windows/ghost_text.lua | 100 | ||||
| -rw-r--r-- | lua/blink/cmp/completion/windows/menu.lua | 136 | ||||
| -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 |
10 files changed, 1012 insertions, 0 deletions
diff --git a/lua/blink/cmp/completion/windows/documentation.lua b/lua/blink/cmp/completion/windows/documentation.lua new file mode 100644 index 0000000..69a6dd8 --- /dev/null +++ b/lua/blink/cmp/completion/windows/documentation.lua @@ -0,0 +1,228 @@ +--- @class blink.cmp.CompletionDocumentationWindow +--- @field win blink.cmp.Window +--- @field last_context_id? number +--- @field auto_show_timer uv_timer_t +--- @field shown_item? blink.cmp.CompletionItem +--- +--- @field auto_show_item fun(context: blink.cmp.Context, item: blink.cmp.CompletionItem) +--- @field show_item fun(context: blink.cmp.Context, item: blink.cmp.CompletionItem) +--- @field update_position fun() +--- @field scroll_up fun(amount: number) +--- @field scroll_down fun(amount: number) +--- @field close fun() + +local config = require('blink.cmp.config').completion.documentation +local win_config = config.window + +local sources = require('blink.cmp.sources.lib') +local menu = require('blink.cmp.completion.windows.menu') + +--- @type blink.cmp.CompletionDocumentationWindow +--- @diagnostic disable-next-line: missing-fields +local docs = { + win = require('blink.cmp.lib.window').new({ + min_width = win_config.min_width, + max_width = win_config.max_width, + max_height = win_config.max_height, + border = win_config.border, + winblend = win_config.winblend, + winhighlight = win_config.winhighlight, + scrollbar = win_config.scrollbar, + wrap = true, + filetype = 'blink-cmp-documentation', + scrolloff = 0, + }), + last_context_id = nil, + auto_show_timer = vim.uv.new_timer(), +} + +menu.position_update_emitter:on(function() docs.update_position() end) +menu.close_emitter:on(function() docs.close() end) + +function docs.auto_show_item(context, item) + docs.auto_show_timer:stop() + if docs.win:is_open() then + docs.auto_show_timer:start(config.update_delay_ms, 0, function() + vim.schedule(function() docs.show_item(context, item) end) + end) + elseif config.auto_show then + docs.auto_show_timer:start(config.auto_show_delay_ms, 0, function() + vim.schedule(function() docs.show_item(context, item) end) + end) + end +end + +function docs.show_item(context, item) + docs.auto_show_timer:stop() + if item == nil or not menu.win:is_open() then return docs.win:close() end + + -- TODO: cancellation + -- TODO: only resolve if documentation does not exist + sources + .resolve(context, item) + ---@param item blink.cmp.CompletionItem + :map(function(item) + if item.documentation == nil and item.detail == nil then + docs.close() + return + end + + if docs.shown_item ~= item then + --- @type blink.cmp.RenderDetailAndDocumentationOpts + local default_render_opts = { + bufnr = docs.win:get_buf(), + detail = item.detail, + documentation = item.documentation, + max_width = docs.win.config.max_width, + use_treesitter_highlighting = config and config.treesitter_highlighting, + } + local render = require('blink.cmp.lib.window.docs').render_detail_and_documentation + + if item.documentation and item.documentation.render ~= nil then + -- let the provider render the documentation and optionally override + -- the default rendering + item.documentation.render({ + item = item, + window = docs.win, + default_implementation = function(opts) render(vim.tbl_extend('force', default_render_opts, opts)) end, + }) + else + render(default_render_opts) + end + end + docs.shown_item = item + + if menu.win:get_win() then + docs.win:open() + docs.win:set_cursor({ 1, 0 }) -- reset scroll + docs.update_position() + end + end) + :catch(function(err) vim.notify(err, vim.log.levels.ERROR, { title = 'blink.cmp' }) end) +end + +-- TODO: compensate for wrapped lines +function docs.scroll_up(amount) + local winnr = docs.win:get_win() + if winnr == nil then return end + + local top_line = math.max(1, vim.fn.line('w0', winnr)) + local desired_line = math.max(1, top_line - amount) + + docs.win:set_cursor({ desired_line, 0 }) +end + +-- TODO: compensate for wrapped lines +function docs.scroll_down(amount) + local winnr = docs.win:get_win() + if winnr == nil then return end + + local line_count = vim.api.nvim_buf_line_count(docs.win:get_buf()) + local bottom_line = math.max(1, vim.fn.line('w$', winnr)) + local desired_line = math.min(line_count, bottom_line + amount) + + docs.win:set_cursor({ desired_line, 0 }) +end + +function docs.update_position() + if not docs.win:is_open() or not menu.win:is_open() then return end + + docs.win:update_size() + + local menu_winnr = menu.win:get_win() + if not menu_winnr then return end + local menu_win_config = vim.api.nvim_win_get_config(menu_winnr) + local menu_win_height = menu.win:get_height() + local menu_border_size = menu.win:get_border_size() + + local cursor_win_row = vim.fn.winline() + + -- decide direction priority based on the menu window's position + local menu_win_is_up = menu_win_config.row - cursor_win_row < 0 + local direction_priority = menu_win_is_up and win_config.direction_priority.menu_north + or win_config.direction_priority.menu_south + + -- remove the direction priority of the signature window if it's open + local signature = require('blink.cmp.signature.window') + if signature.win and signature.win:is_open() then + direction_priority = vim.tbl_filter( + function(dir) return dir ~= (menu_win_is_up and 's' or 'n') end, + direction_priority + ) + end + + -- decide direction, width and height of window + local win_width = docs.win:get_width() + local win_height = docs.win:get_height() + local pos = docs.win:get_direction_with_window_constraints(menu.win, direction_priority, { + width = math.min(win_width, win_config.desired_min_width), + height = math.min(win_height, win_config.desired_min_height), + }) + + -- couldn't find anywhere to place the window + if not pos then + docs.win:close() + return + end + + -- set width and height based on available space + docs.win:set_height(pos.height) + docs.win:set_width(pos.width) + + -- set position based on provided direction + + local height = docs.win:get_height() + local width = docs.win:get_width() + + local function set_config(opts) + docs.win:set_win_config({ relative = 'win', win = menu_winnr, row = opts.row, col = opts.col }) + end + if pos.direction == 'n' then + if menu_win_is_up then + set_config({ row = -height - menu_border_size.top, col = -menu_border_size.left }) + else + set_config({ row = -1 - height - menu_border_size.top, col = -menu_border_size.left }) + end + elseif pos.direction == 's' then + if menu_win_is_up then + set_config({ + row = 1 + menu_win_height - menu_border_size.top, + col = -menu_border_size.left, + }) + else + set_config({ + row = menu_win_height - menu_border_size.top, + col = -menu_border_size.left, + }) + end + elseif pos.direction == 'e' then + if menu_win_is_up and menu_win_height < height then + set_config({ + row = menu_win_height - menu_border_size.top - height, + col = menu_win_config.width + menu_border_size.right, + }) + else + set_config({ + row = -menu_border_size.top, + col = menu_win_config.width + menu_border_size.right, + }) + end + elseif pos.direction == 'w' then + if menu_win_is_up and menu_win_height < height then + set_config({ + row = menu_win_height - menu_border_size.top - height, + col = -width - menu_border_size.left, + }) + else + set_config({ row = -menu_border_size.top, col = -width - menu_border_size.left }) + end + end +end + +function docs.close() + docs.win:close() + docs.auto_show_timer:stop() + docs.shown_item = nil +end + +return docs diff --git a/lua/blink/cmp/completion/windows/ghost_text.lua b/lua/blink/cmp/completion/windows/ghost_text.lua new file mode 100644 index 0000000..2869e95 --- /dev/null +++ b/lua/blink/cmp/completion/windows/ghost_text.lua @@ -0,0 +1,100 @@ +local config = require('blink.cmp.config').completion.ghost_text +local highlight_ns = require('blink.cmp.config').appearance.highlight_ns +local text_edits_lib = require('blink.cmp.lib.text_edits') +local snippets_utils = require('blink.cmp.sources.snippets.utils') + +--- @class blink.cmp.windows.GhostText +--- @field win integer? +--- @field selected_item blink.cmp.CompletionItem? +--- @field extmark_id integer? +--- +--- @field is_open fun(): boolean +--- @field show_preview fun(item: blink.cmp.CompletionItem) +--- @field clear_preview fun() +--- @field draw_preview fun(bufnr: number) + +--- @type blink.cmp.windows.GhostText +--- @diagnostic disable-next-line: missing-fields +local ghost_text = { + win = nil, + selected_item = nil, + extmark_id = nil, +} + +--- @param textEdit lsp.TextEdit +local function get_still_untyped_text(textEdit) + local type_text_length = textEdit.range['end'].character - textEdit.range.start.character + return textEdit.newText:sub(type_text_length + 1) +end + +-- immediately re-draw the preview when the cursor moves/text changes +vim.api.nvim_create_autocmd({ 'CursorMovedI', 'TextChangedI' }, { + callback = function() + if config.enabled and ghost_text.win then ghost_text.draw_preview(vim.api.nvim_win_get_buf(ghost_text.win)) end + end, +}) + +function ghost_text.is_open() return ghost_text.extmark_id ~= nil end + +--- @param selected_item? blink.cmp.CompletionItem +function ghost_text.show_preview(selected_item) + -- nothing to show, clear the preview + if not selected_item then + ghost_text.clear_preview() + return + end + + -- doesn't work in command mode + -- TODO: integrate with noice.nvim? + if vim.api.nvim_get_mode().mode == 'c' then return end + + -- update state and redraw + local changed = ghost_text.selected_item ~= selected_item + ghost_text.selected_item = selected_item + ghost_text.win = vim.api.nvim_get_current_win() + if changed then ghost_text.draw_preview(vim.api.nvim_win_get_buf(ghost_text.win)) end +end + +function ghost_text.clear_preview() + ghost_text.selected_item = nil + ghost_text.win = nil + if ghost_text.extmark_id ~= nil then + vim.api.nvim_buf_del_extmark(0, highlight_ns, ghost_text.extmark_id) + ghost_text.extmark_id = nil + end +end + +function ghost_text.draw_preview(bufnr) + if not ghost_text.selected_item then return end + + local text_edit = text_edits_lib.get_from_item(ghost_text.selected_item) + + if ghost_text.selected_item.insertTextFormat == vim.lsp.protocol.InsertTextFormat.Snippet then + local expanded_snippet = snippets_utils.safe_parse(text_edit.newText) + text_edit.newText = expanded_snippet and tostring(expanded_snippet) or text_edit.newText + end + + local display_lines = vim.split(get_still_untyped_text(text_edit), '\n', { plain = true }) or {} + + local virt_lines = {} + if #display_lines > 1 then + for i = 2, #display_lines do + virt_lines[i - 1] = { { display_lines[i], 'BlinkCmpGhostText' } } + end + end + + local cursor_pos = { + text_edit.range.start.line, + text_edit.range['end'].character, + } + + ghost_text.extmark_id = vim.api.nvim_buf_set_extmark(bufnr, highlight_ns, cursor_pos[1], cursor_pos[2], { + id = ghost_text.extmark_id, + virt_text_pos = 'inline', + virt_text = { { display_lines[1], 'BlinkCmpGhostText' } }, + virt_lines = virt_lines, + hl_mode = 'combine', + }) +end + +return ghost_text diff --git a/lua/blink/cmp/completion/windows/menu.lua b/lua/blink/cmp/completion/windows/menu.lua new file mode 100644 index 0000000..a749ddd --- /dev/null +++ b/lua/blink/cmp/completion/windows/menu.lua @@ -0,0 +1,136 @@ +--- @class blink.cmp.CompletionMenu +--- @field win blink.cmp.Window +--- @field items blink.cmp.CompletionItem[] +--- @field renderer blink.cmp.Renderer +--- @field selected_item_idx? number +--- @field context blink.cmp.Context? +--- @field open_emitter blink.cmp.EventEmitter<{}> +--- @field close_emitter blink.cmp.EventEmitter<{}> +--- @field position_update_emitter blink.cmp.EventEmitter<{}> +--- +--- @field open_with_items fun(context: blink.cmp.Context, items: blink.cmp.CompletionItem[]) +--- @field open fun() +--- @field close fun() +--- @field set_selected_item_idx fun(idx?: number) +--- @field update_position fun() +--- @field redraw_if_needed fun() + +local config = require('blink.cmp.config').completion.menu + +--- @type blink.cmp.CompletionMenu +--- @diagnostic disable-next-line: missing-fields +local menu = { + win = require('blink.cmp.lib.window').new({ + min_width = config.min_width, + max_height = config.max_height, + border = config.border, + winblend = config.winblend, + winhighlight = config.winhighlight, + cursorline = false, + scrolloff = config.scrolloff, + scrollbar = config.scrollbar, + filetype = 'blink-cmp-menu', + }), + items = {}, + context = nil, + auto_show = config.auto_show, + open_emitter = require('blink.cmp.lib.event_emitter').new('completion_menu_open', 'BlinkCmpMenuOpen'), + close_emitter = require('blink.cmp.lib.event_emitter').new('completion_menu_close', 'BlinkCmpMenuClose'), + position_update_emitter = require('blink.cmp.lib.event_emitter').new( + 'completion_menu_position_update', + 'BlinkCmpMenuPositionUpdate' + ), +} + +vim.api.nvim_create_autocmd({ 'CursorMovedI', 'WinScrolled', 'WinResized' }, { + callback = function() menu.update_position() end, +}) + +function menu.open_with_items(context, items) + menu.context = context + menu.items = items + menu.selected_item_idx = menu.selected_item_idx ~= nil and math.min(menu.selected_item_idx, #items) or nil + + if not menu.renderer then menu.renderer = require('blink.cmp.completion.windows.render').new(config.draw) end + menu.renderer:draw(context, menu.win:get_buf(), items) + + local auto_show = menu.auto_show + if type(auto_show) == 'function' then auto_show = auto_show(context, items) end + if auto_show then + menu.open() + menu.update_position() + end +end + +function menu.open() + if menu.win:is_open() then return end + + menu.win:open() + if menu.selected_item_idx ~= nil then + vim.api.nvim_win_set_cursor(menu.win:get_win(), { menu.selected_item_idx, 0 }) + end + + menu.open_emitter:emit() +end + +function menu.close() + menu.auto_show = config.auto_show + if not menu.win:is_open() then return end + + menu.win:close() + menu.close_emitter:emit() +end + +function menu.set_selected_item_idx(idx) + menu.win:set_option_value('cursorline', idx ~= nil) + menu.selected_item_idx = idx + if menu.win:is_open() then menu.win:set_cursor({ idx or 1, 0 }) end +end + +--- TODO: Don't switch directions if the context is the same +function menu.update_position() + local context = menu.context + if context == nil then return end + + local win = menu.win + if not win:is_open() then return end + + win:update_size() + + local border_size = win:get_border_size() + local pos = win:get_vertical_direction_and_height(config.direction_priority) + + -- couldn't find anywhere to place the window + if not pos then + win:close() + return + end + + local alignment_start_col = menu.renderer:get_alignment_start_col() + + -- place the window at the start col of the current text we're fuzzy matching against + -- so the window doesnt move around as we type + local row = pos.direction == 's' and 1 or -pos.height - border_size.vertical + + if vim.api.nvim_get_mode().mode == 'c' then + local cmdline_position = config.cmdline_position() + win:set_win_config({ + relative = 'editor', + row = cmdline_position[1] + row, + col = math.max(cmdline_position[2] + context.bounds.start_col - alignment_start_col, 0), + }) + else + local cursor_col = context.get_cursor()[2] + + local col = context.bounds.start_col - alignment_start_col - cursor_col - 1 - border_size.left + if config.draw.align_to == 'cursor' then col = 0 end + + win:set_win_config({ relative = 'cursor', row = row, col = col }) + end + + win:set_height(pos.height) + + menu.position_update_emitter:emit() +end + +return menu 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 |
