* feat: completion menu borders (#472)
* feat(custom_entries_view): pass custom border option
* feat(window): calculate offset needed for borders
* fix(window): adjust window height w/ too many results
* fix(window): center scrollbar with borders
* ref(custom_entries_view): use `FloatBorder` for borders
* fix(window): offset at bottom of window
* ref(window): move height adjustment to more logical place
* fix(window): improve popup placement
* fix(window): `border_offset` always `0` first time
* feat(window): support compact scrollbar with border
* fix(window): completion popup on cursorline
* perf(window): simplify offset calculation
String indexing will result in the same thing as if I gated it behind
`type()` calls here.
* docs(window): add `border` to `cmp.WindowStyle`
* docs(window): correct `border_offset_scrollbar`
* perf(window): calulated row -> `screenrow`
This will also be more accurate since it accounts for wrapped lines, as
well as buffers.
* fix(window): edge case with multiple splits
* ref(winhighlight): don't specify defaults by default
`NormalFloat:NormalFloat` isn't needed, since `NormalFloat` defaults to
`NormalFloat`. As for `FloatBorder`, that should be set to `Floatborder`
rather than `NormalFloat` or else you get unintended artifacts on the
edges of borders.
* fix(window): popup covers cursor when scrollbar disappears
* ref(window): calc `border_offset_col` on `set_style`
* perf(window): remove unecessary `col` calculation
Taking it out didn't change anything about the popup behavior.
* feat: add `CmpItemMenuThumb` group
* feat(window): improve scrollbar appearance
* chore(window): remove references to unused property
* docs: document new option `thin_scrollbar`
* ref(plugin): remove background from `thin_scrollbar`
* feat(view): pass `thin_scrollbar` option to window
* feat(window): gate new `thin_scrollbar` behind option
* fix(window): cmdline bugging out
* fix(cmp): docs_view pops up overlapped when using borders
This is related to 1cfe2f7dfd. The
calculation for how the popup position is calculated was changed, and
so it needed to be reworked to include borders in order to be able to
work.
* ref: `thin_scrollbar` flag -> `scrollbar` option
This change allows users to define which character they will use for
their scrollbar.
* fix(window): use `scrollbar` setting for scrollbar character
Thanks @Astrantia for pointing this one out.
* docs(README): add completion appearance options to FAQ
* fix(): account for `border_offset_row` with `has_bottom_space`
* style(custom_entries_view): group offset with `row`/`col`
* fix(window): scrollbar at full view height
Because the `bar_height` variable must be whole number, and must be rounded up
from a percent, there is a change that we will end up with the maximum
height as a number.
For example, `info.height` = 24 and `total` = 25.
* feat(window): allow scrollbar to be disabled
* fix(window): scrollbar size < 1
* ref(cmp): move border logic to `window.info`
* ref!: window highlighting based on borders
BREAKING CHANGE: `documentation.winhighlight` does not determine the
highlighting of the `documentation` view— `CmpWindow`
or `CmpBorderedWindow` depending on whether it has a
border.
* ref!: float appearance opts -> `cmp.setup.window`
`cmp.setup.completion.border` and `.scrollbar` were both moved to
`cmp.setup.window.completion.border` and `.scrollbar`
BREAKING CHANGE: `cmp.setup.documentation` has been moved to
`cmp.window.documentation`, as all of the pertaining
options were cosmetic.
TODO: document the change
* fix(window): attempt to get scrollbar's border
* fix(cmp): restore `view.menu.hl_group`
* fix(window): wrong scrollbar position
* ref: get default `CmpItemMenu` from border existence
* chore(cmp): remove old PR comments
* fix(window): scrollbar sometimes too big
* fix(window): docs far away with complete menu scrollbar
* perf(docs_view): reuse `border_width` value
* rev(cmp): restore `CmpItemMenu`
* ref(cmp): distinguish between `ScrollBar` and `ScrollThumb`
* fix(plugin): consistently refer to `Thumb` as `Thumb`
* rev(window): `Pmenu`-style scrollbar when no border
* fix(window): docs_view size wrong when first shown
* fix(window): docs_view scrollbar not responding to size
* fix(window): scrollbar sometimes to small, take 2
* fix(window): scrollbar bg not hiding
* ref(docs_view): put docs closer to completion menu
* fix(window): scrollbar position wrong with right border
* ref(config): add default border to documentation
* fix(window): scrollbar too close without border
* ref(plugin): link `CmpWindow` to `Pmenu`
I set `CmpWindow` to `NormalFloat`, because that is what you would
expect a floating window to use for a highlight group. However at
request I changed it to `Pmenu`.
* ref(plugin): link `CmpWindowBorder` to `CmpWindow`
* fix(window): scrollbar following thumb while scrolling
* ref: add more highlight groups
There just weren't enough highlight groups to satisfy the demands of the
project. If you change `CmpWindow` to `Pmenu`, then the `docs_view`
becomes `Pmenu` as well when on `main` it is `NormalFloat`.
* fix(window): scrollbar overlapping `docs_view` by default
* ref: remove `Bordered` highlight variants
* ref(utils): extract whitespace check to func
* feat: `window.completion.zindex` setting
* ref: `maxwidth|height` -> `max_`
* ref: simplify highlight groups
* feat: `window.*.winhighlight` setting
* ref(utils): `is_whitespace_char` -> `is_visible`
As hrsh7th noted, `''` is not a whitespace character. Yet, it is
necessary to group `''` and `' '` together for certain border behaviors
that are based on visibility. Thus I have renamed the function
* feat: specify `window.*.winhighlight` for un/bordered
* fix(custom_entries_view): set `winhighlight` on `open`
* ref: remove `Cmp*Scroll*` variants
There's no way for `window` to know which kind of window it is drawing a
scrollbar on. Simpler to just have one kind of scrollbar
* feat: distinguish between bordered and unbordered
* ref(cmp): `is_visible` -> `is_invisible`
That's what the function was checking for.
* fix(default): mislabeling of `default` and `bordered`
* chore: rebase fixup
* Change default highlight
* Add misc.rep
* Fix left-side docs_view with scrollbar
* Fix scrollbar
* Fix sbar/thumb win
Improve highlights
* Remove scrollbar cutomization for now
* Remove scrollbar option
* Simplify implementation
* Fix doc width
* Fix outdated docs
* Add comments
* Fix configuration schema
* fmt
* Fix for lint
Co-authored-by: Iron-E <36409591+Iron-E@users.noreply.github.com>
Co-authored-by: hrsh7th <>
410 lines
13 KiB
Lua
410 lines
13 KiB
Lua
local event = require('cmp.utils.event')
|
|
local autocmd = require('cmp.utils.autocmd')
|
|
local feedkeys = require('cmp.utils.feedkeys')
|
|
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')
|
|
local api = require('cmp.utils.api')
|
|
|
|
local SIDE_PADDING = 1
|
|
|
|
local DEFAULT_HEIGHT = 10 -- @see https://github.com/vim/vim/blob/master/src/popupmenu.c#L45
|
|
|
|
---@class cmp.CustomEntriesView
|
|
---@field private entries_win cmp.Window
|
|
---@field private offset number
|
|
---@field private active boolean
|
|
---@field private entries cmp.Entry[]
|
|
---@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('cursorlineopt', 'line')
|
|
self.entries_win:option('foldenable', false)
|
|
self.entries_win:option('wrap', false)
|
|
self.entries_win:option('scrolloff', 0)
|
|
-- This is done so that strdisplaywidth calculations for lines in the
|
|
-- custom_entries_view window exactly match with what is really displayed,
|
|
-- see comment in cmp.Entry.get_view. Setting tabstop to 1 makes all tabs be
|
|
-- always rendered one column wide, which removes the unpredictability coming
|
|
-- from variable width of the tab character.
|
|
self.entries_win:buffer_option('tabstop', 1)
|
|
self.event = event.new()
|
|
self.offset = -1
|
|
self.active = false
|
|
self.entries = {}
|
|
self.bottom_up = false
|
|
|
|
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 or buf ~= self.entries_win:get_buffer() then
|
|
return
|
|
end
|
|
|
|
local fields = config.get().formatting.fields
|
|
for i = top, bot do
|
|
local e = self.entries[i + 1]
|
|
if e then
|
|
local v = e:get_view(self.offset, buf)
|
|
local o = SIDE_PADDING
|
|
local a = 0
|
|
for _, field in ipairs(fields) do
|
|
if field == types.cmp.ItemField.Abbr then
|
|
a = o
|
|
end
|
|
vim.api.nvim_buf_set_extmark(buf, custom_entries_view.ns, i, o, {
|
|
end_line = i,
|
|
end_col = o + v[field].bytes,
|
|
hl_group = v[field].hl_group,
|
|
hl_mode = 'combine',
|
|
ephemeral = true,
|
|
})
|
|
o = o + v[field].bytes + (self.column_width[field] - v[field].width) + 1
|
|
end
|
|
|
|
for _, m in ipairs(e.matches or {}) do
|
|
vim.api.nvim_buf_set_extmark(buf, custom_entries_view.ns, i, a + m.word_match_start - 1, {
|
|
end_line = i,
|
|
end_col = a + m.word_match_end,
|
|
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.on_change = function(self)
|
|
self.active = false
|
|
end
|
|
|
|
custom_entries_view.is_direction_top_down = function(self)
|
|
local c = config.get()
|
|
if (c.view and c.view.entries and c.view.entries.selection_order) == 'top_down' then
|
|
return true
|
|
elseif c.view.entries == nil or c.view.entries.selection_order == nil then
|
|
return true
|
|
else
|
|
return not self.bottom_up
|
|
end
|
|
end
|
|
|
|
custom_entries_view.open = function(self, offset, entries)
|
|
local completion = config.get().window.completion
|
|
self.offset = offset
|
|
self.entries = {}
|
|
self.column_width = { abbr = 0, kind = 0, menu = 0 }
|
|
|
|
local entries_buf = self.entries_win:get_buffer()
|
|
local lines = {}
|
|
local dedup = {}
|
|
local preselect = 0
|
|
for _, e in ipairs(entries) do
|
|
local view = e:get_view(offset, entries_buf)
|
|
if view.dup == 1 or not dedup[e.completion_item.label] then
|
|
dedup[e.completion_item.label] = true
|
|
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 = #self.entries
|
|
end
|
|
end
|
|
end
|
|
vim.api.nvim_buf_set_lines(entries_buf, 0, -1, false, lines)
|
|
vim.api.nvim_buf_set_option(entries_buf, 'modified', false)
|
|
|
|
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 height = vim.api.nvim_get_option('pumheight')
|
|
height = height ~= 0 and height or #self.entries
|
|
height = math.min(height, #self.entries)
|
|
|
|
local pos = api.get_screen_cursor()
|
|
local cursor = api.get_cursor()
|
|
local delta = cursor[2] + 1 - self.offset
|
|
local row, col = pos[1], pos[2] - delta - 1
|
|
|
|
local border_info = window.get_border_info({ style = completion })
|
|
local border_offset_row = border_info.top + border_info.bottom
|
|
local border_offset_col = border_info.left + border_info.right
|
|
if math.floor(vim.o.lines * 0.5) <= row + border_offset_row and vim.o.lines - row - border_offset_row <= math.min(DEFAULT_HEIGHT, height) then
|
|
height = math.min(height, row - 1)
|
|
row = row - height - border_offset_row - 1
|
|
if row < 0 then
|
|
height = height + row
|
|
end
|
|
end
|
|
if math.floor(vim.o.columns * 0.5) <= col + border_offset_col and vim.o.columns - col - border_offset_col <= width then
|
|
width = math.min(width, vim.o.columns - 1)
|
|
col = vim.o.columns - width - border_offset_col - 1
|
|
if col < 0 then
|
|
width = width + col
|
|
end
|
|
end
|
|
|
|
if pos[1] > row then
|
|
self.bottom_up = true
|
|
else
|
|
self.bottom_up = false
|
|
end
|
|
|
|
if not self:is_direction_top_down() then
|
|
local n = #self.entries
|
|
for i = 1, math.floor(n / 2) do
|
|
self.entries[i], self.entries[n - i + 1] = self.entries[n - i + 1], self.entries[i]
|
|
end
|
|
if preselect ~= 0 then
|
|
preselect = #self.entries - preselect + 1
|
|
end
|
|
end
|
|
|
|
-- Apply window options (that might be changed) on the custom completion menu.
|
|
self.entries_win:option('winblend', vim.o.pumblend)
|
|
self.entries_win:option('winhighlight', completion.winhighlight)
|
|
self.entries_win:open({
|
|
relative = 'editor',
|
|
style = 'minimal',
|
|
row = math.max(0, row),
|
|
col = math.max(0, col),
|
|
width = width,
|
|
height = height,
|
|
border = completion.border,
|
|
zindex = completion.zindex or 1001,
|
|
})
|
|
-- always set cursor when starting. It will be adjusted on the call to _select
|
|
vim.api.nvim_win_set_cursor(self.entries_win.win, { 1, 0 })
|
|
if preselect > 0 and config.get().preselect == types.cmp.PreselectMode.Item then
|
|
self:_select(preselect, { behavior = types.cmp.SelectBehavior.Select })
|
|
elseif not string.match(config.get().completion.completeopt, 'noselect') then
|
|
if self:is_direction_top_down() then
|
|
self:_select(1, { behavior = types.cmp.SelectBehavior.Select })
|
|
else
|
|
self:_select(#self.entries - 1, { behavior = types.cmp.SelectBehavior.Select })
|
|
end
|
|
else
|
|
if self:is_direction_top_down() then
|
|
self:_select(0, { behavior = types.cmp.SelectBehavior.Select })
|
|
else
|
|
self:_select(#self.entries + 1, { behavior = types.cmp.SelectBehavior.Select })
|
|
end
|
|
end
|
|
end
|
|
|
|
custom_entries_view.close = function(self)
|
|
self.prefix = nil
|
|
self.offset = -1
|
|
self.active = false
|
|
self.entries = {}
|
|
self.entries_win:close()
|
|
self.bottom_up = false
|
|
end
|
|
|
|
custom_entries_view.abort = function(self)
|
|
if self.prefix then
|
|
self:_insert(self.prefix)
|
|
end
|
|
feedkeys.call('', 'n', function()
|
|
self:close()
|
|
end)
|
|
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 = {}
|
|
local fields = config.get().formatting.fields
|
|
local entries_buf = self.entries_win:get_buffer()
|
|
for i = topline, botline - 1 do
|
|
local e = self.entries[i + 1]
|
|
if e then
|
|
local view = e:get_view(self.offset, entries_buf)
|
|
local text = {}
|
|
table.insert(text, string.rep(' ', SIDE_PADDING))
|
|
for _, field in ipairs(fields) do
|
|
table.insert(text, view[field].text)
|
|
table.insert(text, string.rep(' ', 1 + self.column_width[field] - view[field].width))
|
|
end
|
|
table.insert(text, string.rep(' ', SIDE_PADDING))
|
|
table.insert(texts, table.concat(text, ''))
|
|
end
|
|
end
|
|
vim.api.nvim_buf_set_lines(entries_buf, topline, botline, false, texts)
|
|
vim.api.nvim_buf_set_option(entries_buf, 'modified', false)
|
|
|
|
if api.is_cmdline_mode() then
|
|
vim.api.nvim_win_call(self.entries_win.win, function()
|
|
misc.redraw()
|
|
end)
|
|
end
|
|
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.select_next_item = function(self, option)
|
|
if self:visible() then
|
|
local cursor = vim.api.nvim_win_get_cursor(self.entries_win.win)[1]
|
|
if self:is_direction_top_down() then
|
|
cursor = cursor + 1
|
|
else
|
|
cursor = cursor - 1
|
|
end
|
|
if not self.entries_win:option('cursorline') then
|
|
cursor = (self:is_direction_top_down() and 1) or #self.entries
|
|
elseif #self.entries < cursor then
|
|
cursor = (not self:is_direction_top_down() and #self.entries + 1) or 0
|
|
end
|
|
self:_select(cursor, option)
|
|
end
|
|
end
|
|
|
|
custom_entries_view.select_prev_item = function(self, option)
|
|
if self:visible() then
|
|
local cursor = vim.api.nvim_win_get_cursor(self.entries_win.win)[1]
|
|
if self:is_direction_top_down() then
|
|
cursor = cursor - 1
|
|
else
|
|
cursor = cursor + 1
|
|
end
|
|
if not self.entries_win:option('cursorline') then
|
|
cursor = (self:is_direction_top_down() and #self.entries) or 1
|
|
elseif #self.entries < cursor then
|
|
cursor = (not self:is_direction_top_down() and 0) or #self.entries + 1
|
|
end
|
|
self:_select(cursor, option)
|
|
end
|
|
end
|
|
|
|
custom_entries_view.get_offset = function(self)
|
|
if self:visible() then
|
|
return self.offset
|
|
end
|
|
return nil
|
|
end
|
|
|
|
custom_entries_view.get_entries = function(self)
|
|
if self:visible() then
|
|
return self.entries
|
|
end
|
|
return {}
|
|
end
|
|
|
|
custom_entries_view.get_first_entry = function(self)
|
|
if self:visible() then
|
|
return (self:is_direction_top_down() and self.entries[1]) or self.entries[#self.entries]
|
|
end
|
|
end
|
|
|
|
custom_entries_view.get_selected_entry = function(self)
|
|
if self: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:visible() and self.active then
|
|
return self:get_selected_entry()
|
|
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 and not self.active then
|
|
self.prefix = string.sub(api.get_current_line(), self.offset, api.get_cursor()[2]) or ''
|
|
end
|
|
|
|
self.active = cursor > 0 and cursor <= #self.entries and is_insert
|
|
self.entries_win:option('cursorline', cursor > 0 and cursor <= #self.entries)
|
|
|
|
vim.api.nvim_win_set_cursor(self.entries_win.win, {
|
|
math.max(math.min(cursor, #self.entries), 1),
|
|
0,
|
|
})
|
|
|
|
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 = setmetatable({
|
|
pending = false,
|
|
}, {
|
|
__call = function(this, self, word)
|
|
word = word or ''
|
|
if api.is_cmdline_mode() then
|
|
local cursor = api.get_cursor()
|
|
vim.api.nvim_feedkeys(keymap.backspace(string.sub(api.get_current_line(), self.offset, cursor[2])) .. word, 'int', true)
|
|
else
|
|
if this.pending then
|
|
return
|
|
end
|
|
this.pending = true
|
|
|
|
local release = require('cmp').suspend()
|
|
feedkeys.call('', '', function()
|
|
local cursor = api.get_cursor()
|
|
local keys = {}
|
|
table.insert(keys, keymap.indentkeys())
|
|
table.insert(keys, keymap.backspace(string.sub(api.get_current_line(), self.offset, cursor[2])))
|
|
table.insert(keys, word)
|
|
table.insert(keys, keymap.indentkeys(vim.bo.indentkeys))
|
|
feedkeys.call(
|
|
table.concat(keys, ''),
|
|
'int',
|
|
vim.schedule_wrap(function()
|
|
this.pending = false
|
|
release()
|
|
end)
|
|
)
|
|
end)
|
|
end
|
|
end,
|
|
})
|
|
|
|
return custom_entries_view
|