mirror of
https://github.com/neovim/neovim.git
synced 2025-09-05 19:08:15 +00:00
feat(ui): ext_cmdline/messages for the TUI #27855
Problem: We have an unmaintained Vimscript parser and cmdline highlighting mechanism, with which it is hard to leverage the treesitter highlighter. Long messages result in a hit-enter-prompt. Solution: Implement a vim.ui_attach() UI, that replaces the message grid (orphaning some 3000+ LOC core C code). Introduce an experimental vim._extui module, because removing the message grid at the same time is too risky. The new UI leverages the bundled treesitter highlighter and parser for Vimscript, as well as the matchparen plugin, to highlight the cmdline. Messages are truncated in the cmdline area, or placed in a floating message box in the bottom right corner. Special ("list_cmd") messages and the message history are shown in a, "more prompt" (now a fully interactive regular window). Various default UI elements ('showcmd', 'ruler') are also placed in the cmdline area, as virtual text. `require('vim._extui').enable({})` enables the experimental UI. `{ msg.pos = 'box' }` or `:set cmdheight=0` enables the message box variant. Followup: - Come to a consensus for how best to represent messages (by default). - Start removing message grid when this is deemed a successful replacement. When that is finished, make this new UI the default and update a lot of tests.
This commit is contained in:
@@ -2663,6 +2663,28 @@ vim.ui.select({items}, {opts}, {on_choice}) *vim.ui.select()*
|
||||
within `items`. `nil` if the user aborted the dialog.
|
||||
|
||||
|
||||
==============================================================================
|
||||
Lua module: vim._extui *vim._extui*
|
||||
|
||||
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 'box'|'cmd' Type of window used to place messages, either in the
|
||||
---cmdline or in a separate message box window with ephemeral messages.
|
||||
pos = 'cmd',
|
||||
box = { -- Options related to the message box window.
|
||||
timeout = 4000, -- Time a message is visible.
|
||||
},
|
||||
},
|
||||
})
|
||||
<
|
||||
|
||||
|
||||
|
||||
==============================================================================
|
||||
Lua module: vim.filetype *vim.filetype*
|
||||
|
||||
|
@@ -193,6 +193,8 @@ UI
|
||||
|
||||
• |: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
|
||||
replace the message grid in the TUI.
|
||||
|
||||
VIMSCRIPT
|
||||
|
||||
|
129
runtime/lua/vim/_extui.lua
Normal file
129
runtime/lua/vim/_extui.lua
Normal file
@@ -0,0 +1,129 @@
|
||||
--- @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 'box'|'cmd' Type of window used to place messages, either in the
|
||||
--- ---cmdline or in a separate message box window with ephemeral messages.
|
||||
--- pos = 'cmd',
|
||||
--- box = { -- Options related to the message box window.
|
||||
--- timeout = 4000, -- Time a message is visible.
|
||||
--- },
|
||||
--- },
|
||||
---})
|
||||
---```
|
||||
|
||||
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(event, ...)
|
||||
local handler = ext.msg[event] or ext.cmd[event]
|
||||
if not handler then
|
||||
return
|
||||
end
|
||||
ext.tab_check_wins()
|
||||
handler(...)
|
||||
api.nvim__redraw({
|
||||
flush = true,
|
||||
cursor = handler == ext.cmd[event] and true or nil,
|
||||
win = handler == ext.cmd[event] and ext.wins[ext.tab].cmd or nil,
|
||||
})
|
||||
end
|
||||
local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
|
||||
|
||||
---@nodoc
|
||||
function M.enable(opts)
|
||||
vim.validate('opts', opts, 'table', true)
|
||||
ext.cfg = vim.tbl_deep_extend('keep', opts, ext.cfg)
|
||||
|
||||
if ext.cfg.enable == false then
|
||||
-- Detach and cleanup windows, buffers and autocommands.
|
||||
for _, tab in ipairs(api.nvim_list_tabpages()) do
|
||||
for _, win in pairs(ext.wins[tab] or {}) do
|
||||
api.nvim_win_close(win, true)
|
||||
end
|
||||
end
|
||||
for _, buf in pairs(ext.bufs) do
|
||||
api.nvim_buf_delete(buf, {})
|
||||
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 vim.in_fast_event() then
|
||||
scheduled_ui_callback(event, ...)
|
||||
else
|
||||
ui_callback(event, ...)
|
||||
end
|
||||
end)
|
||||
|
||||
api.nvim_set_hl(ext.ns, 'Normal', { link = 'MsgArea' })
|
||||
api.nvim_set_hl(ext.ns, 'Search', { link = 'MsgArea' })
|
||||
api.nvim_set_hl(ext.ns, 'CurSearch', { link = 'MsgArea' })
|
||||
api.nvim_set_hl(ext.ns, 'IncSearch', { link = 'MsgArea' })
|
||||
|
||||
-- The visibility and appearance of the cmdline and message box window is
|
||||
-- dependent on some option values. Reconfigure windows when option value
|
||||
-- has changed and after VimEnter when the user configured value is known.
|
||||
local function check_opt(name, value)
|
||||
if name == 'cmdheight' then
|
||||
-- 'cmdheight' set; (un)hide cmdline window and set its height.
|
||||
ext.cmdheight = value
|
||||
ext.cfg.msg.pos = ext.cmdheight == 0 and 'box' or ext.cfg.msg.pos
|
||||
local cfg = { height = math.max(ext.cmdheight, 1), hide = ext.cmdheight == 0 }
|
||||
api.nvim_win_set_config(ext.wins[ext.tab].cmd, cfg)
|
||||
elseif name == 'termguicolors' then
|
||||
-- 'termguicolors' toggled; add or remove border and set 'winblend' for box windows.
|
||||
for _, tab in ipairs(api.nvim_list_tabpages()) do
|
||||
api.nvim_win_set_config(ext.wins[tab].box, { border = value and 'none' or 'single' })
|
||||
vim.wo[ext.wins[tab].box].winblend = value and 30 or 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
api.nvim_create_autocmd('OptionSet', {
|
||||
group = ext.augroup,
|
||||
pattern = { 'cmdheight', 'termguicolors' },
|
||||
callback = function(ev)
|
||||
ext.tab_check_wins()
|
||||
check_opt(ev.match, vim.v.option_new)
|
||||
ext.msg.set_pos()
|
||||
end,
|
||||
desc = 'Set cmdline and message window dimensions for changed option values.',
|
||||
})
|
||||
|
||||
api.nvim_create_autocmd({ 'VimEnter', 'VimResized' }, {
|
||||
group = ext.augroup,
|
||||
callback = function(ev)
|
||||
ext.tab_check_wins()
|
||||
if ev.event == 'VimEnter' then
|
||||
check_opt('cmdheight', vim.o.cmdheight)
|
||||
check_opt('termguicolors', vim.o.termguicolors)
|
||||
end
|
||||
ext.msg.set_pos()
|
||||
end,
|
||||
desc = 'Set cmdline and message window dimensions after startup and shell resize.',
|
||||
})
|
||||
|
||||
api.nvim_create_autocmd('WinEnter', {
|
||||
callback = function()
|
||||
local win = api.nvim_get_current_win()
|
||||
if vim.tbl_contains(ext.wins[ext.tab] or {}, 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
|
158
runtime/lua/vim/_extui/cmdline.lua
Normal file
158
runtime/lua/vim/_extui/cmdline.lua
Normal file
@@ -0,0 +1,158 @@
|
||||
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 'prompt' window.
|
||||
row = 0, -- Current row in the cmdline buffer, > 0 for block events.
|
||||
}
|
||||
|
||||
--- 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
|
||||
vim.cmd('noautocmd set cmdheight=' .. height)
|
||||
ext.msg.set_pos()
|
||||
end
|
||||
end
|
||||
|
||||
local promptlen = 0 -- Current length of the prompt, stored for use in "cmdline_pos"
|
||||
--- 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)
|
||||
promptlen = #prompt
|
||||
for _, chunk in ipairs(content) do
|
||||
prompt = prompt .. chunk[2]
|
||||
end
|
||||
api.nvim_buf_set_lines(ext.bufs.cmd, M.row, -1, false, { prompt .. ' ' })
|
||||
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, _, hl_id)
|
||||
M.indent, M.prompt = indent, #prompt > 0
|
||||
-- Only enable TS highlighter for Ex commands (not search or filter commands).
|
||||
M.highlighter.active[ext.bufs.cmd] = firstc == ':' and M.highlighter or nil
|
||||
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[ext.tab].cmd, {}).all)
|
||||
win_config(ext.wins[ext.tab].cmd, false, height)
|
||||
M.cmdline_pos(pos)
|
||||
|
||||
-- Clear message cmdline state; should not be shown during, and reset after cmdline.
|
||||
if ext.cfg.msg.pos == 'cmd' and ext.msg.cmd.msg_row ~= -1 then
|
||||
ext.msg.prev_msg, ext.msg.dupe, ext.msg.cmd.msg_row = '', 0, -1
|
||||
api.nvim_buf_clear_namespace(ext.bufs.cmd, ext.ns, 0, -1)
|
||||
ext.msg.virt.msg = { {}, {} }
|
||||
end
|
||||
ext.msg.virt.last = { {}, {}, {}, {} }
|
||||
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[ext.tab].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)
|
||||
if curpos[1] ~= M.row + 1 or curpos[2] ~= promptlen + pos then
|
||||
curpos[1], curpos[2] = M.row + 1, promptlen + pos
|
||||
-- Add matchparen highlighting to non-prompt part of cmdline.
|
||||
if pos > 0 and fn.exists('#matchparen') then
|
||||
api.nvim_win_set_cursor(ext.wins[ext.tab].cmd, { curpos[1], curpos[2] - 1 })
|
||||
vim.wo[ext.wins[ext.tab].cmd].eventignorewin = ''
|
||||
fn.win_execute(ext.wins[ext.tab].cmd, 'doautocmd CursorMoved')
|
||||
vim.wo[ext.wins[ext.tab].cmd].eventignorewin = 'all'
|
||||
end
|
||||
api.nvim_win_set_cursor(ext.wins[ext.tab].cmd, curpos)
|
||||
end
|
||||
end
|
||||
|
||||
--- Leaving the cmdline, restore 'cmdheight' and 'ruler'.
|
||||
---
|
||||
--@param level integer
|
||||
---@param abort boolean
|
||||
function M.cmdline_hide(_, abort)
|
||||
if M.row > 0 then
|
||||
return -- No need to hide when still in cmdline_block.
|
||||
end
|
||||
|
||||
fn.clearmatches(ext.wins[ext.tab].cmd) -- Clear matchparen highlights.
|
||||
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
|
||||
|
||||
-- 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 M.prompt then
|
||||
vim.schedule(function()
|
||||
if not M.prompt then
|
||||
api.nvim_buf_set_lines(ext.bufs.cmd, 0, -1, false, {})
|
||||
api.nvim_win_set_config(ext.wins[ext.tab].prompt, { hide = true })
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
M.prompt, curpos[1], curpos[2] = false, 0, 0
|
||||
win_config(ext.wins[ext.tab].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.row = M.row + 1
|
||||
end
|
||||
end
|
||||
|
||||
--- Append line to a multiline cmdline.
|
||||
---
|
||||
---@param line CmdContent
|
||||
function M.cmdline_block_append(line)
|
||||
set_text(line, ':')
|
||||
M.row = M.row + 1
|
||||
end
|
||||
|
||||
--- Clear cmdline buffer and leave the cmdline.
|
||||
function M.cmdline_block_hide()
|
||||
M.cmdline_hide(nil, true)
|
||||
M.row = 0
|
||||
end
|
||||
|
||||
return M
|
410
runtime/lua/vim/_extui/messages.lua
Normal file
410
runtime/lua/vim/_extui/messages.lua
Normal file
@@ -0,0 +1,410 @@
|
||||
local api, fn, o = vim.api, vim.fn, vim.o
|
||||
local ext = require('vim._extui.shared')
|
||||
|
||||
---@class vim._extui.messages
|
||||
local M = {
|
||||
-- Message box window. Used for regular messages with 'cmdheight' == 0 or,
|
||||
-- cfg.msg.pos == 'box'. Also used for verbose messages regardless of
|
||||
-- cfg.msg.pos. 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.
|
||||
box = {
|
||||
count = 0, -- Number of messages currently in the message window.
|
||||
width = 1, -- Current width of the message window.
|
||||
timer = nil, ---@type uv.uv_timer_t Timer that removes the most recent message.
|
||||
},
|
||||
-- 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 = {
|
||||
count = 0, -- Number of messages currently in the message window.
|
||||
lines = 0, -- Number of lines in cmdline buffer (including wrapped lines).
|
||||
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_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 message window.
|
||||
idx = { mode = 1, search = 2, cmd = 3, ruler = 4, spill = 1, dupe = 2 },
|
||||
ids = {}, ---@type { ['last'|'msg']: integer? } Table of mark IDs.
|
||||
delayed = false, -- Whether placement of 'last' virt_text is delayed.
|
||||
},
|
||||
}
|
||||
|
||||
--- Start a timer whose callback will remove the message from the message window.
|
||||
---
|
||||
---@param buf integer Buffer the message was written to.
|
||||
---@param len integer Number of rows that should be removed.
|
||||
function M.box:start_timer(buf, len)
|
||||
self.timer = vim.defer_fn(function()
|
||||
if self.count == 0 or not api.nvim_buf_is_valid(buf) then
|
||||
return -- Messages moved to split or buffer was closed.
|
||||
end
|
||||
api.nvim_buf_set_lines(buf, 0, len, false, {})
|
||||
self.count = self.count - 1
|
||||
-- Resize or hide message box for removed message.
|
||||
if self.count > 0 then
|
||||
M.set_pos('box')
|
||||
else
|
||||
self.width = 1
|
||||
M.prev_msg = ext.cfg.msg.pos == 'box' and '' or M.prev_msg
|
||||
api.nvim_buf_clear_namespace(ext.bufs.box, -1, 0, -1)
|
||||
if api.nvim_win_is_valid(ext.wins[ext.tab].box) then
|
||||
api.nvim_win_set_config(ext.wins[ext.tab].box, { hide = true })
|
||||
end
|
||||
end
|
||||
end, ext.cfg.msg.box.timeout)
|
||||
end
|
||||
|
||||
--- Place or delete a virtual text mark in the cmdline or message window.
|
||||
---
|
||||
---@param type 'last'|'msg'
|
||||
local function set_virttext(type)
|
||||
if type == 'last' and ext.cmdheight == 0 or M.virt.delayed then
|
||||
return
|
||||
end
|
||||
|
||||
-- Concatenate the components of M.virt[type] and calculate the concatenated width.
|
||||
local width, chunks = 0, {} ---@type integer, [string, integer|string][]
|
||||
local contents = type == 'last' and M.virt.last or M.virt.msg
|
||||
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
|
||||
|
||||
if M.virt.ids[type] and #chunks == 0 then
|
||||
api.nvim_buf_del_extmark(ext.bufs.cmd, ext.ns, M.virt.ids[type])
|
||||
M.virt.ids[type] = nil
|
||||
M.cmd.last_col = type == 'last' and o.columns or M.cmd.last_col
|
||||
elseif #chunks > 0 then
|
||||
local tar = type == 'msg' and ext.cfg.msg.pos or 'cmd'
|
||||
local win = ext.wins[ext.tab][tar]
|
||||
local max = api.nvim_win_get_height(win)
|
||||
local erow = tar == 'cmd' and M.cmd.msg_row or nil
|
||||
local srow = tar == 'box' and fn.line('w0', ext.wins[ext.tab].box) - 1 or nil
|
||||
local h = api.nvim_win_text_height(win, { start_row = srow, end_row = erow, max_height = max })
|
||||
local row = h.end_row ---@type integer
|
||||
local col = fn.virtcol2col(win, row + 1, h.end_vcol)
|
||||
local scol = fn.screenpos(win, row + 1, col).col ---@type integer
|
||||
|
||||
if type == 'msg' 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 ~= 'box' and 0
|
||||
or api.nvim_win_get_position(win)[2] + (api.nvim_win_get_config(win).border and 1 or 0)
|
||||
|
||||
M.box.width = math.min(o.columns, scol - offset + width)
|
||||
if tar == 'box' and api.nvim_win_get_width(win) < M.box.width then
|
||||
api.nvim_win_set_width(win, M.box.width)
|
||||
end
|
||||
|
||||
local mwidth = tar == 'box' and M.box.width or M.cmd.last_col
|
||||
if scol - offset + width > mwidth then
|
||||
col = fn.virtcol2col(win, row + 1, h.end_vcol - (scol - offset + width - mwidth))
|
||||
end
|
||||
|
||||
-- Give virt_text the same highlight as the message tail.
|
||||
local hl = api.nvim_buf_get_extmarks(ext.bufs[tar], ext.ns, { row, col }, { row, col }, {
|
||||
details = true,
|
||||
overlap = true,
|
||||
type = 'highlight',
|
||||
})
|
||||
chunks[1][2] = hl[1] and hl[1][4].hl_group
|
||||
else
|
||||
local mode = #M.virt.last[M.virt.idx.mode]
|
||||
local pad = o.columns - width ---@type integer
|
||||
local newlines = math.max(0, ext.cmdheight - h.all)
|
||||
row = row + newlines
|
||||
M.cmd.last_col = newlines > 0 and o.columns or mode > 0 and 0 or o.columns - 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 = h.end_vcol - (scol - M.cmd.last_col) ---@type integer
|
||||
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
|
||||
|
||||
---@param tar 'box'|'cmd'|'more'|'prompt'
|
||||
---@param content MsgContent
|
||||
---@param replace_last boolean
|
||||
---@param more boolean? If true, route messages that exceed the target window to more window.
|
||||
function M.show_msg(tar, content, replace_last, more)
|
||||
local msg, restart, dupe = '', false, 0
|
||||
if M[tar] then -- tar == 'box'|'cmd'
|
||||
if tar == ext.cfg.msg.pos then
|
||||
-- Save the concatenated message to identify repeated messages.
|
||||
for _, chunk in ipairs(content) do
|
||||
msg = msg .. chunk[2]
|
||||
end
|
||||
|
||||
-- Check if messages that should be sent to more prompt exceeds maximum newlines.
|
||||
local max = (tar == 'cmd' and ext.cmdheight or math.ceil(o.lines * 0.5))
|
||||
if more and select(2, msg:gsub('\n', '')) > max then
|
||||
M.msg_history_show({ { 'spill', content } })
|
||||
return
|
||||
end
|
||||
|
||||
M.dupe = (msg == M.prev_msg and M.dupe + 1 or 0)
|
||||
dupe = M.dupe
|
||||
M.prev_msg = msg
|
||||
end
|
||||
|
||||
restart = M[tar].count > 0 and (replace_last or dupe > 0)
|
||||
-- Reset indicators the next event loop iteration.
|
||||
if M.cmd.count == 0 and tar == 'cmd' then
|
||||
vim.schedule(function()
|
||||
M.cmd.lines, M.cmd.count = 0, 0
|
||||
end)
|
||||
end
|
||||
M[tar].count = M[tar].count + ((restart or msg == '\n') and 0 or 1)
|
||||
end
|
||||
|
||||
-- Filter out empty newline messages. TODO: don't emit them.
|
||||
if msg == '\n' then
|
||||
return
|
||||
end
|
||||
|
||||
---@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 = M[tar] and M[tar].count <= 1 and (tar == 'cmd' and ext.cmd.row or 0)
|
||||
or api.nvim_buf_line_count(ext.bufs[tar]) - ((replace_last or dupe > 0) and 1 or 0)
|
||||
local start_row, col = row, 0
|
||||
local lines, marks = {}, {} ---@type string[], [integer, integer, vim.api.keyset.set_extmark][]
|
||||
|
||||
-- Accumulate to be inserted and highlighted message chunks for a non-repeated message.
|
||||
for i, chunk in ipairs(dupe > 0 and tar == ext.cfg.msg.pos and {} or content) do
|
||||
local srow, scol = row, col
|
||||
-- Split at newline and concatenate first and last message chunks.
|
||||
for str in (chunk[2] .. '\0'):gmatch('.-[\n%z]') do
|
||||
local idx = i > 1 and row == srow and 0 or 1
|
||||
lines[#lines + idx] = idx > 0 and str:sub(1, -2) or lines[#lines] .. str:sub(1, -2)
|
||||
col = #lines[#lines]
|
||||
row = row + (str:sub(-1) == '\0' and 0 or 1)
|
||||
if tar == 'box' then
|
||||
M.box.width = math.max(M.box.width, api.nvim_strwidth(lines[#lines]))
|
||||
end
|
||||
end
|
||||
if chunk[3] > 0 then
|
||||
marks[#marks + 1] = { srow, scol, { end_col = col, end_row = row, hl_group = chunk[3] } }
|
||||
end
|
||||
end
|
||||
|
||||
if tar ~= ext.cfg.msg.pos or dupe == 0 then
|
||||
-- Add highlighted message to buffer.
|
||||
api.nvim_buf_set_lines(ext.bufs[tar], start_row, -1, false, lines)
|
||||
for _, mark in ipairs(marks) do
|
||||
api.nvim_buf_set_extmark(ext.bufs[tar], ext.ns, mark[1], mark[2], mark[3])
|
||||
end
|
||||
M.virt.msg[M.virt.idx.dupe][1] = dupe ~= 0 and M.virt.msg[M.virt.idx.dupe][1] or nil
|
||||
else
|
||||
-- Place (x) indicator for repeated messages. Mainly to mitigate unnecessary
|
||||
-- resizing of the message box window, but also placed in the cmdline.
|
||||
M.virt.msg[M.virt.idx.dupe][1] = { 0, ('(%d)'):format(dupe) }
|
||||
end
|
||||
|
||||
if tar == 'box' then
|
||||
api.nvim_win_set_width(ext.wins[ext.tab].box, M.box.width)
|
||||
M.set_pos('box')
|
||||
if restart then
|
||||
M.box.timer:stop()
|
||||
M.box.timer:set_repeat(4000)
|
||||
M.box.timer:again()
|
||||
else
|
||||
M.box:start_timer(ext.bufs.box, row - start_row + 1)
|
||||
end
|
||||
elseif tar == 'cmd' and dupe == 0 then
|
||||
fn.clearmatches(ext.wins[ext.tab].cmd) -- Clear matchparen highlights.
|
||||
if ext.cmd.row > 0 then
|
||||
-- In block mode the cmdheight is already dynamic, so just print the full message
|
||||
-- regardless of height. Spoof cmdline_show to put cmdline below message.
|
||||
ext.cmd.row = ext.cmd.row + 1 + row - start_row
|
||||
ext.cmd.cmdline_show({}, 0, ':', '', ext.cmd.indent, 0, 0)
|
||||
api.nvim__redraw({ flush = true, cursor = true, win = ext.wins[ext.tab].cmd })
|
||||
else
|
||||
-- Place [+x] indicator for lines that spill over 'cmdheight'.
|
||||
local h = api.nvim_win_text_height(ext.wins[ext.tab].cmd, {})
|
||||
M.cmd.lines, M.cmd.msg_row = h.all, h.end_row
|
||||
local spill = M.cmd.lines > ext.cmdheight and ('[+%d]'):format(M.cmd.lines - ext.cmdheight)
|
||||
M.virt.msg[M.virt.idx.spill][1] = spill and { 0, spill } or nil
|
||||
api.nvim_win_set_cursor(ext.wins[ext.tab][tar], { 1, 0 })
|
||||
ext.cmd.highlighter.active[ext.bufs.cmd] = nil
|
||||
end
|
||||
end
|
||||
|
||||
if M[tar] then
|
||||
set_virttext('msg')
|
||||
end
|
||||
end
|
||||
|
||||
local replace_bufwrite = false
|
||||
--- Route the message to the appropriate sink.
|
||||
---
|
||||
---@param kind string
|
||||
---@alias MsgChunk [integer, string, integer]
|
||||
---@alias MsgContent MsgChunk[]
|
||||
---@param content MsgContent
|
||||
function M.msg_show(kind, content)
|
||||
if kind == 'search_cmd' then
|
||||
-- Set the entered search command in the cmdline.
|
||||
api.nvim_buf_set_lines(ext.bufs.cmd, 0, -1, false, { content[1][2] })
|
||||
M.virt.msg = ext.cfg.msg.pos == 'cmd' and { {}, {} } or M.virt.msg
|
||||
M.prev_msg = ext.cfg.msg.pos == 'cmd' and '' or M.prev_msg
|
||||
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[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 kind == 'return_prompt' then
|
||||
-- Bypass hit enter prompt.
|
||||
vim.api.nvim_feedkeys(vim.keycode('<CR>'), 'n', false)
|
||||
elseif kind == 'verbose' then
|
||||
-- Verbose messages are sent too often to be meaningful in the cmdline:
|
||||
-- always route to box regardless of cfg.msg.pos.
|
||||
M.show_msg('box', content, false)
|
||||
elseif ext.cmd.prompt then
|
||||
-- Route to prompt that stays open so long as the cmdline prompt is active.
|
||||
api.nvim_buf_set_lines(ext.bufs.prompt, 0, -1, false, { '' })
|
||||
M.show_msg('prompt', content, true)
|
||||
M.set_pos('prompt')
|
||||
else
|
||||
local tar = ext.cfg.msg.pos
|
||||
M.virt.last[M.virt.idx.search][1] = tar ~= 'cmd' and M.virt.last[M.virt.idx.search][1] or nil
|
||||
M.show_msg(tar, content, replace_bufwrite, kind == 'list_cmd')
|
||||
-- Replace message for every second bufwrite message.
|
||||
replace_bufwrite = not replace_bufwrite and kind == 'bufwrite'
|
||||
-- Store the time when an error message was emitted in order to not overwrite
|
||||
-- it with 'last' virt_text in the cmdline to give the user a chance to read it.
|
||||
if tar == 'cmd' and kind == 'emsg' then
|
||||
M.cmd.last_emsg = os.time()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function M.msg_clear() end
|
||||
|
||||
--- Place the mode text in the cmdline.
|
||||
---
|
||||
---@param content MsgContent
|
||||
function M.msg_showmode(content)
|
||||
M.virt.last[M.virt.idx.mode] = 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] = content
|
||||
set_virttext('last')
|
||||
end
|
||||
|
||||
---@alias MsgHistory [string, MsgContent]
|
||||
--- Zoom in on the message window with the message history.
|
||||
---
|
||||
---@param entries MsgHistory[]
|
||||
function M.msg_history_show(entries)
|
||||
if #entries == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
api.nvim_buf_set_lines(ext.bufs.more, 0, -1, false, {})
|
||||
for i, entry in ipairs(entries) do
|
||||
M.show_msg('more', entry[2], i == 1)
|
||||
end
|
||||
|
||||
M.set_pos('more')
|
||||
end
|
||||
|
||||
function M.msg_history_clear() end
|
||||
|
||||
--- Adjust dimensions of the message windows after certain events.
|
||||
---
|
||||
---@param type? 'box'|'cmd'|'more'|'prompt' Type of to be positioned window (nil for all).
|
||||
function M.set_pos(type)
|
||||
local function win_set_pos(win)
|
||||
local texth = type and api.nvim_win_text_height(win, {}) or 0
|
||||
local height = type and math.min(texth.all, math.ceil(o.lines * 0.5))
|
||||
api.nvim_win_set_config(win, {
|
||||
hide = false,
|
||||
relative = 'laststatus',
|
||||
height = height,
|
||||
row = win == ext.wins[ext.tab].box and 0 or 1,
|
||||
col = 10000,
|
||||
zindex = type == 'more' and 299 or nil,
|
||||
})
|
||||
if type == 'box' then
|
||||
-- Ensure last line is visible and first line is at top of window.
|
||||
local row = (texth.all > height and texth.end_row or 0) + 1
|
||||
api.nvim_win_set_cursor(ext.wins[ext.tab].box, { row, 0 })
|
||||
elseif type == 'more' and api.nvim_get_current_win() ~= win then
|
||||
api.nvim_set_current_win(win)
|
||||
end
|
||||
end
|
||||
|
||||
for t, win in pairs(ext.wins[ext.tab] or {}) 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
|
87
runtime/lua/vim/_extui/shared.lua
Normal file
87
runtime/lua/vim/_extui/shared.lua
Normal file
@@ -0,0 +1,87 @@
|
||||
local api, o = vim.api, vim.o
|
||||
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 = -1, -- 'cmdheight' option value set by user.
|
||||
-- Map of tabpage ID to box/cmd/more/prompt window IDs.
|
||||
wins = {}, ---@type { ['box'|'cmd'|'more'|'prompt']: integer }[]
|
||||
bufs = { box = -1, cmd = -1, more = -1, prompt = -1 },
|
||||
tab = 0, -- Current tabpage.
|
||||
cfg = {
|
||||
enable = true,
|
||||
msg = { -- Options related to the message module.
|
||||
---@type 'box'|'cmd' Type of window used to place messages, either in the
|
||||
---cmdline or in a separate ephemeral message box window.
|
||||
pos = 'cmd',
|
||||
box = { -- Options related to the message box window.
|
||||
timeout = 4000, -- Time a message is visible.
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
local wincfg = { -- Default cfg for nvim_open_win().
|
||||
relative = 'laststatus',
|
||||
style = 'minimal',
|
||||
col = 0,
|
||||
row = 1,
|
||||
width = 10000,
|
||||
height = 1,
|
||||
noautocmd = true,
|
||||
zindex = 300,
|
||||
}
|
||||
|
||||
--- Ensure the various buffers and windows have not been deleted.
|
||||
function M.tab_check_wins()
|
||||
M.tab = api.nvim_get_current_tabpage()
|
||||
if not M.wins[M.tab] then
|
||||
M.wins[M.tab] = { box = -1, cmd = -1, more = -1, prompt = -1 }
|
||||
end
|
||||
|
||||
for _, type in ipairs({ 'box', 'cmd', 'more', 'prompt' }) do
|
||||
if not api.nvim_buf_is_valid(M.bufs[type]) then
|
||||
M.bufs[type] = api.nvim_create_buf(false, true)
|
||||
if type == 'cmd' then
|
||||
-- Attach highlighter to the cmdline buffer.
|
||||
local parser = assert(vim.treesitter.get_parser(M.bufs.cmd, 'vim', {}))
|
||||
M.cmd.highlighter = vim.treesitter.highlighter.new(parser)
|
||||
elseif type == 'more' then
|
||||
-- Close more window with Ctrl-C.
|
||||
vim.keymap.set('n', '<C-c>', '<C-w>c', { buffer = M.bufs.more })
|
||||
end
|
||||
end
|
||||
|
||||
local setopt = false
|
||||
if not api.nvim_win_is_valid(M.wins[M.tab][type]) then
|
||||
local top = { vim.opt.fcs:get().horiz or o.ambw == 'single' and '─' or '-', 'WinSeparator' }
|
||||
local border = (type == 'more' or type == 'prompt') and { '', top, '', '', '', '', '', '' }
|
||||
local cfg = vim.tbl_deep_extend('force', wincfg, {
|
||||
focusable = type == 'more',
|
||||
mouse = type ~= 'cmd' and true or nil,
|
||||
anchor = type ~= 'cmd' and 'SE' or nil,
|
||||
hide = type ~= 'cmd' or M.cmdheight == 0 or nil,
|
||||
title = type == 'more' and 'Messages' or nil,
|
||||
border = type == 'box' and not o.termguicolors and 'single' or border or 'none',
|
||||
_cmdline_offset = type == 'cmd' and 0 or nil,
|
||||
})
|
||||
M.wins[M.tab][type] = api.nvim_open_win(M.bufs[type], false, cfg)
|
||||
api.nvim_win_set_hl_ns(M.wins[M.tab][type], M.ns)
|
||||
setopt = true
|
||||
elseif api.nvim_win_get_buf(M.wins[M.tab][type]) ~= M.bufs[type] then
|
||||
api.nvim_win_set_buf(M.wins[M.tab][type], M.bufs[type])
|
||||
setopt = true
|
||||
end
|
||||
|
||||
if setopt then
|
||||
if type == 'box' and o.termguicolors then
|
||||
vim.wo[M.wins[M.tab][type]].winblend = 30
|
||||
end
|
||||
vim.wo[M.wins[M.tab][type]].linebreak = false
|
||||
vim.wo[M.wins[M.tab][type]].smoothscroll = true
|
||||
vim.wo[M.wins[M.tab][type]].eventignorewin = 'all'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
@@ -149,6 +149,7 @@ local config = {
|
||||
'loader.lua',
|
||||
'uri.lua',
|
||||
'ui.lua',
|
||||
'_extui.lua',
|
||||
'filetype.lua',
|
||||
'keymap.lua',
|
||||
'fs.lua',
|
||||
@@ -171,6 +172,7 @@ local config = {
|
||||
'runtime/lua/vim/loader.lua',
|
||||
'runtime/lua/vim/uri.lua',
|
||||
'runtime/lua/vim/ui.lua',
|
||||
'runtime/lua/vim/_extui.lua',
|
||||
'runtime/lua/vim/filetype.lua',
|
||||
'runtime/lua/vim/keymap.lua',
|
||||
'runtime/lua/vim/fs.lua',
|
||||
|
Reference in New Issue
Block a user