Files
nvim-cmp/lua/cmp/utils/window.lua
hrsh7th fae808a2bc dev (#813)
* 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 <>
2022-04-13 23:51:55 +09:00

314 lines
8.8 KiB
Lua

local cache = require('cmp.utils.cache')
local misc = require('cmp.utils.misc')
local buffer = require('cmp.utils.buffer')
local api = require('cmp.utils.api')
---@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 border string|string[]|nil
---@field public zindex number|nil
---@class cmp.Window
---@field public name string
---@field public win number|nil
---@field public thumb_win number|nil
---@field public sbar_win number|nil
---@field public style cmp.WindowStyle
---@field public opt table<string, any>
---@field public buffer_opt table<string, any>
---@field public cache cmp.Cache
local window = {}
---new
---@return cmp.Window
window.new = function()
local self = setmetatable({}, { __index = window })
self.name = misc.id('cmp.utils.window.new')
self.win = nil
self.sbar_win = nil
self.thumb_win = nil
self.style = {}
self.cache = cache.new()
self.opt = {}
self.buffer_opt = {}
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 vim.fn.exists('+' .. key) == 0 then
return
end
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 buffer option.
---NOTE: If the buffer already visible, immediately applied to it.
---@param key string
---@param value any
window.buffer_option = function(self, key, value)
if vim.fn.exists('+' .. key) == 0 then
return
end
if value == nil then
return self.buffer_opt[key]
end
self.buffer_opt[key] = value
local existing_buf = buffer.get(self.name)
if existing_buf then
vim.api.nvim_buf_set_option(existing_buf, key, value)
end
end
---Set style.
---@param style cmp.WindowStyle
window.set_style = function(self, style)
self.style = style
local info = self:info()
if vim.o.lines and vim.o.lines <= info.row + info.height + 1 then
self.style.height = vim.o.lines - info.row - info.border_info.vert - 1
end
self.style.zindex = self.style.zindex or 1
end
---Return buffer id.
---@return number
window.get_buffer = function(self)
local buf, created_new = buffer.ensure(self.name)
if created_new then
for k, v in pairs(self.buffer_opt) do
vim.api.nvim_buf_set_option(buf, k, v)
end
end
return buf
end
---Open window
---@param style cmp.WindowStyle
window.open = function(self, style)
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:get_buffer(), 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)
local info = self:info()
if info.scrollable then
-- Draw the background of the scrollbar
if not info.border_info.visible then
local style = {
relative = 'editor',
style = 'minimal',
width = 1,
height = self.style.height,
row = info.row,
col = info.col + info.width - info.scrollbar_offset, -- info.col was already contained the scrollbar offset.
zindex = (self.style.zindex and (self.style.zindex + 1) or 1),
}
if self.sbar_win and vim.api.nvim_win_is_valid(self.sbar_win) then
vim.api.nvim_win_set_config(self.sbar_win, style)
else
style.noautocmd = true
self.sbar_win = vim.api.nvim_open_win(buffer.ensure(self.name .. 'sbar_buf'), false, style)
vim.api.nvim_win_set_option(self.sbar_win, 'winhighlight', 'EndOfBuffer:PmenuSbar,NormalFloat:PmenuSbar')
end
end
-- Draw the scrollbar thumb
local thumb_height = math.floor(info.inner_height * (info.inner_height / self:get_content_height()) + 0.5)
local thumb_offset = math.floor(info.inner_height * (vim.fn.getwininfo(self.win)[1].topline / self:get_content_height()))
local style = {
relative = 'editor',
style = 'minimal',
width = 1,
height = math.max(1, thumb_height),
row = info.row + thumb_offset + (info.border_info.visible and info.border_info.top or 0),
col = info.col + info.width - 1, -- info.col was already added scrollbar offset.
zindex = (self.style.zindex and (self.style.zindex + 2) or 2),
}
if self.thumb_win and vim.api.nvim_win_is_valid(self.thumb_win) then
vim.api.nvim_win_set_config(self.thumb_win, style)
else
style.noautocmd = true
self.thumb_win = vim.api.nvim_open_win(buffer.ensure(self.name .. 'thumb_buf'), false, style)
vim.api.nvim_win_set_option(self.thumb_win, 'winhighlight', 'EndOfBuffer:PmenuThumb,NormalFloat:PmenuThumb')
end
else
if self.sbar_win and vim.api.nvim_win_is_valid(self.sbar_win) then
vim.api.nvim_win_hide(self.sbar_win)
self.sbar_win = nil
end
if self.thumb_win and vim.api.nvim_win_is_valid(self.thumb_win) then
vim.api.nvim_win_hide(self.thumb_win)
self.thumb_win = nil
end
end
-- In cmdline, vim does not redraw automatically.
if api.is_cmdline_mode() then
vim.api.nvim_win_call(self.win, function()
misc.redraw()
end)
end
end
---Close window
window.close = function(self)
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_hide(self.win)
self.win = nil
end
if self.sbar_win and vim.api.nvim_win_is_valid(self.sbar_win) then
vim.api.nvim_win_hide(self.sbar_win)
self.sbar_win = nil
end
if self.thumb_win and vim.api.nvim_win_is_valid(self.thumb_win) then
vim.api.nvim_win_hide(self.thumb_win)
self.thumb_win = nil
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 win info.
window.info = function(self)
local border_info = self:get_border_info()
local info = {
row = self.style.row,
col = self.style.col,
width = self.style.width + border_info.left + border_info.right,
height = self.style.height + border_info.top + border_info.bottom,
inner_width = self.style.width,
inner_height = self.style.height,
border_info = border_info,
scrollable = false,
scrollbar_offset = 0,
}
if self:get_content_height() > info.inner_height then
info.scrollable = true
if not border_info.visible then
info.scrollbar_offset = 1
info.width = info.width + 1
end
end
return info
end
---Return border information.
---@return { top: number, left: number, right: number, bottom: number, vert: number, horiz: number, visible: boolean }
window.get_border_info = function(self)
local border = self.style.border
if not border or border == 'none' then
return {
top = 0,
left = 0,
right = 0,
bottom = 0,
vert = 0,
horiz = 0,
visible = false,
}
end
if type(border) == 'string' then
if border == 'shadow' then
return {
top = 0,
left = 0,
right = 1,
bottom = 1,
vert = 1,
horiz = 1,
visible = false,
}
end
return {
top = 1,
left = 1,
right = 1,
bottom = 1,
vert = 2,
horiz = 2,
visible = true,
}
end
local new_border = {}
while #new_border <= 8 do
for _, b in ipairs(border) do
table.insert(new_border, type(b) == 'string' and b or b[1])
end
end
local info = {}
info.top = new_border[2] == '' and 0 or 1
info.right = new_border[4] == '' and 0 or 1
info.bottom = new_border[6] == '' and 0 or 1
info.left = new_border[8] == '' and 0 or 1
info.vert = info.top + info.bottom
info.horiz = info.left + info.right
info.visible = not (vim.tbl_contains({ '', ' ' }, new_border[2]) and vim.tbl_contains({ '', ' ' }, new_border[4]) and vim.tbl_contains({ '', ' ' }, new_border[6]) and vim.tbl_contains({ '', ' ' }, new_border[8]))
return info
end
---Get scroll height.
---NOTE: The result of vim.fn.strdisplaywidth depends on the buffer it was called in (see comment in cmp.Entry.get_view).
---@return number
window.get_content_height = function(self)
if not self:option('wrap') then
return vim.api.nvim_buf_line_count(self:get_buffer())
end
local height = 0
vim.api.nvim_buf_call(self:get_buffer(), function()
for _, text in ipairs(vim.api.nvim_buf_get_lines(self:get_buffer(), 0, -1, false)) do
height = height + math.max(1, math.ceil(vim.fn.strdisplaywidth(text) / self.style.width))
end
end)
return height
end
return window