dot-config/yazi/plugins/relative-motions.yazi/main.lua
--- @since 25.4.8
-- stylua: ignore
local MOTIONS_AND_OP_KEYS = {
	{ on = "0" }, { on = "1" }, { on = "2" }, { on = "3" }, { on = "4" },
	{ on = "5" }, { on = "6" }, { on = "7" }, { on = "8" }, { on = "9" },
	-- commands
	{ on = "d" }, { on = "v" }, { on = "y" }, { on = "x" },
	-- tab commands
	{ on = "t" }, { on = "L" }, { on = "H" }, { on = "w" },
	{ on = "W" }, { on = "<" }, { on = ">" }, { on = "~" },
	-- movement
	{ on = "g" }, { on = "j" }, { on = "k" }, { on = "h" }, { on = "l" }, { on = "<Down>" }, { on = "<Up>" }, { on = "<Left>" }, { on = "<Right>" }
}

-- stylua: ignore
local MOTION_KEYS = {
	{ on = "0" }, { on = "1" }, { on = "2" }, { on = "3" }, { on = "4" },
	{ on = "5" }, { on = "6" }, { on = "7" }, { on = "8" }, { on = "9" },
	-- movement
	{ on = "g" }, { on = "j" }, { on = "k" }, { on = "h" }, { on = "l" }, { on = "<Down>" }, { on = "<Up>" }, { on = "<Left>" }, { on = "<Right>" }
}

-- stylua: ignore
local DIRECTION_KEYS = {
	{ on = "j" }, { on = "k" }, { on = "<Down>" }, { on = "<Up>" },
	-- tab movement
	{ on = "t" }
}

local SHOW_NUMBERS_ABSOLUTE = 0
local SHOW_NUMBERS_RELATIVE = 1
local SHOW_NUMBERS_RELATIVE_ABSOLUTE = 2

local ENTER_MODE_FIRST = 0
local ENTER_MODE_CACHE = 1
local ENTER_MODE_CACHE_OR_FIRST = 2

-----------------------------------------------
----------------- R E N D E R -----------------
-----------------------------------------------

