Meh I'll figure out submodules later

This commit is contained in:
mustard 2025-09-16 01:01:02 +02:00
parent 4ca9d44a90
commit 8cb281f436
352 changed files with 66107 additions and 0 deletions

View file

@ -0,0 +1,114 @@
# Input
Input is an abstraction layer on top of Popup.
It uses prompt buffer (check `:h prompt-buffer`) for its popup window.
```lua
local Input = require("nui.input")
local event = require("nui.utils.autocmd").event
local popup_options = {
relative = "cursor",
position = {
row = 1,
col = 0,
},
size = 20,
border = {
style = "rounded",
text = {
top = "[Input]",
top_align = "left",
},
},
win_options = {
winhighlight = "Normal:Normal",
},
}
local input = Input(popup_options, {
prompt = "> ",
default_value = "42",
on_close = function()
print("Input closed!")
end,
on_submit = function(value)
print("Value submitted: ", value)
end,
on_change = function(value)
print("Value changed: ", value)
end,
})
```
If you provide the `on_change` function, it'll be run everytime value changes.
Pressing `<CR>` runs the `on_submit` callback function and closes the window.
Pressing `<C-c>` runs the `on_close` callback function and closes the window.
Of course, you can override the default keymaps and add more. For example:
```lua
-- unmount input by pressing `<Esc>` in normal mode
input:map("n", "<Esc>", function()
input:unmount()
end, { noremap = true })
```
You can manipulate the associated buffer and window using the
`input.bufnr` and `input.winid` properties.
**NOTE**: the first argument accepts options for `nui.popup` component.
## Options
### `prompt`
**Type:** `string` or `NuiText`
Prefix in the input.
### `default_value`
**Type:** `string`
Default value placed in the input on mount
### `on_close`
**Type:** `function`
_Signature:_ `on_close() -> nil`
Callback function, called when input is closed.
### `on_submit`
**Type:** `function`
_Signature:_ `on_submit(value: string) -> nil`
Callback function, called when input value is submitted.
### `on_change`
**Type:** `function`
_Signature:_ `on_change(value: string) -> nil`
Callback function, called when input value is changed.
### `disable_cursor_position_patch`
By default, `nui.input` will try to make sure the cursor on parent window is not
moved after input is submitted/closed. If you want to disable this behavior
for some reason, you can set `disable_cursor_position_patch` to `true`.
## Methods
Methods from `nui.popup` are also available for `nui.input`.
## Wiki Page
You can find additional documentation/examples/guides/tips-n-tricks in [nui.input wiki page](https://github.com/MunifTanjim/nui.nvim/wiki/nui.input).

View file

@ -0,0 +1,174 @@
local Popup = require("nui.popup")
local Text = require("nui.text")
local defaults = require("nui.utils").defaults
local is_type = require("nui.utils").is_type
local event = require("nui.utils.autocmd").event
-- exiting insert mode places cursor one character backward,
-- so patch the cursor position to one character forward
-- when unmounting input.
---@param target_cursor number[]
---@param force? boolean
local function patch_cursor_position(target_cursor, force)
local cursor = vim.api.nvim_win_get_cursor(0)
if target_cursor[2] == cursor[2] and force then
-- didn't exit insert mode yet, but it's gonna
vim.api.nvim_win_set_cursor(0, { cursor[1], cursor[2] + 1 })
elseif target_cursor[2] - 1 == cursor[2] then
-- already exited insert mode
vim.api.nvim_win_set_cursor(0, { cursor[1], cursor[2] + 1 })
end
end
---@class nui_input_options
---@field prompt? string|NuiText
---@field default_value? string
---@field on_change? fun(value: string): nil
---@field on_close? fun(): nil
---@field on_submit? fun(value: string): nil
---@class nui_input_internal: nui_popup_internal
---@field default_value string
---@field prompt NuiText
---@field disable_cursor_position_patch boolean
---@field on_change? fun(value: string): nil
---@field on_close fun(): nil
---@field on_submit fun(value: string): nil
---@field pending_submit_value? string
---@class NuiInput: NuiPopup
---@field private _ nui_input_internal
local Input = Popup:extend("NuiInput")
---@param popup_options nui_popup_options
---@param options nui_input_options
function Input:init(popup_options, options)
popup_options.enter = false
popup_options.buf_options = defaults(popup_options.buf_options, {})
popup_options.buf_options.buftype = "prompt"
if not is_type("table", popup_options.size) then
popup_options.size = {
width = popup_options.size,
}
end
popup_options.size.height = 1
Input.super.init(self, popup_options)
self._.default_value = defaults(options.default_value, "")
self._.prompt = Text(defaults(options.prompt, ""))
self._.disable_cursor_position_patch = defaults(options.disable_cursor_position_patch, false)
self.input_props = {}
self._.on_change = options.on_change
self._.on_close = options.on_close or function() end
self._.on_submit = options.on_submit or function() end
end
function Input:mount()
local props = self.input_props
if self._.mounted then
return
end
vim.fn.prompt_setprompt(self.bufnr, self._.prompt:content())
vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, false, { self._.prompt:content() .. self._.default_value })
self:on(event.BufWinEnter, function()
vim.schedule(function()
if self._.prompt:length() > 0 then
self._.prompt:highlight(self.bufnr, self.ns_id, 1, 0)
end
vim.api.nvim_set_current_win(self.winid)
end)
vim.api.nvim_command("startinsert!")
end, { once = false })
Input.super.mount(self)
if self._.on_change then
---@deprecated
props.on_change = function()
local value_with_prompt = vim.api.nvim_buf_get_lines(self.bufnr, 0, 1, false)[1]
local value = string.sub(value_with_prompt, self._.prompt:length() + 1)
self._.on_change(value)
end
vim.api.nvim_buf_attach(self.bufnr, false, {
on_lines = props.on_change,
})
end
---@deprecated
props.on_submit = function(value)
self._.pending_submit_value = value
self:unmount()
end
vim.fn.prompt_setcallback(self.bufnr, props.on_submit)
-- @deprecated
--- Use `input:unmount`
---@deprecated
props.on_close = function()
self:unmount()
end
vim.fn.prompt_setinterrupt(self.bufnr, props.on_close)
end
function Input:unmount()
if not self._.mounted then
return
end
local container_winid = self._.container_info.winid
local target_cursor = vim.api.nvim_win_is_valid(container_winid) and vim.api.nvim_win_get_cursor(container_winid)
or nil
local prompt_mode = vim.fn.mode()
Input.super.unmount(self)
if self._.loading then
return
end
self._.loading = true
local pending_submit_value = self._.pending_submit_value
vim.schedule(function()
-- NOTE: on prompt-buffer normal mode <CR> causes neovim to enter insert mode.
-- ref: https://github.com/neovim/neovim/blob/d8f5f4d09078/src/nvim/normal.c#L5327-L5333
if (pending_submit_value and prompt_mode == "n") or prompt_mode == "i" then
vim.api.nvim_command("stopinsert")
end
if not self._.disable_cursor_position_patch and target_cursor ~= nil then
patch_cursor_position(target_cursor, pending_submit_value and prompt_mode == "n")
end
if pending_submit_value then
self._.pending_submit_value = nil
self._.on_submit(pending_submit_value)
else
self._.on_close()
end
self._.loading = false
end)
end
---@alias NuiInput.constructor fun(popup_options: nui_popup_options, options: nui_input_options): NuiInput
---@type NuiInput|NuiInput.constructor
local NuiInput = Input
return NuiInput

View file

