From 3705d3a08fbb4fb7e67d8a44c9dc6a031171bb6e Mon Sep 17 00:00:00 2001 From: Soifou Date: Tue, 12 Aug 2025 14:31:49 +0200 Subject: [PATCH 1/4] feat(keymap): add `jump_by` option to navigate by item property Introduce `jump_by` option for `select_next` and `select_prev` commands to jump to the item whose specified property differs from the current one. Support primitive properties (`string`, `number`, `boolean`) of a completion item, including: - client_id - client_name - deprecated - exact - kind - score - score_offset - source_id - source_name Honor cycling, set by `completion.list.cycle.from_[top|bottom]`. Falls back to `count` when no match is found. Example, jump to the next/previous `source_id`: ```lua opts.keymap = { -- ... [''] = { function(cmp) return cmp.select_next({ jump_by = 'source_id' }) end, 'fallback', }, [''] = { function(cmp) return cmp.select_prev({ jump_by = 'source_id' }) end, 'fallback', }, }, ``` Closes #1890 --- doc/configuration/keymap.md | 2 + lua/blink/cmp/completion/list.lua | 61 ++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/doc/configuration/keymap.md b/doc/configuration/keymap.md index db672247..baba56f6 100644 --- a/doc/configuration/keymap.md +++ b/doc/configuration/keymap.md @@ -57,9 +57,11 @@ keymap = { - `select_prev`: Selects the previous item, cycling to the bottom of the list if at the top, if `completion.list.cycle.from_top == true` - Optionally control the `auto_insert` property of `completion.list.selection`: `function(cmp) cmp.select_prev({ auto_insert = false }) end` - Optionally, run when ghost text is visible, instead of only when the menu is visible: `function(cmp) cmp.select_prev({ on_ghost_text = true })` + - Optionally, jump to the item whose specified property differs from the current one. Fallbacks to `count` when no such item is found: `function(cmp) cmp.select_prev({ jump_by = 'source_id' })` - `select_next`: Selects the next item, cycling to the top of the list if at the bottom, if `completion.list.cycle.from_bottom == true` - Optionally control the `auto_insert` property of `completion.list.selection`: `function(cmp) cmp.select_next({ auto_insert = false }) end` - Optionally, run when ghost text is visible, instead of only when the menu is visible: `function(cmp) cmp.select_next({ on_ghost_text = true })` + - Optionally, jump to the item whose specified property differs from the current one. Fallbacks to `count` when no such item is found: `function(cmp) cmp.select_next({ jump_by = 'source_id' })` - `insert_prev`: Inserts the previous item (`auto_insert`), cycling to the bottom of the list if at the top, if `completion.list.cycle.from_top == true`. This will trigger completions if none are available, unlike `select_prev` which would fallback to the next keymap in this case. - `insert_next`: Inserts the next item (`auto_insert`), cycling to the top of the list if at the bottom, if `completion.list.cycle.from_bottom == true`. This will trigger completions if none are available, unlike `select_next` which would fallback to the next keymap in this case. - `show_documentation`: Shows the documentation for the currently selected item diff --git a/lua/blink/cmp/completion/list.lua b/lua/blink/cmp/completion/list.lua index ce0c9fa5..5fc3bd3a 100644 --- a/lua/blink/cmp/completion/list.lua +++ b/lua/blink/cmp/completion/list.lua @@ -21,6 +21,7 @@ --- @field select fun(idx?: number, opts?: { auto_insert?: boolean, undo_preview?: boolean, is_explicit_selection?: boolean }) --- @field select_next fun(opts?: blink.cmp.CompletionListSelectOpts) --- @field select_prev fun(opts?: blink.cmp.CompletionListSelectOpts) +--- @field jump_by fun(dir: number, opts?: blink.cmp.CompletionListSelectOpts): boolean --- --- @field undo_preview fun() --- @field apply_preview fun(item: blink.cmp.CompletionItem) @@ -28,9 +29,21 @@ --- @class blink.cmp.CompletionListSelectOpts --- @field count? number The number of items to jump by, defaults to 1 +--- @field jump_by? blink.cmp.CompletionListJumpBy Jump to the item whose specified property differs from the current one. Fallbacks to `count` when no such item is found. --- @field auto_insert? boolean Insert the completion item automatically when selecting it --- @field on_ghost_text? boolean Run when ghost text is visible, instead of only when the menu is visible +--- @alias blink.cmp.CompletionListJumpBy +--- | 'client_id' +--- | 'client_name' +--- | 'deprecated' +--- | 'exact' +--- | 'kind' +--- | 'score' +--- | 'score_offset' +--- | 'source_id' +--- | 'source_name' + --- @class blink.cmp.CompletionListSelectAndAcceptOpts --- @field callback? fun() Called after the item is accepted @@ -198,7 +211,10 @@ function list.select_next(opts) return list.select(1, opts) end - -- typical case, select the next item + -- try to jump to the item whose specified property differs from the current one + if list.jump_by(1, opts) then return end + + -- fallback, select the next item local count = opts and opts.count or 1 list.select(math.min(list.selected_item_idx + count, #list.items), opts) end @@ -226,11 +242,52 @@ function list.select_prev(opts) return list.select(#list.items, opts) end - -- typical case, select the previous item + -- try to jump to the item whose specified property differs from the current one + if list.jump_by(-1, opts) then return end + + -- fallback, select the previous item local count = opts and opts.count or 1 list.select(math.max(list.selected_item_idx - count, 1), opts) end +--- Jump to the item whose specified property differs from the current one. Supports cycling. +--- @param dir integer direction - 1 for next, -1 for previous +--- @param opts blink.cmp.CompletionListSelectOpts +--- @return boolean +function list.jump_by(dir, opts) + opts = opts or {} + + if not list.items or #list.items == 0 or not list.selected_item_idx then return false end + if type(opts.jump_by) ~= 'string' then return false end + + local current = list.items[list.selected_item_idx][opts.jump_by] + if not vim.tbl_contains({ 'string', 'number', 'boolean' }, type(current)) then return false end + + local function try_jump(start_idx, end_idx, step) + for i = start_idx, end_idx, step do + if list.items[i][opts.jump_by] ~= current then + list.select(i, opts) + return true + end + end + return false + end + + if dir == 1 then + if try_jump(list.selected_item_idx + 1, #list.items, 1) then return true end + if list.config and list.config.cycle and list.config.cycle.from_bottom then + return try_jump(1, list.selected_item_idx - 1, 1) + end + elseif dir == -1 then + if try_jump(list.selected_item_idx - 1, 1, -1) then return true end + if list.config and list.config.cycle and list.config.cycle.from_top then + return try_jump(#list.items, list.selected_item_idx + 1, -1) + end + end + + return false +end + ---------- Preview ---------- function list.undo_preview() From 35aef3efb4320869e7dbc07b43f9feb131fad427 Mon Sep 17 00:00:00 2001 From: Soifou Date: Wed, 13 Aug 2025 18:46:06 +0200 Subject: [PATCH 2/4] fix(keymap): use fallback mechanism for selection failures Selecting an item was always possible but with the introduction of the `jump_by` feature, it now *may* not select an item depending on the context. Thus, we need to implements a fallback mechanism for the `select_prev` and `select_next` commands when selection fails. --- doc/configuration/keymap.md | 4 ++-- lua/blink/cmp/completion/list.lua | 34 ++++++++++++++----------------- lua/blink/cmp/init.lua | 12 ++++------- 3 files changed, 21 insertions(+), 29 deletions(-) diff --git a/doc/configuration/keymap.md b/doc/configuration/keymap.md index baba56f6..b255bbbd 100644 --- a/doc/configuration/keymap.md +++ b/doc/configuration/keymap.md @@ -57,11 +57,11 @@ keymap = { - `select_prev`: Selects the previous item, cycling to the bottom of the list if at the top, if `completion.list.cycle.from_top == true` - Optionally control the `auto_insert` property of `completion.list.selection`: `function(cmp) cmp.select_prev({ auto_insert = false }) end` - Optionally, run when ghost text is visible, instead of only when the menu is visible: `function(cmp) cmp.select_prev({ on_ghost_text = true })` - - Optionally, jump to the item whose specified property differs from the current one. Fallbacks to `count` when no such item is found: `function(cmp) cmp.select_prev({ jump_by = 'source_id' })` + - Optionally, jump to the item whose specified property differs from the current one: `function(cmp) cmp.select_prev({ jump_by = 'source_id' })` - `select_next`: Selects the next item, cycling to the top of the list if at the bottom, if `completion.list.cycle.from_bottom == true` - Optionally control the `auto_insert` property of `completion.list.selection`: `function(cmp) cmp.select_next({ auto_insert = false }) end` - Optionally, run when ghost text is visible, instead of only when the menu is visible: `function(cmp) cmp.select_next({ on_ghost_text = true })` - - Optionally, jump to the item whose specified property differs from the current one. Fallbacks to `count` when no such item is found: `function(cmp) cmp.select_next({ jump_by = 'source_id' })` + - Optionally, jump to the item whose specified property differs from the current one: `function(cmp) cmp.select_next({ jump_by = 'source_id' })` - `insert_prev`: Inserts the previous item (`auto_insert`), cycling to the bottom of the list if at the top, if `completion.list.cycle.from_top == true`. This will trigger completions if none are available, unlike `select_prev` which would fallback to the next keymap in this case. - `insert_next`: Inserts the next item (`auto_insert`), cycling to the top of the list if at the bottom, if `completion.list.cycle.from_bottom == true`. This will trigger completions if none are available, unlike `select_next` which would fallback to the next keymap in this case. - `show_documentation`: Shows the documentation for the currently selected item diff --git a/lua/blink/cmp/completion/list.lua b/lua/blink/cmp/completion/list.lua index 5fc3bd3a..712cf7ac 100644 --- a/lua/blink/cmp/completion/list.lua +++ b/lua/blink/cmp/completion/list.lua @@ -18,9 +18,9 @@ --- @field get_selected_item fun(): blink.cmp.CompletionItem? --- @field get_selection_mode fun(context: blink.cmp.Context): { preselect: boolean, auto_insert: boolean } --- @field get_item_idx_in_list fun(item?: blink.cmp.CompletionItem): number? ---- @field select fun(idx?: number, opts?: { auto_insert?: boolean, undo_preview?: boolean, is_explicit_selection?: boolean }) ---- @field select_next fun(opts?: blink.cmp.CompletionListSelectOpts) ---- @field select_prev fun(opts?: blink.cmp.CompletionListSelectOpts) +--- @field select fun(idx?: number, opts?: { auto_insert?: boolean, undo_preview?: boolean, is_explicit_selection?: boolean }): boolean +--- @field select_next fun(opts?: blink.cmp.CompletionListSelectOpts): boolean +--- @field select_prev fun(opts?: blink.cmp.CompletionListSelectOpts): boolean --- @field jump_by fun(dir: number, opts?: blink.cmp.CompletionListSelectOpts): boolean --- --- @field undo_preview fun() @@ -29,7 +29,7 @@ --- @class blink.cmp.CompletionListSelectOpts --- @field count? number The number of items to jump by, defaults to 1 ---- @field jump_by? blink.cmp.CompletionListJumpBy Jump to the item whose specified property differs from the current one. Fallbacks to `count` when no such item is found. +--- @field jump_by? blink.cmp.CompletionListJumpBy Jump to the item whose specified property differs from the current one. --- @field auto_insert? boolean Insert the completion item automatically when selecting it --- @field on_ghost_text? boolean Run when ghost text is visible, instead of only when the menu is visible @@ -190,10 +190,11 @@ function list.select(idx, opts) list.is_explicitly_selected = opts.is_explicit_selection == nil and true or opts.is_explicit_selection list.selected_item_idx = idx list.select_emitter:emit({ idx = idx, item = item, items = list.items, context = list.context }) + return true end function list.select_next(opts) - if #list.items == 0 or list.context == nil then return end + if #list.items == 0 or list.context == nil then return false end -- haven't selected anything yet, select the first item, if cycling enabled if list.selected_item_idx == nil then return list.select(1, opts) end @@ -205,26 +206,25 @@ function list.select_next(opts) if not select_mode.preselect or select_mode.auto_insert then return list.select(nil, opts) end -- cycling around has been disabled, ignore - if not list.config.cycle.from_bottom then return end + if not list.config.cycle.from_bottom then return false end -- otherwise, we cycle around return list.select(1, opts) end - -- try to jump to the item whose specified property differs from the current one - if list.jump_by(1, opts) then return end + if opts and opts.jump_by then return list.jump_by(1, opts) end -- fallback, select the next item local count = opts and opts.count or 1 - list.select(math.min(list.selected_item_idx + count, #list.items), opts) + return list.select(math.min(list.selected_item_idx + count, #list.items), opts) end function list.select_prev(opts) - if #list.items == 0 or list.context == nil then return end + if #list.items == 0 or list.context == nil then return false end -- haven't selected anything yet, select the last item, if cycling enabled if list.selected_item_idx == nil then - if not list.config.cycle.from_top then return end + if not list.config.cycle.from_top then return false end return list.select(#list.items, opts) end @@ -236,18 +236,17 @@ function list.select_prev(opts) if not select_mode.preselect or select_mode.auto_insert then return list.select(nil, opts) end -- cycling around has been disabled, ignore - if not list.config.cycle.from_top then return end + if not list.config.cycle.from_top then return false end -- otherwise, we cycle around return list.select(#list.items, opts) end - -- try to jump to the item whose specified property differs from the current one - if list.jump_by(-1, opts) then return end + if opts and opts.jump_by then return list.jump_by(-1, opts) end -- fallback, select the previous item local count = opts and opts.count or 1 - list.select(math.max(list.selected_item_idx - count, 1), opts) + return list.select(math.max(list.selected_item_idx - count, 1), opts) end --- Jump to the item whose specified property differs from the current one. Supports cycling. @@ -265,10 +264,7 @@ function list.jump_by(dir, opts) local function try_jump(start_idx, end_idx, step) for i = start_idx, end_idx, step do - if list.items[i][opts.jump_by] ~= current then - list.select(i, opts) - return true - end + if list.items[i][opts.jump_by] ~= current then return list.select(i, opts) end end return false end diff --git a/lua/blink/cmp/init.lua b/lua/blink/cmp/init.lua index 617e6ef6..a933990c 100644 --- a/lua/blink/cmp/init.lua +++ b/lua/blink/cmp/init.lua @@ -201,8 +201,7 @@ end function cmp.select_prev(opts) local on_ghost_text = opts and opts.on_ghost_text if not cmp.is_menu_visible() and (not on_ghost_text or not cmp.is_ghost_text_visible()) then return end - vim.schedule(function() require('blink.cmp.completion.list').select_prev(opts) end) - return true + return require('blink.cmp.completion.list').select_prev(opts) end --- Select the next completion item @@ -210,24 +209,21 @@ end function cmp.select_next(opts) local on_ghost_text = opts and opts.on_ghost_text if not cmp.is_menu_visible() and (not on_ghost_text or not cmp.is_ghost_text_visible()) then return end - vim.schedule(function() require('blink.cmp.completion.list').select_next(opts) end) - return true + return require('blink.cmp.completion.list').select_next(opts) end --- Inserts the next item (`auto_insert`), cycling to the top of the list if at the bottom, if `completion.list.cycle.from_bottom == true`. --- This will trigger completions if none are available, unlike `select_next` which would fallback to the next keymap in this case. function cmp.insert_next() if not cmp.is_active() then return cmp.show_and_insert() end - vim.schedule(function() require('blink.cmp.completion.list').select_next({ auto_insert = true }) end) - return true + return require('blink.cmp.completion.list').select_next({ auto_insert = true }) end --- Inserts the previous item (`auto_insert`), cycling to the bottom of the list if at the top, if `completion.list.cycle.from_top == true`. --- This will trigger completions if none are available, unlike `select_prev` which would fallback to the next keymap in this case. function cmp.insert_prev() if not cmp.is_active() then return cmp.show_and_insert() end - vim.schedule(function() require('blink.cmp.completion.list').select_prev({ auto_insert = true }) end) - return true + return require('blink.cmp.completion.list').select_prev({ auto_insert = true }) end --- Gets the current context From 59466c24b0e539c6313439b506586843e8fe4341 Mon Sep 17 00:00:00 2001 From: Liam Dyer Date: Tue, 2 Sep 2025 09:23:54 -0400 Subject: [PATCH 3/4] refactor: assert presence of jump_by option --- lua/blink/cmp/completion/list.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/blink/cmp/completion/list.lua b/lua/blink/cmp/completion/list.lua index 712cf7ac..842372e0 100644 --- a/lua/blink/cmp/completion/list.lua +++ b/lua/blink/cmp/completion/list.lua @@ -255,9 +255,9 @@ end --- @return boolean function list.jump_by(dir, opts) opts = opts or {} + assert(vim.tbl_contains({ 'string', 'function' }, type(opts.jump_by)), 'jump_by must be a string or function') if not list.items or #list.items == 0 or not list.selected_item_idx then return false end - if type(opts.jump_by) ~= 'string' then return false end local current = list.items[list.selected_item_idx][opts.jump_by] if not vim.tbl_contains({ 'string', 'number', 'boolean' }, type(current)) then return false end From 99e2c66b3215864001de04de54ba3335b3fdae1d Mon Sep 17 00:00:00 2001 From: Liam Dyer Date: Tue, 2 Sep 2025 09:36:38 -0400 Subject: [PATCH 4/4] feat: list.can_select, schedule select_next and select_prev --- lua/blink/cmp/completion/list.lua | 21 +++++++++++++++++++++ lua/blink/cmp/init.lua | 22 ++++++++++++++-------- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/lua/blink/cmp/completion/list.lua b/lua/blink/cmp/completion/list.lua index 842372e0..b7b13c7c 100644 --- a/lua/blink/cmp/completion/list.lua +++ b/lua/blink/cmp/completion/list.lua @@ -21,6 +21,7 @@ --- @field select fun(idx?: number, opts?: { auto_insert?: boolean, undo_preview?: boolean, is_explicit_selection?: boolean }): boolean --- @field select_next fun(opts?: blink.cmp.CompletionListSelectOpts): boolean --- @field select_prev fun(opts?: blink.cmp.CompletionListSelectOpts): boolean +--- @field can_select fun(opts?: blink.cmp.CompletionListSelectOpts): boolean --- @field jump_by fun(dir: number, opts?: blink.cmp.CompletionListSelectOpts): boolean --- --- @field undo_preview fun() @@ -249,6 +250,26 @@ function list.select_prev(opts) return list.select(math.max(list.selected_item_idx - count, 1), opts) end +--- Check if we're able to perform a selection. +--- We need this because `select_next` and `select_prev` are scheduled, but we need to know whether +--- to fallback beforehand. +--- @param opts blink.cmp.CompletionListSelectOpts +--- @return boolean +function list.can_select(opts) + local cmp = require('blink.cmp') + local on_ghost_text = opts and opts.on_ghost_text + if not cmp.is_menu_visible() and (not on_ghost_text or not cmp.is_ghost_text_visible()) then return false end + + if not opts or not opts.jump_by then return true end + + if list.selected_item_idx then + local current = list.items[list.selected_item_idx][opts.jump_by] + if not vim.tbl_contains({ 'string', 'number', 'boolean' }, type(current)) then return false end + end + + return true +end + --- Jump to the item whose specified property differs from the current one. Supports cycling. --- @param dir integer direction - 1 for next, -1 for previous --- @param opts blink.cmp.CompletionListSelectOpts diff --git a/lua/blink/cmp/init.lua b/lua/blink/cmp/init.lua index a933990c..f15e4513 100644 --- a/lua/blink/cmp/init.lua +++ b/lua/blink/cmp/init.lua @@ -199,31 +199,37 @@ end --- Select the previous completion item --- @param opts? blink.cmp.CompletionListSelectOpts function cmp.select_prev(opts) - local on_ghost_text = opts and opts.on_ghost_text - if not cmp.is_menu_visible() and (not on_ghost_text or not cmp.is_ghost_text_visible()) then return end - return require('blink.cmp.completion.list').select_prev(opts) + if not require('blink.cmp.completion.list').can_select(opts) then return end + vim.schedule(function() require('blink.cmp.completion.list').select_prev(opts) end) + return true end --- Select the next completion item --- @param opts? blink.cmp.CompletionListSelectOpts function cmp.select_next(opts) - local on_ghost_text = opts and opts.on_ghost_text - if not cmp.is_menu_visible() and (not on_ghost_text or not cmp.is_ghost_text_visible()) then return end - return require('blink.cmp.completion.list').select_next(opts) + if not require('blink.cmp.completion.list').can_select(opts) then return end + vim.schedule(function() require('blink.cmp.completion.list').select_next(opts) end) + return true end --- Inserts the next item (`auto_insert`), cycling to the top of the list if at the bottom, if `completion.list.cycle.from_bottom == true`. --- This will trigger completions if none are available, unlike `select_next` which would fallback to the next keymap in this case. function cmp.insert_next() if not cmp.is_active() then return cmp.show_and_insert() end - return require('blink.cmp.completion.list').select_next({ auto_insert = true }) + if not require('blink.cmp.completion.list').can_select({ auto_insert = true }) then return end + + vim.schedule(function() require('blink.cmp.completion.list').select_next({ auto_insert = true }) end) + return true end --- Inserts the previous item (`auto_insert`), cycling to the bottom of the list if at the top, if `completion.list.cycle.from_top == true`. --- This will trigger completions if none are available, unlike `select_prev` which would fallback to the next keymap in this case. function cmp.insert_prev() if not cmp.is_active() then return cmp.show_and_insert() end - return require('blink.cmp.completion.list').select_prev({ auto_insert = true }) + if not require('blink.cmp.completion.list').can_select({ auto_insert = true }) then return end + + vim.schedule(function() require('blink.cmp.completion.list').select_prev({ auto_insert = true }) end) + return true end --- Gets the current context