neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

commit bf68ba40a03a19c97454ede293ed289c547b5aaa
parent b95e0a8d20b73ec1989a373b76b9b0e828ad540f
Author: luukvbaal <luukvbaal@gmail.com>
Date:   Thu,  5 Feb 2026 13:45:45 +0100

refactor: rename _extui => _core.ui2 #37692

Problem:
_extui module name is confusing and should eventually end up in _core/.

Solution:
Move it there and name it ui2.
Diffstat:
Mruntime/doc/lua.txt | 4++--
Mruntime/doc/news.txt | 2+-
Aruntime/lua/vim/_core/ui2.lua | 256+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aruntime/lua/vim/_core/ui2/cmdline.lua | 184+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aruntime/lua/vim/_core/ui2/messages.lua | 619+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Druntime/lua/vim/_extui.lua | 152-------------------------------------------------------------------------------
Druntime/lua/vim/_extui/cmdline.lua | 184-------------------------------------------------------------------------------
Druntime/lua/vim/_extui/messages.lua | 619-------------------------------------------------------------------------------
Druntime/lua/vim/_extui/shared.lua | 111-------------------------------------------------------------------------------
Msrc/gen/gen_vimdoc.lua | 5+++--
Mtest/functional/core/main_spec.lua | 1+
Mtest/functional/ui/cmdline2_spec.lua | 4++--
Mtest/functional/ui/messages2_spec.lua | 4++--
Mtest/functional/vimscript/screenchar_spec.lua | 2+-
14 files changed, 1071 insertions(+), 1076 deletions(-)

diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt @@ -5208,13 +5208,13 @@ vim.version.range({spec}) *vim.version.range()* ============================================================================== -Lua module: vim._extui *vim._extui* +Lua module: vim._core.ui2 *vim._core.ui2* WARNING: This is an experimental interface intended to replace the message grid in the TUI. To enable the experimental UI (default opts shown): >lua - require('vim._extui').enable({ + require('vim._core.ui2').enable({ enable = true, -- Whether to enable or disable the UI. msg = { -- Options related to the message module. ---@type 'cmd'|'msg' Where to place regular messages, either in the diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt @@ -397,7 +397,7 @@ UI address. • |:checkhealth| shows a summary in the header for every healthcheck. • |ui-multigrid| provides composition information and absolute coordinates. -• `vim._extui` provides an experimental commandline and message UI intended to +• `vim._core.ui2` provides an experimental commandline and message UI intended to replace the message grid in the TUI. • Error messages are more concise: • "Error detected while processing:" changed to "Error in:". diff --git a/runtime/lua/vim/_core/ui2.lua b/runtime/lua/vim/_core/ui2.lua @@ -0,0 +1,256 @@ +--- @brief +--- +---WARNING: This is an experimental interface intended to replace the message +---grid in the TUI. +--- +---To enable the experimental UI (default opts shown): +---```lua +---require('vim._core.ui2').enable({ +--- enable = true, -- Whether to enable or disable the UI. +--- msg = { -- Options related to the message module. +--- ---@type 'cmd'|'msg' Where to place regular messages, either in the +--- ---cmdline or in a separate ephemeral message window. +--- target = 'cmd', +--- timeout = 4000, -- Time a message is visible in the message window. +--- }, +---}) +---``` +--- +---There are four separate window types used by this interface: +---- "cmd": The cmdline window; also used for 'showcmd', 'showmode', 'ruler', and +--- messages if 'cmdheight' > 0. +---- "msg": The message window; used for messages when 'cmdheight' == 0. +---- "pager": The pager window; used for |:messages| and certain messages +--- that should be shown in full. +---- "dialog": The dialog window; used for prompt messages that expect user input. +--- +---These four windows are assigned the "cmd", "msg", "pager" and "dialog" +---'filetype' respectively. Use a |FileType| autocommand to configure any local +---options for these windows and their respective buffers. +--- +---Rather than a |hit-enter-prompt|, messages shown in the cmdline area that do +---not fit are appended with a `[+x]` "spill" indicator, where `x` indicates the +---spilled lines. To see the full message, the |g<| command can be used. + +local api = vim.api +local M = { + ns = api.nvim_create_namespace('nvim.ui2'), + augroup = api.nvim_create_augroup('nvim.ui2', {}), + cmdheight = vim.o.cmdheight, -- 'cmdheight' option value set by user. + redrawing = false, -- True when redrawing to display UI event. + wins = { cmd = -1, dialog = -1, msg = -1, pager = -1 }, + bufs = { cmd = -1, dialog = -1, msg = -1, pager = -1 }, + cfg = { + enable = true, + msg = { -- Options related to the message module. + ---@type 'cmd'|'msg' Where to place regular messages, either in the + ---cmdline or in a separate ephemeral message window. + target = 'cmd', + timeout = 4000, -- Time a message is visible in the message window. + }, + }, +} +--- @type vim.api.keyset.win_config +local wincfg = { -- Default cfg for nvim_open_win(). + relative = 'laststatus', + style = 'minimal', + col = 0, + row = 1, + width = 10000, + height = 1, + noautocmd = true, +} + +local tab = 0 +---Ensure target buffers and windows are still valid. +function M.check_targets() + local curtab = api.nvim_get_current_tabpage() + for i, type in ipairs({ 'cmd', 'dialog', 'msg', 'pager' }) do + local setopt = not api.nvim_buf_is_valid(M.bufs[type]) + if setopt then + M.bufs[type] = api.nvim_create_buf(false, false) + end + + if + tab ~= curtab + or not api.nvim_win_is_valid(M.wins[type]) + or not api.nvim_win_get_config(M.wins[type]).zindex -- no longer floating + then + local cfg = vim.tbl_deep_extend('force', wincfg, { + focusable = type == 'pager', + mouse = type ~= 'cmd' and true or nil, + anchor = type ~= 'cmd' and 'SE' or nil, + hide = type ~= 'cmd' or M.cmdheight == 0 or nil, + border = type ~= 'msg' and 'none' or nil, + -- kZIndexMessages < cmd zindex < kZIndexCmdlinePopupMenu (grid_defs.h), pager below others. + zindex = 201 - i, + _cmdline_offset = type == 'cmd' and 0 or nil, + }) + if tab ~= curtab and api.nvim_win_is_valid(M.wins[type]) then + cfg = api.nvim_win_get_config(M.wins[type]) + api.nvim_win_close(M.wins[type], true) + end + M.wins[type] = api.nvim_open_win(M.bufs[type], false, cfg) + setopt = true + elseif api.nvim_win_get_buf(M.wins[type]) ~= M.bufs[type] then + api.nvim_win_set_buf(M.wins[type], M.bufs[type]) + setopt = true + end + + if setopt then + -- Set options without firing OptionSet and BufFilePost. + vim._with({ win = M.wins[type], noautocmd = true }, function() + local ignore = 'all,-FileType' .. (type == 'pager' and ',-TextYankPost' or '') + api.nvim_set_option_value('eventignorewin', ignore, { scope = 'local' }) + api.nvim_set_option_value('wrap', true, { scope = 'local' }) + api.nvim_set_option_value('linebreak', false, { scope = 'local' }) + api.nvim_set_option_value('smoothscroll', true, { scope = 'local' }) + api.nvim_set_option_value('breakindent', false, { scope = 'local' }) + api.nvim_set_option_value('foldenable', false, { scope = 'local' }) + api.nvim_set_option_value('showbreak', '', { scope = 'local' }) + api.nvim_set_option_value('spell', false, { scope = 'local' }) + api.nvim_set_option_value('swapfile', false, { scope = 'local' }) + api.nvim_set_option_value('modifiable', true, { scope = 'local' }) + api.nvim_set_option_value('bufhidden', 'hide', { scope = 'local' }) + api.nvim_set_option_value('buftype', 'nofile', { scope = 'local' }) + -- Use MsgArea except in the msg window. Hide Search highlighting except in the pager. + local search_hide = 'Search:,CurSearch:,IncSearch:' + local hl = 'Normal:MsgArea,' .. search_hide + if type == 'pager' then + hl = 'Normal:MsgArea' + elseif type == 'msg' then + hl = search_hide + end + api.nvim_set_option_value('winhighlight', hl, { scope = 'local' }) + end) + api.nvim_buf_set_name(M.bufs[type], ('[%s]'):format(type:sub(1, 1):upper() .. type:sub(2))) + -- Fire FileType with window context to let the user reconfigure local options. + vim._with({ win = M.wins[type] }, function() + api.nvim_set_option_value('filetype', type, { scope = 'local' }) + end) + + if type == 'pager' then + -- Close pager with `q`, same as `checkhealth` + api.nvim_buf_set_keymap(M.bufs.pager, 'n', 'q', '<Cmd>wincmd c<CR>', {}) + elseif type == M.cfg.msg.target then + M.msg.prev_msg = '' -- Will no longer be visible. + end + end + end + tab = curtab +end + +local function ui_callback(redraw_msg, event, ...) + local handler = M.msg[event] or M.cmd[event] + M.check_targets() + handler(...) + -- Cmdline mode, non-fast message and non-empty showcmd require an immediate redraw. + if M.cmd[event] or redraw_msg or (event == 'msg_showcmd' and select(1, ...)[1]) then + M.redrawing = true + api.nvim__redraw({ + flush = handler ~= M.cmd.cmdline_hide or nil, + cursor = handler == M.cmd[event] and true or nil, + win = handler == M.cmd[event] and M.wins.cmd or nil, + }) + M.redrawing = false + end +end +local scheduled_ui_callback = vim.schedule_wrap(ui_callback) + +---@nodoc +function M.enable(opts) + vim.validate('opts', opts, 'table', true) + if opts.msg then + vim.validate('opts.msg.pos', opts.msg.pos, 'nil', true, 'nil: "pos" moved to opts.target') + vim.validate('opts.msg.box', opts.msg.box, 'nil', true, 'nil: "timeout" moved to opts.msg') + vim.validate('opts.msg.target', opts.msg.target, function(tar) + return tar == 'cmd' or tar == 'msg' + end, "'cmd'|'msg'") + end + M.cfg = vim.tbl_deep_extend('keep', opts, M.cfg) + M.cmd = require('vim._core.ui2.cmdline') + M.msg = require('vim._core.ui2.messages') + + if M.cfg.enable == false then + -- Detach and cleanup windows, buffers and autocommands. + for _, win in pairs(M.wins) do + if api.nvim_win_is_valid(win) then + api.nvim_win_close(win, true) + end + end + for _, buf in pairs(M.bufs) do + if api.nvim_buf_is_valid(buf) then + api.nvim_buf_delete(buf, {}) + end + end + api.nvim_clear_autocmds({ group = M.augroup }) + vim.ui_detach(M.ns) + return + end + + vim.ui_attach(M.ns, { ext_messages = true, set_cmdheight = false }, function(event, ...) + if not (M.msg[event] or M.cmd[event]) then + return + end + -- Ensure cmdline is placed after a scheduled message in block mode. + if vim.in_fast_event() or (event == 'cmdline_show' and M.cmd.srow > 0) then + scheduled_ui_callback(false, event, ...) + else + ui_callback(event == 'msg_show', event, ...) + end + return true + end) + + -- The visibility and appearance of the cmdline and message window is + -- dependent on some option values. Reconfigure windows when option value + -- has changed and after VimEnter when the user configured value is known. + -- TODO: Reconsider what is needed when this module is enabled by default early in startup. + local function check_cmdheight(value) + M.check_targets() + -- 'cmdheight' set; (un)hide cmdline window and set its height. + local cfg = { height = math.max(value, 1), hide = value == 0 } + api.nvim_win_set_config(M.wins.cmd, cfg) + -- Change message position when 'cmdheight' was or becomes 0. + if value == 0 or M.cmdheight == 0 then + M.cfg.msg.target = value == 0 and 'msg' or 'cmd' + M.msg.prev_msg = '' + end + M.cmdheight = value + end + + if vim.v.vim_did_enter == 0 then + vim.schedule(function() + check_cmdheight(vim.o.cmdheight) + end) + end + + api.nvim_create_autocmd('OptionSet', { + group = M.augroup, + pattern = { 'cmdheight' }, + callback = function() + check_cmdheight(vim.v.option_new) + M.msg.set_pos() + end, + desc = 'Set cmdline and message window dimensions for changed option values.', + }) + + api.nvim_create_autocmd({ 'VimResized', 'TabEnter' }, { + group = M.augroup, + callback = function() + M.msg.set_pos() + end, + desc = 'Set cmdline and message window dimensions after shell resize or tabpage change.', + }) + + api.nvim_create_autocmd('WinEnter', { + callback = function() + local win = api.nvim_get_current_win() + if vim.tbl_contains(M.wins, win) and api.nvim_win_get_config(win).hide then + vim.cmd.wincmd('p') + end + end, + desc = 'Make sure hidden UI window is never current.', + }) +end + +return M diff --git a/runtime/lua/vim/_core/ui2/cmdline.lua b/runtime/lua/vim/_core/ui2/cmdline.lua @@ -0,0 +1,184 @@ +local ui = require('vim._core.ui2') +local api, fn = vim.api, vim.fn +---@class vim._core.ui2.cmdline +local M = { + highlighter = nil, ---@type vim.treesitter.highlighter? + indent = 0, -- Current indent for block event. + prompt = false, -- Whether a prompt is active; messages are placed in the 'dialog' window. + srow = 0, -- Buffer row at which the current cmdline starts; > 0 in block mode. + erow = 0, -- Buffer row at which the current cmdline ends; messages appended here in block mode. + level = -1, -- Current cmdline level; 0 when inactive, -1 one loop iteration after closing. + wmnumode = 0, -- wildmenumode() when not using the pum, dialog position adjusted when toggled. +} + +--- Set the 'cmdheight' and cmdline window height. Reposition message windows. +--- +---@param win integer Cmdline window in the current tabpage. +---@param hide boolean Whether to hide or show the window. +---@param height integer (Text)height of the cmdline window. +local function win_config(win, hide, height) + if ui.cmdheight == 0 and api.nvim_win_get_config(win).hide ~= hide then + api.nvim_win_set_config(win, { hide = hide, height = not hide and height or nil }) + elseif api.nvim_win_get_height(win) ~= height then + api.nvim_win_set_height(win, height) + end + if vim.o.cmdheight ~= height then + -- Avoid moving the cursor with 'splitkeep' = "screen", and altering the user + -- configured value with noautocmd. + vim._with({ noautocmd = true, o = { splitkeep = 'screen' } }, function() + vim.o.cmdheight = height + end) + ui.msg.set_pos() + elseif M.wmnumode ~= (M.prompt and fn.pumvisible() == 0 and fn.wildmenumode() or 0) then + M.wmnumode = (M.wmnumode == 1 and 0 or 1) + ui.msg.set_pos() + end +end + +local cmdbuff = '' ---@type string Stored cmdline used to calculate translation offset. +local promptlen = 0 -- Current length of the last line in the prompt. +--- Concatenate content chunks and set the text for the current row in the cmdline buffer. +--- +---@alias CmdChunk [integer, string] +---@alias CmdContent CmdChunk[] +---@param content CmdContent +---@param prompt string +local function set_text(content, prompt) + local lines = {} ---@type string[] + for line in (prompt .. '\n'):gmatch('(.-)\n') do + lines[#lines + 1] = fn.strtrans(line) + end + cmdbuff, promptlen, M.erow = '', #lines[#lines], M.srow + #lines - 1 + for _, chunk in ipairs(content) do + cmdbuff = cmdbuff .. chunk[2] + end + lines[#lines] = ('%s%s '):format(lines[#lines], fn.strtrans(cmdbuff)) + api.nvim_buf_set_lines(ui.bufs.cmd, M.srow, -1, false, lines) +end + +--- Set the cmdline buffer text and cursor position. +--- +---@param content CmdContent +---@param pos integer +---@param firstc string +---@param prompt string +---@param indent integer +---@param level integer +---@param hl_id integer +function M.cmdline_show(content, pos, firstc, prompt, indent, level, hl_id) + M.level, M.indent, M.prompt = level, indent, M.prompt or #prompt > 0 + if M.highlighter == nil or M.highlighter.bufnr ~= ui.bufs.cmd then + local parser = assert(vim.treesitter.get_parser(ui.bufs.cmd, 'vim', {})) + M.highlighter = vim.treesitter.highlighter.new(parser) + end + -- Only enable TS highlighter for Ex commands (not search or filter commands). + M.highlighter.active[ui.bufs.cmd] = firstc == ':' and M.highlighter or nil + if ui.msg.cmd.msg_row ~= -1 then + ui.msg.msg_clear() + end + ui.msg.virt.last = { {}, {}, {}, {} } + + set_text(content, ('%s%s%s'):format(firstc, prompt, (' '):rep(indent))) + if promptlen > 0 and hl_id > 0 then + api.nvim_buf_set_extmark(ui.bufs.cmd, ui.ns, 0, 0, { hl_group = hl_id, end_col = promptlen }) + end + + local height = math.max(ui.cmdheight, api.nvim_win_text_height(ui.wins.cmd, {}).all) + win_config(ui.wins.cmd, false, height) + M.cmdline_pos(pos) +end + +--- Insert special character at cursor position. +--- +---@param c string +---@param shift boolean +--@param level integer +function M.cmdline_special_char(c, shift) + api.nvim_win_call(ui.wins.cmd, function() + api.nvim_put({ c }, shift and '' or 'c', false, false) + end) +end + +local curpos = { 0, 0 } -- Last drawn cursor position. +--- Set the cmdline cursor position. +--- +---@param pos integer +--@param level integer +function M.cmdline_pos(pos) + pos = #fn.strtrans(cmdbuff:sub(1, pos)) + if curpos[1] ~= M.erow + 1 or curpos[2] ~= promptlen + pos then + curpos[1], curpos[2] = M.erow + 1, promptlen + pos + -- Add matchparen highlighting to non-prompt part of cmdline. + if pos > 0 and fn.exists('#matchparen#CursorMoved') == 1 then + api.nvim_win_set_cursor(ui.wins.cmd, { curpos[1], curpos[2] - 1 }) + vim._with({ win = ui.wins.cmd, wo = { eventignorewin = '' } }, function() + api.nvim_exec_autocmds('CursorMoved', {}) + end) + end + api.nvim_win_set_cursor(ui.wins.cmd, curpos) + end +end + +--- Leaving the cmdline, restore 'cmdheight' and 'ruler'. +--- +---@param level integer +---@param abort boolean +function M.cmdline_hide(level, abort) + if M.srow > 0 or level > (fn.getcmdwintype() == '' and 1 or 2) then + return -- No need to hide when still in nested cmdline or cmdline_block. + end + + fn.clearmatches(ui.wins.cmd) -- Clear matchparen highlights. + api.nvim_win_set_cursor(ui.wins.cmd, { 1, 0 }) + if abort then + -- Clear cmd buffer for aborted command (non-abort is left visible). + api.nvim_buf_set_lines(ui.bufs.cmd, 0, -1, false, {}) + end + + local clear = vim.schedule_wrap(function(was_prompt) + -- Avoid clearing prompt window when it is re-entered before the next event + -- loop iteration. E.g. when a non-choice confirm button is pressed. + if was_prompt and not M.prompt then + api.nvim_buf_set_lines(ui.bufs.cmd, 0, -1, false, {}) + api.nvim_buf_set_lines(ui.bufs.dialog, 0, -1, false, {}) + api.nvim_win_set_config(ui.wins.dialog, { hide = true }) + vim.on_key(nil, ui.msg.dialog_on_key) + end + -- Messages emitted as a result of a typed command are treated specially: + -- remember if the cmdline was used this event loop iteration. + -- NOTE: Message event callbacks are themselves scheduled, so delay two iterations. + vim.schedule(function() + M.level = -1 + end) + end) + clear(M.prompt) + + M.prompt, M.level, curpos[1], curpos[2] = false, 0, 0, 0 + win_config(ui.wins.cmd, true, ui.cmdheight) +end + +--- Set multi-line cmdline buffer text. +--- +---@param lines CmdContent[] +function M.cmdline_block_show(lines) + for _, content in ipairs(lines) do + set_text(content, ':') + M.srow = M.srow + 1 + end +end + +--- Append line to a multiline cmdline. +--- +---@param line CmdContent +function M.cmdline_block_append(line) + set_text(line, ':') + M.srow = M.srow + 1 +end + +--- Clear cmdline buffer and leave the cmdline. +function M.cmdline_block_hide() + M.srow = 0 + M.cmdline_hide(M.level, true) +end + +return M diff --git a/runtime/lua/vim/_core/ui2/messages.lua b/runtime/lua/vim/_core/ui2/messages.lua @@ -0,0 +1,619 @@ +local api, fn, o = vim.api, vim.fn, vim.o +local ui = require('vim._core.ui2') + +---@alias Msg { extid: integer, timer: uv.uv_timer_t? } +---@class vim._core.ui2.messages +local M = { + -- Message window. Used for regular messages with 'cmdheight' == 0 or, + -- cfg.msg.target == 'msg'. Automatically resizes to the text dimensions up to + -- a point, at which point only the most recent messages will fit and be shown. + -- A timer is started for each message whose callback will remove the message + -- from the window again. + msg = { + ids = {}, ---@type table<string|integer, Msg> List of visible messages. + width = 1, -- Current width of the message window. + }, + -- Cmdline message window. Used for regular messages with 'cmdheight' > 0. + -- Also contains 'ruler', 'showcmd' and search_cmd/count messages as virt_text. + -- Messages that don't fit the 'cmdheight' are cut off and virt_text is added + -- to indicate the number of spilled lines and repeated messages. + cmd = { + ids = {}, ---@type table<string|integer, Msg> List of visible messages. + msg_row = -1, -- Last row of message to distinguish for placing virt_text. + last_col = o.columns, -- Crop text to start column of 'last' virt_text. + last_emsg = 0, -- Time an error was printed that should not be overwritten. + }, + dupe = 0, -- Number of times message is repeated. + prev_id = 0, ---@type string|integer Message id of the previous message. + prev_msg = '', -- Concatenated content of the previous message. + virt = { -- Stored virt_text state. + last = { {}, {}, {}, {} }, ---@type MsgContent[] status in last cmdline row. + msg = { {}, {} }, ---@type MsgContent[] [(x)] indicators in msg window. + top = { {} }, ---@type MsgContent[] [+x] top indicator in dialog window. + bot = { {} }, ---@type MsgContent[] [+x] bottom indicator in dialog window. + idx = { mode = 1, search = 2, cmd = 3, ruler = 4, spill = 1, dupe = 2 }, + ids = {}, ---@type { ['last'|'msg'|'top'|'bot']: integer? } Table of mark IDs. + delayed = false, -- Whether placement of 'last' virt_text is delayed. + }, + dialog_on_key = 0, -- vim.on_key namespace for paging in the dialog window. +} + +local cmd_on_key ---@type integer? Set to vim.on_key namespace while cmdline is expanded. +-- An external redraw indicates the start of a new batch of messages in the cmdline. +api.nvim_set_decoration_provider(ui.ns, { + on_start = function() + M.cmd.ids = (ui.redrawing or cmd_on_key) and M.cmd.ids or {} + end, +}) + +function M.msg:close() + self.width, M.virt.msg[M.virt.idx.dupe][1] = 1, nil + if api.nvim_win_is_valid(ui.wins.msg) then + api.nvim_win_set_config(ui.wins.msg, { hide = true }) + end +end + +--- Start a timer whose callback will remove the message from the message window. +--- +---@param buf integer Buffer the message was written to. +---@param id integer|string Message ID. +function M.msg:start_timer(buf, id) + if self.ids[id].timer then + self.ids[id].timer:stop() + end + self.ids[id].timer = vim.defer_fn(function() + local extid = api.nvim_buf_is_valid(buf) and self.ids[id] and self.ids[id].extid + local mark = extid and api.nvim_buf_get_extmark_by_id(buf, ui.ns, extid, { details = true }) + self.ids[id] = nil + if not mark or not mark[1] then + return + end + -- Clear prev_msg when line that may have dupe marker is removed. + local erow = api.nvim_buf_line_count(buf) - 1 + M.prev_msg = ui.cfg.msg.target == 'msg' and mark[3].end_row == erow and '' or M.prev_msg + + -- Remove message (including potentially leftover empty line). + api.nvim_buf_set_text(buf, mark[1], mark[2], mark[3].end_row, mark[3].end_col, {}) + if fn.col({ mark[1] + 1, '$' }, ui.wins.msg) == 1 then + api.nvim_buf_set_lines(buf, mark[1], mark[1] + 1, false, {}) + end + + -- Resize or hide message window for removed message. + if next(self.ids) then + M.set_pos('msg') + else + self:close() + end + end, ui.cfg.msg.timeout) +end + +--- Place or delete a virtual text mark in the cmdline or message window. +--- +---@param type 'last'|'msg'|'top'|'bot' +---@param tar? 'cmd'|'msg'|'dialog' +local function set_virttext(type, tar) + if (type == 'last' and (ui.cmdheight == 0 or M.virt.delayed)) or cmd_on_key then + return -- Don't show virtual text while cmdline is expanded or delaying for error. + end + + -- Concatenate the components of M.virt[type] and calculate the concatenated width. + local width, chunks = 0, {} ---@type integer, [string, integer|string][] + local contents = M.virt[type] ---@type MsgContent[] + for _, content in ipairs(contents) do + for _, chunk in ipairs(content) do + chunks[#chunks + 1] = { chunk[2], chunk[3] } + width = width + api.nvim_strwidth(chunk[2]) + end + end + tar = tar or type == 'msg' and ui.cfg.msg.target or 'cmd' + + if M.virt.ids[type] and #chunks == 0 then + api.nvim_buf_del_extmark(ui.bufs[tar], ui.ns, M.virt.ids[type]) + M.cmd.last_col = type == 'last' and o.columns or M.cmd.last_col + M.virt.ids[type] = nil + elseif #chunks > 0 then + local win = ui.wins[tar] + local line = (tar == 'msg' or type == 'top') and 'w0' or type == 'bot' and 'w$' + local srow = line and fn.line(line, ui.wins.dialog) - 1 + local erow = tar == 'cmd' and math.min(M.cmd.msg_row, api.nvim_buf_line_count(ui.bufs.cmd) - 1) + local texth = api.nvim_win_text_height(win, { + max_height = (type == 'top' or type == 'bot') and 1 or api.nvim_win_get_height(win), + start_row = srow or nil, + end_row = erow or nil, + }) + local row = texth.end_row + local col = fn.virtcol2col(win, row + 1, texth.end_vcol) + local scol = fn.screenpos(win, row + 1, col).col ---@type integer + + if type ~= 'last' then + -- Calculate at which column to place the virt_text such that it is at the end + -- of the last visible message line, overlapping the message text if necessary, + -- but not overlapping the 'last' virt_text. + local offset = tar ~= 'msg' and 0 + or api.nvim_win_get_position(win)[2] + + (api.nvim_win_get_config(win).border ~= 'none' and 1 or 0) + + -- Check if adding the virt_text on this line will exceed the current window width. + local maxwidth = math.max(M.msg.width, math.min(o.columns, scol - offset + width)) + if tar == 'msg' and api.nvim_win_get_width(win) < maxwidth then + api.nvim_win_set_width(win, maxwidth) + M.msg.width = maxwidth + end + + local mwidth = tar == 'msg' and M.msg.width or tar == 'dialog' and o.columns or M.cmd.last_col + if scol - offset + width > mwidth then + col = fn.virtcol2col(win, row + 1, texth.end_vcol - (scol - offset + width - mwidth)) + end + + -- Give virt_text the same highlight as the message tail. + local pos, opts = { row, col }, { details = true, overlap = true, type = 'highlight' } + local hl = api.nvim_buf_get_extmarks(ui.bufs[tar], ui.ns, pos, pos, opts) + for _, chunk in ipairs(hl[1] and chunks or {}) do + chunk[2] = hl[1][4].hl_group + end + else + local mode = #M.virt.last[M.virt.idx.mode] + local pad = o.columns - width ---@type integer + local newlines = math.max(0, ui.cmdheight - texth.all) + row = row + newlines + M.cmd.last_col = mode > 0 and 0 or o.columns - (newlines > 0 and 0 or width) + + if newlines > 0 then + -- Add empty lines to place virt_text on the last screen row. + api.nvim_buf_set_lines(ui.bufs.cmd, -1, -1, false, fn['repeat']({ '' }, newlines)) + col = 0 + else + if scol > M.cmd.last_col then + -- Give the user some time to read an important message. + if os.time() - M.cmd.last_emsg < 2 then + M.virt.delayed = true + vim.defer_fn(function() + M.virt.delayed = false + set_virttext('last') + end, 2000) + return + end + + -- Crop text on last screen row and find byte offset to place mark at. + local vcol = texth.end_vcol - (scol - M.cmd.last_col) + col = vcol <= 0 and 0 or fn.virtcol2col(win, row + 1, vcol) + M.prev_msg = mode > 0 and '' or M.prev_msg + M.virt.msg = mode > 0 and { {}, {} } or M.virt.msg + api.nvim_buf_set_text(ui.bufs.cmd, row, col, row, -1, { mode > 0 and ' ' or '' }) + end + + pad = pad - ((mode > 0 or col == 0) and 0 or math.min(M.cmd.last_col, scol)) + end + table.insert(chunks, mode + 1, { (' '):rep(pad) }) + set_virttext('msg') -- Readjust to new M.cmd.last_col or clear for mode. + end + + M.virt.ids[type] = api.nvim_buf_set_extmark(ui.bufs[tar], ui.ns, row, col, { + virt_text = chunks, + virt_text_pos = 'overlay', + right_gravity = false, + undo_restore = false, + invalidate = true, + id = M.virt.ids[type], + priority = type == 'msg' and 2 or 1, + }) + end +end + +local hlopts = { undo_restore = false, invalidate = true, priority = 1 } +--- Move messages to expanded cmdline or pager to show in full. +local function expand_msg(src) + -- Copy and clear message from src to enlarged cmdline that is dismissed by any + -- key press, or append to pager in case that is already open (not hidden). + local hidden = api.nvim_win_get_config(ui.wins.pager).hide + local tar = hidden and 'cmd' or 'pager' + if tar ~= src then + local srow = hidden and 0 or api.nvim_buf_line_count(ui.bufs.pager) + local opts = { details = true, type = 'highlight' } + local marks = api.nvim_buf_get_extmarks(ui.bufs[src], -1, 0, -1, opts) + local lines = api.nvim_buf_get_lines(ui.bufs[src], 0, -1, false) + api.nvim_buf_set_lines(ui.bufs[src], 0, -1, false, {}) + api.nvim_buf_set_lines(ui.bufs[tar], srow, -1, false, lines) + for _, mark in ipairs(marks) do + hlopts.end_col, hlopts.hl_group = mark[4].end_col, mark[4].hl_group + api.nvim_buf_set_extmark(ui.bufs[tar], ui.ns, srow + mark[2], mark[3], hlopts) + end + + if tar == 'cmd' and ui.cmd.highlighter then + ui.cmd.highlighter.active[ui.bufs.cmd] = nil + elseif tar == 'pager' then + api.nvim_command('norm! G') + end + + M.virt.msg[M.virt.idx.spill][1] = nil + M[src].ids = {} + M.msg:close() + else + for _, id in pairs(M.virt.ids) do + api.nvim_buf_del_extmark(ui.bufs.cmd, ui.ns, id) + end + end + M.set_pos(tar) +end + +-- Keep track of the current message column to be able to +-- append or overwrite messages for :echon or carriage returns. +local col = 0 +local cmd_timer ---@type uv.uv_timer_t? Timer resetting cmdline state next event loop. +---@param tar 'cmd'|'dialog'|'msg'|'pager' +---@param content MsgContent +---@param replace_last boolean +---@param append boolean +---@param id integer|string +function M.show_msg(tar, content, replace_last, append, id) + local mark, msg, cr, dupe, buf = {}, '', false, 0, ui.bufs[tar] + + if M[tar] then -- tar == 'cmd'|'msg' + local extid = M[tar].ids[id] and M[tar].ids[id].extid + if tar == ui.cfg.msg.target then + -- Save the concatenated message to identify repeated messages. + for _, chunk in ipairs(content) do + msg = msg .. chunk[2] + end + dupe = (not extid and msg == M.prev_msg and ui.cmd.srow == 0 and M.dupe + 1 or 0) + end + + cr = next(M[tar].ids) ~= nil and msg:sub(1, 1) == '\r' + replace_last = next(M[tar].ids) ~= nil and not extid and (replace_last or dupe > 0) + extid = extid or replace_last and M[tar].ids[M.prev_id] and M[tar].ids[M.prev_id].extid + mark = extid and api.nvim_buf_get_extmark_by_id(buf, ui.ns, extid, { details = true }) or {} + + -- Ensure cmdline is clear when writing the first message. + if tar == 'cmd' and dupe == 0 and not next(M.cmd.ids) and ui.cmd.srow == 0 then + api.nvim_buf_set_lines(buf, 0, -1, false, {}) + end + end + + -- Filter out empty newline messages. TODO: don't emit them. + if msg == '\n' then + return + end + + local line_count = api.nvim_buf_line_count(buf) + ---@type integer Start row after last line in the target buffer, unless + ---this is the first message, or in case of a repeated or replaced message. + local row = mark[1] + or (M[tar] and not next(M[tar].ids) and ui.cmd.srow == 0 and 0) + or (line_count - ((replace_last or cr or append) and 1 or 0)) + local curline = (cr or append) and api.nvim_buf_get_lines(buf, row, row + 1, false)[1] + local start_row, width = row, M.msg.width + col = mark[2] or (append and not cr and math.min(col, #curline) or 0) + local start_col = col + + -- Accumulate to be inserted and highlighted message chunks. + for _, chunk in ipairs(content) do + -- Split at newline and write to start of line after carriage return. + for str in (chunk[2] .. '\0'):gmatch('.-[\n\r%z]') do + local repl, pat = str:sub(1, -2), str:sub(-1) + local end_col = col + #repl ---@type integer + + -- Insert new line at end of buffer or when inserting lines for a replaced message. + if line_count < row + 1 or mark[1] and row > start_row then + api.nvim_buf_set_lines(buf, row, row > start_row and row or -1, false, { repl }) + line_count = line_count + 1 + else + local erow = mark[3] and mark[3].end_row or row + local ecol = mark[3] and mark[3].end_col or curline and math.min(end_col, #curline) or -1 + api.nvim_buf_set_text(buf, row, col, erow, ecol, { repl }) + end + curline = api.nvim_buf_get_lines(buf, row, row + 1, false)[1] + width = tar == 'msg' and math.max(width, api.nvim_strwidth(curline)) or 0 + mark[3] = nil + + if chunk[3] > 0 then + hlopts.end_col, hlopts.hl_group = end_col, chunk[3] + api.nvim_buf_set_extmark(buf, ui.ns, row, col, hlopts) + end + + if pat == '\n' then + row, col = row + 1, 0 + else + col = pat == '\r' and 0 or end_col + end + end + end + + if M[tar] then + -- Keep track of message span to replace by ID. + local opts = { end_row = row, end_col = col, invalidate = true, undo_restore = false } + M[tar].ids[id] = M[tar].ids[id] or {} + M[tar].ids[id].extid = api.nvim_buf_set_extmark(buf, ui.ns, start_row, start_col, opts) + end + + if tar == 'msg' then + api.nvim_win_set_width(ui.wins.msg, width) + local texth = api.nvim_win_text_height(ui.wins.msg, { start_row = start_row, end_row = row }) + if texth.all > math.ceil(o.lines * 0.5) then + expand_msg(tar) + else + M.set_pos('msg') + M.msg.width = width + M.msg:start_timer(buf, id) + end + elseif tar == 'cmd' and dupe == 0 then + fn.clearmatches(ui.wins.cmd) -- Clear matchparen highlights. + if ui.cmd.srow > 0 then + -- In block mode the cmdheight is already dynamic, so just print the full message + -- regardless of height. Put cmdline below message. + ui.cmd.srow = row + 1 + else + api.nvim_win_set_cursor(ui.wins.cmd, { 1, 0 }) -- ensure first line is visible + if ui.cmd.highlighter then + ui.cmd.highlighter.active[buf] = nil + end + -- Place [+x] indicator for lines that spill over 'cmdheight'. + local texth = api.nvim_win_text_height(ui.wins.cmd, {}) + local spill = texth.all > ui.cmdheight and (' [+%d]'):format(texth.all - ui.cmdheight) + M.virt.msg[M.virt.idx.spill][1] = spill and { 0, spill } or nil + M.cmd.msg_row = texth.end_row + + if texth.all > ui.cmdheight then + expand_msg(tar) + end + end + end + + if M[tar] and row == api.nvim_buf_line_count(buf) - 1 then + -- Place (x) indicator for repeated messages. Mainly to mitigate unnecessary + -- resizing of the message window, but also placed in the cmdline. + M.virt.msg[M.virt.idx.dupe][1] = dupe > 0 and { 0, ('(%d)'):format(dupe) } or nil + M.prev_id, M.prev_msg, M.dupe = id, msg, dupe + set_virttext('msg') + end + + -- Reset message state the next event loop iteration. + if not cmd_timer and (col > 0 or next(M.cmd.ids) ~= nil) then + cmd_timer = vim.defer_fn(function() + M.cmd.ids, cmd_timer, col = cmd_on_key and M.cmd.ids or {}, nil, 0 + end, 0) + end +end + +--- Route the message to the appropriate sink. +--- +---@param kind string +---@alias MsgChunk [integer, string, integer] +---@alias MsgContent MsgChunk[] +---@param content MsgContent +---@param replace_last boolean +--@param history boolean +---@param append boolean +---@param id integer|string +function M.msg_show(kind, content, replace_last, _, append, id) + if kind == 'empty' then + -- A sole empty message clears the cmdline. + if ui.cfg.msg.target == 'cmd' and not next(M.cmd.ids) and ui.cmd.srow == 0 then + M.msg_clear() + end + elseif kind == 'search_count' then + -- Extract only the search_count, not the entered search command. + -- Match any of search.c:cmdline_search_stat():' [(x | >x | ?)/(y | >y | ??)]' + content = { content[#content] } + content[1][2] = content[1][2]:match('W? %[>?%d*%??/>?%d*%?*%]') .. ' ' + M.virt.last[M.virt.idx.search] = content + M.virt.last[M.virt.idx.cmd] = { { 0, (' '):rep(11) } } + set_virttext('last') + elseif (ui.cmd.prompt or kind == 'wildlist') and ui.cmd.srow == 0 then + -- Route to dialog that stays open so long as the cmdline prompt is active. + replace_last = api.nvim_win_get_config(ui.wins.dialog).hide or kind == 'wildlist' + if kind == 'wildlist' then + api.nvim_buf_set_lines(ui.bufs.dialog, 0, -1, false, {}) + ui.cmd.prompt = true -- Ensure dialog is closed when cmdline is hidden. + end + M.show_msg('dialog', content, replace_last, append, id) + M.set_pos('dialog') + else + -- Set the entered search command in the cmdline (if available). + local tar = kind == 'search_cmd' and 'cmd' or ui.cfg.msg.target + if tar == 'cmd' then + if ui.cmdheight == 0 or (ui.cmd.level > 0 and ui.cmd.srow == 0) then + return -- Do not overwrite an active cmdline unless in block mode. + end + -- Store the time when an important message was emitted in order to not overwrite + -- it with 'last' virt_text in the cmdline so that the user has a chance to read it. + M.cmd.last_emsg = (kind == 'emsg' or kind == 'wmsg') and os.time() or M.cmd.last_emsg + -- Should clear the search count now, mark itself is cleared by invalidate. + M.virt.last[M.virt.idx.search][1] = nil + end + + M.show_msg(tar, content, replace_last, append, id) + -- Don't remember search_cmd message as actual message. + if kind == 'search_cmd' then + M.cmd.ids, M.prev_msg = {}, '' + end + end +end + +---Clear currently visible messages. +function M.msg_clear() + api.nvim_buf_set_lines(ui.bufs.cmd, 0, -1, false, {}) + api.nvim_buf_set_lines(ui.bufs.msg, 0, -1, false, {}) + api.nvim_win_set_config(ui.wins.msg, { hide = true }) + M[ui.cfg.msg.target].ids, M.dupe, M.cmd.msg_row, M.msg.width = {}, 0, -1, 1 + M.prev_msg, M.virt.msg = '', { {}, {} } +end + +--- Place the mode text in the cmdline. +--- +---@param content MsgContent +function M.msg_showmode(content) + M.virt.last[M.virt.idx.mode] = ui.cmd.level > 0 and {} or content + M.virt.last[M.virt.idx.search] = {} + set_virttext('last') +end + +--- Place text from the 'showcmd' buffer in the cmdline. +--- +---@param content MsgContent +function M.msg_showcmd(content) + local str = content[1] and content[1][2]:sub(-10) or '' + M.virt.last[M.virt.idx.cmd][1] = (content[1] or M.virt.last[M.virt.idx.search][1]) + and { 0, str .. (' '):rep(11 - #str) } + set_virttext('last') +end + +--- Place the 'ruler' text in the cmdline window. +--- +---@param content MsgContent +function M.msg_ruler(content) + M.virt.last[M.virt.idx.ruler] = ui.cmd.level > 0 and {} or content + set_virttext('last') +end + +---@alias MsgHistory [string, MsgContent, boolean] +--- Open the message history in the pager. +--- +---@param entries MsgHistory[] +---@param prev_cmd boolean +function M.msg_history_show(entries, prev_cmd) + if #entries == 0 then + return + end + + if cmd_on_key then + -- Dismiss a still expanded cmdline. + api.nvim_feedkeys(vim.keycode('<CR>'), 'n', false) + elseif prev_cmd then + -- Showing output of previous command, clear in case still visible. + M.msg_clear() + end + + api.nvim_buf_set_lines(ui.bufs.pager, 0, -1, false, {}) + for i, entry in ipairs(entries) do + M.show_msg('pager', entry[2], i == 1, entry[3], 0) + end + + M.set_pos('pager') +end + +--- Adjust visibility and dimensions of the message windows after certain events. +--- +---@param type? 'cmd'|'dialog'|'msg'|'pager' Type of to be positioned window (nil for all). +function M.set_pos(type) + local function win_set_pos(win) + local cfg = { hide = false, relative = 'laststatus', col = 10000 } + local texth = type and api.nvim_win_text_height(win, {}) or {} + local top = { vim.opt.fcs:get().msgsep or ' ', 'MsgSeparator' } + cfg.height = type and math.min(texth.all, math.ceil(o.lines * 0.5)) + cfg.border = win ~= ui.wins.msg and { '', top, '', '', '', '', '', '' } or nil + cfg.focusable = type == 'cmd' or nil + cfg.row = (win == ui.wins.msg and 0 or 1) - ui.cmd.wmnumode + cfg.row = cfg.row - ((win == ui.wins.pager and o.laststatus == 3) and 1 or 0) + api.nvim_win_set_config(win, cfg) + + if type == 'cmd' and not cmd_on_key then + -- Temporarily expand the cmdline, until next key press. + local save_spill = M.virt.msg[M.virt.idx.spill][1] + local spill = texth.all > cfg.height and (' [+%d]'):format(texth.all - cfg.height) + M.virt.msg[M.virt.idx.spill][1] = spill and { 0, spill } or nil + set_virttext('msg', 'cmd') + M.virt.msg[M.virt.idx.spill][1] = save_spill + cmd_on_key = vim.on_key(function(_, typed) + if not typed or fn.keytrans(typed) == '<MouseMove>' then + return + end + vim.schedule(function() + local entered = api.nvim_get_current_win() == ui.wins.cmd + cmd_on_key = nil + if api.nvim_win_is_valid(ui.wins.cmd) then + api.nvim_win_close(ui.wins.cmd, true) + end + ui.check_targets() + -- Show or clear the message depending on if the pager was opened. + if entered or not api.nvim_win_get_config(ui.wins.pager).hide then + M.virt.msg[M.virt.idx.spill][1] = nil + api.nvim_buf_set_lines(ui.bufs.cmd, 0, -1, false, {}) + if entered then + api.nvim_command('norm! g<') -- User entered the cmdline window: open the pager. + end + elseif ui.cfg.msg.target == 'cmd' and ui.cmd.level <= 0 then + ui.check_targets() + set_virttext('msg') + end + api.nvim__redraw({ flush = true }) -- NOTE: redundant unless cmdline was opened. + end) + vim.on_key(nil, ui.ns) + end, ui.ns) + elseif type == 'dialog' then + -- Add virtual [+x] text to indicate scrolling is possible. + local function set_top_bot_spill() + local topspill = fn.line('w0', ui.wins.dialog) - 1 + local botspill = api.nvim_buf_line_count(ui.bufs.dialog) - fn.line('w$', ui.wins.dialog) + M.virt.top[1][1] = topspill > 0 and { 0, (' [+%d]'):format(topspill) } or nil + set_virttext('top', 'dialog') + M.virt.bot[1][1] = botspill > 0 and { 0, (' [+%d]'):format(botspill) } or nil + set_virttext('bot', 'dialog') + api.nvim__redraw({ flush = true }) + end + set_top_bot_spill() + + -- Allow paging in the dialog window, consume the key if the topline changes. + M.dialog_on_key = vim.on_key(function(key, typed) + if not typed then + return + end + local page_keys = { + g = 'gg', + G = 'G', + j = 'Lj', + k = 'Hk', + d = [[\<C-D>]], + u = [[\<C-U>]], + f = [[\<C-F>]], + b = [[\<C-B>]], + } + local info = page_keys[key] and fn.getwininfo(ui.wins.dialog)[1] + if info and (key ~= 'f' or info.botline < api.nvim_buf_line_count(ui.bufs.dialog)) then + fn.win_execute(ui.wins.dialog, ('exe "norm! %s"'):format(page_keys[key])) + set_top_bot_spill() + return fn.getwininfo(ui.wins.dialog)[1].topline ~= info.topline and '' or nil + end + end) + elseif type == 'msg' then + -- Ensure last line is visible and first line is at top of window. + local row = (texth.all > cfg.height and texth.end_row or 0) + 1 + api.nvim_win_set_cursor(ui.wins.msg, { row, 0 }) + elseif type == 'pager' then + if fn.getcmdwintype() ~= '' then + -- Cannot leave the cmdwin to enter the pager, so close it. + -- NOTE: regression w.r.t. the message grid, which allowed this. + -- Resolving that would require somehow bypassing textlock for the pager. + api.nvim_command('quit') + end + + -- Cmdwin is actually closed one event iteration later so schedule in case it was open. + vim.schedule(function() + api.nvim_set_current_win(ui.wins.pager) + -- Make pager relative to cmdwin when it is opened, restore when it is closed. + api.nvim_create_autocmd({ 'WinEnter', 'CmdwinEnter', 'CmdwinLeave' }, { + callback = function(ev) + if api.nvim_win_is_valid(ui.wins.pager) then + local config = ev.event == 'CmdwinLeave' and cfg + or ev.event == 'WinEnter' and { hide = true } + or { relative = 'win', win = 0, row = 0, col = 0 } + api.nvim_win_set_config(ui.wins.pager, config) + end + return ev.event == 'WinEnter' + end, + desc = 'Hide or reposition pager window.', + }) + end) + end + end + + for t, win in pairs(ui.wins) do + local cfg = (t == type or (type == nil and t ~= 'cmd')) + and api.nvim_win_is_valid(win) + and api.nvim_win_get_config(win) + if cfg and (type or not cfg.hide) then + win_set_pos(win) + end + end +end + +return M diff --git a/runtime/lua/vim/_extui.lua b/runtime/lua/vim/_extui.lua @@ -1,152 +0,0 @@ ---- @brief ---- ----WARNING: This is an experimental interface intended to replace the message ----grid in the TUI. ---- ----To enable the experimental UI (default opts shown): ----```lua ----require('vim._extui').enable({ ---- enable = true, -- Whether to enable or disable the UI. ---- msg = { -- Options related to the message module. ---- ---@type 'cmd'|'msg' Where to place regular messages, either in the ---- ---cmdline or in a separate ephemeral message window. ---- target = 'cmd', ---- timeout = 4000, -- Time a message is visible in the message window. ---- }, ----}) ----``` ---- ----There are four separate window types used by this interface: ----- "cmd": The cmdline window; also used for 'showcmd', 'showmode', 'ruler', and ---- messages if 'cmdheight' > 0. ----- "msg": The message window; used for messages when 'cmdheight' == 0. ----- "pager": The pager window; used for |:messages| and certain messages ---- that should be shown in full. ----- "dialog": The dialog window; used for prompt messages that expect user input. ---- ----These four windows are assigned the "cmd", "msg", "pager" and "dialog" ----'filetype' respectively. Use a |FileType| autocommand to configure any local ----options for these windows and their respective buffers. ---- ----Rather than a |hit-enter-prompt|, messages shown in the cmdline area that do ----not fit are appended with a `[+x]` "spill" indicator, where `x` indicates the ----spilled lines. To see the full message, the |g<| command can be used. - -local api = vim.api -local ext = require('vim._extui.shared') -ext.msg = require('vim._extui.messages') -ext.cmd = require('vim._extui.cmdline') -local M = {} - -local function ui_callback(redraw_msg, event, ...) - local handler = ext.msg[event] or ext.cmd[event] - ext.check_targets() - handler(...) - -- Cmdline mode, non-fast message and non-empty showcmd require an immediate redraw. - if ext.cmd[event] or redraw_msg or (event == 'msg_showcmd' and select(1, ...)[1]) then - ext.redrawing = true - api.nvim__redraw({ - flush = handler ~= ext.cmd.cmdline_hide or nil, - cursor = handler == ext.cmd[event] and true or nil, - win = handler == ext.cmd[event] and ext.wins.cmd or nil, - }) - ext.redrawing = false - end -end -local scheduled_ui_callback = vim.schedule_wrap(ui_callback) - ----@nodoc -function M.enable(opts) - vim.validate('opts', opts, 'table', true) - if opts.msg then - vim.validate('opts.msg.pos', opts.msg.pos, 'nil', true, 'nil: "pos" moved to opts.target') - vim.validate('opts.msg.box', opts.msg.box, 'nil', true, 'nil: "timeout" moved to opts.msg') - vim.validate('opts.msg.target', opts.msg.target, function(tar) - return tar == 'cmd' or tar == 'msg' - end, "'cmd'|'msg'") - end - ext.cfg = vim.tbl_deep_extend('keep', opts, ext.cfg) - - if ext.cfg.enable == false then - -- Detach and cleanup windows, buffers and autocommands. - for _, win in pairs(ext.wins) do - if api.nvim_win_is_valid(win) then - api.nvim_win_close(win, true) - end - end - for _, buf in pairs(ext.bufs) do - if api.nvim_buf_is_valid(buf) then - api.nvim_buf_delete(buf, {}) - end - end - api.nvim_clear_autocmds({ group = ext.augroup }) - vim.ui_detach(ext.ns) - return - end - - vim.ui_attach(ext.ns, { ext_messages = true, set_cmdheight = false }, function(event, ...) - if not (ext.msg[event] or ext.cmd[event]) then - return - end - -- Ensure cmdline is placed after a scheduled message in block mode. - if vim.in_fast_event() or (event == 'cmdline_show' and ext.cmd.srow > 0) then - scheduled_ui_callback(false, event, ...) - else - ui_callback(event == 'msg_show', event, ...) - end - return true - end) - - -- The visibility and appearance of the cmdline and message window is - -- dependent on some option values. Reconfigure windows when option value - -- has changed and after VimEnter when the user configured value is known. - -- TODO: Reconsider what is needed when this module is enabled by default early in startup. - local function check_cmdheight(value) - ext.check_targets() - -- 'cmdheight' set; (un)hide cmdline window and set its height. - local cfg = { height = math.max(value, 1), hide = value == 0 } - api.nvim_win_set_config(ext.wins.cmd, cfg) - -- Change message position when 'cmdheight' was or becomes 0. - if value == 0 or ext.cmdheight == 0 then - ext.cfg.msg.target = value == 0 and 'msg' or 'cmd' - ext.msg.prev_msg = '' - end - ext.cmdheight = value - end - - if vim.v.vim_did_enter == 0 then - vim.schedule(function() - check_cmdheight(vim.o.cmdheight) - end) - end - - api.nvim_create_autocmd('OptionSet', { - group = ext.augroup, - pattern = { 'cmdheight' }, - callback = function() - check_cmdheight(vim.v.option_new) - ext.msg.set_pos() - end, - desc = 'Set cmdline and message window dimensions for changed option values.', - }) - - api.nvim_create_autocmd({ 'VimResized', 'TabEnter' }, { - group = ext.augroup, - callback = function() - ext.msg.set_pos() - end, - desc = 'Set cmdline and message window dimensions after shell resize or tabpage change.', - }) - - api.nvim_create_autocmd('WinEnter', { - callback = function() - local win = api.nvim_get_current_win() - if vim.tbl_contains(ext.wins, win) and api.nvim_win_get_config(win).hide then - vim.cmd.wincmd('p') - end - end, - desc = 'Make sure hidden extui window is never current.', - }) -end - -return M diff --git a/runtime/lua/vim/_extui/cmdline.lua b/runtime/lua/vim/_extui/cmdline.lua @@ -1,184 +0,0 @@ -local ext = require('vim._extui.shared') -local api, fn = vim.api, vim.fn ----@class vim._extui.cmdline -local M = { - highlighter = nil, ---@type vim.treesitter.highlighter? - indent = 0, -- Current indent for block event. - prompt = false, -- Whether a prompt is active; messages are placed in the 'dialog' window. - srow = 0, -- Buffer row at which the current cmdline starts; > 0 in block mode. - erow = 0, -- Buffer row at which the current cmdline ends; messages appended here in block mode. - level = -1, -- Current cmdline level; 0 when inactive, -1 one loop iteration after closing. - wmnumode = 0, -- wildmenumode() when not using the pum, dialog position adjusted when toggled. -} - ---- Set the 'cmdheight' and cmdline window height. Reposition message windows. ---- ----@param win integer Cmdline window in the current tabpage. ----@param hide boolean Whether to hide or show the window. ----@param height integer (Text)height of the cmdline window. -local function win_config(win, hide, height) - if ext.cmdheight == 0 and api.nvim_win_get_config(win).hide ~= hide then - api.nvim_win_set_config(win, { hide = hide, height = not hide and height or nil }) - elseif api.nvim_win_get_height(win) ~= height then - api.nvim_win_set_height(win, height) - end - if vim.o.cmdheight ~= height then - -- Avoid moving the cursor with 'splitkeep' = "screen", and altering the user - -- configured value with noautocmd. - vim._with({ noautocmd = true, o = { splitkeep = 'screen' } }, function() - vim.o.cmdheight = height - end) - ext.msg.set_pos() - elseif M.wmnumode ~= (M.prompt and fn.pumvisible() == 0 and fn.wildmenumode() or 0) then - M.wmnumode = (M.wmnumode == 1 and 0 or 1) - ext.msg.set_pos() - end -end - -local cmdbuff = '' ---@type string Stored cmdline used to calculate translation offset. -local promptlen = 0 -- Current length of the last line in the prompt. ---- Concatenate content chunks and set the text for the current row in the cmdline buffer. ---- ----@alias CmdChunk [integer, string] ----@alias CmdContent CmdChunk[] ----@param content CmdContent ----@param prompt string -local function set_text(content, prompt) - local lines = {} ---@type string[] - for line in (prompt .. '\n'):gmatch('(.-)\n') do - lines[#lines + 1] = fn.strtrans(line) - end - cmdbuff, promptlen, M.erow = '', #lines[#lines], M.srow + #lines - 1 - for _, chunk in ipairs(content) do - cmdbuff = cmdbuff .. chunk[2] - end - lines[#lines] = ('%s%s '):format(lines[#lines], fn.strtrans(cmdbuff)) - api.nvim_buf_set_lines(ext.bufs.cmd, M.srow, -1, false, lines) -end - ---- Set the cmdline buffer text and cursor position. ---- ----@param content CmdContent ----@param pos integer ----@param firstc string ----@param prompt string ----@param indent integer ----@param level integer ----@param hl_id integer -function M.cmdline_show(content, pos, firstc, prompt, indent, level, hl_id) - M.level, M.indent, M.prompt = level, indent, M.prompt or #prompt > 0 - if M.highlighter == nil or M.highlighter.bufnr ~= ext.bufs.cmd then - local parser = assert(vim.treesitter.get_parser(ext.bufs.cmd, 'vim', {})) - M.highlighter = vim.treesitter.highlighter.new(parser) - end - -- Only enable TS highlighter for Ex commands (not search or filter commands). - M.highlighter.active[ext.bufs.cmd] = firstc == ':' and M.highlighter or nil - if ext.msg.cmd.msg_row ~= -1 then - ext.msg.msg_clear() - end - ext.msg.virt.last = { {}, {}, {}, {} } - - set_text(content, ('%s%s%s'):format(firstc, prompt, (' '):rep(indent))) - if promptlen > 0 and hl_id > 0 then - api.nvim_buf_set_extmark(ext.bufs.cmd, ext.ns, 0, 0, { hl_group = hl_id, end_col = promptlen }) - end - - local height = math.max(ext.cmdheight, api.nvim_win_text_height(ext.wins.cmd, {}).all) - win_config(ext.wins.cmd, false, height) - M.cmdline_pos(pos) -end - ---- Insert special character at cursor position. ---- ----@param c string ----@param shift boolean ---@param level integer -function M.cmdline_special_char(c, shift) - api.nvim_win_call(ext.wins.cmd, function() - api.nvim_put({ c }, shift and '' or 'c', false, false) - end) -end - -local curpos = { 0, 0 } -- Last drawn cursor position. ---- Set the cmdline cursor position. ---- ----@param pos integer ---@param level integer -function M.cmdline_pos(pos) - pos = #fn.strtrans(cmdbuff:sub(1, pos)) - if curpos[1] ~= M.erow + 1 or curpos[2] ~= promptlen + pos then - curpos[1], curpos[2] = M.erow + 1, promptlen + pos - -- Add matchparen highlighting to non-prompt part of cmdline. - if pos > 0 and fn.exists('#matchparen#CursorMoved') == 1 then - api.nvim_win_set_cursor(ext.wins.cmd, { curpos[1], curpos[2] - 1 }) - vim._with({ win = ext.wins.cmd, wo = { eventignorewin = '' } }, function() - api.nvim_exec_autocmds('CursorMoved', {}) - end) - end - api.nvim_win_set_cursor(ext.wins.cmd, curpos) - end -end - ---- Leaving the cmdline, restore 'cmdheight' and 'ruler'. ---- ----@param level integer ----@param abort boolean -function M.cmdline_hide(level, abort) - if M.srow > 0 or level > (fn.getcmdwintype() == '' and 1 or 2) then - return -- No need to hide when still in nested cmdline or cmdline_block. - end - - fn.clearmatches(ext.wins.cmd) -- Clear matchparen highlights. - api.nvim_win_set_cursor(ext.wins.cmd, { 1, 0 }) - if abort then - -- Clear cmd buffer for aborted command (non-abort is left visible). - api.nvim_buf_set_lines(ext.bufs.cmd, 0, -1, false, {}) - end - - local clear = vim.schedule_wrap(function(was_prompt) - -- Avoid clearing prompt window when it is re-entered before the next event - -- loop iteration. E.g. when a non-choice confirm button is pressed. - if was_prompt and not M.prompt then - api.nvim_buf_set_lines(ext.bufs.cmd, 0, -1, false, {}) - api.nvim_buf_set_lines(ext.bufs.dialog, 0, -1, false, {}) - api.nvim_win_set_config(ext.wins.dialog, { hide = true }) - vim.on_key(nil, ext.msg.dialog_on_key) - end - -- Messages emitted as a result of a typed command are treated specially: - -- remember if the cmdline was used this event loop iteration. - -- NOTE: Message event callbacks are themselves scheduled, so delay two iterations. - vim.schedule(function() - M.level = -1 - end) - end) - clear(M.prompt) - - M.prompt, M.level, curpos[1], curpos[2] = false, 0, 0, 0 - win_config(ext.wins.cmd, true, ext.cmdheight) -end - ---- Set multi-line cmdline buffer text. ---- ----@param lines CmdContent[] -function M.cmdline_block_show(lines) - for _, content in ipairs(lines) do - set_text(content, ':') - M.srow = M.srow + 1 - end -end - ---- Append line to a multiline cmdline. ---- ----@param line CmdContent -function M.cmdline_block_append(line) - set_text(line, ':') - M.srow = M.srow + 1 -end - ---- Clear cmdline buffer and leave the cmdline. -function M.cmdline_block_hide() - M.srow = 0 - M.cmdline_hide(M.level, true) -end - -return M diff --git a/runtime/lua/vim/_extui/messages.lua b/runtime/lua/vim/_extui/messages.lua @@ -1,619 +0,0 @@ -local api, fn, o = vim.api, vim.fn, vim.o -local ext = require('vim._extui.shared') - ----@alias Msg { extid: integer, timer: uv.uv_timer_t? } ----@class vim._extui.messages -local M = { - -- Message window. Used for regular messages with 'cmdheight' == 0 or, - -- cfg.msg.target == 'msg'. Automatically resizes to the text dimensions up to - -- a point, at which point only the most recent messages will fit and be shown. - -- A timer is started for each message whose callback will remove the message - -- from the window again. - msg = { - ids = {}, ---@type table<string|integer, Msg> List of visible messages. - width = 1, -- Current width of the message window. - }, - -- Cmdline message window. Used for regular messages with 'cmdheight' > 0. - -- Also contains 'ruler', 'showcmd' and search_cmd/count messages as virt_text. - -- Messages that don't fit the 'cmdheight' are cut off and virt_text is added - -- to indicate the number of spilled lines and repeated messages. - cmd = { - ids = {}, ---@type table<string|integer, Msg> List of visible messages. - msg_row = -1, -- Last row of message to distinguish for placing virt_text. - last_col = o.columns, -- Crop text to start column of 'last' virt_text. - last_emsg = 0, -- Time an error was printed that should not be overwritten. - }, - dupe = 0, -- Number of times message is repeated. - prev_id = 0, ---@type string|integer Message id of the previous message. - prev_msg = '', -- Concatenated content of the previous message. - virt = { -- Stored virt_text state. - last = { {}, {}, {}, {} }, ---@type MsgContent[] status in last cmdline row. - msg = { {}, {} }, ---@type MsgContent[] [(x)] indicators in msg window. - top = { {} }, ---@type MsgContent[] [+x] top indicator in dialog window. - bot = { {} }, ---@type MsgContent[] [+x] bottom indicator in dialog window. - idx = { mode = 1, search = 2, cmd = 3, ruler = 4, spill = 1, dupe = 2 }, - ids = {}, ---@type { ['last'|'msg'|'top'|'bot']: integer? } Table of mark IDs. - delayed = false, -- Whether placement of 'last' virt_text is delayed. - }, - dialog_on_key = 0, -- vim.on_key namespace for paging in the dialog window. -} - -local cmd_on_key ---@type integer? Set to vim.on_key namespace while cmdline is expanded. --- An external redraw indicates the start of a new batch of messages in the cmdline. -api.nvim_set_decoration_provider(ext.ns, { - on_start = function() - M.cmd.ids = (ext.redrawing or cmd_on_key) and M.cmd.ids or {} - end, -}) - -function M.msg:close() - self.width, M.virt.msg[M.virt.idx.dupe][1] = 1, nil - if api.nvim_win_is_valid(ext.wins.msg) then - api.nvim_win_set_config(ext.wins.msg, { hide = true }) - end -end - ---- Start a timer whose callback will remove the message from the message window. ---- ----@param buf integer Buffer the message was written to. ----@param id integer|string Message ID. -function M.msg:start_timer(buf, id) - if self.ids[id].timer then - self.ids[id].timer:stop() - end - self.ids[id].timer = vim.defer_fn(function() - local extid = api.nvim_buf_is_valid(buf) and self.ids[id] and self.ids[id].extid - local mark = extid and api.nvim_buf_get_extmark_by_id(buf, ext.ns, extid, { details = true }) - self.ids[id] = nil - if not mark or not mark[1] then - return - end - -- Clear prev_msg when line that may have dupe marker is removed. - local erow = api.nvim_buf_line_count(buf) - 1 - M.prev_msg = ext.cfg.msg.target == 'msg' and mark[3].end_row == erow and '' or M.prev_msg - - -- Remove message (including potentially leftover empty line). - api.nvim_buf_set_text(buf, mark[1], mark[2], mark[3].end_row, mark[3].end_col, {}) - if fn.col({ mark[1] + 1, '$' }, ext.wins.msg) == 1 then - api.nvim_buf_set_lines(buf, mark[1], mark[1] + 1, false, {}) - end - - -- Resize or hide message window for removed message. - if next(self.ids) then - M.set_pos('msg') - else - self:close() - end - end, ext.cfg.msg.timeout) -end - ---- Place or delete a virtual text mark in the cmdline or message window. ---- ----@param type 'last'|'msg'|'top'|'bot' ----@param tar? 'cmd'|'msg'|'dialog' -local function set_virttext(type, tar) - if (type == 'last' and (ext.cmdheight == 0 or M.virt.delayed)) or cmd_on_key then - return -- Don't show virtual text while cmdline is expanded or delaying for error. - end - - -- Concatenate the components of M.virt[type] and calculate the concatenated width. - local width, chunks = 0, {} ---@type integer, [string, integer|string][] - local contents = M.virt[type] ---@type MsgContent[] - for _, content in ipairs(contents) do - for _, chunk in ipairs(content) do - chunks[#chunks + 1] = { chunk[2], chunk[3] } - width = width + api.nvim_strwidth(chunk[2]) - end - end - tar = tar or type == 'msg' and ext.cfg.msg.target or 'cmd' - - if M.virt.ids[type] and #chunks == 0 then - api.nvim_buf_del_extmark(ext.bufs[tar], ext.ns, M.virt.ids[type]) - M.cmd.last_col = type == 'last' and o.columns or M.cmd.last_col - M.virt.ids[type] = nil - elseif #chunks > 0 then - local win = ext.wins[tar] - local line = (tar == 'msg' or type == 'top') and 'w0' or type == 'bot' and 'w$' - local srow = line and fn.line(line, ext.wins.dialog) - 1 - local erow = tar == 'cmd' and math.min(M.cmd.msg_row, api.nvim_buf_line_count(ext.bufs.cmd) - 1) - local texth = api.nvim_win_text_height(win, { - max_height = (type == 'top' or type == 'bot') and 1 or api.nvim_win_get_height(win), - start_row = srow or nil, - end_row = erow or nil, - }) - local row = texth.end_row - local col = fn.virtcol2col(win, row + 1, texth.end_vcol) - local scol = fn.screenpos(win, row + 1, col).col ---@type integer - - if type ~= 'last' then - -- Calculate at which column to place the virt_text such that it is at the end - -- of the last visible message line, overlapping the message text if necessary, - -- but not overlapping the 'last' virt_text. - local offset = tar ~= 'msg' and 0 - or api.nvim_win_get_position(win)[2] - + (api.nvim_win_get_config(win).border ~= 'none' and 1 or 0) - - -- Check if adding the virt_text on this line will exceed the current window width. - local maxwidth = math.max(M.msg.width, math.min(o.columns, scol - offset + width)) - if tar == 'msg' and api.nvim_win_get_width(win) < maxwidth then - api.nvim_win_set_width(win, maxwidth) - M.msg.width = maxwidth - end - - local mwidth = tar == 'msg' and M.msg.width or tar == 'dialog' and o.columns or M.cmd.last_col - if scol - offset + width > mwidth then - col = fn.virtcol2col(win, row + 1, texth.end_vcol - (scol - offset + width - mwidth)) - end - - -- Give virt_text the same highlight as the message tail. - local pos, opts = { row, col }, { details = true, overlap = true, type = 'highlight' } - local hl = api.nvim_buf_get_extmarks(ext.bufs[tar], ext.ns, pos, pos, opts) - for _, chunk in ipairs(hl[1] and chunks or {}) do - chunk[2] = hl[1][4].hl_group - end - else - local mode = #M.virt.last[M.virt.idx.mode] - local pad = o.columns - width ---@type integer - local newlines = math.max(0, ext.cmdheight - texth.all) - row = row + newlines - M.cmd.last_col = mode > 0 and 0 or o.columns - (newlines > 0 and 0 or width) - - if newlines > 0 then - -- Add empty lines to place virt_text on the last screen row. - api.nvim_buf_set_lines(ext.bufs.cmd, -1, -1, false, fn['repeat']({ '' }, newlines)) - col = 0 - else - if scol > M.cmd.last_col then - -- Give the user some time to read an important message. - if os.time() - M.cmd.last_emsg < 2 then - M.virt.delayed = true - vim.defer_fn(function() - M.virt.delayed = false - set_virttext('last') - end, 2000) - return - end - - -- Crop text on last screen row and find byte offset to place mark at. - local vcol = texth.end_vcol - (scol - M.cmd.last_col) - col = vcol <= 0 and 0 or fn.virtcol2col(win, row + 1, vcol) - M.prev_msg = mode > 0 and '' or M.prev_msg - M.virt.msg = mode > 0 and { {}, {} } or M.virt.msg - api.nvim_buf_set_text(ext.bufs.cmd, row, col, row, -1, { mode > 0 and ' ' or '' }) - end - - pad = pad - ((mode > 0 or col == 0) and 0 or math.min(M.cmd.last_col, scol)) - end - table.insert(chunks, mode + 1, { (' '):rep(pad) }) - set_virttext('msg') -- Readjust to new M.cmd.last_col or clear for mode. - end - - M.virt.ids[type] = api.nvim_buf_set_extmark(ext.bufs[tar], ext.ns, row, col, { - virt_text = chunks, - virt_text_pos = 'overlay', - right_gravity = false, - undo_restore = false, - invalidate = true, - id = M.virt.ids[type], - priority = type == 'msg' and 2 or 1, - }) - end -end - -local hlopts = { undo_restore = false, invalidate = true, priority = 1 } ---- Move messages to expanded cmdline or pager to show in full. -local function expand_msg(src) - -- Copy and clear message from src to enlarged cmdline that is dismissed by any - -- key press, or append to pager in case that is already open (not hidden). - local hidden = api.nvim_win_get_config(ext.wins.pager).hide - local tar = hidden and 'cmd' or 'pager' - if tar ~= src then - local srow = hidden and 0 or api.nvim_buf_line_count(ext.bufs.pager) - local opts = { details = true, type = 'highlight' } - local marks = api.nvim_buf_get_extmarks(ext.bufs[src], -1, 0, -1, opts) - local lines = api.nvim_buf_get_lines(ext.bufs[src], 0, -1, false) - api.nvim_buf_set_lines(ext.bufs[src], 0, -1, false, {}) - api.nvim_buf_set_lines(ext.bufs[tar], srow, -1, false, lines) - for _, mark in ipairs(marks) do - hlopts.end_col, hlopts.hl_group = mark[4].end_col, mark[4].hl_group - api.nvim_buf_set_extmark(ext.bufs[tar], ext.ns, srow + mark[2], mark[3], hlopts) - end - - if tar == 'cmd' and ext.cmd.highlighter then - ext.cmd.highlighter.active[ext.bufs.cmd] = nil - elseif tar == 'pager' then - api.nvim_command('norm! G') - end - - M.virt.msg[M.virt.idx.spill][1] = nil - M[src].ids = {} - M.msg:close() - else - for _, id in pairs(M.virt.ids) do - api.nvim_buf_del_extmark(ext.bufs.cmd, ext.ns, id) - end - end - M.set_pos(tar) -end - --- Keep track of the current message column to be able to --- append or overwrite messages for :echon or carriage returns. -local col = 0 -local cmd_timer ---@type uv.uv_timer_t? Timer resetting cmdline state next event loop. ----@param tar 'cmd'|'dialog'|'msg'|'pager' ----@param content MsgContent ----@param replace_last boolean ----@param append boolean ----@param id integer|string -function M.show_msg(tar, content, replace_last, append, id) - local mark, msg, cr, dupe, buf = {}, '', false, 0, ext.bufs[tar] - - if M[tar] then -- tar == 'cmd'|'msg' - local extid = M[tar].ids[id] and M[tar].ids[id].extid - if tar == ext.cfg.msg.target then - -- Save the concatenated message to identify repeated messages. - for _, chunk in ipairs(content) do - msg = msg .. chunk[2] - end - dupe = (not extid and msg == M.prev_msg and ext.cmd.srow == 0 and M.dupe + 1 or 0) - end - - cr = next(M[tar].ids) ~= nil and msg:sub(1, 1) == '\r' - replace_last = next(M[tar].ids) ~= nil and not extid and (replace_last or dupe > 0) - extid = extid or replace_last and M[tar].ids[M.prev_id] and M[tar].ids[M.prev_id].extid - mark = extid and api.nvim_buf_get_extmark_by_id(buf, ext.ns, extid, { details = true }) or {} - - -- Ensure cmdline is clear when writing the first message. - if tar == 'cmd' and dupe == 0 and not next(M.cmd.ids) and ext.cmd.srow == 0 then - api.nvim_buf_set_lines(buf, 0, -1, false, {}) - end - end - - -- Filter out empty newline messages. TODO: don't emit them. - if msg == '\n' then - return - end - - local line_count = api.nvim_buf_line_count(buf) - ---@type integer Start row after last line in the target buffer, unless - ---this is the first message, or in case of a repeated or replaced message. - local row = mark[1] - or (M[tar] and not next(M[tar].ids) and ext.cmd.srow == 0 and 0) - or (line_count - ((replace_last or cr or append) and 1 or 0)) - local curline = (cr or append) and api.nvim_buf_get_lines(buf, row, row + 1, false)[1] - local start_row, width = row, M.msg.width - col = mark[2] or (append and not cr and math.min(col, #curline) or 0) - local start_col = col - - -- Accumulate to be inserted and highlighted message chunks. - for _, chunk in ipairs(content) do - -- Split at newline and write to start of line after carriage return. - for str in (chunk[2] .. '\0'):gmatch('.-[\n\r%z]') do - local repl, pat = str:sub(1, -2), str:sub(-1) - local end_col = col + #repl ---@type integer - - -- Insert new line at end of buffer or when inserting lines for a replaced message. - if line_count < row + 1 or mark[1] and row > start_row then - api.nvim_buf_set_lines(buf, row, row > start_row and row or -1, false, { repl }) - line_count = line_count + 1 - else - local erow = mark[3] and mark[3].end_row or row - local ecol = mark[3] and mark[3].end_col or curline and math.min(end_col, #curline) or -1 - api.nvim_buf_set_text(buf, row, col, erow, ecol, { repl }) - end - curline = api.nvim_buf_get_lines(buf, row, row + 1, false)[1] - width = tar == 'msg' and math.max(width, api.nvim_strwidth(curline)) or 0 - mark[3] = nil - - if chunk[3] > 0 then - hlopts.end_col, hlopts.hl_group = end_col, chunk[3] - api.nvim_buf_set_extmark(buf, ext.ns, row, col, hlopts) - end - - if pat == '\n' then - row, col = row + 1, 0 - else - col = pat == '\r' and 0 or end_col - end - end - end - - if M[tar] then - -- Keep track of message span to replace by ID. - local opts = { end_row = row, end_col = col, invalidate = true, undo_restore = false } - M[tar].ids[id] = M[tar].ids[id] or {} - M[tar].ids[id].extid = api.nvim_buf_set_extmark(buf, ext.ns, start_row, start_col, opts) - end - - if tar == 'msg' then - api.nvim_win_set_width(ext.wins.msg, width) - local texth = api.nvim_win_text_height(ext.wins.msg, { start_row = start_row, end_row = row }) - if texth.all > math.ceil(o.lines * 0.5) then - expand_msg(tar) - else - M.set_pos('msg') - M.msg.width = width - M.msg:start_timer(buf, id) - end - elseif tar == 'cmd' and dupe == 0 then - fn.clearmatches(ext.wins.cmd) -- Clear matchparen highlights. - if ext.cmd.srow > 0 then - -- In block mode the cmdheight is already dynamic, so just print the full message - -- regardless of height. Put cmdline below message. - ext.cmd.srow = row + 1 - else - api.nvim_win_set_cursor(ext.wins.cmd, { 1, 0 }) -- ensure first line is visible - if ext.cmd.highlighter then - ext.cmd.highlighter.active[buf] = nil - end - -- Place [+x] indicator for lines that spill over 'cmdheight'. - local texth = api.nvim_win_text_height(ext.wins.cmd, {}) - local spill = texth.all > ext.cmdheight and (' [+%d]'):format(texth.all - ext.cmdheight) - M.virt.msg[M.virt.idx.spill][1] = spill and { 0, spill } or nil - M.cmd.msg_row = texth.end_row - - if texth.all > ext.cmdheight then - expand_msg(tar) - end - end - end - - if M[tar] and row == api.nvim_buf_line_count(buf) - 1 then - -- Place (x) indicator for repeated messages. Mainly to mitigate unnecessary - -- resizing of the message window, but also placed in the cmdline. - M.virt.msg[M.virt.idx.dupe][1] = dupe > 0 and { 0, ('(%d)'):format(dupe) } or nil - M.prev_id, M.prev_msg, M.dupe = id, msg, dupe - set_virttext('msg') - end - - -- Reset message state the next event loop iteration. - if not cmd_timer and (col > 0 or next(M.cmd.ids) ~= nil) then - cmd_timer = vim.defer_fn(function() - M.cmd.ids, cmd_timer, col = cmd_on_key and M.cmd.ids or {}, nil, 0 - end, 0) - end -end - ---- Route the message to the appropriate sink. ---- ----@param kind string ----@alias MsgChunk [integer, string, integer] ----@alias MsgContent MsgChunk[] ----@param content MsgContent ----@param replace_last boolean ---@param history boolean ----@param append boolean ----@param id integer|string -function M.msg_show(kind, content, replace_last, _, append, id) - if kind == 'empty' then - -- A sole empty message clears the cmdline. - if ext.cfg.msg.target == 'cmd' and not next(M.cmd.ids) and ext.cmd.srow == 0 then - M.msg_clear() - end - elseif kind == 'search_count' then - -- Extract only the search_count, not the entered search command. - -- Match any of search.c:cmdline_search_stat():' [(x | >x | ?)/(y | >y | ??)]' - content = { content[#content] } - content[1][2] = content[1][2]:match('W? %[>?%d*%??/>?%d*%?*%]') .. ' ' - M.virt.last[M.virt.idx.search] = content - M.virt.last[M.virt.idx.cmd] = { { 0, (' '):rep(11) } } - set_virttext('last') - elseif (ext.cmd.prompt or kind == 'wildlist') and ext.cmd.srow == 0 then - -- Route to dialog that stays open so long as the cmdline prompt is active. - replace_last = api.nvim_win_get_config(ext.wins.dialog).hide or kind == 'wildlist' - if kind == 'wildlist' then - api.nvim_buf_set_lines(ext.bufs.dialog, 0, -1, false, {}) - ext.cmd.prompt = true -- Ensure dialog is closed when cmdline is hidden. - end - M.show_msg('dialog', content, replace_last, append, id) - M.set_pos('dialog') - else - -- Set the entered search command in the cmdline (if available). - local tar = kind == 'search_cmd' and 'cmd' or ext.cfg.msg.target - if tar == 'cmd' then - if ext.cmdheight == 0 or (ext.cmd.level > 0 and ext.cmd.srow == 0) then - return -- Do not overwrite an active cmdline unless in block mode. - end - -- Store the time when an important message was emitted in order to not overwrite - -- it with 'last' virt_text in the cmdline so that the user has a chance to read it. - M.cmd.last_emsg = (kind == 'emsg' or kind == 'wmsg') and os.time() or M.cmd.last_emsg - -- Should clear the search count now, mark itself is cleared by invalidate. - M.virt.last[M.virt.idx.search][1] = nil - end - - M.show_msg(tar, content, replace_last, append, id) - -- Don't remember search_cmd message as actual message. - if kind == 'search_cmd' then - M.cmd.ids, M.prev_msg = {}, '' - end - end -end - ----Clear currently visible messages. -function M.msg_clear() - api.nvim_buf_set_lines(ext.bufs.cmd, 0, -1, false, {}) - api.nvim_buf_set_lines(ext.bufs.msg, 0, -1, false, {}) - api.nvim_win_set_config(ext.wins.msg, { hide = true }) - M[ext.cfg.msg.target].ids, M.dupe, M.cmd.msg_row, M.msg.width = {}, 0, -1, 1 - M.prev_msg, M.virt.msg = '', { {}, {} } -end - ---- Place the mode text in the cmdline. ---- ----@param content MsgContent -function M.msg_showmode(content) - M.virt.last[M.virt.idx.mode] = ext.cmd.level > 0 and {} or content - M.virt.last[M.virt.idx.search] = {} - set_virttext('last') -end - ---- Place text from the 'showcmd' buffer in the cmdline. ---- ----@param content MsgContent -function M.msg_showcmd(content) - local str = content[1] and content[1][2]:sub(-10) or '' - M.virt.last[M.virt.idx.cmd][1] = (content[1] or M.virt.last[M.virt.idx.search][1]) - and { 0, str .. (' '):rep(11 - #str) } - set_virttext('last') -end - ---- Place the 'ruler' text in the cmdline window. ---- ----@param content MsgContent -function M.msg_ruler(content) - M.virt.last[M.virt.idx.ruler] = ext.cmd.level > 0 and {} or content - set_virttext('last') -end - ----@alias MsgHistory [string, MsgContent, boolean] ---- Open the message history in the pager. ---- ----@param entries MsgHistory[] ----@param prev_cmd boolean -function M.msg_history_show(entries, prev_cmd) - if #entries == 0 then - return - end - - if cmd_on_key then - -- Dismiss a still expanded cmdline. - api.nvim_feedkeys(vim.keycode('<CR>'), 'n', false) - elseif prev_cmd then - -- Showing output of previous command, clear in case still visible. - M.msg_clear() - end - - api.nvim_buf_set_lines(ext.bufs.pager, 0, -1, false, {}) - for i, entry in ipairs(entries) do - M.show_msg('pager', entry[2], i == 1, entry[3], 0) - end - - M.set_pos('pager') -end - ---- Adjust visibility and dimensions of the message windows after certain events. ---- ----@param type? 'cmd'|'dialog'|'msg'|'pager' Type of to be positioned window (nil for all). -function M.set_pos(type) - local function win_set_pos(win) - local cfg = { hide = false, relative = 'laststatus', col = 10000 } - local texth = type and api.nvim_win_text_height(win, {}) or {} - local top = { vim.opt.fcs:get().msgsep or ' ', 'MsgSeparator' } - cfg.height = type and math.min(texth.all, math.ceil(o.lines * 0.5)) - cfg.border = win ~= ext.wins.msg and { '', top, '', '', '', '', '', '' } or nil - cfg.focusable = type == 'cmd' or nil - cfg.row = (win == ext.wins.msg and 0 or 1) - ext.cmd.wmnumode - cfg.row = cfg.row - ((win == ext.wins.pager and o.laststatus == 3) and 1 or 0) - api.nvim_win_set_config(win, cfg) - - if type == 'cmd' and not cmd_on_key then - -- Temporarily expand the cmdline, until next key press. - local save_spill = M.virt.msg[M.virt.idx.spill][1] - local spill = texth.all > cfg.height and (' [+%d]'):format(texth.all - cfg.height) - M.virt.msg[M.virt.idx.spill][1] = spill and { 0, spill } or nil - set_virttext('msg', 'cmd') - M.virt.msg[M.virt.idx.spill][1] = save_spill - cmd_on_key = vim.on_key(function(_, typed) - if not typed or fn.keytrans(typed) == '<MouseMove>' then - return - end - vim.schedule(function() - local entered = api.nvim_get_current_win() == ext.wins.cmd - cmd_on_key = nil - if api.nvim_win_is_valid(ext.wins.cmd) then - api.nvim_win_close(ext.wins.cmd, true) - end - ext.check_targets() - -- Show or clear the message depending on if the pager was opened. - if entered or not api.nvim_win_get_config(ext.wins.pager).hide then - M.virt.msg[M.virt.idx.spill][1] = nil - api.nvim_buf_set_lines(ext.bufs.cmd, 0, -1, false, {}) - if entered then - api.nvim_command('norm! g<') -- User entered the cmdline window: open the pager. - end - elseif ext.cfg.msg.target == 'cmd' and ext.cmd.level <= 0 then - ext.check_targets() - set_virttext('msg') - end - api.nvim__redraw({ flush = true }) -- NOTE: redundant unless cmdline was opened. - end) - vim.on_key(nil, ext.ns) - end, ext.ns) - elseif type == 'dialog' then - -- Add virtual [+x] text to indicate scrolling is possible. - local function set_top_bot_spill() - local topspill = fn.line('w0', ext.wins.dialog) - 1 - local botspill = api.nvim_buf_line_count(ext.bufs.dialog) - fn.line('w$', ext.wins.dialog) - M.virt.top[1][1] = topspill > 0 and { 0, (' [+%d]'):format(topspill) } or nil - set_virttext('top', 'dialog') - M.virt.bot[1][1] = botspill > 0 and { 0, (' [+%d]'):format(botspill) } or nil - set_virttext('bot', 'dialog') - api.nvim__redraw({ flush = true }) - end - set_top_bot_spill() - - -- Allow paging in the dialog window, consume the key if the topline changes. - M.dialog_on_key = vim.on_key(function(key, typed) - if not typed then - return - end - local page_keys = { - g = 'gg', - G = 'G', - j = 'Lj', - k = 'Hk', - d = [[\<C-D>]], - u = [[\<C-U>]], - f = [[\<C-F>]], - b = [[\<C-B>]], - } - local info = page_keys[key] and fn.getwininfo(ext.wins.dialog)[1] - if info and (key ~= 'f' or info.botline < api.nvim_buf_line_count(ext.bufs.dialog)) then - fn.win_execute(ext.wins.dialog, ('exe "norm! %s"'):format(page_keys[key])) - set_top_bot_spill() - return fn.getwininfo(ext.wins.dialog)[1].topline ~= info.topline and '' or nil - end - end) - elseif type == 'msg' then - -- Ensure last line is visible and first line is at top of window. - local row = (texth.all > cfg.height and texth.end_row or 0) + 1 - api.nvim_win_set_cursor(ext.wins.msg, { row, 0 }) - elseif type == 'pager' then - if fn.getcmdwintype() ~= '' then - -- Cannot leave the cmdwin to enter the pager, so close it. - -- NOTE: regression w.r.t. the message grid, which allowed this. - -- Resolving that would require somehow bypassing textlock for the pager. - api.nvim_command('quit') - end - - -- Cmdwin is actually closed one event iteration later so schedule in case it was open. - vim.schedule(function() - api.nvim_set_current_win(ext.wins.pager) - -- Make pager relative to cmdwin when it is opened, restore when it is closed. - api.nvim_create_autocmd({ 'WinEnter', 'CmdwinEnter', 'CmdwinLeave' }, { - callback = function(ev) - if api.nvim_win_is_valid(ext.wins.pager) then - local config = ev.event == 'CmdwinLeave' and cfg - or ev.event == 'WinEnter' and { hide = true } - or { relative = 'win', win = 0, row = 0, col = 0 } - api.nvim_win_set_config(ext.wins.pager, config) - end - return ev.event == 'WinEnter' - end, - desc = 'Hide or reposition pager window.', - }) - end) - end - end - - for t, win in pairs(ext.wins) do - local cfg = (t == type or (type == nil and t ~= 'cmd')) - and api.nvim_win_is_valid(win) - and api.nvim_win_get_config(win) - if cfg and (type or not cfg.hide) then - win_set_pos(win) - end - end -end - -return M diff --git a/runtime/lua/vim/_extui/shared.lua b/runtime/lua/vim/_extui/shared.lua @@ -1,111 +0,0 @@ -local api = vim.api -local M = { - msg = nil, ---@type vim._extui.messages - cmd = nil, ---@type vim._extui.cmdline - ns = api.nvim_create_namespace('nvim._ext_ui'), - augroup = api.nvim_create_augroup('nvim._ext_ui', {}), - cmdheight = vim.o.cmdheight, -- 'cmdheight' option value set by user. - redrawing = false, -- True when redrawing to display UI event. - wins = { cmd = -1, dialog = -1, msg = -1, pager = -1 }, - bufs = { cmd = -1, dialog = -1, msg = -1, pager = -1 }, - cfg = { - enable = true, - msg = { -- Options related to the message module. - ---@type 'cmd'|'msg' Where to place regular messages, either in the - ---cmdline or in a separate ephemeral message window. - target = 'cmd', - timeout = 4000, -- Time a message is visible in the message window. - }, - }, -} ---- @type vim.api.keyset.win_config -local wincfg = { -- Default cfg for nvim_open_win(). - relative = 'laststatus', - style = 'minimal', - col = 0, - row = 1, - width = 10000, - height = 1, - noautocmd = true, -} - -local tab = 0 ----Ensure target buffers and windows are still valid. -function M.check_targets() - local curtab = api.nvim_get_current_tabpage() - for i, type in ipairs({ 'cmd', 'dialog', 'msg', 'pager' }) do - local setopt = not api.nvim_buf_is_valid(M.bufs[type]) - if setopt then - M.bufs[type] = api.nvim_create_buf(false, false) - end - - if - tab ~= curtab - or not api.nvim_win_is_valid(M.wins[type]) - or not api.nvim_win_get_config(M.wins[type]).zindex -- no longer floating - then - local cfg = vim.tbl_deep_extend('force', wincfg, { - focusable = type == 'pager', - mouse = type ~= 'cmd' and true or nil, - anchor = type ~= 'cmd' and 'SE' or nil, - hide = type ~= 'cmd' or M.cmdheight == 0 or nil, - border = type ~= 'msg' and 'none' or nil, - -- kZIndexMessages < cmd zindex < kZIndexCmdlinePopupMenu (grid_defs.h), pager below others. - zindex = 201 - i, - _cmdline_offset = type == 'cmd' and 0 or nil, - }) - if tab ~= curtab and api.nvim_win_is_valid(M.wins[type]) then - cfg = api.nvim_win_get_config(M.wins[type]) - api.nvim_win_close(M.wins[type], true) - end - M.wins[type] = api.nvim_open_win(M.bufs[type], false, cfg) - setopt = true - elseif api.nvim_win_get_buf(M.wins[type]) ~= M.bufs[type] then - api.nvim_win_set_buf(M.wins[type], M.bufs[type]) - setopt = true - end - - if setopt then - -- Set options without firing OptionSet and BufFilePost. - vim._with({ win = M.wins[type], noautocmd = true }, function() - local ignore = 'all,-FileType' .. (type == 'pager' and ',-TextYankPost' or '') - api.nvim_set_option_value('eventignorewin', ignore, { scope = 'local' }) - api.nvim_set_option_value('wrap', true, { scope = 'local' }) - api.nvim_set_option_value('linebreak', false, { scope = 'local' }) - api.nvim_set_option_value('smoothscroll', true, { scope = 'local' }) - api.nvim_set_option_value('breakindent', false, { scope = 'local' }) - api.nvim_set_option_value('foldenable', false, { scope = 'local' }) - api.nvim_set_option_value('showbreak', '', { scope = 'local' }) - api.nvim_set_option_value('spell', false, { scope = 'local' }) - api.nvim_set_option_value('swapfile', false, { scope = 'local' }) - api.nvim_set_option_value('modifiable', true, { scope = 'local' }) - api.nvim_set_option_value('bufhidden', 'hide', { scope = 'local' }) - api.nvim_set_option_value('buftype', 'nofile', { scope = 'local' }) - -- Use MsgArea except in the msg window. Hide Search highlighting except in the pager. - local search_hide = 'Search:,CurSearch:,IncSearch:' - local hl = 'Normal:MsgArea,' .. search_hide - if type == 'pager' then - hl = 'Normal:MsgArea' - elseif type == 'msg' then - hl = search_hide - end - api.nvim_set_option_value('winhighlight', hl, { scope = 'local' }) - end) - api.nvim_buf_set_name(M.bufs[type], ('[%s]'):format(type:sub(1, 1):upper() .. type:sub(2))) - -- Fire FileType with window context to let the user reconfigure local options. - vim._with({ win = M.wins[type] }, function() - api.nvim_set_option_value('filetype', type, { scope = 'local' }) - end) - - if type == 'pager' then - -- Close pager with `q`, same as `checkhealth` - api.nvim_buf_set_keymap(M.bufs.pager, 'n', 'q', '<Cmd>wincmd c<CR>', {}) - elseif type == M.cfg.msg.target then - M.msg.prev_msg = '' -- Will no longer be visible. - end - end - end - tab = curtab -end - -return M diff --git a/src/gen/gen_vimdoc.lua b/src/gen/gen_vimdoc.lua @@ -184,14 +184,14 @@ local config = { 'version.lua', -- Sections at the end, in a specific order: - '_extui.lua', + 'ui2.lua', }, files = { 'runtime/lua/vim/_core/editor.lua', 'runtime/lua/vim/_core/options.lua', 'runtime/lua/vim/_core/shared.lua', 'runtime/lua/vim/_core/system.lua', - 'runtime/lua/vim/_extui.lua', + 'runtime/lua/vim/_core/ui2.lua', 'runtime/lua/vim/_inspector.lua', 'runtime/lua/vim/_meta/base64.lua', 'runtime/lua/vim/_meta/builtin.lua', @@ -235,6 +235,7 @@ local config = { end, section_name = { ['_inspector.lua'] = 'inspector', + ['ui2.lua'] = '_core.ui2', }, section_fmt = function(name) name = name:lower() diff --git a/test/functional/core/main_spec.lua b/test/functional/core/main_spec.lua @@ -227,6 +227,7 @@ describe('vim._core', function() 'vim._core.shared', 'vim._core.stringbuffer', 'vim._core.system', + 'vim._core.ui2', 'vim._core.util', 'vim._init_packages', 'vim.filetype', diff --git a/test/functional/ui/cmdline2_spec.lua b/test/functional/ui/cmdline2_spec.lua @@ -16,7 +16,7 @@ describe('cmdline2', function() [101] = { background = Screen.colors.Yellow, foreground = Screen.colors.Grey0 }, }) exec_lua(function() - require('vim._extui').enable({}) + require('vim._core.ui2').enable({}) end) end) @@ -245,7 +245,7 @@ describe('cmdline2', function() clear({ args = { '--clean', - '+lua require("vim._extui").enable({})', + '+lua require("vim._core.ui2").enable({})', "+call feedkeys(':')", }, }) diff --git a/test/functional/ui/messages2_spec.lua b/test/functional/ui/messages2_spec.lua @@ -15,11 +15,11 @@ describe('messages2', function() [100] = { foreground = Screen.colors.Magenta1, bold = true }, }) exec_lua(function() - require('vim._extui').enable({}) + require('vim._core.ui2').enable({}) end) end) after_each(function() - -- Since vim._extui lasts until Nvim exits, there may be unfinished timers. + -- Since ui2 module lasts until Nvim exits, there may be unfinished timers. -- Close unfinished timers to avoid 2s delay on exit with ASAN or TSAN. exec_lua(function() vim.uv.walk(function(handle) diff --git a/test/functional/vimscript/screenchar_spec.lua b/test/functional/vimscript/screenchar_spec.lua @@ -105,7 +105,7 @@ describe('screenchar() and family respect floating windows', function() end) it('from ui2', function() - n.exec_lua('require("vim._extui").enable({ enable = true })') + n.exec_lua('require("vim._core.ui2").enable({ enable = true })') command('echo "foo"') assert_screen_funcs()