Skip to content

Guide_Statuscolumn

Shawon edited this page Mar 14, 2025 · 1 revision

📐 Statuscolumn: A beginners guide

Important

As statuscolumn functions mostly the same as statusline, I will be skipping over the basics. So, check that out first if you are new.

I recommend you try reading :h 'statusline' & :h 'statuscolumn' first as it would help you understand how they work better.

👀 Introduction

The statuscolumn works pretty much like the statusline with a few changes.

You can also modify it's contents just like the statusline.

:set statuscolumn=Hi!

Note

Depending on your configuration you statuscolumn might look weird. So, you should set these options before setting the statuscolumn.

--- Use this so that the statuscolumn updates
--- when you move around.
vim.o.relativenumber = true;

--- This can cause the statuscolumn to look
--- wider than it actually is.
--- > On 0.10, this can cause Neovim to crash
--- > when using click handlers if a higher
--- > value is used.
vim.o.numberwidth = 1;

The statuscolumn is run on every line and thus there's no real advantage of using some text literally.

You will most likely use some statuscolumn items instead.

📦 Statuscolumn items

Just like the statusline, the statuscolumn also has some statuscolumn items.

You can get a functional statuscolumn quite easily using these items,

Important

Make sure you have number enabled.

set number
:set statuscolumn=%s%C%3l\ 

Let's break it down,

Part Description
%s The sign column.
%C The fold column.
%3l The line number. It has a width of 3 columns.
\ Space.
Border to separate the statuscolumn from the text.

🎨 Colors

Of course just like the statusline, we can also add colors to the statuscolumn.

:set statuscolumn=%#Normal#%s%C%#Special#%3l%#Normal#\ 

I used %#Normal# before the statuscolumn as I generally prefer not to use a different color in the statuscolumn, so that is optional.

👉 Click Click Click

We can also have clickable sections in the statuscolumn. However, you must use the same function on every line of the statuscolumn.

And don't try to programmatically change it, that doesn't work.

So, we first need to create a function.

--- Goes to the clicked line number.
_G.__to_line = function ()
    ---@type table
    local mousepos = vim.fn.getmousepos();
    ---@type [ integer, integer ]
    local cursor = vim.api.nvim_win_get_cursor(mousepos.winid);

    pcall(vim.api.nvim_win_set_cursor, mousepos.winid, { mousepos.line, cursor[2] });
end

As the same function is used for handling clicks for every line in the statuscolumn, we need a way to check which line was clicked.

We do this via mousepos() which returns a table that has the line we clicked.

We also store the current cursor position so that it acts the same way as using j & k. But this can sometimes cause Pos outside of buffer error. So we wrap it in pcall() to ignore errors.

Now we can use it as a click_handler,

:set statuscolumn=%#Normal#%s%C%#Special#%@v:lua.__to_line@%3l%X%#Normal#\ 

We can create a start a click region by surrounding the function name(we use v:lua to access functions in lua) between %@ & @.

We end the click region by using %X(or %T).

💥 Creating a statuscolumn module

We set the statuscolumn just like the statusline.

Note

As with the statusline, I will be assuming your statuscolumn file exists at ~/.config/nvim/lua/statuscolumn.lua.

local statuscolumn = {};

--- Helper function for applying
--- highlight groups.
---@param hl string
---@return string
local function set_hl (hl)
    if type(hl) ~= "string" then
        return "";
    elseif vim.fn.hlexists(hl) == 0 then
        return "";
    else
        return "%#" .. hl .. "#";
    end
end

--- Optional, configuration table.
--- Add this if you like tinkering.
statuscolumn.config = {};

--- Function to create the statuscolumn.
---@return string
statuscolumn.render = function ()
    local _statuscolumn = "";

    --- Window whose statuscolumn we are
    --- creating.
    --- No, this is not a typo.
    ---@type integer
    local window = vim.g.statusline_winid;

    --- Buffer of the window.
    ---@type integer
    local buffer = vim.api.nvim_win_get_buf(window);

    for _, component in ipairs(statuscolumn.config) do
        local success, part_text = pcall(statuscolumn[component.kind], buffer, window, component);

        if success then
            --- Only add text if a function doesn't fail.
            _statuscolumn = _statuscolumn .. part_text;
        end
    end

    return _statuscolumn;
