Skip to content

Commit

Permalink
feat: add scrollbar to autocomplete menu (#259)
Browse files Browse the repository at this point in the history
* feat: add scrollbar to autocomplete menu

* feat: split up scrollbar code, add highlight groups, handle borders

* feat: make scrollbar configurable, add for all windows

---------

Co-authored-by: Liam Dyer <liamcdyer@gmail.com>
  • Loading branch information
willothy and Saghen authored Nov 4, 2024
1 parent 973f06a commit 4c2a36c
Show file tree
Hide file tree
Showing 10 changed files with 245 additions and 9 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ MiniDeps.add({
| `BlinkCmpMenu` | Pmenu | The completion menu window |
| `BlinkCmpMenuBorder` | Pmenu | The completion menu window border |
| `BlinkCmpMenuSelection` | PmenuSel | The completion menu window selected item |
| `BlinkCmpScrollBarThumb` | Visual | The scrollbar thumb |
| `BlinkCmpScrollBarGutter` | Pmenu | The scrollbar gutter |
| `BlinkCmpLabel` | Pmenu | Label of the completion item |
| `BlinkCmpLabelDeprecated` | Comment | Deprecated label of the completion item |
| `BlinkCmpLabelMatch` | Pmenu | (Currently unused) Label of the completion item when it matches the query |
Expand Down Expand Up @@ -389,6 +391,8 @@ MiniDeps.add({
winhighlight = 'Normal:BlinkCmpMenu,FloatBorder:BlinkCmpMenuBorder,CursorLine:BlinkCmpMenuSelection,Search:None',
-- keep the cursor X lines away from the top/bottom of the window
scrolloff = 2,
-- note that the gutter will be disabled when border ~= 'none'
scrollbar = true,
-- which directions to show the window,
-- falling back to the next direction when there's not enough space
direction_priority = { 's', 'n' },
Expand Down Expand Up @@ -420,6 +424,8 @@ MiniDeps.add({
border = 'padded',
winblend = 0,
winhighlight = 'Normal:BlinkCmpDoc,FloatBorder:BlinkCmpDocBorder,CursorLine:BlinkCmpDocCursorLine,Search:None',
-- note that the gutter will be disabled when border ~= 'none'
scrollbar = true,
-- which directions to show the documentation window,
-- for each of the possible autocomplete window directions,
-- falling back to the next direction when there's not enough space
Expand All @@ -439,6 +445,8 @@ MiniDeps.add({
border = 'padded',
winblend = 0,
winhighlight = 'Normal:BlinkCmpSignatureHelp,FloatBorder:BlinkCmpSignatureHelpBorder',
-- note that the gutter will be disabled when border ~= 'none'
scrollbar = false,

-- which directions to show the window,
-- falling back to the next direction when there's not enough space
Expand Down
13 changes: 11 additions & 2 deletions lua/blink/cmp/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
--- @field min_width? number
--- @field max_height? number
--- @field border? blink.cmp.WindowBorder
--- @field scrollbar? boolean
--- @field order? "top_down" | "bottom_up"
--- @field direction_priority? ("n" | "s")[]
--- @field auto_show? boolean
Expand Down Expand Up @@ -139,12 +140,13 @@
--- @field desired_min_width? number
--- @field desired_min_height? number
--- @field border? blink.cmp.WindowBorder
--- @field winblend? number
--- @field winhighlight? string
--- @field scrollbar? boolean
--- @field direction_priority? blink.cmp.DocumentationDirectionPriorityConfig
--- @field auto_show? boolean
--- @field auto_show_delay_ms? number Delay before showing the documentation window
--- @field update_delay_ms? number Delay before updating the documentation window
--- @field winblend? number
--- @field winhighlight? string

--- @class blink.cmp.SignatureHelpConfig
--- @field min_width? number
Expand All @@ -153,6 +155,7 @@
--- @field border? blink.cmp.WindowBorder
--- @field winblend? number
--- @field winhighlight? string
--- @field scrollbar? boolean
--- @field direction_priority? ("n" | "s")[]

--- @class GhostTextConfig
Expand Down Expand Up @@ -371,6 +374,8 @@ local config = {
winhighlight = 'Normal:BlinkCmpMenu,FloatBorder:BlinkCmpMenuBorder,CursorLine:BlinkCmpMenuSelection,Search:None',
-- keep the cursor X lines away from the top/bottom of the window
scrolloff = 2,
-- note that the gutter will be disabled when border ~= 'none'
scrollbar = true,
-- TODO: implement
order = 'top_down',
-- which directions to show the window,
Expand Down Expand Up @@ -405,6 +410,8 @@ local config = {
border = 'padded',
winblend = 0,
winhighlight = 'Normal:BlinkCmpDoc,FloatBorder:BlinkCmpDocBorder,CursorLine:BlinkCmpDocCursorLine,Search:None',
-- note that the gutter will be disabled when border ~= 'none'
scrollbar = true,
-- which directions to show the documentation window,
-- for each of the possible autocomplete window directions,
-- falling back to the next direction when there's not enough space
Expand All @@ -424,6 +431,8 @@ local config = {
border = 'padded',
winblend = 0,
winhighlight = 'Normal:BlinkCmpSignatureHelp,FloatBorder:BlinkCmpSignatureHelpBorder',
-- note that the gutter will be disabled when border ~= 'none'
scrollbar = false,

-- which directions to show the window,
-- falling back to the next direction when there's not enough space
Expand Down
3 changes: 3 additions & 0 deletions lua/blink/cmp/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ cmp.add_default_highlights = function()
set_hl('BlinkCmpKind' .. kind, { link = use_nvim_cmp and 'CmpItemKind' .. kind or 'BlinkCmpKind' })
end

set_hl('BlinkCmpScrollBarThumb', { link = 'Visual' })
set_hl('BlinkCmpScrollBarGutter', { link = 'Pmenu' })

set_hl('BlinkCmpGhostText', { link = use_nvim_cmp and 'CmpGhostText' or 'Comment' })

set_hl('BlinkCmpMenu', { link = 'Pmenu' })
Expand Down
1 change: 1 addition & 0 deletions lua/blink/cmp/windows/autocomplete.lua
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ function autocomplete.setup()
winhighlight = autocmp_config.winhighlight,
cursorline = false,
scrolloff = autocmp_config.scrolloff,
scrollbar = autocmp_config.scrollbar,
})

-- Setting highlights is slow and we update on every keystroke so we instead use a decoration provider
Expand Down
3 changes: 2 additions & 1 deletion lua/blink/cmp/windows/documentation.lua
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ function docs.setup()
border = config.border,
winblend = config.winblend,
winhighlight = config.winhighlight,
scrollbar = config.scrollbar,
wrap = true,
filetype = 'markdown',
})
Expand Down Expand Up @@ -168,7 +169,7 @@ function docs.update_position()
elseif pos.direction == 'e' then
set_config({
row = -autocomplete_border_size.top,
col = autocomplete_win_config.width + autocomplete_border_size.left,
col = autocomplete_win_config.width + autocomplete_border_size.right,
})
elseif pos.direction == 'w' then
set_config({ row = -autocomplete_border_size.top, col = -width - autocomplete_border_size.left })
Expand Down
23 changes: 17 additions & 6 deletions lua/blink/cmp/windows/lib/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
--- @field border? blink.cmp.WindowBorder
--- @field wrap? boolean
--- @field filetype? string
--- @field winblend? number
--- @field winhighlight? string
--- @field scrolloff? number
--- @field scrollbar? boolean

--- @class blink.cmp.Window
--- @field id? number
--- @field buf? number
--- @field config blink.cmp.WindowOptions
--- @field scrollbar? blink.cmp.Scrollbar
---
--- @field new fun(config: blink.cmp.WindowOptions): blink.cmp.Window
--- @field get_buf fun(self: blink.cmp.Window): number
Expand Down Expand Up @@ -52,8 +55,13 @@ function win.new(config)
winblend = config.winblend or 0,
winhighlight = config.winhighlight or 'Normal:NormalFloat,FloatBorder:NormalFloat',
scrolloff = config.scrolloff or 0,
scrollbar = config.scrollbar,
}

if self.config.scrollbar then
self.scrollbar = require('blink.cmp.windows.lib.scrollbar').new({ enable_gutter = self.config.border == 'none' })
end

return self
end

Expand Down Expand Up @@ -100,6 +108,8 @@ function win:open()
vim.api.nvim_set_option_value('cursorlineopt', 'line', { win = self.id })
vim.api.nvim_set_option_value('cursorline', self.config.cursorline, { win = self.id })
vim.api.nvim_set_option_value('scrolloff', self.config.scrolloff, { win = self.id })

if self.scrollbar then self.scrollbar:mount(self.id) end
end

function win:set_option_value(option, value) vim.api.nvim_set_option_value(option, value, { win = self.id }) end
Expand All @@ -109,6 +119,7 @@ function win:close()
vim.api.nvim_win_close(self.id, true)
self.id = nil
end
if self.scrollbar then self.scrollbar:unmount() end
end

--- Updates the size of the window to match the max width and height of the content/config
Expand Down Expand Up @@ -138,15 +149,13 @@ function win:get_content_height()
end

--- Gets the size of the borders around the window
--- @param border? 'none' | 'single' | 'double' | 'rounded' | 'solid' | 'shadow' | 'padded' | string[]
--- @return { vertical: number, horizontal: number, left: number, right: number, top: number, bottom: number }
function win:get_border_size(border)
if not border and not self:is_open() then
return { vertical = 0, horizontal = 0, left = 0, right = 0, top = 0, bottom = 0 }
end
function win:get_border_size()
if not self:is_open() then return { vertical = 0, horizontal = 0, left = 0, right = 0, top = 0, bottom = 0 } end

border = border or self.config.border
local border = self.config.border
if border == 'none' then
if self.config.scrollbar then return { vertical = 0, horizontal = 1, left = 0, right = 1, top = 0, bottom = 0 } end
return { vertical = 0, horizontal = 0, left = 0, right = 0, top = 0, bottom = 0 }
elseif border == 'padded' then
return { vertical = 0, horizontal = 2, left = 1, right = 1, top = 0, bottom = 0 }
Expand All @@ -158,6 +167,7 @@ function win:get_border_size(border)
-- borders can be a table of strings and act differently with different # of chars
-- so we normalize it: https://neovim.io/doc/user/api.html#nvim_open_win()
-- based on nvim-cmp
-- TODO: doesn't handle scrollbar
local resolved_border = {}
while #resolved_border <= 8 do
for _, b in ipairs(border) do
Expand All @@ -172,6 +182,7 @@ function win:get_border_size(border)
return { vertical = top + bottom, horizontal = left + right, left = left, right = right, top = top, bottom = bottom }
end

if self.config.scrollbar then return { vertical = 0, horizontal = 1, left = 0, right = 1, top = 0, bottom = 0 } end
return { vertical = 0, horizontal = 0, left = 0, right = 0, top = 0, bottom = 0 }
end

Expand Down
61 changes: 61 additions & 0 deletions lua/blink/cmp/windows/lib/scrollbar/geometry.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
--- Helper for calculating placement of the scrollbar thumb and gutter

--- @class blink.cmp.ScrollbarGeometry
--- @field width number
--- @field height number
--- @field row number
--- @field col number
--- @field zindex number
--- @field relative string
--- @field win number

local M = {}

--- @param target_win number
--- @return number
local function get_win_buf_height(target_win)
local buf = vim.api.nvim_win_get_buf(target_win)

-- not wrapping, so just get the line count
if not vim.wo[target_win].wrap then return vim.api.nvim_buf_line_count(buf) end

local width = vim.api.nvim_win_get_width(target_win)
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
local height = 0
for _, l in ipairs(lines) do
height = height + math.max(1, (math.ceil(vim.fn.strwidth(l) / width)))
end
return height
end

--- @param target_win number
--- @return { should_hide: boolean, thumb: blink.cmp.ScrollbarGeometry, gutter: blink.cmp.ScrollbarGeometry }
function M.get_geometry(target_win)
local width = vim.api.nvim_win_get_width(target_win)
local height = vim.api.nvim_win_get_height(target_win)
local zindex = vim.api.nvim_win_get_config(target_win).zindex or 1

local buf_height = get_win_buf_height(target_win)

local thumb_height = math.max(1, math.floor(height * height / buf_height + 0.5) - 1)

local start_line = math.max(1, vim.fn.line('w0', target_win) - 1)
local pct = (start_line - 1) / buf_height
local thumb_offset = math.ceil(pct * (height - thumb_height))

local common_geometry = {
width = 1,
row = thumb_offset,
col = width,
relative = 'win',
win = target_win,
}

return {
should_hide = height >= buf_height,
thumb = vim.tbl_deep_extend('force', common_geometry, { height = thumb_height, zindex = zindex + 2 }),
gutter = vim.tbl_deep_extend('force', common_geometry, { row = 0, height = height, zindex = zindex + 1 }),
}
end

return M
65 changes: 65 additions & 0 deletions lua/blink/cmp/windows/lib/scrollbar/init.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
--- @class blink.cmp.ScrollbarConfig
--- @field enable_gutter boolean

--- @class blink.cmp.Scrollbar
--- @field target_win? number
--- @field win? blink.cmp.ScrollbarWin
--- @field autocmd? number
---
--- @field new fun(opts: blink.cmp.ScrollbarConfig): blink.cmp.Scrollbar
--- @field is_mounted fun(self: blink.cmp.Scrollbar): boolean
--- @field mount fun(self: blink.cmp.Scrollbar, target_win: number)
--- @field unmount fun(self: blink.cmp.Scrollbar)

--- @type blink.cmp.Scrollbar
--- @diagnostic disable-next-line: missing-fields
local scrollbar = {}

function scrollbar.new(opts)
local self = setmetatable({}, { __index = scrollbar })
self.win = require('blink.cmp.windows.lib.scrollbar.win').new(opts)
return self
end

function scrollbar:is_mounted() return self.autocmd ~= nil end

function scrollbar:mount(target_win)
-- unmount existing scrollbar if the target window changed
if self.target_win ~= target_win then
if not vim.api.nvim_win_is_valid(target_win) then return end
self:unmount()
end
-- ignore if already mounted
if self:is_mounted() then return end

local geometry = require('blink.cmp.windows.lib.scrollbar.geometry').get_geometry(target_win)
self.win:show_thumb(geometry.thumb)
self.win:show_gutter(geometry.gutter)

local function update()
if not vim.api.nvim_win_is_valid(target_win) then return self:unmount() end

local updated_geometry = require('blink.cmp.windows.lib.scrollbar.geometry').get_geometry(target_win)
if updated_geometry.should_hide then return self.win:hide_thumb() end

self.win:show_thumb(updated_geometry.thumb)
self.win:show_gutter(updated_geometry.gutter)
end
-- HACK: for some reason, the autocmds don't fire on the initial mount
-- so we apply after on the next event loop iteration after the windows are definitely setup
vim.schedule(update)

self.autocmd = vim.api.nvim_create_autocmd(
{ 'WinScrolled', 'WinClosed', 'WinResized', 'CursorMoved', 'CursorMovedI' },
{ callback = update }
)
end

function scrollbar:unmount()
self.win:hide()

if self.autocmd then vim.api.nvim_del_autocmd(self.autocmd) end
self.autocmd = nil
end

return scrollbar
Loading

0 comments on commit 4c2a36c

Please sign in to comment.