Skip to content

Commit 398e669

Browse files
authored
feat: add wrap_around option for cursor navigation (#452)
* feat: add wrap_around option for cursor navigation When enabled (wrap_around = true), the cursor wraps to the opposite end when reaching the first or last item in the results list, instead of stopping at the boundary. This applies to: - move_up/move_down in insert mode (C-k/C-j, Up/Down, Tab/S-Tab, etc.) - j/k navigation in normal mode (list buffer) - Both top and bottom prompt positions The option defaults to false to preserve existing behavior. Pagination still takes priority: wrapping only occurs when there are no more pages to load in the current direction. * fix: wrap_around takes priority over pagination When wrap_around is enabled, cursor wraps within the current page instead of loading the next/previous page. This gives the expected cycling behavior where Tab at the top jumps to the bottom and S-Tab at the bottom jumps to the top. * fix: wrap only at global boundaries, paginate on intermediate pages Pagination now takes priority over wrap_around on non-boundary pages. Wrapping only occurs at the true global edges: - First item on first page → wraps to last item on last page - Last item on last page → wraps to first item on first page On all other page boundaries, normal pagination continues as expected. * fix: stylua formatting and remove wrap_around from FffKeymapsConfig type - Collapse multi-line callbacks to single-line (stylua) - Remove wrap_around from FffKeymapsConfig type annotation (belongs only on FffConfig)
1 parent 4693adf commit 398e669

2 files changed

Lines changed: 98 additions & 19 deletions

File tree

lua/fff/conf.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ local M = {}
7474
--- @field git table
7575
--- @field debug table
7676
--- @field logging table
77+
--- @field wrap_around boolean
7778
--- @field file_picker table
7879
--- @field grep FffGrepConfig
7980

@@ -199,6 +200,7 @@ local function init()
199200
max_threads = 4,
200201
lazy_sync = true, -- set to false if you want file indexing to start on open
201202
prompt_vim_mode = false, -- set to true to enable vim-mode in the prompt: <Esc> leaves insert for normal mode bindings (also allows <leader>p or <leader>l to jump around) the second <Esc> closes the picker
203+
wrap_around = false, -- set to true to wrap cursor to the opposite end when reaching the first/last item
202204
layout = {
203205
height = 0.8,
204206
width = 0.8,

lua/fff/picker_ui.lua

Lines changed: 96 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -913,8 +913,18 @@ local function move_list_cursor(direction)
913913
local items = M.state.filtered_items
914914
if #items == 0 then return end
915915

916+
local wrap_around = M.state.config and M.state.config.wrap_around or false
916917
local new_cursor = M.state.cursor + direction
917-
new_cursor = math.max(1, math.min(new_cursor, #items))
918+
919+
if wrap_around then
920+
if new_cursor < 1 then
921+
new_cursor = #items
922+
elseif new_cursor > #items then
923+
new_cursor = 1
924+
end
925+
else
926+
new_cursor = math.max(1, math.min(new_cursor, #items))
927+
end
918928

919929
if new_cursor ~= M.state.cursor then
920930
M.state.cursor = new_cursor
@@ -2048,12 +2058,60 @@ function M.update_status(progress)
20482058
})
20492059
end
20502060

2061+
--- Wrap cursor to the first page, first item (best result)
2062+
function M.wrap_to_first()
2063+
if M.state.pagination.page_index == 0 then
2064+
-- Already on first page, just move cursor
2065+
M.state.cursor = 1
2066+
return true
2067+
end
2068+
2069+
-- For non-grep mode, jump directly to page 0
2070+
if M.state.mode ~= 'grep' then
2071+
return M.load_page_at_index(0, function() M.state.cursor = 1 end)
2072+
end
2073+
2074+
-- For grep mode, we can only go back if page 0 offset is recorded
2075+
if M.state.pagination.grep_file_offsets[1] ~= nil then
2076+
return M.load_page_at_index(0, function() M.state.cursor = 1 end)
2077+
end
2078+
2079+
M.state.cursor = 1
2080+
return true
2081+
end
2082+
2083+
--- Wrap cursor to the last page, last item (worst result)
2084+
function M.wrap_to_last()
2085+
local page_size = M.state.pagination.page_size
2086+
if page_size == 0 then return false end
2087+
2088+
if M.state.mode ~= 'grep' then
2089+
local total = M.state.pagination.total_matched
2090+
if total == 0 then return false end
2091+
local max_page_index = math.max(0, math.ceil(total / page_size) - 1)
2092+
2093+
if M.state.pagination.page_index == max_page_index then
2094+
-- Already on last page, just move cursor to last item
2095+
M.state.cursor = #M.state.filtered_items
2096+
return true
2097+
end
2098+
2099+
return M.load_page_at_index(max_page_index, function(result_count) M.state.cursor = result_count end)
2100+
end
2101+
2102+
-- For grep mode, we can't jump to last page (sequential offsets required)
2103+
-- Just wrap within current page
2104+
M.state.cursor = #M.state.filtered_items
2105+
return true
2106+
end
2107+
20512108
function M.move_up()
20522109
if not M.state.active then return end
20532110
if #M.state.filtered_items == 0 then return end
20542111

20552112
local prompt_position = get_prompt_position()
20562113
local items_count = #M.state.filtered_items
2114+
local wrap_around = M.state.config and M.state.config.wrap_around or false
20572115

20582116
-- Pagination logic depends on prompt position
20592117
if prompt_position == 'bottom' then
@@ -2064,32 +2122,41 @@ function M.move_up()
20642122

20652123
if near_bottom and at_last_item then
20662124
local page_size = M.state.pagination.page_size
2125+
local has_more = false
20672126
if page_size > 0 then
2068-
local has_more
20692127
if M.state.mode == 'grep' then
20702128
has_more = M.state.pagination.grep_next_file_offset > 0
20712129
else
20722130
local max_page = math.max(0, math.ceil(M.state.pagination.total_matched / page_size) - 1)
20732131
has_more = M.state.pagination.page_index < max_page
20742132
end
2075-
if has_more then
2076-
M.load_next_page()
2077-
return
2078-
end
20792133
end
2080-
end
20812134

2082-
M.state.cursor = math.min(M.state.cursor + 1, items_count)
2135+
if has_more then
2136+
-- More pages available: paginate normally
2137+
M.load_next_page()
2138+
return
2139+
elseif wrap_around then
2140+
-- At global end (last item on last page): wrap to first
2141+
M.wrap_to_first()
2142+
end
2143+
else
2144+
M.state.cursor = math.min(M.state.cursor + 1, items_count)
2145+
end
20832146
else
20842147
-- Top prompt: scrolling UP means going to BETTER results (previous page)
20852148
if M.state.cursor <= M.state.pagination.prefetch_margin + 1 and M.state.cursor <= 1 then
20862149
if M.state.pagination.page_index > 0 then
2150+
-- More pages available: paginate normally
20872151
vim.schedule(M.load_previous_page)
20882152
return
2153+
elseif wrap_around then
2154+
-- At global start (first item on first page): wrap to last
2155+
M.wrap_to_last()
20892156
end
2157+
else
2158+
M.state.cursor = math.max(M.state.cursor - 1, 1)
20902159
end
2091-
2092-
M.state.cursor = math.max(M.state.cursor - 1, 1)
20932160
end
20942161

20952162
M.render_list()
@@ -2119,42 +2186,52 @@ function M.move_down()
21192186

21202187
local prompt_position = get_prompt_position()
21212188
local items_count = #M.state.filtered_items
2189+
local wrap_around = M.state.config and M.state.config.wrap_around or false
21222190

21232191
-- Pagination logic depends on prompt position
21242192
if prompt_position == 'bottom' then
21252193
-- Bottom prompt with reverse rendering: visually moving DOWN means cursor DECREASES
21262194
-- because lower index items (better) are rendered at higher line numbers
21272195
if M.state.cursor <= M.state.pagination.prefetch_margin + 1 and M.state.cursor <= 1 then
21282196
if M.state.pagination.page_index > 0 then
2197+
-- More pages available: paginate normally
21292198
vim.schedule(M.load_previous_page)
21302199
return
2200+
elseif wrap_around then
2201+
-- At global start (first item on first page): wrap to last
2202+
M.wrap_to_last()
21312203
end
2204+
else
2205+
M.state.cursor = math.max(M.state.cursor - 1, 1)
21322206
end
2133-
2134-
M.state.cursor = math.max(M.state.cursor - 1, 1)
21352207
else
21362208
-- Top prompt: scrolling DOWN means going to WORSE results (next page)
21372209
local near_bottom = M.state.cursor >= (items_count - M.state.pagination.prefetch_margin)
21382210
local at_last_item = M.state.cursor >= items_count
21392211

21402212
if near_bottom and at_last_item then
21412213
local page_size = M.state.pagination.page_size
2214+
local has_more = false
21422215
if page_size > 0 then
2143-
local has_more
21442216
if M.state.mode == 'grep' then
21452217
has_more = M.state.pagination.grep_next_file_offset > 0
21462218
else
21472219
local max_page = math.max(0, math.ceil(M.state.pagination.total_matched / page_size) - 1)
21482220
has_more = M.state.pagination.page_index < max_page
21492221
end
2150-
if has_more then
2151-
M.load_next_page()
2152-
return
2153-
end
21542222
end
2155-
end
21562223

2157-
M.state.cursor = math.min(M.state.cursor + 1, items_count)
2224+
if has_more then
2225+
-- More pages available: paginate normally
2226+
M.load_next_page()
2227+
return
2228+
elseif wrap_around then
2229+
-- At global end (last item on last page): wrap to first
2230+
M.wrap_to_first()
2231+
end
2232+
else
2233+
M.state.cursor = math.min(M.state.cursor + 1, items_count)
2234+
end
21582235
end
21592236

21602237
M.render_list()

0 commit comments

Comments
 (0)