@ -0,0 +1,307 @@
# Layout
Layout is a helper component for creating complex layout by automatically
handling the calculation for position and size of other components.
**Example**
```lua
local Layout = require("nui.layout")
local Popup = require("nui.popup")
local top_popup = Popup({ border = "double" })
local bottom_left_popup = Popup({ border = "single" })
local bottom_right_popup = Popup({ border = "single" })
local layout = Layout(
{
position = "50%",
size = {
width = 80,
height = 40,
},
},
Layout.Box({
Layout.Box(top_popup, { size = "40%" }),
Layout.Box({
Layout.Box(bottom_left_popup, { size = "50%" }),
Layout.Box(bottom_right_popup, { size = "50%" }),
}, { dir = "row", size = "60%" }),
}, { dir = "col" })
)
layout:mount()
```
_Signature:_ `Layout(options, box)` or `Layout(component, box)`
`component` can be `Popup` or `Split`.
## Options (for float layout)
### `anchor`
**Type:** `"NW"` / `"NE"` / `"SW"` / `"SE"`
Decides which corner of the layout to place at `position`.
---
### `relative`
**Type:** `string` or `table`
This option affects how `position` and `size` are calculated.
**Examples**
Relative to cursor on current window:
```lua
relative = "cursor",
```
Relative to the current editor screen:
```lua
relative = "editor",
```
Relative to the current window (_default_):
```lua
relative = "win",
```
Relative to the window with specific id:
```lua
relative = {
type = "win",
winid = 5,
},
```
Relative to the buffer position:
```lua
relative = {
type = "buf",
-- zero-indexed
position = {
row = 5,
col = 5,
},
},
```
---
### `position`
**Type:** `number` or `percentage string` or `table`
Position is calculated from the top-left corner.
If `position` is `number` or `percentage string`, it applies to both `row` and `col`.
Or you can pass a table to set them separately.
For `percentage string`, position is calculated according to the option `relative`.
If `relative` is set to `"buf"` or `"cursor"`, `percentage string` is not allowed.
**Examples**
```lua
position = 50,
```
```lua
position = "50%",
```
```lua
position = {
row = 30,
col = 20,
},
```
```lua
position = {
row = "20%",
col = "50%",
},
```
---
### `size`
**Type:** `number` or `percentage string` or `table`
Determines the size of the layout.
If `size` is `number` or `percentage string`, it applies to both `width` and `height`.
You can also pass a table to set them separately.
For `percentage string`, `size` is calculated according to the option `relative`.
If `relative` is set to `"buf"` or `"cursor"`, window size is considered.
Decimal `number` in `(0,1)` range is treated similar to `percentage string`. For
example: `0.5` is same as `"50%"`.
**Examples**
```lua
size = 50,
```
```lua
size = "50%",
```
```lua
size = 0.5,
```
```lua
size = {
width = 80,
height = 40,
},
```
```lua
size = {
width = "80%",
height = 0.6,
},
```
## Options (for split layout)
### `relative`
**Type:** `string` or `table`
This option affects how `size` is calculated.
**Examples**
Split current editor screen:
```lua
relative = "editor"
```
Split current window (_default_):
```lua
relative = "win"
```
Split window with specific id:
```lua
relative = {
type = "win",
winid = 42,
}
```
---
### `position`
**Type:** `"top" | "right"| "bottom" | "left"`.
---
### `size`
**Type:** `number` or `percentage string`
Determines the size of the layout.
For `percentage string`, size is calculated according to the option `relative`.
## Layout.Box
_Signature:_ `Layout.Box(box, options)`
**Parameters**
| Name | Type | Description |
| --------- | ------------------------------ | ----------------------------------------- |
| `box` | `Layout.Box[]` / nui component | list of `Layout.Box` or any nui component |
| `options` | `table` | box options |
`options` is a `table` having the following keys:
| Key | Type | Description |
| ------ | ----------------------------- | ------------------------------------------------------ |
| `dir` | `"col"` / `"row"` (_default_) | arrangement direction, only if `box` is `Layout.Box[]` |
| `grow` | `number` | growth factor to fill up the box free space |
| `size` | `number` / `string` / `table` | optional if `grow` is present |
## Methods
### `layout:mount`
_Signature:_ `layout:mount()`
Mounts the layout with all the components.
**Examples**
```lua
layout:mount()
```
### `layout:unmount`
_Signature:_ `layout:unmount()`
Unmounts the layout with all the components.
**Examples**
```lua
layout:unmount()
```
### `layout:hide`
_Signature:_ `layout:hide()`
Hides the layout with all the components. Preserves the buffer (related content, autocmds and keymaps).
### `layout:show`
_Signature:_ `layout:show()`
Shows the hidden layout with all the components.
### `layout:update`
_Signature:_ `layout:update(config, box?)` or `layout:update(box?)`
**Parameters**
`config` is a `table` having the following keys:
| Key | Type |
| ---------- | --------------------------------- |
| `anchor` | `"NW"` / `"NE"` / `"SW"` / `"SE"` |
| `relative` | `string` / `table` |
| `position` | `string` / `table` |
| `size` | `string` / `table` |
`box` is a `table` returned by `Layout.Box`.
They are the same options used for layout initialization.
## Wiki Page
You can find additional documentation/examples/guides/tips-n-tricks in
[nui.layout wiki page](https://github.com/MunifTanjim/nui.nvim/wiki/nui.layout).

View file

@ -0,0 +1,239 @@
local utils = require("nui.utils")
local layout_utils = require("nui.layout.utils")
local u = {
is_type = utils.is_type,
calculate_window_size = layout_utils.calculate_window_size,
}
local mod = {}
local function get_child_position(box, child, current_position, canvas_position)
local position = box.dir == "row" and {
row = canvas_position.row,
col = current_position.col,
} or {
col = canvas_position.col,
row = current_position.row,
}
if child.component then
local border = child.component.border
if border and border._.type == "complex" then
position.col = position.col + math.floor(border._.size_delta.width / 2 + 0.5)
position.row = position.row + math.floor(border._.size_delta.height / 2 + 0.5)
end
end
return position
end
---@param parent table Layout.Box
---@param child table Layout.Box
---@param container_size table
---@param growable_dimension_per_factor? number
local function get_child_size(parent, child, container_size, growable_dimension_per_factor)
local child_size = {
width = child.size.width,
height = child.size.height,
}
if child.grow and growable_dimension_per_factor then
if parent.dir == "col" then
child_size.height = math.floor(growable_dimension_per_factor * child.grow)
else
child_size.width = math.floor(growable_dimension_per_factor * child.grow)
end
end
local outer_size = u.calculate_window_size(child_size, container_size)
local inner_size = {
width = outer_size.width,
height = outer_size.height,
}
if child.component then
if child.component.border then
inner_size.width = inner_size.width - child.component.border._.size_delta.width
inner_size.height = inner_size.height - child.component.border._.size_delta.height
if inner_size.height <= 0 then
local height_adjustment = math.abs(inner_size.height) + 1
inner_size.height = inner_size.height + height_adjustment
outer_size.height = outer_size.height + height_adjustment
end
end
end
return outer_size, inner_size
end
function mod.process(box, meta)
-- luacov: disable
if box.mount or box.component or not box.box then
return error("invalid parameter: box")
end
-- luacov: enable
local container_size = meta.container_size
-- luacov: disable
if not u.is_type("number", container_size.width) or not u.is_type("number", container_size.height) then
return error("invalid value: box.size")
end
-- luacov: enable
local current_position = box.dir == "row" and {
col = meta.position.col,
row = 0,
} or {
col = 0,
row = meta.position.row,
}
local growable_child_factor = 0
for _, child in ipairs(box.box) do
if meta.process_growable_child or not child.grow then
local position = get_child_position(box, child, current_position, meta.position)
local outer_size, inner_size = get_child_size(box, child, container_size, meta.growable_dimension_per_factor)
if child.component then
child.component:set_layout({
size = inner_size,
relative = {
type = "win",
winid = meta.winid,
},
position = position,
})
else
mod.process(child, {
winid = meta.winid,
container_size = outer_size,
position = position,
})
end
current_position.col = current_position.col + outer_size.width
current_position.row = current_position.row + outer_size.height
end
if child.grow then
growable_child_factor = growable_child_factor + child.grow
end
end
if meta.process_growable_child or growable_child_factor == 0 then
return
end
local growable_width = container_size.width - current_position.col
local growable_height = container_size.height - current_position.row
local growable_dimension = box.dir == "col" and growable_height or growable_width
local growable_dimension_per_factor = growable_dimension / growable_child_factor
mod.process(box, {
winid = meta.winid,
container_size = meta.container_size,
position = meta.position,
process_growable_child = true,
growable_dimension_per_factor = growable_dimension_per_factor,
})
end
function mod.mount_box(box)
for _, child in ipairs(box.box) do
if child.component then
child.component:mount()
else
mod.mount_box(child)
end
end
end
---@param box table Layout.Box
function mod.show_box(box)
for _, child in ipairs(box.box) do
if child.component then
child.component:show()
else
mod.show_box(child)
end
end
end
function mod.unmount_box(box)
for _, child in ipairs(box.box) do
if child.component then
child.component:unmount()
else
mod.unmount_box(child)
end
end
end
---@param box table Layout.Box
function mod.hide_box(box)
for _, child in ipairs(box.box) do
if child.component then
child.component:hide()
else
mod.hide_box(child)
end
end
end
---@param box table Layout.Box
---@return table<string, table>
local function collect_box_components(box, components)
if not components then
components = {}
end
for _, child in ipairs(box.box) do
if child.component then
components[child.component._.id] = child.component
else
collect_box_components(child, components)
end
end
return components
end
---@param curr_box table Layout.Box
---@param prev_box table Layout.Box
function mod.process_box_change(curr_box, prev_box)
if curr_box == prev_box then
return
end
local curr_components = collect_box_components(curr_box)
local prev_components = collect_box_components(prev_box)
for id, component in pairs(curr_components) do
if not prev_components[id] then
if not component.winid then
if component._.mounted then
component:show()
else
component:mount()
end
end
end
end
for id, component in pairs(prev_components) do
if not curr_components[id] then
if component._.mounted then
if component.winid then
component:hide()
end
end
end
end
end
return mod

View file

@ -0,0 +1,573 @@
local Object = require("nui.object")
local Popup = require("nui.popup")
local Split = require("nui.split")
local utils = require("nui.utils")
local layout_utils = require("nui.layout.utils")
local float_layout = require("nui.layout.float")
local split_layout = require("nui.layout.split")
local split_utils = require("nui.split.utils")
local autocmd = require("nui.utils.autocmd")
local _ = utils._
local defaults = utils.defaults
local is_type = utils.is_type
local u = {
get_next_id = _.get_next_id,
position = layout_utils.position,
size = layout_utils.size,
split = split_utils,
update_layout_config = layout_utils.update_layout_config,
}
-- GitHub Issue: https://github.com/neovim/neovim/issues/18925
local function apply_workaround_for_float_relative_position_issue_18925(layout)
local winids_len = 1
local winids = { layout.winid }
local function collect_anchor_winids(box)
for _, child in ipairs(box.box) do
if child.component then
local border = child.component.border
if border and border.winid then
winids_len = winids_len + 1
winids[winids_len] = border.winid
end
else
collect_anchor_winids(child)
end
end
end
collect_anchor_winids(layout._.box)
vim.schedule(function()
-- check in case layout was immediately hidden or unmounted
if layout.winid == winids[1] and vim.api.nvim_win_is_valid(winids[1]) then
vim.cmd(
("noa call nvim_set_current_win(%s)\nnormal! jk\nredraw\n"):rep(winids_len):format(unpack(winids))
.. ("noa call nvim_set_current_win(%s)"):format(vim.api.nvim_get_current_win())
)
end
end)
end
---@param options nui_layout_options
local function merge_default_options(options)
options.relative = defaults(options.relative, "win")
return options
end
---@param options nui_layout_options
local function normalize_options(options)
options = _.normalize_layout_options(options)
return options
end
---@return boolean
local function is_box(object)
return object and (object.box or object.component)
end
---@return boolean
local function is_component(object)
return object and object.mount
end
local function is_component_mounted(component)
return is_type("number", component.winid)
end
---@param component NuiPopup|NuiSplit
local function get_layout_config_relative_to_component(component)
return {
relative = { type = "win", winid = component.winid },
position = { row = 0, col = 0 },
size = { width = "100%", height = "100%" },
}
end
---@param layout NuiLayout
---@param box table Layout.Box
local function wire_up_layout_components(layout, box)
for _, child in ipairs(box.box) do
if child.component then
autocmd.create({ "BufWipeout", "QuitPre" }, {
group = layout._.augroup.unmount,
buffer = child.component.bufnr,
callback = vim.schedule_wrap(function()
layout:unmount()
end),
}, child.component.bufnr)
autocmd.create("BufWinEnter", {
group = layout._.augroup.unmount,
buffer = child.component.bufnr,
callback = function()
local winid = child.component.winid
if layout._.type == "float" and not winid then
--[[
`BufWinEnter` does not contain window id and
it is fired before `nvim_open_win` returns
the window id.
--]]
winid = vim.fn.bufwinid(child.component.bufnr)
end
autocmd.create("WinClosed", {
group = layout._.augroup.hide,
nested = true,
pattern = tostring(winid),
callback = function()
layout:hide()
end,
}, child.component.bufnr)
end,
}, child.component.bufnr)
else
wire_up_layout_components(layout, child)
end
end
end
---@class nui_layout_options
---@field anchor? nui_layout_option_anchor
---@field relative? nui_layout_option_relative_type|nui_layout_option_relative
---@field position? number|string|nui_layout_option_position
---@field size? number|string|nui_layout_option_size
---@class NuiLayout
local Layout = Object("NuiLayout")
---@return '"float"'|'"split"' layout_type
local function get_layout_type(box)
for _, child in ipairs(box.box) do
if child.component and child.type then
return child.type
end
local type = get_layout_type(child)
if type then
return type
end
end
error("unexpected empty box")
end
---@param options nui_layout_options|NuiPopup|NuiSplit
---@param box NuiLayout.Box|NuiLayout.Box[]
function Layout:init(options, box)
local id = u.get_next_id()
box = Layout.Box(box)
local type = get_layout_type(box)
self._ = {
id = id,
type = type,
box = box,
loading = false,
mounted = false,
augroup = {
hide = string.format("%s_hide", id),
unmount = string.format("%s_unmount", id),
},
}
if type == "float" then
local container
if is_component(options) then
container = options --[[@as NuiPopup|NuiSplit]]
options = get_layout_config_relative_to_component(container)
else
---@cast options -NuiPopup, -NuiSplit
options = merge_default_options(options)
options = normalize_options(options)
end
self._[type] = {
container = container,
layout = {},
win_enter = false,
win_config = {
border = "none",
focusable = false,
style = "minimal",
anchor = options.anchor,
zindex = 49,
},
win_options = {
winblend = 100,
},
}
if not is_component(container) or is_component_mounted(container) then
self:update(options)
end
end
if type == "split" then
options = u.split.merge_default_options(options)
options = u.split.normalize_options(options)
self._[type] = {
layout = {},
position = options.position,
size = {},
win_config = {
pending_changes = {},
},
}
self:update(options)
end
end
function Layout:_process_layout()
local type = self._.type
if type == "float" then
local info = self._.float
float_layout.process(self._.box, {
winid = self.winid,
container_size = info.size,
position = {
row = 0,
col = 0,
},
})
return
end
if type == "split" then
local info = self._.split
split_layout.process(self._.box, {
position = info.position,
relative = info.relative,
container_size = info.size,
container_fallback_size = info.container_info.size,
})
end
end
function Layout:_open_window()
if self._.type == "float" then
local info = self._.float
self.winid = vim.api.nvim_open_win(self.bufnr, info.win_enter, info.win_config)
assert(self.winid, "failed to create popup window")
_.set_win_options(self.winid, info.win_options)
end
end
function Layout:_close_window()
if not self.winid then
return
end
if vim.api.nvim_win_is_valid(self.winid) then
vim.api.nvim_win_close(self.winid, true)
end
self.winid = nil
end
function Layout:mount()
if self._.loading or self._.mounted then
return
end
self._.loading = true
local type = self._.type
if type == "float" then
local info = self._.float
local container = info.container
if is_component(container) and not is_component_mounted(container) then
container:mount()
self:update(get_layout_config_relative_to_component(container))
end
if not self.bufnr then
self.bufnr = vim.api.nvim_create_buf(false, true)
assert(self.bufnr, "failed to create buffer")
end
self:_open_window()
end
self:_process_layout()
if type == "float" then
float_layout.mount_box(self._.box)
apply_workaround_for_float_relative_position_issue_18925(self)
end
if type == "split" then
split_layout.mount_box(self._.box)
end
self._.loading = false
self._.mounted = true
end
function Layout:unmount()
if self._.loading or not self._.mounted then
return
end
pcall(autocmd.delete_group, self._.augroup.hide)
pcall(autocmd.delete_group, self._.augroup.unmount)
self._.loading = true
local type = self._.type
if type == "float" then
float_layout.unmount_box(self._.box)
if self.bufnr then
if vim.api.nvim_buf_is_valid(self.bufnr) then
vim.api.nvim_buf_delete(self.bufnr, { force = true })
end
self.bufnr = nil
end
self:_close_window()
end
if type == "split" then
split_layout.unmount_box(self._.box)
end
self._.loading = false
self._.mounted = false
end
function Layout:hide()
if self._.loading or not self._.mounted then
return
end
self._.loading = true
pcall(autocmd.delete_group, self._.augroup.hide)
local type = self._.type
if type == "float" then
float_layout.hide_box(self._.box)
self:_close_window()
end
if type == "split" then
split_layout.hide_box(self._.box)
end
self._.loading = false
end
function Layout:show()
if self._.loading then
return
end
if not self._.mounted then
return self:mount()
end
self._.loading = true
autocmd.create_group(self._.augroup.hide, { clear = true })
local type = self._.type
if type == "float" then
self:_open_window()
end
self:_process_layout()
if type == "float" then
float_layout.show_box(self._.box)
apply_workaround_for_float_relative_position_issue_18925(self)
end
if type == "split" then
split_layout.show_box(self._.box)
end
self._.loading = false
end
---@param config? NuiLayout.Box|NuiLayout.Box[]|nui_layout_options
---@param box? NuiLayout.Box
function Layout:update(config, box)
config = config or {} --[[@as nui_layout_options]]
if not box and is_box(config) or is_box(config[1]) then
box = config --[=[@as NuiLayout.Box|NuiLayout.Box[]]=]
---@type nui_layout_options
config = {}
end
autocmd.create_group(self._.augroup.hide, { clear = true })
autocmd.create_group(self._.augroup.unmount, { clear = true })
local prev_box = self._.box
if box then
self._.box = Layout.Box(box)
self._.type = get_layout_type(self._.box)
end
if self._.type == "float" then
local info = self._.float
u.update_layout_config(info, config)
if self.winid then
vim.api.nvim_win_set_config(self.winid, info.win_config)
self:_process_layout()
float_layout.process_box_change(self._.box, prev_box)
apply_workaround_for_float_relative_position_issue_18925(self)
end
wire_up_layout_components(self, self._.box)
end
if self._.type == "split" then
local info = self._.split
local relative_winid = info.relative and info.relative.win
local prev_winid = vim.api.nvim_get_current_win()
if relative_winid then
vim.api.nvim_set_current_win(relative_winid)
end
local curr_box = self._.box
if prev_box ~= curr_box then
self._.box = prev_box
self:hide()
self._.box = curr_box
end
u.split.update_layout_config(info, config)
if prev_box == curr_box then
self:_process_layout()
else
self:show()
end
if vim.api.nvim_win_is_valid(prev_winid) then
vim.api.nvim_set_current_win(prev_winid)
end
wire_up_layout_components(self, self._.box)
end
end
---@class nui_layout_box_options
---@field dir? 'row'|'col'
---@field grow? integer
---@field size? number|string|table<'height'|'width', number|string>
---@class NuiLayout.Box
---@field type? 'float'|'split'
---@field component? NuiPopup|NuiSplit
---@field box? NuiLayout.Box[]
---@field grow? integer
---@field size? nui_layout_option_size
---@param box NuiPopup|NuiSplit|NuiLayout.Box|NuiLayout.Box[]
---@param options? nui_layout_box_options
---@return NuiLayout.Box
function Layout.Box(box, options)
options = options or {}
if is_box(box) then
return box --[[@as NuiLayout.Box]]
end
if box.mount then
local type
---@diagnostic disable: undefined-field
if box:is_instance_of(Popup) then
type = "float"
elseif box:is_instance_of(Split) then
type = "split"
end
---@diagnostic enable: undefined-field
if not type then
error("unsupported component")
end
return {
type = type,
component = box,
grow = options.grow,
size = options.size,
}
end
local dir = defaults(options.dir, "row")
-- normalize children size
for _, child in ipairs(box) do
if not child.grow and not child.size then
error("missing child.size")
end
if dir == "row" then
if type(child.size) ~= "table" then
---@diagnostic disable-next-line: assign-type-mismatch
child.size = { width = child.size }
end
if not child.size.height then
child.size.height = "100%"
end
elseif dir == "col" then
if not is_type("table", child.size) then
---@diagnostic disable-next-line: assign-type-mismatch
child.size = { height = child.size }
end
if not child.size.width then
child.size.width = "100%"
end
end
end
return {
box = box,
dir = dir,
grow = options.grow,
size = options.size,
}
end
-- luacheck: push no max comment line length
---@alias NuiLayout.constructor fun(options: nui_layout_options|NuiPopup|NuiSplit, box: NuiLayout.Box|NuiLayout.Box[]): NuiLayout
---@type NuiLayout|NuiLayout.constructor
local NuiLayout = Layout
-- luacheck: pop
return NuiLayout

View file

@ -0,0 +1,262 @@
local utils = require("nui.utils")
local split_utils = require("nui.split.utils")
local u = {
is_type = utils.is_type,
split = split_utils,
set_win_options = utils._.set_win_options,
}
local mod = {}
---@param box_dir '"row"'|'"col"'
---@return nui_split_internal_position position
local function get_child_position(box_dir)
if box_dir == "row" then
return "right"
else
return "bottom"
end
end
---@param position nui_split_internal_position
---@param child { size: nui_layout_option_size, grow?: boolean }
---@param container_size { width?: number, height?: number }
---@param growable_dimension_per_factor? number
local function get_child_size(position, child, container_size, growable_dimension_per_factor)
local child_size
if position == "left" or position == "right" then
child_size = child.size.width
else
child_size = child.size.height
end
if child.grow and growable_dimension_per_factor then
child_size = math.floor(growable_dimension_per_factor * child.grow)
end
return u.split.calculate_window_size(position, child_size, container_size)
end
local function get_container_size(meta)
local size = meta.container_size
size.width = size.width or meta.container_fallback_size.width
size.height = size.height or meta.container_fallback_size.height
return size
end
function mod.process(box, meta)
-- luacov: disable
if box.mount or box.component or not box.box then
return error("invalid parameter: box")
end
-- luacov: enable
local container_size = get_container_size(meta)
-- luacov: disable
if not u.is_type("number", container_size.width) and not u.is_type("number", container_size.height) then
return error("invalid value: box.size")
end
-- luacov: enable
local consumed_size = {
width = 0,
height = 0,
}
local growable_child_factor = 0
for i, child in ipairs(box.box) do
if meta.process_growable_child or not child.grow then
local position = get_child_position(box.dir)
local relative = { type = "win" }
local size = get_child_size(position, child, container_size, meta.growable_dimension_per_factor)
consumed_size.width = consumed_size.width + (size.width or 0)
consumed_size.height = consumed_size.height + (size.height or 0)
if i == 1 then
position = meta.position
if meta.relative then
relative = meta.relative
end
if position == "left" or position == "right" then
size.width = container_size.width
else
size.height = container_size.height
end
end
if child.component then
child.component:update_layout({
position = position,
relative = relative,
size = size,
})
if i == 1 and child.component.winid then
if position == "left" or position == "right" then
vim.api.nvim_win_set_height(child.component.winid, size.height)
else
vim.api.nvim_win_set_width(child.component.winid, size.width)
end
end
else
mod.process(child, {
container_size = size,
container_fallback_size = container_size,
position = position,
})
end
end
if child.grow then
growable_child_factor = growable_child_factor + child.grow
end
end
if meta.process_growable_child or growable_child_factor == 0 then
return
end
local growable_width = container_size.width - consumed_size.width
local growable_height = container_size.height - consumed_size.height
local growable_dimension = box.dir == "col" and growable_height or growable_width
local growable_dimension_per_factor = growable_dimension / growable_child_factor
mod.process(box, {
container_size = meta.container_size,
container_fallback_size = meta.container_fallback_size,
position = meta.position,
process_growable_child = true,
growable_dimension_per_factor = growable_dimension_per_factor,
})
end
---@param box table Layout.Box
local function get_first_component(box)
if not box.box[1] then
return
end
if box.box[1].component then
return box.box[1].component
end
return get_first_component(box.box[1])
end
---@param box table Layout.Box
local function unset_win_options_fixsize(box)
for _, child in ipairs(box.box) do
if child.component then
local winfix = child.component._._layout_orig_winfixsize
if winfix then
child.component._.win_options.winfixwidth = winfix.winfixwidth
child.component._.win_options.winfixheight = winfix.winfixheight
child.component._._layout_orig_winfixsize = nil
end
u.set_win_options(child.component.winid, {
winfixwidth = child.component._.win_options.winfixwidth,
winfixheight = child.component._.win_options.winfixheight,
})
else
unset_win_options_fixsize(child)
end
end
end
---@param box table Layout.Box
---@param action '"mount"'|'"show"'
---@param meta? { initial_pass?: boolean }
local function do_action(box, action, meta)
meta = meta or { root = true }
for i, child in ipairs(box.box) do
if not meta.initial_pass or i == 1 then
if child.component then
child.component._._layout_orig_winfixsize = {
winfixwidth = child.component._.win_options.winfixwidth,
winfixheight = child.component._.win_options.winfixheight,
}
child.component._.win_options.winfixwidth = i ~= 1
child.component._.win_options.winfixheight = i == 1
if box.dir == "col" then
child.component._.win_options.winfixwidth = not child.component._.win_options.winfixwidth
child.component._.win_options.winfixheight = not child.component._.win_options.winfixheight
end
if child.component and not child.component.winid then
child.component._.relative.win = vim.api.nvim_get_current_win()
child.component._.win_config.win = child.component._.relative.win
end
child.component[action](child.component)
if action == "show" and not child.component._.mounted then
child.component:mount()
end
else
do_action(child, action, {
initial_pass = true,
})
end
end
end
if not meta.initial_pass then
for _, child in ipairs(box.box) do
if child.box then
local first_component = get_first_component(child)
if first_component and first_component.winid then
vim.api.nvim_set_current_win(first_component.winid)
end
do_action(child, action, {
initial_pass = false,
})
end
end
end
if meta.root then
unset_win_options_fixsize(box)
end
end
---@param box table Layout.Box
---@param meta? { initial_pass?: boolean }
function mod.mount_box(box, meta)
do_action(box, "mount", meta)
end
---@param box table Layout.Box
---@param meta? { initial_pass?: boolean }
function mod.show_box(box, meta)
do_action(box, "show", meta)
end
---@param box table Layout.Box
function mod.unmount_box(box)
for _, child in ipairs(box.box) do
if child.component then
child.component:unmount()
else
mod.unmount_box(child)
end
end
end
---@param box table Layout.Box
function mod.hide_box(box)
for _, child in ipairs(box.box) do
if child.component then
child.component:hide()
else
mod.hide_box(child)
end
end
end
return mod

View file

@ -0,0 +1,226 @@
local utils = require("nui.utils")
local _ = utils._
local defaults = utils.defaults
--luacheck: push no max line length
---@alias nui_layout_option_anchor "NW"|"NE"|"SW"|"SE"
---@alias nui_layout_option_relative_type "'cursor'"|"'editor'"|"'win'"|"'buf'"
---@alias nui_layout_option_relative { type: nui_layout_option_relative_type, winid?: number, position?: { row: number, col: number } }
---@alias nui_layout_option_position { row: number|string, col: number|string }
---@alias nui_layout_option_size { width: number|string, height: number|string }
---@alias nui_layout_internal_position { relative: "'cursor'"|"'editor'"|"'win'", win: number, bufpos?: number[], row: number, col: number }
---@alias nui_layout_container_info { relative: nui_layout_option_relative_type, size: { height: integer, width: integer }, type: "'editor'"|"'window'" }
--luacheck: pop
local mod_size = {}
local mod_position = {}
local mod = {
size = mod_size,
position = mod_position,
}
---@param position nui_layout_option_position
---@param size { width: number, height: number }
---@param container nui_layout_container_info
---@return { row: number, col: number }
function mod.calculate_window_position(position, size, container)
local row
local col
local is_percentage_allowed = not vim.tbl_contains({ "buf", "cursor" }, container.relative)
local percentage_error = string.format("position %% can not be used relative to %s", container.relative)
local r = utils.parse_number_input(position.row)
assert(r.value ~= nil, "invalid position.row")
if r.is_percentage then
assert(is_percentage_allowed, percentage_error)
row = math.floor((container.size.height - size.height) * r.value)
else
row = r.value
end
local c = utils.parse_number_input(position.col)
assert(c.value ~= nil, "invalid position.col")
if c.is_percentage then
assert(is_percentage_allowed, percentage_error)
col = math.floor((container.size.width - size.width) * c.value)
else
col = c.value
end
return {
row = row,
col = col,
}
end
---@param size { width: number|string, height: number|string }
---@param container_size { width: number, height: number }
---@return { width: number, height: number }
function mod.calculate_window_size(size, container_size)
local width = _.normalize_dimension(size.width, container_size.width)
assert(width, "invalid size.width")
local height = _.normalize_dimension(size.height, container_size.height)
assert(height, "invalid size.height")
return {
width = width,
height = height,
}
end
---@param position nui_layout_internal_position
---@return nui_layout_container_info
function mod.get_container_info(position)
local relative = position.relative
local winid = position.win == 0 and vim.api.nvim_get_current_win() or position.win
if relative == "editor" then
return {
relative = relative,
size = utils.get_editor_size(),
type = "editor",
winid = winid,
}
end
return {
relative = position.bufpos and "buf" or relative,
size = utils.get_window_size(position.win),
type = "window",
winid = winid,
}
end
---@param relative nui_layout_option_relative
---@param fallback_winid number
---@return nui_layout_internal_position
function mod.parse_relative(relative, fallback_winid)
local winid = defaults(relative.winid, fallback_winid)
if relative.type == "buf" then
return {
relative = "win",
win = winid,
bufpos = {
relative.position.row,
relative.position.col,
},
}
end
return {
relative = relative.type,
win = winid,
}
end
---@param component_internal table
---@param config nui_layout_options
function mod.update_layout_config(component_internal, config)
local internal = component_internal
local options = _.normalize_layout_options({
relative = config.relative,
size = config.size,
position = config.position,
})
local win_config = internal.win_config
if config.anchor then
win_config.anchor = config.anchor
end
if options.relative then
internal.layout.relative = options.relative
local fallback_winid = internal.position and internal.position.win
or internal.layout.relative.type == "cursor" and 0
or vim.api.nvim_get_current_win()
internal.position =
vim.tbl_extend("force", internal.position or {}, mod.parse_relative(internal.layout.relative, fallback_winid))
win_config.relative = internal.position.relative
win_config.win = internal.position.relative == "win" and internal.position.win or nil
win_config.bufpos = internal.position.bufpos
end
-- luacov: disable
if not win_config.relative then
return error("missing layout config: relative")
end
-- luacov: enable
local prev_container_size = internal.container_info and internal.container_info.size
internal.container_info = mod.get_container_info(internal.position)
local container_size_changed = not mod.size.are_same(internal.container_info.size, prev_container_size)
if
options.size
-- need_size_refresh
or (container_size_changed and internal.layout.size and mod.size.contains_percentage_string(internal.layout.size))
then
internal.layout.size = options.size or internal.layout.size
internal.size = mod.calculate_window_size(internal.layout.size, internal.container_info.size)
win_config.width = internal.size.width
win_config.height = internal.size.height
end
if not win_config.width or not win_config.height then
return error("missing layout config: size")
end
if
options.position
-- need_position_refresh
or (
container_size_changed
and internal.layout.position
and mod.position.contains_percentage_string(internal.layout.position)
)
then
internal.layout.position = options.position or internal.layout.position
internal.position = vim.tbl_extend(
"force",
internal.position,
mod.calculate_window_position(internal.layout.position, internal.size, internal.container_info)
)
win_config.row = internal.position.row
win_config.col = internal.position.col
end
if not win_config.row or not win_config.col then
return error("missing layout config: position")
end
end
---@param size_a nui_layout_option_size
---@param size_b? nui_layout_option_size
---@return boolean
function mod_size.are_same(size_a, size_b)
return size_b and size_a.width == size_b.width and size_a.height == size_b.height or false
end
---@param size nui_layout_option_size
---@return boolean
function mod_size.contains_percentage_string(size)
return type(size.width) == "string" or type(size.height) == "string"
end
---@param position nui_layout_option_position
---@return boolean
function mod_position.contains_percentage_string(position)
return type(position.row) == "string" or type(position.col) == "string"
end
return mod

View file

@ -0,0 +1,100 @@
# NuiLine
NuiLine is an abstraction layer on top of the following native functions:
- `vim.api.nvim_buf_set_lines` (check `:h nvim_buf_set_lines()`)
- `vim.api.nvim_buf_set_text` (check `:h nvim_buf_set_text()`)
- `vim.api.nvim_buf_add_highlight` (check `:h nvim_buf_add_highlight()`)
It helps you create line on the buffer containing multiple [`NuiText`](../text)s.
_Signature:_ `NuiLine(texts?)`
**Example**
```lua
local NuiLine = require("nui.line")
local line = NuiLine()
line:append("Something Went Wrong!", "Error")
local bufnr, ns_id, linenr_start = 0, -1, 1
line:render(bufnr, ns_id, linenr_start)
```
## Parameters
### `texts`
**Type:** `table[]`
List of `NuiText` objects to set as initial texts.
**Example**
```lua
local text_one = NuiText("One")
local text_two = NuiText("Two")
local line = NuiLine({ text_one, text_two })
```
## Methods
### `line:append`
_Signature:_ `line:append(content, highlight?)`
Adds a chunk of content to the line.
**Parameters**
| Name | Type | Description |
| ----------- | -------------------------------- | --------------------- |
| `content` | `string` / `NuiText` / `NuiLine` | content |
| `highlight` | `string` or `table` | highlight information |
If `text` is `string`, these parameters are passed to `NuiText`
and a `NuiText` object is returned.
It `content` is a `NuiText`/`NuiLine` object, it is returned unchanged.
### `line:content`
_Signature:_ `line:content()`
Returns the line content.
### `line:highlight`
_Signature:_ `line:highlight(bufnr, ns_id, linenr)`
Applies highlight for the line.
**Parameters**
| Name | Type | Description |
| -------- | -------- | ---------------------------------------------- |
| `bufnr` | `number` | buffer number |
| `ns_id` | `number` | namespace id (use `-1` for fallback namespace) |
| `linenr` | `number` | line number (1-indexed) |
### `line:render`
_Signature:_ `line:render(bufnr, ns_id, linenr_start, linenr_end?)`
Sets the line on buffer and applies highlight.
**Parameters**
| Name | Type | Description |
| -------------- | -------- | ---------------------------------------------- |
| `bufnr` | `number` | buffer number |
| `ns_id` | `number` | namespace id (use `-1` for fallback namespace) |
| `linenr_start` | `number` | start line number (1-indexed) |
| `linenr_end` | `number` | end line number (1-indexed) |
## Wiki Page
You can find additional documentation/examples/guides/tips-n-tricks in [nui.line wiki page](https://github.com/MunifTanjim/nui.nvim/wiki/nui.line).

View file

@ -0,0 +1,80 @@
local Object = require("nui.object")
local NuiText = require("nui.text")
local defaults = require("nui.utils").defaults
---@class NuiLine
---@field _texts NuiText[]
local Line = Object("NuiLine")
---@param texts? NuiText[]
function Line:init(texts)
self._texts = defaults(texts, {})
end
---@param content string|NuiText|NuiLine
---@param highlight? string|nui_text_extmark data for highlight
---@return NuiText|NuiLine
function Line:append(content, highlight)
local block = content
if type(block) == "string" then
block = NuiText(block, highlight)
end
if block._texts then
---@cast block NuiLine
for _, text in ipairs(block._texts) do
table.insert(self._texts, text)
end
else
---@cast block NuiText
table.insert(self._texts, block)
end
return block
end
---@return string
function Line:content()
return table.concat(vim.tbl_map(function(text)
return text:content()
end, self._texts))
end
---@return number
function Line:width()
local width = 0
for _, text in ipairs(self._texts) do
width = width + text:width()
end
return width
end
---@param bufnr number buffer number
---@param ns_id number namespace id
---@param linenr number line number (1-indexed)
---@param ___byte_start___? integer start byte position (0-indexed)
---@return nil
function Line:highlight(bufnr, ns_id, linenr, ___byte_start___)
local current_byte_start = ___byte_start___ or 0
for _, text in ipairs(self._texts) do
text:highlight(bufnr, ns_id, linenr, current_byte_start)
current_byte_start = current_byte_start + text:length()
end
end
---@param bufnr number buffer number
---@param ns_id number namespace id
---@param linenr_start number start line number (1-indexed)
---@param linenr_end? number end line number (1-indexed)
---@return nil
function Line:render(bufnr, ns_id, linenr_start, linenr_end)
local row_start = linenr_start - 1
local row_end = linenr_end and linenr_end - 1 or row_start + 1
local content = self:content()
vim.api.nvim_buf_set_lines(bufnr, row_start, row_end, false, { content })
self:highlight(bufnr, ns_id, linenr_start)
end
---@alias NuiLine.constructor fun(texts?: NuiText[]): NuiLine
---@type NuiLine|NuiLine.constructor
local NuiLine = Line
return NuiLine

View file

@ -0,0 +1,207 @@
# Menu
`Menu` is abstraction layer on top of `Popup`.
```lua
local Menu = require("nui.menu")
local event = require("nui.utils.autocmd").event
local popup_options = {
relative = "cursor",
position = {
row = 1,
col = 0,
},
border = {
style = "rounded",
text = {
top = "[Choose Item]",
top_align = "center",
},
},
win_options = {
winhighlight = "Normal:Normal",
}
}
local menu = Menu(popup_options, {
lines = {
Menu.separator("Group One"),
Menu.item("Item 1"),
Menu.item("Item 2"),
Menu.separator("Group Two", {
char = "-",
text_align = "right",
}),
Menu.item("Item 3"),
Menu.item("Item 4"),
},
max_width = 20,
keymap = {
focus_next = { "j", "<Down>", "<Tab>" },
focus_prev = { "k", "<Up>", "<S-Tab>" },
close = { "<Esc>", "<C-c>" },
submit = { "<CR>", "<Space>" },
},
on_close = function()
print("CLOSED")
end,
on_submit = function(item)
print("SUBMITTED", vim.inspect(item))
end,
})
```
You can manipulate the associated buffer and window using the
`split.bufnr` and `split.winid` properties.
**NOTE**: the first argument accepts options for `nui.popup` component.
## Options
### `lines`
**Type:** `table`
List of menu items.
**`Menu.item(content, data?)`**
`Menu.item` is used to create an item object for the `Menu`.
**Parameters**
| Name | Type |
| --------- | -------------------------------- |
| `content` | `string` / `NuiText` / `NuiLine` |
| `data` | `table` / `nil` |
**Example**
```lua
Menu.item("One") --> { text = "One" }
Menu.item("Two", { id = 2 }) --> { id = 2, text = "Two" }
```
This is what you get as the argument of `on_submit` callback function.
You can include whatever you want in the item object.
**`Menu.separator(content?, options?)`**
`Menu.separator` is used to create a menu item that can't be focused.
**Parameters**
| Name | Type |
| --------- | ---------------------------------------------------------------------------------- |
| `content` | `string` / `NuiText` / `NuiLine` / `nil` |
| `options` | `{ char?: string\|NuiText, text_align?: "'left'"\|"'center'"\|"'right'" }` / `nil` |
You can just use `Menu.item` only and implement `Menu.separator`'s behavior
by providing a custom `should_skip_item` function.
### `prepare_item`
**Type:** `function`
_Signature:_ `prepare_item(item)`
If provided, this function is used for preparing each menu item.
The return value should be a `NuiLine` object or `string` or a list containing either of them.
If return value is `nil`, that node will not be rendered.
### `should_skip_item`
**Type:** `function`
_Signature:_ `should_skip_item(item)`
If provided, this function is used to determine if an item should be
skipped when focusing previous/next item.
The return value should be `boolean`.
By default, items created by `Menu.separator` are skipped.
### `max_height`
**Type:** `number`
Maximum height of the menu.
### `min_height`
**Type:** `number`
Minimum height of the menu.
### `max_width`
**Type:** `number`
Maximum width of the menu.
### `min_width`
**Type:** `number`
Minimum width of the menu.
### `keymap`
**Type:** `table`
Key mappings for the menu.
**Example**
```lua
keymap = {
close = { "<Esc>", "<C-c>" },
focus_next = { "j", "<Down>", "<Tab>" },
focus_prev = { "k", "<Up>", "<S-Tab>" },
submit = { "<CR>" },
},
```
### `on_change`
**Type:** `function`
_Signature:_ `on_change(item, menu) -> nil`
Callback function, called when menu item is focused.
### `on_close`
**Type:** `function`
_Signature:_ `on_close() -> nil`
Callback function, called when menu is closed.
### `on_submit`
**Type:** `function`
_Signature:_ `on_submit(item) -> nil`
Callback function, called when menu is submitted.
## Methods
Methods from `nui.popup` are also available for `nui.menu`.
## Properties
### `menu.tree`
The underlying `NuiTree` object used for rendering the menu. You can use it to
manipulate the menu items on-the-fly and access all the `NuiTree` methods.
## Wiki Page
You can find additional documentation/examples/guides/tips-n-tricks in [nui.menu wiki page](https://github.com/MunifTanjim/nui.nvim/wiki/nui.menu).

View file

@ -0,0 +1,377 @@
local Line = require("nui.line")
local Popup = require("nui.popup")
local Text = require("nui.text")
local Tree = require("nui.tree")
local _ = require("nui.utils")._
local defaults = require("nui.utils").defaults
local is_type = require("nui.utils").is_type
local function calculate_initial_max_width(items)
local max_width = 0
for _, item in ipairs(items) do
local width = 0
if is_type("string", item.text) then
width = vim.api.nvim_strwidth(item.text)
elseif is_type("table", item.text) and item.text.width then
width = item.text:width()
end
if max_width < width then
max_width = width
end
end
return max_width
end
local default_keymap = {
close = { "<Esc>", "<C-c>" },
focus_next = { "j", "<Down>", "<Tab>" },
focus_prev = { "k", "<Up>", "<S-Tab>" },
submit = { "<CR>" },
}
---@param keymap table<_nui_menu_keymap_action, string|string[]>
---@return table<_nui_menu_keymap_action, string[]>
local function parse_keymap(keymap)
local result = defaults(keymap, {})
for name, default_keys in pairs(default_keymap) do
if is_type("nil", result[name]) then
result[name] = default_keys
elseif is_type("string", result[name]) then
result[name] = { result[name] }
end
end
return result
end
---@type nui_menu_should_skip_item
local function default_should_skip_item(node)
return node._type == "separator"
end
---@param menu NuiMenu
---@return nui_menu_prepare_item
local function make_default_prepare_node(menu)
local border = menu.border
local fallback_sep = {
char = Text(is_type("table", border._.char) and border._.char.top or " "),
text_align = is_type("table", border._.text) and border._.text.top_align or "left",
}
-- luacov: disable
if menu._.sep then
-- @deprecated
if menu._.sep.char then
fallback_sep.char = Text(menu._.sep.char)
end
if menu._.sep.text_align then
fallback_sep.text_align = menu._.sep.text_align
end
end
-- luacov: enable
local max_width = menu._.size.width
---@type nui_menu_prepare_item
local function default_prepare_node(node)
---@type NuiText|NuiLine
local content = is_type("string", node.text) and Text(node.text) or node.text
if node._type == "item" then
if content:width() > max_width then
if is_type("function", content.set) then
---@cast content NuiText
_.truncate_nui_text(content, max_width)
else
---@cast content NuiLine
_.truncate_nui_line(content, max_width)
end
end
local line = Line()
line:append(content)
return line
end
if node._type == "separator" then
local sep_char = Text(defaults(node._char, fallback_sep.char))
local sep_text_align = defaults(node._text_align, fallback_sep.text_align)
local sep_max_width = max_width - sep_char:width() * 2
if content:width() > sep_max_width then
if content._texts then
---@cast content NuiLine
_.truncate_nui_line(content, sep_max_width)
else
---@cast content NuiText
_.truncate_nui_text(content, sep_max_width)
end
end
local left_gap_width, right_gap_width =
_.calculate_gap_width(defaults(sep_text_align, "center"), sep_max_width, content:width())
local line = Line()
line:append(Text(sep_char))
if left_gap_width > 0 then
line:append(Text(sep_char):set(string.rep(sep_char:content(), left_gap_width)))
end
line:append(content)
if right_gap_width > 0 then
line:append(Text(sep_char):set(string.rep(sep_char:content(), right_gap_width)))
end
line:append(Text(sep_char))
return line
end
end
return default_prepare_node
end
---@param menu NuiMenu
---@param direction "'next'" | "'prev'"
---@param current_linenr nil | number
local function focus_item(menu, direction, current_linenr)
local curr_linenr = current_linenr or vim.api.nvim_win_get_cursor(menu.winid)[1]
local next_linenr = nil
if direction == "next" then
if curr_linenr == #menu.tree:get_nodes() then
next_linenr = 1
else
next_linenr = curr_linenr + 1
end
elseif direction == "prev" then
if curr_linenr == 1 then
next_linenr = #menu.tree:get_nodes()
else
next_linenr = curr_linenr - 1
end
end
local next_node = menu.tree:get_node(next_linenr)
if menu._.should_skip_item(next_node) then
return focus_item(menu, direction, next_linenr)
end
if next_linenr then
vim.api.nvim_win_set_cursor(menu.winid, { next_linenr, 0 })
menu._.on_change(next_node)
end
end
---@alias nui_menu_prepare_item nui_tree_prepare_node
---@alias nui_menu_should_skip_item fun(node: NuiTree.Node): boolean
---@alias _nui_menu_keymap_action 'close'|'focus_next'|'focus_prev'|'submit'
---@class nui_menu_internal: nui_popup_internal
---@field items NuiTree.Node[]
---@field keymap table<_nui_menu_keymap_action, string[]>
---@field sep { char?: string|NuiText, text_align?: nui_t_text_align } # deprecated
---@field prepare_item nui_menu_prepare_item
---@field should_skip_item nui_menu_should_skip_item
---@field on_change fun(item: NuiTree.Node): nil
---@class nui_menu_options
---@field lines NuiTree.Node[]
---@field prepare_item? nui_tree_prepare_node
---@field should_skip_item? nui_menu_should_skip_item
---@field max_height? integer
---@field min_height? integer
---@field max_width? integer
---@field min_width? integer
---@field keymap? table<_nui_menu_keymap_action, string|string[]>
---@field on_change? fun(item: NuiTree.Node, menu: NuiMenu): nil
---@field on_close? fun(): nil
---@field on_submit? fun(item: NuiTree.Node): nil
---@class NuiMenu: NuiPopup
---@field private _ nui_menu_internal
local Menu = Popup:extend("NuiMenu")
---@param content? string|NuiText|NuiLine
---@param options? { char?: string|NuiText, text_align?: nui_t_text_align }
---@return NuiTree.Node
function Menu.separator(content, options)
options = options or {}
return Tree.Node({
_id = tostring(math.random()),
_type = "separator",
_char = options.char,
_text_align = options.text_align,
text = defaults(content, ""),
})
end
---@param content string|NuiText|NuiLine
---@param data? table
---@return NuiTree.Node
function Menu.item(content, data)
if not data then
---@diagnostic disable-next-line: undefined-field
if is_type("table", content) and content.text then
---@cast content table
data = content
else
data = { text = content }
end
else
data.text = content
end
data._type = "item"
data._id = data.id or tostring(math.random())
return Tree.Node(data)
end
---@param popup_options nui_popup_options
---@param options nui_menu_options
function Menu:init(popup_options, options)
local max_width = calculate_initial_max_width(options.lines)
local width = math.max(math.min(max_width, defaults(options.max_width, 256)), defaults(options.min_width, 4))
local height = math.max(math.min(#options.lines, defaults(options.max_height, 256)), defaults(options.min_height, 1))
---@type nui_popup_options
popup_options = vim.tbl_deep_extend("force", {
enter = true,
size = {
width = width,
height = height,
},
win_options = {
cursorline = true,
scrolloff = 1,
sidescrolloff = 0,
},
zindex = 60,
}, popup_options)
Menu.super.init(self, popup_options)
self._.items = options.lines
self._.keymap = parse_keymap(options.keymap)
---@param node NuiTree.Node
self._.on_change = function(node)
if options.on_change then
options.on_change(node, self)
end
end
---@deprecated
self._.sep = options.separator
self._.should_skip_item = defaults(options.should_skip_item, default_should_skip_item)
self._.prepare_item = defaults(options.prepare_item, self._.prepare_item)
self.menu_props = {}
local props = self.menu_props
props.on_submit = function()
local item = self.tree:get_node()
self:unmount()
if options.on_submit then
options.on_submit(item)
end
end
props.on_close = function()
self:unmount()
if options.on_close then
options.on_close()
end
end
props.on_focus_next = function()
focus_item(self, "next")
end
props.on_focus_prev = function()
focus_item(self, "prev")
end
end
---@param config? nui_layout_options
function Menu:update_layout(config)
Menu.super.update_layout(self, config)
self._.prepare_item = defaults(self._.prepare_item, make_default_prepare_node(self))
end
function Menu:mount()
Menu.super.mount(self)
local props = self.menu_props
for _, key in pairs(self._.keymap.focus_next) do
self:map("n", key, props.on_focus_next, { noremap = true, nowait = true })
end
for _, key in pairs(self._.keymap.focus_prev) do
self:map("n", key, props.on_focus_prev, { noremap = true, nowait = true })
end
for _, key in pairs(self._.keymap.close) do
self:map("n", key, props.on_close, { noremap = true, nowait = true })
end
for _, key in pairs(self._.keymap.submit) do
self:map("n", key, props.on_submit, { noremap = true, nowait = true })
end
self.tree = Tree({
winid = self.winid,
ns_id = self.ns_id,
nodes = self._.items,
get_node_id = function(node)
return node._id
end,
prepare_node = self._.prepare_item,
})
---@deprecated
self._tree = self.tree
self.tree:render()
-- focus first item
for linenr = 1, #self.tree:get_nodes() do
local node, target_linenr = self.tree:get_node(linenr)
if not self._.should_skip_item(node) then
vim.api.nvim_win_set_cursor(self.winid, { target_linenr, 0 })
self._.on_change(node)
break
end
end
end
---@alias NuiMenu.constructor fun(popup_options: nui_popup_options, options: nui_menu_options): NuiMenu
---@type NuiMenu|NuiMenu.constructor
local NuiMenu = Menu
return NuiMenu

View file

@ -0,0 +1,170 @@
-- source: https://github.com/kikito/middleclass
local idx = {
subclasses = { "<nui.utils.object:subclasses>" },
}
local function __tostring(self)
return "class " .. self.name
end
local function __call(self, ...)
return self:new(...)
end
local function create_index_wrapper(class, index)
if type(index) == "table" then
return function(self, key)
local value = self.class.__meta[key]
if value == nil then
return index[key]
end
return value
end
elseif type(index) == "function" then
return function(self, key)
local value = self.class.__meta[key]
if value == nil then
return index(self, key)
end
return value
end
else
return class.__meta
end
end
local function propagate_instance_property(class, key, value)
value = key == "__index" and create_index_wrapper(class, value) or value
class.__meta[key] = value
for subclass in pairs(class[idx.subclasses]) do
if subclass.__properties[key] == nil then
propagate_instance_property(subclass, key, value)
end
end
end
local function declare_instance_property(class, key, value)
class.__properties[key] = value
if value == nil and class.super then
value = class.super.__meta[key]
end
propagate_instance_property(class, key, value)
end
local function is_subclass(subclass, class)
if not subclass.super then
return false
end
if subclass.super == class then
return true
end
return is_subclass(subclass.super, class)
end
local function is_instance(instance, class)
if instance.class == class then
return true
end
return is_subclass(instance.class, class)
end
local function create_class(name, super)
assert(name, "missing name")
local meta = {
is_instance_of = is_instance,
}
meta.__index = meta
local class = {
super = super,
name = name,
static = {
is_subclass_of = is_subclass,
},
[idx.subclasses] = setmetatable({}, { __mode = "k" }),
__meta = meta,
__properties = {},
}
setmetatable(class.static, {
__index = function(_, key)
local value = rawget(class.__meta, key)
if value == nil and super then
return super.static[key]
end
return value
end,
})
setmetatable(class, {
__call = __call,
__index = class.static,
__name = class.name,
__newindex = declare_instance_property,
__tostring = __tostring,
})
return class
end
---@param name string
local function create_object(_, name)
local Class = create_class(name)
---@return string
function Class:__tostring()
return "instance of " .. tostring(self.class)
end
---@return nil
function Class:init() end -- luacheck: no unused args
function Class.static:new(...)
local instance = setmetatable({ class = self }, self.__meta)
instance:init(...)
return instance
end
---@param name string
function Class.static:extend(name) -- luacheck: no redefined
local subclass = create_class(name, self)
for key, value in pairs(self.__meta) do
if not (key == "__index" and type(value) == "table") then
propagate_instance_property(subclass, key, value)
end
end
function subclass.init(instance, ...)
self.init(instance, ...)
end
self[idx.subclasses][subclass] = true
return subclass
end
return Class
end
--luacheck: push no max line length
---@type (fun(name: string): table)|{ is_subclass: (fun(subclass: table, class: table): boolean), is_instance: (fun(instance: table, class: table): boolean) }
local Object = setmetatable({
is_subclass = is_subclass,
is_instance = is_instance,
}, {
__call = create_object,
})
--luacheck: pop
return Object

View file

@ -0,0 +1,674 @@
# Popup
Popup is an abstraction layer on top of window.
Creates a new popup object (but does not mount it immediately).
**Examples**
```lua
local Popup = require("nui.popup")
local popup = Popup({
position = "50%",
size = {
width = 80,
height = 40,
},
enter = true,
focusable = true,
zindex = 50,
relative = "editor",
border = {
padding = {
top = 2,
bottom = 2,
left = 3,
right = 3,
},
style = "rounded",
text = {
top = " I am top title ",
top_align = "center",
bottom = "I am bottom title",
bottom_align = "left",
},
},
buf_options = {
modifiable = true,
readonly = false,
},
win_options = {
winblend = 10,
winhighlight = "Normal:Normal,FloatBorder:FloatBorder",
},
})
```
You can manipulate the associated buffer and window using the
`split.bufnr` and `split.winid` properties.
## Options
### `border`
**Type:** `table`
Contains all border related options.
#### `border.padding`
**Type:** `table`
Controls the popup padding.
**Examples**
It can be a list (`table`) with number of cells for top, right, bottom and left.
The order behaves like [CSS](https://developer.mozilla.org/en-US/docs/Web/CSS) padding.
```lua
border = {
-- `1` for top/bottom and `2` for left/right
padding = { 1, 2 },
},
```
You can also use a map (`table`) to set padding at specific side:
```lua
border = {
-- `1` for top, `2` for left, `0` for other sides
padding = {
top = 1,
left = 2,
},
},
```
#### `border.style`
**Type:** `string` or `table`
Controls the styling of the border.
**Examples**
Can be one of the pre-defined styles: `"double"`, `"none"`, `"rounded"`, `"shadow"`, `"single"`, `"solid"` or `"default"`.
```lua
border = {
style = "double",
},
```
List (`table`) of characters starting from the top-left corner and then clockwise:
```lua
border = {
style = { "╭", "─", "╮", "│", "╯", "─", "╰", "│" },
},
```
Map (`table`) with named characters:
```lua
border = {
style = {
top_left = "╭", top = "─", top_right = "╮",
left = "│", right = "│",
bottom_left = "╰", bottom = "─", bottom_right = "╯",
},
},
```
If you don't need all these options, you can also pass the value of `border.style` to `border`
directly.
To set the highlight group for all the border characters, use the `win_options.winhighlight`
option and include the name of highlight group for `FloatBorder`.
**Examples**
```lua
win_options = {
winhighlight = "Normal:Normal,FloatBorder:SpecialChar",
},
```
To set the highlight group for individual border character, you can use `NuiText` or a tuple
with `(char, hl_group)`.
**Examples**
```lua
border = {
style = { { [[/]], "SpecialChar" }, [[─]], NuiText([[\]], "SpecialChar"), [[│]] },
},
```
#### `border.text`
**Type:** `table`
Text displayed on the border (as title/footnote).
| Key | Type | Description |
| ---------------- | -------------------------------------------- | ---------------------------- |
| `"top"` | `string` / `NuiLine` / `NuiText` | top border text |
| `"top_align"` | `"left"` / `"right"`/ `"center"` _(default)_ | top border text alignment |
| `"bottom"` | `string` / `NuiLine` / `NuiText` | bottom border text |
| `"bottom_align"` | `"left"` / `"right"`/ `"center"` _(default)_ | bottom border text alignment |
`"top"` and `"bottom"` also supports list of `(text, hl_group)` tuples, just like the native popup.
**Examples**
```lua
border = {
text = {
top = "Popup Title",
top_align = "center",
},
},
```
---
### `ns_id`
**Type:** `number` or `string`
Namespace id (`number`) or name (`string`).
---
### `anchor`
**Type:** `"NW"` / `"NE"` / `"SW"` / `"SE"`
Decides which corner of the popup to place at `position`.
---
### `relative`
**Type:** `string` or `table`
This option affects how `position` and `size` are calculated.
**Examples**
Relative to cursor on current window:
```lua
relative = "cursor",
```
Relative to the current editor screen:
```lua
relative = "editor",
```
Relative to the current window (_default_):
```lua
relative = "win",
```
Relative to the window with specific id:
```lua
relative = {
type = "win",
winid = 5,
},
```
Relative to the buffer position:
```lua
relative = {
type = "buf",
-- zero-indexed
position = {
row = 5,
col = 5,
},
},
```
---
### `position`
**Type:** `number` or `percentage string` or `table`
Position is calculated from the top-left corner.
If `position` is `number` or `percentage string`, it applies to both `row` and `col`.
Or you can pass a table to set them separately.
For `percentage string`, position is calculated according to the option `relative`.
If `relative` is set to `"buf"` or `"cursor"`, `percentage string` is not allowed.
**Examples**
```lua
position = 50,
```
```lua
position = "50%",
```
```lua
position = {
row = 30,
col = 20,
},
```
```lua
position = {
row = "20%",
col = "50%",
},
```
---
### `size`
**Type:** `number` or `percentage string` or `table`
Determines the size of the popup.
If `size` is `number` or `percentage string`, it applies to both `width` and `height`.
You can also pass a table to set them separately.
For `percentage string`, `size` is calculated according to the option `relative`.
If `relative` is set to `"buf"` or `"cursor"`, window size is considered.
Decimal `number` in `(0,1)` range is treated similar to `percentage string`. For
example: `0.5` is same as `"50%"`.
**Examples**
```lua
size = 50,
```
```lua
size = "50%",
```
```lua
size = 0.5,
```
```lua
size = {
width = 80,
height = 40,
},
```
```lua
size = {
width = "80%",
height = 0.6,
},
```
---
### `enter`
**Type:** `boolean`
If `true`, the popup is entered immediately after mount.
**Examples**
```lua
enter = true,
```
---
### `focusable`
**Type:** `boolean`
If `false`, the popup can not be entered by user actions (wincmds, mouse events).
**Examples**
```lua
focusable = true,
```
---
### `zindex`
**Type:** `number`
Sets the order of the popup on z-axis.
Popup with higher the `zindex` goes on top of popups with lower `zindex`.
**Examples**
```lua
zindex = 50,
```
---
### `buf_options`
**Type:** `table`
Contains all buffer related options (check `:h options | /local to buffer`).
**Examples**
```lua
buf_options = {
modifiable = false,
readonly = true,
},
```
---
### `win_options`
**Type:** `table`
Contains all window related options (check `:h options | /local to window`).
**Examples**
```lua
win_options = {
winblend = 10,
winhighlight = "Normal:Normal,FloatBorder:FloatBorder",
},
```
---
### `bufnr`
**Type:** `number`
You can pass `bufnr` of an existing buffer to display it on the popup.
**Examples:**
```lua
bufnr = vim.api.nvim_get_current_buf(),
```
## Methods
### `popup:mount`
_Signature:_ `popup:mount()`
Mounts the popup.
**Examples**
```lua
popup:mount()
```
---
### `popup:unmount`
_Signature:_ `popup:unmount()`
Unmounts the popup.
**Examples**
```lua
popup:unmount()
```
---
### `popup:hide`
_Signature:_ `popup:hide()`
Hides the popup window. Preserves the buffer (related content, autocmds and keymaps).
---
### `popup:show`
_Signature:_ `popup:show()`
Shows the hidden popup window.
---
### `popup:map`
_Signature:_ `popup:map(mode, key, handler, opts) -> nil`
Sets keymap for the popup.
**Parameters**
| Name | Type | Description |
| --------- | --------------------- | --------------------------------------------------------------------------- |
| `mode` | `string` | check `:h :map-modes` |
| `key` | `string` | key for the mapping |
| `handler` | `string` / `function` | handler for the mapping |
| `opts` | `table` | check `:h :map-arguments` (including `remap`/`noremap`, excluding `buffer`) |
**Examples**
```lua
local ok = popup:map("n", "<esc>", function(bufnr)
print("ESC pressed in Normal mode!")
end, { noremap = true })
```
---
### `popup:unmap`
_Signature:_ `popup:unmap(mode, key) -> nil`
Deletes keymap for the popup.
**Parameters**
| Name | Type | Description |
| ------ | ------------- | --------------------- |
| `mode` | `"n"` / `"i"` | check `:h :map-modes` |
| `key` | `string` | key for the mapping |
**Examples**
```lua
local ok = popup:unmap("n", "<esc>")
```
---
### `popup:on`
_Signature:_ `popup:on(event, handler, options)`
Defines `autocmd` to run on specific events for this popup.
**Parameters**
| Name | Type | Description |
| --------- | --------------------- | ------------------------------------------ |
| `event` | `string[]` / `string` | check `:h events` |
| `handler` | `function` | handler function for event |
| `options` | `table` | keys `once`, `nested` and values `boolean` |
**Examples**
```lua
local event = require("nui.utils.autocmd").event
popup:on({ event.BufLeave }, function()
popup:unmount()
end, { once = true })
```
`event` can be expressed as any of the followings:
```lua
{ event.BufLeave, event.BufDelete }
-- or
{ event.BufLeave, "BufDelete" }
-- or
event.BufLeave
-- or
"BufLeave"
-- or
"BufLeave,BufDelete"
```
---
### `popup:off`
_Signature:_ `popup:off(event)`
Removes `autocmd` defined with `popup:on({ ... })`
**Parameters**
| Name | Type | Description |
| ------- | --------------------- | ----------------- |
| `event` | `string[]` / `string` | check `:h events` |
**Examples**
```lua
popup:off("*")
```
---
### `popup:update_layout`
_Signature:_ `popup:update_layout(config)`
Sets the layout of the popup. You can use this method to change popup's
size or position after it's mounted.
**Parameters**
`config` is a `table` having the following keys:
| Key | Type |
| ---------- | --------------------------------- |
| `anchor` | `"NW"` / `"NE"` / `"SW"` / `"SE"` |
| `relative` | `string` / `table` |
| `position` | `string` / `table` |
| `size` | `string` / `table` |
They are the same options used for popup initialization.
**Examples**
```lua
popup:update_layout({
relative = "win",
size = {
width = 80,
height = 40,
},
position = {
row = 30,
col = 20,
},
})
```
---
### `popup.border:set_highlight`
_Signature:_ `popup.border:set_highlight(highlight: string) -> nil`
Sets border highlight.
**Parameters**
| Name | Type | Description |
| ----------- | -------- | -------------------- |
| `highlight` | `string` | highlight group name |
**Examples**
```lua
popup.border:set_highlight("SpecialChar")
```
---
### `popup.border:set_style`
_Signature:_ `popup.border:set_style(style: string|table) -> nil`
Sets border style.
**Parameters**
| Name | Type | Description |
| ------- | ------------------ | ------------ |
| `style` | `string` / `table` | border style |
This `style` parameter is exactly the same as popup option `border.style`.
You'll need to call `popup:update_layout()` after this for the change to render on screen.
**Examples**
```lua
popup.border:set_style("rounded")
popup:update_layout()
```
---
### `popup.border:set_text`
_Signature:_ `popup.border:set_text(edge, text, align)`
Sets border text.
**Parameters**
| Name | Type |
| ------- | ------------------------------------------- |
| `edge` | `"top"` / `"bottom"` / `"left"` / `"right"` |
| `text` | `string` |
| `align` | `"left"` / `"right"`/ `"center"` |
**Examples**
```lua
popup.border:set_text("bottom", "[Progress: 42%]", "right")
```
## Wiki Page
You can find additional documentation/examples/guides/tips-n-tricks in [nui.popup wiki page](https://github.com/MunifTanjim/nui.nvim/wiki/nui.popup).

View file

@ -0,0 +1,746 @@
---@diagnostic disable: invisible
local Object = require("nui.object")
local Line = require("nui.line")
local Text = require("nui.text")
local _ = require("nui.utils")._
local is_type = require("nui.utils").is_type
local has_nvim_0_5_1 = vim.fn.has("nvim-0.5.1") == 1
local has_nvim_0_11_0 = _.feature.v0_11
local index_name = {
"top_left",
"top",
"top_right",
"right",
"bottom_right",
"bottom",
"bottom_left",
"left",
}
---@param border _nui_popup_border_style_list
---@return _nui_popup_border_style_map
local function to_border_map(border)
local count = vim.tbl_count(border) --[[@as integer]]
if count < 8 then
-- fillup all 8 characters
for i = count + 1, 8 do
local fallback_index = i % count
local char = border[fallback_index == 0 and count or fallback_index]
if type(char) == "table" then
char = char.content and Text(char) or vim.deepcopy(char)
end
border[i] = char
end
end
---@type _nui_popup_border_style_map
local named_border = {}
for index, name in ipairs(index_name) do
named_border[name] = border[index]
end
return named_border
end
---@param char _nui_popup_border_style_map
---@return _nui_popup_border_internal_char
local function normalize_char_map(char)
if not char or type(char) == "string" then
return char
end
for position, item in pairs(char) do
if type(item) == "string" then
char[position] = Text(item, "FloatBorder")
elseif not item.content then
char[position] = Text(item[1], item[2] or "FloatBorder")
elseif item.extmark then
item.extmark.hl_group = item.extmark.hl_group or "FloatBorder"
else
item.extmark = { hl_group = "FloatBorder" }
end
end
return char --[[@as _nui_popup_border_internal_char]]
end
---@param char? NuiText
---@return boolean
local function is_empty_char(char)
return not char or 0 == char:width()
end
---@param text? _nui_popup_border_option_text_value
---@return nil|NuiLine|NuiText
local function normalize_border_text(text)
if not text then
return text
end
if type(text) == "string" then
return Text(text, "FloatTitle")
end
if text.content then
for _, text_chunk in ipairs(text._texts or { text }) do
text_chunk.extmark = vim.tbl_deep_extend("keep", text_chunk.extmark or {}, {
hl_group = "FloatTitle",
})
end
return text --[[@as NuiLine|NuiText]]
end
local line = Line()
for _, chunk in ipairs(text) do
if type(chunk) == "string" then
line:append(chunk, "FloatTitle")
else
line:append(chunk[1], chunk[2] or "FloatTitle")
end
end
return line
end
---@param internal nui_popup_border_internal
---@param popup_winhighlight? string
---@return nil|string
local function calculate_winhighlight(internal, popup_winhighlight)
if internal.type == "simple" then
return
end
local winhl = popup_winhighlight
-- @deprecated
if internal.highlight then
if not string.match(internal.highlight, ":") then
internal.highlight = "FloatBorder:" .. internal.highlight
end
winhl = internal.highlight
internal.highlight = nil
end
return winhl
end
---@param padding? nui_popup_border_option_padding
---@return nil|nui_popup_border_internal_padding
local function normalize_option_padding(padding)
if not padding then
return nil
end
if is_type("map", padding) then
---@cast padding _nui_popup_border_option_padding_map
return padding
end
local map = {}
---@cast padding _nui_popup_border_option_padding_list
map.top = padding[1] or 0
map.right = padding[2] or map.top
map.bottom = padding[3] or map.top
map.left = padding[4] or map.right
return map
end
---@param text? nui_popup_border_option_text
---@return nil|nui_popup_border_internal_text
local function normalize_option_text(text)
if not text then
return text
end
text.top = normalize_border_text(text.top)
text.bottom = normalize_border_text(text.bottom)
return text --[[@as nui_popup_border_internal_text]]
end
---@param edge 'top'|'bottom'
---@param text? NuiLine|NuiText
---@param align? nui_t_text_align
---@return NuiLine
local function calculate_buf_edge_line(internal, edge, text, align)
local char, size = internal.char, internal.size
local left_char = char[edge .. "_left"]
local mid_char = char[edge]
local right_char = char[edge .. "_right"]
if left_char:content() == "" then
left_char = Text(mid_char:content() == "" and char["left"] or mid_char)
end
if right_char:content() == "" then
right_char = Text(mid_char:content() == "" and char["right"] or mid_char)
end
local max_width = size.width - left_char:width() - right_char:width()
local content = Line()
if mid_char:width() == 0 then
content:append(string.rep(" ", max_width))
else
content:append(text or "")
end
_.truncate_nui_line(content, max_width)
local left_gap_width, right_gap_width = _.calculate_gap_width(align or "center", max_width, content:width())
local line = Line()
line:append(left_char)
if left_gap_width > 0 then
line:append(Text(mid_char):set(string.rep(mid_char:content(), left_gap_width)))
end
line:append(content)
if right_gap_width > 0 then
line:append(Text(mid_char):set(string.rep(mid_char:content(), right_gap_width)))
end
line:append(right_char)
return line
end
---@param internal nui_popup_border_internal
---@return nil|NuiLine[]
local function calculate_buf_lines(internal)
local char, size, text = internal.char, internal.size, internal.text or {}
if type(char) == "string" then
return nil
end
local left_char, right_char = char.left, char.right
local gap_length = size.width - left_char:width() - right_char:width()
---@type NuiLine[]
local lines = {}
table.insert(lines, calculate_buf_edge_line(internal, "top", text.top, text.top_align))
for _ = 1, size.height - 2 do
table.insert(
lines,
Line({
Text(left_char),
Text(string.rep(" ", gap_length)),
Text(right_char),
})
)
end
table.insert(lines, calculate_buf_edge_line(internal, "bottom", text.bottom, text.bottom_align))
return lines
end
local styles = {
bold = to_border_map({ "", "", "", "", "", "", "", "" }),
double = to_border_map({ "", "", "", "", "", "", "", "" }),
none = "none",
rounded = to_border_map({ "", "", "", "", "", "", "", "" }),
shadow = "shadow",
single = to_border_map({ "", "", "", "", "", "", "", "" }),
solid = to_border_map({ " ", " ", " ", " ", " ", " ", " ", " " }),
}
---@param style nui_popup_border_option_style
---@param prev_char_map? _nui_popup_border_internal_char
---@return _nui_popup_border_style_map
local function prepare_char_map(style, prev_char_map)
if type(style) == "string" then
if not styles[style] then
error("invalid border style name")
end
---@cast style _nui_popup_border_style_builtin
return vim.deepcopy(styles[style])
end
if is_type("list", style) then
---@cast style _nui_popup_border_style_list
return to_border_map(style)
end
---@cast style _nui_popup_border_style_map
return vim.tbl_extend("force", prev_char_map or {}, style)
end
---@param internal nui_popup_border_internal
---@return nui_popup_border_internal_size
local function calculate_size_delta(internal)
---@type nui_popup_border_internal_size
local delta = {
width = 0,
height = 0,
}
local char = internal.char
if type(char) == "table" then
if not is_empty_char(char.top) then
delta.height = delta.height + 1
end
if not is_empty_char(char.bottom) then
delta.height = delta.height + 1
end
if not is_empty_char(char.left) then
delta.width = delta.width + 1
end
if not is_empty_char(char.right) then
delta.width = delta.width + 1
end
end
local padding = internal.padding
if padding then
if padding.top then
delta.height = delta.height + padding.top
end
if padding.bottom then
delta.height = delta.height + padding.bottom
end
if padding.left then
delta.width = delta.width + padding.left
end
if padding.right then
delta.width = delta.width + padding.right
end
end
return delta
end
---@param border NuiPopupBorder
---@return nui_popup_border_internal_size
local function calculate_size(border)
---@type nui_popup_border_internal_size
local size = vim.deepcopy(border.popup._.size)
size.width = size.width + border._.size_delta.width
size.height = size.height + border._.size_delta.height
return size
end
---@param border NuiPopupBorder
---@return nui_popup_border_internal_position
local function calculate_position(border)
local position = vim.deepcopy(border.popup._.position)
position.col = position.col - math.floor(border._.size_delta.width / 2 + 0.5)
position.row = position.row - math.floor(border._.size_delta.height / 2 + 0.5)
return position
end
local function adjust_popup_win_config(border)
local internal = border._
local popup_position = {
row = 0,
col = 0,
}
local char = internal.char
if type(char) == "table" then
if not is_empty_char(char.top) then
popup_position.row = popup_position.row + 1
end
if not is_empty_char(char.left) then
popup_position.col = popup_position.col + 1
end
end
local padding = internal.padding
if padding then
if padding.top then
popup_position.row = popup_position.row + padding.top
end
if padding.left then
popup_position.col = popup_position.col + padding.left
end
end
local popup = border.popup
-- luacov: disable
if not has_nvim_0_5_1 then
popup.win_config.row = internal.position.row + popup_position.row
popup.win_config.col = internal.position.col + popup_position.col
return
end
-- luacov: enable
-- relative to the border window
popup.win_config.anchor = nil
popup.win_config.relative = "win"
popup.win_config.win = border.winid
popup.win_config.bufpos = nil
popup.win_config.row = popup_position.row
popup.win_config.col = popup_position.col
end
--luacheck: push no max line length
---@alias nui_t_text_align 'left'|'center'|'right'
---@alias nui_popup_border_internal_type 'simple'|'complex'
---@alias nui_popup_border_internal_position table<'row'|'col', number>
---@alias nui_popup_border_internal_size table<'height'|'width', number>
---@alias nui_popup_border_internal_padding _nui_popup_border_option_padding_map
---@alias nui_popup_border_internal_text { top?: NuiLine|NuiText, top_align?: nui_t_text_align, bottom?: NuiLine|NuiText, bottom_align?: nui_t_text_align }
---@alias _nui_popup_border_internal_char table<_nui_popup_border_style_map_position, NuiText>
---@alias _nui_popup_border_option_padding_list table<1|2|3|4, integer>
---@alias _nui_popup_border_option_padding_map table<'top'|'right'|'bottom'|'left', integer>
---@alias nui_popup_border_option_padding _nui_popup_border_option_padding_list|_nui_popup_border_option_padding_map
---@alias _nui_popup_border_style_char_tuple table<1|2, string>
---@alias _nui_popup_border_style_char string|_nui_popup_border_style_char_tuple|NuiText
---@alias _nui_popup_border_style_builtin 'double'|'none'|'rounded'|'shadow'|'single'|'solid'|'default'
---@alias _nui_popup_border_style_list table<1|2|3|4|5|6|7|8, _nui_popup_border_style_char>
---@alias _nui_popup_border_style_map_position 'top_left'|'top'|'top_right'|'right'|'bottom_right'|'bottom'|'bottom_left'|'left'
---@alias _nui_popup_border_style_map table<_nui_popup_border_style_map_position, _nui_popup_border_style_char>
---@alias nui_popup_border_option_style _nui_popup_border_style_builtin|_nui_popup_border_style_list|_nui_popup_border_style_map
---@alias _nui_popup_border_option_text_value string|NuiLine|NuiText|string[]|table<1|2, string>[]
---@alias nui_popup_border_option_text { top?: _nui_popup_border_option_text_value, top_align?: nui_t_text_align, bottom?: _nui_popup_border_option_text_value, bottom_align?: nui_t_text_align }
--luacheck: pop
---@class nui_popup_border_internal
---@field type nui_popup_border_internal_type
---@field style nui_popup_border_option_style
---@field char _nui_popup_border_internal_char
---@field padding? _nui_popup_border_option_padding_map
---@field position nui_popup_border_internal_position
---@field size nui_popup_border_internal_size
---@field size_delta nui_popup_border_internal_size
---@field text? nui_popup_border_internal_text
---@field lines? NuiLine[]
---@field winhighlight? string
---@class nui_popup_border_options
---@field padding? nui_popup_border_option_padding
---@field style? nui_popup_border_option_style
---@field text? nui_popup_border_option_text
---@class NuiPopupBorder
---@field bufnr integer
---@field private _ nui_popup_border_internal
---@field private popup NuiPopup
---@field win_config nui_popup_win_config
---@field winid number
local Border = Object("NuiPopupBorder")
---@param popup NuiPopup
---@param options nui_popup_border_options
function Border:init(popup, options)
self.popup = popup
self._ = {
---@deprecated
highlight = options.highlight,
padding = normalize_option_padding(options.padding),
text = normalize_option_text(options.text),
}
local internal = self._
if internal.text or internal.padding then
internal.type = "complex"
else
internal.type = "simple"
end
self:set_style(options.style or _.get_default_winborder())
internal.winhighlight = calculate_winhighlight(internal, self.popup._.win_options.winhighlight)
if internal.type == "simple" then
return self
end
self:_buf_create()
self.win_config = {
style = "minimal",
border = "none",
focusable = false,
zindex = self.popup.win_config.zindex,
anchor = self.popup.win_config.anchor,
}
if type(internal.char) == "string" then
self.win_config.border = internal.char
end
end
function Border:_open_window()
if self.winid or not self.bufnr then
return
end
self.win_config.noautocmd = true
self.winid = vim.api.nvim_open_win(self.bufnr, false, self.win_config)
self.win_config.noautocmd = nil
assert(self.winid, "failed to create border window")
if self._.winhighlight then
_.set_win_option(self.winid, "winhighlight", self._.winhighlight)
end
if self.popup._.win_options.winblend then
_.set_win_option(self.winid, "winblend", self.popup._.win_options.winblend)
end
adjust_popup_win_config(self)
vim.api.nvim_command("redraw")
end
function Border:_close_window()
if not self.winid then
return
end
if vim.api.nvim_win_is_valid(self.winid) then
vim.api.nvim_win_close(self.winid, true)
end
self.winid = nil
end
function Border:_buf_create()
if not self.bufnr or not vim.api.nvim_buf_is_valid(self.bufnr) then
self.bufnr = vim.api.nvim_create_buf(false, true)
vim.bo[self.bufnr].modifiable = true
assert(self.bufnr, "failed to create border buffer")
end
end
function Border:mount()
local popup = self.popup
if not popup._.loading or popup._.mounted then
return
end
local internal = self._
if internal.type == "simple" then
return
end
self:_buf_create()
if internal.lines then
_.render_lines(internal.lines, self.bufnr, popup.ns_id, 1, #internal.lines)
end
self:_open_window()
end
function Border:unmount()
local popup = self.popup
if not popup._.loading or not popup._.mounted then
return
end
local internal = self._
if internal.type == "simple" then
return
end
if self.bufnr then
if vim.api.nvim_buf_is_valid(self.bufnr) then
_.clear_namespace(self.bufnr, self.popup.ns_id)
vim.api.nvim_buf_delete(self.bufnr, { force = true })
end
self.bufnr = nil
end
self:_close_window()
end
function Border:_relayout()
local internal = self._
if internal.type ~= "complex" then
return
end
if self.popup.win_config.anchor and self.popup.win_config.anchor ~= self.win_config.anchor then
self.win_config.anchor = self.popup.win_config.anchor
self.popup.win_config.anchor = nil
end
local position = self.popup._.position
self.win_config.relative = position.relative
self.win_config.win = position.relative == "win" and position.win or nil
self.win_config.bufpos = position.bufpos
internal.size = calculate_size(self)
self.win_config.width = internal.size.width
self.win_config.height = internal.size.height
internal.position = calculate_position(self)
self.win_config.row = internal.position.row
self.win_config.col = internal.position.col
internal.lines = calculate_buf_lines(internal)
if self.winid then
vim.api.nvim_win_set_config(self.winid, self.win_config)
end
if self.bufnr then
if internal.lines then
_.render_lines(internal.lines, self.bufnr, self.popup.ns_id, 1, #internal.lines)
end
end
adjust_popup_win_config(self)
vim.api.nvim_command("redraw")
end
---@param edge "'top'" | "'bottom'"
---@param text? nil|string|NuiLine|NuiText
---@param align? nil | "'left'" | "'center'" | "'right'"
function Border:set_text(edge, text, align)
local internal = self._
if not internal.text then
return
end
internal.text[edge] = normalize_border_text(text)
internal.text[edge .. "_align"] = align or internal.text[edge .. "_align"]
if not internal.lines then
return
end
local line = calculate_buf_edge_line(
internal,
edge,
internal.text[edge],
internal.text[edge .. "_align"] --[[@as nui_t_text_align]]
)
local linenr = edge == "top" and 1 or #internal.lines
internal.lines[linenr] = line
line:render(self.bufnr, self.popup.ns_id, linenr)
end
---@param highlight string highlight group
function Border:set_highlight(highlight)
local internal = self._
local winhighlight_data = _.parse_winhighlight(self.popup._.win_options.winhighlight)
winhighlight_data["FloatBorder"] = highlight
self.popup._.win_options.winhighlight = _.serialize_winhighlight(winhighlight_data)
if self.popup.winid then
_.set_win_option(self.popup.winid, "winhighlight", self.popup._.win_options.winhighlight)
end
internal.winhighlight = calculate_winhighlight(internal, self.popup._.win_options.winhighlight)
if self.winid then
_.set_win_option(self.winid, "winhighlight", internal.winhighlight)
end
end
---@param style nui_popup_border_option_style
function Border:set_style(style)
local internal = self._
if style == "default" then
style = _.get_default_winborder()
if style == "none" and internal.type == "complex" then
style = "single"
end
end
internal.style = style
local char = prepare_char_map(internal.style, internal.char)
local is_borderless = type(char) == "string"
if is_borderless then
if not internal.char then -- initial
if internal.text then
error("text not supported for style:" .. char)
end
elseif internal.type == "complex" then -- subsequent
error("cannot change from previous style to " .. char)
end
end
internal.char = normalize_char_map(char)
internal.size_delta = calculate_size_delta(internal)
end
---@param char_map _nui_popup_border_internal_char
---@return _nui_popup_border_style_char_tuple[]
local function to_tuple_list(char_map)
---@type _nui_popup_border_style_char_tuple[]
local border = {}
for index, name in ipairs(index_name) do
if not char_map[name] then
error(string.format("missing named border: %s", name))
end
local char = char_map[name]
border[index] = { char:content(), char.extmark.hl_group }
end
return border
end
---@return nil|_nui_popup_border_style_builtin|_nui_popup_border_style_char_tuple[]
function Border:get()
local internal = self._
if internal.type ~= "simple" then
if has_nvim_0_11_0 then
return "none"
end
return nil
end
if type(internal.char) == "string" then
return internal.char
end
return to_tuple_list(internal.char)
end
---@alias NuiPopupBorder.constructor fun(popup: NuiPopup, options: nui_popup_border_options): NuiPopupBorder
---@type NuiPopupBorder|NuiPopupBorder.constructor
local NuiPopupBorder = Border
return NuiPopupBorder

View file

@ -0,0 +1,426 @@
local Border = require("nui.popup.border")
local Object = require("nui.object")
local buf_storage = require("nui.utils.buf_storage")
local autocmd = require("nui.utils.autocmd")
local keymap = require("nui.utils.keymap")
local utils = require("nui.utils")
local _ = utils._
local defaults = utils.defaults
local is_type = utils.is_type
local layout_utils = require("nui.layout.utils")
local u = {
clear_namespace = _.clear_namespace,
get_next_id = _.get_next_id,
size = layout_utils.size,
position = layout_utils.position,
update_layout_config = layout_utils.update_layout_config,
}
-- luacov: disable
-- @deprecated
---@param opacity number
---@deprecated
local function calculate_winblend(opacity)
assert(0 <= opacity, "opacity must be equal or greater than 0")
assert(opacity <= 1, "opacity must be equal or lesser than 0")
return 100 - (opacity * 100)
end
-- luacov: enable
local function merge_default_options(options)
options.relative = defaults(options.relative, "win")
options.enter = defaults(options.enter, false)
options.zindex = defaults(options.zindex, 50)
options.buf_options = defaults(options.buf_options, {})
options.win_options = defaults(options.win_options, {})
options.border = defaults(options.border, _.get_default_winborder())
return options
end
local function normalize_options(options)
options = _.normalize_layout_options(options)
if is_type("string", options.border) then
options.border = {
style = options.border,
}
end
return options
end
--luacheck: push no max line length
---@alias nui_popup_internal_position { relative: "'cursor'"|"'editor'"|"'win'", win: number, bufpos?: number[], row: number, col: number }
---@alias nui_popup_internal_size { height: number, width: number }
---@alias nui_popup_win_config { focusable: boolean, style: "'minimal'", zindex: number, relative: "'cursor'"|"'editor'"|"'win'", win?: number, bufpos?: number[], row: number, col: number, width: number, height: number, border?: string|table, anchor?: nui_layout_option_anchor }
--luacheck: pop
---@class nui_popup_internal
---@field augroup table<'hide'|'unmount', string>
---@field buf_options table<string, any>
---@field layout table
---@field layout_ready boolean
---@field loading boolean
---@field mounted boolean
---@field position nui_popup_internal_position
---@field size nui_popup_internal_size
---@field unmanaged_bufnr? boolean
---@field win_config nui_popup_win_config
---@field win_enter boolean
---@field win_options table<string, any>
---@class nui_popup_options
---@field border? _nui_popup_border_style_builtin|nui_popup_border_options
---@field ns_id? string|integer
---@field anchor? nui_layout_option_anchor
---@field relative? nui_layout_option_relative_type|nui_layout_option_relative
---@field position? number|string|nui_layout_option_position
---@field size? number|string|nui_layout_option_size
---@field enter? boolean
---@field focusable? boolean
---@field zindex? integer
---@field buf_options? table<string, any>
---@field win_options? table<string, any>
---@field bufnr? integer
---@class NuiPopup
---@field border NuiPopupBorder
---@field bufnr integer
---@field ns_id integer
---@field private _ nui_popup_internal
---@field win_config nui_popup_win_config
---@field winid number
local Popup = Object("NuiPopup")
---@param options nui_popup_options
function Popup:init(options)
local id = u.get_next_id()
options = merge_default_options(options)
options = normalize_options(options)
self._ = {
id = id,
buf_options = options.buf_options,
layout = {},
layout_ready = false,
loading = false,
mounted = false,
win_enter = options.enter,
win_options = options.win_options,
win_config = {
focusable = options.focusable,
style = "minimal",
anchor = options.anchor,
zindex = options.zindex,
},
augroup = {
hide = string.format("%s_hide", id),
unmount = string.format("%s_unmount", id),
},
}
self.win_config = self._.win_config
self.ns_id = _.normalize_namespace_id(options.ns_id)
if options.bufnr then
self.bufnr = options.bufnr
self._.unmanaged_bufnr = true
else
self:_buf_create()
end
-- luacov: disable
-- @deprecated
if not self._.win_options.winblend and is_type("number", options.opacity) then
self._.win_options.winblend = calculate_winblend(options.opacity)
end
-- @deprecated
if not self._.win_options.winhighlight and not is_type("nil", options.highlight) then
self._.win_options.winhighlight = options.highlight
end
-- luacov: enable
self.border = Border(self, options.border)
self.win_config.border = self.border:get()
if options.position and options.size then
self:update_layout(options)
end
end
function Popup:_open_window()
if self.winid or not self.bufnr then
return
end
self.win_config.noautocmd = true
self.winid = vim.api.nvim_open_win(self.bufnr, self._.win_enter, self.win_config)
self.win_config.noautocmd = nil
vim.api.nvim_win_call(self.winid, function()
autocmd.exec("BufWinEnter", {
buffer = self.bufnr,
modeline = false,
})
end)
assert(self.winid, "failed to create popup window")
_.set_win_options(self.winid, self._.win_options)
end
function Popup:_close_window()
if not self.winid then
return
end
if vim.api.nvim_win_is_valid(self.winid) then
vim.api.nvim_win_close(self.winid, true)
end
self.winid = nil
end
function Popup:_buf_create()
if not self.bufnr then
self.bufnr = vim.api.nvim_create_buf(false, true)
assert(self.bufnr, "failed to create buffer")
end
end
function Popup:mount()
if not self._.layout_ready then
return error("layout is not ready")
end
if self._.loading or self._.mounted then
return
end
self._.loading = true
autocmd.create_group(self._.augroup.hide, { clear = true })
autocmd.create_group(self._.augroup.unmount, { clear = true })
autocmd.create("QuitPre", {
group = self._.augroup.unmount,
buffer = self.bufnr,
callback = vim.schedule_wrap(function()
self:unmount()
end),
}, self.bufnr)
autocmd.create("BufWinEnter", {
group = self._.augroup.unmount,
buffer = self.bufnr,
callback = function()
-- When two popup using the same buffer and both of them
-- are hidden, calling `:show` for one of them fires
-- `BufWinEnter` for both of them. And in that scenario
-- one of them will not have `self.winid`.
if self.winid then
-- @todo skip registering `WinClosed` multiple times
-- for the same popup
autocmd.create("WinClosed", {
group = self._.augroup.hide,
nested = true,
pattern = tostring(self.winid),
callback = function()
self:hide()
end,
}, self.bufnr)
end
end,
}, self.bufnr)
self.border:mount()
self:_buf_create()
_.set_buf_options(self.bufnr, self._.buf_options)
self:_open_window()
self._.loading = false
self._.mounted = true
end
function Popup:hide()
if self._.loading or not self._.mounted then
return
end
self._.loading = true
pcall(autocmd.delete_group, self._.augroup.hide)
self.border:_close_window()
self:_close_window()
self._.loading = false
end
function Popup:show()
if self._.loading then
return
end
if not self._.mounted then
return self:mount()
end
self._.loading = true
autocmd.create_group(self._.augroup.hide, { clear = true })
self.border:_open_window()
self:_open_window()
self._.loading = false
end
function Popup:_buf_destroy()
if self.bufnr then
if vim.api.nvim_buf_is_valid(self.bufnr) then
u.clear_namespace(self.bufnr, self.ns_id)
if not self._.unmanaged_bufnr then
vim.api.nvim_buf_delete(self.bufnr, { force = true })
end
end
buf_storage.cleanup(self.bufnr)
if not self._.unmanaged_bufnr then
self.bufnr = nil
end
end
end
function Popup:unmount()
if self._.loading or not self._.mounted then
return
end
self._.loading = true
pcall(autocmd.delete_group, self._.augroup.hide)
pcall(autocmd.delete_group, self._.augroup.unmount)
self.border:unmount()
self:_buf_destroy()
self:_close_window()
self._.loading = false
self._.mounted = false
end
-- set keymap for this popup window
---@param mode string check `:h :map-modes`
---@param key string|string[] key for the mapping
---@param handler string | fun(): nil handler for the mapping
---@param opts? table<"'expr'"|"'noremap'"|"'nowait'"|"'remap'"|"'script'"|"'silent'"|"'unique'", boolean>
---@return nil
function Popup:map(mode, key, handler, opts, ___force___)
if not self.bufnr then
error("popup buffer not found.")
end
return keymap.set(self.bufnr, mode, key, handler, opts, ___force___)
end
---@param mode string check `:h :map-modes`
---@param key string|string[] key for the mapping
---@return nil
function Popup:unmap(mode, key, ___force___)
if not self.bufnr then
error("popup buffer not found.")
end
return keymap._del(self.bufnr, mode, key, ___force___)
end
---@param event string | string[]
---@param handler string | function
---@param options? table<"'once'" | "'nested'", boolean>
function Popup:on(event, handler, options)
if not self.bufnr then
error("popup buffer not found.")
end
autocmd.buf.define(self.bufnr, event, handler, options)
end
---@param event? string | string[]
function Popup:off(event)
if not self.bufnr then
error("popup buffer not found.")
end
autocmd.buf.remove(self.bufnr, nil, event)
end
-- luacov: disable
-- @deprecated
-- Use `popup:update_layout`.
---@deprecated
function Popup:set_layout(config)
return self:update_layout(config)
end
-- luacov: enable
---@param config? nui_layout_options
function Popup:update_layout(config)
config = config or {}
u.update_layout_config(self._, config)
self.border:_relayout()
self._.layout_ready = true
if self.winid then
-- upstream issue: https://github.com/neovim/neovim/issues/20370
local win_config_style = self.win_config.style
---@diagnostic disable-next-line: assign-type-mismatch
self.win_config.style = ""
vim.api.nvim_win_set_config(self.winid, self.win_config)
self.win_config.style = win_config_style
end
end
-- luacov: disable
-- @deprecated
-- Use `popup:update_layout`.
---@deprecated
function Popup:set_size(size)
self:update_layout({ size = size })
end
-- luacov: enable
-- luacov: disable
-- @deprecated
-- Use `popup:update_layout`.
---@deprecated
function Popup:set_position(position, relative)
self:update_layout({ position = position, relative = relative })
end
-- luacov: enable
---@alias NuiPopup.constructor fun(options: nui_popup_options): NuiPopup
---@type NuiPopup|NuiPopup.constructor
local NuiPopup = Popup
return NuiPopup

View file

@ -0,0 +1,91 @@
# Split
Split is can be used to split your current window or editor.
```lua
local Split = require("nui.split")
local split = Split({
relative = "editor",
position = "bottom",
size = "20%",
})
```
You can manipulate the associated buffer and window using the
`split.bufnr` and `split.winid` properties.
## Options
### `ns_id`
**Type:** `number` or `string`
Namespace id (`number`) or name (`string`).
### `relative`
**Type:** `string` or `table`
This option affects how `size` is calculated.
**Examples**
Split current editor screen:
```lua
relative = "editor"
```
Split current window (_default_):
```lua
relative = "win"
```
Split window with specific id:
```lua
relative = {
type = "win",
winid = 42,
}
```
### `position`
`position` can be one of: `"top"`, `"right"`, `"bottom"` or `"left"`.
### `size`
`size` can be `number` or `percentage string`.
For `percentage string`, size is calculated according to the option `relative`.
### `enter`
**Type:** `boolean`
If `false`, the split is not entered immediately after mount.
**Examples**
```lua
enter = false
```
### `buf_options`
Table containing buffer options to set for this split.
### `win_options`
Table containing window options to set for this split.
## Methods
[Methods from `nui.popup`](/lua/nui/popup#methods) are also available for `nui.split`.
## Wiki Page
You can find additional documentation/examples/guides/tips-n-tricks in [nui.split wiki page](https://github.com/MunifTanjim/nui.nvim/wiki/nui.split).

View file

@ -0,0 +1,378 @@
local Object = require("nui.object")
local buf_storage = require("nui.utils.buf_storage")
local autocmd = require("nui.utils.autocmd")
local keymap = require("nui.utils.keymap")
local utils = require("nui.utils")
local split_utils = require("nui.split.utils")
local u = {
clear_namespace = utils._.clear_namespace,
get_next_id = utils._.get_next_id,
normalize_namespace_id = utils._.normalize_namespace_id,
split = split_utils,
}
local split_direction_command_map = {
editor = {
top = "topleft",
right = "vertical botright",
bottom = "botright",
left = "vertical topleft",
},
win = {
top = "aboveleft",
right = "vertical rightbelow",
bottom = "belowright",
left = "vertical leftabove",
},
}
---@param winid integer
---@param win_config _nui_split_internal_win_config
local function move_split_window(winid, win_config)
if win_config.relative == "editor" then
vim.api.nvim_win_call(winid, function()
vim.cmd("wincmd " .. ({ top = "K", right = "L", bottom = "J", left = "H" })[win_config.position])
end)
elseif win_config.relative == "win" then
local move_options = {
vertical = win_config.position == "left" or win_config.position == "right",
rightbelow = win_config.position == "bottom" or win_config.position == "right",
}
vim.cmd(
string.format(
"noautocmd call win_splitmove(%s, %s, #{ vertical: %s, rightbelow: %s })",
winid,
win_config.win,
move_options.vertical and 1 or 0,
move_options.rightbelow and 1 or 0
)
)
end
end
---@param winid integer
---@param win_config _nui_split_internal_win_config
local function set_win_config(winid, win_config)
if win_config.pending_changes.position then
move_split_window(winid, win_config)
end
if win_config.pending_changes.size then
if win_config.width then
vim.api.nvim_win_set_width(winid, win_config.width)
elseif win_config.height then
vim.api.nvim_win_set_height(winid, win_config.height)
end
end
win_config.pending_changes = {}
end
--luacheck: push no max line length
---@alias nui_split_option_relative_type 'editor'|'win'
---@alias nui_split_option_relative { type: nui_split_option_relative_type, winid?: number }
---@alias nui_split_option_position "'top'"|"'right'"|"'bottom'"|"'left'"
---@alias nui_split_option_size { height?: number|string }|{ width?: number|string }
---@alias _nui_split_internal_relative { type: nui_split_option_relative_type, win: number }
---@alias _nui_split_internal_win_config { height?: number, width?: number, position: nui_split_option_position, relative: nui_split_option_relative, win?: integer, pending_changes: table<'position'|'size', boolean> }
--luacheck: pop
---@class nui_split_internal
---@field enter? boolean
---@field loading boolean
---@field mounted boolean
---@field buf_options table<string, any>
---@field win_options table<string, any>
---@field position nui_split_option_position
---@field relative _nui_split_internal_relative
---@field size { height?: number }|{ width?: number }
---@field win_config _nui_split_internal_win_config
---@field pending_quit? boolean
---@field augroup table<'hide'|'unmount', string>
---@class nui_split_options
---@field ns_id? string|integer
---@field relative? nui_split_option_relative_type|nui_split_option_relative
---@field position? nui_split_option_position
---@field size? number|string|nui_split_option_size
---@field enter? boolean
---@field buf_options? table<string, any>
---@field win_options? table<string, any>
---@class NuiSplit
---@field private _ nui_split_internal
---@field bufnr integer
---@field ns_id integer
---@field winid number
local Split = Object("NuiSplit")
---@param options nui_split_options
function Split:init(options)
local id = u.get_next_id()
options = u.split.merge_default_options(options)
options = u.split.normalize_options(options)
self._ = {
id = id,
enter = options.enter,
buf_options = options.buf_options,
loading = false,
mounted = false,
layout = {},
position = options.position,
size = {},
win_options = options.win_options,
win_config = {
pending_changes = {},
},
augroup = {
hide = string.format("%s_hide", id),
unmount = string.format("%s_unmount", id),
},
}
self.ns_id = u.normalize_namespace_id(options.ns_id)
self:_buf_create()
self:update_layout(options)
end
--luacheck: push no max line length
---@param config { relative?: nui_split_option_relative_type|nui_split_option_relative, position?: nui_split_option_position, size?: number|string|nui_split_option_size }
function Split:update_layout(config)
config = config or {}
u.split.update_layout_config(self._, config)
if self.winid then
set_win_config(self.winid, self._.win_config)
end
end
--luacheck: pop
function Split:_open_window()
if self.winid or not self.bufnr then
return
end
self.winid = vim.api.nvim_win_call(self._.relative.type == "editor" and 0 or self._.relative.win, function()
vim.api.nvim_command(
string.format(
"silent noswapfile %s %ssplit",
split_direction_command_map[self._.relative.type][self._.position],
self._.size.width or self._.size.height or ""
)
)
return vim.api.nvim_get_current_win()
end)
vim.api.nvim_win_set_buf(self.winid, self.bufnr)
if self._.enter then
vim.api.nvim_set_current_win(self.winid)
end
self._.win_config.pending_changes = { size = true }
set_win_config(self.winid, self._.win_config)
utils._.set_win_options(self.winid, self._.win_options)
end
function Split:_close_window()
if not self.winid then
return
end
if vim.api.nvim_win_is_valid(self.winid) and not self._.pending_quit then
vim.api.nvim_win_close(self.winid, true)
end
self.winid = nil
end
function Split:_buf_create()
if not self.bufnr then
self.bufnr = vim.api.nvim_create_buf(false, true)
assert(self.bufnr, "failed to create buffer")
end
end
function Split:mount()
if self._.loading or self._.mounted then
return
end
self._.loading = true
autocmd.create_group(self._.augroup.hide, { clear = true })
autocmd.create_group(self._.augroup.unmount, { clear = true })
autocmd.create("QuitPre", {
group = self._.augroup.unmount,
buffer = self.bufnr,
callback = function()
self._.pending_quit = true
vim.schedule(function()
self:unmount()
self._.pending_quit = nil
end)
end,
}, self.bufnr)
autocmd.create("BufWinEnter", {
group = self._.augroup.unmount,
buffer = self.bufnr,
callback = function()
-- When two splits using the same buffer and both of them
-- are hidden, calling `:show` for one of them fires
-- `BufWinEnter` for both of them. And in that scenario
-- one of them will not have `self.winid`.
if self.winid then
autocmd.create("WinClosed", {
group = self._.augroup.hide,
nested = true,
pattern = tostring(self.winid),
callback = function()
self:hide()
end,
}, self.bufnr)
end
end,
}, self.bufnr)
self:_buf_create()
utils._.set_buf_options(self.bufnr, self._.buf_options)
self:_open_window()
self._.loading = false
self._.mounted = true
end
function Split:hide()
if self._.loading or not self._.mounted then
return
end
self._.loading = true
pcall(autocmd.delete_group, self._.augroup.hide)
self:_close_window()
self._.loading = false
end
function Split:show()
if self._.loading then
return
end
if not self._.mounted then
return self:mount()
end
self._.loading = true
autocmd.create_group(self._.augroup.hide, { clear = true })
self:_open_window()
self._.loading = false
end
function Split:_buf_destroy()
if self.bufnr then
if vim.api.nvim_buf_is_valid(self.bufnr) then
u.clear_namespace(self.bufnr, self.ns_id)
if not self._.pending_quit then
vim.api.nvim_buf_delete(self.bufnr, { force = true })
end
end
buf_storage.cleanup(self.bufnr)
self.bufnr = nil
end
end
function Split:unmount()
if self._.loading or not self._.mounted then
return
end
self._.loading = true
pcall(autocmd.delete_group, self._.augroup.hide)
pcall(autocmd.delete_group, self._.augroup.unmount)
self:_buf_destroy()
self:_close_window()
self._.loading = false
self._.mounted = false
end
-- set keymap for this split
---@param mode string check `:h :map-modes`
---@param key string|string[] key for the mapping
---@param handler string | fun(): nil handler for the mapping
---@param opts? table<"'expr'"|"'noremap'"|"'nowait'"|"'remap'"|"'script'"|"'silent'"|"'unique'", boolean>
---@return nil
function Split:map(mode, key, handler, opts, ___force___)
if not self.bufnr then
error("split buffer not found.")
end
return keymap.set(self.bufnr, mode, key, handler, opts, ___force___)
end
---@param mode string check `:h :map-modes`
---@param key string|string[] key for the mapping
---@return nil
function Split:unmap(mode, key)
if not self.bufnr then
error("split buffer not found.")
end
return keymap._del(self.bufnr, mode, key)
end
---@param event string | string[]
---@param handler string | function
---@param options? table<"'once'" | "'nested'", boolean>
function Split:on(event, handler, options)
if not self.bufnr then
error("split buffer not found.")
end
autocmd.buf.define(self.bufnr, event, handler, options)
end
---@param event? string | string[]
function Split:off(event)
if not self.bufnr then
error("split buffer not found.")
end
autocmd.buf.remove(self.bufnr, nil, event)
end
---@alias NuiSplit.constructor fun(options: nui_split_options): NuiSplit
---@type NuiSplit|NuiSplit.constructor
local NuiSplit = Split
return NuiSplit

View file

@ -0,0 +1,177 @@
local utils = require("nui.utils")
local layout_utils = require("nui.layout.utils")
local u = {
defaults = utils.defaults,
get_editor_size = utils.get_editor_size,
get_window_size = utils.get_window_size,
is_type = utils.is_type,
normalize_dimension = utils._.normalize_dimension,
size = layout_utils.size,
}
local mod = {}
---@param size number|string|nui_split_option_size
---@param position nui_split_option_position
---@return number|string size
local function to_split_size(size, position)
if not u.is_type("table", size) then
---@cast size number|string
return size
end
if position == "left" or position == "right" then
return size.width
end
return size.height
end
---@param options table
---@return table options
function mod.merge_default_options(options)
options.relative = u.defaults(options.relative, "win")
options.position = u.defaults(options.position, vim.go.splitbelow and "bottom" or "top")
options.enter = u.defaults(options.enter, true)
options.buf_options = u.defaults(options.buf_options, {})
options.win_options = vim.tbl_extend("force", {
winfixwidth = true,
winfixheight = true,
}, u.defaults(options.win_options, {}))
return options
end
---@param options nui_split_options
function mod.normalize_layout_options(options)
if utils.is_type("string", options.relative) then
options.relative = {
---@diagnostic disable-next-line: assign-type-mismatch
type = options.relative,
}
end
return options
end
---@param options nui_split_options
function mod.normalize_options(options)
options = mod.normalize_layout_options(options)
return options
end
local function parse_relative(relative, fallback_winid)
local winid = u.defaults(relative.winid, fallback_winid)
return {
type = relative.type,
win = winid,
}
end
---@param relative _nui_split_internal_relative
---@return { size: { height: integer, width: integer }, type: 'editor'|'window' }
local function get_container_info(relative)
if relative.type == "editor" then
local size = u.get_editor_size()
-- best effort adjustments
size.height = size.height - vim.api.nvim_get_option("cmdheight")
if vim.api.nvim_get_option("laststatus") >= 2 then
size.height = size.height - 1
end
if vim.api.nvim_get_option("showtabline") == 2 then
size.height = size.height - 1
end
return {
size = size,
type = "editor",
}
end
return {
size = u.get_window_size(relative.win),
type = "window",
}
end
---@param position nui_split_option_position
---@param size number|string
---@param container_size { width: number, height: number }
---@return { width?: number, height?: number }
function mod.calculate_window_size(position, size, container_size)
if not size then
return {}
end
if position == "left" or position == "right" then
return {
width = u.normalize_dimension(size, container_size.width),
}
end
return {
height = u.normalize_dimension(size, container_size.height),
}
end
function mod.update_layout_config(component_internal, config)
local internal = component_internal
local options = mod.normalize_layout_options({
relative = config.relative,
position = config.position,
size = config.size,
})
if internal.relative and internal.relative.win and not vim.api.nvim_win_is_valid(internal.relative.win) then
internal.relative.win = vim.api.nvim_get_current_win()
internal.win_config.win = internal.relative.win
internal.win_config.pending_changes.relative = true
end
if options.relative then
local fallback_winid = internal.relative and internal.relative.win or vim.api.nvim_get_current_win()
internal.relative = parse_relative(options.relative, fallback_winid)
local prev_relative = internal.win_config.relative
local prev_win = internal.win_config.win
internal.win_config.relative = internal.relative.type
internal.win_config.win = internal.relative.type == "win" and internal.relative.win or nil
internal.win_config.pending_changes.relative = internal.win_config.relative ~= prev_relative
or internal.win_config.win ~= prev_win
end
if options.position or internal.win_config.pending_changes.relative then
local prev_position = internal.win_config.position
internal.position = options.position or internal.position
internal.win_config.position = internal.position
internal.win_config.pending_changes.position = internal.win_config.position ~= prev_position
end
if options.size or internal.win_config.pending_changes.position or internal.win_config.pending_changes.relative then
internal.layout.size = to_split_size(options.size or internal.layout.size, internal.position)
internal.container_info = get_container_info(internal.relative)
internal.size = mod.calculate_window_size(internal.position, internal.layout.size, internal.container_info.size)
internal.win_config.width = internal.size.width
internal.win_config.height = internal.size.height
internal.win_config.pending_changes.size = true
end
end
return mod

View file

@ -0,0 +1,115 @@
# NuiTable
NuiTable can render table-like structured content on the buffer.
**Examples**
```lua
local NuiTable = require("nui.table")
local tbl = NuiTable({
bufnr = bufnr,
columns = {
{
align = "center",
header = "Name",
columns = {
{ accessor_key = "firstName", header = "First" },
{
id = "lastName",
accessor_fn = function(row)
return row.lastName
end,
header = "Last",
},
},
},
{
align = "right",
accessor_key = "age",
cell = function(cell)
return Text(tostring(cell.get_value()), "DiagnosticInfo")
end,
header = "Age",
},
},
data = {
{ firstName = "John", lastName = "Doe", age = 42 },
{ firstName = "Jane", lastName = "Doe", age = 27 },
},
})
tbl:render()
```
## Options
### `bufnr`
**Type:** `number`
Id of the buffer where the table will be rendered.
---
### `ns_id`
**Type:** `number` or `string`
Namespace id (`number`) or name (`string`).
---
### `columns`
**Type:** `NuiTable.ColumnDef[]`
List of `NuiTable.ColumnDef` objects.
---
### `data`
**Type:** `any[]`
List of data items.
## Methods
### `tbl:get_cell`
_Signature:_ `tbl:get_cell(position?: {integer, integer}) -> NuiTable.Cell | nil`
**Parameters**
| Name | Type | Description |
| ---------- | ---------------------- | ------------------------------------- |
| `position` | `{ integer, integer }` | `(row, col)` tuple relative to cursor |
Returns the `NuiTable.Cell` if found.
### `tbl:refresh_cell`
_Signature:_ `tbl:refresh_cell(cell: NuiTable.Cell) -> nil`
Refreshes the `cell` on buffer.
**Parameters**
| Name | Type | Description |
| ------ | --------------- | ----------- |
| `cell` | `NuiTable.Cell` | cell |
### `tbl:render`
_Signature:_ `tbl:render(linenr_start?: integer) -> nil`
Renders the table on buffer.
| Name | Type | Description |
| -------------- | ----------------- | ----------------------------- |
| `linenr_start` | `integer` / `nil` | start line number (1-indexed) |
## Wiki Page
You can find additional documentation/examples/guides/tips-n-tricks in [nui.table wiki page](https://github.com/MunifTanjim/nui.nvim/wiki/nui.table).

View file

@ -0,0 +1,675 @@
local Object = require("nui.object")
local Text = require("nui.text")
local Line = require("nui.line")
local _ = require("nui.utils")._
-- luacheck: push no max comment line length
---@alias nui_table_border_char_name 'down_right'|'hor'|'down_hor'|'down_left'|'ver'|'ver_left'|'ver_hor'|'ver_left'|'up_right'|'up_hor'|'up_left'
---@alias _nui_table_header_kind
---| -1 -- footer
---| 1 -- header
---@class nui_t_list<T>: { [integer]: T, len: integer }
-- luacheck: pop
---@type table<nui_table_border_char_name,string>
local default_border = {
hor = "",
ver = "",
down_right = "",
down_hor = "",
down_left = "",
ver_right = "",
ver_hor = "",
ver_left = "",
up_right = "",
up_hor = "",
up_left = "",
}
---@param internal nui_table_internal
---@param columns NuiTable.ColumnDef[]
---@param parent? NuiTable.ColumnDef
---@param depth? integer
local function prepare_columns(internal, columns, parent, depth)
for _, col in ipairs(columns) do
if col.header then
internal.has_header = true
end
if col.footer then
internal.has_footer = true
end
if not col.id then
if col.accessor_key then
col.id = col.accessor_key
elseif type(col.header) == "string" then
col.id = col.header --[[@as string]]
elseif type(col.header) == "table" then
col.id = (col.header --[[@as NuiText|NuiLine]]):content()
end
end
if not col.id then
error("missing column id")
end
if col.accessor_key and not col.accessor_fn then
col.accessor_fn = function(row)
return row[col.accessor_key]
end
end
col.depth = depth or 0
col.parent = parent
if parent and not col.header then
col.header = col.id
internal.has_header = true
end
if col.columns then
prepare_columns(internal, col.columns, col, col.depth + 1)
else
table.insert(internal.columns, col)
end
if col.depth == 0 then
table.insert(internal.headers, col)
else
internal.headers.depth = math.max(internal.headers.depth, col.depth + 1)
end
if not col.align then
col.align = "left"
end
if not col.width then
col.width = 0
end
end
end
---@class NuiTable.ColumnDef
---@field accessor_fn? fun(original_row: table, index: integer): string|NuiText|NuiLine
---@field accessor_key? string
---@field align? nui_t_text_align
---@field cell? fun(info: NuiTable.Cell): string|NuiText|NuiLine
---@field columns? NuiTable.ColumnDef[]
---@field footer? string|NuiText|NuiLine|fun(info: { column: NuiTable.Column }): string|NuiText|NuiLine
---@field header? string|NuiText|NuiLine|fun(info: { column: NuiTable.Column }): string|NuiText|NuiLine
---@field id? string
---@field max_width? integer
---@field min_width? integer
---@field width? integer
---@class NuiTable.Column
---@field accessor_fn? fun(original_row: table, index: integer): string|NuiText|NuiLine
---@field accessor_key? string
---@field align nui_t_text_align
---@field columns? NuiTable.ColumnDef[]
---@field depth integer
---@field id string
---@field parent? NuiTable.Column
---@field width integer
---@class NuiTable.Row
---@field id string
---@field index integer
---@field original table
---@class NuiTable.Cell
---@field column NuiTable.Column
---@field content NuiText|NuiLine
---@field get_value fun(): string|NuiText|NuiLine
---@field row NuiTable.Row
---@field range table<1|2|3|4, integer> -- [start_row, start_col, end_row, end_col]
---@class nui_table_internal
---@field border table
---@field buf_options table<string, any>
---@field headers NuiTable.Column[]|{ depth: integer }
---@field columns NuiTable.ColumnDef[]
---@field data table[]
---@field has_header boolean
---@field has_footer boolean
---@field linenr table<1|2, integer>
---@field data_linenrs integer[]
---@field data_grid nui_t_list<NuiTable.Cell[]>
---@class nui_table_options
---@field bufnr integer
---@field ns_id integer|string
---@field columns NuiTable.ColumnDef[]
---@field data table[]
---@class NuiTable
---@field private _ nui_table_internal
---@field bufnr integer
---@field ns_id integer
local Table = Object("NuiTable")
---@param options nui_table_options
function Table:init(options)
if options.bufnr then
if not vim.api.nvim_buf_is_valid(options.bufnr) then
error("invalid bufnr " .. options.bufnr)
end
self.bufnr = options.bufnr
end
if not self.bufnr then
error("missing bufnr")
end
self.ns_id = _.normalize_namespace_id(options.ns_id)
local border = vim.tbl_deep_extend("keep", options.border or {}, default_border)
self._ = {
buf_options = vim.tbl_extend("force", {
bufhidden = "hide",
buflisted = false,
buftype = "nofile",
modifiable = false,
readonly = true,
swapfile = false,
undolevels = 0,
}, options.buf_options or {}),
border = border,
headers = { depth = 1 },
columns = {},
data = options.data or {},
has_header = false,
has_footer = false,
linenr = {},
data_linenrs = {},
}
prepare_columns(self._, options.columns or {})
_.set_buf_options(self.bufnr, self._.buf_options)
end
---@param current_width integer
---@param min_width? integer
---@param max_width? integer
---@param content_width integer
local function get_col_width(current_width, min_width, max_width, content_width)
local min = math.max(content_width, min_width or 0)
return math.max(current_width, math.min(max_width or min, min))
end
---@generic C: table
---@param idx integer
---@param grid nui_t_list<nui_t_list<C>>
---@param kind _nui_table_header_kind
---@return nui_t_list<C> header_row
local function get_header_row_at(idx, grid, kind)
local row = grid[idx]
if not row then
row = { len = 0 }
grid[idx] = row
grid.len = math.max(grid.len, kind * idx)
end
return row
end
---@generic C: table
---@param kind _nui_table_header_kind
---@param columns (NuiTable.ColumnDef|{ depth: integer })[]
---@param grid nui_t_list<nui_t_list<C>>
---@param max_depth integer
local function prepare_header_grid(kind, columns, grid, max_depth)
local columns_len = #columns
for column_idx = 1, columns_len do
local column = columns[column_idx]
local row_idx = kind + kind * column.depth
local row = get_header_row_at(row_idx, grid, kind)
local content = kind == 1 and column.header or kind == -1 and column.footer or Text("")
if type(content) == "function" then
--[[@cast column NuiTable.Column]]
content = content({ column = column })
--[[@cast content -function]]
end
if type(content) ~= "table" then
content = Text(content --[[@as string]])
--[[@cast content -string]]
end
column.width = get_col_width(column.width, column.min_width, column.max_width, content:width())
local cell = {
column = column,
content = content,
col_span = 1,
row_span = 1,
ridx = 1,
}
row.len = row.len + 1
row[row.len] = cell
if column.columns then
cell.col_span = #column.columns
prepare_header_grid(kind, column.columns, grid, max_depth)
else
cell.row_span = max_depth - column.depth
for i = 1, cell.row_span - 1 do
local span_row = get_header_row_at(row_idx + i * kind, grid, kind)
span_row.len = span_row.len + 1
span_row[span_row.len] = vim.tbl_extend("keep", { ridx = i + 1 }, cell)
end
end
end
end
---@param cell NuiTable.Cell
---@return NuiText|NuiLine
local function prepare_cell_content(cell)
local column = cell.column --[[@as NuiTable.ColumnDef|NuiTable.Column]]
local content = column.cell and column.cell(cell) or cell.get_value()
if type(content) ~= "table" then
content = Text(tostring(content))
end
return content
end
---@return nui_t_list<NuiTable.Cell[]> data_grid
---@return nui_t_list<nui_t_list<table>> header_grid
function Table:_prepare_grid()
---@type nui_t_list<NuiTable.Cell[]>
local data_grid = {}
---@type nui_t_list<nui_t_list<table>>
local header_grid = { len = 0 }
if self._.has_header then
prepare_header_grid(1, self._.headers, header_grid, self._.headers.depth)
end
local rows = self._.data
local rows_len = #rows
local columns = self._.columns
local columns_len = #columns
for row_idx = 1, rows_len do
local data = rows[row_idx]
data_grid[row_idx] = {}
---@type NuiTable.Row
local row = {
id = tostring(row_idx),
index = row_idx,
original = data,
}
for column_idx = 1, columns_len do
local column = columns[column_idx]
---@type NuiTable.Cell
local cell = {
row = row,
column = column,
get_value = function()
return column.accessor_fn(row.original, row.index)
end,
}
cell.content = prepare_cell_content(cell)
column.width = get_col_width(column.width, column.min_width, column.max_width, cell.content:width())
data_grid[row_idx][column_idx] = cell
end
end
if self._.has_footer then
prepare_header_grid(-1, self._.headers, header_grid, self._.headers.depth)
end
for idx = -header_grid.len, header_grid.len do
for _, th in ipairs(header_grid[idx] or {}) do
local column = th.column
if column.columns then
column.width = 0
for i = 1, th.col_span do
column.width = column.width + column.columns[i].width
end
column.width = column.width + th.col_span - 1
end
end
end
data_grid.len = rows_len
return data_grid, header_grid
end
---@param line NuiLine
---@param content NuiLine|NuiText
---@param width integer
---@param align nui_t_text_align
local function append_content(line, content, width, align)
if content._texts then
--[[@cast content NuiLine]]
_.truncate_nui_line(content, width)
else
--[[@cast content NuiText]]
_.truncate_nui_text(content, width)
end
local left_gap_width, right_gap_width = _.calculate_gap_width(align, width, content:width())
if left_gap_width > 0 then
line:append(Text(string.rep(" ", left_gap_width)))
end
line:append(content)
if right_gap_width > 0 then
line:append(Text(string.rep(" ", right_gap_width)))
end
return line
end
---@param kind _nui_table_header_kind
---@param lines nui_t_list<NuiLine>
---@param grid nui_t_list<nui_t_list<table>>
function Table:_prepare_header_lines(kind, lines, grid)
local line_idx = lines.len
local start_idx, end_idx = 1, grid.len
if kind == -1 then
start_idx, end_idx = -grid.len, -1
end
local border = self._.border
for row_idx = start_idx, end_idx do
local row = grid[row_idx]
if not row then
break
end
local inner_border_line = Line()
local data_line = Line()
local outer_border_line = Line()
outer_border_line:append(kind == 1 and border.down_right or border.up_right)
data_line:append(border.ver)
local cells_len = #row
for cell_idx = 1, cells_len do
local prev_cell = row[cell_idx - 1]
local cell = row[cell_idx]
local next_cell = row[cell_idx + 1]
if cell.row_span == cell.ridx then
if cell_idx == 1 or (prev_cell and prev_cell.ridx ~= prev_cell.row_span) then
inner_border_line:append(border.ver_right)
else
inner_border_line:append(border.ver_hor)
end
elseif next_cell then
inner_border_line:append(border.ver)
else
inner_border_line:append(border.ver_left)
end
local column = cell.column
if column.columns then
for sc_idx = 1, cell.col_span do
local sub_column = column.columns[sc_idx]
inner_border_line:append(string.rep(border.hor, sub_column.width))
if sc_idx ~= cell.col_span then
inner_border_line:append(kind == 1 and border.down_hor or border.up_hor)
end
end
else
if cell.ridx == cell.row_span then
inner_border_line:append(string.rep(border.hor, column.width))
else
inner_border_line:append(string.rep(" ", column.width))
end
end
if cell.ridx == cell.row_span then
append_content(data_line, cell.content, column.width, column.align)
else
append_content(data_line, Text(""), column.width, column.align)
end
data_line:append(border.ver)
outer_border_line:append(string.rep(border.hor, column.width))
outer_border_line:append(kind == 1 and border.down_hor or border.up_hor)
end
local last_cell = row[cells_len]
if last_cell.ridx == last_cell.row_span then
inner_border_line:append(border.ver_left)
else
inner_border_line:append(border.ver)
end
outer_border_line._texts[#outer_border_line._texts]:set(kind == 1 and border.down_left or border.up_left)
if kind == -1 then
line_idx = line_idx + 1
lines[line_idx] = inner_border_line
elseif row_idx == 1 then
line_idx = line_idx + 1
lines[line_idx] = outer_border_line
end
line_idx = line_idx + 1
lines[line_idx] = data_line
if kind == 1 then
line_idx = line_idx + 1
lines[line_idx] = inner_border_line
elseif row_idx == -1 then
line_idx = line_idx + 1
lines[line_idx] = outer_border_line
end
end
lines.len = line_idx
end
---@param linenr_start? integer start line number (1-indexed)
function Table:render(linenr_start)
if #self._.columns == 0 then
return
end
linenr_start = math.max(1, linenr_start or self._.linenr[1] or 1)
local prev_linenr = { self._.linenr[1], self._.linenr[2] }
local data_grid, header_grid = self:_prepare_grid()
self._.data_grid = data_grid
local line_idx = 0
---@type nui_t_list<NuiLine>
local lines = { len = line_idx }
self:_prepare_header_lines(1, lines, header_grid)
line_idx = lines.len
local border = self._.border
local rows_len = data_grid.len
if line_idx == 0 and rows_len > 0 then
local columns = self._.columns
local columns_len = #columns
local top_border_line = Line()
top_border_line:append(border.down_right)
for column_idx = 1, columns_len do
local column = columns[column_idx]
top_border_line:append(string.rep(border.hor, column.width))
if column_idx ~= columns_len then
top_border_line:append(border.down_hor)
end
end
top_border_line:append(border.down_left)
line_idx = line_idx + 1
lines[line_idx] = top_border_line
end
local data_linenrs = self._.data_linenrs
for row_idx = 1, rows_len do
local char_idx = 0
local is_last_line = row_idx == rows_len
local bottom_border_mid = is_last_line and border.up_hor or border.ver_hor
local row = data_grid[row_idx]
local data_line = Line()
local bottom_border_line = Line()
local data_linenr = line_idx + linenr_start
data_line:append(border.ver)
char_idx = char_idx + 1
bottom_border_line:append(is_last_line and border.up_right or border.ver_right)
local cells_len = #row
for cell_idx = 1, cells_len do
local cell = row[cell_idx]
local column = cell.column
append_content(data_line, cell.content, column.width, column.align)
data_line:append(border.ver)
cell.range = { data_linenr, char_idx, data_linenr, char_idx + column.width }
char_idx = cell.range[4] + 1
bottom_border_line:append(string.rep(border.hor, column.width))
bottom_border_line:append(bottom_border_mid)
end
bottom_border_line._texts[#bottom_border_line._texts]:set(is_last_line and border.up_left or border.ver_left)
line_idx = line_idx + 1
lines[line_idx] = data_line
data_linenrs[row_idx] = data_linenr
if not is_last_line or not header_grid[-1] then
line_idx = line_idx + 1
lines[line_idx] = bottom_border_line
end
end
lines.len = line_idx
self:_prepare_header_lines(-1, lines, header_grid)
line_idx = lines.len
lines.len = nil
_.set_buf_options(self.bufnr, { modifiable = true, readonly = false })
_.clear_namespace(self.bufnr, self.ns_id)
-- if linenr_start was shifted downwards,
-- clear the previously rendered lines above.
_.clear_lines(
self.bufnr,
math.min(linenr_start, prev_linenr[1] or linenr_start),
prev_linenr[1] and linenr_start - 1 or 0
)
-- for initial render, start inserting in a single line.
-- for subsequent renders, replace the lines from previous render.
_.render_lines(lines, self.bufnr, self.ns_id, linenr_start, prev_linenr[1] and prev_linenr[2] or linenr_start)
_.set_buf_options(self.bufnr, { modifiable = false, readonly = true })
self._.linenr[1], self._.linenr[2] = linenr_start, line_idx + linenr_start - 1
end
---@param position? {[1]: integer, [2]: integer}
function Table:get_cell(position)
local pos = vim.fn.getcharpos(".") --[[@as integer[] ]]
local line, char = pos[2], pos[3]
local row_idx = 0
for idx, linenr in ipairs(self._.data_linenrs) do
if linenr == line then
row_idx = idx
break
elseif linenr > line then
break
end
end
row_idx = row_idx + (position and position[1] or 0)
local row = self._.data_grid[row_idx]
if not row then
return
end
local cell_idx = 0
for idx, cell in ipairs(row) do
local range = cell.range
if range[2] < char and char <= range[4] then
cell_idx = idx
end
end
cell_idx = cell_idx + (position and position[2] or 0)
return row[cell_idx]
end
function Table:refresh_cell(cell)
local column = cell.column
local range = cell.range
local byte_range = _.char_to_byte_range(self.bufnr, range[1], range[2], range[4])
local content = prepare_cell_content(cell)
if cell.content ~= content then
cell.content = content
local extmarks = vim.api.nvim_buf_get_extmarks(
self.bufnr,
self.ns_id,
{ range[1] - 1, byte_range[1] },
{ range[3] - 1, byte_range[2] - 1 },
{}
)
for _, extmark in ipairs(extmarks) do
vim.api.nvim_buf_del_extmark(self.bufnr, self.ns_id, extmark[1])
end
end
_.set_buf_options(self.bufnr, { modifiable = true, readonly = false })
_.render_lines(
{ append_content(Line(), content, column.width, column.align) },
self.bufnr,
self.ns_id,
range[1],
range[3],
byte_range[1],
byte_range[2]
)
_.set_buf_options(self.bufnr, { modifiable = false, readonly = true })
end
---@alias NuiTable.constructor fun(options: nui_table_options): NuiTable
---@type NuiTable|NuiTable.constructor
local NuiTable = Table
return NuiTable

View file

@ -0,0 +1,141 @@
# NuiText
NuiText is an abstraction layer on top of the following native functions:
- `vim.api.nvim_buf_set_text` (check `:h nvim_buf_set_text()`)
- `vim.api.nvim_buf_set_extmark` (check `:h nvim_buf_set_extmark()`)
It helps you set text and add highlight for it on the buffer.
_Signature:_ `NuiText(content, extmark?)`
**Examples**
```lua
local NuiText = require("nui.text")
local text = NuiText("Something Went Wrong!", "Error")
local bufnr, ns_id, linenr_start, byte_start = 0, -1, 1, 0
text:render(bufnr, ns_id, linenr_start, byte_start)
```
## Parameters
### `content`
**Type:** `string` or `table`
Text content or `NuiText` object.
If `NuiText` object is passed, a copy of it is created.
### `extmark`
**Type:** `string` or `table`
Highlight group name or extmark options.
If a `string` is passed, it is used as the highlight group name.
If a `table` is passed it is used as extmark data. It can have the
following keys:
| Key | Description |
| ------------ | -------------------- |
| `"hl_group"` | highlight group name |
For more, check `:help nvim_buf_set_extmark()`.
## Methods
### `text:set`
_Signature:_ `text:set(content, extmark?)`
Sets the text content and highlight information.
**Parameters**
| Name | Type | Description |
| --------- | ------------------- | --------------------------------------- |
| `content` | `string` | text content |
| `extmark` | `string` or `table` | highlight group name or extmark options |
This `extmark` parameter is exactly the same as `NuiText`'s `extmark` parameter.
### `text:content`
_Signature:_ `text:content()`
Returns the text content.
### `text:length`
_Signature:_ `text:length()`
Returns the byte length of the text.
### `text:width`
_Signature:_ `text:width()`
Returns the character length of the text.
### `text:highlight`
_Signature:_ `text:highlight(bufnr, ns_id, linenr, byte_start)`
Applies highlight for the text.
**Parameters**
| Name | Type | Description |
| ------------ | -------- | -------------------------------------------------- |
| `bufnr` | `number` | buffer number |
| `ns_id` | `number` | namespace id (use `-1` for fallback namespace) |
| `linenr` | `number` | line number (1-indexed) |
| `byte_start` | `number` | start position of the text on the line (0-indexed) |
### `text:render`
_Signature:_ `text:render(bufnr, ns_id, linenr_start, byte_start, linenr_end?, byte_end?)`
Sets the text on buffer and applies highlight.
**Parameters**
| Name | Type | Description |
| -------------- | -------- | -------------------------------------------------- |
| `bufnr` | `number` | buffer number |
| `ns_id` | `number` | namespace id (use `-1` for fallback namespace) |
| `linenr_start` | `number` | start line number (1-indexed) |
| `byte_start` | `number` | start position of the text on the line (0-indexed) |
| `linenr_end` | `number` | end line number (1-indexed) |
| `byte_end` | `number` | end position of the text on the line (0-indexed) |
### `text:render_char`
_Signature:_ `text:render_char(bufnr, ns_id, linenr_start, char_start, linenr_end?, char_end?)`
Sets the text on buffer and applies highlight.
This does the thing as `text:render` method, but you can use character count
instead of byte count. It will convert multibyte character count to appropriate
byte count for you.
**Parameters**
| Name | Type | Description |
| -------------- | -------- | -------------------------------------------------- |
| `bufnr` | `number` | buffer number |
| `ns_id` | `number` | namespace id (use `-1` for fallback namespace) |
| `linenr_start` | `number` | start line number (1-indexed) |
| `char_start` | `number` | start position of the text on the line (0-indexed) |
| `linenr_end` | `number` | end line number (1-indexed) |
| `char_end` | `number` | end position of the text on the line (0-indexed) |
## Wiki Page
You can find additional documentation/examples/guides/tips-n-tricks in [nui.text wiki page](https://github.com/MunifTanjim/nui.nvim/wiki/nui.text).

View file

@ -0,0 +1,114 @@
local Object = require("nui.object")
local _ = require("nui.utils")._
local is_type = require("nui.utils").is_type
---@class nui_text_extmark
---@field id? integer
---@field hl_group? string
---@field [string] any
---@class NuiText
---@field protected extmark? nui_text_extmark
local Text = Object("NuiText")
---@param content string|NuiText text content or NuiText object
---@param extmark? string|nui_text_extmark highlight group name or extmark options
function Text:init(content, extmark)
if type(content) == "string" then
self:set(content, extmark)
else
-- cloning
self:set(content._content, extmark or content.extmark)
end
end
---@param content string text content
---@param extmark? string|nui_text_extmark highlight group name or extmark options
---@return NuiText
function Text:set(content, extmark)
if self._content ~= content then
self._content = content
self._length = vim.fn.strlen(content)
self._width = vim.api.nvim_strwidth(content)
end
if extmark then
-- preserve self.extmark.id
local id = self.extmark and self.extmark.id or nil
self.extmark = is_type("string", extmark) and { hl_group = extmark } or vim.deepcopy(extmark)
self.extmark.id = id
end
return self
end
---@return string
function Text:content()
return self._content
end
---@return number
function Text:length()
return self._length
end
---@return number
function Text:width()
return self._width
end
---@param bufnr number buffer number
---@param ns_id number namespace id
---@param linenr number line number (1-indexed)
---@param byte_start number start byte position (0-indexed)
---@return nil
function Text:highlight(bufnr, ns_id, linenr, byte_start)
if not self.extmark then
return
end
self.extmark.end_col = byte_start + self:length()
self.extmark.id =
vim.api.nvim_buf_set_extmark(bufnr, _.ensure_namespace_id(ns_id), linenr - 1, byte_start, self.extmark)
end
---@param bufnr number buffer number
---@param ns_id number namespace id
---@param linenr_start number start line number (1-indexed)
---@param byte_start number start byte position (0-indexed)
---@param linenr_end? number end line number (1-indexed)
---@param byte_end? number end byte position (0-indexed)
---@return nil
function Text:render(bufnr, ns_id, linenr_start, byte_start, linenr_end, byte_end)
local row_start = linenr_start - 1
local row_end = linenr_end and linenr_end - 1 or row_start
local col_start = byte_start
local col_end = byte_end or byte_start + self:length()
local content = self:content()
vim.api.nvim_buf_set_text(bufnr, row_start, col_start, row_end, col_end, { content })
self:highlight(bufnr, ns_id, linenr_start, byte_start)
end
---@param bufnr number buffer number
---@param ns_id number namespace id
---@param linenr_start number start line number (1-indexed)
---@param char_start number start character position (0-indexed)
---@param linenr_end? number end line number (1-indexed)
---@param char_end? number end character position (0-indexed)
---@return nil
function Text:render_char(bufnr, ns_id, linenr_start, char_start, linenr_end, char_end)
char_end = char_end or char_start + self:width()
local byte_range = _.char_to_byte_range(bufnr, linenr_start, char_start, char_end)
self:render(bufnr, ns_id, linenr_start, byte_range[1], linenr_end, byte_range[2])
end
---@alias NuiText.constructor fun(content: string|NuiText, extmark?: string|nui_text_extmark): NuiText
---@type NuiText|NuiText.constructor
local NuiText = Text
return NuiText

View file

@ -0,0 +1,308 @@
# NuiTree
NuiTree can render tree-like structured content on the buffer.
**Examples**
```lua
local NuiTree = require("nui.tree")
local tree = NuiTree({
bufnr = bufnr,
nodes = {
NuiTree.Node({ text = "a" }),
NuiTree.Node({ text = "b" }, {
NuiTree.Node({ text = "b-1" }),
NuiTree.Node({ text = { "b-2", "b-3" } }),
}),
},
})
tree:render()
```
## Options
### `bufnr`
**Type:** `number`
Id of the buffer where the tree will be rendered.
---
### `ns_id`
**Type:** `number` or `string`
Namespace id (`number`) or name (`string`).
---
### `nodes`
**Type:** `table`
List of [`NuiTree.Node`](#nuitreenode) objects.
---
### `get_node_id`
**Type:** `function`
_Signature:_ `get_node_id(node) -> string`
If provided, this function is used for generating node's id.
The return value should be a unique `string`.
**Example**
```lua
get_node_id = function(node)
if node.id then
return "-" .. node.id
end
if node.text then
return string.format("%s-%s-%s", node:get_parent_id() or "", node:get_depth(), node.text)
end
return "-" .. math.random()
end,
```
---
### `prepare_node`
**Type:** `function`
_Signature:_ `prepare_node(node, parent_node?) -> nil | string | string[] | NuiLine | NuiLine[]`
If provided, this function is used for preparing each node line.
The return value should be a `NuiLine` object or `string` or a list containing either of them.
If return value is `nil`, that node will not be rendered.
**Example**
```lua
prepare_node = function(node)
local line = NuiLine()
line:append(string.rep(" ", node:get_depth() - 1))
if node:has_children() then
line:append(node:is_expanded() and " " or " ")
else
line:append(" ")
end
line:append(node.text)
return line
end,
```
---
### `buf_options`
**Type:** `table`
Contains all buffer related options (check `:h options | /local to buffer`).
**Examples**
```lua
buf_options = {
bufhidden = "hide",
buflisted = false,
buftype = "nofile",
swapfile = false,
},
```
## Methods
### `tree:get_node`
_Signature:_ `tree:get_node(node_id_or_linenr?) -> NuiTreeNode | nil, number | nil, number | nil`
**Parameters**
| Name | Type | Description |
| ------------------- | ----------------------------- | ------------------------ |
| `node_id_or_linenr` | `number` or `string` or `nil` | node's id or line number |
If `node_id_or_linenr` is `string`, the node with that _id_ is returned.
If `node_id_or_linenr` is `number`, the node on that _linenr_ is returned.
If `node_id` is `nil`, the current node under cursor is returned.
Returns the `node` if found, and the start and end `linenr` if it is rendered.
### `tree:get_nodes`
_Signature:_ `tree:get_node(parent_id?) -> NuiTreeNode[]`
**Parameters**
| Name | Type | Description |
| ----------- | ----------------- | ---------------- |
| `parent_id` | `string` or `nil` | parent node's id |
If `parent_id` is present, child nodes under that parent are returned,
Otherwise root nodes are returned.
### `tree:add_node`
_Signature:_ `tree:add_node(node, parent_id?)`
Adds a node to the tree.
| Name | Type | Description |
| ----------- | ----------------- | ---------------- |
| `node` | `NuiTree.Node` | node |
| `parent_id` | `string` or `nil` | parent node's id |
If `parent_id` is present, node is added under that parent,
Otherwise node is added to the tree root.
### `tree:remove_node`
_Signature:_ `tree:remove_node(node)`
Removes a node from the tree.
Returns the removed node.
| Name | Type | Description |
| --------- | -------- | ----------- |
| `node_id` | `string` | node's id |
### `tree:set_nodes`
_Signature:_ `tree:set_nodes(nodes, parent_id?)`
Adds a node to the tree.
| Name | Type | Description |
| ----------- | ----------------- | ---------------- |
| `nodes` | `NuiTree.Node[]` | list of nodes |
| `parent_id` | `string` or `nil` | parent node's id |
If `parent_id` is present, nodes are set as parent node's children,
otherwise nodes are set at tree root.
### `tree:render`
_Signature:_ `tree:render(linenr_start?)`
Renders the tree on buffer.
| Name | Type | Description |
| -------------- | ---------------- | ----------------------------- |
| `linenr_start` | `number` / `nil` | start line number (1-indexed) |
## NuiTree.Node
`NuiTree.Node` is used to create a node object for `NuiTree`.
_Signature:_ `NuiTree.Node(data, children)`
**Examples**
```lua
local NuiTree = require("nui.tree")
local node = NuiTree.Node({ text = "b" }, {
NuiTree.Node({ text = "b-1" }),
NuiTree.Node({ text = "b-2" }),
})
```
### Parameters
#### `data`
**Type:** `table`
Data for the node. Can contain anything. The default `get_node_id`
and `prepare_node` functions uses the `id` and `text` keys.
**Example**
```lua
{
id = "/usr/local/bin/lua",
text = "lua"
}
```
If you don't want to provide those two values, you should consider
providing your own `get_node_id` and `prepare_node` functions.
#### `children`
**Type:** `table`
List of `NuiTree.Node` objects.
### Methods
#### `node:get_id`
_Signature:_ `node:get_id()`
Returns node's id.
#### `node:get_depth`
_Signature:_ `node:get_depth()`
Returns node's depth.
#### `node:get_parent_id`
_Signature:_ `node:get_parent_id()`
Returns parent node's id.
#### `node:has_children`
_Signature:_ `node:has_children()`
Checks if node has children.
#### `node:get_child_ids`
_Signature:_ `node:get_child_ids() -> string[]`
Returns ids of child nodes.
#### `node:is_expanded`
_Signature:_ `node:is_expanded()`
Checks if node is expanded.
#### `node:expand`
_Signature:_ `node:expand()`
Expands node.
#### `node:collapse`
_Signature:_ `node:collapse()`
Collapses node.
## Wiki Page
You can find additional documentation/examples/guides/tips-n-tricks in [nui.tree wiki page](https://github.com/MunifTanjim/nui.nvim/wiki/nui.tree).

View file

@ -0,0 +1,482 @@
local Object = require("nui.object")
local _ = require("nui.utils")._
local defaults = require("nui.utils").defaults
local is_type = require("nui.utils").is_type
local tree_util = require("nui.tree.util")
-- returns id of the first window that contains the buffer
---@param bufnr number
---@return number winid
local function get_winid(bufnr)
return vim.fn.win_findbuf(bufnr)[1]
end
---@param nodes NuiTree.Node[]
---@param parent_node? NuiTree.Node
---@param get_node_id nui_tree_get_node_id
---@return { by_id: table<string, NuiTree.Node>, root_ids: string[] }
local function initialize_nodes(nodes, parent_node, get_node_id)
local start_depth = parent_node and parent_node:get_depth() + 1 or 1
---@type table<string, NuiTree.Node>
local by_id = {}
---@type string[]
local root_ids = {}
---@param node NuiTree.Node
---@param depth number
local function initialize(node, depth)
node._depth = depth
node._id = get_node_id(node)
node._initialized = true
local node_id = node:get_id()
if by_id[node_id] then
error("duplicate node id " .. node_id)
end
by_id[node_id] = node
if depth == start_depth then
table.insert(root_ids, node_id)
end
if not node.__children or #node.__children == 0 then
return
end
if not node._child_ids then
node._child_ids = {}
end
for _, child_node in ipairs(node.__children) do
child_node._parent_id = node_id
initialize(child_node, depth + 1)
table.insert(node._child_ids, child_node:get_id())
end
node.__children = nil
end
for _, node in ipairs(nodes) do
node._parent_id = parent_node and parent_node:get_id() or nil
initialize(node, start_depth)
end
return {
by_id = by_id,
root_ids = root_ids,
}
end
---@class NuiTree.Node
---@field _id string
---@field _depth integer
---@field _parent_id? string
---@field _child_ids? string[]
---@field __children? NuiTree.Node[]
---@field [string] any
local TreeNode = {
super = nil,
}
---@alias NuiTreeNode NuiTree.Node
---@return string
function TreeNode:get_id()
return self._id
end
---@return number
function TreeNode:get_depth()
return self._depth
end
---@return string|nil
function TreeNode:get_parent_id()
return self._parent_id
end
---@return boolean
function TreeNode:has_children()
local items = self._child_ids or self.__children
return items and #items > 0 or false
end
---@return string[]
function TreeNode:get_child_ids()
return self._child_ids or {}
end
---@return boolean
function TreeNode:is_expanded()
return self._is_expanded
end
---@return boolean is_updated
function TreeNode:expand()
if (self._child_ids or self.__children) and not self:is_expanded() then
self._is_expanded = true
return true
end
return false
end
---@return boolean is_updated
function TreeNode:collapse()
if self:is_expanded() then
self._is_expanded = false
return true
end
return false
end
--luacheck: push no max line length
---@alias nui_tree_get_node_id fun(node: NuiTree.Node): string
---@alias nui_tree_prepare_node fun(node: NuiTree.Node, parent_node?: NuiTree.Node): nil | string | string[] | NuiLine | NuiLine[]
--luacheck: pop
---@class nui_tree_internal
---@field buf_options table<string, any>
---@field get_node_id nui_tree_get_node_id
---@field linenr { [1]?: integer, [2]?: integer }
---@field linenr_by_node_id table<string, { [1]: integer, [2]: integer }>
---@field node_id_by_linenr table<integer, string>
---@field prepare_node nui_tree_prepare_node
---@field win_options table<string, any> # deprecated
---@class nui_tree_options
---@field bufnr integer
---@field ns_id? string|integer
---@field nodes? NuiTree.Node[]
---@field get_node_id? fun(node: NuiTree.Node): string
---@field prepare_node? fun(node: NuiTree.Node, parent_node?: NuiTree.Node): nil|string|string[]|NuiLine|NuiLine[]
---@class NuiTree
---@field bufnr integer
---@field nodes { by_id: table<string,NuiTree.Node>, root_ids: string[] }
---@field ns_id integer
---@field private _ nui_tree_internal
---@field winid number # @deprecated
local Tree = Object("NuiTree")
---@param options nui_tree_options
function Tree:init(options)
---@deprecated
if options.winid then
if not vim.api.nvim_win_is_valid(options.winid) then
error("invalid winid " .. options.winid)
end
self.winid = options.winid
self.bufnr = vim.api.nvim_win_get_buf(self.winid)
end
if options.bufnr then
if not vim.api.nvim_buf_is_valid(options.bufnr) then
error("invalid bufnr " .. options.bufnr)
end
self.bufnr = options.bufnr
self.winid = nil
end
if not self.bufnr then
error("missing bufnr")
end
self.ns_id = _.normalize_namespace_id(options.ns_id)
self._ = {
buf_options = vim.tbl_extend("force", {
bufhidden = "hide",
buflisted = false,
buftype = "nofile",
modifiable = false,
readonly = true,
swapfile = false,
undolevels = 0,
}, defaults(options.buf_options, {})),
---@deprecated
win_options = vim.tbl_extend("force", {
foldcolumn = "0",
foldmethod = "manual",
wrap = false,
}, defaults(options.win_options, {})),
get_node_id = defaults(options.get_node_id, tree_util.default_get_node_id),
prepare_node = defaults(options.prepare_node, tree_util.default_prepare_node),
linenr = {},
}
_.set_buf_options(self.bufnr, self._.buf_options)
---@deprecated
if self.winid then
_.set_win_options(self.winid, self._.win_options)
end
self:set_nodes(defaults(options.nodes, {}))
end
---@generic D : table
---@param data D data table
---@param children? NuiTree.Node[]
---@return NuiTree.Node|D
function Tree.Node(data, children)
---@type NuiTree.Node
local self = {
__children = children,
_initialized = false,
_is_expanded = false,
_child_ids = nil,
_parent_id = nil,
---@diagnostic disable-next-line: assign-type-mismatch
_depth = nil,
---@diagnostic disable-next-line: assign-type-mismatch
_id = nil,
}
self = setmetatable(vim.tbl_extend("keep", self, data), {
__index = TreeNode,
__name = "NuiTree.Node",
})
return self
end
---@param node_id_or_linenr? string | integer
---@return NuiTree.Node|nil node
---@return nil|integer linenr
---@return nil|integer linenr
function Tree:get_node(node_id_or_linenr)
if is_type("string", node_id_or_linenr) then
return self.nodes.by_id[node_id_or_linenr], unpack(self._.linenr_by_node_id[node_id_or_linenr] or {})
end
local winid = get_winid(self.bufnr)
local linenr = node_id_or_linenr or vim.api.nvim_win_get_cursor(winid)[1]
local node_id = self._.node_id_by_linenr[linenr]
return self.nodes.by_id[node_id], unpack(self._.linenr_by_node_id[node_id] or {})
end
---@param parent_id? string parent node's id
---@return NuiTree.Node[] nodes
function Tree:get_nodes(parent_id)
local node_ids = {}
if parent_id then
local parent_node = self.nodes.by_id[parent_id]
if parent_node then
node_ids = parent_node._child_ids
end
else
node_ids = self.nodes.root_ids
end
return vim.tbl_map(function(id)
return self.nodes.by_id[id]
end, node_ids or {})
end
---@param nodes NuiTree.Node[]
---@param parent_node? NuiTree.Node
function Tree:_add_nodes(nodes, parent_node)
local new_nodes = initialize_nodes(nodes, parent_node, self._.get_node_id)
self.nodes.by_id = vim.tbl_extend("force", self.nodes.by_id, new_nodes.by_id)
if parent_node then
if not parent_node._child_ids then
parent_node._child_ids = {}
end
for _, id in ipairs(new_nodes.root_ids) do
table.insert(parent_node._child_ids, id)
end
else
for _, id in ipairs(new_nodes.root_ids) do
table.insert(self.nodes.root_ids, id)
end
end
end
---@param nodes NuiTree.Node[]
---@param parent_id? string parent node's id
function Tree:set_nodes(nodes, parent_id)
self._.node_id_by_linenr = {}
self._.linenr_by_node_id = {}
if not parent_id then
self.nodes = { by_id = {}, root_ids = {} }
self:_add_nodes(nodes)
return
end
local parent_node = self.nodes.by_id[parent_id]
if not parent_node then
error("invalid parent_id " .. parent_id)
end
if parent_node._child_ids then
for _, node_id in ipairs(parent_node._child_ids) do
self.nodes.by_id[node_id] = nil
end
parent_node._child_ids = nil
end
self:_add_nodes(nodes, parent_node)
end
---@param node NuiTree.Node
---@param parent_id? string parent node's id
function Tree:add_node(node, parent_id)
local parent_node = self.nodes.by_id[parent_id]
if parent_id and not parent_node then
error("invalid parent_id " .. parent_id)
end
self:_add_nodes({ node }, parent_node)
end
local function remove_node(tree, node_id)
local node = tree.nodes.by_id[node_id]
if node:has_children() then
for _, child_id in ipairs(node._child_ids) do
-- We might want to store the nodes and return them with the node itself?
-- We should _really_ not be doing this recursively, but it will work for now
remove_node(tree, child_id)
end
end
tree.nodes.by_id[node_id] = nil
return node
end
---@param node_id string
---@return NuiTree.Node
function Tree:remove_node(node_id)
local node = remove_node(self, node_id)
local parent_id = node._parent_id
if parent_id then
local parent_node = self.nodes.by_id[parent_id]
parent_node._child_ids = vim.tbl_filter(function(id)
return id ~= node_id
end, parent_node._child_ids)
else
self.nodes.root_ids = vim.tbl_filter(function(id)
return id ~= node_id
end, self.nodes.root_ids)
end
return node
end
---@param linenr_start number start line number (1-indexed)
---@return (string|NuiLine)[]|{ len: integer } lines
function Tree:_prepare_content(linenr_start)
local internal = self._
local by_id = self.nodes.by_id
---@type { [1]: string|NuiLine }
local list_wrapper = {}
local tree_linenr = 0
local lines = { len = tree_linenr }
local node_id_by_linenr = {}
internal.node_id_by_linenr = node_id_by_linenr
local linenr_by_node_id = {}
internal.linenr_by_node_id = linenr_by_node_id
local function prepare(node_id, parent_node)
local node = by_id[node_id]
if not node then
return
end
local node_lines = internal.prepare_node(node, parent_node)
if node_lines then
if type(node_lines) ~= "table" or node_lines.content then
list_wrapper[1] = node_lines
node_lines = list_wrapper
end
---@cast node_lines -string, -NuiLine
local node_linenr = linenr_by_node_id[node_id] or {}
for node_line_idx = 1, #node_lines do
local node_line = node_lines[node_line_idx]
tree_linenr = tree_linenr + 1
local buffer_linenr = tree_linenr + linenr_start - 1
lines[tree_linenr] = node_line
node_id_by_linenr[buffer_linenr] = node_id
if node_line_idx == 1 then
node_linenr[1] = buffer_linenr
end
node_linenr[2] = buffer_linenr
end
linenr_by_node_id[node_id] = node_linenr
end
local child_ids = node._child_ids
if child_ids and node._is_expanded then
for child_id_idx = 1, #child_ids do
prepare(child_ids[child_id_idx], node)
end
end
end
local root_ids = self.nodes.root_ids
for node_id_idx = 1, #root_ids do
prepare(root_ids[node_id_idx])
end
lines.len = tree_linenr
return lines
end
---@param linenr_start? number start line number (1-indexed)
function Tree:render(linenr_start)
linenr_start = math.max(1, linenr_start or self._.linenr[1] or 1)
local prev_linenr = { self._.linenr[1], self._.linenr[2] }
local lines = self:_prepare_content(linenr_start)
local line_idx = lines.len
lines.len = nil
_.set_buf_options(self.bufnr, { modifiable = true, readonly = false })
_.clear_namespace(self.bufnr, self.ns_id, prev_linenr[1], prev_linenr[2])
-- if linenr_start was shifted downwards,
-- clear the previously rendered lines above.
_.clear_lines(
self.bufnr,
math.min(linenr_start, prev_linenr[1] or linenr_start),
prev_linenr[1] and linenr_start - 1 or 0
)
-- for initial render, start inserting in a single line.
-- for subsequent renders, replace the lines from previous render.
_.render_lines(lines, self.bufnr, self.ns_id, linenr_start, prev_linenr[1] and prev_linenr[2] or linenr_start)
_.set_buf_options(self.bufnr, { modifiable = false, readonly = true })
self._.linenr[1], self._.linenr[2] = linenr_start, line_idx + linenr_start - 1
end
---@alias NuiTree.constructor fun(options: nui_tree_options): NuiTree
---@type NuiTree|NuiTree.constructor
local NuiTree = Tree
return NuiTree

View file

@ -0,0 +1,70 @@
local NuiLine = require("nui.line")
local mod = {}
---@param node NuiTree.Node
---@return string node_id
function mod.default_get_node_id(node)
if node.id then
return "-" .. node.id
end
if node.text then
local texts = node.text
if type(node.text) ~= "table" or node.text.content then
texts = { node.text }
end
return string.format(
"%s-%s-%s",
node._parent_id or "",
node._depth,
table.concat(
vim.tbl_map(function(text)
if type(text) == "string" then
return text
end
return text:content()
end, texts),
"-"
)
)
end
return "-" .. math.random()
end
---@param node NuiTree.Node
---@return NuiLine[]
function mod.default_prepare_node(node)
if not node.text then
error("missing node.text")
end
local texts = node.text
if type(node.text) ~= "table" or node.text.content then
texts = { node.text }
end
local lines = {}
for i, text in ipairs(texts) do
local line = NuiLine()
line:append(string.rep(" ", node._depth - 1))
if i == 1 and node:has_children() then
line:append(node:is_expanded() and "" or "")
else
line:append(" ")
end
line:append(text)
table.insert(lines, line)
end
return lines
end
return mod

View file

@ -0,0 +1,478 @@
local buf_storage = require("nui.utils.buf_storage")
local is_type = require("nui.utils").is_type
local feature = require("nui.utils")._.feature
local autocmd = {
event = {
-- after adding a buffer to the buffer list
BufAdd = "BufAdd",
-- deleting a buffer from the buffer list
BufDelete = "BufDelete",
-- after entering a buffer
BufEnter = "BufEnter",
-- after renaming a buffer
BufFilePost = "BufFilePost",
-- before renaming a buffer
BufFilePre = "BufFilePre",
-- just after buffer becomes hidden
BufHidden = "BufHidden",
-- before leaving a buffer
BufLeave = "BufLeave",
-- after the 'modified' state of a buffer changes
BufModifiedSet = "BufModifiedSet",
-- after creating any buffer
BufNew = "BufNew",
-- when creating a buffer for a new file
BufNewFile = "BufNewFile",
-- read buffer using command
BufReadCmd = "BufReadCmd",
-- after reading a buffer
BufReadPost = "BufReadPost",
-- before reading a buffer
BufReadPre = "BufReadPre",
-- just before unloading a buffer
BufUnload = "BufUnload",
-- after showing a buffer in a window
BufWinEnter = "BufWinEnter",
-- just after buffer removed from window
BufWinLeave = "BufWinLeave",
-- just before really deleting a buffer
BufWipeout = "BufWipeout",
-- write buffer using command
BufWriteCmd = "BufWriteCmd",
-- after writing a buffer
BufWritePost = "BufWritePost",
-- before writing a buffer
BufWritePre = "BufWritePre",
-- info was received about channel
ChanInfo = "ChanInfo",
-- channel was opened
ChanOpen = "ChanOpen",
-- command undefined
CmdUndefined = "CmdUndefined",
-- command line was modified
CmdlineChanged = "CmdlineChanged",
-- after entering cmdline mode
CmdlineEnter = "CmdlineEnter",
-- before leaving cmdline mode
CmdlineLeave = "CmdlineLeave",
-- after entering the cmdline window
CmdWinEnter = "CmdwinEnter",
-- before leaving the cmdline window
CmdWinLeave = "CmdwinLeave",
-- after loading a colorscheme
ColorScheme = "ColorScheme",
-- before loading a colorscheme
ColorSchemePre = "ColorSchemePre",
-- after popup menu changed
CompleteChanged = "CompleteChanged",
-- after finishing insert complete
CompleteDone = "CompleteDone",
-- idem, before clearing info
CompleteDonePre = "CompleteDonePre",
-- cursor in same position for a while
CursorHold = "CursorHold",
-- idem, in Insert mode
CursorHoldI = "CursorHoldI",
-- cursor was moved
CursorMoved = "CursorMoved",
-- cursor was moved in Insert mode
CursorMovedI = "CursorMovedI",
-- diffs have been updated
DiffUpdated = "DiffUpdated",
-- directory changed
DirChanged = "DirChanged",
-- after changing the 'encoding' option
EncodingChanged = "EncodingChanged",
-- before exiting
ExitPre = "ExitPre",
-- append to a file using command
FileAppendCmd = "FileAppendCmd",
-- after appending to a file
FileAppendPost = "FileAppendPost",
-- before appending to a file
FileAppendPre = "FileAppendPre",
-- before first change to read-only file
FileChangedRO = "FileChangedRO",
-- after shell command that changed file
FileChangedShell = "FileChangedShell",
-- after (not) reloading changed file
FileChangedShellPost = "FileChangedShellPost",
-- read from a file using command
FileReadCmd = "FileReadCmd",
-- after reading a file
FileReadPost = "FileReadPost",
-- before reading a file
FileReadPre = "FileReadPre",
-- new file type detected (user defined)
FileType = "FileType",
-- write to a file using command
FileWriteCmd = "FileWriteCmd",
-- after writing a file
FileWritePost = "FileWritePost",
-- before writing a file
FileWritePre = "FileWritePre",
-- after reading from a filter
FilterReadPost = "FilterReadPost",
-- before reading from a filter
FilterReadPre = "FilterReadPre",
-- after writing to a filter
FilterWritePost = "FilterWritePost",
-- before writing to a filter
FilterWritePre = "FilterWritePre",
-- got the focus
FocusGained = "FocusGained",
-- lost the focus to another app
FocusLost = "FocusLost",
-- if calling a function which doesn't exist
FuncUndefined = "FuncUndefined",
-- after starting the GUI
GUIEnter = "GUIEnter",
-- after starting the GUI failed
GUIFailed = "GUIFailed",
-- when changing Insert/Replace mode
InsertChange = "InsertChange",
-- before inserting a char
InsertCharPre = "InsertCharPre",
-- when entering Insert mode
InsertEnter = "InsertEnter",
-- just after leaving Insert mode
InsertLeave = "InsertLeave",
-- just before leaving Insert mode
InsertLeavePre = "InsertLeavePre",
-- just before popup menu is displayed
MenuPopup = "MenuPopup",
-- after changing the mode
ModeChanged = "ModeChanged",
-- after setting any option
OptionSet = "OptionSet",
-- after :make, :grep etc.
QuickFixCmdPost = "QuickFixCmdPost",
-- before :make, :grep etc.
QuickFixCmdPre = "QuickFixCmdPre",
-- before :quit
QuitPre = "QuitPre",
-- upon string reception from a remote vim
RemoteReply = "RemoteReply",
-- when the search wraps around the document
SearchWrapped = "SearchWrapped",
-- after loading a session file
SessionLoadPost = "SessionLoadPost",
-- after ":!cmd"
ShellCmdPost = "ShellCmdPost",
-- after ":1,2!cmd", ":w !cmd", ":r !cmd".
ShellFilterPost = "ShellFilterPost",
-- after nvim process received a signal
Signal = "Signal",
-- sourcing a Vim script using command
SourceCmd = "SourceCmd",
-- after sourcing a Vim script
SourcePost = "SourcePost",
-- before sourcing a Vim script
SourcePre = "SourcePre",
-- spell file missing
SpellFileMissing = "SpellFileMissing",
-- after reading from stdin
StdinReadPost = "StdinReadPost",
-- before reading from stdin
StdinReadPre = "StdinReadPre",
-- found existing swap file
SwapExists = "SwapExists",
-- syntax selected
Syntax = "Syntax",
-- a tab has closed
TabClosed = "TabClosed",
-- after entering a tab page
TabEnter = "TabEnter",
-- before leaving a tab page
TabLeave = "TabLeave",
-- when creating a new tab
TabNew = "TabNew",
-- after entering a new tab
TabNewEntered = "TabNewEntered",
-- after changing 'term'
TermChanged = "TermChanged",
-- after the process exits
TermClose = "TermClose",
-- after entering Terminal mode
TermEnter = "TermEnter",
-- after leaving Terminal mode
TermLeave = "TermLeave",
-- after opening a terminal buffer
TermOpen = "TermOpen",
-- after setting "v:termresponse"
TermResponse = "TermResponse",
-- text was modified
TextChanged = "TextChanged",
-- text was modified in Insert mode(no popup)
TextChangedI = "TextChangedI",
-- text was modified in Insert mode(popup)
TextChangedP = "TextChangedP",
-- after a yank or delete was done (y, d, c)
TextYankPost = "TextYankPost",
-- after UI attaches
UIEnter = "UIEnter",
-- after UI detaches
UILeave = "UILeave",
-- user defined autocommand
User = "User",
-- whenthe user presses the same key 42 times
UserGettingBored = "UserGettingBored",
-- after starting Vim
VimEnter = "VimEnter",
-- before exiting Vim
VimLeave = "VimLeave",
-- before exiting Vim and writing ShaDa file
VimLeavePre = "VimLeavePre",
-- after Vim window was resized
VimResized = "VimResized",
-- after Nvim is resumed
VimResume = "VimResume",
-- before Nvim is suspended
VimSuspend = "VimSuspend",
-- after closing a window
WinClosed = "WinClosed",
-- after entering a window
WinEnter = "WinEnter",
-- before leaving a window
WinLeave = "WinLeave",
-- when entering a new window
WinNew = "WinNew",
-- after scrolling a window
WinScrolled = "WinScrolled",
-- alias for `BufAdd`
BufCreate = "BufAdd",
-- alias for `BufReadPost`
BufRead = "BufReadPost",
-- alias for `BufWritePre`
BufWrite = "BufWritePre",
-- alias for `EncodingChanged`
FileEncoding = "EncodingChanged",
},
buf = {
storage = buf_storage.create("nui.utils.autocmd", { _next_handler_id = 1 }),
},
}
---@param callback fun(event: table): nil
---@param bufnr integer
local function to_stored_handler(callback, bufnr)
local handler_id = autocmd.buf.storage[bufnr]._next_handler_id
autocmd.buf.storage[bufnr]._next_handler_id = handler_id + 1
autocmd.buf.storage[bufnr][handler_id] = callback
local command = string.format(":lua require('nui.utils.autocmd').execute_stored_handler(%s, %s)", bufnr, handler_id)
return command
end
---@param bufnr integer
---@param handler_id number
function autocmd.execute_stored_handler(bufnr, handler_id)
local handler = autocmd.buf.storage[bufnr][handler_id]
if is_type("function", handler) then
handler()
end
end
---@param name string
---@param opts { clear?: boolean }
function autocmd.create_group(name, opts)
if feature.lua_autocmd then
return vim.api.nvim_create_augroup(name, opts)
end
vim.cmd(string.format(
[[
augroup %s
%s
augroup end
]],
name,
opts.clear and "autocmd!" or ""
))
end
---@param name string
function autocmd.delete_group(name)
if feature.lua_autocmd then
return vim.api.nvim_del_augroup_by_name(name)
end
vim.cmd(string.format(
[[
autocmd! %s
augroup! %s
]],
name,
name
))
end
---@param event string|string[]
---@param opts table
---@param bufnr? integer # to store callback if lua autocmd is not available
function autocmd.create(event, opts, bufnr)
if feature.lua_autocmd then
return vim.api.nvim_create_autocmd(event, opts)
end
event = type(event) == "table" and table.concat(event, ",") or event --[[@as string]]
local pattern = is_type("table", opts.pattern) and table.concat(opts.pattern, ",") or opts.pattern
if opts.buffer then
pattern = string.format("<buffer=%s>", opts.buffer)
end
if opts.callback then
local buffer = opts.buffer or bufnr
if not buffer then
error("[nui.utils.autocmd] missing param: bufnr")
end
opts.command = to_stored_handler(opts.callback, buffer)
end
vim.cmd(
string.format(
"autocmd %s %s %s %s %s %s",
opts.group or "",
event,
pattern,
opts.once and "++once" or "",
opts.nested and "++nested" or "",
opts.command
)
)
end
---@param opts table
function autocmd.delete(opts)
if feature.lua_autocmd then
for _, item in ipairs(vim.api.nvim_get_autocmds(opts)) do
if item.id then
vim.api.nvim_del_autocmd(item.id)
end
end
return
end
local event = is_type("table", opts.event) and table.concat(opts.event, ",") or opts.event
local pattern = is_type("table", opts.pattern) and table.concat(opts.pattern, ",") or opts.pattern
if opts.buffer then
pattern = string.format("<buffer=%s>", opts.buffer)
end
vim.cmd(string.format("autocmd! %s %s %s", opts.group or "", event or "*", pattern or ""))
end
---@param event string|string[]
---@param opts table
function autocmd.exec(event, opts)
local events = type(event) == "table" and event or { event } --[=[@as string[]]=]
if feature.lua_autocmd then
vim.api.nvim_exec_autocmds(events, {
group = opts.group,
pattern = opts.pattern,
buffer = opts.buffer,
modeline = opts.modeline,
data = opts.data,
})
return
end
for _, event_name in ipairs(events) do
local command = string.format(
[[doautocmd %s %s %s %s]],
opts.modeline == false and "<nomodeline>" or "",
opts.group or "",
event_name,
opts.pattern or ""
)
if opts.buffer then
vim.api.nvim_buf_call(opts.buffer, function()
vim.cmd(command)
end)
else
vim.cmd(command)
end
end
end
-- @deprecated
---@deprecated
---@param event string | string[]
---@param pattern string | string[]
---@param cmd string
---@param options nil | table<"'once'" | "'nested'", boolean>
function autocmd.define(event, pattern, cmd, options)
local opts = options or {}
opts.pattern = pattern
opts.command = cmd
autocmd.create(event, opts)
end
-- @deprecated
---@deprecated
---@param group_name string
---@param auto_clear boolean
---@param definitions table<"'event'" | "'pattern'" | "'cmd'" | "'options'", any>
function autocmd.define_grouped(group_name, auto_clear, definitions)
if not is_type("boolean", auto_clear) then
error("invalid param type: auto_clear, expected boolean")
end
autocmd.create_group(group_name, { clear = auto_clear })
for _, definition in ipairs(definitions) do
autocmd.define(definition.event, definition.pattern, definition.cmd, definition.options)
end
end
-- @deprecated
---@deprecated
---@param group_name nil | string
---@param event nil | string | string[]
---@param pattern nil | string | string[]
function autocmd.remove(group_name, event, pattern)
autocmd.delete({
event = event,
group = group_name,
pattern = pattern,
})
end
---@param bufnr number
---@param event string | string[]
---@param handler string | function
---@param options nil | table<"'once'" | "'nested'", boolean>
function autocmd.buf.define(bufnr, event, handler, options)
local opts = options or {}
opts.buffer = bufnr
if is_type("function", handler) then
opts.callback = handler
else
opts.command = handler
end
autocmd.create(event, opts, bufnr)
end
---@param bufnr number
---@param group_name nil | string
---@param event nil | string | string[]
function autocmd.buf.remove(bufnr, group_name, event)
autocmd.delete({
buffer = bufnr,
event = event,
group = group_name,
})
end
return autocmd

View file

@ -0,0 +1,33 @@
local defaults = require("nui.utils").defaults
local buf_storage = {
_registry = {},
}
---@param storage_name string
---@param default_value any
---@return table<number, any>
function buf_storage.create(storage_name, default_value)
local storage = setmetatable({}, {
__index = function(tbl, bufnr)
rawset(tbl, bufnr, vim.deepcopy(defaults(default_value, {})))
-- TODO: can `buf_storage.cleanup` be automatically (and reliably) triggered on `BufWipeout`?
return tbl[bufnr]
end,
})
buf_storage._registry[storage_name] = storage
return storage
end
---@param bufnr number
function buf_storage.cleanup(bufnr)
for _, storage in pairs(buf_storage._registry) do
rawset(storage, bufnr, nil)
end
end
return buf_storage

View file

@ -0,0 +1,387 @@
local ok_nvim_version, nvim_version = pcall(vim.version)
if not ok_nvim_version then
nvim_version = {}
end
-- internal utils
local _ = {
feature = {
lua_keymap = type(vim.keymap) ~= "nil",
lua_autocmd = type(vim.api.nvim_create_autocmd) ~= "nil",
v0_10 = nvim_version.minor >= 10,
v0_11 = nvim_version.minor >= 11,
},
}
local utils = {
_ = _,
}
function utils.get_editor_size()
return {
width = vim.o.columns,
height = vim.o.lines,
}
end
function utils.get_window_size(winid)
winid = winid or 0
return {
width = vim.api.nvim_win_get_width(winid),
height = vim.api.nvim_win_get_height(winid),
}
end
function utils.defaults(v, default_value)
return type(v) == "nil" and default_value or v
end
-- luacheck: push no max comment line length
---@param type_name "'nil'" | "'number'" | "'string'" | "'boolean'" | "'table'" | "'function'" | "'thread'" | "'userdata'" | "'list'" | '"map"'
---@return boolean
function utils.is_type(type_name, v)
-- `vim.tbl_islist` will be removed in the future
local islist = vim.islist or vim.tbl_islist
if type_name == "list" then
return islist(v)
end
if type_name == "map" then
return type(v) == "table" and not islist(v)
end
return type(v) == type_name
end
-- luacheck: pop
---@param v string | number
function utils.parse_number_input(v)
local parsed = {}
parsed.is_percentage = type(v) == "string" and string.sub(v, -1) == "%"
if parsed.is_percentage then
parsed.value = tonumber(string.sub(v, 1, #v - 1)) / 100
else
parsed.value = tonumber(v)
parsed.is_percentage = parsed.value and 0 < parsed.value and parsed.value < 1
end
return parsed
end
---@param prefix? string
---@return (fun(): string) get_next_id
local function get_id_generator(prefix)
prefix = prefix or ""
local id = 0
return function()
id = id + 1
return prefix .. id
end
end
_.get_next_id = get_id_generator("nui_")
---@private
---@param bufnr number
---@param linenr number line number (1-indexed)
---@param char_start number start character position (0-indexed)
---@param char_end number end character position (0-indexed)
---@return number[] byte_range
function _.char_to_byte_range(bufnr, linenr, char_start, char_end)
local line = vim.api.nvim_buf_get_lines(bufnr, linenr - 1, linenr, false)[1]
local skipped_part = vim.fn.strcharpart(line, 0, char_start)
local target_part = vim.fn.strcharpart(line, char_start, char_end - char_start)
local byte_start = vim.fn.strlen(skipped_part)
local byte_end = math.min(byte_start + vim.fn.strlen(target_part), vim.fn.strlen(line))
return { byte_start, byte_end }
end
---@type integer
local fallback_namespace_id = vim.api.nvim_create_namespace("nui.nvim")
---@private
---@param ns_id integer
---@return integer
function _.ensure_namespace_id(ns_id)
return ns_id == -1 and fallback_namespace_id or ns_id
end
---@private
---@param ns_id? integer|string
---@return integer ns_id namespace id
function _.normalize_namespace_id(ns_id)
if utils.is_type("string", ns_id) then
---@cast ns_id string
return vim.api.nvim_create_namespace(ns_id)
end
---@cast ns_id integer
return ns_id or fallback_namespace_id
end
---@private
---@param bufnr integer
---@param ns_id integer
---@param linenr_start? integer (1-indexed)
---@param linenr_end? integer (1-indexed,inclusive)
function _.clear_namespace(bufnr, ns_id, linenr_start, linenr_end)
linenr_start = linenr_start or 1
linenr_end = linenr_end and linenr_end + 1 or 0
vim.api.nvim_buf_clear_namespace(bufnr, ns_id, linenr_start - 1, linenr_end - 1)
end
-- luacov: disable
local nvim_buf_set_option = vim.api.nvim_buf_set_option
---@param bufnr integer
---@param name string
---@param value any
local function set_buf_option(bufnr, name, value)
nvim_buf_set_option(bufnr, name, value)
end
local nvim_win_set_option = vim.api.nvim_win_set_option
---@param winid integer
---@param name string
---@param value any
local function set_win_option(winid, name, value)
nvim_win_set_option(winid, name, value)
end
-- luacov: enable
if _.feature.v0_10 then
function set_buf_option(bufnr, name, value)
vim.api.nvim_set_option_value(name, value, { buf = bufnr })
end
function set_win_option(winid, name, value)
vim.api.nvim_set_option_value(name, value, { win = winid, scope = "local" })
end
end
_.set_buf_option = set_buf_option
_.set_win_option = set_win_option
---@private
---@param bufnr number
---@param buf_options table<string, any>
function _.set_buf_options(bufnr, buf_options)
for name, value in pairs(buf_options) do
set_buf_option(bufnr, name, value)
end
end
---@private
---@param winid number
---@param win_options table<string, any>
function _.set_win_options(winid, win_options)
for name, value in pairs(win_options) do
set_win_option(winid, name, value)
end
end
---@private
---@param dimension number | string
---@param container_dimension number
---@return nil | number
function _.normalize_dimension(dimension, container_dimension)
local number = utils.parse_number_input(dimension)
if not number.value then
return nil
end
if number.is_percentage then
return math.floor(container_dimension * number.value)
end
return number.value
end
local strchars, strcharpart, strdisplaywidth = vim.fn.strchars, vim.fn.strcharpart, vim.fn.strdisplaywidth
---@param text string
---@param max_length number
---@return string
function _.truncate_text(text, max_length)
if strdisplaywidth(text) <= max_length then
return text
end
local low, high = 0, strchars(text)
local mid
while low < high do
mid = math.floor((low + high + 1) / 2)
if strdisplaywidth(strcharpart(text, 0, mid)) < max_length then
low = mid
else
high = mid - 1
end
end
return strcharpart(text, 0, low) .. ""
end
---@param text NuiText
---@param max_width number
function _.truncate_nui_text(text, max_width)
text:set(_.truncate_text(text:content(), max_width))
end
---@param line NuiLine
---@param max_width number
function _.truncate_nui_line(line, max_width)
local width = line:width()
local last_part_idx = #line._texts
while width > max_width do
local extra_width = width - max_width
local last_part = line._texts[last_part_idx]
if last_part:width() <= extra_width then
width = width - last_part:width()
line._texts[last_part_idx] = nil
last_part_idx = last_part_idx - 1
-- need to add truncate indicator in previous part
if last_part:width() == extra_width then
last_part = line._texts[last_part_idx]
last_part:set(_.truncate_text(last_part:content() .. " ", last_part:width()))
end
else
last_part:set(_.truncate_text(last_part:content(), last_part:width() - extra_width))
width = width - extra_width
end
end
end
---@param align "'left'" | "'center'" | "'right'"
---@param total_width number
---@param text_width number
---@return number left_gap_width, number right_gap_width
function _.calculate_gap_width(align, total_width, text_width)
local gap_width = total_width - text_width
if align == "left" then
return 0, gap_width
elseif align == "center" then
return math.floor(gap_width / 2), math.ceil(gap_width / 2)
elseif align == "right" then
return gap_width, 0
end
error("invalid value align=" .. align)
end
---@param lines (string|NuiLine)[]
---@param bufnr number
---@param ns_id number
---@param linenr_start integer (1-indexed)
---@param linenr_end? integer (1-indexed,inclusive)
---@param byte_start? integer (0-indexed)
---@param byte_end? integer (0-indexed,exclusive)
function _.render_lines(lines, bufnr, ns_id, linenr_start, linenr_end, byte_start, byte_end)
local row_start = linenr_start - 1
local row_end = linenr_end or row_start + 1
local content = vim.tbl_map(function(line)
if type(line) == "string" then
return line
end
return line:content()
end, lines)
if byte_start then
local col_start = byte_start
local col_end = byte_end or #vim.api.nvim_buf_get_lines(bufnr, row_start, row_end, false)[1]
vim.api.nvim_buf_set_text(bufnr, row_start, col_start, row_end - 1, col_end, content)
else
vim.api.nvim_buf_set_lines(bufnr, row_start, row_end, false, content)
end
for linenr, line in ipairs(lines) do
if type(line) ~= "string" then
line:highlight(bufnr, ns_id, linenr + row_start, byte_start)
end
end
end
---@param bufnr integer
---@param linenr_start integer (1-indexed)
---@param linenr_end integer (1-indexed,inclusive)
function _.clear_lines(bufnr, linenr_start, linenr_end)
local count = linenr_end - linenr_start + 1
if count < 1 then
return
end
local lines = {}
for i = 1, count do
lines[i] = ""
end
vim.api.nvim_buf_set_lines(bufnr, linenr_start - 1, linenr_end, false, lines)
end
function _.normalize_layout_options(options)
if utils.is_type("string", options.relative) then
options.relative = {
type = options.relative,
}
end
if options.position and not utils.is_type("table", options.position) then
options.position = {
row = options.position,
col = options.position,
}
end
if options.size and not utils.is_type("table", options.size) then
options.size = {
width = options.size,
height = options.size,
}
end
return options
end
---@param winhighlight string
---@return table<string, string> highlight_map
function _.parse_winhighlight(winhighlight)
local highlight = {}
local parts = vim.split(winhighlight, ",", { plain = true, trimempty = true })
for _, part in ipairs(parts) do
local key, value = part:match("(.+):(.+)")
highlight[key] = value
end
return highlight
end
---@param highlight_map table<string, string>
---@return string winhighlight
function _.serialize_winhighlight(highlight_map)
local parts = vim.tbl_map(function(key)
return key .. ":" .. highlight_map[key]
end, vim.tbl_keys(highlight_map))
table.sort(parts)
return table.concat(parts, ",")
end
function _.get_default_winborder()
return "none"
end
if _.feature.v0_11 then
function _.get_default_winborder()
local style = vim.api.nvim_get_option_value("winborder", {})
if style == "" then
return "none"
end
return style
end
end
return utils

View file

@ -0,0 +1,154 @@
local buf_storage = require("nui.utils.buf_storage")
local is_type = require("nui.utils").is_type
local feature = require("nui.utils")._.feature
local keymap = {
storage = buf_storage.create("nui.utils.keymap", { _next_handler_id = 1, keys = {}, handlers = {} }),
}
---@param mode string
---@param key string
---@return string key_id
local function get_key_id(mode, key)
return string.format("%s---%s", mode, vim.api.nvim_replace_termcodes(key, true, true, true))
end
---@param bufnr number
---@param key_id string
---@return integer|nil handler_id
local function get_handler_id(bufnr, key_id)
return keymap.storage[bufnr].keys[key_id]
end
---@param bufnr number
---@param key_id string
---@return integer handler_id
local function next_handler_id(bufnr, key_id)
local handler_id = keymap.storage[bufnr]._next_handler_id
keymap.storage[bufnr].keys[key_id] = handler_id
keymap.storage[bufnr]._next_handler_id = handler_id + 1
return handler_id
end
---@param bufnr number
---@param mode string
---@param key string
---@param handler string|fun(): nil
---@return { rhs: string, callback?: fun(): nil }|nil
local function get_keymap_info(bufnr, mode, key, handler, overwrite)
local key_id = get_key_id(mode, key)
-- luacov: disable
if get_handler_id(bufnr, key_id) and not overwrite then
return nil
end
-- luacov: enable
local handler_id = next_handler_id(bufnr, key_id)
local rhs, callback = "", nil
if type(handler) == "function" then
if feature.lua_keymap then
callback = handler
else
keymap.storage[bufnr].handlers[handler_id] = handler
rhs = string.format("<cmd>lua require('nui.utils.keymap').execute(%s, %s)<CR>", bufnr, handler_id)
end
else
rhs = handler
end
return {
rhs = rhs,
callback = callback,
}
end
---@param bufnr number
---@param handler_id number
function keymap.execute(bufnr, handler_id)
local handler = keymap.storage[bufnr].handlers[handler_id]
if is_type("function", handler) then
handler(bufnr)
end
end
---@param bufnr number
---@param mode string
---@param lhs string|string[]
---@param handler string|fun(): nil
---@param opts? table<"'expr'"|"'noremap'"|"'nowait'"|"'remap'"|"'script'"|"'silent'"|"'unique'", boolean>
---@return nil
function keymap.set(bufnr, mode, lhs, handler, opts, force)
if feature.lua_keymap and not is_type("boolean", force) then
force = true
end
local keys = lhs
if type(lhs) ~= "table" then
keys = { lhs }
end
---@cast keys -string
opts = opts or {}
if not is_type("nil", opts.remap) then
opts.noremap = not opts.remap
opts.remap = nil
end
for _, key in ipairs(keys) do
local keymap_info = get_keymap_info(bufnr, mode, key, handler, force)
-- luacov: disable
if not keymap_info then
return false
end
-- luacov: enable
local options = vim.deepcopy(opts)
options.callback = keymap_info.callback
vim.api.nvim_buf_set_keymap(bufnr, mode, key, keymap_info.rhs, options)
end
return true
end
---@param bufnr number
---@param mode string
---@param lhs string|string[]
---@return nil
function keymap._del(bufnr, mode, lhs, force)
if feature.lua_keymap and not is_type("boolean", force) then
force = true
end
local keys = lhs
if type(lhs) ~= "table" then
keys = { lhs }
end
---@cast keys -string
for _, key in ipairs(keys) do
local key_id = get_key_id(mode, key)
local handler_id = get_handler_id(bufnr, key_id)
-- luacov: disable
if not handler_id and not force then
return false
---@cast handler_id -nil
end
-- luacov: enable
keymap.storage[bufnr].keys[key_id] = nil
keymap.storage[bufnr].handlers[handler_id] = nil
vim.api.nvim_buf_del_keymap(bufnr, mode, key)
end
return true
end
return keymap