Skip to content

Commit a6dd0af

Browse files
author
Bassam Data
committed
reafactor(selecta): make keymaps configurable see #4
1 parent 6401d17 commit a6dd0af

File tree

1 file changed

+135
-53
lines changed

1 file changed

+135
-53
lines changed

lua/namu/selecta/selecta.lua

+135-53
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@ require('selecta').pick(items, {
8888
---@field footer_pos? "left"|"center"|"right"
8989
---@field title_pos? "left"|string
9090

91+
---@class SelectaMovementConfig
92+
---@field next string|string[] Key(s) for moving to next item
93+
---@field previous string|string[] Key(s) for moving to previous item
94+
---@field close string|string[] Key(s) for closing picker
95+
---@field select string|string[] Key(s) for selecting item
96+
---@field alternative_next? string @deprecated Use next array instead
97+
---@field alternative_previous? string @deprecated Use previous array instead
98+
9199
---@class SelectaOptions
92100
---@field title? string
93101
---@field formatter? fun(item: SelectaItem): string
@@ -109,6 +117,7 @@ require('selecta').pick(items, {
109117
---@field initially_hidden? boolean
110118
---@field initial_index? number
111119
---@field debug? boolean
120+
---@field movement? SelectaMovementConfig
112121

113122
---@class SelectaHooks
114123
---@field on_render? fun(buf: number, items: SelectaItem[], opts: SelectaOptions) Called after items are rendered
@@ -177,10 +186,15 @@ M.config = {
177186
ratio = 0.7, -- percentage of screen width where right-aligned windows start
178187
},
179188
movement = {
180-
next = "<C-n>",
181-
previous = "<C-p>",
182-
alternative_next = "<DOWN>",
183-
alternative_previous = "<UP>",
189+
next = { "<C-n>", "<DOWN>" }, -- Support multiple keys
190+
previous = { "<C-p>", "<UP>" }, -- Support multiple keys
191+
close = { "<ESC>" }, -- close mapping
192+
select = { "<CR>" }, -- select mapping
193+
delete_word = {}, -- delete word mapping
194+
clear_line = {}, -- clear line mapping
195+
-- Deprecated mappings (but still working)
196+
-- alternative_next = "<DOWN>", -- @deprecated: Will be removed in v1.0
197+
-- alternative_previous = "<UP>", -- @deprecated: Will be removed in v1.0
184198
},
185199
multiselect = {
186200
enabled = false,
@@ -1055,19 +1069,90 @@ local SPECIAL_KEYS = {
10551069
MOUSE = vim.api.nvim_replace_termcodes("<LeftMouse>", true, true, true),
10561070
}
10571071

1072+
---Delete last word from query
1073+
---@param state SelectaState
1074+
local function delete_last_word(state)
1075+
-- If we're at the start, nothing to delete
1076+
if state.cursor_pos <= 1 then
1077+
return
1078+
end
1079+
1080+
-- Find the last non-space character before cursor
1081+
local last_char_pos = state.cursor_pos - 1
1082+
while last_char_pos > 1 and state.query[last_char_pos] == " " do
1083+
last_char_pos = last_char_pos - 1
1084+
end
1085+
1086+
-- Find the start of the word
1087+
local word_start = last_char_pos
1088+
while word_start > 1 and state.query[word_start - 1] ~= " " do
1089+
word_start = word_start - 1
1090+
end
1091+
1092+
-- Remove characters from word_start to last_char_pos
1093+
for i = word_start, last_char_pos do
1094+
table.remove(state.query, word_start)
1095+
end
1096+
1097+
-- Update cursor position
1098+
state.cursor_pos = word_start
1099+
state.initial_open = false
1100+
state.cursor_moved = false
1101+
end
1102+
1103+
---Clear the entire query line
1104+
---@param state SelectaState
1105+
local function clear_query_line(state)
1106+
state.query = {}
1107+
state.cursor_pos = 1
1108+
state.initial_open = false
1109+
state.cursor_moved = false
1110+
end
1111+
10581112
-- Pre-compute the movement keys
1113+
---@param opts SelectaOptions
1114+
---@return table<string, string[]>
10591115
local function get_movement_keys(opts)
10601116
local movement_config = opts.movement or M.config.movement
1061-
return {
1062-
next = {
1063-
vim.api.nvim_replace_termcodes(movement_config.next or "<C-n>", true, true, true),
1064-
vim.api.nvim_replace_termcodes(movement_config.alternative_next or "<C-j>", true, true, true),
1065-
},
1066-
previous = {
1067-
vim.api.nvim_replace_termcodes(movement_config.previous or "<C-p>", true, true, true),
1068-
vim.api.nvim_replace_termcodes(movement_config.alternative_previous or "<C-k>", true, true, true),
1069-
},
1117+
local keys = {
1118+
next = {},
1119+
previous = {},
1120+
close = {},
1121+
select = {},
1122+
delete_word = {},
1123+
clear_line = {},
10701124
}
1125+
1126+
-- Handle arrays of keys
1127+
for action, mapping in pairs(movement_config) do
1128+
if action ~= "alternative_next" and action ~= "alternative_previous" then
1129+
if type(mapping) == "table" then
1130+
-- Handle array of keys
1131+
for _, key in ipairs(mapping) do
1132+
table.insert(keys[action], vim.api.nvim_replace_termcodes(key, true, true, true))
1133+
end
1134+
elseif type(mapping) == "string" then
1135+
-- Handle single key as string
1136+
table.insert(keys[action], vim.api.nvim_replace_termcodes(mapping, true, true, true))
1137+
end
1138+
end
1139+
end
1140+
1141+
-- Handle deprecated alternative mappings
1142+
if movement_config.alternative_next then
1143+
-- TODO: version 0.6.0 will start sending this message
1144+
-- vim.notify_once(
1145+
-- "alternative_next/previous are deprecated and will be removed in v1.0. "
1146+
-- .. "Use movement.next/previous arrays instead.",
1147+
-- vim.log.levels.WARN
1148+
-- )
1149+
table.insert(keys.next, vim.api.nvim_replace_termcodes(movement_config.alternative_next, true, true, true))
1150+
end
1151+
if movement_config.alternative_previous then
1152+
table.insert(keys.previous, vim.api.nvim_replace_termcodes(movement_config.alternative_previous, true, true, true))
1153+
end
1154+
1155+
return keys
10711156
end
10721157

10731158
-- Simplified movement handler
@@ -1226,29 +1311,28 @@ local function handle_char(state, char, opts)
12261311
end
12271312

12281313
local char_key = type(char) == "number" and vim.fn.nr2char(char) or char
1314+
local movement_keys = get_movement_keys(opts)
12291315

1316+
-- Handle custom keymaps first
12301317
if opts.keymaps then
12311318
for _, keymap in ipairs(opts.keymaps) do
12321319
if char_key == vim.api.nvim_replace_termcodes(keymap.key, true, true, true) then
12331320
local selected = state.filtered_items[vim.api.nvim_win_get_cursor(state.win)[1]]
12341321
if selected then
1235-
-- Check for multiselect state
1322+
-- Handle multiselect state
12361323
if opts.multiselect and opts.multiselect.enabled and state.selected_count > 0 then
1237-
-- Collect selected items
12381324
local selected_items = {}
12391325
for _, item in ipairs(state.filtered_items) do
12401326
if state.selected[tostring(item.value or item.text)] then
12411327
table.insert(selected_items, item)
12421328
end
12431329
end
1244-
-- Pass all selected items to handler if there are any
12451330
local should_close = keymap.handler(selected_items, state, M.close_picker)
12461331
if should_close == false then
12471332
M.close_picker(state)
12481333
return nil
12491334
end
12501335
else
1251-
-- Original single-item behavior
12521336
local should_close = keymap.handler(selected, state, M.close_picker)
12531337
if should_close == false then
12541338
M.close_picker(state)
@@ -1261,11 +1345,9 @@ local function handle_char(state, char, opts)
12611345
end
12621346
end
12631347

1348+
-- Handle multiselect keymaps
12641349
if opts.multiselect and opts.multiselect.enabled then
12651350
local multiselect_keys = opts.multiselect.keymaps or M.config.multiselect.keymaps
1266-
1267-
M.clear_log() -- Optional: clear the log when starting new session
1268-
-- Handle multiselect keymaps
12691351
if char_key == vim.api.nvim_replace_termcodes(multiselect_keys.toggle, true, true, true) then
12701352
if handle_toggle(state, opts, 1) then
12711353
return nil
@@ -1284,42 +1366,25 @@ local function handle_char(state, char, opts)
12841366
return nil
12851367
end
12861368
end
1287-
-- Handle mouse clicks
1288-
if char_key == SPECIAL_KEYS.MOUSE and vim.v.mouse_win ~= state.win and vim.v.mouse_win ~= state.prompt_win then
1289-
if opts.on_cancel then
1290-
opts.on_cancel()
1291-
end
1292-
M.close_picker(state)
1293-
return nil
1294-
end
12951369

1296-
local movement_keys = get_movement_keys(opts)
1297-
local movement = nil
1298-
1299-
-- Determine movement direction from configured keys only
1370+
-- Handle movement and control keys
13001371
if vim.tbl_contains(movement_keys.previous, char_key) then
1301-
movement = -1
1372+
state.cursor_moved = true
1373+
state.initial_open = false
1374+
handle_movement(state, -1, opts)
1375+
return nil
13021376
elseif vim.tbl_contains(movement_keys.next, char_key) then
1303-
movement = 1
1304-
end
1305-
1306-
if movement then
13071377
state.cursor_moved = true
13081378
state.initial_open = false
1309-
handle_movement(state, movement, opts)
1310-
return nil -- prevent further processing
1311-
elseif char_key == SPECIAL_KEYS.LEFT then
1312-
state.cursor_pos = math.max(1, state.cursor_pos - 1)
1313-
elseif char_key == SPECIAL_KEYS.RIGHT then
1314-
state.cursor_pos = math.min(#state.query + 1, state.cursor_pos + 1)
1315-
elseif char_key == SPECIAL_KEYS.BS then
1316-
if state.cursor_pos > 1 then
1317-
table.remove(state.query, state.cursor_pos - 1)
1318-
state.cursor_pos = state.cursor_pos - 1
1319-
state.initial_open = false
1320-
state.cursor_moved = false
1379+
handle_movement(state, 1, opts)
1380+
return nil
1381+
elseif vim.tbl_contains(movement_keys.close, char_key) then
1382+
if opts.on_cancel then
1383+
opts.on_cancel()
13211384
end
1322-
elseif char_key == SPECIAL_KEYS.CR then
1385+
M.close_picker(state)
1386+
return nil
1387+
elseif vim.tbl_contains(movement_keys.select, char_key) then
13231388
if opts.multiselect and opts.multiselect.enabled then
13241389
local selected_items = {}
13251390
for _, item in ipairs(state.items) do
@@ -1330,27 +1395,44 @@ local function handle_char(state, char, opts)
13301395
if #selected_items > 0 and opts.multiselect.on_select then
13311396
opts.multiselect.on_select(selected_items)
13321397
elseif #selected_items == 0 then
1333-
-- If no items selected, treat as single select
13341398
local current = state.filtered_items[vim.api.nvim_win_get_cursor(state.win)[1]]
13351399
if current and opts.on_select then
13361400
opts.on_select(current)
13371401
end
13381402
end
13391403
else
1340-
-- Existing single-select behavior
13411404
local selected = state.filtered_items[vim.api.nvim_win_get_cursor(state.win)[1]]
13421405
if selected and opts.on_select then
13431406
opts.on_select(selected)
13441407
end
13451408
end
13461409
M.close_picker(state)
13471410
return nil
1348-
elseif char_key == SPECIAL_KEYS.ESC then
1411+
elseif vim.tbl_contains(movement_keys.delete_word, char_key) then
1412+
delete_last_word(state)
1413+
update_display(state, opts)
1414+
return nil
1415+
elseif vim.tbl_contains(movement_keys.clear_line, char_key) then
1416+
clear_query_line(state)
1417+
update_display(state, opts)
1418+
return nil
1419+
elseif char_key == SPECIAL_KEYS.MOUSE and vim.v.mouse_win ~= state.win and vim.v.mouse_win ~= state.prompt_win then
13491420
if opts.on_cancel then
13501421
opts.on_cancel()
13511422
end
13521423
M.close_picker(state)
13531424
return nil
1425+
elseif char_key == SPECIAL_KEYS.LEFT then
1426+
state.cursor_pos = math.max(1, state.cursor_pos - 1)
1427+
elseif char_key == SPECIAL_KEYS.RIGHT then
1428+
state.cursor_pos = math.min(#state.query + 1, state.cursor_pos + 1)
1429+
elseif char_key == SPECIAL_KEYS.BS then
1430+
if state.cursor_pos > 1 then
1431+
table.remove(state.query, state.cursor_pos - 1)
1432+
state.cursor_pos = state.cursor_pos - 1
1433+
state.initial_open = false
1434+
state.cursor_moved = false
1435+
end
13541436
elseif type(char) == "number" and char >= 32 and char <= 126 then
13551437
table.insert(state.query, state.cursor_pos, vim.fn.nr2char(char))
13561438
state.cursor_pos = state.cursor_pos + 1

0 commit comments

Comments
 (0)