Skip to content

feat: add scrollbar to autocomplete menu #259

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -388,6 +390,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 @@ -419,6 +423,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 @@ -438,6 +444,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 All @@ -138,12 +139,13 @@
--- @field max_width? number
--- @field max_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 @@ -152,6 +154,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 @@ -370,6 +373,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 @@ -403,6 +408,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 @@ -422,6 +429,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 @@ -161,7 +162,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