You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
nvim-libmodal/lua/libmodal/Mode.lua

448 lines
12 KiB
Lua

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

local globals = require 'libmodal.globals'
local ParseTable = require 'libmodal.collections.ParseTable'
local utils = require 'libmodal.utils' --- @type libmodal.utils
--- @alias CursorPosition {[1]: integer, [2]: integer} see `nvim_win_get_cursor`
--- @class libmodal.Mode
--- @field private autocmds integer[]
--- @field private changedtick integer
--- @field private cursor CursorPosition
--- @field private flush_input_timer unknown
--- @field private help? libmodal.utils.Help
--- @field private input libmodal.utils.Var[integer]
--- @field private input_bytes? integer[] local `input` history
--- @field private instruction fun()|{[string]: fun()|string}
--- @field private mappings libmodal.collections.ParseTable
--- @field private modeline string[][]
--- @field private name string
--- @field private popups libmodal.collections.Stack
--- @field private supress_exit boolean
--- @field public count libmodal.utils.Var[integer]
--- @field public exit libmodal.utils.Var[boolean]
--- @field public timeouts? libmodal.utils.Var[boolean]
local Mode = utils.classes.new()
local C_v = utils.api.replace_termcodes '<C-v>'
local C_s = utils.api.replace_termcodes '<C-s>'
--- Event groups organized by modes
local EVENTS_BY_MODE = {
--- Cursor events triggered by which modes
CURSOR_MOVED = {
CursorMoved = {
n = true,
nt = true,
ntT = true,
s = true,
S = true,
[C_s] = true,
v = true,
V = true,
[C_v] = true,
vs = true,
Vs = true,
[C_v .. 's'] = true,
},
CursorMovedI = {
i = true,
niI = true,
niR = true,
R = true,
Rv = true,
},
},
TEXT_CHANGED = {
TextChanged = {
n = true,
nt = true,
ntT = true,
},
TextChangedI = {
i = true,
niI = true,
niR = true,
R = true,
Rv = true,
},
TextChangedP = {
ic = true,
ix = true,
Rc = true,
Rvc = true,
Rx = true,
Rvx = true,
},
TextChangedT = {
t = true,
},
},
}
--- The namespaces used by modes
local NS = {
--- The virtual cursor namespace. Used to workaround neovim/neovim#20793
CURSOR = vim.api.nvim_create_namespace('libmodal-mode-virtual_cursor'),
}
local HELP_CHAR = '?'
local TIMEOUT =
{
CHAR = ' ',
SEND = function(self) vim.api.nvim_feedkeys(self.CHAR, 'nt', false) end
}
TIMEOUT.CHAR_NUMBER = TIMEOUT.CHAR:byte()
--- Byte for 0
local ZERO = string.byte(0)
--- Byte for 9
local NINE = string.byte(9)
--- Execute events depending on current mode
--- @param events_by_mode {[string]: {[string]: true}}
local function execute_event_by_mode(events_by_mode)
local mode = vim.api.nvim_get_mode().mode
for event, modes in pairs(events_by_mode) do
if modes[mode] then
vim.api.nvim_exec_autocmds(event, {})
break
end
end
end
--- execute the `instruction`.
--- @private
--- @param instruction fun(libmodal.Mode)|string a Lua function or Vimscript command.
--- @return nil
function Mode:execute_instruction(instruction)
if type(instruction) == 'function' then
instruction(self)
else
vim.api.nvim_command(instruction)
end
self.count:set(0)
self:render_virtual_cursor(0, true)
self:execute_text_changed_events()
end
--- check the user's input against the `self.instruction` mappings to see if there is anything to execute.
--- if there is nothing to execute, the user's input is rendered on the screen (as does Vim by default).
--- @private
--- @return nil
function Mode:check_input_for_mapping()
-- stop any running timers
self.flush_input_timer:stop()
-- append the latest input to the locally stored input history.
self.input_bytes[#self.input_bytes + 1] = self.input:get()
-- get the command based on the users input.
local cmd = self.mappings:get(self.input_bytes)
-- get the type of the command.
local command_type = type(cmd)
-- if there was no matching command
if not cmd then
if #self.input_bytes < 2 and self.input_bytes[1] == HELP_CHAR:byte() then
self.help:show()
end
self.input_bytes = {}
elseif command_type == 'table' and globals.is_true(self.timeouts:get()) then -- the command was a table, meaning that it MIGHT match.
local timeout = vim.api.nvim_get_option_value('timeoutlen', {})
self.flush_input_timer:start( -- start the timer
timeout, 0, vim.schedule_wrap(function()
-- send input to interrupt a blocking `getchar`
TIMEOUT:SEND()
-- if there is a command, execute it.
if cmd[ParseTable.CR] then
self:execute_instruction(cmd[ParseTable.CR])
end
-- clear input
self.input_bytes = {}
self.popups:peek():refresh(self.input_bytes)
end)
)
else -- the command was an actual vim command.
--- @diagnostic disable-next-line:param-type-mismatch already checked `cmd` != `table`
self:execute_instruction(cmd)
self.input_bytes = {}
end
local input_bytes = self.input_bytes or {}
local count = self.count:get()
if count > 0 then
local count_bytes = { tostring(count):byte(1, -1) }
input_bytes = vim.list_extend(count_bytes, input_bytes)
end
self.popups:peek():refresh(input_bytes)
end
--- clears the virtual cursor from the screen
--- @param bufnr integer to clear the cursor on
--- @private
function Mode:clear_virtual_cursor(bufnr)
vim.api.nvim_buf_clear_namespace(bufnr, NS.CURSOR, 0, -1);
end
--- Runs CursorMoved* events, if applicable
--- @param cursor CursorPosition the current cursor position
function Mode:execute_cursor_moved_events(cursor)
if not vim.deep_equal(self.cursor, cursor) then
execute_event_by_mode(EVENTS_BY_MODE.CURSOR_MOVED)
self.cursor = cursor
end
end
--- Runs TextChanged* events, if applicable
function Mode:execute_text_changed_events()
local changedtick = vim.api.nvim_buf_get_changedtick(0)
if self.changedtick ~= changedtick then
execute_event_by_mode(EVENTS_BY_MODE.TEXT_CHANGED)
self.changedtick = changedtick
end
end
--- enter this mode.
--- @return nil
function Mode:enter()
-- intialize variables that are needed for each recurse of a function
if type(self.instruction) == 'table' then
-- initialize the input history variable.
self.popups:push(utils.Popup.new())
end
self.count:set(0)
self.exit:set(false)
--- HACK: neovim/neovim#20793
vim.api.nvim_command 'highlight Cursor blend=100'
vim.schedule(function() vim.opt.guicursor:append { 'a:Cursor/lCursor' } end)
self.cursor = self:cursor_in(0)
self:render_virtual_cursor(0)
do
local augroup = vim.api.nvim_create_augroup('libmodal-mode-' .. self.name, { clear = false })
self.autocmds = {
vim.api.nvim_create_autocmd('BufLeave', {
callback = function(ev)
local bufnr = ev.buf
self:clear_virtual_cursor(bufnr)
end,
group = augroup,
}),
vim.api.nvim_create_autocmd('BufEnter', {
callback = function(ev)
local bufnr = ev.buf
self.changedtick = vim.api.nvim_buf_get_changedtick(bufnr)
end,
});
}
end
self.changedtick = vim.api.nvim_buf_get_changedtick(0)
self.previous_mode_name = vim.g.libmodalActiveModeName
vim.g.libmodalActiveModeName = self.name
--[[ MODE LOOP. ]]
local previous_mode = self.previous_mode_name or vim.api.nvim_get_mode().mode
vim.api.nvim_exec_autocmds('ModeChanged', {pattern = previous_mode .. ':' .. self.name})
repeat
-- try (using pcall) to use the mode.
local ok, result = pcall(self.get_user_input, self)
-- if there were errors, handle them.
if not ok then
--- @diagnostic disable-next-line:param-type-mismatch if `not ok` then `mode_result` is a string
utils.notify_error('Error during nvim-libmodal mode', result)
self.exit:set_local(true)
end
until globals.is_true(self.exit:get())
self:tear_down()
vim.api.nvim_exec_autocmds('ModeChanged', {pattern = self.name .. ':' .. previous_mode})
end
--- get input from the user.
--- @private
function Mode:get_user_input()
-- echo the indicator.
self:show_mode()
-- capture input.
local user_input = vim.fn.getchar()
-- return if there was a timeout event.
if user_input == TIMEOUT.CHAR_NUMBER then
return true
end
-- set the global input variable to the new input.
self.input:set(user_input)
if type(user_input) == "number" and ZERO <= user_input and user_input <= NINE then
local oldCount = self.count:get()
local newCount = tonumber(oldCount .. string.char(user_input))
self.count:set(newCount)
end
if not self.supress_exit and user_input == globals.ESC_NR then -- the user wants to exit.
if self.count:get() < 1 then -- exit
return self.exit:set_local(true)
end
self.count:set(0) -- reset count count
end
--[[ The instruction type is determined every cycle, because the user may be assuming a more direct control
over the instruction and it may change over the course of execution. ]]
local instruction_type = type(self.instruction)
if instruction_type == 'table' then -- the instruction was provided as a was a set of mappings.
self:check_input_for_mapping()
elseif instruction_type == 'string' then -- the instruction is the name of a Vimscript function.
vim.fn[self.instruction]()
else -- the instruction is a function.
self.instruction()
end
end
--- @param winid integer
--- @return CursorPosition line_and_col
function Mode:cursor_in(winid)
local cursor = vim.api.nvim_win_get_cursor(winid)
cursor[1] = cursor[1] - 1 -- win_get_cursor returns +1 for our purpose
return cursor
end
--- render the virtual cursor using extmarks
--- @param winid integer
--- @param clear? boolean if true, clear other virtual cursors before rendering the new one
--- @private
function Mode:render_virtual_cursor(winid, clear)
local bufnr = vim.api.nvim_win_get_buf(winid)
if clear then
self:clear_virtual_cursor(bufnr)
end
local cursor = self:cursor_in(winid)
vim.highlight.range(bufnr, NS.CURSOR, 'Cursor', cursor, cursor, { inclusive = true })
self:execute_cursor_moved_events(cursor)
end
--- show the mode indicator, if it is enabled
function Mode:show_mode()
utils.api.redraw()
local showmode = vim.api.nvim_get_option_value('showmode', {})
if showmode then
vim.api.nvim_echo({{'-- ' .. self.name .. ' --', 'LibmodalPrompt'}}, false, {})
end
end
--- `enter` a `Mode` using the arguments given, and then flag the current mode to exit.
--- @param ... unknown arguments to `Mode.new`
--- @return nil
--- @see libmodal.Mode.enter which `...` shares the layout of
--- @see libmodal.Mode.exit
function Mode:switch(...)
local mode = Mode.new(...)
mode:enter()
self.exit:set_local(true)
end
--- uninitialize variables from after exiting the mode.
--- @private
--- @return nil
function Mode:tear_down()
--- HACK: neovim/neovim#20793
self:clear_virtual_cursor(0)
vim.schedule(function() vim.opt.guicursor:remove { 'a:Cursor/lCursor' } end)
vim.api.nvim_command 'highlight Cursor blend=0'
for _, autocmd in ipairs(self.autocmds) do
vim.api.nvim_del_autocmd(autocmd)
end
self.cursor = nil
if type(self.instruction) == 'table' then
self.flush_input_timer:stop()
self.input_bytes = nil
self.popups:pop():close()
end
if self.previous_mode_name and #vim.trim(self.previous_mode_name) < 1 then
vim.g.libmodalActiveModeName = nil
else
vim.g.libmodalActiveModeName = self.previous_mode_name
end
utils.api.redraw()
end
--- create a new mode.
--- @private
--- @param name string the name of the mode.
--- @param instruction fun(libmodal.Mode)|string|table a Lua function, keymap dictionary, Vimscript command.
--- @param supress_exit? boolean
--- @return libmodal.Mode
function Mode.new(name, instruction, supress_exit)
name = vim.trim(name)
-- inherit the metatable.
local self = setmetatable(
{
count = utils.Var.new(name, 'count'),
exit = utils.Var.new(name, 'exit'),
input = utils.Var.new(name, 'input'),
instruction = instruction,
name = name,
modeline = {{'-- ' .. name .. ' --', 'LibmodalPrompt'}},
},
Mode
)
-- define the exit flag
self.supress_exit = supress_exit or false
-- if the user provided keymaps
if type(instruction) == 'table' then
-- create a timer to perform actions with.
self.flush_input_timer = vim.loop.new_timer()
-- determine if a default `Help` should be created.
if not self.instruction[HELP_CHAR] then
self.help = utils.Help.new(instruction, 'KEY MAP')
end
self.input_bytes = {}
-- build the parse tree.
self.mappings = ParseTable.new(instruction)
-- create a table for mode-specific data.
self.popups = require('libmodal.collections.Stack').new()
-- create a variable for whether or not timeouts are enabled.
self.timeouts = utils.Var.new(self.name, 'timeouts', vim.g.libmodalTimeouts)
end
return self
end
return Mode