From ada9ddeff71e82ad0e52c9a280a1e315a8810b9a Mon Sep 17 00:00:00 2001 From: hrsh7th Date: Fri, 8 Oct 2021 18:27:33 +0900 Subject: [PATCH] Use floating window for completion menus (#224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP * WIP * Fix #226 * Insert text * Emulate vim native * テキトウ * Tekito * Move scrollbar impl * aaa * Ignore unexpected event * fix * fix scroll * Refactor (conflict...) * Fix bug * Positive integer * Refactor a bit * Fix for pumheight=0 * fx * Improve matching highlight * Improve colorscheme handling * fmt * Add cmp.visible * Fix pum pos * ABBR_MARGIN * Fix cel calculation * up * refactor * fix * a * a * compat * Remove current completion state * Fix ghost text * Add feature toggle * highlight customization * Update * Add breaking change announcement * Add README.md * Remove unused function * extmark ephemeral ghost text * Support native comp * Fix docs pos * a * Remove if native menu visible * theme async * Improvement idea: option to disables insert on select item (#240) * use ghost text instead of insertion on prev/next item * add disables_insert_on_selection option * move disable_insert_on_select option as argumet on * update README * use an enum behavior to disable insert on select * Adopt contribution * Preselect * Improve * Change configuration option * a * Improve * Improve * Implement proper behavior to native/custom * Support maybe * Improve docs view * Improve * Avoid syntax leak * TODO: refactor * Fix * Revert win pos * fmt * ghost text remaining * Don't use italic by default * bottom * dedup by label * Ignore events * up * Hacky native view partial support * up * perf * improve * more cache * fmt * Fix format option * fmt * recheck * Fix * Improve * Improve * compat * implement redraw * improve * up * fmt/lint * immediate ghost text * source timeout * up * Support multibyte * disable highlight * up * improve * fmt * fmt * fix * fix * up * up * Use screenpos * Add undojoin check * Fix height * matcher bug * Fix dot-repeat * Remove undojoin * macro * Support dot-repeat * MacroSafe * Default item count is 200 * fmt Co-authored-by: Eric Puentes --- README.md | 64 ++++-- autoload/vital/_cmp/VS/Vim/Buffer.vim | 2 +- lua/cmp/config/default.lua | 49 ++++- lua/cmp/config/mapping.lua | 14 +- lua/cmp/context.lua | 12 -- lua/cmp/core.lua | 286 +++++++++++-------------- lua/cmp/entry.lua | 59 ++++-- lua/cmp/float.lua | 138 ------------ lua/cmp/init.lua | 82 ++++--- lua/cmp/matcher.lua | 77 +++++-- lua/cmp/matcher_spec.lua | 11 +- lua/cmp/menu.lua | 232 -------------------- lua/cmp/source.lua | 67 +++--- lua/cmp/types/cmp.lua | 10 +- lua/cmp/utils/async.lua | 38 +++- lua/cmp/utils/binary.lua | 33 +++ lua/cmp/utils/binary_spec.lua | 28 +++ lua/cmp/utils/cache.lua | 12 +- lua/cmp/utils/event.lua | 51 +++++ lua/cmp/utils/highlight.lua | 46 ++++ lua/cmp/utils/keymap.lua | 6 +- lua/cmp/utils/misc.lua | 2 +- lua/cmp/utils/str.lua | 31 +-- lua/cmp/utils/window.lua | 256 ++++++++++++++++++++++ lua/cmp/view.lua | 206 ++++++++++++++++++ lua/cmp/view/custom_entries_view.lua | 295 ++++++++++++++++++++++++++ lua/cmp/view/docs_view.lua | 126 +++++++++++ lua/cmp/view/ghost_text_view.lua | 72 +++++++ lua/cmp/view/native_entries_view.lua | 152 +++++++++++++ lua/cmp/vim_source.lua | 6 +- plugin/cmp.lua | 59 +++++- 31 files changed, 1803 insertions(+), 719 deletions(-) delete mode 100644 lua/cmp/float.lua delete mode 100644 lua/cmp/menu.lua create mode 100644 lua/cmp/utils/binary.lua create mode 100644 lua/cmp/utils/binary_spec.lua create mode 100644 lua/cmp/utils/event.lua create mode 100644 lua/cmp/utils/highlight.lua create mode 100644 lua/cmp/utils/window.lua create mode 100644 lua/cmp/view.lua create mode 100644 lua/cmp/view/custom_entries_view.lua create mode 100644 lua/cmp/view/docs_view.lua create mode 100644 lua/cmp/view/ghost_text_view.lua create mode 100644 lua/cmp/view/native_entries_view.lua diff --git a/README.md b/README.md index 75f90f8c6..ab552ea59 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,11 @@ A completion engine plugin for neovim written in Lua. Completion sources are installed from external repositories and "sourced". +Readme! +==================== + +nvim-cmp's breaking change are [here](https://github.com/hrsh7th/nvim-cmp/issues/231). + Status ==================== @@ -67,7 +72,7 @@ lua <'] = cmp.mapping.select_next_item({ behavior = cmp.SelectBehavior.Insert }), + [''] = cmp.mapping.select_prev_item({ behavior = cmp.SelectBehavior.Insert }), + [''] = cmp.mapping.select_next_item({ behavior = cmp.SelectBehavior.Select }), + [''] = cmp.mapping.select_prev_item({ behavior = cmp.SelectBehavior.Select }), [''] = cmp.mapping.scroll_docs(-4), [''] = cmp.mapping.scroll_docs(4), [''] = cmp.mapping.complete(), @@ -344,14 +353,6 @@ The documentation window's max width. The documentation window's max height. -#### formatting.deprecated (type: boolean) - -Specify deprecated candidate should be marked as deprecated or not. - -This option is useful but disabled by default because sometimes, this option can break your terminal appearance. - -Default: `false` - #### formatting.format (type: fun(entry: cmp.Entry, vim_item: vim.CompletedItem): vim.CompletedItem) A function to customize completion menu. @@ -375,12 +376,17 @@ cmp.setup { A callback function called when the item is confirmed. -#### experimental.ghost_text (type: boolean) +#### experimental.native_menu (type: boolean) -Specify whether to display ghost text. +Use vim's native completion menu instead of custom floating menu. Default: `false` +#### experimental.ghost_text (type: cmp.GhostTextConfig | false) + +Specify whether to display ghost text. + +Default: `false` Commands ==================== @@ -396,6 +402,34 @@ Autocmds Invoke after nvim-cmp setup. +Highlights +==================== + +※ The following highlights are used only when enabling `experimental.cusom_menu = true`. + +#### `CmpItemAbbr` + +The abbr field. + +#### `CmpItemAbbrDeprecated` + +The deprecated item's abbr field. + +#### `CmpItemAbbrMatch` + +The matched characters highlight. + +#### `CmpItemAbbrMatchFuzzy` + +The fuzzy matched characters highlight. + +#### `CmpItemKind` + +The kind field. + +#### `CmpItemMenu` + +The menu field. Programatic API ==================== @@ -418,11 +452,11 @@ Close current completion menu. Close current completion menu and restore current line (similar to native `` behavior). -#### `cmp.select_next_item()` +#### `cmp.select_next_item({ cmp.SelectBehavior.{Insert,Select} })` Select next completion item if possible. -#### `cmp.select_prev_item()` +#### `cmp.select_prev_item({ cmp.SelectBehavior.{Insert,Select} })` Select prev completion item if possible. diff --git a/autoload/vital/_cmp/VS/Vim/Buffer.vim b/autoload/vital/_cmp/VS/Vim/Buffer.vim index f5ace6f3a..df58dcd8d 100644 --- a/autoload/vital/_cmp/VS/Vim/Buffer.vim +++ b/autoload/vital/_cmp/VS/Vim/Buffer.vim @@ -49,7 +49,7 @@ endfunction function! s:ensure(expr) abort if !bufexists(a:expr) if type(a:expr) == type(0) - throw printf('VS.Vim.Buffer: `%s` is not valid expr.', l:bufnr) + throw printf('VS.Vim.Buffer: `%s` is not valid expr.', a:expr) endif badd `=a:expr` endif diff --git a/lua/cmp/config/default.lua b/lua/cmp/config/default.lua index 224557638..111b33d7a 100644 --- a/lua/cmp/config/default.lua +++ b/lua/cmp/config/default.lua @@ -1,4 +1,5 @@ local compare = require('cmp.config.compare') +local mapping = require('cmp.config.mapping') local types = require('cmp.types') local WIDE_HEIGHT = 40 @@ -46,28 +47,58 @@ return function() sorting = { priority_weight = 2, comparators = { - compare.offset, - compare.exact, - compare.score, - compare.kind, - compare.sort_text, - compare.length, - compare.order, + function(e1, e2) + local diff + diff = compare.offset(e1, e2) + if diff ~= nil then + return diff + end + diff = compare.exact(e1, e2) + if diff ~= nil then + return diff + end + diff = compare.score(e1, e2) + if diff ~= nil then + return diff + end + diff = compare.kind(e1, e2) + if diff ~= nil then + return diff + end + diff = compare.sort_text(e1, e2) + if diff ~= nil then + return diff + end + diff = compare.length(e1, e2) + if diff ~= nil then + return diff + end + return compare.order(e1, e2) + end, }, }, event = {}, - mapping = {}, + mapping = { + [''] = mapping.select_next_item({ behavior = types.cmp.SelectBehavior.Select }), + [''] = mapping.select_prev_item({ behavior = types.cmp.SelectBehavior.Select }), + [''] = mapping.select_next_item({ behavior = types.cmp.SelectBehavior.Insert }), + [''] = mapping.select_prev_item({ behavior = types.cmp.SelectBehavior.Insert }), + [''] = function(fallback) + require('cmp').close() + fallback() + end, + }, formatting = { - deprecated = false, format = function(_, vim_item) return vim_item end, }, experimental = { + native_menu = false, ghost_text = false, }, diff --git a/lua/cmp/config/mapping.lua b/lua/cmp/config/mapping.lua index b18503459..1732b1262 100644 --- a/lua/cmp/config/mapping.lua +++ b/lua/cmp/config/mapping.lua @@ -1,5 +1,3 @@ -local misc = require('cmp.utils.misc') - local mapping = setmetatable({}, { __call = function(_, invoke, modes) return { @@ -10,6 +8,7 @@ local mapping = setmetatable({}, { } end, }) + ---Invoke completion mapping.complete = function() return function(fallback) @@ -45,27 +44,24 @@ mapping.scroll_docs = function(delta) end end end -mapping.scroll = misc.deprecated(mapping.scroll_docs, '`cmp.mapping.scroll` is deprecated. Please change it to `cmp.mapping.scroll_docs` instead.') ---Select next completion item. -mapping.select_next_item = function() +mapping.select_next_item = function(option) return function(fallback) - if not require('cmp').select_next_item() then + if not require('cmp').select_next_item(option) then fallback() end end end -mapping.next_item = misc.deprecated(mapping.select_next_item, '`cmp.mapping.next_item` is deprecated. Please change it to `cmp.mapping.select_next_item` instead.') ---Select prev completion item. -mapping.select_prev_item = function() +mapping.select_prev_item = function(option) return function(fallback) - if not require('cmp').select_prev_item() then + if not require('cmp').select_prev_item(option) then fallback() end end end -mapping.prev_item = misc.deprecated(mapping.select_prev_item, '`cmp.mapping.prev_item` is deprecated. Please change it to `cmp.mapping.select_prev_item` instead.') ---Confirm selection mapping.confirm = function(option) diff --git a/lua/cmp/context.lua b/lua/cmp/context.lua index c7de4e5d2..bd1856542 100644 --- a/lua/cmp/context.lua +++ b/lua/cmp/context.lua @@ -8,8 +8,6 @@ local cache = require('cmp.utils.cache') ---@field public cache cmp.Cache ---@field public prev_context cmp.Context ---@field public option cmp.ContextOption ----@field public pumvisible boolean ----@field public pumselect boolean ---@field public filetype string ---@field public time number ---@field public mode string @@ -41,13 +39,10 @@ context.new = function(prev_context, option) option = option or {} local self = setmetatable({}, { __index = context }) - local completeinfo = vim.fn.complete_info({ 'selected', 'mode', 'pum_visible' }) self.id = misc.id('context') self.cache = cache.new() self.prev_context = prev_context or context.empty() self.option = option or { reason = types.cmp.ContextReason.None } - self.pumvisible = completeinfo.pum_visible ~= 0 - self.pumselect = completeinfo.selected ~= -1 self.filetype = vim.api.nvim_buf_get_option(0, 'filetype') self.time = vim.loop.now() self.mode = vim.api.nvim_get_mode().mode @@ -110,13 +105,6 @@ end context.changed = function(self, ctx) local curr = self - if self.pumvisible then - local completed_item = vim.v.completed_item or {} - if completed_item.word then - return false - end - end - if curr.bufnr ~= ctx.bufnr then return true end diff --git a/lua/cmp/core.lua b/lua/cmp/core.lua index 67a53463e..5a7143824 100644 --- a/lua/cmp/core.lua +++ b/lua/cmp/core.lua @@ -1,137 +1,92 @@ local debug = require('cmp.utils.debug') local char = require('cmp.utils.char') -local str = require('cmp.utils.str') local pattern = require('cmp.utils.pattern') local async = require('cmp.utils.async') local keymap = require('cmp.utils.keymap') local context = require('cmp.context') local source = require('cmp.source') -local menu = require('cmp.menu') +local view = require('cmp.view') local misc = require('cmp.utils.misc') local config = require('cmp.config') local types = require('cmp.types') +local SOURCE_TIMEOUT = 500 +local THROTTLE_TIME = 120 +local DEBOUNCE_TIME = 20 + ---@class cmp.Core +---@field public suspending boolean +---@field public view cmp.View +---@field public sources cmp.Source[] +---@field public sources_by_name table +---@field public context cmp.Context local core = {} -core.SOURCE_TIMEOUT = 500 -core.THROTTLE_TIME = 80 - ----Suspending state. -core.suspending = false - -core.GHOST_TEXT_NS = vim.api.nvim_create_namespace('cmp:GHOST_TEXT') - ----@type cmp.Menu -core.menu = menu.new({ - on_select = function(e) - for _, c in ipairs(config.get().confirmation.get_commit_characters(e:get_commit_characters())) do - keymap.listen('i', c, core.on_keymap) - end - core.ghost_text(e) - end, -}) - ----Show ghost text if possible ----@param e cmp.Entry -core.ghost_text = function(e) - vim.api.nvim_buf_clear_namespace(0, core.GHOST_TEXT_NS, 0, -1) - - local c = config.get().experimental.ghost_text - if not c then - return - end - - if not e then - return - end - - local ctx = context.new() - if ctx.cursor_after_line ~= '' then - return - end - - local diff = ctx.cursor.col - e:get_offset() - local text = e:get_insert_text() - if e.completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then - text = vim.lsp.util.parse_snippet(text) - end - text = string.sub(str.oneline(text), diff + 1) - if #text > 0 then - vim.api.nvim_buf_set_extmark(ctx.bufnr, core.GHOST_TEXT_NS, ctx.cursor.row - 1, ctx.cursor.col - 1, { - right_gravity = false, - virt_text = { { text, c.hl_group or 'Comment' } }, - virt_text_pos = 'overlay', - hl_mode = 'combine', - priority = 1, - }) - end +core.new = function() + local self = setmetatable({}, { __index = core }) + self.suspending = false + self.sources = {} + self.sources_by_name = {} + self.context = context.new() + self.view = view.new() + self.view.event:on('keymap', function(...) + self:on_keymap(...) + end) + return self end ----@type table -core.sources = {} - ----@type table -core.sources_by_name = {} - ----@type cmp.Context -core.context = context.new() - ---Register source ---@param s cmp.Source -core.register_source = function(s) - core.sources[s.id] = s - if not core.sources_by_name[s.name] then - core.sources_by_name[s.name] = {} - end - table.insert(core.sources_by_name[s.name], s) - if misc.is_insert_mode() then - core.complete(core.get_context({ reason = types.cmp.ContextReason.Auto })) +core.register_source = function(self, s) + self.sources[s.id] = s + if not self.sources_by_name[s.name] then + self.sources_by_name[s.name] = {} end + table.insert(self.sources_by_name[s.name], s) end ---Unregister source ---@param source_id string -core.unregister_source = function(source_id) - local name = core.sources[source_id].name - core.sources_by_name[name] = vim.tbl_filter(function(s) +core.unregister_source = function(self, source_id) + local name = self.sources[source_id].name + self.sources_by_name[name] = vim.tbl_filter(function(s) return s.id ~= source_id - end, core.sources_by_name[name]) - core.sources[source_id] = nil + end, self.sources_by_name[name]) + self.sources[source_id] = nil end ---Get new context ---@param option cmp.ContextOption ---@return cmp.Context -core.get_context = function(option) - local prev = core.context:clone() +core.get_context = function(self, option) + local prev = self.context:clone() prev.prev_context = nil local ctx = context.new(prev, option) - core.set_context(ctx) - return core.context + self:set_context(ctx) + return self.context end ---Set new context ---@param ctx cmp.Context -core.set_context = function(ctx) - core.context = ctx +core.set_context = function(self, ctx) + self.context = ctx end ---Suspend completion -core.suspend = function() - core.suspending = true +core.suspend = function(self) + self.suspending = true return function() - core.suspending = false + self.suspending = false end end ---Get sources that sorted by priority ---@param statuses cmp.SourceStatus[] ---@return cmp.Source[] -core.get_sources = function(statuses) +core.get_sources = function(self, statuses) local sources = {} for _, c in pairs(config.get().sources) do - for _, s in ipairs(core.sources_by_name[c.name] or {}) do + for _, s in ipairs(self.sources_by_name[c.name] or {}) do if not statuses or vim.tbl_contains(statuses, s.status) then if s:is_available() then table.insert(sources, s) @@ -143,7 +98,7 @@ core.get_sources = function(statuses) end ---Keypress handler -core.on_keymap = function(keys, fallback) +core.on_keymap = function(self, keys, fallback) for key, action in pairs(config.get().mapping) do if keymap.equals(key, keys) then if type(action) == 'function' then @@ -157,18 +112,18 @@ core.on_keymap = function(keys, fallback) --Commit character. NOTE: This has a lot of cmp specific implementation to make more user-friendly. local chars = keymap.t(keys) - local e = core.menu:get_selected_entry() + local e = self.view:get_active_entry() if e and vim.tbl_contains(config.get().confirmation.get_commit_characters(e:get_commit_characters()), chars) then local is_printable = char.is_printable(string.byte(chars, 1)) - core.confirm(e, { + self:confirm(e, { behavior = is_printable and 'insert' or 'replace', }, function() - local ctx = core.get_context() + local ctx = self:get_context() local word = e:get_word() if string.sub(ctx.cursor_before_line, -#word, ctx.cursor.col - 1) == word and is_printable then fallback() else - core.reset() + self:reset() end end) return @@ -178,7 +133,7 @@ core.on_keymap = function(keys, fallback) end ---Prepare completion -core.prepare = function() +core.prepare = function(self) for keys, action in pairs(config.get().mapping) do if type(action) == 'function' then action = { @@ -187,36 +142,37 @@ core.prepare = function() } end for _, mode in ipairs(action.modes) do - keymap.listen(mode, keys, core.on_keymap) + keymap.listen(mode, keys, function(...) + self:on_keymap(...) + end) end end end ---Check auto-completion -core.on_change = function(event) - if core.suspending then +core.on_change = function(self, event) + local ignore = false + ignore = ignore or self.suspending + ignore = ignore or (vim.fn.pumvisible() == 1 and (vim.v.completed_item).word) + ignore = ignore or not self.view:ready() + if ignore then + self:get_context({ reason = types.cmp.ContextReason.Auto }) return end - core.autoindent(event, function() - local ctx = core.get_context({ reason = types.cmp.ContextReason.Auto }) - - -- Skip autocompletion when the item is selected manually. - if ctx.pumvisible and not vim.tbl_isempty(vim.v.completed_item) then - return - end + self:autoindent(event, function() + local ctx = self:get_context({ reason = types.cmp.ContextReason.Auto }) debug.log(('ctx: `%s`'):format(ctx.cursor_before_line)) if ctx:changed(ctx.prev_context) then + self.view:redraw() debug.log('changed') - core.menu:restore(ctx) - core.ghost_text(core.menu:get_first_entry()) if vim.tbl_contains(config.get().completion.autocomplete or {}, event) then - core.complete(ctx) + self:complete(ctx) else - core.filter.timeout = core.THROTTLE_TIME - core.filter() + self.filter.timeout = THROTTLE_TIME + self:filter() end else debug.log('unchanged') @@ -227,25 +183,28 @@ end ---Check autoindent ---@param event cmp.TriggerEvent ---@param callback function -core.autoindent = function(event, callback) +core.autoindent = function(self, event, callback) if event == types.cmp.TriggerEvent.TextChanged then local cursor_before_line = misc.get_cursor_before_line() local prefix = pattern.matchstr('[^[:blank:]]\\+$', cursor_before_line) if prefix then for _, key in ipairs(vim.split(vim.bo.indentkeys, ',')) do if vim.tbl_contains({ '=' .. prefix, '0=' .. prefix }, key) then - return vim.schedule(function() + local release = self:suspend() + vim.schedule(function() if cursor_before_line == misc.get_cursor_before_line() then local indentkeys = vim.bo.indentkeys vim.bo.indentkeys = indentkeys .. ',!^F' keymap.feedkeys(keymap.t(''), 'n', function() vim.bo.indentkeys = indentkeys + release() callback() end) else callback() end end) + return end end end @@ -255,62 +214,71 @@ end ---Invoke completion ---@param ctx cmp.Context -core.complete = function(ctx) +core.complete = function(self, ctx) if not misc.is_insert_mode() then return end - - core.set_context(ctx) - - local callback = function() - local new = context.new(ctx) - if new:changed(new.prev_context) and ctx == core.context then - core.complete(new) - else - core.filter.timeout = core.THROTTLE_TIME - core.filter() - end - end - for _, s in ipairs(core.get_sources({ source.SourceStatus.WAITING, source.SourceStatus.COMPLETED })) do - s:complete(ctx, callback) + self:set_context(ctx) + + for _, s in ipairs(self:get_sources({ source.SourceStatus.WAITING, source.SourceStatus.COMPLETED })) do + s:complete( + ctx, + (function(src) + local callback + callback = function() + local new = context.new(ctx) + if new:changed(new.prev_context) and ctx == self.context then + src:complete(new, callback) + else + self.filter.stop() + self.filter.timeout = DEBOUNCE_TIME + self:filter() + end + end + return callback + end)(s) + ) end - core.filter.timeout = ctx.pumvisible and core.THROTTLE_TIME or 0 - core.filter() + self.filter.timeout = THROTTLE_TIME + self:filter() end ---Update completion menu -core.filter = async.throttle(function() - if not misc.is_insert_mode() then - return - end - local ctx = core.get_context() - - -- To wait for processing source for that's timeout. - local sources = {} - for _, s in ipairs(core.get_sources({ source.SourceStatus.FETCHING, source.SourceStatus.COMPLETED })) do - local time = core.SOURCE_TIMEOUT - s:get_fetching_time() - if not s.incomplete and time > 0 then - if #sources == 0 then - core.filter.stop() - core.filter.timeout = time + 1 - core.filter() - return +core.filter = async.throttle( + vim.schedule_wrap(function(self) + if not misc.is_insert_mode() then + return + end + local ctx = self:get_context() + + -- To wait for processing source for that's timeout. + local sources = {} + for _, s in ipairs(self:get_sources({ source.SourceStatus.FETCHING, source.SourceStatus.COMPLETED })) do + local time = SOURCE_TIMEOUT - s:get_fetching_time() + if not s.incomplete and time > 0 then + if #sources == 0 then + self.filter.stop() + self.filter.timeout = time + 1 + self:filter() + return + end + break end - break + table.insert(sources, s) end - table.insert(sources, s) - end + self.filter.timeout = THROTTLE_TIME - core.menu:update(ctx, sources) - core.ghost_text(core.menu:get_first_entry()) -end, core.THROTTLE_TIME) + self.view:open(ctx, sources) + end), + THROTTLE_TIME +) ---Confirm completion. ---@param e cmp.Entry ---@param option cmp.ConfirmOption ---@param callback function -core.confirm = function(e, option, callback) +core.confirm = function(self, e, option, callback) if not (e and not e.confirmed) then return end @@ -318,8 +286,11 @@ core.confirm = function(e, option, callback) debug.log('entry.confirm', e:get_completion_item()) - local suspending = core.suspend() - local ctx = core.get_context() + local release = self:suspend() + local ctx = self:get_context() + + -- Close menus. + self.view:close() -- Simulate `` behavior. local confirm = {} @@ -398,7 +369,7 @@ core.confirm = function(e, option, callback) }) end e:execute(vim.schedule_wrap(function() - suspending() + release() if config.get().event.on_confirm_done then config.get().event.on_confirm_done(e) @@ -413,14 +384,11 @@ core.confirm = function(e, option, callback) end ---Reset current completion state -core.reset = function() - for _, s in pairs(core.sources) do +core.reset = function(self) + for _, s in pairs(self.sources) do s:reset() end - core.menu:reset() - - core.get_context() -- To prevent new event - core.ghost_text(nil) + self:get_context() -- To prevent new event end return core diff --git a/lua/cmp/entry.lua b/lua/cmp/entry.lua index 088ad8b0d..60eb6159b 100644 --- a/lua/cmp/entry.lua +++ b/lua/cmp/entry.lua @@ -10,6 +10,7 @@ local types = require('cmp.types') ---@field public cache cmp.Cache ---@field public score number ---@field public exact boolean +---@field public matches table ---@field public context cmp.Context ---@field public source cmp.Source ---@field public source_offset number @@ -32,6 +33,8 @@ entry.new = function(ctx, source, completion_item) self.id = misc.id('entry') self.cache = cache.new() self.score = 0 + self.exact = false + self.matches = {} self.context = ctx self.source = source self.source_offset = source.request_offset @@ -56,7 +59,7 @@ entry.get_offset = function(self) local c = misc.to_vimindex(self.context.cursor_line, range.start.character) for idx = c, self.source_offset do if not char.is_white(string.byte(self.context.cursor_line, idx)) then - offset = math.min(offset, idx) + offset = idx break end end @@ -103,8 +106,8 @@ entry.get_word = function(self) local word if misc.safe(self.completion_item.textEdit) then word = str.trim(self.completion_item.textEdit.newText) - local _, after = self:get_overwrite() - if 0 < after or self.completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then + local overwrite = self:get_overwrite() + if 0 < overwrite[2] or self.completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then word = str.get_word(word, string.byte(self.context.cursor_after_line, 1)) end elseif misc.safe(self.completion_item.insertText) then @@ -129,9 +132,9 @@ entry.get_overwrite = function(self) local e = misc.to_vimindex(self.context.cursor_line, r['end'].character) local before = self.context.cursor.col - s local after = e - self.context.cursor.col - return before, after + return { before, after } end - return 0, 0 + return { 0, 0 } end) end @@ -185,6 +188,38 @@ entry.get_insert_text = function(self) end) end +---Return the item is deprecated or not. +---@return boolean +entry.is_deprecated = function(self) + return self.completion_item.deprecated or vim.tbl_contains(self.completion_item.tags or {}, types.lsp.CompletionItemTag.Deprecated) +end + +---Return view information. +---@return { abbr: { text: string, bytes: number, width: number, hl_group: string }, kind: { text: string, bytes: number, width: number, hl_group: string }, menu: { text: string, bytes: number, width: number, hl_group: string } } +entry.get_view = function(self, suggest_offset) + local item = self:get_vim_item(suggest_offset) + return self.cache:ensure({ 'get_view', self.resolved_completion_item and 1 or 0 }, function() + local view = {} + view.abbr = {} + view.abbr.text = item.abbr or '' + view.abbr.bytes = #view.abbr.text + view.abbr.width = vim.str_utfindex(view.abbr.text) + view.abbr.hl_group = self:is_deprecated() and 'CmpItemAbbrDeprecated' or 'CmpItemAbbr' + view.kind = {} + view.kind.text = item.kind or '' + view.kind.bytes = #view.kind.text + view.kind.width = vim.str_utfindex(view.kind.text) + view.kind.hl_group = 'CmpItemKind' + view.menu = {} + view.menu.text = item.menu or '' + view.menu.bytes = #view.menu.text + view.menu.width = vim.str_utfindex(view.menu.text) + view.menu.hl_group = 'CmpItemMenu' + view.dup = item.dup + return view + end) +end + ---Make vim.CompletedItem ---@param suggest_offset number ---@return vim.CompletedItem @@ -192,7 +227,7 @@ entry.get_vim_item = function(self, suggest_offset) return self.cache:ensure({ 'get_vim_item', suggest_offset, self.resolved_completion_item and 1 or 0 }, function() local completion_item = self:get_completion_item() local word = self:get_word() - local abbr = str.trim(completion_item.label) + local abbr = str.oneline(str.trim(completion_item.label)) -- ~ indicator if #(misc.safe(completion_item.additionalTextEdits) or {}) > 0 then @@ -204,13 +239,6 @@ entry.get_vim_item = function(self, suggest_offset) end end - -- deprecated - if config.get().formatting.deprecated then - if completion_item.deprecated or vim.tbl_contains(completion_item.tags or {}, types.lsp.CompletionItemTag.Deprecated) then - abbr = str.strikethrough(abbr) - end - end - -- append delta text if suggest_offset < self:get_offset() then word = string.sub(self.context.cursor_before_line, suggest_offset, self:get_offset() - 1) .. word @@ -246,9 +274,12 @@ entry.get_vim_item = function(self, suggest_offset) if config.get().formatting.format then vim_item = config.get().formatting.format(self, vim_item) end + vim_item.word = str.oneline(vim_item.word or '') + vim_item.abbr = str.oneline(vim_item.abbr or '') + vim_item.kind = str.oneline(vim_item.kind or '') + vim_item.menu = str.oneline(vim_item.menu or '') vim_item.equal = 1 vim_item.empty = 1 - vim_item.user_data = ('cmp:%s'):format(self.id) return vim_item end) diff --git a/lua/cmp/float.lua b/lua/cmp/float.lua deleted file mode 100644 index 0e7e2423b..000000000 --- a/lua/cmp/float.lua +++ /dev/null @@ -1,138 +0,0 @@ -local async = require('cmp.utils.async') -local config = require('cmp.config') - ----@class cmp.Float ----@field public entry cmp.Entry|nil ----@field public buf number|nil ----@field public win number|nil -local float = {} - ----Create new floating window module -float.new = function() - local self = setmetatable({}, { __index = float }) - self.entry = nil - self.win = nil - self.buf = nil - return self -end - ----Show floating window ----@param e cmp.Entry -float.show = function(self, e) - float.close.stop() - - local documentation = config.get().documentation - if not documentation then - return - end - - local pum = vim.fn.pum_getpos() or {} - if not pum.col then - return self:close() - end - - local right_space = vim.o.columns - (pum.col + pum.width + (pum.scrollbar and 1 or 0)) - 1 - local left_space = pum.col - 1 - local maxwidth = math.min(documentation.maxwidth, math.max(left_space, right_space)) - - -- update buffer content if needed. - if not self.entry or e.id ~= self.entry.id then - local documents = e:get_documentation() - if #documents == 0 then - return self:close() - end - - self.entry = e - self.buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_option(self.buf, 'bufhidden', 'wipe') - vim.lsp.util.stylize_markdown(self.buf, documents, { - max_width = maxwidth, - max_height = documentation.maxheight, - }) - end - - local width, height = vim.lsp.util._make_floating_popup_size(vim.api.nvim_buf_get_lines(self.buf, 0, -1, false), { - max_width = maxwidth, - max_height = documentation.maxheight, - }) - if width <= 0 or height <= 0 then - return self:close() - end - - local right_col = pum.col + pum.width + (pum.scrollbar and 1 or 0) - local left_col = pum.col - width - 3 -- TODO: Why is this needed -3? - - local col - if right_space >= width and left_space >= width then - if right_space < left_space then - col = left_col - else - col = right_col - end - elseif right_space >= width then - col = right_col - elseif left_space >= width then - col = left_col - else - return self:close() - end - - local style = { - relative = 'editor', - style = 'minimal', - width = width, - height = height, - row = pum.row, - col = col, - border = documentation.border, - } - - if self.win and vim.api.nvim_win_is_valid(self.win) then - vim.api.nvim_win_set_buf(self.win, self.buf) - vim.api.nvim_win_set_config(self.win, style) - else - self.win = vim.api.nvim_open_win(self.buf, false, style) - vim.api.nvim_win_set_option(self.win, 'conceallevel', 2) - vim.api.nvim_win_set_option(self.win, 'concealcursor', 'n') - vim.api.nvim_win_set_option(self.win, 'winhighlight', config.get().documentation.winhighlight) - vim.api.nvim_win_set_option(self.win, 'foldenable', false) - vim.api.nvim_win_set_option(self.win, 'wrap', true) - vim.api.nvim_win_set_option(self.win, 'scrolloff', 0) - end -end - ----Close floating window -float.close = async.throttle( - vim.schedule_wrap(function(self) - if self:is_visible() then - vim.api.nvim_win_close(self.win, true) - end - self.entry = nil - self.buf = nil - self.win = nil - end), - 20 -) - -float.scroll = function(self, delta) - if self:is_visible() then - local info = vim.fn.getwininfo(self.win)[1] or {} - local buf = vim.api.nvim_win_get_buf(self.win) - local top = info.topline or 1 - top = top + delta - top = math.max(top, 1) - top = math.min(top, vim.api.nvim_buf_line_count(buf) - info.height + 1) - - vim.defer_fn(function() - vim.api.nvim_buf_call(buf, function() - vim.api.nvim_command('normal! ' .. top .. 'zt') - end) - end, 0) - end -end - -float.is_visible = function(self) - return self.win and vim.api.nvim_win_is_valid(self.win) -end - -return float diff --git a/lua/cmp/init.lua b/lua/cmp/init.lua index b4a13f2bc..6a72537c7 100644 --- a/lua/cmp/init.lua +++ b/lua/cmp/init.lua @@ -1,11 +1,13 @@ local core = require('cmp.core') -local keymap = require('cmp.utils.keymap') local source = require('cmp.source') local config = require('cmp.config') local autocmd = require('cmp.utils.autocmd') +local keymap = require('cmp.utils.keymap') local cmp = {} +cmp.core = core.new() + ---Expose types for k, v in pairs(require('cmp.types.cmp')) do cmp[k] = v @@ -26,27 +28,38 @@ cmp.mapping = require('cmp.config.mapping') ---@return number cmp.register_source = function(name, s) local src = source.new(name, s) - core.register_source(src) + cmp.core:register_source(src) return src.id end ---Unregister completion source ---@param id number cmp.unregister_source = function(id) - core.unregister_source(id) + cmp.core:unregister_source(id) end ---Invoke completion manually cmp.complete = function() - core.complete(core.get_context({ reason = cmp.ContextReason.Manual })) + cmp.core:complete(cmp.core:get_context({ reason = cmp.ContextReason.Manual })) return true end +---Return view is visible or not. +cmp.visible = function() + return cmp.core.view:visible() or vim.fn.pumvisible() == 1 +end + ---Close current completion cmp.close = function() - if vim.fn.pumvisible() == 1 then - core.reset() - keymap.feedkeys(keymap.t(''), 'n') + if cmp.core.view:visible() then + local release = cmp.core:suspend() + cmp.core.view:close() + cmp.core:reset() + vim.schedule(release) + return true + elseif vim.fn.pumvisible() == 1 then + vim.fn.complete(1, {}) + cmp.core:reset() return true else return false @@ -55,10 +68,13 @@ end ---Abort current completion cmp.abort = function() - if vim.fn.pumvisible() == 1 then - keymap.feedkeys(keymap.t(''), 'n', function() - core.reset() - end) + if cmp.core.view:visible() then + local release = cmp.core:suspend() + cmp.core.view:abort() + vim.schedule(release) + return true + elseif vim.fn.pumvisible() == 1 then + vim.api.nvim_select_popupmenu_item(-1, true, true, {}) return true else return false @@ -66,9 +82,12 @@ cmp.abort = function() end ---Select next item if possible -cmp.select_next_item = function() - if vim.fn.pumvisible() == 1 then - vim.api.nvim_feedkeys(keymap.t(''), 'n', true) +cmp.select_next_item = function(option) + option = option or {} + if cmp.core.view:visible() then + local release = cmp.core:suspend() + cmp.core.view:select_next_item(option) + vim.schedule(release) return true else return false @@ -76,9 +95,12 @@ cmp.select_next_item = function() end ---Select prev item if possible -cmp.select_prev_item = function() - if vim.fn.pumvisible() == 1 then - vim.api.nvim_feedkeys(keymap.t(''), 'n', true) +cmp.select_prev_item = function(option) + option = option or {} + if cmp.core.view:visible() then + local release = cmp.core:suspend() + cmp.core.view:select_prev_item(option) + vim.schedule(release) return true else return false @@ -87,8 +109,8 @@ end ---Scrolling documentation window if possible cmp.scroll_docs = function(delta) - if core.menu.float:is_visible() then - core.menu.float:scroll(delta) + if cmp.core.view:visible() then + cmp.core.view:scroll_docs(delta) return true else return false @@ -98,15 +120,20 @@ end ---Confirm completion cmp.confirm = function(option) option = option or {} - local e = core.menu:get_selected_entry() or (option.select and core.menu:get_first_entry() or nil) + + local e = cmp.core.view:get_selected_entry() or (option.select and cmp.core.view:get_first_entry() or nil) if e then - core.confirm(e, { + cmp.core:confirm(e, { behavior = option.behavior, }, function() - core.complete(core.get_context({ reason = cmp.ContextReason.TriggerOnly })) + cmp.core:complete(cmp.core:get_context({ reason = cmp.ContextReason.TriggerOnly })) end) return true else + if vim.fn.complete_info({ 'selected' }).selected ~= -1 then + keymap.feedkeys(keymap.t(''), 'n') + return true + end return false end end @@ -121,7 +148,7 @@ cmp.status = function() kinds.installed = {} kinds.invalid = {} local names = {} - for _, s in pairs(core.sources) do + for _, s in pairs(cmp.core.sources) do names[s.name] = true if config.get_source_config(s.name) then @@ -192,20 +219,21 @@ autocmd.subscribe('InsertEnter', function() -- Avoid unexpected mode detection (mode() function will returns `normal mode` on the InsertEnter event.) vim.schedule(function() if config.enabled() then - core.prepare() - core.on_change('InsertEnter') + cmp.core:prepare() + cmp.core:on_change('InsertEnter') end end) end) autocmd.subscribe('TextChanged', function() if config.enabled() then - core.on_change('TextChanged') + cmp.core:on_change('TextChanged') end end) autocmd.subscribe('InsertLeave', function() - core.reset() + cmp.core:reset() + cmp.core.view:close() end) return cmp diff --git a/lua/cmp/matcher.lua b/lua/cmp/matcher.lua index 5389b92c5..a68d6658f 100644 --- a/lua/cmp/matcher.lua +++ b/lua/cmp/matcher.lua @@ -1,9 +1,8 @@ local char = require('cmp.utils.char') -local str = require('cmp.utils.str') local matcher = {} -matcher.WORD_BOUNDALY_ORDER_FACTOR = 5 +matcher.WORD_BOUNDALY_ORDER_FACTOR = 10 matcher.PREFIX_FACTOR = 8 matcher.NOT_FUZZY_FACTOR = 6 @@ -78,12 +77,12 @@ end matcher.match = function(input, word, words) -- Empty input if #input == 0 then - return matcher.PREFIX_FACTOR + matcher.NOT_FUZZY_FACTOR + return matcher.PREFIX_FACTOR + matcher.NOT_FUZZY_FACTOR, {} end -- Ignore if input is long than word if #input > #word then - return 0 + return 0, {} end --- Gather matched regions @@ -107,17 +106,27 @@ matcher.match = function(input, word, words) end if #matches == 0 then - return 0 + return 0, {} end + matcher.debug(word, matches) + -- Add prefix bonus local prefix = false if matches[1].input_match_start == 1 and matches[1].word_match_start == 1 then prefix = true else for _, w in ipairs(words or {}) do - if str.has_prefix(w, string.sub(input, matches[1].input_match_start, matches[1].input_match_end)) then - prefix = true + prefix = true + local o = 1 + for i = matches[1].input_match_start, matches[1].input_match_end do + if not char.match(string.byte(w, o), string.byte(input, i)) then + prefix = false + break + end + o = o + 1 + end + if prefix then break end end @@ -125,7 +134,7 @@ matcher.match = function(input, word, words) -- Compute prefix match score local score = prefix and matcher.PREFIX_FACTOR or 0 - local boundary_fixer = prefix and matches[1].index - 1 or 0 + local offset = prefix and matches[1].index - 1 or 0 local idx = 1 for _, m in ipairs(matches) do local s = 0 @@ -135,20 +144,21 @@ matcher.match = function(input, word, words) end idx = idx + 1 if s > 0 then - s = s * (m.strict_match and 1.2 or 1) - score = score + (s * (1 + math.max(0, matcher.WORD_BOUNDALY_ORDER_FACTOR - (m.index - boundary_fixer)) / matcher.WORD_BOUNDALY_ORDER_FACTOR)) + s = s * (1 + m.strict_ratio) + s = s * (1 + math.max(0, matcher.WORD_BOUNDALY_ORDER_FACTOR - (m.index - offset)) / matcher.WORD_BOUNDALY_ORDER_FACTOR) + score = score + s end end -- Check remaining input as fuzzy if matches[#matches].input_match_end < #input then - if matcher.fuzzy(input, word, matches) then - return score + if prefix and matcher.fuzzy(input, word, matches) then + return score, matches end - return 0 + return 0, {} end - return score + matcher.NOT_FUZZY_FACTOR + return score + matcher.NOT_FUZZY_FACTOR, matches end --- fuzzy @@ -178,16 +188,37 @@ matcher.fuzzy = function(input, word, matches) local matched = false local word_offset = 0 local word_index = last_match.word_match_end + 1 + local input_match_start = -1 + local input_match_end = -1 + local word_match_start = -1 + local strict_count = 0 + local match_count = 0 while word_offset + word_index <= #word and input_index <= #input do - if char.match(string.byte(word, word_index + word_offset), string.byte(input, input_index)) then + local c1, c2 = string.byte(word, word_index + word_offset), string.byte(input, input_index) + if char.match(c1, c2) then + if not matched then + input_match_start = input_index + word_match_start = word_index + word_offset + end matched = true input_index = input_index + 1 + strict_count = strict_count + (c1 == c2 and 1 or 0) + match_count = match_count + 1 elseif matched then input_index = last_input_index + input_match_end = input_index - 1 end word_offset = word_offset + 1 end if input_index > #input then + table.insert(matches, { + input_match_start = input_match_start, + input_match_end = input_match_end, + word_match_start = word_match_start, + word_match_end = word_index + word_offset - 1, + strict_ratio = strict_count / match_count, + fuzzy = true, + }) return true end return false @@ -208,10 +239,11 @@ matcher.find_match_region = function(input, input_start_index, input_end_index, return nil end - local strict_match_count = 0 local input_match_start = -1 local input_index = input_end_index local word_offset = 0 + local strict_count = 0 + local match_count = 0 while input_index <= #input and word_index + word_offset <= #word do local c1 = string.byte(input, input_index) local c2 = string.byte(word, word_index + word_offset) @@ -221,11 +253,8 @@ matcher.find_match_region = function(input, input_start_index, input_end_index, input_match_start = input_index end - -- Increase strict_match_count - if c1 == c2 then - strict_match_count = strict_match_count + 1 - end - + strict_count = strict_count + (c1 == c2 and 1 or 0) + match_count = match_count + 1 word_offset = word_offset + 1 else -- Match end (partial region) @@ -235,7 +264,8 @@ matcher.find_match_region = function(input, input_start_index, input_end_index, input_match_end = input_index - 1, word_match_start = word_index, word_match_end = word_index + word_offset - 1, - strict_match = strict_match_count == input_index - input_match_start, + strict_ratio = strict_count / match_count, + fuzzy = false, } else return nil @@ -251,7 +281,8 @@ matcher.find_match_region = function(input, input_start_index, input_end_index, input_match_end = input_index - 1, word_match_start = word_index, word_match_end = word_index + word_offset - 1, - strict_match = strict_match_count == input_index - input_match_start, + strict_ratio = strict_count / match_count, + fuzzy = false, } end diff --git a/lua/cmp/matcher_spec.lua b/lua/cmp/matcher_spec.lua index 8bc5e15e8..65ac89cc9 100644 --- a/lua/cmp/matcher_spec.lua +++ b/lua/cmp/matcher_spec.lua @@ -23,12 +23,21 @@ describe('matcher', function() assert.is.truthy(matcher.match('my_', 'my_awesome_variable') > matcher.match('my_', 'completion_matching_strategy_list')) assert.is.truthy(matcher.match('luacon', 'lua_context') > matcher.match('luacon', 'LuaContext')) assert.is.truthy(matcher.match('call', 'calc') == 0) + + assert.is.truthy(matcher.match('vi', 'void#') >= 1) + assert.is.truthy(matcher.match('vo', 'void#') >= 1) + assert.is.truthy(matcher.match('usela', 'useLayoutEffect') > matcher.match('usela', 'useDataLayer')) + assert.is.truthy(matcher.match('true', 'v:true', { 'true' }) == matcher.match('true', 'true')) + assert.is.truthy(matcher.match('g', 'get', { 'get' }) > matcher.match('g', 'dein#get', { 'dein#get' })) end) it('debug', function() matcher.debug = function(...) print(vim.inspect({ ... })) end - -- print('score', matcher.match('vsnipnextjump', 'vsnip-jump-next')) + -- print(vim.inspect({ + -- a = matcher.match('true', 'v:true', { 'true' }), + -- b = matcher.match('true', 'true'), + -- })) end) end) diff --git a/lua/cmp/menu.lua b/lua/cmp/menu.lua deleted file mode 100644 index f2d3d561c..000000000 --- a/lua/cmp/menu.lua +++ /dev/null @@ -1,232 +0,0 @@ -local debug = require('cmp.utils.debug') -local types = require('cmp.types') -local async = require('cmp.utils.async') -local float = require('cmp.float') -local config = require('cmp.config') -local autocmd = require('cmp.utils.autocmd') - ----@class cmp.MenuOption ----@field on_select fun(e: cmp.Entry) - ----@class cmp.Menu ----@field public float cmp.Float ----@field public cache cmp.Cache ----@field public offset number ----@field public on_select fun(e: cmp.Entry) ----@field public items vim.CompletedItem[] ----@field public entries cmp.Entry[] ----@field public deduped_entries cmp.Entry[] ----@field public selected_entry cmp.Entry|nil ----@field public context cmp.Context ----@field public resolve_dedup fun(callback: function) -local menu = {} - ----Create menu ----@param opts cmp.MenuOption ----@return cmp.Menu -menu.new = function(opts) - local self = setmetatable({}, { __index = menu }) - self.float = float.new() - self.resolve_dedup = async.dedup() - self.on_select = opts.on_select or function() end - self:reset() - autocmd.subscribe('CompleteChanged', function() - local e = self:get_selected_entry() - if e then - self:select(e) - else - self:unselect() - end - end) - return self -end - ----Close menu -menu.close = function(self) - vim.schedule(function() - debug.log('menu.close', vim.fn.pumvisible()) - if vim.fn.pumvisible() == 1 then - -- TODO: Is it safe to call...? - local line = vim.api.nvim_win_get_cursor(0)[1] - vim.fn.complete(#vim.api.nvim_buf_get_lines(0, line - 1, line, true)[1] + 1, {}) - end - self:unselect() - end) -end - ----Reset menu -menu.reset = function(self) - self.offset = nil - self.items = {} - self.entries = {} - self.deduped_entries = {} - self.context = nil - self.preselect = 0 - self:close() -end - ----Update menu ----@param ctx cmp.Context ----@param sources cmp.Source[] ----@return cmp.Menu -menu.update = function(self, ctx, sources) - local entries = {} - - -- check the source triggered by character - local has_triggered_by_symbol_source = false - for _, s in ipairs(sources) do - if #s:get_entries(ctx) > 0 then - if s.is_triggered_by_symbol then - has_triggered_by_symbol_source = true - break - end - end - end - - -- create filtered entries. - local offset = ctx.cursor.col - for i, s in ipairs(sources) do - if s.offset <= offset then - if not has_triggered_by_symbol_source or s.is_triggered_by_symbol then - -- source order priority bonus. - local priority = s:get_config().priority or ((#sources - (i - 1)) * config.get().sorting.priority_weight) - - for _, e in ipairs(s:get_entries(ctx)) do - e.score = e.score + priority - table.insert(entries, e) - offset = math.min(offset, e:get_offset()) - end - end - end - end - - -- sort. - table.sort(entries, function(e1, e2) - for _, fn in ipairs(config.get().sorting.comparators) do - local diff = fn(e1, e2) - if diff ~= nil then - return diff - end - end - end) - - -- create vim items. - local items = {} - local deduped_entries = {} - local deduped_words = {} - local preselect = 0 - for _, e in ipairs(entries) do - local item = e:get_vim_item(offset) - if item.dup == 1 or not deduped_words[item.word] then - deduped_words[item.word] = true - -- We have done deduplication already, no need to force Vim to repeat it. - item.dup = 1 - table.insert(items, item) - table.insert(deduped_entries, e) - if preselect == 0 and e.completion_item.preselect and config.get().preselect ~= types.cmp.PreselectMode.None then - preselect = #deduped_entries - end - end - end - - -- save recent pum state. - self.offset = offset - self.items = items - self.entries = entries - self.deduped_entries = deduped_entries - self.preselect = preselect - self.context = ctx - self:show() -end - ----Restore previous menu ----@param ctx cmp.Context -menu.restore = function(self, ctx) - if not ctx.pumvisible then - if #self.items > 0 then - if self.offset <= ctx.cursor.col then - debug.log('menu/restore') - self:show() - end - end - end -end - ----Show completion item -menu.show = function(self) - if #self.deduped_entries == 0 then - self:close() - return - end - debug.log('menu.show', #self.deduped_entries) - - local completeopt = vim.o.completeopt - if self.preselect == 1 then - vim.opt.completeopt = { 'menuone', 'noinsert' } - else - vim.opt.completeopt = config.get().completion.completeopt - end - vim.fn.complete(self.offset, self.items) - if self.preselect > 1 then - vim.api.nvim_select_popupmenu_item(self.preselect - 1, false, false, {}) - end - vim.opt.completeopt = completeopt -end - ----Select current item ----@param e cmp.Entry -menu.select = function(self, e) - -- Documentation (always invoke to follow to the pum position) - e:resolve(self.resolve_dedup(vim.schedule_wrap(function() - if self:get_selected_entry() == e then - self.float:show(e) - end - end))) - - self.on_select(e) -end - ----Select current item -menu.unselect = function(self) - self.float:close() -end - ----Geta current active entry ----@return cmp.Entry|nil -menu.get_active_entry = function(self) - if vim.fn.pumvisible() == 0 or not (vim.v.completed_item or {}).user_data then - return nil - end - return self:get_selected_entry() -end - ----Get current selected entry ----@return cmp.Entry|nil -menu.get_selected_entry = function(self) - if not self:is_valid_mode() then - return nil - end - - local selected = vim.fn.complete_info({ 'selected' }).selected - if selected == -1 then - return nil - end - return self.deduped_entries[math.max(selected, 0) + 1] -end - ----Get first entry ----@param self cmp.Entry|nil -menu.get_first_entry = function(self) - if not self:is_valid_mode() then - return nil - end - return self.deduped_entries[1] -end - ----Return the completion menu is visible or not. ----@return boolean -menu.is_valid_mode = function() - return vim.fn.complete_info({ 'mode' }).mode == 'eval' -end - -return menu diff --git a/lua/cmp/source.lua b/lua/cmp/source.lua index 7e97ed5fc..4f55fdac0 100644 --- a/lua/cmp/source.lua +++ b/lua/cmp/source.lua @@ -104,10 +104,12 @@ source.get_entries = function(self, ctx) if not inputs[o] then inputs[o] = string.sub(ctx.cursor_before_line, o) end - e.score = matcher.match(inputs[o], e:get_filter_text(), { e:get_word() }) + local score, matches = matcher.match(inputs[o], e:get_filter_text(), { e:get_word() }) + e.score = score e.exact = false + e.matches = matches if e.score >= 1 then - e.exact = vim.tbl_contains({ e:get_filter_text(), e:get_word() }, inputs[o]) + e.exact = e:get_filter_text() == inputs[o] or e:get_word() == inputs[o] table.insert(entries, e) end end @@ -115,7 +117,7 @@ source.get_entries = function(self, ctx) return entries end) - local max_item_count = self:get_config().max_item_count + local max_item_count = self:get_config().max_item_count or 200 local limited_entries = {} for _, e in ipairs(entries) do table.insert(limited_entries, e) @@ -306,37 +308,40 @@ source.complete = function(self, ctx, callback) option = self:get_config().opts, completion_context = completion_context, }, - self.complete_dedup(vim.schedule_wrap(function(response) - if #((response or {}).items or response or {}) > 0 then - debug.log(self:get_debug_name(), 'retrieve', #(response.items or response)) - local old_offset = self.offset - local old_entries = self.entries - - self.status = source.SourceStatus.COMPLETED - self.incomplete = response.isIncomplete or false - self.entries = {} - for i, item in ipairs(response.items or response) do - if (misc.safe(item) or {}).label then - local e = entry.new(ctx, self, item) - self.entries[i] = e - self.offset = math.min(self.offset, e:get_offset()) + async.timeout( + self.complete_dedup(vim.schedule_wrap(function(response) + if #((response or {}).items or response or {}) > 0 then + debug.log(self:get_debug_name(), 'retrieve', #(response.items or response)) + local old_offset = self.offset + local old_entries = self.entries + + self.status = source.SourceStatus.COMPLETED + self.incomplete = response.isIncomplete or false + self.entries = {} + for i, item in ipairs(response.items or response) do + if (misc.safe(item) or {}).label then + local e = entry.new(ctx, self, item) + self.entries[i] = e + self.offset = math.min(self.offset, e:get_offset()) + end end - end - self.revision = self.revision + 1 - if #self:get_entries(ctx) == 0 then - self.offset = old_offset - self.entries = old_entries self.revision = self.revision + 1 + if #self:get_entries(ctx) == 0 then + self.offset = old_offset + self.entries = old_entries + self.revision = self.revision + 1 + end + else + debug.log(self:get_debug_name(), 'continue', 'nil') + if completion_context.triggerKind == types.lsp.CompletionTriggerKind.TriggerCharacter then + self:reset() + end + self.status = prev_status end - else - debug.log(self:get_debug_name(), 'continue', 'nil') - if completion_context.triggerKind == types.lsp.CompletionTriggerKind.TriggerCharacter then - self:reset() - end - self.status = prev_status - end - callback() - end)) + callback() + end)), + 2000 + ) ) return true end diff --git a/lua/cmp/types/cmp.lua b/lua/cmp/types/cmp.lua index c79386cd8..49f9268dd 100644 --- a/lua/cmp/types/cmp.lua +++ b/lua/cmp/types/cmp.lua @@ -5,6 +5,11 @@ cmp.ConfirmBehavior = {} cmp.ConfirmBehavior.Insert = 'insert' cmp.ConfirmBehavior.Replace = 'replace' +---@alias cmp.SelectBehavior "'insert'" | "'select'" +cmp.SelectBehavior = {} +cmp.SelectBehavior.Insert = 'insert' +cmp.SelectBehavior.Select = 'select' + ---@alias cmp.ContextReason "'auto'" | "'manual'" | "'none'" cmp.ContextReason = {} cmp.ContextReason.Auto = 'auto' @@ -28,6 +33,9 @@ cmp.PreselectMode.None = 'none' ---@class cmp.ConfirmOption ---@field public behavior cmp.ConfirmBehavior +---@class cmp.SelectOption +---@field public behavior cmp.SelectBehavior + ---@class cmp.SnippetExpansionParams ---@field public body string ---@field public insert_text_mode number @@ -82,7 +90,6 @@ cmp.PreselectMode.None = 'none' ---@field public comparators function[] ---@class cmp.FormattingConfig ----@field public deprecated boolean ---@field public format fun(entry: cmp.Entry, vim_item: vim.CompletedItem): vim.CompletedItem ---@class cmp.SnippetConfig @@ -92,6 +99,7 @@ cmp.PreselectMode.None = 'none' ---@field on_confirm_done function(e: cmp.Entry) ---@class cmp.ExperimentalConfig +---@field public native_menu boolean ---@field public ghost_text cmp.GhostTextConfig|"false" ---@class cmp.GhostTextConfig diff --git a/lua/cmp/utils/async.lua b/lua/cmp/utils/async.lua index 49be19612..bd4334e25 100644 --- a/lua/cmp/utils/async.lua +++ b/lua/cmp/utils/async.lua @@ -26,19 +26,39 @@ async.throttle = function(fn, timeout) end timer:stop() - local delta = math.max(0, self.timeout - (vim.loop.now() - time)) - timer:start( - delta, - 0, - vim.schedule_wrap(function() - time = nil - fn(unpack(args)) - end) - ) + local delta = math.max(1, self.timeout - (vim.loop.now() - time)) + timer:start(delta, 0, function() + time = nil + fn(unpack(args)) + end) end, }) end +---Timeout callback function +---@param fn function +---@param timeout number +---@return function +async.timeout = function(fn, timeout) + local timer + local done = false + local callback = function(...) + if not done then + done = true + timer:stop() + timer:close() + fn(...) + end + end + timer = vim.loop.new_timer() + timer:start(timeout, 0, function() + callback() + end) + return callback +end + +---@alias cmp.AsyncDedup fun(callback: function): function + ---Create deduplicated callback ---@return function async.dedup = function() diff --git a/lua/cmp/utils/binary.lua b/lua/cmp/utils/binary.lua new file mode 100644 index 000000000..ab1b0d225 --- /dev/null +++ b/lua/cmp/utils/binary.lua @@ -0,0 +1,33 @@ +local binary = {} + +---Insert item to list to ordered index +---@param list any[] +---@param item any +---@param func fun(a: any, b: any): "1"|"-1"|"0" +binary.insort = function(list, item, func) + table.insert(list, binary.search(list, item, func), item) +end + +---Search suitable index from list +---@param list any[] +---@param item any +---@param func fun(a: any, b: any): "1"|"-1"|"0" +---@return number +binary.search = function(list, item, func) + local s = 1 + local e = #list + while s <= e do + local idx = math.floor((e + s) / 2) + local diff = func(item, list[idx]) + if diff > 0 then + s = idx + 1 + elseif diff < 0 then + e = idx - 1 + else + return idx + 1 + end + end + return s +end + +return binary diff --git a/lua/cmp/utils/binary_spec.lua b/lua/cmp/utils/binary_spec.lua new file mode 100644 index 000000000..92fe129e7 --- /dev/null +++ b/lua/cmp/utils/binary_spec.lua @@ -0,0 +1,28 @@ +local binary = require('cmp.utils.binary') + +describe('utils.binary', function() + it('insort', function() + local func = function(a, b) + return a.score - b.score + end + local list = {} + binary.insort(list, { id = 'a', score = 1 }, func) + binary.insort(list, { id = 'b', score = 5 }, func) + binary.insort(list, { id = 'c', score = 2.5 }, func) + binary.insort(list, { id = 'd', score = 2 }, func) + binary.insort(list, { id = 'e', score = 8 }, func) + binary.insort(list, { id = 'g', score = 8 }, func) + binary.insort(list, { id = 'h', score = 7 }, func) + binary.insort(list, { id = 'i', score = 6 }, func) + binary.insort(list, { id = 'j', score = 4 }, func) + assert.are.equal(list[1].id, 'a') + assert.are.equal(list[2].id, 'd') + assert.are.equal(list[3].id, 'c') + assert.are.equal(list[4].id, 'j') + assert.are.equal(list[5].id, 'b') + assert.are.equal(list[6].id, 'i') + assert.are.equal(list[7].id, 'h') + assert.are.equal(list[8].id, 'e') + assert.are.equal(list[9].id, 'g') + end) +end) diff --git a/lua/cmp/utils/cache.lua b/lua/cmp/utils/cache.lua index 59df21e67..8607b2a3f 100644 --- a/lua/cmp/utils/cache.lua +++ b/lua/cmp/utils/cache.lua @@ -14,7 +14,7 @@ end cache.get = function(self, key) key = self:key(key) if self.entries[key] ~= nil then - return unpack(self.entries[key]) + return self.entries[key] end return nil end @@ -22,9 +22,9 @@ end ---Set cache value explicitly ---@param key string ---@vararg any -cache.set = function(self, key, ...) +cache.set = function(self, key, value) key = self:key(key) - self.entries[key] = { ... } + self.entries[key] = value end ---Ensure value by callback @@ -33,9 +33,11 @@ end cache.ensure = function(self, key, callback) local value = self:get(key) if value == nil then - self:set(key, callback()) + local v = callback() + self:set(key, v) + return v end - return self:get(key) + return value end ---Clear all cache entries diff --git a/lua/cmp/utils/event.lua b/lua/cmp/utils/event.lua new file mode 100644 index 000000000..662d5731e --- /dev/null +++ b/lua/cmp/utils/event.lua @@ -0,0 +1,51 @@ +---@class cmp.Event +---@field private events table +local event = {} + +---Create vents +event.new = function() + local self = setmetatable({}, { __index = event }) + self.events = {} + return self +end + +---Add event listener +---@param name string +---@param callback function +---@return function +event.on = function(self, name, callback) + if not self.events[name] then + self.events[name] = {} + end + table.insert(self.events[name], callback) + return function() + self:off(name, callback) + end +end + +---Remove event listener +---@param name string +---@param callback function +event.off = function(self, name, callback) + for i, callback_ in ipairs(self.events[name] or {}) do + if callback_ == callback then + table.remove(self.events[name], i) + break + end + end +end + +---Remove all events +event.clear = function(self) + self.events = {} +end + +---Emit event +---@param name string +event.emit = function(self, name, ...) + for _, callback in ipairs(self.events[name] or {}) do + callback(...) + end +end + +return event diff --git a/lua/cmp/utils/highlight.lua b/lua/cmp/utils/highlight.lua new file mode 100644 index 000000000..c8278b229 --- /dev/null +++ b/lua/cmp/utils/highlight.lua @@ -0,0 +1,46 @@ +local highlight = {} + +highlight.keys = { + 'gui', + 'guifg', + 'guibg', + 'cterm', + 'ctermfg', + 'ctermbg', +} + +highlight.inherit = function(name, source, override) + local cmd = ('highlight! default %s'):format(name) + for _, key in ipairs(highlight.keys) do + if override[key] then + cmd = cmd .. (' %s=%s'):format(key, override[key]) + else + local v = highlight.get(source, key) + v = v == '' and 'NONE' or v + cmd = cmd .. (' %s=%s'):format(key, v) + end + end + vim.cmd(cmd) +end + +highlight.get = function(source, key) + if key == 'gui' or key == 'cterm' then + local ui = {} + for _, k in ipairs({ 'bold', 'italic', 'reverse', 'inverse', 'standout', 'underline', 'undercurl', 'strikethrough' }) do + if vim.fn.synIDattr(vim.fn.hlID(source), k, key) == 1 then + table.insert(ui, k) + end + end + return table.concat(ui, ',') + elseif key == 'guifg' then + return vim.fn.synIDattr(vim.fn.hlID(source), 'fg#', 'gui') + elseif key == 'guibg' then + return vim.fn.synIDattr(vim.fn.hlID(source), 'bg#', 'gui') + elseif key == 'ctermfg' then + return vim.fn.synIDattr(vim.fn.hlID(source), 'fg', 'term') + elseif key == 'ctermbg' then + return vim.fn.synIDattr(vim.fn.hlID(source), 'bg', 'term') + end +end + +return highlight diff --git a/lua/cmp/utils/keymap.lua b/lua/cmp/utils/keymap.lua index 81cdeaecd..02f4d9aef 100644 --- a/lua/cmp/utils/keymap.lua +++ b/lua/cmp/utils/keymap.lua @@ -149,8 +149,12 @@ misc.set(_G, { 'cmp', 'utils', 'keymap', 'listen', 'run' }, function(mode, keys) local bufnr = vim.api.nvim_get_current_buf() local fallback = keymap.listen.cache:get({ mode, bufnr, keys }).fallback local callback = keymap.listen.cache:get({ mode, bufnr, keys }).callback + local done = false callback(keys, function() - keymap.feedkeys(keymap.t(fallback), 'i') + if not done then + done = true + keymap.feedkeys(keymap.t(fallback), 'i') + end end) return keymap.t('') end) diff --git a/lua/cmp/utils/misc.lua b/lua/cmp/utils/misc.lua index 244005d4c..8378b0310 100644 --- a/lua/cmp/utils/misc.lua +++ b/lua/cmp/utils/misc.lua @@ -35,7 +35,7 @@ end ---@return T misc.merge = function(v1, v2) local merge1 = type(v1) == 'table' and (not vim.tbl_islist(v1) or vim.tbl_isempty(v1)) - local merge2 = type(v2) == 'table' and (not vim.tbl_islist(v1) or vim.tbl_isempty(v1)) + local merge2 = type(v2) == 'table' and (not vim.tbl_islist(v2) or vim.tbl_isempty(v2)) if merge1 and merge2 then local new_tbl = {} for k, v in pairs(v2) do diff --git a/lua/cmp/utils/str.lua b/lua/cmp/utils/str.lua index 6a3aa0e66..91d1bd75c 100644 --- a/lua/cmp/utils/str.lua +++ b/lua/cmp/utils/str.lua @@ -15,6 +15,8 @@ INVALID_CHARS[string.byte('\t')] = true INVALID_CHARS[string.byte('\n')] = true INVALID_CHARS[string.byte('\r')] = true +local NR_BYTE = string.byte('\n') + local PAIR_CHARS = {} PAIR_CHARS[string.byte('[')] = string.byte(']') PAIR_CHARS[string.byte('(')] = string.byte(')') @@ -72,24 +74,6 @@ str.strikethrough = function(text) return buffer end ----omit ----@param text string ----@param width number ----@return string -str.omit = function(text, width) - if width == 0 then - return '' - end - - if not text then - text = '' - end - if #text > width then - return string.sub(text, 1, width + 1) .. '...' - end - return text -end - ---trim ---@param text string ---@return string @@ -136,21 +120,12 @@ str.get_word = function(text, stop_char) return text end ----Get character length. ----@param text string ----@param s number ----@param e number ----@return number -str.chars = function(text, s, e) - return vim.fn.strchars(string.sub(text, s, e)) -end - ---Oneline ---@param text string ---@return string str.oneline = function(text) for i = 1, #text do - if string.byte(text, i) == string.byte('\n', 1) then + if string.byte(text, i) == NR_BYTE then return string.sub(text, 1, i - 1) end end diff --git a/lua/cmp/utils/window.lua b/lua/cmp/utils/window.lua new file mode 100644 index 000000000..9883b497a --- /dev/null +++ b/lua/cmp/utils/window.lua @@ -0,0 +1,256 @@ +local cache = require('cmp.utils.cache') +local misc = require('cmp.utils.misc') + +---@class cmp.WindowStyle +---@field public relative string +---@field public row number +---@field public col number +---@field public width number +---@field public height number +---@field public zindex number|nil + +---@class cmp.Window +---@field public buf number +---@field public win number|nil +---@field public sbuf1 number +---@field public swin1 number|nil +---@field public sbuf2 number +---@field public swin2 number|nil +---@field public style cmp.WindowStyle +---@field public opt table +---@field public cache cmp.Cache +local window = {} + +---new +---@return cmp.Window +window.new = function() + local self = setmetatable({}, { __index = window }) + self.buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_option(self.buf, 'undolevels', -1) + vim.api.nvim_buf_set_option(self.buf, 'buftype', 'nofile') + self.win = nil + self.style = {} + self.sbuf1 = vim.api.nvim_create_buf(false, true) + self.swin1 = nil + self.sbuf2 = vim.api.nvim_create_buf(false, true) + self.swin2 = nil + self.cache = cache.new() + self.opt = {} + self.id = 0 + return self +end + +---Set window option. +---NOTE: If the window already visible, immediately applied to it. +---@param key string +---@param value any +window.option = function(self, key, value) + if value == nil then + return self.opt[key] + end + + self.opt[key] = value + if self:visible() then + vim.api.nvim_win_set_option(self.win, key, value) + end +end + +---Set style. +---@param style cmp.WindowStyle +window.set_style = function(self, style) + if vim.o.columns and vim.o.columns <= style.col + style.width then + style.width = vim.o.columns - style.col - 1 + end + if vim.o.lines and vim.o.lines <= style.row + style.height then + style.height = vim.o.lines - style.row - 1 + end + self.style = style + self.style.zindex = self.style.zindex or 1 +end + +---Open window +---@param style cmp.WindowStyle +window.open = function(self, style) + self.id = self.id + 1 + + if style then + self:set_style(style) + end + + if self.style.width < 1 or self.style.height < 1 then + return + end + + if self.win and vim.api.nvim_win_is_valid(self.win) then + vim.api.nvim_win_set_config(self.win, self.style) + else + local s = misc.copy(self.style) + s.noautocmd = true + self.win = vim.api.nvim_open_win(self.buf, false, s) + for k, v in pairs(self.opt) do + vim.api.nvim_win_set_option(self.win, k, v) + end + end + self:update() +end + +---Update +window.update = function(self) + if self:has_scrollbar() then + local total = self:get_content_height() + local info = self:info() + local bar_height = math.ceil(info.height * (info.height / total)) + local bar_offset = math.min(info.height - bar_height, math.floor(info.height * (vim.fn.getwininfo(self.win)[1].topline / total))) + local style1 = {} + style1.relative = 'editor' + style1.style = 'minimal' + style1.width = 1 + style1.height = info.height + style1.row = info.row + style1.col = info.col + info.width - (info.has_scrollbar and 1 or 0) + style1.zindex = (self.style.zindex and (self.style.zindex + 1) or 1) + if self.swin1 and vim.api.nvim_win_is_valid(self.swin1) then + vim.api.nvim_win_set_config(self.swin1, style1) + else + style1.noautocmd = true + self.swin1 = vim.api.nvim_open_win(self.sbuf1, false, style1) + vim.api.nvim_win_set_option(self.swin1, 'winhighlight', 'Normal:PmenuSbar,NormalNC:PmenuSbar,NormalFloat:PmenuSbar') + end + local style2 = {} + style2.relative = 'editor' + style2.style = 'minimal' + style2.width = 1 + style2.height = bar_height + style2.row = info.row + bar_offset + style2.col = info.col + info.width - (info.has_scrollbar and 1 or 0) + style2.zindex = (self.style.zindex and (self.style.zindex + 2) or 2) + if self.swin2 and vim.api.nvim_win_is_valid(self.swin2) then + vim.api.nvim_win_set_config(self.swin2, style2) + else + style2.noautocmd = true + self.swin2 = vim.api.nvim_open_win(self.sbuf2, false, style2) + vim.api.nvim_win_set_option(self.swin2, 'winhighlight', 'Normal:PmenuThumb,NormalNC:PmenuThumb,NormalFloat:PmenuThumb') + end + else + if self.swin1 and vim.api.nvim_win_is_valid(self.swin1) then + vim.api.nvim_win_close(self.swin1, false) + self.swin1 = nil + end + if self.swin2 and vim.api.nvim_win_is_valid(self.swin2) then + vim.api.nvim_win_close(self.swin2, false) + self.swin2 = nil + end + end +end + +---Close window +window.close = function(self) + local id = self.id + vim.schedule(function() + if id == self.id then + if self.win and vim.api.nvim_win_is_valid(self.win) then + if self.win and vim.api.nvim_win_is_valid(self.win) then + vim.api.nvim_win_close(self.win, true) + self.win = nil + end + if self.swin1 and vim.api.nvim_win_is_valid(self.swin1) then + vim.api.nvim_win_close(self.swin1, false) + self.swin1 = nil + end + if self.swin2 and vim.api.nvim_win_is_valid(self.swin2) then + vim.api.nvim_win_close(self.swin2, false) + self.swin2 = nil + end + end + end + end) +end + +---Return the window is visible or not. +window.visible = function(self) + return self.win and vim.api.nvim_win_is_valid(self.win) +end + +---Return the scrollbar will shown or not. +window.has_scrollbar = function(self) + return (self.style.height or 0) < self:get_content_height() +end + +---Return win info. +window.info = function(self) + local border_width = self:get_border_width() + local has_scrollbar = self:has_scrollbar() + return { + row = self.style.row, + col = self.style.col, + width = self.style.width + border_width + (has_scrollbar and 1 or 0), + height = self.style.height, + border_width = border_width, + has_scrollbar = has_scrollbar, + } +end + +---Get border width +---@return number +window.get_border_width = function(self) + local border = self.style.border + if type(border) == 'table' then + local new_border = {} + while #new_border < 8 do + for _, b in ipairs(border) do + table.insert(new_border, b) + end + end + border = new_border + end + + local w = 0 + if border then + if type(border) == 'string' then + if border == 'single' then + w = 2 + elseif border == 'solid' then + w = 2 + elseif border == 'double' then + w = 2 + elseif border == 'rounded' then + w = 2 + elseif border == 'shadow' then + w = 1 + end + elseif type(border) == 'table' then + local b4 = type(border[4]) == 'table' and border[4][1] or border[4] + if #b4 > 0 then + w = w + 1 + end + local b8 = type(border[8]) == 'table' and border[8][1] or border[8] + if #b8 > 0 then + w = w + 1 + end + end + end + return w +end + +---Get scroll height. +---@return number +window.get_content_height = function(self) + if not self:option('wrap') then + return vim.api.nvim_buf_line_count(self.buf) + end + + return self.cache:ensure({ + 'get_content_height', + self.style.width, + self.buf, + vim.api.nvim_buf_get_changedtick(self.buf), + }, function() + local height = 0 + for _, text in ipairs(vim.api.nvim_buf_get_lines(self.buf, 0, -1, false)) do + height = height + math.ceil(math.max(1, vim.fn.strdisplaywidth(text)) / self.style.width) + end + return height + end) +end + +return window diff --git a/lua/cmp/view.lua b/lua/cmp/view.lua new file mode 100644 index 000000000..c3debdfb1 --- /dev/null +++ b/lua/cmp/view.lua @@ -0,0 +1,206 @@ +local config = require('cmp.config') +local async = require('cmp.utils.async') +local event = require('cmp.utils.event') +local keymap = require('cmp.utils.keymap') +local docs_view = require('cmp.view.docs_view') +local custom_entries_view = require('cmp.view.custom_entries_view') +local native_entries_view = require('cmp.view.native_entries_view') +local ghost_text_view = require('cmp.view.ghost_text_view') + +---@class cmp.View +---@field public event cmp.Event +---@field private resolve_dedup cmp.AsyncDedup +---@field private native_entries_view cmp.NativeEntriesView +---@field private custom_entries_view cmp.CustomEntriesView +---@field private change_dedup cmp.AsyncDedup +---@field private docs_view cmp.DocsView +---@field private ghost_text_view cmp.GhostTextView +local view = {} + +---Create menu +view.new = function() + local self = setmetatable({}, { __index = view }) + self.resolve_dedup = async.dedup() + self.custom_entries_view = custom_entries_view.new() + self.native_entries_view = native_entries_view.new() + self.docs_view = docs_view.new() + self.ghost_text_view = ghost_text_view.new() + self.event = event.new() + + return self +end + +---Return the view components are available or not. +---@return boolean +view.ready = function(self) + return self:_get_entries_view():ready() +end + +---Redraw menu. +view.redraw = function(self) + self:_get_entries_view():redraw() +end + +---Open menu +---@param ctx cmp.Context +---@param sources cmp.Source[] +view.open = function(self, ctx, sources) + local entries = {} + + -- check the source triggered by character + local has_triggered_by_symbol_source = false + for _, s in ipairs(sources) do + if #s:get_entries(ctx) > 0 then + if s.is_triggered_by_symbol then + has_triggered_by_symbol_source = true + break + end + end + end + + -- create filtered entries. + local offset = ctx.cursor.col + for i, s in ipairs(sources) do + if s.offset <= offset then + if not has_triggered_by_symbol_source or s.is_triggered_by_symbol then + -- source order priority bonus. + local priority = s:get_config().priority or ((#sources - (i - 1)) * config.get().sorting.priority_weight) + + for _, e in ipairs(s:get_entries(ctx)) do + e.score = e.score + priority + table.insert(entries, e) + offset = math.min(offset, e:get_offset()) + end + end + end + end + + -- sort. + local comparetors = config.get().sorting.comparators + table.sort(entries, function(e1, e2) + for _, fn in ipairs(comparetors) do + local diff = fn(e1, e2) + if diff ~= nil then + return diff + end + end + end) + + -- open + if #entries > 0 then + self:_get_entries_view():open(offset, entries) + else + self:close() + end +end + +---Close menu +view.close = function(self) + self:_get_entries_view():close() + self.docs_view:close() + self.ghost_text_view:hide() +end + +---Abort menu +view.abort = function(self) + self:_get_entries_view():abort() + self.docs_view:close() + self.ghost_text_view:hide() +end + +---Return the view is visible or not. +---@return boolean +view.visible = function(self) + return self:_get_entries_view():visible() +end + +---Scroll documentation window if possible. +---@param delta number +view.scroll_docs = function(self, delta) + self.docs_view:scroll(delta) +end + +---Select prev menu item. +---@param option cmp.SelectOption +view.select_next_item = function(self, option) + self:_get_entries_view():select_next_item(option) +end + +---Select prev menu item. +---@param option cmp.SelectOption +view.select_prev_item = function(self, option) + self:_get_entries_view():select_prev_item(option) +end + +---Get first entry +---@param self cmp.Entry|nil +view.get_first_entry = function(self) + return self:_get_entries_view():get_first_entry() +end + +---Get current selected entry +---@return cmp.Entry|nil +view.get_selected_entry = function(self) + return self:_get_entries_view():get_selected_entry() +end + +---Get current active entry +---@return cmp.Entry|nil +view.get_active_entry = function(self) + return self:_get_entries_view():get_active_entry() +end + +---Return current configured entries_view +---@return cmp.CustomEntriesView|cmp.NativeEntriesView +view._get_entries_view = function(self) + local c = config.get() + self.native_entries_view.event:clear() + self.custom_entries_view.event:clear() + + if c.experimental.native_menu then + self.native_entries_view.event:on('change', function() + self:on_entry_change() + end) + return self.native_entries_view + else + self.custom_entries_view.event:on('change', function() + self:on_entry_change() + end) + return self.custom_entries_view + end +end + +---On entry change +view.on_entry_change = async.throttle( + vim.schedule_wrap(function(self) + if not self:visible() then + return + end + local e = self:get_selected_entry() + if e then + for _, c in ipairs(config.get().confirmation.get_commit_characters(e:get_commit_characters())) do + keymap.listen('i', c, function(...) + self.event:emit('keymap', ...) + end) + end + e:resolve(vim.schedule_wrap(self.resolve_dedup(function() + if not self:visible() then + return + end + self.docs_view:open(e, self:_get_entries_view():info()) + end))) + else + self.docs_view:close() + end + + e = e or self:get_first_entry() + if e then + self.ghost_text_view:show(e) + else + self.ghost_text_view:hide() + end + end), + 20 +) + +return view diff --git a/lua/cmp/view/custom_entries_view.lua b/lua/cmp/view/custom_entries_view.lua new file mode 100644 index 000000000..0e71a4013 --- /dev/null +++ b/lua/cmp/view/custom_entries_view.lua @@ -0,0 +1,295 @@ +local event = require('cmp.utils.event') +local autocmd = require('cmp.utils.autocmd') +local window = require('cmp.utils.window') +local config = require('cmp.config') +local types = require('cmp.types') +local keymap = require('cmp.utils.keymap') +local misc = require('cmp.utils.misc') + +---@class cmp.CustomEntriesView +---@field private entries_win cmp.Window +---@field private offset number +---@field private entries cmp.Entry[] +---@field private column_bytes any +---@field private column_width any +---@field public event cmp.Event +local custom_entries_view = {} + +custom_entries_view.ns = vim.api.nvim_create_namespace('cmp.view.custom_entries_view') + +custom_entries_view.new = function() + local self = setmetatable({}, { __index = custom_entries_view }) + self.entries_win = window.new() + self.entries_win:option('conceallevel', 2) + self.entries_win:option('concealcursor', 'n') + self.entries_win:option('foldenable', false) + self.entries_win:option('wrap', false) + self.entries_win:option('scrolloff', 0) + self.entries_win:option('winhighlight', 'Normal:Pmenu,FloatBorder:Pmenu,CursorLine:PmenuSel,Search:None') + self.event = event.new() + self.offset = -1 + self.entries = {} + + autocmd.subscribe( + 'CompleteChanged', + vim.schedule_wrap(function() + if self:visible() and vim.fn.pumvisible() == 1 then + self:close() + end + end) + ) + + vim.api.nvim_set_decoration_provider(custom_entries_view.ns, { + on_win = function(_, win, buf, top, bot) + if win ~= self.entries_win.win then + return + end + + for i = top, bot do + local e = self.entries[i + 1] + if e then + local v = e:get_view(self.offset) + local o = 1 + for _, key in ipairs({ 'abbr', 'kind', 'menu' }) do + if self.column_bytes[key] > 0 then + vim.api.nvim_buf_set_extmark(buf, custom_entries_view.ns, i, o, { + end_line = i, + end_col = o + v[key].bytes, + hl_group = v[key].hl_group, + hl_mode = 'combine', + ephemeral = true, + }) + o = o + self.column_bytes[key] + 1 + end + end + for _, m in ipairs(e.matches or {}) do + vim.api.nvim_buf_set_extmark(buf, custom_entries_view.ns, i, m.word_match_start, { + end_line = i, + end_col = m.word_match_end + 1, + hl_group = m.fuzzy and 'CmpItemAbbrMatchFuzzy' or 'CmpItemAbbrMatch', + hl_mode = 'combine', + ephemeral = true, + }) + end + end + end + end, + }) + + return self +end + +custom_entries_view.ready = function() + return vim.fn.pumvisible() == 0 +end + +custom_entries_view.redraw = function() + -- noop +end + +custom_entries_view.open = function(self, offset, entries) + self.offset = offset + self.entries = {} + self.column_bytes = { abbr = 0, kind = 0, menu = 0 } + self.column_width = { abbr = 0, kind = 0, menu = 0 } + + local lines = {} + local dedup = {} + local preselect = 0 + local i = 1 + for _, e in ipairs(entries) do + local view = e:get_view(offset) + if view.dup == 1 or not dedup[e.completion_item.label] then + dedup[e.completion_item.label] = true + self.column_bytes.abbr = math.max(self.column_bytes.abbr, view.abbr.bytes) + self.column_bytes.kind = math.max(self.column_bytes.kind, view.kind.bytes) + self.column_bytes.menu = math.max(self.column_bytes.menu, view.menu.bytes) + self.column_width.abbr = math.max(self.column_width.abbr, view.abbr.width) + self.column_width.kind = math.max(self.column_width.kind, view.kind.width) + self.column_width.menu = math.max(self.column_width.menu, view.menu.width) + table.insert(self.entries, e) + table.insert(lines, ' ') + if preselect == 0 and e.completion_item.preselect then + preselect = i + end + i = i + 1 + end + end + vim.api.nvim_buf_set_lines(self.entries_win.buf, 0, -1, false, lines) + + local width = 0 + width = width + 1 + width = width + self.column_width.abbr + (self.column_width.kind > 0 and 1 or 0) + width = width + self.column_width.kind + (self.column_width.menu > 0 and 1 or 0) + width = width + self.column_width.menu + 1 + + local cursor = vim.api.nvim_win_get_cursor(0) + local row = vim.fn.screenpos('.', cursor[1], cursor[2] + 1).row + local height = vim.api.nvim_get_option('pumheight') + height = height == 0 and #self.entries or height + height = math.min(height, #self.entries) + if (vim.o.lines - row) <= 8 and row - 8 > 0 then + height = math.min(height, row - 1) + row = row - height - 1 + else + height = math.min(height, vim.o.lines - row) + end + + if width < 1 or height < 1 then + return + end + + local delta = cursor[2] + 1 - self.offset + self.entries_win:option('cursorline', false) + self.entries_win:open({ + relative = 'editor', + style = 'minimal', + row = row, + col = vim.fn.screencol() - 1 - delta - 1, + width = width, + height = height, + zindex = 1001, + }) + vim.api.nvim_win_set_cursor(self.entries_win.win, { 1, 1 }) + + if preselect > 0 and config.get().preselect == types.cmp.PreselectMode.Item then + self:preselect(preselect) + elseif string.match(config.get().completion.completeopt, 'noinsert') then + self:preselect(1) + else + self:draw() + end + self.event:emit('change') +end + +custom_entries_view.close = function(self) + self.offset = -1 + self.entries = {} + self.entries_win:close() +end + +custom_entries_view.abort = function(self) + if self.prefix then + self:_insert(self.prefix) + end + self:close() +end + +custom_entries_view.draw = function(self) + local info = vim.fn.getwininfo(self.entries_win.win)[1] + local topline = info.topline - 1 + local botline = info.topline + info.height - 1 + local texts = {} + for i = topline, botline - 1 do + local view = self.entries[i + 1]:get_view(self.offset) + local text = {} + table.insert(text, ' ') + table.insert(text, view.abbr.text) + table.insert(text, string.rep(' ', 1 + self.column_width.abbr - view.abbr.width)) + table.insert(text, view.kind.text) + table.insert(text, string.rep(' ', 1 + self.column_width.kind - view.kind.width)) + table.insert(text, view.menu.text) + table.insert(text, string.rep(' ', 1 + self.column_width.menu - view.menu.width)) + table.insert(text, ' ') + table.insert(texts, table.concat(text, '')) + end + vim.api.nvim_buf_set_lines(self.entries_win.buf, topline, botline, false, texts) +end + +custom_entries_view.visible = function(self) + return self.entries_win:visible() +end + +custom_entries_view.info = function(self) + return self.entries_win:info() +end + +custom_entries_view.preselect = function(self, index) + if self:visible() then + if index <= #self.entries then + self.entries_win:option('cursorline', true) + vim.api.nvim_win_set_cursor(self.entries_win.win, { index, 1 }) + self.entries_win:update() + self:draw() + end + end +end + +custom_entries_view.select_next_item = function(self, option) + if self.entries_win:visible() then + local cursor = vim.api.nvim_win_get_cursor(self.entries_win.win)[1] + 1 + if not self.entries_win:option('cursorline') then + cursor = 1 + elseif #self.entries < cursor then + cursor = 0 + end + self:_select(cursor, option) + end +end + +custom_entries_view.select_prev_item = function(self, option) + if self.entries_win:visible() then + local cursor = vim.api.nvim_win_get_cursor(self.entries_win.win)[1] - 1 + if not self.entries_win:option('cursorline') then + cursor = #self.entries + end + self:_select(cursor, option) + end +end + +custom_entries_view.get_first_entry = function(self) + if self.entries_win:visible() then + return self.entries[1] + end +end + +custom_entries_view.get_selected_entry = function(self) + if self.entries_win:visible() and self.entries_win:option('cursorline') then + return self.entries[vim.api.nvim_win_get_cursor(self.entries_win.win)[1]] + end +end + +custom_entries_view.get_active_entry = function(self) + if self.entries_win:visible() and self.entries_win:option('cursorline') then + local cursor = vim.api.nvim_win_get_cursor(self.entries_win.win) + if cursor[2] == 0 then + return self:get_selected_entry() + end + end +end + +custom_entries_view._select = function(self, cursor, option) + local is_insert = (option.behavior or types.cmp.SelectBehavior.Insert) == types.cmp.SelectBehavior.Insert + if is_insert then + if vim.api.nvim_win_get_cursor(self.entries_win.win)[2] == 1 then + self.prefix = string.sub(vim.api.nvim_get_current_line(), self.offset, vim.api.nvim_win_get_cursor(0)[2]) or '' + end + end + + self.entries_win:option('cursorline', cursor > 0) + vim.api.nvim_win_set_cursor(self.entries_win.win, { math.max(cursor, 1), is_insert and 0 or 1 }) + + if is_insert then + self:_insert(self.entries[cursor] and self.entries[cursor]:get_vim_item(self.offset).word or self.prefix) + end + self.entries_win:update() + self:draw() + self.event:emit('change') +end + +custom_entries_view._insert = function(self, word) + vim.api.nvim_buf_set_keymap(0, 'i', '(cmp.view.custom_entries_view._insert.remove)', ('v:lua.cmp.view.custom_entries_view._insert.remove(%s)'):format(self.offset), { + expr = true, + noremap = true, + }) + keymap.feedkeys(keymap.t('(cmp.view.custom_entries_view._insert.remove)'), 't') + keymap.feedkeys(word, 'nt') +end + +misc.set(_G, { 'cmp', 'view', 'custom_entries_view', '_insert', 'remove' }, function(offset) + local cursor = vim.api.nvim_win_get_cursor(0) + local length = vim.str_utfindex(string.sub(vim.api.nvim_get_current_line(), offset, cursor[2])) + return keymap.t(string.rep('U', length)) +end) + +return custom_entries_view diff --git a/lua/cmp/view/docs_view.lua b/lua/cmp/view/docs_view.lua new file mode 100644 index 000000000..aac0cf0ac --- /dev/null +++ b/lua/cmp/view/docs_view.lua @@ -0,0 +1,126 @@ +local window = require('cmp.utils.window') +local config = require('cmp.config') + +---@class cmp.DocsView +---@field public window cmp.Window +local docs_view = {} + +---Create new floating window module +docs_view.new = function() + local self = setmetatable({}, { __index = docs_view }) + self.entry = nil + self.window = window.new() + self.window:option('conceallevel', 2) + self.window:option('concealcursor', 'n') + self.window:option('foldenable', false) + self.window:option('scrolloff', 0) + self.window:option('wrap', true) + return self +end + +---Open documentation window +---@param e cmp.Entry +---@param view cmp.WindowStyle +docs_view.open = function(self, e, view) + local documentation = config.get().documentation + if not documentation then + return + end + + if not e or not view then + return self:close() + end + + local right_space = vim.o.columns - (view.col + view.width) - 2 + local left_space = view.col - 2 + local maxwidth = math.min(documentation.maxwidth, math.max(left_space, right_space)) + + -- update buffer content if needed. + if not self.entry or e.id ~= self.entry.id then + local documents = e:get_documentation() + if #documents == 0 then + return self:close() + end + + self.entry = e + vim.api.nvim_buf_call(self.window.buf, function() + vim.cmd([[syntax clear]]) + end) + vim.lsp.util.stylize_markdown(self.window.buf, documents, { + max_width = maxwidth, + max_height = documentation.maxheight, + }) + end + + local width, height = vim.lsp.util._make_floating_popup_size(vim.api.nvim_buf_get_lines(self.window.buf, 0, -1, false), { + max_width = maxwidth, + max_height = documentation.maxheight, + }) + if width <= 0 or height <= 0 then + return self:close() + end + + local right_col = view.col + view.width + local left_col = view.col - width - 2 + + local col, left + if right_space >= width and left_space >= width then + if right_space < left_space then + col = left_col + left = true + else + col = right_col + end + elseif right_space >= width then + col = right_col + elseif left_space >= width then + col = left_col + left = true + else + return self:close() + end + + self.window:option('winhighlight', documentation.winhighlight) + self.window:set_style({ + relative = 'editor', + style = 'minimal', + width = width, + height = height, + row = view.row, + col = col, + border = documentation.border, + }) + if left and self.window:has_scrollbar() then + self.window.style.col = self.window.style.col - 1 + end + self.window:open() +end + +---Close floating window +docs_view.close = function(self) + self.window:close() + self.entry = nil +end + +docs_view.scroll = function(self, delta) + if self:visible() then + local info = vim.fn.getwininfo(self.window.win)[1] or {} + local top = info.topline or 1 + top = top + delta + top = math.max(top, 1) + top = math.min(top, self.window:get_content_height() - info.height + 1) + + vim.defer_fn(function() + vim.api.nvim_buf_call(self.window.buf, function() + vim.api.nvim_command('normal! ' .. top .. 'zt') + self.window:update() + end) + end, 0) + end +end + +docs_view.visible = function(self) + return self.window:visible() +end + +return docs_view diff --git a/lua/cmp/view/ghost_text_view.lua b/lua/cmp/view/ghost_text_view.lua new file mode 100644 index 000000000..bfac25b70 --- /dev/null +++ b/lua/cmp/view/ghost_text_view.lua @@ -0,0 +1,72 @@ +local config = require('cmp.config') +local str = require('cmp.utils.str') +local types = require('cmp.types') + +---@class cmp.GhostTextView +local ghost_text_view = {} + +ghost_text_view.ns = vim.api.nvim_create_namespace('cmp:GHOST_TEXT') + +ghost_text_view.new = function() + local self = setmetatable({}, { __index = ghost_text_view }) + self.win = nil + self.entry = nil + vim.api.nvim_set_decoration_provider(ghost_text_view.ns, { + on_win = function(_, win) + return win == self.win + end, + on_line = function(_) + local c = config.get().experimental.ghost_text + if not c then + return + end + + if not self.entry then + return + end + + local cursor = vim.api.nvim_win_get_cursor(0) + if string.sub(vim.api.nvim_get_current_line(), cursor[2] + 1) ~= '' then + return + end + + local diff = 1 + cursor[2] - self.entry:get_offset() + local text = self.entry:get_insert_text() + if self.entry.completion_item.insertTextFormat == types.lsp.InsertTextFormat.Snippet then + text = vim.lsp.util.parse_snippet(text) + end + text = string.sub(str.oneline(text), diff + 1) + if #text > 0 then + vim.api.nvim_buf_set_extmark(0, ghost_text_view.ns, cursor[1] - 1, cursor[2], { + right_gravity = false, + virt_text = { { text, c.hl_group or 'Comment' } }, + virt_text_pos = 'overlay', + hl_mode = 'combine', + ephemeral = true, + }) + end + end, + }) + return self +end + +---Show ghost text +---@param e cmp.Entry +ghost_text_view.show = function(self, e) + local changed = e ~= self.entry + self.win = vim.api.nvim_get_current_win() + self.entry = e + if changed then + vim.cmd([[redraw!]]) -- force invoke decoration provider. + end +end + +ghost_text_view.hide = function(self) + if self.win and self.entry then + self.win = nil + self.entry = nil + vim.cmd([[redraw!]]) -- force invoke decoration provider. + end +end + +return ghost_text_view diff --git a/lua/cmp/view/native_entries_view.lua b/lua/cmp/view/native_entries_view.lua new file mode 100644 index 000000000..48837f819 --- /dev/null +++ b/lua/cmp/view/native_entries_view.lua @@ -0,0 +1,152 @@ +local event = require('cmp.utils.event') +local autocmd = require('cmp.utils.autocmd') +local keymap = require('cmp.utils.keymap') +local types = require('cmp.types') +local config = require('cmp.config') + +---@class cmp.NativeEntriesView +---@field private offset number +---@field private items vim.CompletedItem +---@field private entries cmp.Entry[] +---@field private preselect number +---@field public event cmp.Event +local native_entries_view = {} + +native_entries_view.new = function() + local self = setmetatable({}, { __index = native_entries_view }) + self.event = event.new() + self.offset = -1 + self.items = {} + self.entries = {} + self.preselect = 0 + autocmd.subscribe('CompleteChanged', function() + self.event:emit('change') + end) + return self +end + +native_entries_view.ready = function(_) + if vim.fn.pumvisible() == 0 then + return true + end + return vim.fn.complete_info({ 'mode' }).mode == 'eval' +end + +native_entries_view.redraw = function(self) + if #self.entries > 0 and self.offset <= vim.api.nvim_win_get_cursor(0)[2] then + local completeopt = vim.o.completeopt + vim.o.completeopt = self.preselect == 1 and 'menu,menuone,noinsert' or config.get().completion.completeopt + vim.fn.complete(self.offset, self.items) + vim.o.completeopt = completeopt + + if self.preselect > 1 and config.get().preselect == types.cmp.PreselectMode.Item then + self:preselect(self.preselect) + end + end +end + +native_entries_view.open = function(self, offset, entries) + local dedup = {} + local items = {} + local dedup_entries = {} + local preselect = 0 + for _, e in ipairs(entries) do + local item = e:get_vim_item(offset) + if item.dup == 1 or not dedup[item.abbr] then + dedup[item.abbr] = true + table.insert(items, item) + table.insert(dedup_entries, e) + if preselect == 0 and e.completion_item.preselect then + preselect = #dedup_entries + end + end + end + self.offset = offset + self.items = items + self.entries = dedup_entries + self.preselect = preselect + self:redraw() +end + +native_entries_view.close = function(self) + if string.sub(vim.api.nvim_get_mode().mode, 1, 1) == 'i' then + vim.fn.complete(1, {}) + end + self.offset = -1 + self.entries = {} + self.items = {} + self.preselect = 0 +end + +native_entries_view.abort = function(_) + if string.sub(vim.api.nvim_get_mode().mode, 1, 1) == 'i' then + vim.api.nvim_select_popupmenu_item(-1, true, true, {}) + end +end + +native_entries_view.visible = function(_) + return vim.fn.pumvisible() == 1 +end + +native_entries_view.info = function(self) + if self:visible() then + local info = vim.fn.pum_getpos() + return { + width = info.width + (info.scrollbar and 1 or 0), + height = info.height, + row = info.row, + col = info.col, + } + end +end + +native_entries_view.preselect = function(self, index) + if self:visible() then + if index <= #self.entries then + vim.api.nvim_select_popupmenu_item(index - 1, false, false, {}) + end + end +end + +native_entries_view.select_next_item = function(self, option) + if self:visible() then + if (option.behavior or types.cmp.SelectBehavior.Insert) == types.cmp.SelectBehavior.Insert then + keymap.feedkeys(keymap.t(''), 'n') + else + keymap.feedkeys(keymap.t(''), 'n') + end + end +end + +native_entries_view.select_prev_item = function(self, option) + if self:visible() then + if (option.behavior or types.cmp.SelectBehavior.Insert) == types.cmp.SelectBehavior.Insert then + keymap.feedkeys(keymap.t(''), 'n') + else + keymap.feedkeys(keymap.t(''), 'n') + end + end +end + +native_entries_view.get_first_entry = function(self) + if self:visible() then + return self.entries[1] + end +end + +native_entries_view.get_selected_entry = function(self) + if self:visible() then + local idx = vim.fn.complete_info({ 'selected' }).selected + if idx > -1 then + return self.entries[math.max(0, idx) + 1] + end + end +end + +native_entries_view.get_active_entry = function(self) + if self:visible() and (vim.v.completed_item or {}).word then + return self:get_selected_entry() + end +end + +return native_entries_view diff --git a/lua/cmp/vim_source.lua b/lua/cmp/vim_source.lua index a991b85fe..2ee8fbf37 100644 --- a/lua/cmp/vim_source.lua +++ b/lua/cmp/vim_source.lua @@ -5,7 +5,9 @@ local vim_source = {} ---@param id number ---@param args any[] vim_source.on_callback = function(id, args) - return vim_source.to_callback.callbacks[id](unpack(args)) + if vim_source.to_callback.callbacks[id] then + vim_source.to_callback.callbacks[id](unpack(args)) + end end ---@param callback function @@ -34,7 +36,7 @@ vim_source.to_args = function(args) return args end ----@param id number +---@param bridge_id number ---@param methods string[] vim_source.new = function(bridge_id, methods) local self = {} diff --git a/plugin/cmp.lua b/plugin/cmp.lua index 6d30e66fd..11aed20d2 100644 --- a/plugin/cmp.lua +++ b/plugin/cmp.lua @@ -4,6 +4,7 @@ end vim.g.loaded_cmp = true local misc = require('cmp.utils.misc') +local highlight = require('cmp.utils.highlight') -- TODO: https://github.com/neovim/neovim/pull/14661 vim.cmd [[ @@ -11,12 +12,68 @@ vim.cmd [[ autocmd! autocmd InsertEnter * lua require'cmp.utils.autocmd'.emit('InsertEnter') autocmd InsertLeave * lua require'cmp.utils.autocmd'.emit('InsertLeave') - autocmd TextChangedI,TextChangedP * lua require'cmp.utils.autocmd'.emit('TextChanged') + autocmd CursorMovedI,TextChangedI,TextChangedP * lua require'cmp.utils.autocmd'.emit('TextChanged') autocmd CompleteChanged * lua require'cmp.utils.autocmd'.emit('CompleteChanged') autocmd CompleteDone * lua require'cmp.utils.autocmd'.emit('CompleteDone') + autocmd ColorScheme * call v:lua.cmp.plugin.colorscheme() augroup END ]] +misc.set(_G, { 'cmp', 'plugin', 'colorscheme' }, function() + highlight.inherit('CmpItemAbbrDefault', 'Comment', { + guibg = 'NONE', + ctermbg = 'NONE', + }) + highlight.inherit('CmpItemAbbrDeprecatedDefault', 'Comment', { + gui = 'NONE', + guibg = 'NONE', + ctermbg = 'NONE', + }) + highlight.inherit('CmpItemAbbrMatchDefault', 'Normal', { + gui = 'bold', + guibg = 'NONE', + ctermbg = 'NONE', + }) + highlight.inherit('CmpItemAbbrMatchFuzzyDefault', 'Normal', { + gui = 'NONE', + guibg = 'NONE', + ctermbg = 'NONE', + }) + highlight.inherit('CmpItemKindDefault', 'Special', { + guibg = 'NONE', + ctermbg = 'NONE', + }) + highlight.inherit('CmpItemMenuDefault', 'NonText', { + guibg = 'NONE', + ctermbg = 'NONE', + }) +end) +_G.cmp.plugin.colorscheme() + +if vim.fn.hlexists('CmpItemAbbr') ~= 1 then + vim.cmd [[highlight! default link CmpItemAbbr CmpItemAbbrDefault]] +end + +if vim.fn.hlexists('CmpItemAbbrDeprecated') ~= 1 then + vim.cmd [[highlight! default link CmpItemAbbrDeprecated CmpItemAbbrDeprecatedDefault]] +end + +if vim.fn.hlexists('CmpItemAbbrMatch') ~= 1 then + vim.cmd [[highlight! default link CmpItemAbbrMatch CmpItemAbbrMatchDefault]] +end + +if vim.fn.hlexists('CmpItemAbbrMatchFuzzy') ~= 1 then + vim.cmd [[highlight! default link CmpItemAbbrMatchFuzzy CmpItemAbbrMatchFuzzyDefault]] +end + +if vim.fn.hlexists('CmpItemKind') ~= 1 then + vim.cmd [[highlight! default link CmpItemKind CmpItemKindDefault]] +end + +if vim.fn.hlexists('CmpItemMenu') ~= 1 then + vim.cmd [[highlight! default link CmpItemMenu CmpItemMenuDefault]] +end + vim.cmd [[command! CmpStatus lua require('cmp').status()]] vim.cmd [[doautocmd User cmp#ready]]