Custom Neovim statusline
How to create your own statusline inside Neovim.
Overview
Active statusline
Active statusline with hidden segments
Inactive statusline
In this post we will build a minimal, custom statusline for Neovim that shows:
- When the window is active:
- The current file path (relative to the current working directory).
- The Git branch and change counts (if the file is in a Git repository).
- The filetype.
- How far you have scrolled in the file (percentage).
- The current line and column of the cursor.
- When the window is inactive:
- Only the file name.
These are the pieces I personally find useful, but you will see that everything is fully customizable.
We will also add custom key-bindings to toggle:
- The directory portion of the path.
- The Git branch name (keeping the change counts).
This keeps the bar tidy when the space is tight or the names are long.
Follow this guide to understand the final implementation, that can be found at the end of the post.
Prerequisites
- Neovim:
>= 0.9.0
. vim.opt.laststatus = 2
(the default; always show a statusline in the previously focused window).- gitsigns.nvim for Git information.
- A Nerd Font (or any font that can render icons).
- Basic Lua knowledge.
Config structure (one example)
1
2
3
4
5
6
7
8
$HOME/.config/nvim
├── init.lua
└── lua
├── vt
│ ├── specs.lua
│ └── status_line.lua
└── plugins
└── gitsigns.lua
In init.lua
we simply require the modules:
1
2
3
require("vt.specs")
require("vt.status_line")
-- more requires ...
I use lazy.nvim as a plugin manager, so specs.lua
just lists plugin specs:
1
2
3
4
5
6
7
8
LAZY_PLUGIN_SPEC = {}
local function spec(item)
table.insert(LAZY_PLUGIN_SPEC, { import = item })
end
spec("plugins.gitsigns")
-- more plugins ...
A minimal plugins/gitsigns.lua
looks like:
1
2
3
4
5
return {
"lewis6991/gitsigns.nvim",
lazy = false,
config = true,
}
All the statusline code will live in lua/vt/status_line.lua
.
Statusline basics
statusline
is a format string Neovim evaluates for each window. The active window uses StatusLine
highlight group; inactive windows use StatusLineNC
. See :h statusline
.
Core building blocks
- Alignment:
%=
splits the bar into zones (left | right; add another%=
for a middle). - Truncation:
%<
marks where text to its left may be shortened when space is tight. - Literals:
%%
prints a literal%
. - Expressions:
%{...}
evaluates an expression. - Highlighting:
%#Group#
switches highlight;%*
resets to the default group.
Common built-ins (quick ref)
- File:
%t
(tail),%f
(relative path),%F
(full path),%y
(filetype) - Flags:
%m
(modified +),%r
(readonly) - Position:
%l
(line),%c
(col),%L
(total),%p%%
(percent)
Creating the statusline
Let’s start with a basic version of our statusline:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Statusline = {}
function Statusline.active()
-- `%P` shows the scroll percentage but says 'Bot', 'Top' and 'All' as well.
return "[%f]%=%y [%P %l:%c]"
end
function Statusline.inactive()
return " %t"
end
vim.api.nvim_exec2([[
-- Starts an autocommand group with the name `Statusline`.
augroup Statusline
-- Clears any autocommands already in this group.
au!
-- On `WinEnter` or `BufEnter` events, for any file (`*`), set the `window-local`
-- option statusline to an expression (`%!...`) that calls your Lua function
-- `Statusline.active()`. `%!` means "evaluate this expression whenever the
-- statusline is drawn." `v:lua.Statusline.active()` bridges to the Lua global
-- `Statusline.active`.
au WinEnter,BufEnter * setlocal statusline=%!v:lua.Statusline.active()
-- On `WinLeave` or `BufLeave` events, switch that window’s statusline to the
-- inactive variant by calling `Statusline.inactive()`.
au WinLeave,BufLeave * setlocal statusline=%!v:lua.Statusline.inactive()
-- Ends the group
augroup END
]], {})
statusline
is window-local, so we use setlocal
. Even with laststatus=3
(a single global bar) Neovim still reads the value from the current window, so these autocommands keep it correct.
Adding git information
gitsigns.nvim
populates vim.b.gitsigns_status_dict
; we turn that into a segment:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
local function git()
local git_info = vim.b.gitsigns_status_dict
if not git_info or git_info.head == "" then
return ""
end
local head = git_info.head
local added = git_info.added and (" +" .. git_info.added) or ""
local changed = git_info.changed and (" ~" .. git_info.changed) or ""
local removed = git_info.removed and (" -" .. git_info.removed) or ""
if git_info.added == 0 then added = "" end
if git_info.changed == 0 then changed = "" end
if git_info.removed == 0 then removed = "" end
return table.concat({
"[ ", -- branch icon
head,
added, changed, removed,
"]",
})
end
Update the active statusline:
1
2
3
4
5
6
7
8
function Statusline.active()
return table.concat {
"[%f] ",
git(),
"%=",
"%y [%P %l:%c]"
}
end
Already looking good!
Keybindings to hide items
Toggle file path visibility
First split the path and file name. We’ll return the directory only:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
local function filepath()
-- Modify the given file path with the given modifiers
local fpath = vim.fn.fnamemodify(vim.fn.expand "%", ":~:.:h")
if fpath == "" or fpath == "." then
return ""
end
return string.format("%%<%s/", fpath)
-- `%%` -> `%`.
-- `%s` -> value of `fpath`.
-- The result is `%<fpath/`.
-- `%<` tells where to truncate when there is not enough space.
end
See :h filename-modifiers
to know what the modifiers in the line 3 do.
Add it to the render function:
1
2
3
4
5
6
7
8
9
10
function Statusline.active()
return table.concat {
-- Before: `[%f]`
-- `%t` shows only the file name
"[", filepath(), "%t] ",
git(),
"%=",
"%y [%P %l:%c]"
}
end
We’ll use the function we have just created to toggle the visibility of the file path. Let’s start saving the state and adding some configuration.
1
2
3
4
5
6
7
8
9
10
-- At the start of the file
local state = {
show_path = true,
}
local config = {
icons = {
path_hidden = "" -- opened directory icon
},
}
Change the filepath
function logic.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
local function filepath()
local fpath = vim.fn.fnamemodify(vim.fn.expand "%", ":~:.:h")
if fpath == "" or fpath == "." then
return ""
end
-- Whether to show the path or the icon
if state.show_path then
return string.format("%%<%s/", fpath)
end
return config.icons.path_hidden .. "/"
end
Create a function that changes the current path status, and a keymap in order to call that function.
1
2
3
4
5
6
7
8
9
function Statusline.toggle_path()
state.show_path = not state.show_path
-- Draw the statusline manually
vim.cmd("redrawstatus")
end
-- `sp` for "statusline path"
vim.keymap.set("n", "<leader>sp", function() Statusline.toggle_path() end, { desc = "Toggle statusline path" })
Perfect! Now we are able to hide the file path and show it again if we need to.
Toggle Git branch visibility
You may also have long branch names that you want to hide from time to time. So, let’s add the same functionality for them.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
local state = {
show_branch = true,
}
local config = {
icons = {
branch_hidden = "", -- crossed out eye icon
},
}
local function git()
-- ...
if not state.show_branch then
head = config.icons.branch_hidden
end
return table.concat({
"[ ",
head,
added, changed, removed,
"]",
})
end
function Statusline.toggle_branch()
state.show_branch = not state.show_branch
vim.cmd("redrawstatus")
end
-- `sb` for `statusline branch`
vim.keymap.set("n", "<leader>sb", function() Statusline.toggle_branch() end, { desc = "Toggle statusline git branch" })
That’s it!
As you can see, this is really customizable. You can do whatever you want with your statusline.
Adding color
The statusline looks already really good for me, but you may want some color. We’ll create a function to help us applying any highlight group to text.
1
2
3
4
5
6
local function hl(group, text)
return string.format("%%#%s#%s%%*", group, text)
-- Result: `%#group#text%*`
-- `%#group#` tells the highlight group that must be applied to `text`.
-- `%*` restores the normal highlight group.
end
Create a custom highlight group.
1
2
3
4
5
6
7
8
local config = {
placeholder_hl = "StatusLineDim", -- a dim highlight group we define below
}
-- set (or link) the dim highlight once
vim.api.nvim_set_hl(0, config.placeholder_hl, {}) -- create if missing
-- link to `Comment` to keep it dim; adjust as you like
vim.api.nvim_set_hl(0, config.placeholder_hl, { link = "Comment" })
Apply it to our icons.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
local function filepath()
-- ...
return hl(config.placeholder_hl, config.icons.path_hidden .. "/")
end
local function git()
-- ...
if not state.show_branch then
head = hl(config.placeholder_hl, config.icons.branch_hidden)
end
-- ...
end
Full implementation
Here you have the full statusline implemented
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
-- internal state for toggles
local state = {
show_path = true,
show_branch = true,
}
-- config for placeholders + highlighting
local config = {
icons = {
path_hidden = "",
branch_hidden = "",
},
placeholder_hl = "StatusLineDim",
}
-- helper to wrap text in a statusline highlight group
local function hl(group, text)
return string.format("%%#%s#%s%%*", group, text)
end
-- create and link the highlight group(s)
vim.api.nvim_set_hl(0, config.placeholder_hl, {}) -- create if missing
vim.api.nvim_set_hl(0, config.placeholder_hl, { link = "Comment" })
local function filepath()
local fpath = vim.fn.fnamemodify(vim.fn.expand "%", ":~:.:h")
if fpath == "" or fpath == "." then
return ""
end
if state.show_path then
return string.format("%%<%s/", fpath)
end
return hl(config.placeholder_hl, config.icons.path_hidden .. "/")
end
local function git()
local git_info = vim.b.gitsigns_status_dict
if not git_info or git_info.head == "" then
return ""
end
local head = git_info.head
local added = git_info.added and (" +" .. git_info.added) or ""
local changed = git_info.changed and (" ~" .. git_info.changed) or ""
local removed = git_info.removed and (" -" .. git_info.removed) or ""
if git_info.added == 0 then added = "" end
if git_info.changed == 0 then changed = "" end
if git_info.removed == 0 then removed = "" end
if not state.show_branch then
head = hl(config.placeholder_hl, config.icons.branch_hidden)
end
return table.concat({
"[ ",
head,
added, changed, removed,
"]",
})
end
Statusline = {}
function Statusline.active()
return table.concat {
"[", filepath(), "%t] ",
git(),
"%=",
"%y [%P %l:%c]"
}
end
function Statusline.inactive()
return " %t"
end
function Statusline.toggle_path()
state.show_path = not state.show_path
vim.cmd("redrawstatus")
end
function Statusline.toggle_branch()
state.show_branch = not state.show_branch
vim.cmd("redrawstatus")
end
vim.keymap.set("n", "<leader>sp", function() Statusline.toggle_path() end, { desc = "Toggle statusline path" })
vim.keymap.set("n", "<leader>sb", function() Statusline.toggle_branch() end, { desc = "Toggle statusline git branch" })
vim.api.nvim_exec2([[
augroup Statusline
au!
au WinEnter,BufEnter * setlocal statusline=%!v:lua.Statusline.active()
au WinLeave,BufLeave * setlocal statusline=%!v:lua.Statusline.inactive()
augroup END
]], {})
Conclusion
As you can see, customizing your statusline in Neovim is not that difficult. It might take a while, but you use Neovim, you like to spend time doing this kind of things 😉.
Leave a comment 💬 and/or a reaction 🎉 if you liked it, and share it 🙌 if you think it’s usefull.
Thanks for reading so far!
Links
- nvim (my Neovim configuration).
- gitsigns.nvim