jump.lua
Branches > main > 48eacac > files / nvim / lua / jump.lua
local ns = vim.api.nvim_create_namespace("dev.grahp.nvim.jump")
local utils = require("utils")
--- Name meanings:
--- Jump target: Somewhere you can jump to - Union of Match and table of optional Disambiguator
--- Disambiguator: A third character used to differentiate jump targets when there are multiple
--- Match: Union Pos and a table of Char
--- Row: The number of lines into the buffer line is? (0-based?) TODO rename?
--- Col: The number of characters into the line? (1-based?)
--- Pos: table of row, col
--- Char: A character used for jumping, entered by the user
--- Label: A hint painted to the screen to show where jump targets are, and what disambiguator you might need to press
--- ALL CAPS
--- Blocks and reads one char of user input.
--- Returns the char or nil
local function read_char()
local char = vim.fn.getcharstr()
if char == "" then
return nil
else
return char
end
end
local function smart_case_find_index(s, pattern, pos)
if not pattern:match("%u") then
s = s:lower()
end
local match_start, match_end = string.find(s, pattern, pos, true)
if match_start and match_end then
return match_end
end
end
local function string_get(s, i)
return s:sub(i, i)
end
local function lines()
local bufnr = vim.api.nvim_get_current_buf()
return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
end
--- Returns all matches on the screen that start with "char".
local function find_matches(char)
local lines = lines()
local out = {}
local start_line = vim.fn.line("w0")
local end_line = vim.fn.line("w$")
for row, line in ipairs(lines) do
-- I miss filter lol
if row >= start_line and row <= end_line then
local i = 1
while true do
local pos = smart_case_find_index(line, char, i)
if not pos then
break
end
local char = string_get(line, pos + 1)
if char then
table.insert(out, {row = row,
col = pos,
char = char})
end
i = pos + 1
end
end
end
return out
end
--- Returns a table of chars to the number of times those chars appear in the entire table of matches
--- All matches will map to at least 1 and will not be nil.
local function char_counts(matches)
local counts = {}
for _, m in ipairs(matches) do
local char = m.char:lower()
counts[char] = (counts[char] or 0) + 1
end
return counts
end
--- Takes a table of matches and adds a unique disambiguator to each match
--- If a match's char is unique across the entire matches table,
--- the match will have no diambiguator, otherwise it will be picked from 'disambiguators' (a table of disambiguators)
--- May return fewer matches than provided if all disambiguators are exhausted
local function assign_disambiguators(matches, disambiguators)
local next_disambiguator = utils.seq_iterator(disambiguators)
local counts = char_counts(matches)
local acc = {}
for i, match in ipairs(matches) do
local disambiguator = next_disambiguator()
if not disambiguator then
break
end
local char = match.char:lower()
if counts[char] > 1 then
match.disambiguator = disambiguator
end
table.insert(acc, match)
end
return acc
end
local function paint_labels(matches)
vim.api.nvim_buf_clear_namespace(0, ns, 0, -1)
for _, m in ipairs(matches) do
vim.api.nvim_buf_set_extmark(0, ns, m.row - 1, m.col,
{virt_text = {{m.char, "Jump"},
m.disambiguator and {m.disambiguator, "JumpDisambiguator"}},
virt_text_pos = "overlay",
hl_mode = "combine"})
end
vim.cmd("redraw")
end
local function clear_labels()
vim.api.nvim_buf_clear_namespace(0, ns, 0, -1)
end
--- Adds the current pos to the jump list
--- See :h jumplist
local function add_to_jump_list()
vim.cmd("normal! m'")
end
local function jump_to(pos)
add_to_jump_list()
vim.api.nvim_win_set_cursor(0, {pos.row, pos.col - 1})
end
--- Returns as many matches as it can (bounded by disambiguators)
local function get_jump_targets(char, disambiguators)
local matches = find_matches(char)
return assign_disambiguators(matches, disambiguators)
end
--- These are in order of how easy I find them to press :p
--- Disambiguators are taken in sequence
local disambiguators = {
"a", "s", "d", "f", "j", "k", "l",
"r", "u", "e", "i", "w", "o",
"v", "m", "c", ",", "x", ".",
"/", "p", "q", "z", "g", "h", "t", "y", "b", "n",
"S", "D", "F", "J", "K", "L", ":",
"R", "U", "E", "I", "W", "O",
"M", "<", ">",
"?", "P", "Q", "Z", "G", "H", "T", "Y", "B", "N",
"'",
"1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "[", "]", "-", "=", "`",
"\"",
"!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "{", "}", "_", "+", "~",
}
--- Reads the 2 or 3 inputted characters
local function jump()
local char = read_char()
if not char then return end
--- TODO would be cool to do f/t jumping
--- Works across lines and uses the safe disambiguators until they run out
local matches = get_jump_targets(char, disambiguators)
if #matches == 0 then return end
paint_labels(matches)
local char2 = read_char()
if not char2 then
clear_labels()
return
end
local char2_matches = {}
for _, m in ipairs(matches) do
if m.char == char2 or m.char:lower() == char2 then
table.insert(char2_matches, m)
end
end
if #char2_matches == 0 then
clear_labels()
return
elseif #char2_matches == 1 and not char2_matches[1].disambiguator then
jump_to(char2_matches[1])
clear_labels()
return
end
clear_labels()
paint_labels(char2_matches)
local char3 = read_char()
if not char3 then
clear_labels()
return
end
for _, m in ipairs(char2_matches) do
if m.disambiguator == char3 then
jump_to(m)
break
end
end
clear_labels()
end
return {jump = jump}