Message - OXY2DEV/ui.nvim GitHub Wiki
💻 Mesaage
This file explains how the ui/message.lua file works.
You can change how the messages look by listening to the ext_messages
events. This will however auto enables ext_cmdline
& ext_linegrid
too.
You would typically start with something like this,
---@type integer Namespace for the UI(s).
local namespace = vim.api.nvim_create_namespace("ui");
vim.ui_attach(namespace, {
ext_messages = true,
-- These are enabled automatically.
ext_cmdine = true,
ext_linegrid = true
}, function (event, ...)
--- {event}, Event name
--- {...}, Arguments this event produces.
--- Do stuff...
end);
📜 Event list
The message UI receives the following events,
msg_show
Triggered whenever a new message should be shown.
[!WARNING] This event is trigger in
fast event
context(see:h api-fast
) so have to wait for Neovim to get out of fast event to actually do stuff.During fast event most API functions(
vim.api
) won't work, Vimscript functions(vim.fn
) also won't work so make sure you don't run any of those. You can usevim.schedule()
to get out of it.
msg_clear
Triggered when all open messages should be cleared.
[!NOTE] This event gets fired for a lot of different things. In most cases you can ignore it.
-
msg_showmode
Used for showing mode(e.g.-- INSERT --
) and macro recording messages(e.g.recording @q
).You can safely handle these in the
statusline
so you can safely ignore this event. -
msg_showcmd
Triggered when the last commands preview should be shown. -
msg_ruler
Triggered when the ruler should be shown. This can be handled by yourstatusline
. -
msg_history_show
Triggered when the message history should be shown.
[!NOTE] If there's no message, running
:messages
won't trigger this event.
msg_history_hide
Triggered when the message history should be hidden. Normally, this should be ignored.
📥 Storing messages
Messages are stored in 2 different places, message.visible and message.history.
message.visible
is used for temporarily showing messages to the user and message.history
is used for the internal message history. Both of these have the structure of a map
where the key is the ID of the message and the value is the message itself.
An ID(see message.id
) is used to determine the key used for a message.
Why a map instead of a list? While using a list would make this simpler, the messages in
message.visible
can be removed in any order and at any time. So, using a list would mean I would need to prevent holes from appearing in the list and list operations tend to take more time in general.
This method also makes replacing already visible messages with new messages easier.
Whenever a msg_show
event is triggered, the message gets either sent to message.__add() or message.__replace().
[!IMPORTANT] Certain messages get redirected to special functions, e.g. message.__list() for list type messages and message.__confirm() for confirmation style messages).
💭 Message to text
Messages are provided by Neovim
as lists of { attribute id, text, highlight id }
, unlike the command-line where we used attribute id
we will have to use highlight id
for the highlighting.
[!WARNING] Parts of the message can have newlines(
\n
) in them! Do not directly write them to the buffer.
You can turn message data into text and highlight regions like so,
---@param id integer Highlight group ID.
---@return string Highlight group name.
local function id_to_hl (id)
return vim.fn.synIDattr(vim.fn.synIDtrans(id), "name")
end
------------------------------------------------------------------------------
---@class part Message part.
---
---@field [1] integer Attribute ID.
---@field [2] string Message text.
---@field [3] integer Highlight group ID.
---@class hl_region A single highlight region.
---
---@field [1] integer Start byte index.
---@field [2] integer End byte index.
---@field [3] string Highlight group name.
---@alias hl_regions hl_region[] Highlight regions for a line.
------------------------------------------------------------------------------
local function process_msg (msg_parts)
---@type string[]
local lines = { "" };
---@type hl_regions[]
local highlights = { {} };
------------------------------------------------------------------------------
--- Handles a part of the message that has no `\n`.
---@param part part
local function handle (part)
table.insert(highlights[#highlights], {
#lines[#lines],
#lines[#lines] + #part[2],
id_to_hl(part[3])
});
lines[#lines] = lines[#lines] .. part[2];
end
--- Handles a part of the message that has `\n`.
---@param part part
local function handle_newline (part)
---@type string[]
local text_parts = vim.split(part[2], "\n", {});
for p, text_part in ipairs(text_parts) do
if p == 1 then
lines[#lines] = lines[#lines] .. text_part;
table.insert(highlights[#highlights], {
#lines[#lines],
#lines[#lines] + #text_part,
id_to_hl(part[3])
});
else
table.insert(lines, text_part);
table.insert(highlights], {
{
0,
#text_part,
id_to_hl(part[3])
}
});
end
end
end
-- `msg_parts` is a list of message parts.
for _, part in ipairs(msg_parts) do
if string.match(part[2], "\n") then
handle_newline(part);
else
handle(part);
end
end
return lines, highlights;
end
You can iterate over messages and create list of lines & highlights. You can then use vim.list_extend()
to merge them together
local lines, highlights = {}, {};
---@type integer[]
local IDs = vim.tbl_keys(message.visible);
table.sort(IDs);
for _, ID in ipairs(IDs) do
-- Assuming each entry has the following structure,
-- {
-- kind = "echo",
-- content = { ... }
-- }
local msg_lines, msg_highlights = process_msg(message.visible[ID].content);
lines = vim.list_extend(lines, msg_lines);
highlights = vim.list_extend(highlights, msg_highlights);
end
vim.api.nvim_buf_clear_namespace(msg.buffer, msg.namespace, 0, -1);
vim.api.nvim_buf_set_lines(msg.buffer, 0, -1, false, lines);
for h, hl in ipairs(highlights) do
for _, entry in ipairs(hl) do
-- entry[3] may sometimes be "".
pcall(
vim.api.nvim_buf_set_extmark,
msg.buffer,
msg.namespace,
h - 1,
entry[1],
{
end_col = entry[2],
hl_group = entry[3]
}
);
end
end
These aren't required for the UI. But I thought I should explain how the other parts of message.lua
works.
Feel free to skip these!
✨ Decorations
ui.nvim
allows adding icons and highlight group to the message.
The decorations are defined like virtual text
(a list of { text, highlight group }
tuples).
Why virtual text? Because many of the other
Neovim
options follow this pattern(window title/footer, foldtext, extmarks etc.) and it's easy to understand and write.
Now, normally just passing this to nvim_buf_set_extmark()
would be enough. However, the notification window I made has text wrapped so simply adding an extmark doesn't look good and we can't check where the text has been wrapped with ease either.
So, we will turn this into text that will be shown in the statuscolumn
of the notification window.
But to communicate with the statuscolumn, we export the decorations into message.decorations. We than use the lines line number(vim.v.lnum
, this is 1-indexed) to determine what(icon, padding or tail) to show.
The text transformation looks like this.
local parts = {
{ "Some " },
{ "special", "Special" },
{ " text" }
};
local statuscolumn = "";
for p, part in ipairs(parts) do
if type(part[2]) == "string" then
statuscolumn = statuscolumn .. string.format("%%#%s#%s", part[2], part[1]);
if p ~= #parts then
-- This fakes the behavior of virtual text.
-- And prevents colors from bleeding out.
statuscolumn = statuscolumn .. "%#Normal#";
end
else
statuscolumn = statuscolumn .. part[1];
end
end
You can check the statuscolumn used by this plugin in message.statuscolumn.
📐 Notification window dimensions
Figuring out the width & height of the notification window
may seem harder than it actually is.
For the width we will use the help of vim.fn.strdisplaywidth()
to determine the width of each line. Then, we will clamp it between a minimum & maximum value(the default is 0
& 0.5 * display width
).
We can then used the clamped width and divide each lines width with it(you have to floor the result too). Add all of them together and you have a rough estimate of the height of the window.
[!NOTE] When opening the window make sure you also count how width the
statuscolumn
is!