commit db2a54996ecfb4138301ddb091f537c0a90a9bd4
parent da7580498a82252a9e13e29abd092d33a23bac22
Author: luukvbaal <luukvbaal@gmail.com>
Date: Wed, 4 Feb 2026 20:11:35 +0100
feat(ui2): replace message by its ID #37672
Problem: UI2 does not implement the msg_show event msg_id parameter.
Solution: Store message IDs currently shown in the cmd/msg buffers.
Set extmarks spanning the message which are used to replace
a still visible message when a new message with the same ID
is received.
Diffstat:
2 files changed, 167 insertions(+), 73 deletions(-)
diff --git a/runtime/lua/vim/_extui/messages.lua b/runtime/lua/vim/_extui/messages.lua
@@ -1,6 +1,7 @@
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,
@@ -9,21 +10,21 @@ local M = {
-- A timer is started for each message whose callback will remove the message
-- from the window again.
msg = {
- count = 0, -- Number of messages currently in the message window.
+ ids = {}, ---@type table<string|integer, Msg> List of visible messages.
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.
+ 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.
@@ -34,20 +35,19 @@ local M = {
ids = {}, ---@type { ['last'|'msg'|'top'|'bot']: integer? } Table of mark IDs.
delayed = false, -- Whether placement of 'last' virt_text is delayed.
},
- on_dialog_key = 0, -- vim.on_key namespace for paging in the dialog window.
+ 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.count = ext.redrawing and M.cmd.count or 0
+ 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
- M.prev_msg = ext.cfg.msg.target == 'msg' and '' or M.prev_msg
- api.nvim_buf_clear_namespace(ext.bufs.msg, -1, 0, -1)
if api.nvim_win_is_valid(ext.wins.msg) then
api.nvim_win_set_config(ext.wins.msg, { hide = true })
end
@@ -56,16 +56,30 @@ end
--- 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.msg: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 pager or buffer was closed.
+---@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
- api.nvim_buf_set_lines(buf, 0, len, false, {})
- self.count = self.count - 1
+
-- Resize or hide message window for removed message.
- if self.count > 0 then
+ if next(self.ids) then
M.set_pos('msg')
else
self:close()
@@ -73,14 +87,13 @@ function M.msg:start_timer(buf, len)
end, ext.cfg.msg.timeout)
end
-local cmd_on_key = nil
--- 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, error or full message in cmdline is shown.
+ 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.
@@ -187,12 +200,9 @@ local function set_virttext(type, tar)
end
end
--- We need to keep track of the current message column to be able to
--- append or overwrite messages for :echon or carriage returns.
-local col, hlopts = 0, { undo_restore = false, invalidate = true, priority = 1 }
-
---- Move messages to cmdline or pager to show in full.
-local function msg_to_full(src)
+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
@@ -207,13 +217,15 @@ local function msg_to_full(src)
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[src].count = 0
+
M.virt.msg[M.virt.idx.spill][1] = nil
+ M[src].ids = {}
else
for _, id in pairs(M.virt.ids) do
api.nvim_buf_del_extmark(ext.bufs.cmd, ext.ns, id)
@@ -222,30 +234,36 @@ local function msg_to_full(src)
M.set_pos(tar)
end
-local reset_timer ---@type uv.uv_timer_t?
+-- 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
-function M.show_msg(tar, content, replace_last, append)
- local msg, restart, cr, dupe, count = '', false, false, 0, 0
+---@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 = (msg == M.prev_msg and ext.cmd.srow == 0 and M.dupe + 1 or 0)
+ dupe = (not extid and msg == M.prev_msg and ext.cmd.srow == 0 and M.dupe + 1 or 0)
end
- cr = M[tar].count > 0 and msg:sub(1, 1) == '\r'
- restart = M[tar].count > 0 and (replace_last or dupe > 0)
- count = M[tar].count + ((restart or msg == '\n') and 0 or 1)
+ 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 M.cmd.count == 0 and ext.cmd.srow == 0 then
- api.nvim_buf_set_lines(ext.bufs.cmd, 0, -1, false, {})
+ 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
@@ -254,35 +272,40 @@ function M.show_msg(tar, content, replace_last, append)
return
end
- local line_count = api.nvim_buf_line_count(ext.bufs[tar])
+ 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 = M[tar] and count <= 1 and ext.cmd.srow == 0 and 0
- or line_count - ((replace_last or restart or cr or append) and 1 or 0)
- local curline = (cr or append) and api.nvim_buf_get_lines(ext.bufs[tar], row, row + 1, false)[1]
+ 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 = append and not cr and math.min(col, #curline) or 0
+ 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 a non-repeated message.
- for _, chunk in ipairs((not M[tar] or dupe == 0) and content or {}) do
+ -- 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
- if line_count < row + 1 then
- api.nvim_buf_set_lines(ext.bufs[tar], row, -1, false, { repl })
+ -- 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 ecol = curline and math.min(end_col, #curline) or -1
- api.nvim_buf_set_text(ext.bufs[tar], row, col, row, ecol, { repl })
+ 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(ext.bufs[tar], row, row + 1, false)[1]
+ 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(ext.bufs[tar], ext.ns, row, col, hlopts)
+ api.nvim_buf_set_extmark(buf, ext.ns, row, col, hlopts)
end
if pat == '\n' then
@@ -293,21 +316,22 @@ function M.show_msg(tar, content, replace_last, append)
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 })
+ 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
- msg_to_full(tar)
- end
-
- M.set_pos('msg')
- M.msg.width = width
- if restart then
- M.msg.timer:stop()
- M.msg.timer:set_repeat(4000)
- M.msg.timer:again()
+ expand_msg(tar)
else
- M.msg:start_timer(ext.bufs.msg, row - start_row + 1)
+ 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.
@@ -318,7 +342,7 @@ function M.show_msg(tar, content, replace_last, append)
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[ext.bufs.cmd] = nil
+ 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, {})
@@ -327,23 +351,23 @@ function M.show_msg(tar, content, replace_last, append)
M.cmd.msg_row = texth.end_row
if texth.all > ext.cmdheight then
- msg_to_full(tar)
+ expand_msg(tar)
end
end
end
- if M[tar] then
+ 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_msg, M.dupe, M[tar].count = msg, dupe, count
+ 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 reset_timer and (col > 0 or M.cmd.count > 0) then
- reset_timer = vim.defer_fn(function()
- reset_timer, col, M.cmd.count = nil, 0, 0
+ 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
@@ -357,10 +381,11 @@ end
---@param replace_last boolean
--@param history boolean
---@param append boolean
-function M.msg_show(kind, content, replace_last, _, append)
+---@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 M.cmd.count == 0 and ext.cmd.srow == 0 then
+ 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
@@ -378,7 +403,7 @@ function M.msg_show(kind, content, replace_last, _, append)
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)
+ M.show_msg('dialog', content, replace_last, append, id)
M.set_pos('dialog')
else
-- Set the entered search command in the cmdline (if available).
@@ -394,10 +419,10 @@ function M.msg_show(kind, content, replace_last, _, append)
M.virt.last[M.virt.idx.search][1] = nil
end
- M.show_msg(tar, content, replace_last, append)
+ 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.count, M.prev_msg = 0, ''
+ M.cmd.ids, M.prev_msg = {}, ''
end
end
end
@@ -407,7 +432,7 @@ 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.dupe, M[ext.cfg.msg.target].count, M.cmd.msg_row, M.msg.width = 0, 0, -1, 1
+ 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
@@ -449,7 +474,7 @@ function M.msg_history_show(entries, prev_cmd)
end
if cmd_on_key then
- -- Dismiss a still open full message cmd window.
+ -- 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.
@@ -458,7 +483,7 @@ function M.msg_history_show(entries, prev_cmd)
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])
+ M.show_msg('pager', entry[2], i == 1, entry[3], 0)
end
M.set_pos('pager')
@@ -480,7 +505,7 @@ function M.set_pos(type)
api.nvim_win_set_config(win, cfg)
if type == 'cmd' and not cmd_on_key then
- -- Temporarily showing a full message in the cmdline, until next key press.
+ -- 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
diff --git a/test/functional/ui/messages2_spec.lua b/test/functional/ui/messages2_spec.lua
@@ -528,4 +528,73 @@ describe('messages2', function()
i hate locks so much!!!! |*2
]])
end)
+
+ it('replace by message ID', function()
+ exec_lua(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 })
+ end)
+ screen:expect([[
+ ^ |
+ {1:~ }|*8
+ {3: }|
+ foo |
+ bar |
+ baz |
+ foo |
+ ]])
+ exec_lua(function()
+ vim.api.nvim_echo({ { 'foo' } }, true, { id = 2 })
+ end)
+ screen:expect([[
+ ^ |
+ {1:~ }|*9
+ {3: }|
+ foo |*3
+ ]])
+ exec_lua(function()
+ vim.api.nvim_echo({ { 'bar\nbaz' } }, true, { id = 1 })
+ end)
+ screen:expect([[
+ ^ |
+ {1:~ }|*8
+ {3: }|
+ bar |
+ baz |
+ foo |*2
+ ]])
+ exec_lua(function()
+ vim.o.cmdheight = 0
+ 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 })
+ end)
+ screen:expect([[
+ ^ |
+ {1:~ }|*9
+ {1:~ }{4:foo}|
+ {1:~ }{4:bar}|
+ {1:~ }{4:baz}|
+ {1:~ }{4:foo}|
+ ]])
+ exec_lua(function()
+ vim.api.nvim_echo({ { 'foo' } }, true, { id = 2 })
+ end)
+ screen:expect([[
+ ^ |
+ {1:~ }|*10
+ {1:~ }{4:foo}|*3
+ ]])
+ exec_lua(function()
+ vim.api.nvim_echo({ { 'f', 'Conceal' }, { 'oo\nbar' } }, true, { id = 3 })
+ end)
+ screen:expect([[
+ ^ |
+ {1:~ }|*9
+ {1:~ }{4:foo}|*2
+ {1:~ }{14:f}{4:oo}|
+ {1:~ }{4:bar}|
+ ]])
+ end)
end)