Skip to content

Commit 4c2a36c

Browse files
willothySaghen
andauthored
feat: add scrollbar to autocomplete menu (#259)
* 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 <[email protected]>
1 parent 973f06a commit 4c2a36c

File tree

10 files changed

+245
-9
lines changed

10 files changed

+245
-9
lines changed

README.md

+8
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ MiniDeps.add({
134134
| `BlinkCmpMenu` | Pmenu | The completion menu window |
135135
| `BlinkCmpMenuBorder` | Pmenu | The completion menu window border |
136136
| `BlinkCmpMenuSelection` | PmenuSel | The completion menu window selected item |
137+
| `BlinkCmpScrollBarThumb` | Visual | The scrollbar thumb |
138+
| `BlinkCmpScrollBarGutter` | Pmenu | The scrollbar gutter |
137139
| `BlinkCmpLabel` | Pmenu | Label of the completion item |
138140
| `BlinkCmpLabelDeprecated` | Comment | Deprecated label of the completion item |
139141
| `BlinkCmpLabelMatch` | Pmenu | (Currently unused) Label of the completion item when it matches the query |
@@ -389,6 +391,8 @@ MiniDeps.add({
389391
winhighlight = 'Normal:BlinkCmpMenu,FloatBorder:BlinkCmpMenuBorder,CursorLine:BlinkCmpMenuSelection,Search:None',
390392
-- keep the cursor X lines away from the top/bottom of the window
391393
scrolloff = 2,
394+
-- note that the gutter will be disabled when border ~= 'none'
395+
scrollbar = true,
392396
-- which directions to show the window,
393397
-- falling back to the next direction when there's not enough space
394398
direction_priority = { 's', 'n' },
@@ -420,6 +424,8 @@ MiniDeps.add({
420424
border = 'padded',
421425
winblend = 0,
422426
winhighlight = 'Normal:BlinkCmpDoc,FloatBorder:BlinkCmpDocBorder,CursorLine:BlinkCmpDocCursorLine,Search:None',
427+
-- note that the gutter will be disabled when border ~= 'none'
428+
scrollbar = true,
423429
-- which directions to show the documentation window,
424430
-- for each of the possible autocomplete window directions,
425431
-- falling back to the next direction when there's not enough space
@@ -439,6 +445,8 @@ MiniDeps.add({
439445
border = 'padded',
440446
winblend = 0,
441447
winhighlight = 'Normal:BlinkCmpSignatureHelp,FloatBorder:BlinkCmpSignatureHelpBorder',
448+
-- note that the gutter will be disabled when border ~= 'none'
449+
scrollbar = false,
442450

443451
-- which directions to show the window,
444452
-- falling back to the next direction when there's not enough space

lua/blink/cmp/config.lua

+11-2
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@
112112
--- @field min_width? number
113113
--- @field max_height? number
114114
--- @field border? blink.cmp.WindowBorder
115+
--- @field scrollbar? boolean
115116
--- @field order? "top_down" | "bottom_up"
116117
--- @field direction_priority? ("n" | "s")[]
117118
--- @field auto_show? boolean
@@ -139,12 +140,13 @@
139140
--- @field desired_min_width? number
140141
--- @field desired_min_height? number
141142
--- @field border? blink.cmp.WindowBorder
143+
--- @field winblend? number
144+
--- @field winhighlight? string
145+
--- @field scrollbar? boolean
142146
--- @field direction_priority? blink.cmp.DocumentationDirectionPriorityConfig
143147
--- @field auto_show? boolean
144148
--- @field auto_show_delay_ms? number Delay before showing the documentation window
145149
--- @field update_delay_ms? number Delay before updating the documentation window
146-
--- @field winblend? number
147-
--- @field winhighlight? string
148150

149151
--- @class blink.cmp.SignatureHelpConfig
150152
--- @field min_width? number
@@ -153,6 +155,7 @@
153155
--- @field border? blink.cmp.WindowBorder
154156
--- @field winblend? number
155157
--- @field winhighlight? string
158+
--- @field scrollbar? boolean
156159
--- @field direction_priority? ("n" | "s")[]
157160

158161
--- @class GhostTextConfig
@@ -371,6 +374,8 @@ local config = {
371374
winhighlight = 'Normal:BlinkCmpMenu,FloatBorder:BlinkCmpMenuBorder,CursorLine:BlinkCmpMenuSelection,Search:None',
372375
-- keep the cursor X lines away from the top/bottom of the window
373376
scrolloff = 2,
377+
-- note that the gutter will be disabled when border ~= 'none'
378+
scrollbar = true,
374379
-- TODO: implement
375380
order = 'top_down',
376381
-- which directions to show the window,
@@ -405,6 +410,8 @@ local config = {
405410
border = 'padded',
406411
winblend = 0,
407412
winhighlight = 'Normal:BlinkCmpDoc,FloatBorder:BlinkCmpDocBorder,CursorLine:BlinkCmpDocCursorLine,Search:None',
413+
-- note that the gutter will be disabled when border ~= 'none'
414+
scrollbar = true,
408415
-- which directions to show the documentation window,
409416
-- for each of the possible autocomplete window directions,
410417
-- falling back to the next direction when there's not enough space
@@ -424,6 +431,8 @@ local config = {
424431
border = 'padded',
425432
winblend = 0,
426433
winhighlight = 'Normal:BlinkCmpSignatureHelp,FloatBorder:BlinkCmpSignatureHelpBorder',
434+
-- note that the gutter will be disabled when border ~= 'none'
435+
scrollbar = false,
427436

428437
-- which directions to show the window,
429438
-- falling back to the next direction when there's not enough space

lua/blink/cmp/init.lua

+3
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ cmp.add_default_highlights = function()
100100
set_hl('BlinkCmpKind' .. kind, { link = use_nvim_cmp and 'CmpItemKind' .. kind or 'BlinkCmpKind' })
101101
end
102102

103+
set_hl('BlinkCmpScrollBarThumb', { link = 'Visual' })
104+
set_hl('BlinkCmpScrollBarGutter', { link = 'Pmenu' })
105+
103106
set_hl('BlinkCmpGhostText', { link = use_nvim_cmp and 'CmpGhostText' or 'Comment' })
104107

105108
set_hl('BlinkCmpMenu', { link = 'Pmenu' })

lua/blink/cmp/windows/autocomplete.lua

+1
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ function autocomplete.setup()
8181
winhighlight = autocmp_config.winhighlight,
8282
cursorline = false,
8383
scrolloff = autocmp_config.scrolloff,
84+
scrollbar = autocmp_config.scrollbar,
8485
})
8586

8687
-- Setting highlights is slow and we update on every keystroke so we instead use a decoration provider

lua/blink/cmp/windows/documentation.lua

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ function docs.setup()
1212
border = config.border,
1313
winblend = config.winblend,
1414
winhighlight = config.winhighlight,
15+
scrollbar = config.scrollbar,
1516
wrap = true,
1617
filetype = 'markdown',
1718
})
@@ -168,7 +169,7 @@ function docs.update_position()
168169
elseif pos.direction == 'e' then
169170
set_config({
170171
row = -autocomplete_border_size.top,
171-
col = autocomplete_win_config.width + autocomplete_border_size.left,
172+
col = autocomplete_win_config.width + autocomplete_border_size.right,
172173
})
173174
elseif pos.direction == 'w' then
174175
set_config({ row = -autocomplete_border_size.top, col = -width - autocomplete_border_size.left })

lua/blink/cmp/windows/lib/init.lua

+17-6
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66
--- @field border? blink.cmp.WindowBorder
77
--- @field wrap? boolean
88
--- @field filetype? string
9+
--- @field winblend? number
910
--- @field winhighlight? string
1011
--- @field scrolloff? number
12+
--- @field scrollbar? boolean
1113

1214
--- @class blink.cmp.Window
1315
--- @field id? number
1416
--- @field buf? number
1517
--- @field config blink.cmp.WindowOptions
18+
--- @field scrollbar? blink.cmp.Scrollbar
1619
---
1720
--- @field new fun(config: blink.cmp.WindowOptions): blink.cmp.Window
1821
--- @field get_buf fun(self: blink.cmp.Window): number
@@ -52,8 +55,13 @@ function win.new(config)
5255
winblend = config.winblend or 0,
5356
winhighlight = config.winhighlight or 'Normal:NormalFloat,FloatBorder:NormalFloat',
5457
scrolloff = config.scrolloff or 0,
58+
scrollbar = config.scrollbar,
5559
}
5660

61+
if self.config.scrollbar then
62+
self.scrollbar = require('blink.cmp.windows.lib.scrollbar').new({ enable_gutter = self.config.border == 'none' })
63+
end
64+
5765
return self
5866
end
5967

@@ -100,6 +108,8 @@ function win:open()
100108
vim.api.nvim_set_option_value('cursorlineopt', 'line', { win = self.id })
101109
vim.api.nvim_set_option_value('cursorline', self.config.cursorline, { win = self.id })
102110
vim.api.nvim_set_option_value('scrolloff', self.config.scrolloff, { win = self.id })
111+
112+
if self.scrollbar then self.scrollbar:mount(self.id) end
103113
end
104114

105115
function win:set_option_value(option, value) vim.api.nvim_set_option_value(option, value, { win = self.id }) end
@@ -109,6 +119,7 @@ function win:close()
109119
vim.api.nvim_win_close(self.id, true)
110120
self.id = nil
111121
end
122+
if self.scrollbar then self.scrollbar:unmount() end
112123
end
113124

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

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

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

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

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
--- Helper for calculating placement of the scrollbar thumb and gutter
2+
3+
--- @class blink.cmp.ScrollbarGeometry
4+
--- @field width number
5+
--- @field height number
6+
--- @field row number
7+
--- @field col number
8+
--- @field zindex number
9+
--- @field relative string
10+
--- @field win number
11+
12+
local M = {}
13+
14+
--- @param target_win number
15+
--- @return number
16+
local function get_win_buf_height(target_win)
17+
local buf = vim.api.nvim_win_get_buf(target_win)
18+
19+
-- not wrapping, so just get the line count
20+
if not vim.wo[target_win].wrap then return vim.api.nvim_buf_line_count(buf) end
21+
22+
local width = vim.api.nvim_win_get_width(target_win)
23+
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
24+
local height = 0
25+
for _, l in ipairs(lines) do
26+
height = height + math.max(1, (math.ceil(vim.fn.strwidth(l) / width)))
27+
end
28+
return height
29+
end
30+
31+
--- @param target_win number
32+
--- @return { should_hide: boolean, thumb: blink.cmp.ScrollbarGeometry, gutter: blink.cmp.ScrollbarGeometry }
33+
function M.get_geometry(target_win)
34+
local width = vim.api.nvim_win_get_width(target_win)
35+
local height = vim.api.nvim_win_get_height(target_win)
36+
local zindex = vim.api.nvim_win_get_config(target_win).zindex or 1
37+
38+
local buf_height = get_win_buf_height(target_win)
39+
40+
local thumb_height = math.max(1, math.floor(height * height / buf_height + 0.5) - 1)
41+
42+
local start_line = math.max(1, vim.fn.line('w0', target_win) - 1)
43+
local pct = (start_line - 1) / buf_height
44+
local thumb_offset = math.ceil(pct * (height - thumb_height))
45+
46+
local common_geometry = {
47+
width = 1,
48+
row = thumb_offset,
49+
col = width,
50+
relative = 'win',
51+
win = target_win,
52+
}
53+
54+
return {
55+
should_hide = height >= buf_height,
56+
thumb = vim.tbl_deep_extend('force', common_geometry, { height = thumb_height, zindex = zindex + 2 }),
57+
gutter = vim.tbl_deep_extend('force', common_geometry, { row = 0, height = height, zindex = zindex + 1 }),
58+
}
59+
end
60+
61+
return M
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
--- @class blink.cmp.ScrollbarConfig
2+
--- @field enable_gutter boolean
3+
4+
--- @class blink.cmp.Scrollbar
5+
--- @field target_win? number
6+
--- @field win? blink.cmp.ScrollbarWin
7+
--- @field autocmd? number
8+
---
9+
--- @field new fun(opts: blink.cmp.ScrollbarConfig): blink.cmp.Scrollbar
10+
--- @field is_mounted fun(self: blink.cmp.Scrollbar): boolean
11+
--- @field mount fun(self: blink.cmp.Scrollbar, target_win: number)
12+
--- @field unmount fun(self: blink.cmp.Scrollbar)
13+
14+
--- @type blink.cmp.Scrollbar
15+
--- @diagnostic disable-next-line: missing-fields
16+
local scrollbar = {}
17+
18+
function scrollbar.new(opts)
19+
local self = setmetatable({}, { __index = scrollbar })
20+
self.win = require('blink.cmp.windows.lib.scrollbar.win').new(opts)
21+
return self
22+
end
23+
24+
function scrollbar:is_mounted() return self.autocmd ~= nil end
25+
26+
function scrollbar:mount(target_win)
27+
-- unmount existing scrollbar if the target window changed
28+
if self.target_win ~= target_win then
29+
if not vim.api.nvim_win_is_valid(target_win) then return end
30+
self:unmount()
31+
end
32+
-- ignore if already mounted
33+
if self:is_mounted() then return end
34+
35+
local geometry = require('blink.cmp.windows.lib.scrollbar.geometry').get_geometry(target_win)
36+
self.win:show_thumb(geometry.thumb)
37+
self.win:show_gutter(geometry.gutter)
38+
39+
local function update()
40+
if not vim.api.nvim_win_is_valid(target_win) then return self:unmount() end
41+
42+
local updated_geometry = require('blink.cmp.windows.lib.scrollbar.geometry').get_geometry(target_win)
43+
if updated_geometry.should_hide then return self.win:hide_thumb() end
44+
45+
self.win:show_thumb(updated_geometry.thumb)
46+
self.win:show_gutter(updated_geometry.gutter)
47+
end
48+
-- HACK: for some reason, the autocmds don't fire on the initial mount
49+
-- so we apply after on the next event loop iteration after the windows are definitely setup
50+
vim.schedule(update)
51+
52+
self.autocmd = vim.api.nvim_create_autocmd(
53+
{ 'WinScrolled', 'WinClosed', 'WinResized', 'CursorMoved', 'CursorMovedI' },
54+
{ callback = update }
55+
)
56+
end
57+
58+
function scrollbar:unmount()
59+
self.win:hide()
60+
61+
if self.autocmd then vim.api.nvim_del_autocmd(self.autocmd) end
62+
self.autocmd = nil
63+
end
64+
65+
return scrollbar

0 commit comments

Comments
 (0)