Photo by Dries Augustyns on Unsplash
A long time passed since I wrote of my approach of migrating from VSCode to Neovim. That article saw its birth just after an intense first round of wrestling with the terminal editor as a developer with a traditional IDE experience.
I was happy with the results. In fact, I managed to code with reasonable speed in a matter of weeks.
The early achievements left me excited, with many ideas for improvements and countless doubts on how to achieve many of them. That must be the general feeling of most Vim/Neovim new adopters.
Today I am sharing how I continued to enhance my configuration, grouping related functionalities into domains and organising my modules in a tree structure. I am also showcasing a few non trivial custom integrations targeted at improving the experience of using the terminal editor.
Solving the right problem
While developing a (Neo)Vim configuration, one experiences being end user and solution provider at the same time. It is not by chance that most vim plugins used to be pieces of user configuration refined over time, later on made independent and packaged for sharing.
It made sense then to lay a list of personal pain points and expectations to face as challenges to solve while extending my configuration. The list came on the back of the configuration first pass, with all its horrific pragmatic compromises.
Challenge #1: Code split
When it comes to code organisation most configurations I have seen do one of the following: they either put everything inside one big file or they split into multiple files having one of them dedicated to global options, one to plugins management, one to each plugin’s setup, one to keyboard mappings, and so on.
Having a single file allows to manage everything in one place, although it soon becomes uneasy to maintain. Dividing seems to make sense, but the mentioned split criteria forces to make changes in multiple locations whenever a tweak is needed.
I opted instead for organising my setup by grouping functionalities based on their coupling, following an approach inspired by DDD.
Correlated plugins, their declarations, setup and associated customisations ends up arranged in their own module, separated from other ones containing unrelated sets of configurations.
Here is the Editor module as example:
-- ~/.config/nvim/lua/editor/init.lua
local Module = require("_shared.module")
---@class Editor
local Editor = {}
Editor.modules = {
"editor.syntax",
"editor.language",
"editor.completion",
"editor.spelling",
}
Editor.plugins = {
-- Comments
"b3nj5m1n/kommentary",
-- Autoclosing pair of chars
"windwp/nvim-autopairs",
-- Parentheses, brackets, quotes, XML tags
"tpope/vim-surround",
-- Change case and handles variants of a word
"tpope/vim-abolish",
-- additional operator targets
"wellle/targets.vim",
-- Highlighting command ranges
{ "winston0410/range-highlight.nvim", requires = "winston0410/cmd-parser.nvim" },
}
function Editor:setup()
-- Kommentary
vim.g.kommentary_create_default_mappings = false
-- Range highlight
require("range-highlight").setup()
-- ...
end
return Module:new(Editor)
Defining the correct number of domains and their boundaries is one of the most challenging aspects of similar architecture choices.
At the time of writing, modules organisation looks like the following:
config
├─ editor
│ ├─ completion
│ ├─ language
│ ├─ spelling
│ ├─ syntax
├─ finder
│ ├─ list
│ ├─ picker
├─ interface
│ ├─ tab
│ ├─ theme
│ ├─ window
├─ project
│ ├─ git
│ ├─ github
│ ├─ tree
├─ terminal
├─ _shared
I am not following DDD principles strictly and I am instead breaking several of them. For example domain consumers reach directly for subdomains.
There is a hierarchical relationship among domains and subdomains: all modules are logically tied together in a tree structure thanks to the Module
class every configuration node is an instance of:
-- ~/.config/nvim/lua/_shared/module.lua
---Represents a configuration module
---@class Module
local Module = {}
--- Module specific plugins
Module.plugins = {}
--- Module submodules
Module.modules = {}
--- Module specific configurations
Module.setup = function() end
--- Initializes the module
function Module:init()
self:setup()
for _, child_module_name in ipairs(self.modules) do
local child_module = require(child_module_name)
child_module:init()
end
end
---@generic M
---@type fun(module: M): Module | M
function Module:new(m)
setmetatable(m, self)
self.__index = self
-- Sourcing submodules
for _, child_module_name in ipairs(m.modules) do
require(child_module_name)
end
return m
end
return Module
When the configuration gets sourced, the root config module is initialised, initialising in turn all other modules in recursive fashion:
-- ~/.config/nvim/init.lua
local config = require("config")
config:init()
The tree logical organisation also solves one of the major challenges presented by placing plugins declarations in different files.
Packer.nvim prescribes all plugins to be registered in a single startup call. I couldn’t therefore let modules register them independently.
Instead, I leveraged tree search to gather all plugins declarations in a single place:
-- ~/.config/nvim/lua/_shared/module.lua
---Represents a configuration module
---@class Module
local Module = {}
-- ...
--- Returns a flat list of all the plugins used by the module and by its children
function Module:list_plugins()
local plugins = vim.deepcopy(self.plugins)
local child_modules = self.modules
for _, child_module_name in ipairs(child_modules) do
local child_module = require(child_module_name)
local child_module_plugins = child_module:list_plugins()
for _, child_module_plugin in ipairs(child_module_plugins) do
table.insert(plugins, child_module_plugin)
end
end
return plugins
end
return Module
The root config module takes care of installation procedures and feeding the list of plugins to packer by overriding the init
method:
-- ~/.config/nvim/lua/config.lua
local Module = require("_shared.module")
local installed = nil
local install_path = vim.fn.stdpath("data") .. "/site/pack/packer/start/packer.nvim"
local config_files = vim.fn.expand("~", false) .. "/.config/nvim/**/*"
local Config = {}
Config.plugins = {
"wbthomason/packer.nvim",
}
Config.modules = {
"interface",
"project",
"editor",
"finder",
"terminal",
}
function Config:init()
-- Checking packer install location
installed = vim.fn.empty(vim.fn.glob(install_path)) == 0
-- Cloning packer in place if it is not found
if not installed then
print("Installing plugins...")
vim.fn.execute("!git clone https://github.com/wbthomason/packer.nvim " .. install_path)
vim.cmd([[packadd packer.nvim]])
end
-- Registering plugins to use
require("packer").startup(function(use)
use(self:list_plugins())
end)
-- Installing plugins
if not installed then
local group = vim.api.nvim_create_augroup("OnPackerSyncComplete", { clear = true, once = true })
vim.api.nvim_create_autocmd("User", {
group = group,
pattern = "PackerComplete",
callback = function()
self:init()
vim.cmd("packloadall!")
end,
})
require("packer").sync()
return
end
self:setup()
for _, child_module_name in ipairs(self.modules) do
local child_module = require(child_module_name)
child_module:init()
end
end
function Config:setup()
-- ...
end
return Module:new(Config)
When Neovim is launched, the root module detects whether it is needed to perform a full installation by checking for the presence of packer. That happens before any other module is initialised, avoiding errors given by uninstalled dependencies. Thanks to the PackerComplete
command exposed by the plugin manager, it is possible to delay the actual initialisation until plugins are installed and ready. Nice.
The final goal remains to have related plugins and associated setup in a single file. When any customisation tweaks will be needed, all relevant code will be found in the same location.
It results trivial to add, remove and refactor config modules as their content is mostly independent from other modules.
Challenge #2: Shared settings
Modules split and tree structure deal with plugins and related customisations.
Neovim options represent instead a shared concern for all configuration domains. Nonetheless, options are expected to be found and managed in a single location because many of them interacts with one another.
The same can be stated for user keymaps as it is generally needed to avoid overlapping and to find consistent patterns to facilitate mnemonics.
It is possible to centralise global options and keymaps through a dedicated interface. Even selected plugin options benefit from being managed there, allowing to tweak them without changing associated modules:
-- ~/.config/nvim/lua/settings.lua
local Settings = {}
Settings._globals = {
-- Asking for confirmation instead of just failing certain commands
confirm = true,
-- Incremental live completion (note: this is now a default on master)
inccommand = "nosplit",
-- Set highlight on search
hlsearch = true,
-- Highlighting the cursor line
cul = true,
-- Avoid rerendering during macros, registers etc
lazyredraw = true,
-- ...
}
function Settings.globals(globals)
if not globals then
return Settings._globals
end
Settings._globals = vim.tbl_extend("force", Settings._globals, globals)
for option_name, option_value in pairs(Settings._globals) do
vim.opt[option_name] = option_value
end
return Settings._globals
end
Settings._keymaps = {
leader = " ",
-- Buffers navigation
["buffer.next"] = "<A-Tab>",
["buffer.prev"] = "<A-S-Tab>",
-- Write only if changed
["buffer.save"] = "<leader>w",
-- Write all
["buffer.save.all"] = "<leader>W",
-- Quit (or close window)
["buffer.close"] = "<leader>q",
-- Delete buffer
["buffer.close.delete"] = "<leader>Q",
-- ...
}
Settings.keymaps = validator.f.arguments({
validator.f.optional(validator.f.shape({
leader = validator.f.optional("string"),
})),
}) .. function(keymaps)
if not keymaps then
return Settings._keymaps
end
Settings._keymaps = vim.tbl_extend("force", Settings._keymaps, keymaps)
return Settings._keymaps
end
Settings._options = {
["language.parsers"] = {},
["language.servers"] = {},
["theme.colorscheme"] = "nightfox",
["theme.component_separator"] = "│",
["theme.section_separator"] = "█",
["terminal.jobs"] = {},
}
Settings.options = function(options)
if not options then
return Settings._options
end
Settings._options = vim.tbl_extend("force", Settings._options, options)
return Settings._options
end
return setmetatable(Settings, {
__call = function(self, settings)
local globals = settings.globals or {}
local keymaps = settings.keymaps or {}
local options = settings.options or {}
self.globals(globals)
self.keymaps(keymaps)
self.options(options)
return self
end,
})
Configuration modules consume globals, keymaps and plugin options by using provided getters. The idea is to decouple customisation logics from raw values:
-- ~/.config/nvim/lua/editor/init.lua
local Module = require("_shared.module")
local settings = require("settings")
---@class Editor
local Editor = {}
-- ...
function Editor:setup()
-- ...
local keymaps = settings.keymaps()
vim.keymap.set(
"n",
keymaps["buffer.next"],
":bnext<Cr>",
{ noremap = true, silent = true }
)
-- ...
end
return Module:new(Editor)
Shared settings are handled independently and initialised just before modules are.
The existence of a dedicated interface enables injecting overrides from external sources, effectively simulating the behaviour of software that allow to manage user preferences using a dedicated user settings file:
-- ~/.config/nvim/init.lua
local settings = require("settings")
local config = require("config")
-- User settings
local custom_settings_file = vim.fn.stdpath("config") .. "/settings.lua"
local custom_settings = vim.fn.filereadable(custom_settings_file) == 1 and dofile(custom_settings_file) or {}
settings(custom_settings)
-- Initialisation
config:init()
I didn’t yet dig deeper into all possibilities unlocked by the use of a settings file.
An idea that comes to mind is to give the chance of overriding values depending on the working directory path, allowing to define different setups for different projects.
Challenge #3: Validation
Lua is a powerful little language. Despite its simplicity, it provides all building blocks required to replicate advanced constructs.
The only feature I miss dearly when coding in Lua is a type system.
Knowing whether a specific method was called with an incorrect signature before appreciating the code blowing up at runtime is quite valuable. Also, the process of debugging without the support of a type system can be sometimes more than frustrating.
In dynamically typed language contexts the situation can be mitigated by configuring a language server which will provide diagnostics either by inferring types from code or parsing purposely written annotations.
Besides, runtime validation is often applied by developers when needed. Neovim specifically offers vim.validate
api for validation.
/usr/local/Cellar/neovim/0.8.1/share/nvim/runtime/doc/lua.txt
validate({opt})
Validates a parameter specification (types and values).
Usage example:
function user.new(name, age, hobbies)
vim.validate{
name={name, 'string'},
age={age, 'number'},
hobbies={hobbies, 'table'},
}
...
end
There are two main factors making my eye twitch when validation is applied this way.
Firstly, that is an imperative approach and details of arguments validation ends up mixed with logics consuming those.
Secondly, the signature of vim.validate
is inconsistent and can be called in different ways:
/usr/local/Cellar/neovim/0.8.1/share/nvim/runtime/doc/lua.txt
Parameters:
• {opt} (table) Names of parameters to validate. Each key is a
parameter name; each value is a tuple in one of these forms:
1. (arg_value, type_name, optional)
• arg_value: argument value
• type_name: string|table type name, one of: ("table", "t",
"string", "s", "number", "n", "boolean", "b", "function",
"f", "nil", "thread", "userdata") or list of them.
• optional: (optional) boolean, if true, `nil` is valid
2. (arg_value, fn, msg)
• arg_value: argument value
• fn: any function accepting one argument, returns true if
and only if the argument is valid. Can optionally return
an additional informative error message as the second
returned value.
• msg: (optional) error string if validation fails
Whilst this was possibly done to offer more flexibility, I preferred instead to create my own wrapper around it with the purpose of unifying validation apis and providing a declarative interface to consumers.
Decorators are a good candidate for applying arguments validation without modifying decorated functions. Lua doesn’t offer decorators natively, but it is relatively easy to implement them using metamethods. I decided to use __concat
, which visually results in concatenating arguments validations to their target functions:
-- ~/.config/nvim/lua/_shared/key.lua
local validator = require("_shared.validator")
local Key = {}
-- ...
--- Replaces terminal codes and keycodes (<CR>, <Esc>, ...) in a string with the internal representation.
---@type fun(keys: string)
Key.to_term_code = validator.f.arguments({ "string" })
.. function(keys)
return vim.api.nvim_replace_termcodes(keys, true, true, true)
end
--- Characters in keys are queued for processing as if they
--- come from a mapping or were typed by the user.
--- Replaces terminal codes and keycodes (<CR>, <Esc>, ...) in a
--- string with the internal representation.
---@type fun(keys: string, mode?: string): number
Key.input = validator.f.arguments({ "string", validator.f.optional(validator.f.pattern("^[mntix!]+$")) })
.. function(keys, input_mode)
local mode = input_mode or "n" -- Noremap mode by default
return Key.feed(Key.to_term_code(keys), mode)
end
return Key
Validations are defined in schema-inspired fashion by using validator.f.arguments
, which in turn accepts a list of validators mapped to function arguments by order.
Decorating a function with validation ultimately means to wrap it in another function which will intercepts its arguments performing assertions on them, either throwing an exception or eventually calling the decorated function with valid arguments:
-- ~/.config/nvim/lua/_shared/validator.lua
local Validator = {}
Validator.f = {
-- ...
---@class ArgumentsValidator
---@operator concat:function
---@operator call:function
---Generates a function decorator which validates arguments passed to the decorated function
---@param arguments_validators table validators to use for function arguments
---@param error_message? string error message thrown
---@return ArgumentsValidator
arguments = function(arguments_validators, error_message)
error_message = error_message or "Arguments validation error: %s"
local validate_arguments = Validator.f.list(arguments_validators)
return setmetatable({
decorate = function(func)
return function(...)
local valid, validation_error = validate_arguments({ ... })
if not valid then
error(string.format(error_message, validation_error))
end
return func(...)
end
end,
}, {
__call = function(self, ...)
return self.decorate(...)
end,
__concat = function(self, ...)
return self.decorate(...)
end,
})
end,
}
return Validator
Validators are generated at function definition and triggered at function call. They can be composed as needed to achieve the level of validation detail required.
Besides, they can still be used imperatively for code reusability purposes or when the situation calls for it:
local validator = require("_shared.validator")
---@alias TreeNode
--- | {name: string} # Node representing the parent directory
--- | {absolute_path: string, fs_stat: { type: string } } # Node
local validate_node = validator.f.any_of({
-- Directory or file
validator.f.shape({
absolute_path = "string",
fs_stat = validator.f.shape({
type = "string",
}),
}),
-- Upper directory
validator.f.shape({
name = "string",
}),
})
-- Using the validator directly
local node = { absolute_path = "/node/path", fs_stat = { type = "file" } }
validate_node(node)
---@class Project.Tree
local Tree = {}
---@type fun(self: Project.Tree, node: TreeNode)
Tree.search_in_node = validator.f.arguments({ validator.f.equal(Tree), validate_node })
.. function(_, node)
-- ...
end
---@type fun(self: Project.Tree, node: TreeNode)
Tree.tree_actions_menu = validator.f.arguments({ validator.f.equal(Tree), validate_node })
.. function(self, node)
-- ...
end
Validation logics is now completely decoupled from function bodies. Validation rules are defined in a declarative manner, hiding technical details behind a readable interface.
In all honesty, I started abstracting a few utilities to make vim.validate
more consistent and, like it often happens, I ended up writing a small library. I am happy with it though as it currently covers any validation needs I may have and it has proven straightforward to extend.
Internal validation behaviours are a bit complex to be fully described in this article. The full code is found here, though.
Challenge #4: Experience
Neovim saw dramatic usability improvements if compared to its father Vim, although it suffers from the same lack of discoverability which makes long time users still unaware of all functionalities available to them.
That justifies the popularity of plugins like which-key, which displays a popup with possible key bindings completing a started command.
I never got around to integrate which-key in my configuration, but I longed nonetheless for having access to contextual actions without the need to remember them all.
Many software provide similar helpful capabilities by using dropdown menus so I decided to try and use telescope to build a customisable menu picker, which I could reuse whenever needed:
-- ~/.config/nvim/lua/finder/picker.lua
local Module = require("_shared.module")
local fn = require("_shared.fn")
local validator = require("_shared.validator")
---@class picker
local Picker = {}
-- ...
---@type fun(self: Picker, menu: { [number]: string, on_select: function }, options: { prompt_title: string } | nil)
Picker.menu = validator.f.arguments({
validator.f.equal(Picker),
validator.f.shape({
validator.f.list({ "string", validator.f.optional("string") }),
on_select = "function",
}),
validator.f.optional(validator.f.shape({
prompt_title = "string",
})),
})
.. function(_, menu, options)
options = options or {}
local entry_display = require("telescope.pickers.entry_display")
local displayer = entry_display.create({
separator = " ",
items = {
-- calculating the max width needed for the first column
fn.ireduce(menu, function(item, result)
item.width = math.max(item.width, #result[1])
return item
end, { width = 10 }),
{ remaining = true },
},
})
local make_display = function(entry)
return displayer({
entry.value[1],
{ entry.value[2] or "", "Comment" },
})
end
local entry_maker = function(menu_item)
return {
value = menu_item,
ordinal = menu_item[1],
display = make_display,
}
end
local finder = require("telescope.finders").new_table({
results = fn.imap(menu, function(menu_item)
return menu_item
end),
entry_maker = entry_maker,
})
local sorter = require("telescope.sorters").get_generic_fuzzy_sorter()
local default_options = {
finder = finder,
sorter = sorter,
attach_mappings = function(prompt_buffer_number)
local actions = require("telescope.actions")
local state = require("telescope.actions.state")
-- On select item
actions.select_default:replace(function()
menu.on_select({ buffer = prompt_buffer_number, state = state, actions = actions })
end)
-- Disabling any kind of multiple selection
actions.add_selection:replace(function() end)
actions.remove_selection:replace(function() end)
actions.toggle_selection:replace(function() end)
actions.select_all:replace(function() end)
actions.drop_all:replace(function() end)
actions.toggle_all:replace(function() end)
return true
end,
}
require("telescope.pickers").new(options, default_options):find()
end
---@type fun(self: Picker, menu: { [number]: string, on_select: function }, options: { prompt_title: string } | nil)
function Picker:context_menu(menu, options)
local theme = require("telescope.themes").get_cursor()
options = vim.tbl_extend("force", theme, options or {})
return Picker:menu(menu, options)
end
return Module:new(Picker)
The signature shared by menu
and context_menu
methods abstracts away the picker’s underlying requirements, allowing to pass a list of menu items alongside a callback triggered on selection.
Besides information necessary to the picker display, extra data can be attached to entries. That comes in handy when handling item selection:
-- ~/.config/nvim/lua/project/tree.lua
local Module = require("_shared.module")
local key = require("_shared.key")
local validator = require("_shared.validator")
local fn = require("_shared.fn")
local settings = require("settings")
---@class Project.Tree
local Tree = {}
-- ...
function Tree:setup()
require("nvim-tree").setup({
-- ...
on_attach = function(tree_buffer)
local actions = self:actions()
local mappings = fn.imap(actions, function(action)
return { action.keymap, action.handler, buffer = tree_buffer }
end)
key.nmap(unpack(mappings))
key.nmap({
keymaps["dropdown.open"],
require("nvim-tree.utils").inject_node(fn.bind(self.tree_actions_menu, self)),
buffer = tree_buffer
})
end,
})
end
function Tree:actions()
local keymaps = settings.keymaps()
return {
{
name = "Edit in vertical split",
keymap = keymaps["project.tree.node.open.vertical"],
handler = require("nvim-tree.api").node.open.vertical,
},
{
name = "Edit in horizontal split",
keymap = keymaps["project.tree.node.open.horizontal"],
handler = require("nvim-tree.api").node.open.horizontal,
},
{
name = "Edit in tab",
keymap = keymaps["project.tree.node.open.tab"],
handler = require("nvim-tree.api").node.open.tab,
},
-- ...
}
end
---@type fun(self: Project.Tree, node: TreeNode)
Tree.tree_actions_menu = validator.f.arguments({ validator.f.equal(Tree), validate_node })
.. function(self, node)
local actions = self:actions()
local menu = vim.tbl_extend(
"error",
fn.imap(actions, function(action)
return { action.name, action.keymap, handler = action.handler }
end),
{
on_select = function(context_menu)
local selection = context_menu.state.get_selected_entry()
context_menu.actions.close(context_menu.buffer)
selection.value.handler(node)
end,
}
)
local options = { prompt_title = node.name }
require("finder.picker"):context_menu(menu, options)
end
return Module:new(Tree)
The code example above showcases opening a context menu containing nvim-tree actions:
By the way, nvim-tree.utils
inject_node
is a higher order function which passes data of the node found at cursor position into the received function, making keymap callbacks potentially capable of dynamically change the menu depending on it.
Likewise, it can be used to add behaviours not initially present among the tree explorer default actions:
-- ~/.config/nvim/lua/project/tree.lua
local Module = require("_shared.module")
local key = require("_shared.key")
local validator = require("_shared.validator")
local fn = require("_shared.fn")
local settings = require("settings")
---@class Project.Tree
local Tree = {}
-- ...
function Tree:setup()
require("nvim-tree").setup({
-- ...
on_attach = function(tree_buffer)
local actions = self:actions()
local mappings = fn.imap(actions, function(action)
return { action.keymap, action.handler, buffer = tree_buffer }
end)
key.nmap(unpack(mappings))
key.nmap({
keymaps["dropdown.open"],
require("nvim-tree.utils").inject_node(fn.bind(self.tree_actions_menu, self)),
buffer = tree_buffer
})
end,
})
end
function Tree:actions()
local keymaps = settings.keymaps()
return {
-- ...
{
name = "Search node contents",
keymap = keymaps["project.tree.search.node.content"],
handler = require("nvim-tree.utils").inject_node(fn.bind(self.search_in_node, self)),
},
-- ...
}
end
---@type fun(self: Project.Tree, node: TreeNode)
Tree.search_in_node = validator.f.arguments({ validator.f.equal(Tree), validate_node })
.. function(_, node)
-- when the selected node is the one pointing at the parent directory, absolute_path will not be present
if not node.absolute_path then
return require("finder.picker"):text()
end
-- live_grep in highlighted directory
if node.fs_stat.type == "directory" then
return require("finder.picker"):text(node.absolute_path)
end
-- open file and current_buffer_fuzzy_find
if node.fs_stat.type == "file" then
require("nvim-tree.actions.node.open-file").fn("edit_in_place", node.absolute_path)
return require("finder.picker"):buffer_text()
end
end
return Module:new(Tree)
In such manner it is trivial to integrate between nvim-tree and telescope built-in pickers. The example uses one keymap only, but the handler acts differently based on the currently highlighted node. If the cursor is on a directory the live grep picker will be displayed to search within the folder. If the cursor is on a file instead its buffer will be opened and the current buffer fuzzy find picker displayed.
A gif is worth a thousand words:
Grouping related functionalities together helps making them more accessible at the cost of adding just a few keystrokes.
After gloating for a while looking at the completed context menu implementation, it stroked me that the same idea could be scaled to menus themselves.
I could gloat even more if I managed to provide an interface to create “tabbed” collections of pickers and navigate them reusing the same keymaps used to navigate buffers:
-- ~/.config/nvim/lua/finder/picker.lua
local Module = require("_shared.module")
local key = require("_shared.key")
local fn = require("_shared.fn")
local validator = require("_shared.validator")
local settings = require("settings")
---@class Finder.Picker.Tab
---@field prompt_title string
---@field find function
---@class Finder.Picker.Tabs
local Tabs = {}
---@type fun(self: Finder.Picker.Tabs, tabs: Finder.Picker.Tab[]): Finder.Picker.Tabs
Tabs.new = validator.f.arguments({
-- This would be cool but it is not possible because builtin pickers are launched directly
-- validator.f.list({ validator.f.instance_of(require("telescope.pickers")._Picker) }),
validator.f.equal(Tabs),
validator.f.list({ validator.f.shape({ prompt_title = "string", find = "function" }) }),
})
.. function(self, tabs)
tabs._current = 1
setmetatable(tabs, self)
self.__index = self
return tabs
end
function Tabs:_options()
return {
prompt_title = self:_prompt_title(),
attach_mappings = fn.bind(self._attach_mappings, self),
}
end
-- NOTE: this is a lot of code just to calculate a fancy prompt title
-- TODO: refactor?
function Tabs:_prompt_title()
local globals = settings.globals()
local current_picker_title = "[ " .. self[self._current].prompt_title .. " ]"
-- Creating a table containing all titles making up for the left half of the title
-- starting from the left half of the current picker title and looping backward
local i_left = self._current - 1
local prev_picker_titles = { string.sub(current_picker_title, 1, math.floor(#current_picker_title / 2)) }
repeat
if i_left < 1 then
i_left = #self
else
table.insert(prev_picker_titles, 1, self[i_left].prompt_title)
i_left = i_left - 1
end
until i_left == self._current
-- Creating a table containing all titles making up for the right half of the title
-- starting from the right half of the current picker title and looping onward
local i_right = self._current + 1
local next_picker_titles = {
string.sub(current_picker_title, (math.floor(#current_picker_title / 2)) + 1, #current_picker_title),
}
repeat
if i_right > #self then
i_right = 1
else
table.insert(next_picker_titles, self[i_right].prompt_title)
i_right = i_right + 1
end
until i_right == self._current
-- Merging left and right, capping at 40 chars length
local prompt_title_left = string.reverse(
string.sub(string.reverse(table.concat(prev_picker_titles, " ")), 1, (20 - #globals.listchars.precedes))
)
local prompt_title_right = string.sub(table.concat(next_picker_titles, " "), 1, (20 - #globals.listchars.extends))
local prompt_title = globals.listchars.precedes
.. prompt_title_left
.. prompt_title_right
.. globals.listchars.extends
return prompt_title
end
---@param buffer number
---@return boolean
function Tabs:_attach_mappings(buffer)
local keymaps = settings.keymaps()
key.imap({
keymaps["buffer.next"],
fn.bind(self.next, self),
buffer = buffer,
}, {
keymaps["buffer.prev"],
fn.bind(self.prev, self),
buffer = buffer,
})
return true
end
function Tabs:prev()
self._current = self._current <= 1 and #self or self._current - 1
local options = self:_options()
local picker = self[self._current]
return picker.find(options)
end
function Tabs:next()
self._current = self._current >= #self and 1 or self._current + 1
local options = self:_options()
local picker = self[self._current]
return picker.find(options)
end
function Tabs:find()
local options = self:_options()
local picker = self[self._current]
return picker.find(options)
end
---@class Picker
local Picker = {}
-- ...
function Picker:setup()
local keymaps = settings.keymaps()
key.nmap(
-- ...
{ keymaps["find.help"], fn.bind(self.help, self) },
-- ...
)
end
function Picker:tabs(pickers)
return Tabs:new(pickers)
end
function Picker:help()
return self:tabs({
{ prompt_title = "Help", find = require("telescope.builtin").help_tags },
{ prompt_title = "Commands", find = require("telescope.builtin").commands },
{ prompt_title = "Options", find = require("telescope.builtin").vim_options },
{ prompt_title = "Autocommands", find = require("telescope.builtin").autocommands },
{ prompt_title = "Keymaps", find = require("telescope.builtin").keymaps },
{ prompt_title = "Filetypes", find = require("telescope.builtin").filetypes },
{ prompt_title = "Highlights", find = require("telescope.builtin").highlights },
}):find()
end
-- ...
return Module:new(Picker)
Similarly to what happens with custom menus, the picker tabs functionality is exposed by the finder.picker
configuration module api so that other domains can consume it to build their own picker collections. Menus and tabs are compatible and one could create tabbed pickers out of custom menus.
The only argument required is the list of pickers, each described by prompt_title
and find
catering for picker’s title and launch function:
I am overall satisfied with menus and tabbed pickers solutions as they are proving useful to my development flows. Thanks to the heavy lifting done by telescope, they ease from the need of brute-force-memorising all commands by making them more accessible.
Conclusions
My main goals with Neovim revolve around making my configuration easier to maintain, scalable and having a more friendly experience while using it.
The code samples presented in this article were simplified, trying to focus them on the topics discussed. The full code is found in my dotfiles repository. Like any (Neo)Vim configuration that is permanently WIP.
Please keep in mind that I possibly spent more time than I should have on the improvements described. I am indeed treating my config almost like it was a commercial project and not my own customisation. It is likely that some of the examples will look over-engineered for the purpose of building a personal setup.
In any case, I am having plenty of fun learning about both Neovim and Lua.
If a bit of self-promotion is allowed, I maintain Sherpa as an open source project dedicated to free learning.
I collect and organise educative resources with the goal of helping people learn about dev topics, giving an alternative to paid subscriptions platforms. I also created a Neovim and Lua learning paths there! Check them out if you are interested.
Top comments (0)