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,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