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

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.
1 year ago
--- @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
4 years ago
--[[ 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