fix(ui2): multiline/color replaced message, expanded cmdline, error messages #38044

Problem:  - Unintentionally inserting lines for a replaced multiline
          message that also has multiple highlights.
          - Scheduled check to see if the expanded cmdline window was
          entered makes it difficult to keep track of what happens when
          the key pressed to dismiss it results in a message.
          - Reading the first line of an error message should be enough
          notice for something going wrong.
          - "search_cmd" messages should not be shown with 0 'cmdheight'.
          - Unable to configure dynamically changed pager height.
          - Enabling UI2 doesn't make sense with no UIs attached.

Solution: - Only insert a line for the first chunk after a newline.
          - Use getmousepos() to check if the expanded cmdline was
          clicked to enter the pager.
          entering the pager to serve as a configuration interface.
          - Don't expand the cmdline for error messages; user can press g<.
          - Don't show "search_cmd" messages with 'cmdheight' set to 0.
          - Change 'eventignorewin' to ensure WinEnter is fired when
          - Have enable() return early when no UIs are attached.
This commit is contained in:
luukvbaal
2026-02-24 23:05:38 +01:00
committed by GitHub
parent 16aab4cb48
commit 844caca881
4 changed files with 108 additions and 65 deletions

View File

@@ -59,6 +59,7 @@ local wincfg = { -- Default cfg for nvim_open_win().
width = 10000,
height = 1,
noautocmd = true,
focusable = false,
}
local tab = 0
@@ -77,7 +78,6 @@ function M.check_targets()
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,
@@ -100,8 +100,7 @@ function M.check_targets()
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('eventignorewin', 'all,-FileType', { 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' })
@@ -161,8 +160,9 @@ local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
function M.enable(opts)
vim.validate('opts', opts, 'table', true)
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 #vim.api.nvim_list_uis() == 0 then
return -- Don't prevent stdout messaging when no UIs are attached.
end
if M.cfg.enable == false then
-- Detach and cleanup windows, buffers and autocommands.
@@ -181,6 +181,8 @@ function M.enable(opts)
return
end
M.cmd = require('vim._core.ui2.cmdline')
M.msg = require('vim._core.ui2.messages')
vim.ui_attach(M.ns, { ext_messages = true, set_cmdheight = false }, function(event, ...)
if not (M.msg[event] or M.cmd[event]) then
return

View File

@@ -46,13 +46,6 @@ api.nvim_set_decoration_provider(ui.ns, {
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.
@@ -82,7 +75,8 @@ function M.msg:start_timer(buf, id)
if next(self.ids) then
M.set_pos('msg')
else
self:close()
pcall(api.nvim_win_set_config, ui.wins.msg, { hide = true })
self.width, M.virt.msg[M.virt.idx.dupe][1] = 1, nil
end
end, ui.cfg.msg.timeout)
end
@@ -212,7 +206,8 @@ local function expand_msg(src)
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, {})
M.msg_clear()
api.nvim_buf_set_lines(ui.bufs[tgt], srow, -1, false, lines)
for _, mark in ipairs(marks) do
hlopts.end_col, hlopts.hl_group = mark[4].end_col, mark[4].hl_group
@@ -224,11 +219,8 @@ local function expand_msg(src)
elseif tgt == 'pager' then
api.nvim_command('norm! G')
end
M.virt.msg[M.virt.idx.spill][1] = nil
M[src].ids = {}
M.msg:close()
else
M.virt.msg[M.virt.idx.dupe][1] = nil
for _, id in pairs(M.virt.ids) do
api.nvim_buf_del_extmark(ui.bufs.cmd, ui.ns, id)
end
@@ -241,11 +233,12 @@ end
local col = 0
local cmd_timer ---@type uv.uv_timer_t? Timer resetting cmdline state next event loop.
---@param tgt 'cmd'|'dialog'|'msg'|'pager'
---@param kind string
---@param content MsgContent
---@param replace_last boolean
---@param append boolean
---@param id integer|string
function M.show_msg(tgt, content, replace_last, append, id)
function M.show_msg(tgt, kind, content, replace_last, append, id)
local mark, msg, cr, dupe, buf = {}, '', false, 0, ui.bufs[tgt]
if M[tgt] then -- tgt == 'cmd'|'msg'
@@ -284,7 +277,7 @@ function M.show_msg(tgt, content, replace_last, append, id)
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
local start_col, insert = col, false
-- Accumulate to be inserted and highlighted message chunks.
for i, chunk in ipairs(content) do
@@ -294,9 +287,9 @@ function M.show_msg(tgt, content, replace_last, append, id)
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
if line_count < row + 1 or insert then
api.nvim_buf_set_lines(buf, row, row > start_row and row or -1, false, { repl })
line_count = line_count + 1
insert, line_count = false, 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
@@ -311,7 +304,7 @@ function M.show_msg(tgt, content, replace_last, append, id)
end
if pat == '\n' then
row, col = row + 1, 0
row, col, insert = row + 1, 0, mark[1] ~= nil
else
col = pat == '\r' and 0 or end_col
end
@@ -357,7 +350,9 @@ function M.show_msg(tgt, content, replace_last, append, id)
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 the cmdline for a non-error message that doesn't fit.
local error_kinds = { rpc_error = 1, emsg = 1, echoerr = 1, lua_error = 1 }
if texth.all > ui.cmdheight and (ui.cmdheight == 0 or not error_kinds[kind]) then
expand_msg(tgt)
end
end
@@ -412,12 +407,15 @@ function M.msg_show(kind, content, replace_last, _, append, id)
api.nvim_buf_set_lines(ui.bufs.dialog, 0, -1, false, {})
end
ui.cmd.dialog = true -- Ensure dialog is closed when cmdline is hidden.
M.show_msg('dialog', content, replace_last, append, id)
M.show_msg('dialog', kind, content, replace_last, append, id)
M.set_pos('dialog')
else
-- Set the entered search command in the cmdline (if available).
local tgt = kind == 'search_cmd' and 'cmd' or ui.cfg.msg.target
if tgt == 'cmd' then
if kind == 'search_cmd' and ui.cmdheight == 0 then
-- Blocked by messaging() without ext_messages. TODO: look at other messaging() guards.
return
elseif tgt == 'cmd' then
-- 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
@@ -425,7 +423,7 @@ function M.msg_show(kind, content, replace_last, _, append, id)
M.virt.last[M.virt.idx.search][1] = nil
end
M.show_msg(tgt, content, replace_last, append, id)
M.show_msg(tgt, kind, 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 = {}, ''
@@ -479,17 +477,15 @@ function M.msg_history_show(entries, prev_cmd)
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.
-- Showing output of previous command, clear in case still visible.
if cmd_on_key or prev_cmd then
M.msg_clear()
api.nvim_feedkeys(vim.keycode('<Esc>'), 'n', false)
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)
M.show_msg('pager', entry[1], entry[2], i == 1, entry[3], 0)
end
M.set_pos('pager')
@@ -501,9 +497,10 @@ end
function M.set_pos(tgt)
local function win_set_pos(win)
local cfg = { hide = false, relative = 'laststatus', col = 10000 }
local texth = tgt and api.nvim_win_text_height(win, {}) or {}
local texth = api.nvim_win_text_height(win, {})
local top = { vim.opt.fcs:get().msgsep or ' ', 'MsgSeparator' }
cfg.height = tgt and math.min(texth.all, tgt == 'pager' and 10000 or math.ceil(o.lines * 0.5))
local lines = o.lines - (win == ui.wins.pager and ui.cmdheight + (o.ls == 3 and 2 or 0) or 0)
cfg.height = math.min(texth.all, math.ceil(lines * (win == ui.wins.pager and 1 or 0.5)))
cfg.border = win ~= ui.wins.msg and { '', top, '', '', '', '', '', '' } or nil
cfg.focusable = tgt == 'cmd' or nil
cfg.row = (win == ui.wins.msg and 0 or 1) - ui.cmd.wmnumode
@@ -524,29 +521,20 @@ function M.set_pos(tgt)
if not typed or typed == '<MouseMove>' then
return
end
vim.schedule(function()
local entered = typed == '<CR>' or 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
-- User entered the cmdline window or pressed enter: open the pager.
api.nvim_command('norm! g<')
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)
cmd_on_key, M[ui.cfg.msg.target].ids = nil, {}
-- Check if window was entered and reopen with original config.
local entered = typed == '<CR>'
or typed:find('LeftMouse') and fn.getmousepos().winid == ui.wins.cmd
pcall(api.nvim_win_close, ui.wins.cmd, true)
ui.check_targets()
-- Show or clear the message depending on if the pager was opened.
if entered then
api.nvim_command('norm! g<')
end
set_virttext('msg')
end, ui.ns)
elseif tgt == 'dialog' then
-- Add virtual [+x] text to indicate scrolling is possible.
@@ -595,7 +583,7 @@ function M.set_pos(tgt)
-- 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 tgt == 'pager' then
elseif tgt == 'pager' and api.nvim_get_current_win() ~= ui.wins.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.
@@ -605,9 +593,11 @@ function M.set_pos(tgt)
-- Cmdwin is actually closed one event iteration later so schedule in case it was open.
vim.schedule(function()
-- Allow events while the user is in the pager.
api.nvim_set_option_value('eiw', '', { scope = 'local', win = ui.wins.pager })
api.nvim_set_current_win(ui.wins.pager)
-- Ensure cursor is at beginning of first message.
api.nvim_win_set_cursor(ui.wins.pager, { 1, 0 })
-- Make pager relative to cmdwin when it is opened, restore when it is closed.
api.nvim_create_autocmd({ 'WinEnter', 'CmdwinEnter', 'CmdwinLeave' }, {
callback = function(ev)
@@ -616,6 +606,7 @@ function M.set_pos(tgt)
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)
api.nvim_set_option_value('eiw', 'all', { scope = 'local', win = ui.wins.pager })
end
return ev.event == 'WinEnter'
end,

View File

@@ -244,9 +244,9 @@ describe('cmdline2', function()
feed('call confirm("Ok?")<CR>')
screen:try_resize(screen._width + 1, screen._height)
screen:expect([[
|
{1:~ }|*10
|*10
{3: }|
|
{6:Ok?} |
{6:[O]k: }^ |
]])
@@ -254,9 +254,9 @@ describe('cmdline2', function()
feed('k')
screen:try_resize(screen._width, screen._height + 1)
screen:expect([[
|
{1:~ }|*11
|*11
{3: }|
|
{6:Ok?} |
{6:[O]k: }^ |
]])

View File

@@ -142,6 +142,31 @@ describe('messages2', function()
{1:~ }|*11
foo [+1] 1,1 All|
]])
-- Changing 'laststatus' reveals the global statusline with a pager height
-- exceeding the available lines: #38008.
command('tabonly | call nvim_echo([["foo\n"]]->repeat(&lines), 1, {})')
screen:expect([[
^x |
{1:~ }|*5
{3: }|
foo |*6
foo [+8] |
]])
feed('<CR>')
screen:expect([[
{3: }|
^foo |
foo |*11
1,1 Top|
]])
command('set laststatus=3')
screen:expect([[
{3: }|
^foo |
foo |*10
{3:[Pager] 1,1 Top}|
|
]])
end)
it('new buffer, window and options after closing a buffer', function()
@@ -290,7 +315,7 @@ describe('messages2', function()
screen:expect([[
^ |
{1:~ }|*12
foo [+1] |
|
]])
end)
@@ -442,7 +467,7 @@ describe('messages2', function()
^foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofo|
|
]])
t.eq({ filetype = 5 }, n.eval('g:set')) -- still fires for 'filetype'
t.eq(5, n.eval('g:set').filetype) -- still fires for 'filetype'
end)
it('Search highlights only apply to pager', function()
@@ -598,6 +623,9 @@ describe('messages2', function()
vim.api.nvim_echo({ { 'foo' } }, true, { id = 1 })
vim.api.nvim_echo({ { 'bar\nbaz' } }, true, { id = 2 })
vim.api.nvim_echo({ { 'foo' } }, true, { id = 3 })
vim.keymap.set('n', 'Q', function()
vim.api.nvim_echo({ { 'Syntax', 23 }, { '\n - ', 0 }, { 'cCommentL', 439 } }, false, {})
end)
end)
screen:expect([[
^ |
@@ -628,6 +656,19 @@ describe('messages2', function()
baz |
foo |*2
]])
-- Pressing a key immediately dismisses an expanded cmdline, and
-- replacing a multiline, multicolored message doesn't error due
-- to unneccesarily inserted lines #37994.
feed('Q')
screen:expect([[
^ |
{1:~ }|*10
{3: }|
{100:Syntax} |
- cCommentL |
]])
feed('Q')
screen:expect_unchanged()
set_msg_target_zero_ch()
exec_lua(function()
vim.api.nvim_echo({ { 'foo' } }, true, { id = 1 })
@@ -718,4 +759,13 @@ describe('messages2', function()
{6:[O]k: }^ |
]])
end)
it('no search_cmd with cmdheight=0', function()
set_msg_target_zero_ch()
feed('ifoo<Esc>?foo<CR>')
screen:expect([[
{10:^foo} |
{1:~ }|*13
]])
end)
end)