repositories / dotfiles
dotfiles
bugabingas dorkfiles
owned by admin
neovim/lua/bugabinga/cycle.lua
Rawlocal auto = require 'std.auto'
local map = require 'std.map'
local table = require 'std.table'
local history = {}
local state = {
timer = nil,
source_buf = nil,
source_win = nil,
popup_win = nil,
popup_buf = nil,
selecting_idx = 1,
buffers = {},
hl_ns = nil,
}
local SETTLE_MS = 1000
local MAX_HEIGHT = 100
local ICON_WIDTH = 2
local PATH_START_OFFSET = 3
local function get_icon(buf_path)
local ok, icon_data = pcall(require, 'mini.icons')
if not ok then return ' ', '' end
local icon, hl = icon_data.get('file', buf_path)
return icon or ' ', hl or ''
end
local function truncate(str, max)
if #str > max then
return str:sub(1, max - 1) .. '…'
end
return str
end
local function shorten_path(full_path, max_len)
local basename = vim.fs.basename(full_path)
local abs_cwd = vim.fs.normalize(vim.fn.getcwd())
local abs_path = vim.fs.normalize(full_path)
if abs_path:find(abs_cwd, 1, true) ~= 1 then
return #basename <= max_len and basename or truncate(basename, max_len)
end
local rel_path = abs_path:sub(#abs_cwd + 1)
if rel_path:sub(1, 1) == '/' then
rel_path = rel_path:sub(2)
end
rel_path = './' .. rel_path
if #rel_path <= max_len then
return rel_path
end
if #basename >= max_len then
return truncate(basename, max_len)
end
local available = max_len - #basename - 1
if available <= 0 then
return basename
end
if #rel_path - #basename > available then
local dirname_len = available - 1
if dirname_len > 0 then
local dirname_start = #rel_path - #basename - dirname_len
rel_path = './…/' .. rel_path:sub(dirname_start)
else
rel_path = './…'
end
end
return rel_path
end
local function render_item(b, idx, selecting_idx, width)
local prefix = idx == selecting_idx and '› ' or ' '
local full_path = b.name
local icon, icon_hl = get_icon(full_path)
local icon_len = #icon
local rel_path = shorten_path(full_path, width - #prefix - icon_len - 1)
local line = prefix .. icon .. ' ' .. rel_path
local basename = vim.fs.basename(full_path)
local basename_in_line = icon .. ' ' .. rel_path
local name_start_byte = basename_in_line:find(basename, 1, true)
local name_start = #prefix + (name_start_byte or 1)
local name_end_exclusive = name_start + #basename
local info = {
line_idx = idx - 1,
icon_hl = icon_hl ~= '' and icon_hl or nil,
name_start = name_start,
name_end_exclusive = name_end_exclusive,
}
return line, info
end
local function render_list(buffers, selecting_idx, width)
local lines = {}
local highlights = {}
for i, b in ipairs(buffers) do
local line, info = render_item(b, i, selecting_idx, width)
lines[i] = line
highlights[i] = info
end
return lines, highlights
end
local function get_listed_buffers()
local cur = vim.api.nvim_get_current_buf()
local result = {}
for _, bufnr in ipairs(history) do
if vim.api.nvim_buf_is_valid(bufnr) and vim.bo[bufnr].buflisted then
local name = vim.api.nvim_buf_get_name(bufnr)
if name ~= '' then
table.insert(result, { bufnr = bufnr, name = name })
end
end
end
for _, info in ipairs(vim.fn.getbufinfo { buflisted = 1 }) do
local bufnr = info.bufnr
if vim.api.nvim_buf_is_valid(bufnr) and vim.bo[bufnr].buflisted and info.name ~= '' then
local found = false
for _, b in ipairs(result) do
if b.bufnr == bufnr then found = true break end
end
if not found then
table.insert(result, { bufnr = bufnr, name = info.name })
end
end
end
return result
end
local function close_popup()
if state.popup_win and type(state.popup_win) == 'number' then
local ok, valid = pcall(vim.api.nvim_win_is_valid, state.popup_win)
if ok and valid then
pcall(vim.api.nvim_win_close, state.popup_win, true)
end
end
state.popup_win = nil
state.popup_buf = nil
state.selecting_idx = 1
state.buffers = {}
state.hl_ns = nil
end
local function cancel()
if state.timer then
state.timer:stop()
state.timer:close()
state.timer = nil
end
close_popup()
if state.source_buf then
local ok, valid = pcall(vim.api.nvim_buf_is_valid, state.source_buf)
if ok and valid then
pcall(vim.api.nvim_set_current_buf, state.source_buf)
end
end
end
local function commit_switch()
local target = state.buffers[state.selecting_idx]
if target then
close_popup()
vim.api.nvim_set_current_buf(target.bufnr)
for i = #history, 1, -1 do
if history[i] == target.bufnr then table.remove(history, i) end
end
table.insert(history, target.bufnr)
if #history > 50 then table.remove(history, 1) end
else
close_popup()
end
end
local function apply_highlights(bufnr, hl_ns, highlights)
for _, h in ipairs(highlights) do
if h.icon_hl then
vim.hl.range(bufnr, hl_ns, h.icon_hl, { h.line_idx, 0 }, { h.line_idx, 2 })
end
vim.hl.range(
bufnr,
hl_ns,
'CycleBufferFileName',
{ h.line_idx, h.name_start - 1 },
{ h.line_idx, h.name_end_exclusive - 1 }
)
end
end
local function clear_highlights()
if state.hl_ns and state.popup_buf and vim.api.nvim_buf_is_valid(state.popup_buf) then
vim.api.nvim_buf_clear_namespace(state.popup_buf, state.hl_ns, 0, -1)
end
end
local function open_popup()
close_popup()
state.buffers = get_listed_buffers()
if #state.buffers == 0 then return end
if state.selecting_idx > #state.buffers then
state.selecting_idx = 1
end
local width = math.min(55, vim.o.columns - 4)
local height = math.min(#state.buffers, MAX_HEIGHT)
local row = math.floor((vim.o.lines - height) / 2) - 1
local col = math.floor((vim.o.columns - width) / 2)
state.popup_buf = vim.api.nvim_create_buf(false, true)
state.hl_ns = vim.api.nvim_create_namespace('cycle_buffer')
local lines, highlights = render_list(state.buffers, state.selecting_idx, width)
vim.api.nvim_buf_set_lines(state.popup_buf, 0, -1, false, lines)
vim.bo[state.popup_buf].modifiable = false
vim.bo[state.popup_buf].filetype = 'CycleBuffer'
local opts = {
relative = 'editor',
width = width,
height = height,
row = row,
col = col,
style = 'minimal',
border = vim.g.border_style,
focusable = false,
}
state.popup_win = vim.api.nvim_open_win(state.popup_buf, true, opts)
vim.wo[state.popup_win].cursorline = true
apply_highlights(state.popup_buf, state.hl_ns, highlights)
map.normal { keys = '<C-Tab>', command = function() BugabingaCycle.cycle(1) end, buffer = state.popup_buf }
map.normal { keys = '<C-S-Tab>', command = function() BugabingaCycle.cycle(-1) end, buffer = state.popup_buf }
map.normal { keys = '<Tab>', command = function() BugabingaCycle.cycle(1) end, buffer = state.popup_buf }
map.normal { keys = '<S-Tab>', command = function() BugabingaCycle.cycle(-1) end, buffer = state.popup_buf }
map.normal { keys = '<Esc>', command = cancel, buffer = state.popup_buf }
map.normal { keys = 'q', command = cancel, buffer = state.popup_buf }
end
local function cycle(direction)
if state.timer then
state.timer:stop()
state.timer:close()
state.timer = nil
end
if not state.popup_win then
state.source_win = vim.api.nvim_get_current_win()
state.source_buf = vim.api.nvim_get_current_buf()
state.selecting_idx = 1
open_popup()
if not state.popup_win then
vim.notify('buffer cycle: No other buffers', vim.log.levels.INFO)
return
end
else
state.selecting_idx = state.selecting_idx + direction
if state.selecting_idx < 1 then state.selecting_idx = #state.buffers
elseif state.selecting_idx > #state.buffers then state.selecting_idx = 1 end
if state.popup_buf and vim.api.nvim_buf_is_valid(state.popup_buf) then
local width = math.min(55, vim.o.columns - 4)
local lines, highlights = render_list(state.buffers, state.selecting_idx, width)
clear_highlights()
local was_modifiable = vim.bo[state.popup_buf].modifiable
vim.bo[state.popup_buf].modifiable = true
vim.api.nvim_buf_set_lines(state.popup_buf, 0, -1, false, lines)
vim.bo[state.popup_buf].modifiable = was_modifiable
apply_highlights(state.popup_buf, state.hl_ns, highlights)
vim.api.nvim_win_set_cursor(state.popup_win, { state.selecting_idx, 0 })
end
end
local timer = assert(vim.uv.new_timer())
timer:start(SETTLE_MS, 0, vim.schedule_wrap(function()
timer:stop()
timer:close()
state.timer = nil
commit_switch()
end))
state.timer = timer
end
auto 'cycle_mru' {
events = 'BufEnter',
command = function()
if state.popup_win then return end
local bufnr = vim.api.nvim_get_current_buf()
if not vim.bo[bufnr].buflisted then return end
local name = vim.api.nvim_buf_get_name(bufnr)
if name == '' then return end
for i = #history, 1, -1 do
if history[i] == bufnr then table.remove(history, i) end
end
table.insert(history, bufnr)
if #history > 50 then table.remove(history, 1) end
end,
}
map.normal {
description = 'Cycle to next MRU buffer.',
category = 'cycle',
keys = '<C-Tab>',
command = function() cycle(1) end,
}
map.normal {
description = 'Cycle to previous MRU buffer.',
category = 'cycle',
keys = '<C-S-Tab>',
command = function() cycle(-1) end,
}
auto 'cycle_bufwipeout' {
events = 'BufWipeout',
command = function(args)
for i = #history, 1, -1 do
if history[i] == args.buf then table.remove(history, i) end
end
end,
}
auto 'cycle_vimleave' {
events = 'VimLeave',
once = true,
command = function()
cancel()
end,
}
_G.BugabingaCycle = {
cycle = cycle,
cancel = cancel,
history = history,
get_icon = get_icon,
shorten_path = shorten_path,
}
return BugabingaCycle