Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

neovim/lua/bugabinga/cycle.lua

Raw
local 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