Skip to content

Commit

Permalink
refactor: rewrite to use mappings (for most modes) (#59)
Browse files Browse the repository at this point in the history
* refactor: rewrite to use mappings

* fix: formatting

* refactor: properly detect when a sequence shouldn't continue

* cleanup

* softer deprecation

* update readme for rewrite

* fix: remove nowait to allow remapping keys

* feat: allow disabling mappings and update readme

* refactor: swap expr-mappings with `feedkeys`

to allow modifying the buffer in functions

* docs: update readme

* docs: update readme

---------

Co-authored-by: Max <[email protected]>
  • Loading branch information
Sam-programs and max397574 authored Jul 4, 2024
1 parent 7e86eda commit baa6048
Show file tree
Hide file tree
Showing 2 changed files with 244 additions and 192 deletions.
288 changes: 140 additions & 148 deletions lua/better_escape.lua
Original file line number Diff line number Diff line change
@@ -1,175 +1,167 @@
local M = {}
local uv = vim.uv
local t = vim.keycode

local api = vim.api
M.waiting = false

local settings = {
timeout = vim.o.timeoutlen,
mapping = { "jk", "jj" },
clear_empty_lines = false,
---@type string|function
keys = "<Esc>",
mappings = {
i = {
j = {
k = "<Esc>",
j = "<Esc>",
},
},
c = {
j = {
k = "<Esc>",
j = "<Esc>",
},
},
t = {
j = {
k = "<Esc>",
j = "<Esc>",
},
},
v = {
j = {
k = "<Esc>",
},
},
s = {
j = {
k = "<Esc>",
},
},
},
}

local first_chars = {}
local second_chars = {}

---@class State
---@field char string
---@field modified boolean

local timer
local waiting = false
---@type State[]
local input_states = {}

---@param tbl table table to search through
---@param element any element to search in tbl
---@return table indices
--- Search for indices in tbl where element occurs
local function get_indices(tbl, element)
local indices = {}
for idx, value in ipairs(tbl) do
if element == value then
table.insert(indices, idx)
local function clear_mappings()
for mode, keys in pairs(settings.mappings) do
for key, subkeys in pairs(keys) do
vim.keymap.del(mode, key)
for subkey, _ in pairs(subkeys) do
vim.keymap.del(mode, subkey)
end
end
end
return indices
end

---@param keys string keys to feed
--- Replace keys with termcodes and feed them
local function feed(keys, mode)
api.nvim_feedkeys(
api.nvim_replace_termcodes(keys, true, true, true),
mode or "n",
false
)
-- WIP: move this into recorder.lua ?
local last_key = nil
local bufmodified = false
local sequence_timer = uv.new_timer()
local recorded_key = false
local function log_key(key)
bufmodified = vim.bo.modified
last_key = key
recorded_key = true
sequence_timer:stop()
M.waiting = true
sequence_timer:start(settings.timeout, 0, function()
M.waiting = false
if last_key == key then
last_key = nil
end
end)
end

local function start_timer()
waiting = true

if timer then
timer:stop()
vim.on_key(function(mappings, typed)
if typed == "" then
return
end

timer = vim.defer_fn(function()
waiting = false
end, settings.timeout)
end

local function get_keys()
-- if keys is string use it, else use it as a function
return type(settings.keys) == "string" and settings.keys or settings.keys()
end

local function check_timeout()
if waiting then
local current_line = api.nvim_get_current_line()
if settings.clear_empty_lines and current_line:match("^%s+j$") then
vim.schedule(function()
api.nvim_set_current_line("")
feed(get_keys(), "in")
end)
else
feed("<BS><BS>" .. get_keys(), "in") -- delete the characters from the mapping
end

waiting = false -- more timely
return true
if recorded_key then
recorded_key = false
return
end
return false
end

function M.check_charaters()
local char = vim.v.char
table.insert(input_states, { char = char, modified = vim.bo.modified })

local matched = false
if #input_states >= 2 then
---@type State
local prev_state = input_states[#input_states - 1]
local indices = get_indices(second_chars, char)
-- if char == second_chars[idx] and prev_char == first_chars[idx] as well
-- then matched = true
for _, idx in ipairs(indices) do
if first_chars[idx] == prev_state.char then
matched = check_timeout()
break
end
end

if matched then
input_states = {}
vim.schedule(function()
vim.bo.modified = prev_state.modified
last_key = nil
end)

-- list of modes that press <backspace> when escaping
local undo_key = {
i = "<bs>",
c = "<bs>",
t = "<bs>",
v = "",
s = "",
}
local parent_keys = {}

local function map_keys()
parent_keys = {}
for mode, keys in pairs(settings.mappings) do
for key, subkeys in pairs(keys) do
vim.keymap.set(mode, key, function()
log_key(key)
vim.api.nvim_feedkeys(t(key), "in", false)
end)
for subkey, mapping in pairs(subkeys) do
if mapping then
if not parent_keys[mode] then
parent_keys[mode] = {}
end
if not parent_keys[mode][subkey] then
parent_keys[mode][subkey] = {}
end
parent_keys[mode][subkey][key] = true
vim.keymap.set(mode, subkey, function()
-- In case the subkey happens to also be a starting key
if last_key == nil then
log_key(subkey)
vim.api.nvim_feedkeys(t(subkey), "in", false)
return
end
-- Make sure we are in the correct sequence
if not parent_keys[mode][subkey][last_key] then
vim.api.nvim_feedkeys(t(subkey), "in", false)
return
end
vim.api.nvim_feedkeys(t(undo_key[mode] or ""), "in", false)
vim.api.nvim_feedkeys(
t("<cmd>setlocal %smodified<cr>"):format(
bufmodified and "" or "no"
)
, "in", false)
if type(mapping) == "string" then
vim.api.nvim_input(mapping)
elseif type(mapping) == "function" then
mapping()
end
end)
end
end
end
end

-- if can't find a match, and the typed char is first in a mapping, start the timeout
if not matched and vim.tbl_contains(first_chars, char) then
start_timer()
end
end

local function char_at(str, pos)
return vim.fn.nr2char(vim.fn.strgetchar(str, pos))
end

local function validate_settings()
assert(type(settings.mapping) == "table", "Mapping must be a table.")

for _, mapping in ipairs(settings.mapping) do
-- replace all multibyte chars to `A` char
local length = #vim.fn.substitute(mapping, ".", "A", "g")
assert(length == 2, "Mapping must be 2 keys.")
end

if settings.timeout then
assert(type(settings.timeout) == "number", "Timeout must be a number.")
assert(
settings.timeout >= 100,
"Timeout must be greater than or equal to 100."
)
end

assert(
vim.tbl_contains({ "string", "function" }, type(settings.keys)),
"Keys must be a function or string."
)
end

function M.setup(update)
settings = vim.tbl_deep_extend("force", settings, update or {})
-- if mapping is a string (single mapping) make it a table
if type(settings.mapping) == "string" then
settings.mapping = { settings.mapping }
if settings.keys or settings.clear_empty_lines then
vim.notify(
"[better-escape.nvim]: Rewrite! Check: https://github.com/max397574/better-escape.nvim",
vim.log.levels.WARN,
{}
)
end
local ok, msg = pcall(validate_settings)
if ok then
-- create tables with the first and seconds chars of the mappings
for _, shortcut in ipairs(settings.mapping) do
vim.cmd("silent! iunmap " .. shortcut)
table.insert(first_chars, char_at(shortcut, 0))
table.insert(second_chars, char_at(shortcut, 1))
if settings.mapping then
vim.notify(
"[better-escape.nvim]: Rewrite! Check: https://github.com/max397574/better-escape.nvim",
vim.log.levels.WARN,
{}
)
if type(settings.mapping) == "string" then
settings.mapping = { settings.mapping }
end
for _, mapping in ipairs(settings.mappings) do
settings.mappings.i[mapping:sub(1, 2)] = {}
settings.mappings.i[mapping:sub(1, 1)][mapping:sub(2, 2)] =
settings.keys
end

vim.cmd([[
augroup better_escape
autocmd!
autocmd InsertCharPre * lua require"better_escape".check_charaters()
augroup END
]])
else
vim.notify("Error(better-escape.nvim): " .. msg, vim.log.levels.ERROR)
end
pcall(clear_mappings)
map_keys()
end

return setmetatable(M, {
__index = function(_, k)
if k == "waiting" then
return waiting
end
end,
})
return M
Loading

0 comments on commit baa6048

Please sign in to comment.