local render_motion_setup = ya.sync(function(_)
	ya.render()

	Status.motion = function() return ui.Span("") end

	Status.children_redraw = function(self, side)
		local lines = {}
		if side == self.RIGHT then
			lines[1] = self:motion(self)
		end
		for _, c in ipairs(side == self.RIGHT and self._right or self._left) do
			lines[#lines + 1] = (type(c[1]) == "string" and self[c[1]] or c[1])(self)
		end
		return ui.Line(lines)
	end

	-- TODO: check why it doesn't work line this
	-- Status:children_add(function() return ui.Span("") end, 1000, Status.RIGHT)
end)

local render_motion = ya.sync(function(_, motion_num, motion_cmd)
	ya.render()

	Status.motion = function(self)
		if not motion_num then
			return ui.Span("")
		end

		local style = self:style()

		local motion_span
		if not motion_cmd then
			motion_span = ui.Span(string.format("  %3d ", motion_num))
		else
			motion_span = ui.Span(string.format(" %3d%s ", motion_num, motion_cmd))
		end

		local status_config = th.status
		local separator_open = status_config.sep_right.open
		local separator_close = status_config.sep_right.close

		return ui.Line {
			ui.Span(separator_open):fg(style.main.bg),
			motion_span:style(style.main),
			ui.Span(separator_close):fg(style.main.bg),
			ui.Span(" "),
		}
	end
end)

local render_numbers = ya.sync(function(_, mode)
	ya.render()

	Entity.number = function(_, index, total, file, hovered)
		local idx
		if mode == SHOW_NUMBERS_RELATIVE then
			idx = math.abs(hovered - index)
		elseif mode == SHOW_NUMBERS_ABSOLUTE then
			idx = file.idx
		else -- SHOW_NUMBERS_RELATIVE_ABSOLUTE
			if hovered == index then
				idx = file.idx
			else
				idx = math.abs(hovered - index)
			end
		end

		local num_format = "%" .. #tostring(total) .. "d"

		-- emulate vim's hovered offset
		if hovered == index then
			return ui.Span(string.format(num_format .. " ", idx))
		else
			return ui.Span(string.format(" " .. num_format, idx))
		end
	end

	Current.redraw = function(self)
		local files = self._folder.window
		if #files == 0 then
			return self:empty()
		end

		local hovered_index
		for i, f in ipairs(files) do
			if f.is_hovered then
				hovered_index = i
				break
			end
		end

		local entities, linemodes = {}, {}
		for i, f in ipairs(files) do
			linemodes[#linemodes + 1] = Linemode:new(f):redraw()

			local entity = Entity:new(f)
			entities[#entities + 1] = ui.Line({ Entity:number(i, #self._folder.files, f, hovered_index), entity:redraw() })
				:style(entity:style())
		end

		return {
			ui.List(entities):area(self._area),
			ui.Text(linemodes):area(self._area):align(ui.Text.RIGHT),
		}
	end
end)

local function render_clear() render_motion() end

-----------------------------------------------
--------- C O M M A N D   P A R S E R ---------
-----------------------------------------------

local get_keys = ya.sync(function(state) return state._only_motions and MOTION_KEYS or MOTIONS_AND_OP_KEYS end)

local function normal_direction(dir)
	if dir == "<Down>" then
		return "j"
	elseif dir == "<Up>" then
		return "k"
	elseif dir == "<Left>" then
		return "h"
	elseif dir == "<Right>" then
		return "l"
	end
	return dir
end

local function get_cmd(first_char, keys)
	local last_key
	local lines = first_char or ""

	while true do
		render_motion(tonumber(lines))
		local key = ya.which { cands = keys, silent = true }
		if not key then
			return nil, nil, nil
		end

		last_key = keys[key].on
		if not tonumber(last_key) then
			last_key = normal_direction(last_key)
			break
		end

		lines = lines .. last_key
	end

	render_motion(tonumber(lines), last_key)

	-- command direction
	local direction
	if last_key == "g" or last_key == "v" or last_key == "d" or last_key == "y" or last_key == "x" then
		DIRECTION_KEYS[#DIRECTION_KEYS + 1] = {
			on = last_key,
		}
		local direction_key = ya.which { cands = DIRECTION_KEYS, silent = true }
		if not direction_key then
			return nil, nil, nil
		end

		direction = DIRECTION_KEYS[direction_key].on
		direction = normal_direction(direction)
	end

	return tonumber(lines), last_key, direction
end

local function is_tab_command(command)
	local tab_commands = { "t", "L", "H", "w", "W", "<", ">", "~" }
	for _, cmd in ipairs(tab_commands) do
		if command == cmd then
			return true
		end
	end
	return false
end

local get_active_tab = ya.sync(function(_) return cx.tabs.idx end)

local get_cache_or_first_dir = ya.sync(function(state)
	if state._enter_mode == ENTER_MODE_CACHE then
		return nil
	elseif state._enter_mode == ENTER_MODE_CACHE_OR_FIRST then
		local hovered_file = cx.active.current.hovered

		if hovered_file ~= nil and hovered_file.cha.is_dir then
			return cx.active.current.cursor
		end
	end

	local files = cx.active.current.files
	local index = 1

	for i = 1, #files do
		if files[i].cha.is_dir then
			index = i
			break
		end
	end

	return index - 1
end)
-----------------------------------------------
---------- E N T R Y   /   S E T U P ----------
-----------------------------------------------

return {
	entry = function(_, job)
		local initial_value

		local args = job.args
		-- this is checking if the argument is a valid number
		if #args > 0 then
			initial_value = tostring(tonumber(args[1]))
			if initial_value == "nil" then
				return
			end
		end

		local lines, cmd, direction = get_cmd(initial_value, get_keys())
		if not lines or not cmd then
			-- command was cancelled
			render_clear()
			return
		end

		if cmd == "g" then
			if direction == "g" then
				ya.mgr_emit("arrow", { "top" })
				ya.mgr_emit("arrow", { lines - 1 })
				render_clear()
				return
			elseif direction == "j" then
				cmd = "j"
			elseif direction == "k" then
				cmd = "k"
			elseif direction == "t" then
				ya.mgr_emit("tab_switch", { lines - 1 })
				render_clear()
				return
			else
				-- no valid direction
				render_clear()
				return
			end
		end

		if cmd == "j" then
			ya.mgr_emit("arrow", { lines })
		elseif cmd == "k" then
			ya.mgr_emit("arrow", { -lines })
		elseif cmd == "h" then
			for _ = 1, lines do
				ya.mgr_emit("leave", {})
			end
		elseif cmd == "l" then
			for _ = 1, lines do
				ya.mgr_emit("enter", {})
				local file_idx = get_cache_or_first_dir()
				if file_idx then
					ya.mgr_emit("arrow", { "top" })
					ya.mgr_emit("arrow", { file_idx })
				end
			end
		elseif is_tab_command(cmd) then
			if cmd == "t" then
				for _ = 1, lines do
					ya.mgr_emit("tab_create", {})
				end
			elseif cmd == "H" then
				ya.mgr_emit("tab_switch", { -lines, relative = true })
			elseif cmd == "L" then
				ya.mgr_emit("tab_switch", { lines, relative = true })
			elseif cmd == "w" then
				ya.mgr_emit("tab_close", { lines - 1 })
			elseif cmd == "W" then
				local curr_tab = get_active_tab()
				local del_tab = curr_tab + lines - 1
				for _ = curr_tab, del_tab do
					ya.mgr_emit("tab_close", { curr_tab - 1 })
				end
				ya.mgr_emit("tab_switch", { curr_tab - 1 })
			elseif cmd == "<" then
				ya.mgr_emit("tab_swap", { -lines })
			elseif cmd == ">" then
				ya.mgr_emit("tab_swap", { lines })
			elseif cmd == "~" then
				local jump = lines - get_active_tab()
				ya.mgr_emit("tab_swap", { jump })
			end
		else
			ya.mgr_emit("visual_mode", {})
			-- invert direction when user specifies it
			if direction == "k" then
				ya.mgr_emit("arrow", { -lines })
			elseif direction == "j" then
				ya.mgr_emit("arrow", { lines })
			else
				ya.mgr_emit("arrow", { lines - 1 })
			end
			ya.mgr_emit("escape", {})

			if cmd == "d" then
				ya.mgr_emit("remove", {})
			elseif cmd == "y" then
				ya.mgr_emit("yank", {})
			elseif cmd == "x" then
				ya.mgr_emit("yank", { cut = true })
			end
		end

		render_clear()
	end,
	setup = function(state, args)
		if not args then
			return
		end

		-- initialize state variables
		state._only_motions = args["only_motions"] or false

		if args["show_motion"] then
			render_motion_setup()
		end

		if args["enter_mode"] == "cache" then
			state._enter_mode = ENTER_MODE_CACHE
		elseif args["enter_mode"] == "first" then
			state._enter_mode = ENTER_MODE_FIRST
		elseif args["enter_mode"] == "cache_or_first" then
			state._enter_mode = ENTER_MODE_CACHE_OR_FIRST
		else
			state._enter_mode = ENTER_MODE_CACHE_OR_FIRST
		end

		if args["show_numbers"] == "absolute" then
			render_numbers(SHOW_NUMBERS_ABSOLUTE)
		elseif args["show_numbers"] == "relative" then
			render_numbers(SHOW_NUMBERS_RELATIVE)
		elseif args["show_numbers"] == "relative_absolute" then
			render_numbers(SHOW_NUMBERS_RELATIVE_ABSOLUTE)
		end
	end,
}