end

--- Optional, setup function.
statuscolumn.setup = function (config)
    if type(config) == "table" then
        statuscolumn.config = vim.tbl_deep_extend("force", statuscolumn.config, config);
    end

    vim.o.relativenumber = true;
    vim.o.numberwidth = 1;

    vim.o.statuscolumn = "%!v:lua.require('statuscolumn').render()";
end

return statuscolumn;

Now, we can use it in our init.lua.

--- Change the path to where you created
--- `statuscolumn.lua`.
require("statuscolumn").setup();

🎁 Components

Just like statuscolumn items, we can have our own components that do various things.

So, we will try making some simple components such as,

  • Line number
  • Fold
  • Signs
  • Separator

🧩 Line number

First, let's tackle the main purpose of a statuscolumn. The line numbers!

We will use a combination of relative line numbers & absolute line numbers.

--- configuration for line number component.
---@class statuscolumn.lnum
---
--- Component identifier.
---@field kind "lnum"
---
--- Highlight group for absolute line numbers.
---@field hl? string
---
--- Highlight group for relative line numbers.
---@field rel_hl? string


--- Shows the line number.
---@param buffer integer
---@param config statuscolumn.lnum
---@return string
statuscolumn.lnum = function (buffer, _, config)
    ---@type integer Maximum number of lines in the buffer.
    local line_count = vim.api.nvim_buf_line_count(buffer);

    local num = vim.v.relnum == 0 and vim.v.lnum or vim.v.relnum;
    local hl = vim.v.relnum == 0 and config.hl or config.rel_hl;

    return table.concat({
        set_hl(hl),
        string.rep(" ", #tostring(line_count) - #tostring(num)),
        num
    });
end

Tip

You can use the __to_line function that was shown above with this!

    return table.concat({
        set_hl(hl),
        "%@v:lua.__to_line@",
        string.rep(" ", #tostring(line_count) - #tostring(num)),
        num,
        "%X"
    });

Let's add this component to the configuration.

statuscolumn.config = {
    --- Other components.
    {
        kind = "lnum",

        hl = "Special",
        rel_hl = "Comment"
    },
};

🧩 Folds

Folding is one of my most-used feature of Vim(& Neovim). So, a fold column is quite helpful for me.

Unfortunately, the API function for folds is quite limited. So, instead we will use FFI(Foreign Function Interface) to access the internal functions instead.

local FFI = require("ffi");
--- Lines of C code.
---@type string[]
local C = {
    "typedef struct {} Error;",
    "typedef struct {} win;",

    "typedef struct {",
    "    int start;",
    "    int level;",
    "    int llevel;",
    "    int lines;",
    "} foldinfo;",

    "win *find_window_by_handle(int window, Error *err);",
    "foldinfo fold_info(win* wp, int lnum);",
};

FFI.cdef(table.concat(C, "\n"));

Now, let's create the function itself.

---@class statuscolumn.folds
---
---@field kind "folds"
---
--- Text for close folds.
---@field close_text string
---@field close_hl? string
---
---@field open_text string
---@field open_hl? string
---
---@field scope_end_text string
---@field scope_end_hl? string
---
---@field scope_merge_text string
---@field scope_merge_hl? string
---
---@field scope_text string
---@field scope_hl? string
---
---@field fill_text string
---@field fill_hl? string


---@param buffer integer
---@param window integer
---@param config statuscolumn.folds
statuscolumn.folds = function (buffer, window, config)
    ---@type integer
    local window_handle = FFI.C.find_window_by_handle(window, nil);
    ---@type integer
    local nlnum = math.min(vim.v.lnum + 1, vim.api.nvim_buf_line_count(buffer));

    ---@class fold_info
    ---
    ---@field start integer Start of the fold.
    ---@field level integer Level of the fold.
    ---@field llevel integer Highest level inside the fold.
    ---@field lines integer Number of lines a closed fold contains.
    local info = FFI.C.fold_info(window_handle, vim.v.lnum);
    ---@type fold_info
    local Ninfo = FFI.C.fold_info(window_handle, nlnum);

    local closed_fold = false;

    vim.api.nvim_buf_call(buffer, function ()
        closed_fold = vim.fn.foldclosed(vim.v.lnum) ~= -1;
    end);

    local _o = "";

    if info.start == vim.v.lnum then
        --- Start of a fold.
        if closed_fold == true then
            --- Closed fold.
            _o = table.concat({
                _o,
                set_hl(config.close_hl),
                config.close_text or ""
            });
        else
            --- Open fold.
            _o = table.concat({
                _o,
                set_hl(config.open_hl),
                config.open_text or ""
            });
        end
    elseif info.level >= 1 and vim.v.lnum == vim.api.nvim_buf_line_count(buffer) then
        --- Last line of a buffer.
        _o = table.concat({
            _o,
            set_hl(config.scope_end_hl),
            config.scope_end_text or ""
        });
    elseif info.start ~= Ninfo.start then
        --- Last line of a fold.
        if Ninfo.level == 0 then
            --- End of the fold.
            _o = table.concat({
                _o,
                set_hl(config.scope_end_hl),
                config.scope_end_text or ""
            });
        elseif info.level == Ninfo.level then
            --- End of the fold.
            ---
            --- Next line has a fold
            --- whose level is >=
            --- to this one.
            _o = table.concat({
                _o,
                set_hl(config.scope_end_hl),
                config.scope_end_text or ""
            });
        elseif info.level > Ninfo.level then
            --- End of the fold.
            ---
            --- Next line has a fold
            --- whose level is >=
            --- to this one.
            _o = table.concat({
                _o,
                set_hl(config.scope_merge_hl),
                config.scope_merge_text or ""
            });
        elseif info.level > 0 then
            _o = table.concat({
                _o,
                set_hl(config.scope_hl),
                config.scope_text or ""
            });
        else
            _o = table.concat({
                _o,
                set_hl(config.fill_hl),
                config.fill_text or ""
            });
        end
    elseif info.level > 0 then
        _o = table.concat({
            _o,
            set_hl(config.scope_hl),
            config.scope_text or ""
        });
    else
        _o = table.concat({
            _o,
            set_hl(config.fill_hl),
            config.fill_text or ""
        });
    end

     return _o;
end

It may look kinda confusing, cause it kinda is. But long story short, we just compare the current line's fold information(level, start, end) with the next lines fold information.

Let's add this component to the configuration.

statuscolumn.config = {
    {
        kind = "folds",

        close_text = "󱉌",
        open_text = "󱉎",

        scope_text = "",
        scope_end_text = "",
        scope_merge_text = "",

        fill_text = " "
    },
    --- Other components.
};

🧩 Signs

For signs, we will not do anything complicated.

---@class statuscolumn.signs
---
---@field kind "signs"


--- The sign column.
---@return string
statuscolumn.signs = function ()
    return "%s";
end

We add it before the fold column.

statuscolumn.config = {
    {
        kind = "signs"
    },
    --- Other components.
};

🧩 Separator

Finally, we will create a Separator between different columns.

---@class statuscolumn.separator
---
---@field kind "separator"
---
--- Text to use as separator.
---@field text? string
---
--- Width of the separator.
---@field width? integer
---
--- Highlight group for the separator.
---@field hl? string


---@param config statuscolumn.separator
statuscolumn.separator = function (_, _, config)
    return table.concat({
        set_hl(config.hl),
        string.rep(config.text or " ", config.width or 1)
    });
end

Let's add some separators to our config!

statuscolumn.config = {
    {
        kind = "separator"
    },
    {
        kind = "signs"
    },
    {
        kind = "folds",

        close_text = "󱉌",
        open_text = "󱉎",

        scope_text = "",
        scope_end_text = "",
        scope_merge_text = "",

        fill_text = " "
    },
    {
        kind = "lnum",

        hl = "Special",
        rel_hl = "Comment"
    },
    {
        kind = "separator"
    },
    {
        kind = "separator",

        text = "",
        hl = "Comment"
    },
};

And we are done!

🔥 More example

You can check more complex components, per-window configuration, click handling etc. in the source file.



Also available in Vimdoc, :h bars.nvim-statuscolumn.

Clone this wiki locally