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/lib | |
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/lib')
| -rw-r--r-- | lua/blink/cmp/lib/async.lua | 217 | ||||
| -rw-r--r-- | lua/blink/cmp/lib/buffer_events.lua | 148 | ||||
| -rw-r--r-- | lua/blink/cmp/lib/cmdline_events.lua | 104 | ||||
| -rw-r--r-- | lua/blink/cmp/lib/event_emitter.lua | 37 | ||||
| -rw-r--r-- | lua/blink/cmp/lib/text_edits.lua | 193 | ||||
| -rw-r--r-- | lua/blink/cmp/lib/utils.lua | 112 | ||||
| -rw-r--r-- | lua/blink/cmp/lib/window/docs.lua | 224 | ||||
| -rw-r--r-- | lua/blink/cmp/lib/window/init.lua | 445 | ||||
| -rw-r--r-- | lua/blink/cmp/lib/window/scrollbar/geometry.lua | 92 | ||||
| -rw-r--r-- | lua/blink/cmp/lib/window/scrollbar/init.lua | 37 | ||||
| -rw-r--r-- | lua/blink/cmp/lib/window/scrollbar/win.lua | 107 |
11 files changed, 1716 insertions, 0 deletions
diff --git a/lua/blink/cmp/lib/async.lua b/lua/blink/cmp/lib/async.lua new file mode 100644 index 0000000..b9c39ac --- /dev/null +++ b/lua/blink/cmp/lib/async.lua @@ -0,0 +1,217 @@ +--- Allows chaining of async operations without callback hell +--- +--- @class blink.cmp.Task +--- @field status blink.cmp.TaskStatus +--- @field result any | nil +--- @field error any | nil +--- @field new fun(fn: fun(resolve: fun(result: any), reject: fun(err: any))): blink.cmp.Task +--- +--- @field cancel fun(self: blink.cmp.Task) +--- @field map fun(self: blink.cmp.Task, fn: fun(result: any): blink.cmp.Task | any): blink.cmp.Task +--- @field catch fun(self: blink.cmp.Task, fn: fun(err: any): blink.cmp.Task | any): blink.cmp.Task +--- +--- @field on_completion fun(self: blink.cmp.Task, cb: fun(result: any)) +--- @field on_failure fun(self: blink.cmp.Task, cb: fun(err: any)) +--- @field on_cancel fun(self: blink.cmp.Task, cb: fun()) +--- @field _completion_cbs function[] +--- @field _failure_cbs function[] +--- @field _cancel_cbs function[] +--- @field _cancel? fun() +local task = { + __task = true, +} + +---@enum blink.cmp.TaskStatus +local STATUS = { + RUNNING = 1, + COMPLETED = 2, + FAILED = 3, + CANCELLED = 4, +} + +function task.new(fn) + local self = setmetatable({}, { __index = task }) + self.status = STATUS.RUNNING + self._completion_cbs = {} + self._failure_cbs = {} + self._cancel_cbs = {} + self.result = nil + self.error = nil + + local resolve = function(result) + if self.status ~= STATUS.RUNNING then return end + + self.status = STATUS.COMPLETED + self.result = result + + for _, cb in ipairs(self._completion_cbs) do + cb(result) + end + end + + local reject = function(err) + if self.status ~= STATUS.RUNNING then return end + + self.status = STATUS.FAILED + self.error = err + + for _, cb in ipairs(self._failure_cbs) do + cb(err) + end + end + + local success, cancel_fn_or_err = pcall(function() return fn(resolve, reject) end) + + if not success then + reject(cancel_fn_or_err) + elseif type(cancel_fn_or_err) == 'function' then + self._cancel = cancel_fn_or_err + end + + return self +end + +function task:cancel() + if self.status ~= STATUS.RUNNING then return end + self.status = STATUS.CANCELLED + + if self._cancel ~= nil then self._cancel() end + for _, cb in ipairs(self._cancel_cbs) do + cb() + end +end + +--- mappings + +function task:map(fn) + local chained_task + chained_task = task.new(function(resolve, reject) + self:on_completion(function(result) + local success, mapped_result = pcall(fn, result) + if not success then + reject(mapped_result) + return + end + + if type(mapped_result) == 'table' and mapped_result.__task then + mapped_result:on_completion(resolve) + mapped_result:on_failure(reject) + mapped_result:on_cancel(function() chained_task:cancel() end) + return + end + resolve(mapped_result) + end) + self:on_failure(reject) + self:on_cancel(function() chained_task:cancel() end) + return function() chained_task:cancel() end + end) + return chained_task +end + +function task:catch(fn) + local chained_task + chained_task = task.new(function(resolve, reject) + self:on_completion(resolve) + self:on_failure(function(err) + local success, mapped_err = pcall(fn, err) + if not success then + reject(mapped_err) + return + end + + if type(mapped_err) == 'table' and mapped_err.__task then + mapped_err:on_completion(resolve) + mapped_err:on_failure(reject) + mapped_err:on_cancel(function() chained_task:cancel() end) + return + end + resolve(mapped_err) + end) + self:on_cancel(function() chained_task:cancel() end) + return function() chained_task:cancel() end + end) + return chained_task +end + +--- events + +function task:on_completion(cb) + if self.status == STATUS.COMPLETED then + cb(self.result) + elseif self.status == STATUS.RUNNING then + table.insert(self._completion_cbs, cb) + end + return self +end + +function task:on_failure(cb) + if self.status == STATUS.FAILED then + cb(self.error) + elseif self.status == STATUS.RUNNING then + table.insert(self._failure_cbs, cb) + end + return self +end + +function task:on_cancel(cb) + if self.status == STATUS.CANCELLED then + cb() + elseif self.status == STATUS.RUNNING then + table.insert(self._cancel_cbs, cb) + end + return self +end + +--- utils + +function task.await_all(tasks) + if #tasks == 0 then + return task.new(function(resolve) resolve({}) end) + end + + local all_task + all_task = task.new(function(resolve, reject) + local results = {} + local has_resolved = {} + + local function resolve_if_completed() + -- we can't check #results directly because a table like + -- { [2] = { ... } } has a length of 2 + for i = 1, #tasks do + if has_resolved[i] == nil then return end + end + resolve(results) + end + + for idx, task in ipairs(tasks) do + task:on_completion(function(result) + results[idx] = result + has_resolved[idx] = true + resolve_if_completed() + end) + task:on_failure(function(err) + reject(err) + for _, task in ipairs(tasks) do + task:cancel() + end + end) + task:on_cancel(function() + for _, sub_task in ipairs(tasks) do + sub_task:cancel() + end + if all_task == nil then + vim.schedule(function() all_task:cancel() end) + else + all_task:cancel() + end + end) + end + end) + return all_task +end + +function task.empty() + return task.new(function(resolve) resolve() end) +end + +return { task = task, STATUS = STATUS } diff --git a/lua/blink/cmp/lib/buffer_events.lua b/lua/blink/cmp/lib/buffer_events.lua new file mode 100644 index 0000000..dcca8b8 --- /dev/null +++ b/lua/blink/cmp/lib/buffer_events.lua @@ -0,0 +1,148 @@ +--- Exposes three events (cursor moved, char added, insert leave) for triggers to use. +--- Notably, when "char added" is fired, the "cursor moved" event will not be fired. +--- Unlike in regular neovim, ctrl + c and buffer switching will trigger "insert leave" + +--- @class blink.cmp.BufferEvents +--- @field has_context fun(): boolean +--- @field show_in_snippet boolean +--- @field ignore_next_text_changed boolean +--- @field ignore_next_cursor_moved boolean +--- +--- @field new fun(opts: blink.cmp.BufferEventsOptions): blink.cmp.BufferEvents +--- @field listen fun(self: blink.cmp.BufferEvents, opts: blink.cmp.BufferEventsListener) +--- @field suppress_events_for_callback fun(self: blink.cmp.BufferEvents, cb: fun()) + +--- @class blink.cmp.BufferEventsOptions +--- @field has_context fun(): boolean +--- @field show_in_snippet boolean + +--- @class blink.cmp.BufferEventsListener +--- @field on_char_added fun(char: string, is_ignored: boolean) +--- @field on_cursor_moved fun(event: 'CursorMoved' | 'InsertEnter', is_ignored: boolean) +--- @field on_insert_leave fun() + +--- @type blink.cmp.BufferEvents +--- @diagnostic disable-next-line: missing-fields +local buffer_events = {} + +function buffer_events.new(opts) + return setmetatable({ + has_context = opts.has_context, + show_in_snippet = opts.show_in_snippet, + ignore_next_text_changed = false, + ignore_next_cursor_moved = false, + }, { __index = buffer_events }) +end + +--- Normalizes the autocmds + ctrl+c into a common api and handles ignored events +function buffer_events:listen(opts) + local snippet = require('blink.cmp.config').snippets + + local last_char = '' + vim.api.nvim_create_autocmd('InsertCharPre', { + callback = function() + if snippet.active() and not self.show_in_snippet and not self.has_context() then return end + last_char = vim.v.char + end, + }) + + vim.api.nvim_create_autocmd('TextChangedI', { + callback = function() + if not require('blink.cmp.config').enabled() then return end + if snippet.active() and not self.show_in_snippet and not self.has_context() then return end + + local is_ignored = self.ignore_next_text_changed + self.ignore_next_text_changed = false + + -- no characters added so let cursormoved handle it + if last_char == '' then return end + + opts.on_char_added(last_char, is_ignored) + + last_char = '' + end, + }) + + vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI', 'InsertEnter' }, { + callback = function(ev) + -- only fire a CursorMoved event (notable not CursorMovedI) + -- when jumping between tab stops in a snippet while showing the menu + if + ev.event == 'CursorMoved' + and (vim.api.nvim_get_mode().mode ~= 'v' or not self.has_context() or not snippet.active()) + then + return + end + + local is_cursor_moved = ev.event == 'CursorMoved' or ev.event == 'CursorMovedI' + + local is_ignored = is_cursor_moved and self.ignore_next_cursor_moved + if is_cursor_moved then self.ignore_next_cursor_moved = false end + + -- characters added so let textchanged handle it + if last_char ~= '' then return end + + if not require('blink.cmp.config').enabled() then return end + if not self.show_in_snippet and not self.has_context() and snippet.active() then return end + + opts.on_cursor_moved(is_cursor_moved and 'CursorMoved' or ev.event, is_ignored) + end, + }) + + -- definitely leaving the context + vim.api.nvim_create_autocmd({ 'ModeChanged', 'BufLeave' }, { + callback = function() + last_char = '' + -- HACK: when using vim.snippet.expand, the mode switches from insert -> normal -> visual -> select + -- so we schedule to ignore the intermediary modes + -- TODO: deduplicate requests + vim.schedule(function() + if not vim.tbl_contains({ 'i', 's' }, vim.api.nvim_get_mode().mode) then opts.on_insert_leave() end + end) + end, + }) + + -- ctrl+c doesn't trigger InsertLeave so handle it separately + local ctrl_c = vim.api.nvim_replace_termcodes('<C-c>', true, true, true) + vim.on_key(function(key) + if key == ctrl_c then + vim.schedule(function() + local mode = vim.api.nvim_get_mode().mode + if mode ~= 'i' then + last_char = '' + opts.on_insert_leave() + end + end) + end + end) +end + +--- Suppresses autocmd events for the duration of the callback +--- HACK: there's likely edge cases with this since we can't know for sure +--- if the autocmds will fire for cursor_moved afaik +function buffer_events:suppress_events_for_callback(cb) + local cursor_before = vim.api.nvim_win_get_cursor(0) + local changed_tick_before = vim.api.nvim_buf_get_changedtick(0) + + cb() + + local cursor_after = vim.api.nvim_win_get_cursor(0) + local changed_tick_after = vim.api.nvim_buf_get_changedtick(0) + + local is_insert_mode = vim.api.nvim_get_mode().mode == 'i' + + self.ignore_next_text_changed = changed_tick_after ~= changed_tick_before and is_insert_mode + + -- HACK: the cursor may move from position (1, 1) to (1, 0) and back to (1, 1) during the callback + -- This will trigger a CursorMovedI event, but we can't detect it simply by checking the cursor position + -- since they're equal before vs after the callback. So instead, we always mark the cursor as ignored in + -- insert mode, but if the cursor was equal, we undo the ignore after a small delay, which practically guarantees + -- that the CursorMovedI event will fire + -- TODO: It could make sense to override the nvim_win_set_cursor function and mark as ignored if it's called + -- on the current buffer + local cursor_moved = cursor_after[1] ~= cursor_before[1] or cursor_after[2] ~= cursor_before[2] + self.ignore_next_cursor_moved = is_insert_mode + if not cursor_moved then vim.defer_fn(function() self.ignore_next_cursor_moved = false end, 10) end +end + +return buffer_events diff --git a/lua/blink/cmp/lib/cmdline_events.lua b/lua/blink/cmp/lib/cmdline_events.lua new file mode 100644 index 0000000..6f23ed8 --- /dev/null +++ b/lua/blink/cmp/lib/cmdline_events.lua @@ -0,0 +1,104 @@ +--- @class blink.cmp.CmdlineEvents +--- @field has_context fun(): boolean +--- @field ignore_next_text_changed boolean +--- @field ignore_next_cursor_moved boolean +--- +--- @field new fun(): blink.cmp.CmdlineEvents +--- @field listen fun(self: blink.cmp.CmdlineEvents, opts: blink.cmp.CmdlineEventsListener) +--- @field suppress_events_for_callback fun(self: blink.cmp.CmdlineEvents, cb: fun()) + +--- @class blink.cmp.CmdlineEventsListener +--- @field on_char_added fun(char: string, is_ignored: boolean) +--- @field on_cursor_moved fun(event: 'CursorMoved' | 'InsertEnter', is_ignored: boolean) +--- @field on_leave fun() + +--- @type blink.cmp.CmdlineEvents +--- @diagnostic disable-next-line: missing-fields +local cmdline_events = {} + +function cmdline_events.new() + return setmetatable({ + ignore_next_text_changed = false, + ignore_next_cursor_moved = false, + }, { __index = cmdline_events }) +end + +function cmdline_events:listen(opts) + -- TextChanged + local on_changed = function(key) opts.on_char_added(key, false) end + + -- We handle backspace as a special case, because the text will have changed + -- but we still want to fire the CursorMoved event, and not the TextChanged event + local did_backspace = false + local is_change_queued = false + vim.on_key(function(raw_key, escaped_key) + if vim.api.nvim_get_mode().mode ~= 'c' then return end + + -- ignore if it's a special key + -- FIXME: odd behavior when escaped_key has multiple keycodes, i.e. by pressing <C-p> and then "t" + local key = vim.fn.keytrans(escaped_key) + if key == '<BS>' and not is_change_queued then did_backspace = true end + if key:sub(1, 1) == '<' and key:sub(#key, #key) == '>' and raw_key ~= ' ' then return end + if key == '' then return end + + if not is_change_queued then + is_change_queued = true + did_backspace = false + vim.schedule(function() + on_changed(raw_key) + is_change_queued = false + end) + end + end) + + -- CursorMoved + local previous_cmdline = '' + vim.api.nvim_create_autocmd('CmdlineEnter', { + callback = function() previous_cmdline = '' end, + }) + + -- TODO: switch to CursorMovedC when nvim 0.11 is released + -- HACK: check every 16ms (60 times/second) to see if the cursor moved + -- for neovim < 0.11 + local timer = vim.uv.new_timer() + local previous_cursor + local callback + callback = vim.schedule_wrap(function() + timer:start(16, 0, callback) + if vim.api.nvim_get_mode().mode ~= 'c' then return end + + local cmdline_equal = vim.fn.getcmdline() == previous_cmdline + local cursor_equal = vim.fn.getcmdpos() == previous_cursor + + previous_cmdline = vim.fn.getcmdline() + previous_cursor = vim.fn.getcmdpos() + + if cursor_equal or (not cmdline_equal and not did_backspace) then return end + did_backspace = false + + local is_ignored = self.ignore_next_cursor_moved + self.ignore_next_cursor_moved = false + + opts.on_cursor_moved('CursorMoved', is_ignored) + end) + timer:start(16, 0, callback) + + vim.api.nvim_create_autocmd('CmdlineLeave', { + callback = function() opts.on_leave() end, + }) +end + +--- Suppresses autocmd events for the duration of the callback +--- HACK: there's likely edge cases with this +function cmdline_events:suppress_events_for_callback(cb) + local cursor_before = vim.fn.getcmdpos() + + cb() + + if not vim.api.nvim_get_mode().mode == 'c' then return end + + local cursor_after = vim.fn.getcmdpos() + self.ignore_next_cursor_moved = self.ignore_next_cursor_moved or cursor_after ~= cursor_before +end + +return cmdline_events diff --git a/lua/blink/cmp/lib/event_emitter.lua b/lua/blink/cmp/lib/event_emitter.lua new file mode 100644 index 0000000..d3939cb --- /dev/null +++ b/lua/blink/cmp/lib/event_emitter.lua @@ -0,0 +1,37 @@ +--- @class blink.cmp.EventEmitter<T> : { event: string, autocmd?: string, listeners: table<fun(data: T)>, new: ( fun(event: string, autocmd: string): blink.cmp.EventEmitter ), on: ( fun(self: blink.cmp.EventEmitter, callback: fun(data: T)) ), off: ( fun(self: blink.cmp.EventEmitter, callback: fun(data: T)) ), emit: ( fun(self: blink.cmp.EventEmitter, data?: table) ) }; +--- TODO: is there a better syntax for this? + +local event_emitter = {} + +--- @param event string +--- @param autocmd? string +function event_emitter.new(event, autocmd) + local self = setmetatable({}, { __index = event_emitter }) + self.event = event + self.autocmd = autocmd + self.listeners = {} + return self +end + +function event_emitter:on(callback) table.insert(self.listeners, callback) end + +function event_emitter:off(callback) + for idx, cb in ipairs(self.listeners) do + if cb == callback then table.remove(self.listeners, idx) end + end +end + +function event_emitter:emit(data) + data = data or {} + data.event = self.event + for _, callback in ipairs(self.listeners) do + callback(data) + end + if self.autocmd then + require('blink.cmp.lib.utils').schedule_if_needed( + function() vim.api.nvim_exec_autocmds('User', { pattern = self.autocmd, modeline = false, data = data }) end + ) + end +end + +return event_emitter diff --git a/lua/blink/cmp/lib/text_edits.lua b/lua/blink/cmp/lib/text_edits.lua new file mode 100644 index 0000000..2ce76fe --- /dev/null +++ b/lua/blink/cmp/lib/text_edits.lua @@ -0,0 +1,193 @@ +local config = require('blink.cmp.config') +local context = require('blink.cmp.completion.trigger.context') + +local text_edits = {} + +--- Applies one or more text edits to the current buffer, assuming utf-8 encoding +--- @param edits lsp.TextEdit[] +function text_edits.apply(edits) + local mode = context.get_mode() + if mode == 'default' then return vim.lsp.util.apply_text_edits(edits, vim.api.nvim_get_current_buf(), 'utf-8') end + + assert(mode == 'cmdline', 'Unsupported mode for text edits: ' .. mode) + assert(#edits == 1, 'Cmdline mode only supports one text edit. Contributions welcome!') + + local edit = edits[1] + local line = context.get_line() + local edited_line = line:sub(1, edit.range.start.character) + .. edit.newText + .. line:sub(edit.range['end'].character + 1) + -- FIXME: for some reason, we have to set the cursor here, instead of later, + -- because this will override the cursor position set later + vim.fn.setcmdline(edited_line, edit.range.start.character + #edit.newText + 1) +end + +------- Undo ------- + +--- Gets the reverse of the text edit, must be called before applying +--- @param text_edit lsp.TextEdit +--- @return lsp.TextEdit +function text_edits.get_undo_text_edit(text_edit) + return { + range = text_edits.get_undo_range(text_edit), + newText = text_edits.get_text_to_replace(text_edit), + } +end + +--- Gets the range for undoing an applied text edit +--- @param text_edit lsp.TextEdit +function text_edits.get_undo_range(text_edit) + text_edit = vim.deepcopy(text_edit) + local lines = vim.split(text_edit.newText, '\n') + local last_line_len = lines[#lines] and #lines[#lines] or 0 + + local range = text_edit.range + range['end'].line = range.start.line + #lines - 1 + range['end'].character = #lines > 1 and last_line_len or range.start.character + last_line_len + + return range +end + +--- Gets the text the text edit will replace +--- @param text_edit lsp.TextEdit +--- @return string +function text_edits.get_text_to_replace(text_edit) + local lines = {} + for line = text_edit.range.start.line, text_edit.range['end'].line do + local line_text = context.get_line() + local is_start_line = line == text_edit.range.start.line + local is_end_line = line == text_edit.range['end'].line + + if is_start_line and is_end_line then + table.insert(lines, line_text:sub(text_edit.range.start.character + 1, text_edit.range['end'].character)) + elseif is_start_line then + table.insert(lines, line_text:sub(text_edit.range.start.character + 1)) + elseif is_end_line then + table.insert(lines, line_text:sub(1, text_edit.range['end'].character)) + else + table.insert(lines, line_text) + end + end + return table.concat(lines, '\n') +end + +------- Get ------- + +--- Grabbed from vim.lsp.utils. Converts an offset_encoding to byte offset +--- @param position lsp.Position +--- @param offset_encoding? 'utf-8'|'utf-16'|'utf-32' +--- @return number +local function get_line_byte_from_position(position, offset_encoding) + local bufnr = vim.api.nvim_get_current_buf() + local col = position.character + + -- When on the first character, we can ignore the difference between byte and character + if col == 0 then return 0 end + + local line = vim.api.nvim_buf_get_lines(bufnr, position.line, position.line + 1, false)[1] or '' + if vim.fn.has('nvim-0.11.0') == 1 then + col = vim.str_byteindex(line, offset_encoding or 'utf-16', col, false) or 0 + else + col = vim.lsp.util._str_byteindex_enc(line, col, offset_encoding or 'utf-16') + end + return math.min(col, #line) +end + +--- Gets the text edit from an item, handling insert/replace ranges and converts +--- offset encodings (utf-16 | utf-32) to utf-8 +--- @param item blink.cmp.CompletionItem +--- @return lsp.TextEdit +function text_edits.get_from_item(item) + local text_edit = vim.deepcopy(item.textEdit) + + -- Guess the text edit if the item doesn't define it + if text_edit == nil then return text_edits.guess(item) end + + -- FIXME: temporarily convert insertReplaceEdit to regular textEdit + if text_edit.range == nil then + if config.completion.keyword.range == 'full' and text_edit.replace ~= nil then + text_edit.range = text_edit.replace + else + text_edit.range = text_edit.insert or text_edit.replace + end + end + text_edit.insert = nil + text_edit.replace = nil + --- @cast text_edit lsp.TextEdit + + -- Adjust the position of the text edit to be the current cursor position + -- since the data might be outdated. We compare the cursor column position + -- from when the items were fetched versus the current. + -- HACK: is there a better way? + -- TODO: take into account the offset_encoding + local offset = context.get_cursor()[2] - item.cursor_column + text_edit.range['end'].character = text_edit.range['end'].character + offset + + -- convert the offset encoding to utf-8 + -- TODO: we have to do this last because it applies a max on the position based on the length of the line + -- so it would break the offset code when removing characters at the end of the line + local offset_encoding = text_edits.offset_encoding_from_item(item) + text_edit = text_edits.to_utf_8(text_edit, offset_encoding) + + text_edit.range = text_edits.clamp_range_to_bounds(text_edit.range) + + return text_edit +end + +function text_edits.offset_encoding_from_item(item) + local client = vim.lsp.get_client_by_id(item.client_id) + return client ~= nil and client.offset_encoding or 'utf-8' +end + +function text_edits.to_utf_8(text_edit, offset_encoding) + if offset_encoding == 'utf-8' then return text_edit end + text_edit = vim.deepcopy(text_edit) + text_edit.range.start.character = get_line_byte_from_position(text_edit.range.start, offset_encoding) + text_edit.range['end'].character = get_line_byte_from_position(text_edit.range['end'], offset_encoding) + return text_edit +end + +--- Uses the keyword_regex to guess the text edit ranges +--- @param item blink.cmp.CompletionItem +--- TODO: doesnt work when the item contains characters not included in the context regex +function text_edits.guess(item) + local word = item.insertText or item.label + + local start_col, end_col = require('blink.cmp.fuzzy').guess_edit_range( + item, + context.get_line(), + context.get_cursor()[2], + config.completion.keyword.range + ) + local current_line = context.get_cursor()[1] + + -- convert to 0-index + return { + range = { + start = { line = current_line - 1, character = start_col }, + ['end'] = { line = current_line - 1, character = end_col }, + }, + newText = word, + } +end + +--- Clamps the range to the bounds of their respective lines +--- @param range lsp.Range +--- @return lsp.Range +--- TODO: clamp start and end lines +function text_edits.clamp_range_to_bounds(range) + range = vim.deepcopy(range) + + local start_line = context.get_line(range.start.line) + range.start.character = math.min(math.max(range.start.character, 0), #start_line) + + local end_line = context.get_line(range['end'].line) + range['end'].character = math.min( + math.max(range['end'].character, range.start.line == range['end'].line and range.start.character or 0), + #end_line + ) + + return range +end + +return text_edits diff --git a/lua/blink/cmp/lib/utils.lua b/lua/blink/cmp/lib/utils.lua new file mode 100644 index 0000000..84bcc3d --- /dev/null +++ b/lua/blink/cmp/lib/utils.lua @@ -0,0 +1,112 @@ +local utils = {} + +--- Shallow copy table +--- @generic T +--- @param t T +--- @return T +function utils.shallow_copy(t) + local t2 = {} + for k, v in pairs(t) do + t2[k] = v + end + return t2 +end + +--- Returns the union of the keys of two tables +--- @generic T +--- @param t1 T[] +--- @param t2 T[] +--- @return T[] +function utils.union_keys(t1, t2) + local t3 = {} + for k, _ in pairs(t1) do + t3[k] = true + end + for k, _ in pairs(t2) do + t3[k] = true + end + return vim.tbl_keys(t3) +end + +--- Returns a list of unique values from the input array +--- @generic T +--- @param arr T[] +--- @return T[] +function utils.deduplicate(arr) + local hash = {} + for _, v in ipairs(arr) do + hash[v] = true + end + return vim.tbl_keys(hash) +end + +function utils.schedule_if_needed(fn) + if vim.in_fast_event() then + vim.schedule(fn) + else + fn() + end +end + +--- Flattens an arbitrarily deep table into a single level table +--- @param t table +--- @return table +function utils.flatten(t) + if t[1] == nil then return t end + + local flattened = {} + for _, v in ipairs(t) do + if type(v) == 'table' and vim.tbl_isempty(v) then goto continue end + + if v[1] == nil then + table.insert(flattened, v) + else + vim.list_extend(flattened, utils.flatten(v)) + end + + ::continue:: + end + return flattened +end + +--- Returns the index of the first occurrence of the value in the array +--- @generic T +--- @param arr T[] +--- @param val T +--- @return number? +function utils.index_of(arr, val) + for idx, v in ipairs(arr) do + if v == val then return idx end + end + return nil +end + +--- Finds an item in an array using a predicate function +--- @generic T +--- @param arr T[] +--- @param predicate fun(item: T): boolean +--- @return number? +function utils.find_idx(arr, predicate) + for idx, v in ipairs(arr) do + if predicate(v) then return idx end + end + return nil +end + +--- Slices an array +--- @generic T +--- @param arr T[] +--- @param start number? +--- @param finish number? +--- @return T[] +function utils.slice(arr, start, finish) + start = start or 1 + finish = finish or #arr + local sliced = {} + for i = start, finish do + sliced[#sliced + 1] = arr[i] + end + return sliced +end + +return utils diff --git a/lua/blink/cmp/lib/window/docs.lua b/lua/blink/cmp/lib/window/docs.lua new file mode 100644 index 0000000..f250701 --- /dev/null +++ b/lua/blink/cmp/lib/window/docs.lua @@ -0,0 +1,224 @@ +local highlight_ns = require('blink.cmp.config').appearance.highlight_ns + +local docs = {} + +--- @class blink.cmp.RenderDetailAndDocumentationOpts +--- @field bufnr number +--- @field detail? string|string[] +--- @field documentation? lsp.MarkupContent | string +--- @field max_width number +--- @field use_treesitter_highlighting boolean? + +--- @class blink.cmp.RenderDetailAndDocumentationOptsPartial +--- @field bufnr? number +--- @field detail? string +--- @field documentation? lsp.MarkupContent | string +--- @field max_width? number +--- @field use_treesitter_highlighting boolean? + +--- @param opts blink.cmp.RenderDetailAndDocumentationOpts +function docs.render_detail_and_documentation(opts) + local detail_lines = {} + local details = type(opts.detail) == 'string' and { opts.detail } or opts.detail or {} + --- @cast details string[] + details = require('blink.cmp.lib.utils').deduplicate(details) + for _, v in ipairs(details) do + vim.list_extend(detail_lines, docs.split_lines(v)) + end + + local doc_lines = {} + if opts.documentation ~= nil then + local doc = type(opts.documentation) == 'string' and opts.documentation or opts.documentation.value + doc_lines = docs.split_lines(doc) + end + + detail_lines, doc_lines = docs.extract_detail_from_doc(detail_lines, doc_lines) + + ---@type string[] + local combined_lines = vim.list_extend({}, detail_lines) + + -- add a blank line for the --- separator + local doc_already_has_separator = #doc_lines > 1 and (doc_lines[1] == '---' or doc_lines[1] == '***') + if #detail_lines > 0 and #doc_lines > 0 then table.insert(combined_lines, '') end + -- skip original separator in doc_lines, so we can highlight it later + vim.list_extend(combined_lines, doc_lines, doc_already_has_separator and 2 or 1) + + vim.api.nvim_buf_set_lines(opts.bufnr, 0, -1, true, combined_lines) + vim.api.nvim_set_option_value('modified', false, { buf = opts.bufnr }) + + -- Highlight with treesitter + vim.api.nvim_buf_clear_namespace(opts.bufnr, highlight_ns, 0, -1) + + if #detail_lines > 0 and opts.use_treesitter_highlighting then + docs.highlight_with_treesitter(opts.bufnr, vim.bo.filetype, 0, #detail_lines) + end + + -- Only add the separator if there are documentation lines (otherwise only display the detail) + if #detail_lines > 0 and #doc_lines > 0 then + vim.api.nvim_buf_set_extmark(opts.bufnr, highlight_ns, #detail_lines, 0, { + virt_text = { { string.rep('─', opts.max_width), 'BlinkCmpDocSeparator' } }, + virt_text_pos = 'overlay', + }) + end + + if #doc_lines > 0 and opts.use_treesitter_highlighting then + local start = #detail_lines + (#detail_lines > 0 and 1 or 0) + docs.highlight_with_treesitter(opts.bufnr, 'markdown', start, start + #doc_lines) + end +end + +--- Highlights the given range with treesitter with the given filetype +--- @param bufnr number +--- @param filetype string +--- @param start_line number +--- @param end_line number +--- TODO: fallback to regex highlighting if treesitter fails +--- TODO: only render what's visible +function docs.highlight_with_treesitter(bufnr, filetype, start_line, end_line) + local Range = require('vim.treesitter._range') + + local root_lang = vim.treesitter.language.get_lang(filetype) + if root_lang == nil then return end + + local success, trees = pcall(vim.treesitter.get_parser, bufnr, root_lang) + if not success or not trees then return end + + trees:parse({ start_line, end_line }) + + trees:for_each_tree(function(tree, tstree) + local lang = tstree:lang() + local highlighter_query = vim.treesitter.query.get(lang, 'highlights') + if not highlighter_query then return end + + local root_node = tree:root() + local _, _, root_end_row, _ = root_node:range() + + local iter = highlighter_query:iter_captures(tree:root(), bufnr, start_line, end_line) + local line = start_line + while line < end_line do + local capture, node, metadata, _ = iter(line) + if capture == nil then break end + + local range = { root_end_row + 1, 0, root_end_row + 1, 0 } + if node then range = vim.treesitter.get_range(node, bufnr, metadata and metadata[capture]) end + local start_row, start_col, end_row, end_col = Range.unpack4(range) + + if capture then + local name = highlighter_query.captures[capture] + local hl = 0 + if not vim.startswith(name, '_') then hl = vim.api.nvim_get_hl_id_by_name('@' .. name .. '.' .. lang) end + + -- The "priority" attribute can be set at the pattern level or on a particular capture + local priority = ( + tonumber(metadata.priority or metadata[capture] and metadata[capture].priority) + or vim.highlight.priorities.treesitter + ) + + -- The "conceal" attribute can be set at the pattern level or on a particular capture + local conceal = metadata.conceal or metadata[capture] and metadata[capture].conceal + + if hl and end_row >= line then + vim.api.nvim_buf_set_extmark(bufnr, highlight_ns, start_row, start_col, { + end_line = end_row, + end_col = end_col, + hl_group = hl, + priority = priority, + conceal = conceal, + }) + end + end + + if start_row > line then line = start_row end + end + end) +end + +--- Gets the start and end row of the code block for the given row +--- Or returns nil if there's no code block +--- @param lines string[] +--- @param row number +--- @return number?, number? +function docs.get_code_block_range(lines, row) + if row < 1 or row > #lines then return end + -- get the start of the code block + local code_block_start = nil + for i = 1, row do + local line = lines[i] + if line:match('^%s*```') then + if code_block_start == nil then + code_block_start = i + else + code_block_start = nil + end + end + end + if code_block_start == nil then return end + + -- get the end of the code block + local code_block_end = nil + for i = row, #lines do + local line = lines[i] + if line:match('^%s*```') then + code_block_end = i + break + end + end + if code_block_end == nil then return end + + return code_block_start, code_block_end +end + +--- Avoids showing the detail if it's part of the documentation +--- or, if the detail is in a code block in the doc, +--- extracts the code block into the detail +---@param detail_lines string[] +---@param doc_lines string[] +---@return string[], string[] +--- TODO: Also move the code block into detail if it's at the start of the doc +--- and we have no detail +function docs.extract_detail_from_doc(detail_lines, doc_lines) + local detail_str = table.concat(detail_lines, '\n') + local doc_str = table.concat(doc_lines, '\n') + local doc_str_detail_row = doc_str:find(detail_str, 1, true) + + -- didn't find the detail in the doc, so return as is + if doc_str_detail_row == nil or #detail_str == 0 or #doc_str == 0 then return detail_lines, doc_lines end + + -- get the line of the match + -- hack: surely there's a better way to do this but it's late + -- and I can't be bothered + local offset = 1 + local detail_line = 1 + for line_num, line in ipairs(doc_lines) do + if #line + offset > doc_str_detail_row then + detail_line = line_num + break + end + offset = offset + #line + 1 + end + + -- extract the code block, if it exists, and use it as the detail + local code_block_start, code_block_end = docs.get_code_block_range(doc_lines, detail_line) + if code_block_start ~= nil and code_block_end ~= nil then + detail_lines = vim.list_slice(doc_lines, code_block_start + 1, code_block_end - 1) + + local doc_lines_start = vim.list_slice(doc_lines, 1, code_block_start - 1) + local doc_lines_end = vim.list_slice(doc_lines, code_block_end + 1, #doc_lines) + vim.list_extend(doc_lines_start, doc_lines_end) + doc_lines = doc_lines_start + else + detail_lines = {} + end + + return detail_lines, doc_lines +end + +function docs.split_lines(text) + local lines = {} + for s in text:gmatch('[^\r\n]+') do + table.insert(lines, s) + end + return lines +end + +return docs diff --git a/lua/blink/cmp/lib/window/init.lua b/lua/blink/cmp/lib/window/init.lua new file mode 100644 index 0000000..9a5d18b --- /dev/null +++ b/lua/blink/cmp/lib/window/init.lua @@ -0,0 +1,445 @@ +-- TODO: The scrollbar and redrawing logic should be done by wrapping the functions that would +-- trigger a redraw or update the window + +--- @class blink.cmp.WindowOptions +--- @field min_width? number +--- @field max_width? number +--- @field max_height? number +--- @field cursorline? boolean +--- @field border? blink.cmp.WindowBorder +--- @field wrap? boolean +--- @field winblend? number +--- @field winhighlight? string +--- @field scrolloff? number +--- @field scrollbar? boolean +--- @field filetype string + +--- @class blink.cmp.Window +--- @field id? number +--- @field buf? number +--- @field config blink.cmp.WindowOptions +--- @field scrollbar? blink.cmp.Scrollbar +--- @field redraw_queued boolean +--- +--- @field new fun(config: blink.cmp.WindowOptions): blink.cmp.Window +--- @field get_buf fun(self: blink.cmp.Window): number +--- @field get_win fun(self: blink.cmp.Window): number +--- @field is_open fun(self: blink.cmp.Window): boolean +--- @field open fun(self: blink.cmp.Window) +--- @field close fun(self: blink.cmp.Window) +--- @field set_option_value fun(self: blink.cmp.Window, option: string, value: any) +--- @field update_size fun(self: blink.cmp.Window) +--- @field get_content_height fun(self: blink.cmp.Window): number +--- @field get_border_size fun(self: blink.cmp.Window, border?: 'none' | 'single' | 'double' | 'rounded' | 'solid' | 'shadow' | 'padded' | string[]): { vertical: number, horizontal: number, left: number, right: number, top: number, bottom: number } +--- @field get_height fun(self: blink.cmp.Window): number +--- @field get_content_width fun(self: blink.cmp.Window): number +--- @field get_width fun(self: blink.cmp.Window): number +--- @field get_cursor_screen_position fun(): { distance_from_top: number, distance_from_bottom: number } +--- @field set_cursor fun(self: blink.cmp.Window, cursor: number[]) +--- @field set_height fun(self: blink.cmp.Window, height: number) +--- @field set_width fun(self: blink.cmp.Window, width: number) +--- @field set_win_config fun(self: blink.cmp.Window, config: table) +--- @field get_vertical_direction_and_height fun(self: blink.cmp.Window, direction_priority: ("n" | "s")[]): { height: number, direction: 'n' | 's' }? +--- @field get_direction_with_window_constraints fun(self: blink.cmp.Window, anchor_win: blink.cmp.Window, direction_priority: ("n" | "s" | "e" | "w")[], desired_min_size?: { width: number, height: number }): { width: number, height: number, direction: 'n' | 's' | 'e' | 'w' }? +--- @field redraw_if_needed fun(self: blink.cmp.Window) + +--- @type blink.cmp.Window +--- @diagnostic disable-next-line: missing-fields +local win = {} + +--- @param config blink.cmp.WindowOptions +function win.new(config) + local self = setmetatable({}, { __index = win }) + + self.id = nil + self.buf = nil + self.config = { + min_width = config.min_width, + max_width = config.max_width, + max_height = config.max_height or 10, + cursorline = config.cursorline or false, + border = config.border or 'none', + wrap = config.wrap or false, + winblend = config.winblend or 0, + winhighlight = config.winhighlight or 'Normal:NormalFloat,FloatBorder:NormalFloat', + scrolloff = config.scrolloff or 0, + scrollbar = config.scrollbar, + filetype = config.filetype, + } + self.redraw_queued = false + + if self.config.scrollbar then + self.scrollbar = require('blink.cmp.lib.window.scrollbar').new({ + enable_gutter = self.config.border == 'none' or self.config.border == 'padded', + }) + end + + return self +end + +function win:get_buf() + -- create buffer if it doesn't exist + if self.buf == nil or not vim.api.nvim_buf_is_valid(self.buf) then + self.buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_option_value('tabstop', 1, { buf = self.buf }) -- prevents tab widths from being unpredictable + end + return self.buf +end + +function win:get_win() + if self.id ~= nil and not vim.api.nvim_win_is_valid(self.id) then self.id = nil end + return self.id +end + +function win:is_open() return self.id ~= nil and vim.api.nvim_win_is_valid(self.id) end + +function win:open() + -- window already exists + if self.id ~= nil and vim.api.nvim_win_is_valid(self.id) then return end + + -- create window + self.id = vim.api.nvim_open_win(self:get_buf(), false, { + relative = 'cursor', + style = 'minimal', + width = self.config.min_width or 1, + height = self.config.max_height, + row = 1, + col = 1, + focusable = false, + zindex = 1001, + border = self.config.border == 'padded' and { ' ', '', '', ' ', '', '', ' ', ' ' } or self.config.border, + }) + vim.api.nvim_set_option_value('winblend', self.config.winblend, { win = self.id }) + vim.api.nvim_set_option_value('winhighlight', self.config.winhighlight, { win = self.id }) + vim.api.nvim_set_option_value('wrap', self.config.wrap, { win = self.id }) + vim.api.nvim_set_option_value('foldenable', false, { win = self.id }) + vim.api.nvim_set_option_value('conceallevel', 2, { win = self.id }) + vim.api.nvim_set_option_value('concealcursor', 'n', { win = self.id }) + vim.api.nvim_set_option_value('cursorlineopt', 'line', { win = self.id }) + vim.api.nvim_set_option_value('cursorline', self.config.cursorline, { win = self.id }) + vim.api.nvim_set_option_value('scrolloff', self.config.scrolloff, { win = self.id }) + vim.api.nvim_set_option_value('filetype', self.config.filetype, { buf = self.buf }) + + if self.scrollbar then self.scrollbar:update(self.id) end + self:redraw_if_needed() +end + +function win:set_option_value(option, value) + if self.id == nil or not vim.api.nvim_win_is_valid(self.id) then return end + vim.api.nvim_set_option_value(option, value, { win = self.id }) +end + +function win:close() + if self.id ~= nil then + vim.api.nvim_win_close(self.id, true) + self.id = nil + end + if self.scrollbar then self.scrollbar:update() end + self:redraw_if_needed() +end + +--- Updates the size of the window to match the max width and height of the content/config +function win:update_size() + if not self:is_open() then return end + local winnr = self:get_win() + local config = self.config + + -- todo: never go above the screen width and height + + -- set width to current content width, bounded by min and max + local width = self:get_content_width() + if config.max_width then width = math.min(width, config.max_width) end + if config.min_width then width = math.max(width, config.min_width) end + vim.api.nvim_win_set_width(winnr, width) + + -- set height to current line count, bounded by max + local height = math.min(self:get_content_height(), config.max_height) + vim.api.nvim_win_set_height(winnr, height) +end + +-- todo: fix nvim_win_text_height +-- @return number +function win:get_content_height() + if not self:is_open() then return 0 end + return vim.api.nvim_win_text_height(self:get_win(), {}).all +end + +--- Gets the size of the borders around the window +--- @return { vertical: number, horizontal: number, left: number, right: number, top: number, bottom: number } +function win:get_border_size() + if not self:is_open() then return { vertical = 0, horizontal = 0, left = 0, right = 0, top = 0, bottom = 0 } end + + local left = 0 + local right = 0 + local top = 0 + local bottom = 0 + + local border = self.config.border + if border == 'padded' then + left = 1 + right = 1 + elseif border == 'shadow' then + right = 1 + bottom = 1 + elseif type(border) == 'string' and border ~= 'none' then + left = 1 + right = 1 + top = 1 + bottom = 1 + elseif type(border) == 'table' then + -- borders can be a table of strings and act differently with different # of chars + -- so we normalize it: https://neovim.io/doc/user/api.html#nvim_open_win() + -- based on nvim-cmp + -- TODO: doesn't handle scrollbar + local resolved_border = {} + while #resolved_border <= 8 do + for _, b in ipairs(border) do + table.insert(resolved_border, type(b) == 'string' and b or b[1]) + end + end + + top = resolved_border[2] == '' and 0 or 1 + bottom = resolved_border[6] == '' and 0 or 1 + left = resolved_border[8] == '' and 0 or 1 + right = resolved_border[4] == '' and 0 or 1 + end + + if self.scrollbar and self.scrollbar:is_visible() then + local offset = (border == 'none' or border == 'padded') and 1 or 0 + right = right + offset + end + + return { vertical = top + bottom, horizontal = left + right, left = left, right = right, top = top, bottom = bottom } +end + +--- Gets the height of the window, taking into account the border +function win:get_height() + if not self:is_open() then return 0 end + return vim.api.nvim_win_get_height(self:get_win()) + self:get_border_size().vertical +end + +--- Gets the width of the longest line in the window +function win:get_content_width() + if not self:is_open() then return 0 end + local max_width = 0 + for _, line in ipairs(vim.api.nvim_buf_get_lines(self.buf, 0, -1, false)) do + max_width = math.max(max_width, vim.api.nvim_strwidth(line)) + end + return max_width +end + +--- Gets the width of the window, taking into account the border +function win:get_width() + if not self:is_open() then return 0 end + return vim.api.nvim_win_get_width(self:get_win()) + self:get_border_size().horizontal +end + +--- Gets the cursor's distance from all sides of the screen +function win.get_cursor_screen_position() + local screen_height = vim.o.lines + local screen_width = vim.o.columns + + -- command line + if vim.api.nvim_get_mode().mode == 'c' then + local config = require('blink.cmp.config').completion.menu + local cmdline_position = config.cmdline_position() + + return { + distance_from_top = cmdline_position[1], + distance_from_bottom = screen_height - cmdline_position[1] - 1, + distance_from_left = cmdline_position[2], + distance_from_right = screen_width - cmdline_position[2], + } + end + + -- default + local cursor_line, cursor_column = unpack(vim.api.nvim_win_get_cursor(0)) + -- todo: convert cursor_column to byte index + local pos = vim.fn.screenpos(vim.api.nvim_win_get_number(0), cursor_line, cursor_column) + + return { + distance_from_top = pos.row - 1, + distance_from_bottom = screen_height - pos.row, + distance_from_left = pos.col, + distance_from_right = screen_width - pos.col, + } +end + +function win:set_cursor(cursor) + local winnr = self:get_win() + assert(winnr ~= nil, 'Window must be open to set cursor') + + vim.api.nvim_win_set_cursor(winnr, cursor) + + if self.scrollbar then self.scrollbar:update(winnr) end + self:redraw_if_needed() +end + +function win:set_height(height) + local winnr = self:get_win() + assert(winnr ~= nil, 'Window must be open to set height') + + vim.api.nvim_win_set_height(winnr, height) + + if self.scrollbar then self.scrollbar:update(winnr) end + self:redraw_if_needed() +end + +function win:set_width(width) + local winnr = self:get_win() + assert(winnr ~= nil, 'Window must be open to set width') + + vim.api.nvim_win_set_width(winnr, width) + + if self.scrollbar then self.scrollbar:update(winnr) end + self:redraw_if_needed() +end + +function win:set_win_config(config) + local winnr = self:get_win() + assert(winnr ~= nil, 'Window must be open to set window config') + + vim.api.nvim_win_set_config(winnr, config) + + if self.scrollbar then self.scrollbar:update(winnr) end + self:redraw_if_needed() +end + +--- Gets the direction with the most space available, prioritizing the directions in the order of the +--- direction_priority list +function win:get_vertical_direction_and_height(direction_priority) + local constraints = self.get_cursor_screen_position() + local max_height = self:get_height() + local border_size = self:get_border_size() + local function get_distance(direction) + return direction == 's' and constraints.distance_from_bottom or constraints.distance_from_top + end + + local direction_priority_by_space = vim.fn.sort(vim.deepcopy(direction_priority), function(a, b) + local distance_a = math.min(max_height, get_distance(a)) + local distance_b = math.min(max_height, get_distance(b)) + return (distance_a < distance_b) and 1 or (distance_a > distance_b) and -1 or 0 + end) + + local direction = direction_priority_by_space[1] + local height = math.min(max_height, get_distance(direction)) + if height <= border_size.vertical then return end + return { height = height - border_size.vertical, direction = direction } +end + +function win:get_direction_with_window_constraints(anchor_win, direction_priority, desired_min_size) + local cursor_constraints = self.get_cursor_screen_position() + + -- nvim.api.nvim_win_get_position doesn't return the correct position most of the time + -- so we calculate the position ourselves + local anchor_config + local anchor_win_config = vim.api.nvim_win_get_config(anchor_win:get_win()) + if anchor_win_config.relative == 'win' then + local anchor_relative_win_position = vim.api.nvim_win_get_position(anchor_win_config.win) + anchor_config = { + row = anchor_win_config.row + anchor_relative_win_position[1] + 1, + col = anchor_win_config.col + anchor_relative_win_position[2] + 1, + } + elseif anchor_win_config.relative == 'editor' then + anchor_config = { + row = anchor_win_config.row + 1, + col = anchor_win_config.col + 1, + } + end + assert(anchor_config ~= nil, 'The anchor window must be relative to a window or the editor') + + -- compensate for the anchor window being too wide given the screen width and configured column + if anchor_config.col + anchor_win_config.width > vim.o.columns then + anchor_config.col = vim.o.columns - anchor_win_config.width + end + + local anchor_border_size = anchor_win:get_border_size() + local anchor_col = anchor_config.col - anchor_border_size.left + local anchor_row = anchor_config.row - anchor_border_size.top + local anchor_height = anchor_win:get_height() + local anchor_width = anchor_win:get_width() + + -- we want to avoid covering the cursor line, so we need to get the direction of the window + -- that we're anchoring against + local cursor_screen_row = vim.api.nvim_get_mode().mode == 'c' and vim.o.lines - 1 or vim.fn.winline() + local anchor_is_above_cursor = anchor_config.row - cursor_screen_row < 0 + + local screen_height = vim.o.lines + local screen_width = vim.o.columns + + local direction_constraints = { + n = { + vertical = anchor_is_above_cursor and (anchor_row - 1) or cursor_constraints.distance_from_top, + horizontal = screen_width - (anchor_col - 1), + }, + s = { + vertical = anchor_is_above_cursor and cursor_constraints.distance_from_bottom + or (screen_height - (anchor_height + anchor_row - 1 + anchor_border_size.vertical)), + horizontal = screen_width - (anchor_col - 1), + }, + e = { + vertical = anchor_is_above_cursor and cursor_constraints.distance_from_top + or cursor_constraints.distance_from_bottom, + horizontal = screen_width - (anchor_col - 1) - anchor_width - anchor_border_size.right, + }, + w = { + vertical = anchor_is_above_cursor and cursor_constraints.distance_from_top + or cursor_constraints.distance_from_bottom, + horizontal = anchor_col - 1 + anchor_border_size.left, + }, + } + + local max_height = self:get_height() + local max_width = self:get_width() + local direction_priority_by_space = vim.fn.sort(vim.deepcopy(direction_priority), function(a, b) + local constraints_a = direction_constraints[a] + local constraints_b = direction_constraints[b] + + local is_desired_a = desired_min_size.height <= constraints_a.vertical + and desired_min_size.width <= constraints_a.horizontal + local is_desired_b = desired_min_size.height <= constraints_b.vertical + and desired_min_size.width <= constraints_b.horizontal + + -- If both have desired size, preserve original priority + if is_desired_a and is_desired_b then return 0 end + + -- prioritize "a" if it has the desired size and "b" doesn't + if is_desired_a then return -1 end + + -- prioritize "b" if it has the desired size and "a" doesn't + if is_desired_b then return 1 end + + -- neither have the desired size, so pick based on which has the most space + local distance_a = math.min(max_height, constraints_a.vertical, constraints_a.horizontal) + local distance_b = math.min(max_height, constraints_b.vertical, constraints_b.horizontal) + return distance_a < distance_b and 1 or distance_a > distance_b and -1 or 0 + end) + + local border_size = self:get_border_size() + local direction = direction_priority_by_space[1] + local height = math.min(max_height, direction_constraints[direction].vertical) + if height <= border_size.vertical then return end + local width = math.min(max_width, direction_constraints[direction].horizontal) + if width <= border_size.horizontal then return end + + return { + width = width - border_size.horizontal, + height = height - border_size.vertical, + direction = direction, + } +end + +--- In cmdline mode, the window won't be redrawn automatically so we redraw ourselves on schedule +function win:redraw_if_needed() + if self.redraw_queued or vim.api.nvim_get_mode().mode ~= 'c' or self:get_win() == nil then return end + + -- We redraw on schedule to avoid the cmdline disappearing during redraw + -- and to batch multiple redraws together + self.redraw_queued = true + vim.schedule(function() + self.redraw_queued = false + vim.api.nvim__redraw({ win = self:get_win(), flush = true }) + end) +end + +return win diff --git a/lua/blink/cmp/lib/window/scrollbar/geometry.lua b/lua/blink/cmp/lib/window/scrollbar/geometry.lua new file mode 100644 index 0000000..ad481a0 --- /dev/null +++ b/lua/blink/cmp/lib/window/scrollbar/geometry.lua @@ -0,0 +1,92 @@ +--- Helper for calculating placement of the scrollbar thumb and gutter + +--- @class blink.cmp.ScrollbarGeometry +--- @field width number +--- @field height number +--- @field row number +--- @field col number +--- @field zindex number +--- @field relative string +--- @field win number + +local M = {} + +--- @param target_win number +--- @return number +local function get_win_buf_height(target_win) + local buf = vim.api.nvim_win_get_buf(target_win) + + -- not wrapping, so just get the line count + if not vim.wo[target_win].wrap then return vim.api.nvim_buf_line_count(buf) end + + local width = vim.api.nvim_win_get_width(target_win) + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + local height = 0 + for _, l in ipairs(lines) do + height = height + math.max(1, (math.ceil(vim.fn.strwidth(l) / width))) + end + return height +end + +--- @param border string|string[] +--- @return number +local function get_col_offset(border) + -- we only need an extra offset when working with a padded window + if type(border) == 'table' and border[1] == ' ' and border[4] == ' ' and border[7] == ' ' and border[8] == ' ' then + return 1 + end + return 0 +end + +--- Gets the starting line, handling line wrapping if enabled +--- @param target_win number +--- @param width number +--- @return number +local get_content_start_line = function(target_win, width) + local start_line = math.max(1, vim.fn.line('w0', target_win)) + if not vim.wo[target_win].wrap then return start_line end + + local bufnr = vim.api.nvim_win_get_buf(target_win) + local wrapped_start_line = 1 + for _, text in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, start_line - 1, false)) do + -- nvim_buf_get_lines sometimes returns a blob. see hrsh7th/nvim-cmp#2050 + if vim.fn.type(text) == vim.v.t_blob then text = vim.fn.string(text) end + wrapped_start_line = wrapped_start_line + math.max(1, math.ceil(vim.fn.strdisplaywidth(text) / width)) + end + return wrapped_start_line +end + +--- @param target_win number +--- @return { should_hide: boolean, thumb: blink.cmp.ScrollbarGeometry, gutter: blink.cmp.ScrollbarGeometry } +function M.get_geometry(target_win) + local config = vim.api.nvim_win_get_config(target_win) + local width = config.width + local height = config.height + local zindex = config.zindex + + local buf_height = get_win_buf_height(target_win) + local thumb_height = math.max(1, math.floor(height * height / buf_height + 0.5) - 1) + + local start_line = get_content_start_line(target_win, width or 1) + + local pct = (start_line - 1) / (buf_height - height) + local thumb_offset = math.floor((pct * (height - thumb_height)) + 0.5) + thumb_height = thumb_offset + thumb_height > height and height - thumb_offset or thumb_height + thumb_height = math.max(1, thumb_height) + + local common_geometry = { + width = 1, + row = thumb_offset, + col = width + get_col_offset(config.border), + relative = 'win', + win = target_win, + } + + return { + should_hide = height >= buf_height, + thumb = vim.tbl_deep_extend('force', common_geometry, { height = thumb_height, zindex = zindex + 2 }), + gutter = vim.tbl_deep_extend('force', common_geometry, { row = 0, height = height, zindex = zindex + 1 }), + } +end + +return M diff --git a/lua/blink/cmp/lib/window/scrollbar/init.lua b/lua/blink/cmp/lib/window/scrollbar/init.lua new file mode 100644 index 0000000..c72615a --- /dev/null +++ b/lua/blink/cmp/lib/window/scrollbar/init.lua @@ -0,0 +1,37 @@ +-- TODO: move the set_config and set_height calls from the menu/documentation/signature files +-- to helpers in the window lib, and call scrollbar updates from there. This way, consumers of +-- the window lib don't need to worry about scrollbars + +--- @class blink.cmp.ScrollbarConfig +--- @field enable_gutter boolean + +--- @class blink.cmp.Scrollbar +--- @field win blink.cmp.ScrollbarWin +--- +--- @field new fun(opts: blink.cmp.ScrollbarConfig): blink.cmp.Scrollbar +--- @field is_visible fun(self: blink.cmp.Scrollbar): boolean +--- @field update fun(self: blink.cmp.Scrollbar, target_win: number | nil) + +--- @type blink.cmp.Scrollbar +--- @diagnostic disable-next-line: missing-fields +local scrollbar = {} + +function scrollbar.new(opts) + local self = setmetatable({}, { __index = scrollbar }) + self.win = require('blink.cmp.lib.window.scrollbar.win').new(opts) + return self +end + +function scrollbar:is_visible() return self.win:is_visible() end + +function scrollbar:update(target_win) + if target_win == nil or not vim.api.nvim_win_is_valid(target_win) then return self.win:hide() end + + local geometry = require('blink.cmp.lib.window.scrollbar.geometry').get_geometry(target_win) + if geometry.should_hide then return self.win:hide() end + + self.win:show_thumb(geometry.thumb) + self.win:show_gutter(geometry.gutter) +end + +return scrollbar diff --git a/lua/blink/cmp/lib/window/scrollbar/win.lua b/lua/blink/cmp/lib/window/scrollbar/win.lua new file mode 100644 index 0000000..9ac3193 --- /dev/null +++ b/lua/blink/cmp/lib/window/scrollbar/win.lua @@ -0,0 +1,107 @@ +--- Manages creating/updating scrollbar gutter and thumb windows + +--- @class blink.cmp.ScrollbarWin +--- @field enable_gutter boolean +--- @field thumb_win? number +--- @field gutter_win? number +--- @field buf? number +--- +--- @field new fun(opts: blink.cmp.ScrollbarConfig): blink.cmp.ScrollbarWin +--- @field is_visible fun(self: blink.cmp.ScrollbarWin): boolean +--- @field show_thumb fun(self: blink.cmp.ScrollbarWin, geometry: blink.cmp.ScrollbarGeometry) +--- @field show_gutter fun(self: blink.cmp.ScrollbarWin, geometry: blink.cmp.ScrollbarGeometry) +--- @field hide_thumb fun(self: blink.cmp.ScrollbarWin) +--- @field hide_gutter fun(self: blink.cmp.ScrollbarWin) +--- @field hide fun(self: blink.cmp.ScrollbarWin) +--- @field _make_win fun(self: blink.cmp.ScrollbarWin, geometry: blink.cmp.ScrollbarGeometry, hl_group: string): number +--- @field redraw_if_needed fun(self: blink.cmp.ScrollbarWin) + +--- @type blink.cmp.ScrollbarWin +--- @diagnostic disable-next-line: missing-fields +local scrollbar_win = {} + +function scrollbar_win.new(opts) return setmetatable(opts, { __index = scrollbar_win }) end + +function scrollbar_win:is_visible() return self.thumb_win ~= nil and vim.api.nvim_win_is_valid(self.thumb_win) end + +function scrollbar_win:show_thumb(geometry) + -- create window if it doesn't exist + if self.thumb_win == nil or not vim.api.nvim_win_is_valid(self.thumb_win) then + self.thumb_win = self:_make_win(geometry, 'BlinkCmpScrollBarThumb') + else + -- update with the geometry + local thumb_existing_config = vim.api.nvim_win_get_config(self.thumb_win) + local thumb_config = vim.tbl_deep_extend('force', thumb_existing_config, geometry) + vim.api.nvim_win_set_config(self.thumb_win, thumb_config) + end + + self:redraw_if_needed() +end + +function scrollbar_win:show_gutter(geometry) + if not self.enable_gutter then return end + + -- create window if it doesn't exist + if self.gutter_win == nil or not vim.api.nvim_win_is_valid(self.gutter_win) then + self.gutter_win = self:_make_win(geometry, 'BlinkCmpScrollBarGutter') + else + -- update with the geometry + local gutter_existing_config = vim.api.nvim_win_get_config(self.gutter_win) + local gutter_config = vim.tbl_deep_extend('force', gutter_existing_config, geometry) + vim.api.nvim_win_set_config(self.gutter_win, gutter_config) + end + + self:redraw_if_needed() +end + +function scrollbar_win:hide_thumb() + if self.thumb_win and vim.api.nvim_win_is_valid(self.thumb_win) then + vim.api.nvim_win_close(self.thumb_win, true) + self.thumb_win = nil + self:redraw_if_needed() + end +end + +function scrollbar_win:hide_gutter() + if self.gutter_win and vim.api.nvim_win_is_valid(self.gutter_win) then + vim.api.nvim_win_close(self.gutter_win, true) + self.gutter_win = nil + self:redraw_if_needed() + end +end + +function scrollbar_win:hide() + self:hide_thumb() + self:hide_gutter() +end + +function scrollbar_win:_make_win(geometry, hl_group) + if self.buf == nil or not vim.api.nvim_buf_is_valid(self.buf) then self.buf = vim.api.nvim_create_buf(false, true) end + + local win_config = vim.tbl_deep_extend('force', geometry, { + style = 'minimal', + focusable = false, + noautocmd = true, + }) + local win = vim.api.nvim_open_win(self.buf, false, win_config) + vim.api.nvim_set_option_value('winhighlight', 'Normal:' .. hl_group .. ',EndOfBuffer:' .. hl_group, { win = win }) + return win +end + +local redraw_queued = false +function scrollbar_win:redraw_if_needed() + if redraw_queued or vim.api.nvim_get_mode().mode ~= 'c' then return end + + redraw_queued = true + vim.schedule(function() + redraw_queued = false + if self.gutter_win ~= nil and vim.api.nvim_win_is_valid(self.gutter_win) then + vim.api.nvim__redraw({ win = self.gutter_win, flush = true }) + end + if self.thumb_win ~= nil and vim.api.nvim_win_is_valid(self.thumb_win) then + vim.api.nvim__redraw({ win = self.thumb_win, flush = true }) + end + end) +end + +return scrollbar_win |
