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 use vim.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 your statusline.

  • 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!