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,10 @@
coverage:
status:
project:
default:
informational: true
only_pulls: true
patch:
default:
informational: true
only_pulls: true

View file

@ -0,0 +1,123 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
name: luacheck
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Get Cache Key
id: luver-cache-key
env:
CI_RUNNER_OS: ${{ runner.os }}
CI_SECRETS_CACHE_VERSION: ${{ secrets.CACHE_VERSION }}
run: |
echo "value=${CI_RUNNER_OS}-luver-${CI_SECRETS_CACHE_VERSION}-$(date -u +%Y-%m-%d)" >> $GITHUB_OUTPUT
shell: bash
- name: Setup Cache
uses: actions/cache@v3
with:
path: ~/.local/share/luver
key: ${{ steps.luver-cache-key.outputs.value }}
- name: Setup Lua
uses: MunifTanjim/luver-action@v1
with:
default: 5.1.5
lua_versions: 5.1.5
luarocks_versions: 5.1.5:3.8.0
- name: Setup luacheck
run: |
luarocks install luacheck
- name: Lint
run: ./scripts/lint.sh --no-cache
format:
name: stylua
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Check Format
uses: JohnnyMorganz/stylua-action@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
version: 0.17.1
args: --color always --check lua/nui/ tests/
test:
name: test
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Get Cache Key
id: luver-cache-key
env:
CI_RUNNER_OS: ${{ runner.os }}
CI_SECRETS_CACHE_VERSION: ${{ secrets.CACHE_VERSION }}
run: |
echo "value=${CI_RUNNER_OS}-luver-${CI_SECRETS_CACHE_VERSION}-$(date -u +%Y-%m-%d)" >> $GITHUB_OUTPUT
shell: bash
- name: Setup Cache
uses: actions/cache@v3
with:
path: ~/.local/share/luver
key: ${{ steps.luver-cache-key.outputs.value }}
- name: Setup Lua
uses: MunifTanjim/luver-action@v1
with:
default: 5.1.5
lua_versions: 5.1.5
luarocks_versions: 5.1.5:3.8.0
- name: Setup luacov
run: |
luarocks install luacov
luarocks install luafilesystem
- name: Setup Neovim
uses: MunifTanjim/setup-neovim-action@v1
with:
tag: nightly
- name: Run Tests
run: |
nvim --version
./scripts/test.sh
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
verbose: true
release:
name: release
if: ${{ github.ref == 'refs/heads/main' }}
needs:
- lint
- format
- test
runs-on: ubuntu-22.04
permissions:
actions: write
contents: write
pull-requests: write
steps:
- uses: google-github-actions/release-please-action@v3
id: release
with:
release-type: simple
package-name: nui.nvim
bump-minor-pre-major: true
pull-request-title-pattern: "chore: release ${version}"
include-v-in-tag: false
- name: Trigger Publish
if: ${{ steps.release.outputs.release_created }}
env:
GH_TOKEN: ${{ github.token }}
TAG_NAME: ${{ steps.release.outputs.tag_name }}
run: |
gh workflow run --repo ${GITHUB_REPOSITORY} publish.yml -f version=${TAG_NAME}

View file

@ -0,0 +1,33 @@
name: Publish
on:
push:
tags:
- "[0-1].[0-9]+.[0-9]+"
workflow_dispatch:
inputs:
version:
description: Version to publish
required: false
type: string
force:
description: Force publish
required: false
default: false
type: boolean
jobs:
publish:
name: publish
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v3
- name: LuaRocks Publish
uses: MunifTanjim/luarocks-publish-action@v1
with:
lua_version: 5.1.5
luarocks_version: 3.9.1
version: ${{ inputs.version }}
api_key: ${{ secrets.LUAROCKS_API_KEY }}
force: ${{ inputs.force }}

View file

@ -0,0 +1,5 @@
.luacheckcache
luacov.*.out
.tests

View file

@ -0,0 +1,16 @@
cache = ".luacheckcache"
-- https://luacheck.readthedocs.io/en/stable/warnings.html
ignore = {
"211/_.*",
"212/_.*",
"213/_.*",
}
include_files = { "*.luacheckrc", "lua/**/*.lua", "tests/**/*.lua" }
globals = { "vim" }
std = "luajit"
files["tests/helpers/**/*.lua"] = {
read_globals = { "assert", "describe" },
}
-- vim: set filetype=lua :

View file

@ -0,0 +1,6 @@
include = {
"lua%/nui",
}
includeuntestedfiles = {
"lua/nui"
}

View file

@ -0,0 +1,6 @@
column_width = 120
line_endings = "Unix"
indent_type = "Spaces"
indent_width = 2
quote_style = "AutoPreferDouble"
no_call_parentheses = false

View file

@ -0,0 +1,331 @@
# Changelog
## [0.4.0](https://github.com/MunifTanjim/nui.nvim/compare/0.3.0...0.4.0) (2025-04-23)
### Features
* **popup:** support 'default' border style ([8d5b0b5](https://github.com/MunifTanjim/nui.nvim/commit/8d5b0b568517935d3c84f257f272ef004d9f5a59))
* **popup:** use 'winborder' for default border ([118a12f](https://github.com/MunifTanjim/nui.nvim/commit/118a12f6304759d95d0d003f64067d93572b3238))
* **popup:** use same zindex for border ([a2bc1e9](https://github.com/MunifTanjim/nui.nvim/commit/a2bc1e9d0359caa5d11ad967cd1e30e8d4676226))
* **table:** accept param 'position' for method 'get_cell' ([8794284](https://github.com/MunifTanjim/nui.nvim/commit/87942848c93668532f46ec61ccc7aff7abf7d37e))
* **table:** expose NuiTable.Cell.range ([b81333d](https://github.com/MunifTanjim/nui.nvim/commit/b81333d12f824dbed5eb231c8a4409a290fdd848))
### Bug Fixes
* consider multi-byte characters when truncating text ([53e907f](https://github.com/MunifTanjim/nui.nvim/commit/53e907ffe5eedebdca1cd503b00aa8692068ca46))
* **input:** cursor position patch w/ relative=cursor ([b18316d](https://github.com/MunifTanjim/nui.nvim/commit/b18316d50538bb3f40c8fd2c887490c9d29f8811))
* **input:** cursor position patch w/ relative=editor ([a3597dc](https://github.com/MunifTanjim/nui.nvim/commit/a3597dc88b53489d3fddbddbbd13787355253bb0))
* **input:** ignore 'keymap' for feeding default value ([fbb139c](https://github.com/MunifTanjim/nui.nvim/commit/fbb139c6f14896b434d0229099e1acd863ae6bec))
* **input:** mounting multiple inputs together ([322978c](https://github.com/MunifTanjim/nui.nvim/commit/322978c734866996274467de084a95e4f9b5e0b1))
* **layout:** enable nested flag for WinClosed autocmd ([fc59553](https://github.com/MunifTanjim/nui.nvim/commit/fc59553b5a8a1c13b8aa25ae62b6a47ec2b1882c))
* **layout:** handle if child.grow results in height <= 0 ([61574ce](https://github.com/MunifTanjim/nui.nvim/commit/61574ce6e60c815b0a0c4b5655b8486ba58089a1))
* **layout:** immediate hide/unmount after mounting ([42e4756](https://github.com/MunifTanjim/nui.nvim/commit/42e47565ecbd22306205904e21b45c169812525c))
* **layout:** starting current position for nested boxes ([1b24de4](https://github.com/MunifTanjim/nui.nvim/commit/1b24de4778de527ef82adad6d0e819819d946387))
* **popup:** always use current window for relative=cursor ([3dc46d7](https://github.com/MunifTanjim/nui.nvim/commit/3dc46d725f7b94bee5117c0a699b57b1902b5d65))
* **popup:** ignore 'winborder' for complex border ([aa29efe](https://github.com/MunifTanjim/nui.nvim/commit/aa29efe58f2e5734ff49b44c3d7d0cd4b9266e9a))
* **popup:** mimic native 'solid' border ([cbd2668](https://github.com/MunifTanjim/nui.nvim/commit/cbd2668414331c10039278f558630ed19b93e69b))
* **tree:** update error message for duplicate node id ([8d3bce9](https://github.com/MunifTanjim/nui.nvim/commit/8d3bce9764e627b62b07424e0df77f680d47ffdb))
## [0.3.0](https://github.com/MunifTanjim/nui.nvim/compare/0.2.0...0.3.0) (2024-02-16)
### Features
* **layout:** method 'show' mounts if not already mounted ([c1627d0](https://github.com/MunifTanjim/nui.nvim/commit/c1627d07dbd64fac9bab213e30df7ec044ac32c6))
* **popup:** method 'show' mounts if not already mounted ([ecd77d8](https://github.com/MunifTanjim/nui.nvim/commit/ecd77d8b5d917714f4e4f7bf5b7e91184c6cecae))
* **split:** method 'show' mounts if not already mounted ([43f7605](https://github.com/MunifTanjim/nui.nvim/commit/43f7605f864d82ab1a2642541814465c25fb76d8))
* support decimal number in (0,1) range as size ([35da9ca](https://github.com/MunifTanjim/nui.nvim/commit/35da9ca1de0fc4dda96c2e214d93d363c145f418))
* **tree:** allow node:expand with zero child ([0f913a3](https://github.com/MunifTanjim/nui.nvim/commit/0f913a3ae1a24c8a4487fbf111b4044cc22b1b0d))
* **utils:** replace deprecated api to set option ([401a7c6](https://github.com/MunifTanjim/nui.nvim/commit/401a7c65bfd6433e1b0b48d2c246e2621fc44387))
### Bug Fixes
* **input:** check mounted state in mount/unmount method ([c0c8e34](https://github.com/MunifTanjim/nui.nvim/commit/c0c8e347ceac53030f5c1ece1c5a5b6a17a25b32))
* **input:** skip cursor position patch for hidden input ([80445d0](https://github.com/MunifTanjim/nui.nvim/commit/80445d015d2b5f9af0d9e8bce63d303bc86eda8a))
* **menu:** defer execution of `make_default_prepare_node` ([49182fa](https://github.com/MunifTanjim/nui.nvim/commit/49182fae69bd3f9be33862f106c1bb9f6bc3b4f5))
* **menu:** set default zindex higher than popup ([abb0662](https://github.com/MunifTanjim/nui.nvim/commit/abb066278507040e4c1ddf1c53ccde3139b42ab0))
* **popup:** make sure border buf is modifiable ([aa1b4c1](https://github.com/MunifTanjim/nui.nvim/commit/aa1b4c1e05983ff7debd2b4b2788651db099de2f))
* **popup:** support border:set_text before layout mount ([c9b4de6](https://github.com/MunifTanjim/nui.nvim/commit/c9b4de623d19a85b353ff70d2ae9c77143abe69c))
* **popup:** use popup winblend for border ([257dccc](https://github.com/MunifTanjim/nui.nvim/commit/257dccc43b4badc735978f0791d216f7d665b75a))
* **split:** for relative=editor always split from current window ([af8ddf5](https://github.com/MunifTanjim/nui.nvim/commit/af8ddf5db7e8485051aacefb24d76ab24ea26a0c))
* **split:** manual doautocmd BufWinEnter ([af7dfee](https://github.com/MunifTanjim/nui.nvim/commit/af7dfee12fbf51d12cfc6ee386fa54f7a5a573c8))
## [0.2.0](https://github.com/MunifTanjim/nui.nvim/compare/0.1.0...0.2.0) (2023-06-18)
### Features
* **input:** call on_close inside :unmount ([60e91dd](https://github.com/MunifTanjim/nui.nvim/commit/60e91dd3d3b19dfe1ca5833600dd94381657f035))
* **input:** update types ([c3c2957](https://github.com/MunifTanjim/nui.nvim/commit/c3c2957f2d5e2d5ebeb1be421cb0a2f4b50461ff))
* **layout:** support anchor for float layout ([f284153](https://github.com/MunifTanjim/nui.nvim/commit/f2841533540eb60d6878964a3cd3f8196e1f200c))
* **layout:** update types ([62b3203](https://github.com/MunifTanjim/nui.nvim/commit/62b320361fe0c93697a600fd4ca9ede295bd3c81))
* **line:** update types ([1a8d824](https://github.com/MunifTanjim/nui.nvim/commit/1a8d8240b458a6b82751702bfb217f00eaf305b6))
* **menu:** update types ([0a97a88](https://github.com/MunifTanjim/nui.nvim/commit/0a97a88bf28c8545550bcbcdd28a03b428647dbc))
* **popup:** add method border:set_style ([9d98e9b](https://github.com/MunifTanjim/nui.nvim/commit/9d98e9bac8cf681a608ef20c0a2205354a77c419))
* **popup:** create border buffer on initialization ([643e9af](https://github.com/MunifTanjim/nui.nvim/commit/643e9afb9411f5ebd95efb43437692e74238a4a3))
* **popup:** support `(text, hl_group)[]` for border text ([062e366](https://github.com/MunifTanjim/nui.nvim/commit/062e366afcdf2bc1e9d28313a1df4ff14f05cb4e))
* **popup:** support anchor in :update_layout ([52c9115](https://github.com/MunifTanjim/nui.nvim/commit/52c9115b10b22a2b8416bdab3072662d67d91ed6))
* **popup:** support nui.line for border text ([1b8fa8b](https://github.com/MunifTanjim/nui.nvim/commit/1b8fa8b2adaf3583a05f53b505093017d23cd62f))
* **popup:** update types ([f6b6923](https://github.com/MunifTanjim/nui.nvim/commit/f6b6923883491aeb7ce8dbae3b7b3767e8376aa8))
* **split:** update types ([f4469cb](https://github.com/MunifTanjim/nui.nvim/commit/f4469cb716ba4430e22ad7f0c71ef4da8baf1f34))
* support byte range for _.render_lines util ([993d550](https://github.com/MunifTanjim/nui.nvim/commit/993d5500f1c09710feae07a7887cf9a36cd7e02d))
* **table:** add nui.table block ([bfd3806](https://github.com/MunifTanjim/nui.nvim/commit/bfd3806904c29babfa61705a37e8b32ab687d2d2))
* **table:** support linenr_start for render method ([457a5cf](https://github.com/MunifTanjim/nui.nvim/commit/457a5cfe43a18d21337045b6026818a2898144d1))
* **table:** update types ([64bdc57](https://github.com/MunifTanjim/nui.nvim/commit/64bdc579873fa5bd303f6951ead2b419493c88e8))
* **text:** update types ([9f7666d](https://github.com/MunifTanjim/nui.nvim/commit/9f7666d89f9b4abf76d7db25a0511833dc72e7c1))
* **tree:** always track linenr in :render ([f008972](https://github.com/MunifTanjim/nui.nvim/commit/f008972ac7d24f7188521a7f8d158aac2fb0b07e))
* **tree:** update types ([d3cc976](https://github.com/MunifTanjim/nui.nvim/commit/d3cc9762581afa19e86353238423f521a61aeea4))
* **utils:** support string line in _.render_lines ([51764d2](https://github.com/MunifTanjim/nui.nvim/commit/51764d2c2235ad944ef4086d5f3954305728e5bd))
* **utils:** update types ([df321ba](https://github.com/MunifTanjim/nui.nvim/commit/df321ba052f37715ea5936a52d862a12efd836c6))
### Bug Fixes
* **input:** unmount race condition ([e319f25](https://github.com/MunifTanjim/nui.nvim/commit/e319f2554d14a521f4271576ebff2685105d7628))
* **layout:** even more patch for neovim/neovim[#18925](https://github.com/MunifTanjim/nui.nvim/issues/18925) ([de66444](https://github.com/MunifTanjim/nui.nvim/commit/de6644476702ba39344f1b28900b74381bc8c4c9))
* **layout:** more robust workaround for neovim/neovim[#18925](https://github.com/MunifTanjim/nui.nvim/issues/18925) ([9230eb0](https://github.com/MunifTanjim/nui.nvim/commit/9230eb01fb34f81b7b31ac77dcfdf356a71e487e))
* **popup:** border empty char handling ([bd2fefb](https://github.com/MunifTanjim/nui.nvim/commit/bd2fefb2efac70231fee497137295333dd4ada30))
* **popup:** ensure valid border.bufnr on mount ([6867305](https://github.com/MunifTanjim/nui.nvim/commit/6867305a508e374b1f4ec84ba0efa59351c6f7e6))
* **table:** update types ([d5a82aa](https://github.com/MunifTanjim/nui.nvim/commit/d5a82aae64426a805e19d8ef5a379292f9dc55d3))
* **tree:** do not wipe linenr tracking in :set_nodes ([da3b5eb](https://github.com/MunifTanjim/nui.nvim/commit/da3b5eb197391f3e1fac6f79c75e123ae9590be3))
### Performance Improvements
* **tree:** optimize hot paths ([19de4d5](https://github.com/MunifTanjim/nui.nvim/commit/19de4d5299be40a9aada6af940daeef20a59929e))
## 0.1.0 (2023-05-27)
### ⚠ BREAKING CHANGES
* **line:** change parameter order for methods.
* **text:** change parameter order for methods.
### Features
* accept multiple keys for keymap ([4998347](https://github.com/MunifTanjim/nui.nvim/commit/4998347f02fde115ad0c023b90be9c5654834635))
* add internal utils._.calculate_gap_width ([90d7285](https://github.com/MunifTanjim/nui.nvim/commit/90d7285c182b396c21ba69684068fd7d5d6eba79))
* add util to clear namespace for buffer ([6c63bf5](https://github.com/MunifTanjim/nui.nvim/commit/6c63bf5fd51076455d1b04f5c09d9841ab081263))
* **bar:** add lower-level `core.add_highlight` function ([20385a6](https://github.com/MunifTanjim/nui.nvim/commit/20385a698e8a5dd98ee7e63f16b700a10b921098))
* **bar:** add some lower-level `core.add_*` functions ([5d1ca66](https://github.com/MunifTanjim/nui.nvim/commit/5d1ca66829d8fac9965cd18fcc2cd9aa49ba1ea5))
* **bar:** initial implementation ([35758e9](https://github.com/MunifTanjim/nui.nvim/commit/35758e946a64376e0e9625a27469410b3d1f9223))
* **bar:** remove module ([0dc148c](https://github.com/MunifTanjim/nui.nvim/commit/0dc148c6ec06577fcf06cbab3b7dac96d48ba6be))
* **input:** add component Input ([7307c94](https://github.com/MunifTanjim/nui.nvim/commit/7307c94a7a0954f1c717e094c39772f28bb9d13f))
* **input:** move internal default_value and prompt prop ([595a2ea](https://github.com/MunifTanjim/nui.nvim/commit/595a2ea90f7d31bc23386ba67939117d45771035))
* **input:** support nui.text for options.prompt ([c380563](https://github.com/MunifTanjim/nui.nvim/commit/c38056355f5a72c7a1a005f7125afd62ed7b2083))
* **layout:** add method layout:update ([3f611a6](https://github.com/MunifTanjim/nui.nvim/commit/3f611a674254370d6d73f9a35a3eedbc8d07ee3b))
* **layout:** add some util for size and position ([cc3b970](https://github.com/MunifTanjim/nui.nvim/commit/cc3b970c1537de9562f82e5fa6aa126a629242d1))
* **layout:** feature guard lua autocmd api usage ([3dc6b89](https://github.com/MunifTanjim/nui.nvim/commit/3dc6b89bda8f1616a2638727eeb75a4a770e1c21))
* **layout:** initial implementation ([716c3f9](https://github.com/MunifTanjim/nui.nvim/commit/716c3f9d857b62a086b4f9123d0d34d1b7733922))
* **layout:** introduce layout type ([a5fd005](https://github.com/MunifTanjim/nui.nvim/commit/a5fd005263d238d2fbd6ee335e06139645f11fa9))
* **layout:** introduce split layout ([042cceb](https://github.com/MunifTanjim/nui.nvim/commit/042cceb497cc4cfa3ae735a5e7bc01b4b6f19ef1))
* **layout:** make window transparent ([2d33512](https://github.com/MunifTanjim/nui.nvim/commit/2d33512836cb603d58cb538e716a02c44547c6ab))
* **layout:** re-use windows for unchanged split layout box ([71ddaaf](https://github.com/MunifTanjim/nui.nvim/commit/71ddaafadfd8b3c21f809ea26fb4fe2a110bbf54))
* **layout:** support :show and :hide for float layout ([f572782](https://github.com/MunifTanjim/nui.nvim/commit/f572782bd8cc6b6b8671566b09146c4c09f20ae2))
* **layout:** support container component ([5bc7376](https://github.com/MunifTanjim/nui.nvim/commit/5bc737602e231111c44458c84e3eca05393f355f))
* **layout:** support o.grow factor for layout.box ([796dc82](https://github.com/MunifTanjim/nui.nvim/commit/796dc8293be59acc0c0d53b99e7dca7c32db4413))
* **layout:** support o.grow for layout.box ([2c6bac9](https://github.com/MunifTanjim/nui.nvim/commit/2c6bac942e3af35eb42165c923c0d3dc60a46430))
* **layout:** throw for empty box at init ([6e872b3](https://github.com/MunifTanjim/nui.nvim/commit/6e872b3bf28aaf52fd1b831ea0690aea205d6464))
* **layout:** tweak component wire up ([eed888e](https://github.com/MunifTanjim/nui.nvim/commit/eed888e47fa91980ce295dd347b213a102200b5a))
* **layout:** use backported autocmd methods ([4e21085](https://github.com/MunifTanjim/nui.nvim/commit/4e21085cd1b6be12da7ff0e7168e9100c51c363a))
* **layout:** wire up float layout components ([3aa617d](https://github.com/MunifTanjim/nui.nvim/commit/3aa617d9054e052cde68cd3141db4396af93a9cb))
* **line:** accept initial nui.text objects ([2f44cc9](https://github.com/MunifTanjim/nui.nvim/commit/2f44cc941cf1a129b6b6069a7489672b24cf0015))
* **line:** accept NuiText object as param for line:append() method ([34fd4bf](https://github.com/MunifTanjim/nui.nvim/commit/34fd4bfde84ff4b735a98dfb3508280d39e520f6))
* **line:** add method :width ([80122e5](https://github.com/MunifTanjim/nui.nvim/commit/80122e542fcebc361c4ca585be40a071c6e360be))
* **line:** add nui.line block ([1333fd0](https://github.com/MunifTanjim/nui.nvim/commit/1333fd07c57310d1421dcb337a21fbddb20d3c84))
* **line:** make ns_id required, update method signature ([5695bde](https://github.com/MunifTanjim/nui.nvim/commit/5695bde7ac9b8bcbb1df237a05169bad99458090))
* **line:** support nui.line in method :append ([401a69f](https://github.com/MunifTanjim/nui.nvim/commit/401a69f27ede2d5a9a725f4f45758708d3b72d09))
* make components extendable ([c75976e](https://github.com/MunifTanjim/nui.nvim/commit/c75976e823085218aeef4f5312a8c88e7d333358))
* **menu:** add component Menu ([dca0630](https://github.com/MunifTanjim/nui.nvim/commit/dca0630b0d7ab5dfcabaa70284c94c2e133b8200))
* **menu:** expose .tree ([d12a697](https://github.com/MunifTanjim/nui.nvim/commit/d12a6977846b2fa978bff89b439e509320854e10))
* **menu:** improve menu separator implementation ([0e05425](https://github.com/MunifTanjim/nui.nvim/commit/0e0542505369861fc22f6bff3ad2976bdef6d8f3))
* **menu:** move internal props ([0373a94](https://github.com/MunifTanjim/nui.nvim/commit/0373a94bc79725726aadcc1e0e26de159e3152e1))
* **menu:** pass self to on_change callback ([4438c5e](https://github.com/MunifTanjim/nui.nvim/commit/4438c5e53f8a5c834569309a5d3e276e6bf1abe9))
* **menu:** rename method menu:init to menu:new ([2825c3d](https://github.com/MunifTanjim/nui.nvim/commit/2825c3d60438bbed9c04f18f030ab3505249e8a7))
* **menu:** simplify automatic width calculation ([54cbaf5](https://github.com/MunifTanjim/nui.nvim/commit/54cbaf5d227cfedbb939d16b405116e4cea6e3fb))
* **menu:** support arbritary props for Menu.item ([77cefa6](https://github.com/MunifTanjim/nui.nvim/commit/77cefa67df7f282db20aa9afb5925aff79455dc2))
* **menu:** support nui.line for Menu.item ([51cbd0c](https://github.com/MunifTanjim/nui.nvim/commit/51cbd0ccc9410e317a947eea1e99966226a5f8b5))
* **menu:** support nui.text as separator char ([3029554](https://github.com/MunifTanjim/nui.nvim/commit/30295541c1bda2ab171ab7b684f790be3fbb60a7))
* **menu:** support nui.text for Menu.item ([13e557d](https://github.com/MunifTanjim/nui.nvim/commit/13e557d045b62efc6da87bdcc77503b5adf1db21))
* **menu:** support options.on_change ([db06fed](https://github.com/MunifTanjim/nui.nvim/commit/db06feddf324d4c6c8763fe9fca43b9140de84a9))
* **object:** add helper functions ([9531977](https://github.com/MunifTanjim/nui.nvim/commit/95319774b31558479c71e9b190870aae7bd8e49f))
* **object:** initial implementation ([194837f](https://github.com/MunifTanjim/nui.nvim/commit/194837ffc4a9c77dfcc18809c7e43a02b4d11e03))
* **popup:** add method .border:set_highlight ([acb72b1](https://github.com/MunifTanjim/nui.nvim/commit/acb72b150c7fdf01331b08460ec07fe7a81029b6))
* **popup:** add method popup:set_layout(config) ([006711f](https://github.com/MunifTanjim/nui.nvim/commit/006711f3ab4e8626f47b2b903759a9ac0e232fbd))
* **popup:** add method popup:set_position(position, relative) ([7ea1a6b](https://github.com/MunifTanjim/nui.nvim/commit/7ea1a6b910b1b33fceb33068df8f31e14ccceb1e))
* **popup:** add method popup:set_size(size) ([07b6e9a](https://github.com/MunifTanjim/nui.nvim/commit/07b6e9a90b58af8ca9c09f3695aa14603c947b61))
* **popup:** add method popup:unmap ([46bbf33](https://github.com/MunifTanjim/nui.nvim/commit/46bbf336e9068c73c4e810cb93c7a1f8197ebbc3))
* **popup:** add method popup.border:set_text(...) ([b82a5d3](https://github.com/MunifTanjim/nui.nvim/commit/b82a5d3bda43b5cd9b9dca39b782e86d2735933f))
* **popup:** add methods popup:hide() and popup:show() ([b3c706d](https://github.com/MunifTanjim/nui.nvim/commit/b3c706d4c30bd5cd8b05d10a2c8bf8fcd1f5cf7a))
* **popup:** add methods popup:on(...) and popup:off(...) ([0eb57a2](https://github.com/MunifTanjim/nui.nvim/commit/0eb57a2bdd565dfad09e4bda4293ef11c17d741f))
* **popup:** add option 'focusable' ([8339965](https://github.com/MunifTanjim/nui.nvim/commit/8339965e991ad54e6606ea22213996521701e293))
* **popup:** add option padding ([596cd77](https://github.com/MunifTanjim/nui.nvim/commit/596cd77a875eef1c6629e12074139e2ca0033e38))
* **popup:** add options 'buf_options' and 'win_options' ([163d99a](https://github.com/MunifTanjim/nui.nvim/commit/163d99a3fb9fe9a630c96bbf6159973885c3caf3))
* **popup:** add options.ns_id ([6f165aa](https://github.com/MunifTanjim/nui.nvim/commit/6f165aad4d2ac5a5a279a695ed247ec954cdcc4b))
* **popup:** add type annotation for bufnr,winid,ns_id ([c2d1f73](https://github.com/MunifTanjim/nui.nvim/commit/c2d1f73eb0ab02e90e8316a6e70158d2eec72153))
* **popup:** add type annotation for win_config ([f8ccc5c](https://github.com/MunifTanjim/nui.nvim/commit/f8ccc5cf8e8aec7ff34572b80e7b5d92b2d07556))
* **popup:** allow layout refresh when container size changes ([ee8d315](https://github.com/MunifTanjim/nui.nvim/commit/ee8d315456691fc1d05dcec465d95f8aec902541))
* **popup:** change behavior of padding ([9f29df4](https://github.com/MunifTanjim/nui.nvim/commit/9f29df4153da1483ad784a473f5bbcf02fcbbe3b))
* **popup:** clear namespace object on unmount ([58e06b0](https://github.com/MunifTanjim/nui.nvim/commit/58e06b0175cf22672d96a522343ec6ce017ba54c))
* **popup:** create buffer on initialization ([6b1deda](https://github.com/MunifTanjim/nui.nvim/commit/6b1deda411b96f0d694dead7cc12ab9db1dd9b65))
* **popup:** default border.text hl to FloatTitle ([4eaec2a](https://github.com/MunifTanjim/nui.nvim/commit/4eaec2ac66af2ca6ddddd3f665ad0909b90ae36a))
* **popup:** feature guard lua autocmd api usage ([6028584](https://github.com/MunifTanjim/nui.nvim/commit/60285847a4df99c2ab0ece3020eb930c81b09c41))
* **popup:** improve border highlight implementation ([2f58c40](https://github.com/MunifTanjim/nui.nvim/commit/2f58c406eb9cb5cedf5d82f9ce7c4f6efc418550))
* **popup:** improve cleanup ([220d4a4](https://github.com/MunifTanjim/nui.nvim/commit/220d4a4bbaa8f7dc80c0aa37e8377c7c150c6384))
* **popup:** merge internal position_meta into position ([bc2fc9c](https://github.com/MunifTanjim/nui.nvim/commit/bc2fc9c1b7b7241c4a0f34597f8ddc11c5f90844))
* **popup:** move internal buf_options and win_options ([3148908](https://github.com/MunifTanjim/nui.nvim/commit/31489084b7d3363c9a6f8f4d435374a25f4f636b))
* **popup:** move internal loading and mounted state ([4dc3214](https://github.com/MunifTanjim/nui.nvim/commit/4dc321448faaad171f0aab07d092a0e626fb1cbb))
* **popup:** move internal position state ([d229bbb](https://github.com/MunifTanjim/nui.nvim/commit/d229bbb4846bd4268ca1f6f67543294237ee0029))
* **popup:** move internal position_meta state ([cbbbe90](https://github.com/MunifTanjim/nui.nvim/commit/cbbbe90213091a462e71a1f102e0927ac11bac8a))
* **popup:** move internal size prop ([0a2fced](https://github.com/MunifTanjim/nui.nvim/commit/0a2fcedb97673ba478e26a0f59edbca950d15d31))
* **popup:** move internal win_enter prop ([f7736c9](https://github.com/MunifTanjim/nui.nvim/commit/f7736c90db395adc276519bd871bcec088bed908))
* **popup:** remove automatic cleanup ([06b48cf](https://github.com/MunifTanjim/nui.nvim/commit/06b48cf645971e1e1d598a083b98733890a82302))
* **popup:** remove method popup:on(...) ([c24b131](https://github.com/MunifTanjim/nui.nvim/commit/c24b13195c3e8c7c42d64736d96ded0ce7cb13ed))
* **popup:** rename method :set_layout to :update_layout ([90f59b0](https://github.com/MunifTanjim/nui.nvim/commit/90f59b035565e549e467fc414f1178d30c074ce9))
* **popup:** rename window to popup ([7097509](https://github.com/MunifTanjim/nui.nvim/commit/7097509b4b0fd21d870cea19c51389043c408449))
* **popup:** rework border internals ([8a776a2](https://github.com/MunifTanjim/nui.nvim/commit/8a776a2033220b16fa032b9d3590c75a178435ec))
* **popup:** simplify border highlight mechanism ([0ec30d9](https://github.com/MunifTanjim/nui.nvim/commit/0ec30d912c473139832d5c0bfd8ccfa675a48974))
* **popup:** simplify border.text ([674305a](https://github.com/MunifTanjim/nui.nvim/commit/674305a0cb1df2b2c0f51ade7081235755e93643))
* **popup:** support nui.text for simple border ([5f5a2e5](https://github.com/MunifTanjim/nui.nvim/commit/5f5a2e5284a08f930098383a6d0a00b861fc58d2))
* **popup:** support NuiText as border.text ([1248c67](https://github.com/MunifTanjim/nui.nvim/commit/1248c674a4a632ea7d45a76b1255df997379e116))
* **popup:** support option 'anchor' ([a86c733](https://github.com/MunifTanjim/nui.nvim/commit/a86c733e7d30596d80927b5eaf6869aa9a3d30af))
* **popup:** support set_size when unmounted ([fa86f85](https://github.com/MunifTanjim/nui.nvim/commit/fa86f8539373e1b76b93f95dfa36a5793ed4fe35))
* **popup:** support unmanaged buffer ([335415a](https://github.com/MunifTanjim/nui.nvim/commit/335415af52ea23d07433aa1f72f7c0d56c219316))
* **popup:** use backported autocmd methods ([372369d](https://github.com/MunifTanjim/nui.nvim/commit/372369dea4c059f831e540e34b9a49bf183c245b))
* **split:** add component Split ([bcb7382](https://github.com/MunifTanjim/nui.nvim/commit/bcb73828d54169f5ae9d141bca05fecb6aec5ec5))
* **split:** add method :update_layout ([32f44a6](https://github.com/MunifTanjim/nui.nvim/commit/32f44a610691ff2e22e60a8040801e047112806f))
* **split:** add method split:unmap ([e0444e0](https://github.com/MunifTanjim/nui.nvim/commit/e0444e020fd3ed336e2b6ba998ff7903ddb68ddd))
* **split:** create buffer on initialization ([0e36b78](https://github.com/MunifTanjim/nui.nvim/commit/0e36b78b836200ef83ef723c6a0ebb753ac8a11e))
* **split:** feature guard lua autocmd api usage ([d14daab](https://github.com/MunifTanjim/nui.nvim/commit/d14daab6710d1ebd7ec868f15bddde2bbd2c186f))
* **split:** improve cleanup ([36b0649](https://github.com/MunifTanjim/nui.nvim/commit/36b0649d7df3d1899d5aed8b27d22159cf058a2e))
* **split:** move internal buf_options and win_options ([6c9a3ee](https://github.com/MunifTanjim/nui.nvim/commit/6c9a3ee9fdb2c7a48cf13d9c82ee56822c5bfdb8))
* **split:** move internal loading and mounted state ([653199c](https://github.com/MunifTanjim/nui.nvim/commit/653199c635ad56c1e313e0890c6a72da8d3ee3bd))
* **split:** move internal props ([25e51eb](https://github.com/MunifTanjim/nui.nvim/commit/25e51eba14cdf2d445d0c0efde78d808741483cb))
* **split:** set buffer after .winid is set ([35091ca](https://github.com/MunifTanjim/nui.nvim/commit/35091ca0f8c766ea3542508f3ab9217a746cc5a0))
* **split:** store id internally ([96ef1cb](https://github.com/MunifTanjim/nui.nvim/commit/96ef1cb4e3c830dc79e18acac209ea5f7eb78829))
* **split:** support o.enter ([b75e2e6](https://github.com/MunifTanjim/nui.nvim/commit/b75e2e6d2a86a1105a756b894385d5fe836c3821))
* **split:** support o.ns_id ([28cafab](https://github.com/MunifTanjim/nui.nvim/commit/28cafab82f5d5ffc4321b9f82bae7b38951434d1))
* **split:** support o.relative.winid ([82851af](https://github.com/MunifTanjim/nui.nvim/commit/82851af021d651bb6bf6da0991c79f4c529ff37e))
* **split:** tweak split size handling for new window ([b681ab2](https://github.com/MunifTanjim/nui.nvim/commit/b681ab2a8a8750b37dcece4e87105a5671c39745))
* **split:** use backported autocmd methods ([6bd1d8a](https://github.com/MunifTanjim/nui.nvim/commit/6bd1d8af326c654a8b9d122c32a62fed5e55fe89))
* **text:** add method :new ([4ad7811](https://github.com/MunifTanjim/nui.nvim/commit/4ad781109a44bc00fa7d1208576f4086add27ad3))
* **text:** add method text:set(content, highlight?) ([eaf4844](https://github.com/MunifTanjim/nui.nvim/commit/eaf4844e9e84994705acc0347673a6b15e7c030f))
* **text:** add nui.text block ([a6df800](https://github.com/MunifTanjim/nui.nvim/commit/a6df800df514c2f0a9dd88d8db267ec5b8c8d68d))
* **text:** change highlight table key group->hl_group ([a8aaca1](https://github.com/MunifTanjim/nui.nvim/commit/a8aaca1578dff3504e78538b477910de344af0f8))
* **text:** make ns_id required, update method signature ([b63d199](https://github.com/MunifTanjim/nui.nvim/commit/b63d199ddfdc89457ae401ed0a5659fa1055c37c))
* **text:** preserve own extmark id ([26622d1](https://github.com/MunifTanjim/nui.nvim/commit/26622d147762f2212bf30e0792df1d0164a73cd9))
* **text:** remove method :new ([18a0390](https://github.com/MunifTanjim/nui.nvim/commit/18a0390d5caca31b310af41787e5ddd407c68783))
* **text:** return self from method text:set ([c5971ed](https://github.com/MunifTanjim/nui.nvim/commit/c5971ed28773cf9b6ce4ed8bfbc20266aa05b2a1))
* **text:** support cloning and use extmarks ([281d453](https://github.com/MunifTanjim/nui.nvim/commit/281d4535d046b1dd09d45bca8b0739bb16461b75))
* **text:** support extmark override when cloning ([878dfaf](https://github.com/MunifTanjim/nui.nvim/commit/878dfaf85fa19ff6811f19f1dad0bbfda77d5bbf))
* **tree:** add method node:get_child_ids ([bc05620](https://github.com/MunifTanjim/nui.nvim/commit/bc056204ff06b205dd3804474fc155180e704d47))
* **tree:** add method tree:get_nodes ([f85aedc](https://github.com/MunifTanjim/nui.nvim/commit/f85aedc1378acb375e4da0ec2220c1df0929e843))
* **tree:** add method tree:set_nodes(nodes, parent_id?) ([b25fab5](https://github.com/MunifTanjim/nui.nvim/commit/b25fab59d997cd9793bf7f42a1e41b9b7684d987))
* **tree:** add nui.tree block ([3bdfa78](https://github.com/MunifTanjim/nui.nvim/commit/3bdfa780fdba053358b85fd1f276052ae1455b61))
* **tree:** add o.bufnr and deprecate o.winid ([ce4869f](https://github.com/MunifTanjim/nui.nvim/commit/ce4869f97e4f3d4f9eb64d021fff775684ae8859))
* **tree:** clear namespace before render ([1f66cc7](https://github.com/MunifTanjim/nui.nvim/commit/1f66cc794bb6cad23f97b71e099cc32dd63c0614))
* **tree:** make node:has_children method work before init ([96f600b](https://github.com/MunifTanjim/nui.nvim/commit/96f600b58f128bde4a78a56c0a64d55664a43955))
* **tree:** move internal buf_options and win_options ([3a7c0c4](https://github.com/MunifTanjim/nui.nvim/commit/3a7c0c48b279ee0495c5ec61ca312fddd052195c))
* **tree:** move internal get_node_id and prepare_node functions ([1b9e046](https://github.com/MunifTanjim/nui.nvim/commit/1b9e04685097b93a4f8d01002adaaa5dcfb327af))
* **tree:** pass parent_node to prepare_node function ([559d33d](https://github.com/MunifTanjim/nui.nvim/commit/559d33dcace8016603129e63e5cb605aaa059c10))
* **tree:** rename options.ns to options.ns_id ([5db3901](https://github.com/MunifTanjim/nui.nvim/commit/5db390110bf9944b678c84cd7bcd2a28af712481))
* **tree:** return end linenr from tree:get_node method ([8ae5e31](https://github.com/MunifTanjim/nui.nvim/commit/8ae5e3106a0fa17144a0a086ccfce1e73a73f19f))
* **tree:** return linenr from tree:get_node method ([3b746d7](https://github.com/MunifTanjim/nui.nvim/commit/3b746d7b6f16818a970d1c4810261d18b958a956))
* **tree:** support linenr for method tree:get_node ([7fee7c6](https://github.com/MunifTanjim/nui.nvim/commit/7fee7c6c176e83806a144f7e0a8ac6251920a8a7))
* **tree:** support linenr_start for method :render ([afb9e5b](https://github.com/MunifTanjim/nui.nvim/commit/afb9e5b4512e17d879fb069e77de4141472d0fb9))
* **tree:** support multiline node ([4926ee9](https://github.com/MunifTanjim/nui.nvim/commit/4926ee9ba8fac49ad23cd695f1f5c0952c52dd4a))
* **tree:** support nil return for o.prepare_node ([5a79b1b](https://github.com/MunifTanjim/nui.nvim/commit/5a79b1b3b8231cfa33290d8d3c56d36b6496499e))
* use api-autocmd if available ([3f05d74](https://github.com/MunifTanjim/nui.nvim/commit/3f05d742b273ed4b48db4e5ab99c09b8b05cd537))
* use native keymap callback if supported ([ad2c05c](https://github.com/MunifTanjim/nui.nvim/commit/ad2c05c983dda8d423d952fce55eb3cf3966a1ea))
* **utils:** add autocmd ([1f51d5a](https://github.com/MunifTanjim/nui.nvim/commit/1f51d5a6735153ea5e3f9a1f68a12c9f1bce5681))
* **utils:** add buf_storage ([6300e3b](https://github.com/MunifTanjim/nui.nvim/commit/6300e3bdcc2fc38e361a48b76247c4524a0fb27a))
* **utils:** backport autocmd to nvim < 0.7.x ([587a49f](https://github.com/MunifTanjim/nui.nvim/commit/587a49f90fb036a6d94904851dafdd53d1327fe0))
* **utils:** move keymap to utils ([56c2230](https://github.com/MunifTanjim/nui.nvim/commit/56c223041ad342b2a8d28d2bb1dbd88dc0d7839a))
* **utils:** update autocmd.event ([6f9153c](https://github.com/MunifTanjim/nui.nvim/commit/6f9153cc8462a3bb0a8a883694befa6554d31403))
* **window:** add method window:destroy() ([c51856d](https://github.com/MunifTanjim/nui.nvim/commit/c51856da53df1327a406e7983d5fd748486fc339))
* **window:** add method window:map(...) ([f69ee04](https://github.com/MunifTanjim/nui.nvim/commit/f69ee0498ecc57268d23a4b51516002404c3def1))
* **window:** add method window:on(event_name, handler) ([f36b496](https://github.com/MunifTanjim/nui.nvim/commit/f36b496b52aec95dc9e122830b0d2ff64eeb21f5))
* **window:** add method window:render() ([d1a047d](https://github.com/MunifTanjim/nui.nvim/commit/d1a047d22d0794943a08a2de598ac57c61fc4978))
* **window:** add option highlight ([c4bbe61](https://github.com/MunifTanjim/nui.nvim/commit/c4bbe6139f012aca971ea26721dc4c1942b71286))
* **window:** change default border to none ([8db2faa](https://github.com/MunifTanjim/nui.nvim/commit/8db2faa3dfa9b196fe2fd11311c534b37db2428a))
* **window:** enhanced border support ([a17070c](https://github.com/MunifTanjim/nui.nvim/commit/a17070c6b55ba36db82edef0c3af28ac0929e649))
* **window:** initial implementation ([19e4bb6](https://github.com/MunifTanjim/nui.nvim/commit/19e4bb669d2fdd168a6d0a3edebaca150f93678f))
* **window:** rename 'destroy' to 'unmount' ([c409518](https://github.com/MunifTanjim/nui.nvim/commit/c409518e6640e50dc754b199cd37246f60a3fdbc))
* **window:** rename 'render' to 'mount' ([66190d2](https://github.com/MunifTanjim/nui.nvim/commit/66190d237fc61db2135c0c19eb9091091d9090dc))
* **window:** simplify option relative ([5584892](https://github.com/MunifTanjim/nui.nvim/commit/55848921914083d57e178448f3714f3ba4d9fc75))
### Bug Fixes
* **bar:** rename tabnr to tabid in context ([f220495](https://github.com/MunifTanjim/nui.nvim/commit/f2204952ba670372de507bdd446c6a14f821ac73))
* **bar:** type for generator ([2a6533f](https://github.com/MunifTanjim/nui.nvim/commit/2a6533fb798efad7dd783311315bab8dc5eb381b))
* **input:** escape multi-byte chars in default value ([698e758](https://github.com/MunifTanjim/nui.nvim/commit/698e75814cd7c56b0dd8af4936bcef2d13807f3c))
* **input:** stopinsert on close ([971cca4](https://github.com/MunifTanjim/nui.nvim/commit/971cca41914aaefc9569b8a1904cf8c25cec5aa8))
* **input:** try to keep stable cursor position on parent window ([6f803e8](https://github.com/MunifTanjim/nui.nvim/commit/6f803e88093573f73d4ee6c0dfe0575df3f97a9f))
* **layout:** apply size for first child box in split layout ([dde3f89](https://github.com/MunifTanjim/nui.nvim/commit/dde3f89b74b726eacfa6bea82c895af265573fe4))
* **layout:** float position calculation for child with complex border ([257da38](https://github.com/MunifTanjim/nui.nvim/commit/257da38029d3859ed111804f9d4e95b0fa993a31))
* **layout:** focus on relative.win for split ':update' ([6e8f9a0](https://github.com/MunifTanjim/nui.nvim/commit/6e8f9a0f280fada3f8ce694a42f8376a31e9776e))
* **layout:** o.relative for split layout ([bf5900f](https://github.com/MunifTanjim/nui.nvim/commit/bf5900f1b60bf6499755ac92315181a24a87a577))
* **layout:** preserve split win_options 'winfixheight' and 'winfixwidth' ([ecd9def](https://github.com/MunifTanjim/nui.nvim/commit/ecd9def93891b9260b15b5fcef542eaabf4145c9))
* **layout:** process float layout box change ([51721a4](https://github.com/MunifTanjim/nui.nvim/commit/51721a409794bf2e432e99acc21b635102fedcea))
* **layout:** process split layout box change ([b12db53](https://github.com/MunifTanjim/nui.nvim/commit/b12db5321c194c10eb34e610fb76ce2c058853fc))
* **layout:** typo in update_layout_config util ([d5d3d6c](https://github.com/MunifTanjim/nui.nvim/commit/d5d3d6ce542b3921f8a8da213ef7b0841f6a4adc))
* luacheck lint warnings ([e7dd31c](https://github.com/MunifTanjim/nui.nvim/commit/e7dd31c4135389e460a686d48d10ed532ca5234e))
* **menu:** error with fallback separator char ([76fc8ed](https://github.com/MunifTanjim/nui.nvim/commit/76fc8edf771adff74aba513bd0f62e984996ef88))
* **popup:** add border.style default value ([a07b754](https://github.com/MunifTanjim/nui.nvim/commit/a07b754552008012f2d7d3602b7a233a29d92c66))
* **popup:** border highlight for type:complex ([151d593](https://github.com/MunifTanjim/nui.nvim/commit/151d593b28911d61b10e1a8ba14f9e4c755141aa))
* **popup:** border padding with style=shadow ([37e0511](https://github.com/MunifTanjim/nui.nvim/commit/37e0511f189cd19eabd0e71841f10c3a39bbb62d))
* **popup:** check bufnr is valid before clearing namespace ([62facd3](https://github.com/MunifTanjim/nui.nvim/commit/62facd37e0dd8196212399a897374f689886f500))
* **popup:** check if border bufnr is valid before clearing namespace ([b99e6cb](https://github.com/MunifTanjim/nui.nvim/commit/b99e6cb13dc51768abc1c4c8585045a0c0459ef1))
* **popup:** do better mount/unmount handling ([7622fcf](https://github.com/MunifTanjim/nui.nvim/commit/7622fcf3dc4cffffc666d9f1f4e646168f640a2a))
* **popup:** do buf_storage.cleanup after buffer wipeout ([644e595](https://github.com/MunifTanjim/nui.nvim/commit/644e595a3862ca45de71ff14fa465019ecfc17ad))
* **popup:** do BufWinEnter autocmd after self.winid is set ([4c77e3a](https://github.com/MunifTanjim/nui.nvim/commit/4c77e3a064b7b0fdfb5f2729500a81b431ff86f8))
* **popup:** do not reset win_options on update_layout call ([1f43b13](https://github.com/MunifTanjim/nui.nvim/commit/1f43b13d133eb4b4f53a4485379d9afa58808389))
* **popup:** handle border padding without text ([1f9aebc](https://github.com/MunifTanjim/nui.nvim/commit/1f9aebcaca1f43c311ba16a2aad9170d597640a8))
* **popup:** handle border:set_text properly ([e78c822](https://github.com/MunifTanjim/nui.nvim/commit/e78c822378c102978792465dbf93b8ffb27a4cc8))
* **popup:** handle map as border.style ([26a0eea](https://github.com/MunifTanjim/nui.nvim/commit/26a0eeaca8890b74d53a91f84520abea94fcf8ee))
* **popup:** handle various mix of border and padding ([49a155f](https://github.com/MunifTanjim/nui.nvim/commit/49a155f3bbacb90a8b2df320f5b127b568083cd6))
* **popup:** highlight for simple border ([3ff3d26](https://github.com/MunifTanjim/nui.nvim/commit/3ff3d2628f3c14633792f702b3cfea377addcf47))
* **popup:** ignore WinClosed from other popup ([a501202](https://github.com/MunifTanjim/nui.nvim/commit/a5012020a48f5740bf30bb3468ca532cb7627997))
* **popup:** manual doautocmd BufWinEnter ([0807a9c](https://github.com/MunifTanjim/nui.nvim/commit/0807a9ca3274d5f89d02802aa00dac9fc2649864))
* **popup:** position relative to buffer position ([f369333](https://github.com/MunifTanjim/nui.nvim/commit/f36933397a0689a96106d9aa74db320286f5ffda))
* **popup:** properly apply winhighlight ([4396e44](https://github.com/MunifTanjim/nui.nvim/commit/4396e4427dc7ec80c58575096fdcad46d336a7ac))
* **popup:** remove mutation in border:get() ([1179f2e](https://github.com/MunifTanjim/nui.nvim/commit/1179f2e3f5245ab585e1154478f45bab1cf41867))
* **popup:** respect 'enter' option on subsequent mounts ([602e4d8](https://github.com/MunifTanjim/nui.nvim/commit/602e4d885fda5da9f015345fecb8c745ffedbf52))
* **popup:** set noautocmd for opening border window ([4715f60](https://github.com/MunifTanjim/nui.nvim/commit/4715f6092443f0b8fb9a3bcb0cfd03202bb03477))
* **popup:** set win_config.win explicitly if applicable ([d50d84a](https://github.com/MunifTanjim/nui.nvim/commit/d50d84a340a3088b8560bf74fa3d6f6ad0556134))
* **popup:** store winid for parent window ([03131f8](https://github.com/MunifTanjim/nui.nvim/commit/03131f8aa1873d08009ca214727a2c699d73dfe6))
* **popup:** take border size_delta into account when calculating position ([e67310b](https://github.com/MunifTanjim/nui.nvim/commit/e67310b23d21ebe8b12d9dbadb3dfa562dda5057))
* **popup:** track mounted state ([c1db7f8](https://github.com/MunifTanjim/nui.nvim/commit/c1db7f8377dfce4f724f35eec74868a15443dccf))
* **popup:** update position handling ([d87b561](https://github.com/MunifTanjim/nui.nvim/commit/d87b56193991e102516689f394101d1367bf8ef5))
* **popup:** update state and buffer handling for hide/show ([02e9262](https://github.com/MunifTanjim/nui.nvim/commit/02e9262d2d1820c2ed573f3a09848affc6526704))
* **popup:** use copy of border styles table item ([abd1a4a](https://github.com/MunifTanjim/nui.nvim/commit/abd1a4a23d0bbc0d0d4bc8faaa240330b44a4d5c))
* **popup:** use popup winhighlight for border ([cf67636](https://github.com/MunifTanjim/nui.nvim/commit/cf676363181aae149271226531abca341e583997))
* **split:** check bufnr is valid before clearing namespace ([e9889bb](https://github.com/MunifTanjim/nui.nvim/commit/e9889bbd9919544697d497537acacd9c67d0de99))
* **split:** do buf_storage.cleanup after buffer wipeout ([5bf5d62](https://github.com/MunifTanjim/nui.nvim/commit/5bf5d62531b473b01cae65ea389d4f788fb2341f))
* **split:** do not update position when unchanged ([6d86148](https://github.com/MunifTanjim/nui.nvim/commit/6d86148ad43554f51caa82e99d63a57be1654182))
* **split:** set size after open window ([c45ad68](https://github.com/MunifTanjim/nui.nvim/commit/c45ad685cb156174761f912c7697fdb6fd5a3b50))
* **split:** skip manual buf/win removal when pending quit ([cc76e6f](https://github.com/MunifTanjim/nui.nvim/commit/cc76e6ff13629b18d3dedfadd4f52e35ff085700))
* **split:** tweak container size relative to editor ([120fe69](https://github.com/MunifTanjim/nui.nvim/commit/120fe69bc4d96a13f89c2450ceb27fcd921b77ae))
* **split:** use self.winid for WinClosed ([747c20d](https://github.com/MunifTanjim/nui.nvim/commit/747c20d10a42f22a0125ee2e7397aee4c2422ffe))
* support nui.text for internal alignment util ([915fabe](https://github.com/MunifTanjim/nui.nvim/commit/915fabe334639c384ed5c66005046b96d4e8d30a))
* **text:** fix :highlight extmark.end_col ([3775746](https://github.com/MunifTanjim/nui.nvim/commit/3775746f8324db1ff5068eaf10f321db5e566611))
* **tree:** fix calculation for method :render ([70f2dad](https://github.com/MunifTanjim/nui.nvim/commit/70f2dadb73b5aa15727ec8f7a620818997505be5))
* **tree:** pass ns_id correctly to nui.line in :render method ([70fc6b6](https://github.com/MunifTanjim/nui.nvim/commit/70fc6b66c651538a78d393cf9fc830c81e919ca8))
* **tree:** remove children recursively in method remove_node ([4939282](https://github.com/MunifTanjim/nui.nvim/commit/4939282919885e1c83aff68ecb35b3cadf6015a9))
* **tree:** remove id from .nodes.root_ids on tree:remove_node ([792caa3](https://github.com/MunifTanjim/nui.nvim/commit/792caa3c1dc3efb22385b3b363c32567ba9cb059))
* **tree:** set default buf_options.bufhidden=hide ([7c7bdf4](https://github.com/MunifTanjim/nui.nvim/commit/7c7bdf40219bc274a849fa241daa47872c809fd8))
* **tree:** set default buf_options.undolevels=0 ([42552b3](https://github.com/MunifTanjim/nui.nvim/commit/42552b3797c3452c5c94e0c84a04fbda9591b9d1))
* vim.schedule QuitPre event callback ([d147222](https://github.com/MunifTanjim/nui.nvim/commit/d147222a1300901656f3ebd5b95f91732785a329))
* **window:** cleanup properly ([dbc8185](https://github.com/MunifTanjim/nui.nvim/commit/dbc81850faeeb70c303cdf3c50e0a4b6bbf154bd))
* **window:** set zindex properly ([2d427f7](https://github.com/MunifTanjim/nui.nvim/commit/2d427f7f9bac670523de2906776117192f243d8c))
* **window:** use 0-indexed position ([fbf96df](https://github.com/MunifTanjim/nui.nvim/commit/fbf96df95e437687206b9213924372c13c328b7a))
### Continuous Integration
* introduce automated release ([70560c4](https://github.com/MunifTanjim/nui.nvim/commit/70560c4e7b36ff974e7136d08aa4022e79a002f4))

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Munif Tanjim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,353 @@
![GitHub Workflow Status: CI](https://img.shields.io/github/actions/workflow/status/MunifTanjim/nui.nvim/ci.yml?branch=main&label=CI&style=for-the-badge)
[![Coverage](https://img.shields.io/codecov/c/gh/MunifTanjim/nui.nvim/master?style=for-the-badge)](https://codecov.io/gh/MunifTanjim/nui.nvim)
[![Version](https://img.shields.io/luarocks/v/MunifTanjim/nui.nvim?color=%232c3e67&style=for-the-badge)](https://luarocks.org/modules/MunifTanjim/nui.nvim)
![License](https://img.shields.io/github/license/MunifTanjim/nui.nvim?color=%23000080&style=for-the-badge)
# nui.nvim
UI Component Library for Neovim.
## Requirements
- [Neovim 0.5.0](https://github.com/neovim/neovim/releases/tag/v0.5.0)
## Installation
Install the plugins with your preferred plugin manager. For example, with [`vim-plug`](https://github.com/junegunn/vim-plug):
```vim
Plug 'MunifTanjim/nui.nvim'
```
## Blocks
### [NuiText](lua/nui/text)
Quickly add highlighted text on the buffer.
**[Check Detailed Documentation for `nui.text`](lua/nui/text)**
**[Check Wiki Page for `nui.text`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.text)**
### [NuiLine](lua/nui/line)
Quickly add line containing highlighted text chunks on the buffer.
**[Check Detailed Documentation for `nui.line`](lua/nui/line)**
**[Check Wiki Page for `nui.line`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.line)**
### [NuiTable](lua/nui/table)
Quickly render table-like structured content on the buffer.
**[Check Detailed Documentation for `nui.table`](lua/nui/table)**
**[Check Wiki Page for `nui.table`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.table)**
### [NuiTree](lua/nui/tree)
Quickly render tree-like structured content on the buffer.
**[Check Detailed Documentation for `nui.tree`](lua/nui/tree)**
**[Check Wiki Page for `nui.tree`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.tree)**
## Components
### [Layout](lua/nui/layout)
![Layout GIF](https://github.com/MunifTanjim/nui.nvim/wiki/media/layout.gif)
```lua
local Popup = require("nui.popup")
local Layout = require("nui.layout")
local popup_one, popup_two = Popup({
enter = true,
border = "single",
}), Popup({
border = "double",
})
local layout = Layout(
{
position = "50%",
size = {
width = 80,
height = "60%",
},
},
Layout.Box({
Layout.Box(popup_one, { size = "40%" }),
Layout.Box(popup_two, { size = "60%" }),
}, { dir = "row" })
)
local current_dir = "row"
popup_one:map("n", "r", function()
if current_dir == "col" then
layout:update(Layout.Box({
Layout.Box(popup_one, { size = "40%" }),
Layout.Box(popup_two, { size = "60%" }),
}, { dir = "row" }))
current_dir = "row"
else
layout:update(Layout.Box({
Layout.Box(popup_two, { size = "60%" }),
Layout.Box(popup_one, { size = "40%" }),
}, { dir = "col" }))
current_dir = "col"
end
end, {})
layout:mount()
```
**[Check Detailed Documentation for `nui.layout`](lua/nui/layout)**
**[Check Wiki Page for `nui.layout`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.layout)**
### [Popup](lua/nui/popup)
![Popup GIF](https://github.com/MunifTanjim/nui.nvim/wiki/media/popup.gif)
```lua
local Popup = require("nui.popup")
local event = require("nui.utils.autocmd").event
local popup = Popup({
enter = true,
focusable = true,
border = {
style = "rounded",
},
position = "50%",
size = {
width = "80%",
height = "60%",
},
})
-- mount/open the component
popup:mount()
-- unmount component when cursor leaves buffer
popup:on(event.BufLeave, function()
popup:unmount()
end)
-- set content
vim.api.nvim_buf_set_lines(popup.bufnr, 0, 1, false, { "Hello World" })
```
**[Check Detailed Documentation for `nui.popup`](lua/nui/popup)**
**[Check Wiki Page for `nui.popup`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.popup)**
### [Input](lua/nui/input)
![Input GIF](https://github.com/MunifTanjim/nui.nvim/wiki/media/input.gif)
```lua
local Input = require("nui.input")
local event = require("nui.utils.autocmd").event
local input = Input({
position = "50%",
size = {
width = 20,
},
border = {
style = "single",
text = {
top = "[Howdy?]",
top_align = "center",
},
},
win_options = {
winhighlight = "Normal:Normal,FloatBorder:Normal",
},
}, {
prompt = "> ",
default_value = "Hello",
on_close = function()
print("Input Closed!")
end,
on_submit = function(value)
print("Input Submitted: " .. value)
end,
})
-- mount/open the component
input:mount()
-- unmount component when cursor leaves buffer
input:on(event.BufLeave, function()
input:unmount()
end)
```
**[Check Detailed Documentation for `nui.input`](lua/nui/input)**
**[Check Wiki Page for `nui.input`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.input)**
### [Menu](lua/nui/menu)
![Menu GIF](https://github.com/MunifTanjim/nui.nvim/wiki/media/menu.gif)
```lua
local Menu = require("nui.menu")
local event = require("nui.utils.autocmd").event
local menu = Menu({
position = "50%",
size = {
width = 25,
height = 5,
},
border = {
style = "single",
text = {
top = "[Choose-an-Element]",
top_align = "center",
},
},
win_options = {
winhighlight = "Normal:Normal,FloatBorder:Normal",
},
}, {
lines = {
Menu.item("Hydrogen (H)"),
Menu.item("Carbon (C)"),
Menu.item("Nitrogen (N)"),
Menu.separator("Noble-Gases", {
char = "-",
text_align = "right",
}),
Menu.item("Helium (He)"),
Menu.item("Neon (Ne)"),
Menu.item("Argon (Ar)"),
},
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("Menu Closed!")
end,
on_submit = function(item)
print("Menu Submitted: ", item.text)
end,
})
-- mount the component
menu:mount()
```
**[Check Detailed Documentation for `nui.menu`](lua/nui/menu)**
**[Check Wiki Page for `nui.menu`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.menu)**
### [Split](lua/nui/split)
![Split GIF](https://github.com/MunifTanjim/nui.nvim/wiki/media/split.gif)
```lua
local Split = require("nui.split")
local event = require("nui.utils.autocmd").event
local split = Split({
relative = "editor",
position = "bottom",
size = "20%",
})
-- mount/open the component
split:mount()
-- unmount component when cursor leaves buffer
split:on(event.BufLeave, function()
split:unmount()
end)
```
**[Check Detailed Documentation for `nui.split`](lua/nui/split)**
**[Check Wiki Page for `nui.split`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.split)**
## Extendibility
Each of the [blocks](#blocks) and [components](#components) can be extended to add new
methods or change their behaviors.
```lua
local Timer = Popup:extend("Timer")
function Timer:init(popup_options)
local options = vim.tbl_deep_extend("force", popup_options or {}, {
border = "double",
focusable = false,
position = { row = 0, col = "100%" },
size = { width = 10, height = 1 },
win_options = {
winhighlight = "Normal:Normal,FloatBorder:SpecialChar",
},
})
Timer.super.init(self, options)
end
function Timer:countdown(time, step, format)
local function draw_content(text)
local gap_width = 10 - vim.api.nvim_strwidth(text)
vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, false, {
string.format(
"%s%s%s",
string.rep(" ", math.floor(gap_width / 2)),
text,
string.rep(" ", math.ceil(gap_width / 2))
),
})
end
self:mount()
local remaining_time = time
draw_content(format(remaining_time))
vim.fn.timer_start(step, function()
remaining_time = remaining_time - step
draw_content(format(remaining_time))
if remaining_time <= 0 then
self:unmount()
end
end, { ["repeat"] = math.ceil(remaining_time / step) })
end
local timer = Timer()
timer:countdown(10000, 1000, function(time)
return tostring(time / 1000) .. "s"
end)
```
#### `nui.object`
A small object library is bundled with `nui.nvim`. It is, more or less, a clone of the
[`kikito/middleclass`](https://github.com/kikito/middleclass) library.
[Check Wiki Page for `nui.object`](https://github.com/MunifTanjim/nui.nvim/wiki/nui.object)
## License
Licensed under the MIT License. Check the [LICENSE](./LICENSE) file for details.

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

View file

@ -0,0 +1,27 @@
rockspec_format = "3.0"
package = "nui.nvim"
version = "dev-1"
source = {
url = "git+https://github.com/MunifTanjim/nui.nvim.git",
tag = nil,
}
description = {
summary = "UI Component Library for Neovim.",
detailed = [[
UI Component Library for Neovim.
]],
license = "MIT",
homepage = "https://github.com/MunifTanjim/nui.nvim",
issues_url = "https://github.com/MunifTanjim/nui.nvim/issues",
maintainer = "Munif Tanjim (https://muniftanjim.dev)",
labels = {
"neovim",
},
}
build = {
type = "builtin",
}
test = {
type = "command",
command = "scripts/test.sh",
}

View file

@ -0,0 +1,19 @@
#!/usr/bin/env sh
r_v_major="0"
r_v_minor="17"
r_v_patch="1"
v="$(stylua --version | cut -d' ' -f2)"
v_major="$(echo "${v}" | cut -d'.' -f1)"
v_minor="$(echo "${v}" | cut -d'.' -f2)"
v_patch="$(echo "${v}" | cut -d'.' -f3)"
v_error_message="required stylua ~v${r_v_major}.${r_v_minor}.${r_v_patch}, found v${v_major}.${v_minor}.${v_patch}"
if test ${v_major} -ne ${r_v_major} || test ${v_minor} -ne ${r_v_minor} || test ${v_patch} -lt ${r_v_patch}; then
echo ${v_error_message} >&2
exit 1
fi
stylua --color always ${1} lua/nui/ tests/

View file

@ -0,0 +1,3 @@
#!/usr/bin/env sh
luacheck $@ .

View file

@ -0,0 +1,35 @@
diff --git a/lua/plenary/busted.lua b/lua/plenary/busted.lua
index 1b15fce..8363084 100644
--- a/lua/plenary/busted.lua
+++ b/lua/plenary/busted.lua
@@ -238,7 +238,7 @@ mod.run = function(file)
-- If nothing runs (empty file without top level describe)
if not results.pass then
if is_headless then
- return vim.cmd "0cq"
+ os.exit(0)
else
return
end
@@ -259,7 +259,7 @@ mod.run = function(file)
end
else
if is_headless then
- return vim.cmd "0cq"
+ os.exit(0)
end
end
end)()
diff --git a/lua/plenary/test_harness.lua b/lua/plenary/test_harness.lua
index 394e28d..66cc6b4 100644
--- a/lua/plenary/test_harness.lua
+++ b/lua/plenary/test_harness.lua
@@ -169,7 +169,7 @@ function harness.test_directory(directory, opts)
return vim.cmd "1cq"
end
- return vim.cmd "0cq"
+ os.exit(0)
end
end

View file

@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
declare -r plugins_dir="./.tests/site/pack/deps/start"
declare -r module="nui"
declare test_scope="${module}"
while [[ $# -gt 0 ]]; do
case "${1}" in
--clean)
shift
echo "[test] cleaning up environment"
rm -rf "${plugins_dir}"
echo "[test] envionment cleaned"
;;
*)
if [[ "${test_scope}" == "${module}" ]] && [[ "${1}" == "${module}/"* ]]; then
test_scope="${1}"
fi
shift
;;
esac
done
function setup_environment() {
echo
echo "[test] setting up environment"
echo
if [[ ! -d "${plugins_dir}" ]]; then
mkdir -p "${plugins_dir}"
fi
if [[ ! -d "${plugins_dir}/plenary.nvim" ]]; then
echo "[plugins] plenary.nvim: installing..."
git clone https://github.com/nvim-lua/plenary.nvim "${plugins_dir}/plenary.nvim"
# commit 9069d14a120cadb4f6825f76821533f2babcab92 broke luacov
# issue: https://github.com/nvim-lua/plenary.nvim/issues/353
local -r plenary_353_patch="$(pwd)/scripts/plenary-353.patch"
git -C "${plugins_dir}/plenary.nvim" apply "${plenary_353_patch}"
echo "[plugins] plenary.nvim: installed"
echo
fi
echo "[test] environment ready"
echo
}
function luacov_start() {
luacov_dir="$(dirname "$(luarocks which luacov 2>/dev/null | head -1)")"
if [[ "${luacov_dir}" == "." ]]; then
luacov_dir=""
fi
if test -n "${luacov_dir}"; then
rm -f luacov.*.out
export LUA_PATH=";;${luacov_dir}/?.lua"
fi
}
function luacov_end() {
if test -n "${luacov_dir}"; then
if test -f "luacov.stats.out"; then
luacov
echo
tail -n +$(($(grep -n "^Summary$" luacov.report.out | cut -d":" -f1) - 1)) luacov.report.out
fi
fi
}
setup_environment
luacov_start
declare test_logs=""
if [[ -d "./tests/${test_scope}/" ]]; then
test_logs=$(nvim --headless --noplugin -u tests/init.lua -c "lua require('plenary.test_harness').test_directory('./tests/${test_scope}/', { minimal_init = 'tests/init.lua', sequential = true })" || true)
elif [[ -f "./tests/${test_scope}_spec.lua" ]]; then
test_logs=$(nvim --headless --noplugin -u tests/init.lua -c "lua require('plenary.busted').run('./tests/${test_scope}_spec.lua')" || true)
fi
echo "${test_logs}"
luacov_end
if echo "${test_logs}" | grep --quiet "stack traceback"; then
{
echo ""
echo "FOUND STACK TRACEBACK IN TEST LOGS"
echo ""
} >&2
exit 1
fi

View file

@ -0,0 +1,312 @@
local function to_string(text)
if type(text) == "string" then
return text
end
if type(text) == "table" then
if text.content then
return text:content()
end
return text[1]
end
error("unsupported text")
end
local popup = {}
local mod = {}
mod.popup = popup
function mod.eq(...)
return assert.are.same(...)
end
function mod.approx(...)
return assert.are.near(...)
end
function mod.neq(...)
return assert["not"].are.same(...)
end
---@param fn fun(): nil
---@param error string
---@param is_plain boolean
function mod.errors(fn, error, is_plain)
assert.matches_error(fn, error, 1, is_plain)
end
---@param keys string
---@param mode string
function mod.feedkeys(keys, mode)
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, false, true), mode or "", true)
end
---@param tbl table
---@param keys string[]
function mod.tbl_pick(tbl, keys)
if not keys or #keys == 0 then
return tbl
end
local new_tbl = {}
for _, key in ipairs(keys) do
new_tbl[key] = tbl[key]
end
return new_tbl
end
---@param tbl table
---@param keys string[]
function mod.tbl_omit(tbl, keys)
if not keys or #keys == 0 then
return tbl
end
local new_tbl = vim.deepcopy(tbl)
for _, key in ipairs(keys) do
rawset(new_tbl, key, nil)
end
return new_tbl
end
---@param bufnr number
---@param ns_id integer
---@param linenr integer (1-indexed)
---@param byte_start? integer (0-indexed)
---@param byte_end? integer (0-indexed, inclusive)
function mod.get_line_extmarks(bufnr, ns_id, linenr, byte_start, byte_end)
return vim.api.nvim_buf_get_extmarks(
bufnr,
ns_id,
{ linenr - 1, byte_start or 0 },
{ linenr - 1, byte_end and byte_end + 1 or -1 },
{ details = true }
)
end
---@param bufnr number
---@param ns_id integer
---@param linenr integer (1-indexed)
---@param text string
---@return table[]
---@return { byte_start: integer, byte_end: integer } info (byte range: 0-indexed, inclusive)
function mod.get_text_extmarks(bufnr, ns_id, linenr, text)
local line = vim.api.nvim_buf_get_lines(bufnr, linenr - 1, linenr, false)[1]
local byte_start = string.find(line, text) -- 1-indexed
byte_start = byte_start - 1 -- 0-indexed
local byte_end = byte_start + #text - 1 -- inclusive
local extmarks = vim.api.nvim_buf_get_extmarks(
bufnr,
ns_id,
{ linenr - 1, byte_start },
{ linenr - 1, byte_end },
{ details = true }
)
return extmarks, { byte_start = byte_start, byte_end = byte_end }
end
---@param bufnr number
---@param lines string[]
---@param linenr_start? integer (1-indexed)
---@param linenr_end? integer (1-indexed, inclusive)
function mod.assert_buf_lines(bufnr, lines, linenr_start, linenr_end)
mod.eq(vim.api.nvim_buf_get_lines(bufnr, linenr_start and linenr_start - 1 or 0, linenr_end or -1, false), lines)
end
---@param bufnr number
---@param options table
function mod.assert_buf_options(bufnr, options)
for name, value in pairs(options) do
mod.eq(vim.api.nvim_buf_get_option(bufnr, name), value)
end
end
---@param winid number
---@param options table
function mod.assert_win_options(winid, options)
for name, value in pairs(options) do
mod.eq(vim.api.nvim_win_get_option(winid, name), value)
end
end
---@param extmark table
---@param linenr number (1-indexed)
---@param text string
---@param hl_group string
function mod.assert_extmark(extmark, linenr, text, hl_group)
mod.eq(extmark[2], linenr - 1)
if text then
local start_col = extmark[3]
mod.eq(extmark[4].end_col - start_col, #text)
end
mod.eq(mod.tbl_pick(extmark[4], { "end_row", "hl_group" }), {
end_row = linenr - 1,
hl_group = hl_group,
})
end
---@param bufnr number
---@param ns_id integer
---@param linenr integer (1-indexed)
---@param text string
---@param hl_group string
function mod.assert_highlight(bufnr, ns_id, linenr, text, hl_group)
local extmarks, info = mod.get_text_extmarks(bufnr, ns_id, linenr, text)
mod.eq(#extmarks, 1)
mod.eq(extmarks[1][3], info.byte_start)
mod.assert_extmark(extmarks[1], linenr, text, hl_group)
end
---@param feature_name string
---@param desc string
---@param func fun(is_available: boolean):nil
function mod.describe_flipping_feature(feature_name, desc, func)
local initial_value = require("nui.utils")._.feature[feature_name]
describe(string.format("(w/ %s) %s", feature_name, desc), function()
require("nui.utils")._.feature[feature_name] = true
func(true)
require("nui.utils")._.feature[feature_name] = initial_value
end)
describe(string.format("(w/o %s) %s", feature_name, desc), function()
require("nui.utils")._.feature[feature_name] = false
func(false)
require("nui.utils")._.feature[feature_name] = initial_value
end)
end
function popup.create_border_style_list()
return { "", "", "", "", "", "", "", "" }
end
function popup.create_border_style_map()
return {
top_left = "",
top = "",
top_right = "",
left = "",
right = "",
bottom_left = "",
bottom = "",
bottom_right = "",
}
end
function popup.create_border_style_map_with_tuple(hl_group)
local style = popup.create_border_style_map()
for k, v in pairs(style) do
style[k] = { v, hl_group .. "_" .. k }
end
return style
end
function popup.create_border_style_map_with_nui_text(hl_group)
local Text = require("nui.text")
local style = popup.create_border_style_map()
for k, v in pairs(style) do
style[k] = Text(v, hl_group .. "_" .. k)
end
return style
end
function popup.assert_border_lines(options, border_bufnr)
local size = { width = options.size.width, height = options.size.height }
-- `vim.tbl_islist` will be removed in the future
local islist = vim.islist or vim.tbl_islist
local style = vim.deepcopy(options.border.style)
if islist(style) then
style = {
top_left = style[1],
top = style[2],
top_right = style[3],
left = style[8],
right = style[4],
bottom_left = style[7],
bottom = style[6],
bottom_right = style[5],
}
end
local expected_lines = {}
table.insert(
expected_lines,
string.format(
"%s%s%s",
to_string(style.top_left),
string.rep(to_string(style.top), size.width),
to_string(style.top_right)
)
)
for _ = 1, size.height do
table.insert(
expected_lines,
string.format("%s%s%s", to_string(style.left), string.rep(" ", size.width), to_string(style.right))
)
end
table.insert(
expected_lines,
string.format(
"%s%s%s",
to_string(style.bottom_left),
string.rep(to_string(style.bottom), size.width),
to_string(style.bottom_right)
)
)
mod.assert_buf_lines(border_bufnr, expected_lines)
end
function popup.assert_border_highlight(options, border_bufnr, hl_group, no_hl_group_suffix)
local size = { width = options.size.width, height = options.size.height }
for linenr = 1, size.height + 2 do
local is_top_line = linenr == 1
local is_bottom_line = linenr == size.height + 2
local extmarks = mod.get_line_extmarks(border_bufnr, options.ns_id, linenr)
mod.eq(#extmarks, (is_top_line or is_bottom_line) and 4 or 2)
local function with_suffix(hl_group_name, suffix)
if no_hl_group_suffix then
return hl_group_name
end
return hl_group_name .. suffix
end
mod.assert_extmark(
extmarks[1],
linenr,
nil,
with_suffix(hl_group, (is_top_line and "_top_left" or is_bottom_line and "_bottom_left" or "_left"))
)
if is_top_line or is_bottom_line then
mod.assert_extmark(extmarks[2], linenr, nil, with_suffix(hl_group, (is_top_line and "_top" or "_bottom")))
mod.assert_extmark(extmarks[3], linenr, nil, with_suffix(hl_group, (is_top_line and "_top" or "_bottom")))
end
mod.assert_extmark(
extmarks[#extmarks],
linenr,
nil,
with_suffix(hl_group, (is_top_line and "_top_right" or is_bottom_line and "_bottom_right" or "_right"))
)
end
end
return mod

View file

@ -0,0 +1,24 @@
-- mimic startup option `--clean`
local function clean_startup()
for _, path in ipairs(vim.split(vim.o.runtimepath, ",")) do
if
string.find(path, vim.fn.expand("~/.config/nvim"))
or string.find(path, vim.fn.expand("~/.local/share/nvim/site"))
then
vim.opt.packpath:remove(path)
vim.opt.runtimepath:remove(path)
end
end
end
clean_startup()
local root_dir = vim.fn.fnamemodify(vim.trim(vim.fn.system("git rev-parse --show-toplevel")), ":p"):gsub("/$", "")
package.path = string.format("%s;%s/?.lua;%s/?/init.lua", package.path, root_dir, root_dir)
vim.opt.packpath:prepend(root_dir .. "/.tests/site")
vim.cmd([[
packadd plenary.nvim
]])

View file

@ -0,0 +1,285 @@
pcall(require, "luacov")
local Input = require("nui.input")
local Text = require("nui.text")
local h = require("tests.helpers")
local eq, feedkeys = h.eq, h.feedkeys
-- Input's functionalities are not testable using headless nvim.
-- Not sure what to do about it.
describe("nui.input", function()
local parent_winid, parent_bufnr
local popup_options
local input
before_each(function()
parent_winid = vim.api.nvim_get_current_win()
parent_bufnr = vim.api.nvim_get_current_buf()
popup_options = {
relative = "win",
position = "50%",
size = 20,
}
end)
after_each(function()
if input then
input:unmount()
input = nil
end
end)
pending("o.prompt", function()
it("supports NuiText", function()
local prompt_text = "> "
local hl_group = "NuiInputTest"
input = Input(popup_options, {
prompt = Text(prompt_text, hl_group),
})
input:mount()
vim.wait(100, function() end)
h.assert_buf_lines(input.bufnr, {
prompt_text,
})
h.assert_highlight(input.bufnr, input.ns_id, 1, prompt_text, hl_group)
end)
end)
describe("o.on_change", function()
it("works", function()
local done = false
local values = {}
input = Input(popup_options, {
on_change = function(value)
values[#values + 1] = value
end,
on_close = function()
done = true
end,
})
input:mount()
vim.wait(100, function() end)
feedkeys("aa", "x") -- append a
feedkeys("ab", "x") -- append b
feedkeys("ac", "x") -- append c
vim.fn.wait(100, function()
return done
end)
eq(values, { "a", "ab", "abc" })
end)
end)
describe("o.on_close", function()
it("is called on <C-c>", function()
local done = false
input = Input(popup_options, {
on_close = function()
done = true
end,
})
input:mount()
vim.wait(100, function() end)
feedkeys("i<C-c>", "x")
vim.fn.wait(2000, function()
return done
end)
eq(done, true)
end)
it("is called on unmount", function()
local done = false
input = Input(popup_options, {
on_close = function()
done = true
end,
})
input:mount()
vim.wait(100, function() end)
input:unmount()
vim.fn.wait(200, function()
return done
end)
eq(done, true)
end)
end)
describe("cursor_position_patch", function()
local initial_cursor
local function setup()
vim.api.nvim_buf_set_lines(parent_bufnr, 0, -1, false, {
"1 nui.nvim",
"2 nui.nvim",
"3 nui.nvim",
})
initial_cursor = { 2, 4 }
vim.api.nvim_win_set_cursor(parent_winid, initial_cursor)
end
it("works after submitting from insert mode", function()
setup()
local done = false
input = Input(popup_options, {
on_submit = function()
done = true
end,
})
input:mount()
vim.wait(100, function() end)
feedkeys("<cr>", "x")
vim.fn.wait(1000, function()
return done
end)
eq(done, true)
eq(vim.api.nvim_win_get_cursor(parent_winid), initial_cursor)
end)
it("works after submitting from normal mode", function()
setup()
local done = false
input = Input(popup_options, {
on_submit = function()
done = true
end,
})
input:mount()
vim.wait(100, function() end)
feedkeys("<esc><cr>", "x")
vim.fn.wait(1000, function()
return done
end)
eq(done, true)
eq(vim.api.nvim_win_get_cursor(parent_winid), initial_cursor)
end)
it("works after closing from insert mode", function()
setup()
local done = false
input = Input(popup_options, {
on_close = function()
done = true
end,
})
input:mount()
vim.wait(100, function() end)
input:map("i", "<esc>", function()
input:unmount()
end, { nowait = true, noremap = true })
feedkeys("i<esc>", "x")
vim.fn.wait(1000, function()
return done
end)
eq(done, true)
eq(vim.api.nvim_win_get_cursor(parent_winid), initial_cursor)
end)
it("works after closing from normal mode", function()
setup()
local done = false
input = Input(popup_options, {
on_close = function()
done = true
end,
})
input:mount()
vim.wait(100, function() end)
input:map("n", "<esc>", function()
input:unmount()
end, { nowait = true, noremap = true })
feedkeys("<esc>", "x")
vim.fn.wait(1000, function()
return done
end)
eq(done, true)
eq(vim.api.nvim_win_get_cursor(parent_winid), initial_cursor)
end)
end)
describe("method :mount", function()
it("is idempotent", function()
input = Input(popup_options, {})
input:mount()
vim.wait(100, function() end)
local bufnr, winid = input.bufnr, input.winid
eq(type(bufnr), "number")
eq(type(winid), "number")
input:mount()
eq(bufnr, input.bufnr)
eq(winid, input.winid)
end)
end)
describe("method :unmount", function()
it("is idempotent", function()
local done = 0
input = Input(popup_options, {
on_close = function()
done = done + 1
end,
})
input:mount()
vim.wait(100, function() end)
input:unmount()
input:unmount()
input:unmount()
vim.fn.wait(200, function()
return done > 1
end)
eq(done, 1)
end)
end)
end)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,144 @@
pcall(require, "luacov")
local utils = require("nui.layout.utils")
local h = require("tests.helpers")
local eq = h.eq
describe("nui.layout", function()
describe("utils", function()
describe("parse_relative", function()
local fallback_winid = 17
it("works for type=buf", function()
local relative = {
type = "buf",
position = { row = 2, col = 4 },
winid = 42,
}
local result = utils.parse_relative(relative, fallback_winid)
eq(result, {
relative = "win",
win = relative.winid,
bufpos = {
relative.position.row,
relative.position.col,
},
})
end)
it("works for type=cursor", function()
local relative = {
type = "cursor",
winid = 42,
}
local result = utils.parse_relative(relative, fallback_winid)
eq(result, {
relative = relative.type,
win = relative.winid,
})
end)
it("works for type=editor", function()
local relative = {
type = "editor",
winid = 42,
}
local result = utils.parse_relative(relative, fallback_winid)
eq(result, {
relative = relative.type,
win = relative.winid,
})
end)
it("works for type=win", function()
local relative = {
type = "win",
winid = 42,
}
local result = utils.parse_relative(relative, fallback_winid)
eq(result, {
relative = relative.type,
win = relative.winid,
})
end)
it("uses fallback_winid if relative.winid is nil", function()
local relative = {
type = "win",
}
local result = utils.parse_relative(relative, fallback_winid)
eq(result, {
relative = relative.type,
win = fallback_winid,
})
end)
end)
describe("get_container_info", function()
it("works for relative=editor", function()
local result = utils.get_container_info({
relative = "editor",
})
eq(result, {
relative = "editor",
size = {
width = vim.o.columns,
height = vim.o.lines,
},
type = "editor",
})
end)
it("works for relative=cursor", function()
local winid = vim.api.nvim_get_current_win()
local result = utils.get_container_info({
relative = "cursor",
win = 0,
})
eq(result, {
relative = "cursor",
size = {
width = vim.api.nvim_win_get_width(winid),
height = vim.api.nvim_win_get_height(winid),
},
type = "window",
winid = winid,
})
end)
it("works for relative=win w/ bufpos", function()
local winid = vim.api.nvim_get_current_win()
local result = utils.get_container_info({
relative = "win",
win = winid,
bufpos = { 2, 4 },
})
eq(result, {
relative = "buf",
size = {
width = vim.api.nvim_win_get_width(winid),
height = vim.api.nvim_win_get_height(winid),
},
type = "window",
winid = winid,
})
end)
end)
end)
end)

View file

@ -0,0 +1,164 @@
pcall(require, "luacov")
local Line = require("nui.line")
local Text = require("nui.text")
local h = require("tests.helpers")
local eq = h.eq
describe("nui.line", function()
it("can accept initial nui.text objects", function()
local t1, t2 = Text("One"), Text("Two")
local line = Line({ t1, t2 })
eq(#line._texts, 2)
end)
describe("method :append", function()
it("returns nui.text for string parameter", function()
local line = Line()
local text = line:append("One")
eq(type(text.content), "function")
end)
it("returns nui.text for nui.text parameter", function()
local line = Line()
local text = Text("One")
local ret_text = line:append(text)
eq(text == ret_text, true)
eq(type(ret_text.content), "function")
end)
it("returns nui.line for nui.line parameter", function()
local line = Line()
local content_line = Line({ Text("One"), Text("Two") })
local ret_content_line = line:append(content_line)
eq(content_line == ret_content_line, true)
eq(type(ret_content_line.append), "function")
end)
it("stores and returns block with same reference", function()
local line = Line()
local text_one = line:append("One")
eq(line._texts[1] == text_one, true)
local text_two = Text("Two")
local ret_text_two = line:append(text_two)
eq(text_two == ret_text_two, true)
eq(line._texts[2] == text_two, true)
eq(line._texts[2] == ret_text_two, true)
local text_three = Text("Three")
local text_four = Text("Four")
local content_line = Line({ text_three, text_four })
local ret_content_line = line:append(content_line)
eq(content_line == ret_content_line, true)
eq(line._texts[3] == content_line._texts[1], true)
eq(line._texts[4] == content_line._texts[2], true)
end)
end)
describe("method :content", function()
it("returns whole text content", function()
local line = Line()
line:append("One")
line:append("Two")
eq(line:content(), "OneTwo")
end)
end)
describe("method :width", function()
it("returns whole text width", function()
local line = Line()
line:append("One")
line:append("Two")
eq(line:width(), 6)
end)
end)
describe("method", function()
local winid, bufnr
before_each(function()
winid = vim.api.nvim_get_current_win()
bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_win_set_buf(winid, bufnr)
end)
after_each(function()
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
describe(":highlight", function()
local hl_group_one, hl_group_two, ns, ns_id
local linenr
local t1, t2, t3, t4
local line
before_each(function()
hl_group_one = "NuiTextTestOne"
hl_group_two = "NuiTextTestTwo"
ns = "NuiTest"
ns_id = vim.api.nvim_create_namespace(ns)
linenr = 1
t1 = Text("One")
t2 = Text("Two", hl_group_one)
t3 = Text("Three", hl_group_two)
t4 = Text("Four")
line = Line({ t1, t2, t3, t4 })
end)
it("is applied with :render", function()
line:render(bufnr, ns_id, linenr)
h.assert_highlight(bufnr, ns_id, linenr, t2:content(), hl_group_one)
h.assert_highlight(bufnr, ns_id, linenr, t3:content(), hl_group_two)
end)
it("can highlight existing buffer line", function()
vim.api.nvim_buf_set_lines(
bufnr,
linenr - 1,
-1,
false,
{ t1:content() .. t2:content() .. t3:content() .. t4:content() }
)
line:highlight(bufnr, ns_id, linenr)
h.assert_highlight(bufnr, ns_id, linenr, t2:content(), hl_group_one)
h.assert_highlight(bufnr, ns_id, linenr, t3:content(), hl_group_two)
end)
end)
describe(":render", function()
it("works", function()
local linenr = 1
local line = Line()
line:append("4")
line:append("2")
line:render(bufnr, -1, linenr)
h.assert_buf_lines(bufnr, {
"42",
})
end)
end)
end)
end)

View file

@ -0,0 +1,620 @@
pcall(require, "luacov")
local Menu = require("nui.menu")
local Layout = require("nui.layout")
local Line = require("nui.line")
local Text = require("nui.text")
local h = require("tests.helpers")
local spy = require("luassert.spy")
local eq, feedkeys = h.eq, h.feedkeys
describe("nui.menu", function()
local callbacks
local popup_options
local menu
before_each(function()
callbacks = {
on_change = function() end,
on_submit = function() end,
}
popup_options = {
relative = "win",
position = "50%",
}
end)
after_each(function()
if menu then
menu:unmount()
menu = nil
end
end)
describe("method :new", function()
it("works with menu", function()
menu = Menu:new(popup_options, {
lines = {
Menu.item("a"),
},
})
menu:mount()
h.assert_buf_lines(menu.bufnr, {
"a",
})
end)
end)
describe("o.keymap", function()
it("supports multiple keys as table", function()
local on_change = spy.on(callbacks, "on_change")
local lines = {
Menu.item("Item 1", { id = 1 }),
Menu.item("Item 2", { id = 2 }),
Menu.item("Item 3", { id = 3 }),
}
menu = Menu(popup_options, {
keymap = {
focus_next = { "j", "s" },
focus_prev = { "k", "w" },
},
lines = lines,
on_change = on_change,
})
menu:mount()
feedkeys("j", "x")
assert.spy(on_change).called_with(lines[2], menu)
on_change:clear()
feedkeys("s", "x")
assert.spy(on_change).called_with(lines[3], menu)
on_change:clear()
feedkeys("w", "x")
assert.spy(on_change).called_with(lines[2], menu)
on_change:clear()
feedkeys("k", "x")
assert.spy(on_change).called_with(lines[1], menu)
on_change:clear()
end)
it("supports single key as string", function()
local on_change = spy.on(callbacks, "on_change")
local lines = {
Menu.item("Item 1", { id = 1 }),
Menu.item("Item 2", { id = 2 }),
Menu.item("Item 3", { id = 3 }),
}
menu = Menu(popup_options, {
keymap = {
focus_next = "s",
focus_prev = "w",
},
lines = lines,
on_change = on_change,
})
menu:mount()
feedkeys("s", "x")
assert.spy(on_change).called_with(lines[2], menu)
on_change:clear()
feedkeys("w", "x")
assert.spy(on_change).called_with(lines[1], menu)
on_change:clear()
end)
end)
describe("size", function()
it("respects o.min_width", function()
local min_width = 3
local items = {
Menu.item("A"),
Menu.separator("*"),
Menu.item("B"),
}
menu = Menu(popup_options, {
lines = items,
min_width = min_width,
})
menu:mount()
eq(vim.api.nvim_win_get_width(menu.winid), min_width)
h.assert_buf_lines(menu.bufnr, {
"A",
" * ",
"B",
})
end)
it("respects o.max_width", function()
local max_width = 6
local items = {
Menu.item("Item 1"),
Menu.separator("*"),
Menu.item("Item Number Two"),
}
menu = Menu(popup_options, {
lines = items,
max_width = max_width,
})
menu:mount()
eq(vim.api.nvim_win_get_width(menu.winid), max_width)
h.assert_buf_lines(menu.bufnr, {
"Item 1",
" * ",
"Item …",
})
end)
it("respects o.min_height", function()
local min_height = 3
local items = {
Menu.item("A"),
Menu.separator("*"),
Menu.item("B"),
}
menu = Menu(popup_options, {
lines = items,
min_height = min_height,
})
menu:mount()
eq(vim.api.nvim_win_get_height(menu.winid), min_height)
end)
it("respects o.max_height", function()
local max_height = 2
local items = {
Menu.item("A"),
Menu.separator("*"),
Menu.item("B"),
}
menu = Menu(popup_options, {
lines = items,
max_height = max_height,
})
menu:mount()
eq(vim.api.nvim_win_get_height(menu.winid), max_height)
end)
end)
it("calls o.on_change item focus is changed", function()
local on_change = spy.on(callbacks, "on_change")
local lines = {
Menu.item("Item 1", { id = 1 }),
Menu.item("Item 2", { id = 2 }),
}
menu = Menu(popup_options, {
lines = lines,
on_change = on_change,
})
menu:mount()
-- initial focus
assert.spy(on_change).called_with(lines[1], menu)
on_change:clear()
feedkeys("j", "x")
assert.spy(on_change).called_with(lines[2], menu)
on_change:clear()
feedkeys("j", "x")
assert.spy(on_change).called_with(lines[1], menu)
on_change:clear()
feedkeys("k", "x")
assert.spy(on_change).called_with(lines[2], menu)
on_change:clear()
end)
it("calls o.on_submit when item is submitted", function()
local on_submit = spy.on(callbacks, "on_submit")
local lines = {
Menu.item("Item 1", { id = 1 }),
Menu.item("Item 2", { id = 2 }),
}
menu = Menu(popup_options, {
lines = lines,
on_submit = on_submit,
})
menu:mount()
feedkeys("j", "x")
feedkeys("<CR>", "x")
assert.spy(on_submit).called_with(lines[2])
end)
it("calls o.on_close when menu is closed", function()
local on_close = spy.on(callbacks, "on_close")
local lines = {
Menu.item("Item 1", { id = 1 }),
Menu.item("Item 2", { id = 2 }),
}
menu = Menu(popup_options, {
lines = lines,
on_close = on_close,
})
menu:mount()
feedkeys("<Esc>", "x")
assert.spy(on_close).called_with()
end)
describe("item", function()
it("is prepared using o.prepare_item if provided", function()
local items = {
Menu.item("A"),
Menu.separator("*"),
Menu.item("B"),
}
local function prepare_item(item)
return "-" .. item.text .. "-"
end
menu = Menu(popup_options, {
lines = items,
prepare_item = prepare_item,
})
menu:mount()
h.assert_buf_lines(menu.bufnr, vim.tbl_map(prepare_item, items))
end)
it("is prepared when o.prepare_item is not provided", function()
local items = {
Menu.item("A"),
Menu.separator("*"),
Menu.item("B"),
}
popup_options.border = "single"
menu = Menu(popup_options, {
lines = items,
})
menu:mount()
h.assert_buf_lines(menu.bufnr, {
"A",
"─*──",
"B",
})
end)
it("is skipped respecting o.should_skip_item if provided", function()
local on_change = spy.on(callbacks, "on_change")
local items = {
Menu.item("-"),
Menu.item("A", { id = 1 }),
Menu.item("-"),
Menu.item("B", { id = 2 }),
Menu.item("-"),
}
menu = Menu(popup_options, {
lines = items,
on_change = on_change,
should_skip_item = function(item)
return not item.id
end,
})
menu:mount()
assert.spy(on_change).called_with(items[2], menu)
on_change:clear()
feedkeys("j", "x")
assert.spy(on_change).called_with(items[4], menu)
on_change:clear()
feedkeys("j", "x")
assert.spy(on_change).called_with(items[2], menu)
on_change:clear()
end)
it("supports table with key .text", function()
local text = "text"
local items = {
Menu.item({ text = text }),
}
menu = Menu(popup_options, {
lines = items,
})
menu:mount()
h.assert_buf_lines(menu.bufnr, {
text,
})
end)
it("supports nui.text", function()
local hl_group = "NuiMenuTest"
local text = "text"
local items = {
Menu.item(Text(text, hl_group)),
}
menu = Menu(popup_options, {
lines = items,
})
menu:mount()
h.assert_buf_lines(menu.bufnr, {
text,
})
h.assert_highlight(menu.bufnr, menu.ns_id, 1, text, hl_group)
end)
it("supports nui.line", function()
local hl_group = "NuiMenuTest"
local text = "text"
local items = {
Menu.item(Line({ Text(text, hl_group) })),
}
menu = Menu(popup_options, {
lines = items,
})
menu:mount()
h.assert_buf_lines(menu.bufnr, {
text,
})
h.assert_highlight(menu.bufnr, menu.ns_id, 1, text, hl_group)
end)
it("content longer than max_width is truncated", function()
local items = {
Menu.item({ text = "Item 10 -" }),
Menu.item(Text("Item 20 -")),
Menu.item(Line({ Text("Item 30 -") })),
Menu.item(Line({ Text("Item 40"), Text(" -") })),
Menu.item(Line({ Text("Item 50 -"), Text(" -") })),
}
menu = Menu(popup_options, {
max_width = 7,
lines = items,
})
menu:mount()
h.assert_buf_lines(menu.bufnr, {
"Item 1…",
"Item 2…",
"Item 3…",
"Item 4…",
"Item 5…",
})
end)
end)
it("can truncate content longer than max_width w/ multi-byte chars", function()
menu = Menu(popup_options, {
lines = {
Menu.item("中文长度测试"),
Menu.item("Test中英文测试"),
Menu.item("Long Long Group"),
},
max_width = 11,
})
menu:mount()
h.assert_buf_lines(menu.bufnr, {
"中文长度测…",
"Test中英文…",
"Long Long …",
})
end)
describe("separator", function()
it("text supports string", function()
menu = Menu(popup_options, {
lines = {
Menu.item("A"),
Menu.separator("Group"),
},
min_width = 10,
})
menu:mount()
h.assert_buf_lines(menu.bufnr, {
"A",
" Group ",
})
end)
it("content longer than max_width is truncated", function()
menu = Menu(popup_options, {
lines = {
Menu.item("A"),
Menu.separator("Long Long Group"),
},
max_width = 10,
})
menu:mount()
h.assert_buf_lines(menu.bufnr, {
"A",
" Long Lo… ",
})
end)
it("text supports nui.text", function()
local hl_group = "NuiMenuTest"
local text = "Group"
menu = Menu(popup_options, {
lines = {
Menu.item("A"),
Menu.separator(Text(text, hl_group)),
},
min_width = 10,
})
menu:mount()
h.assert_buf_lines(menu.bufnr, {
"A",
" Group ",
})
h.assert_highlight(menu.bufnr, menu.ns_id, 2, text, hl_group)
end)
it("text supports nui.line", function()
local hl_group = "NuiMenuTest"
local text = "Group"
menu = Menu(popup_options, {
lines = {
Menu.item("A"),
Menu.separator(Line({ Text(text, hl_group), Text(" nui.text") })),
},
min_width = 10,
})
menu:mount()
h.assert_buf_lines(menu.bufnr, {
"A",
" Group nui.t… ",
})
h.assert_highlight(menu.bufnr, menu.ns_id, 2, text, hl_group)
end)
it("o.char supports string", function()
menu = Menu(popup_options, {
lines = {
Menu.item("A"),
Menu.separator("Group", {
char = "*",
text_align = "right",
}),
},
min_width = 10,
})
menu:mount()
h.assert_buf_lines(menu.bufnr, {
"A",
"****Group*",
})
end)
it("o.char supports nui.text", function()
local hl_group = "NuiMenuTest"
menu = Menu(popup_options, {
lines = {
Menu.item("A"),
Menu.separator("Group", {
char = Text("*", hl_group),
text_align = "center",
}),
},
min_width = 10,
})
menu:mount()
h.assert_buf_lines(menu.bufnr, {
"A",
"**Group***",
})
local linenr = 2
local extmarks = h.get_line_extmarks(menu.bufnr, menu.ns_id, linenr)
eq(#extmarks, 4)
h.assert_extmark(extmarks[1], linenr, "*", hl_group)
h.assert_extmark(extmarks[2], linenr, "*", hl_group)
h.assert_extmark(extmarks[3], linenr, "**", hl_group)
h.assert_extmark(extmarks[4], linenr, "*", hl_group)
end)
end)
describe("w/ Layout", function()
it("can be used", function()
menu = Menu({}, {
lines = {
Menu.item("A"),
},
})
local layout = Layout(
{
position = "50%",
size = "100%",
},
Layout.Box({
Layout.Box(menu, { size = "100%" }),
})
)
layout:mount()
h.assert_buf_lines(menu.bufnr, {
"A",
})
end)
end)
end)

View file

@ -0,0 +1,413 @@
pcall(require, "luacov")
local h = require("tests.helpers")
local Object = require("nui.object")
local spy = require("luassert.spy")
local function assert_class(Class, SuperClass, name)
h.eq(type(Class), "table")
h.eq(Class.super, SuperClass)
h.eq(Class.name, name)
h.eq(tostring(Class), "class " .. name)
h.eq(type(Class.new), "function")
h.eq(type(Class.extend), "function")
local is_callable = pcall(function()
return Class()
end)
h.eq(is_callable, true)
end
local function assert_instance(instance, Class)
h.eq(instance.class, Class)
h.eq(tostring(instance), "instance of class " .. Class.name)
h.eq(instance.name, nil)
h.eq(instance.super, nil)
h.eq(instance.static, nil)
h.eq(instance.new, nil)
h.eq(instance.extend, nil)
end
local function create_classes(...)
local by_name = {}
local classes = {}
for i, def in ipairs({ ... }) do
if type(def) == "string" then
local class = Object(def)
assert_class(class, nil, def)
by_name[def] = class
classes[i] = class
elseif type(def) == "table" then
local super = type(def[2]) == "table" and def[2] or (by_name[def[2]] and by_name[def[2]] or nil)
local class = super and super:extend(def[1]) or Object(def[1])
assert_class(class, super, def[1])
by_name[def[1]] = class
classes[i] = class
else
error("invalid argument")
end
end
return unpack(classes)
end
describe("nui.object", function()
describe("class", function()
it("can be created", function()
local Class = Object("Class")
assert_class(Class, nil, "Class")
end)
describe("static", function()
describe("method", function()
describe(":new", function()
it("is called when creating instance", function()
local Class = Object("Class")
spy.on(Class.static, "new")
Class()
assert.spy(Class.static.new).called_with(Class)
Class.static.new:revert()
spy.on(Class.static, "new")
Class:new()
assert.spy(Class.static.new).called_with(Class)
Class.static.new:revert()
end)
it("creates new instance", function()
local Class = Object("Class")
local instance = Class:new()
assert_instance(instance, Class)
end)
end)
describe(":extend", function()
it("creates subclass", function()
local Class = Object("Class")
local SubClass = Class:extend("SubClass")
assert_class(SubClass, Class, "SubClass")
end)
end)
describe(":is_subclass_of", function()
it("works", function()
local A, B, C = create_classes("A", { "B", "A" }, { "C", "B" })
for _, class in ipairs({ A, B, C }) do
h.eq(class.is_subclass_of, Object.is_subclass)
end
h.eq(A:is_subclass_of(A), false)
h.eq(A:is_subclass_of(B), false)
h.eq(A:is_subclass_of(C), false)
h.eq(B:is_subclass_of(A), true)
h.eq(B:is_subclass_of(B), false)
h.eq(B:is_subclass_of(C), false)
h.eq(C:is_subclass_of(A), true)
h.eq(C:is_subclass_of(B), true)
h.eq(C:is_subclass_of(C), false)
end)
end)
end)
local function define_static_say_level(A)
A.static.level = 1
function A.static.say_level(class)
return "Level: " .. class.level
end
h.eq(A.level, 1)
h.eq(A:say_level(), "Level: 1")
end
it("can be defined for class", function()
local A = create_classes("A")
define_static_say_level(A)
end)
it("is inherited by subclass", function()
local A, B = create_classes("A", { "B", "A" })
define_static_say_level(A)
h.eq(B.level, 1)
h.eq(B:say_level(), "Level: 1")
local C, D = create_classes({ "C", A }, { "D", B })
h.eq(C.level, 1)
h.eq(C:say_level(), "Level: 1")
h.eq(D.level, 1)
h.eq(D:say_level(), "Level: 1")
end)
it("can be redefined for subclass", function()
local A = create_classes("A")
define_static_say_level(A)
local B = create_classes({ "B", A })
B.static.level = 2
h.eq(B:say_level(), "Level: 2")
function B.static.say_level(class)
return "LEVEL: " .. class.level
end
h.eq(B:say_level(), "LEVEL: 2")
local C, D = create_classes({ "C", A }, { "D", B })
C.static.level = 2
h.eq(C:say_level(), "Level: 2")
D.static.level = 3
h.eq(D:say_level(), "LEVEL: 3")
end)
it("for subclass does not affect super", function()
local A = create_classes("A")
define_static_say_level(A)
local B = create_classes({ "B", A })
B.static.level = 2
function B.static.say_level(class)
return "LEVEL: " .. class.level
end
h.eq(A:say_level(), "Level: 1")
local C = create_classes({ "C", B })
function C.static.say_name(class)
return class.name
end
h.eq(C:say_name(), "C")
h.eq(type(C.say_name), "function")
h.eq(type(B.say_name), "nil")
h.eq(type(A.say_name), "nil")
end)
end)
describe("instance", function()
it("can be created", function()
local A = create_classes("A")
local a = A:new()
assert_instance(a, A)
end)
describe("method", function()
describe(":is_instance_of", function()
it("works", function()
local A, B, C, D = create_classes("A", { "B", "A" }, { "C", "B" }, "D")
local a, b, c, d = A:new(), B:new(), C:new(), D:new()
for _, instance in ipairs({ a, b, c, d }) do
h.eq(instance.is_instance_of, Object.is_instance)
end
h.eq(a:is_instance_of(A), true)
h.eq(a:is_instance_of(B), false)
h.eq(a:is_instance_of(C), false)
h.eq(a:is_instance_of(D), false)
h.eq(b:is_instance_of(A), true)
h.eq(b:is_instance_of(B), true)
h.eq(b:is_instance_of(C), false)
h.eq(b:is_instance_of(D), false)
h.eq(c:is_instance_of(A), true)
h.eq(c:is_instance_of(B), true)
h.eq(c:is_instance_of(C), true)
h.eq(c:is_instance_of(D), false)
h.eq(d:is_instance_of(A), false)
h.eq(d:is_instance_of(B), false)
h.eq(d:is_instance_of(C), false)
h.eq(d:is_instance_of(D), true)
end)
end)
it("can be defined", function()
local A = create_classes("A")
function A:before_instance_creation()
return "before " .. self.class.name .. " instance"
end
local a = A:new()
function A:after_instance_creation()
return "after " .. self.class.name .. " instance"
end
h.eq(a:before_instance_creation(), "before A instance")
h.eq(a:after_instance_creation(), "after A instance")
end)
it("can be inherited", function()
local A, B = create_classes("A", { "B", "A" })
function A:say_class_name()
return self.class.name
end
local a = A:new()
h.eq(a:say_class_name(), "A")
local b = B:new()
h.eq(b:say_class_name(), "B")
local C = create_classes({ "C", B })
local c = C:new()
h.eq(c:say_class_name(), "C")
end)
it("can be redefined", function()
local A, B = create_classes("A", { "B", "A" })
function A:say_class_name()
return self.class.name
end
local a = A:new()
h.eq(a:say_class_name(), "A")
function B:say_class_name()
return string.lower(self.class.name)
end
local b = B:new()
h.eq(b:say_class_name(), "b")
local C = create_classes({ "C", B })
local c = C:new()
h.eq(c:say_class_name(), "c")
function C:say_class_name()
return string.rep(self.class.name, 3)
end
h.eq(c:say_class_name(), "CCC")
C.say_class_name = nil
h.eq(c:say_class_name(), "c")
B.say_class_name = nil
h.eq(c:say_class_name(), "C")
end)
end)
describe("metamethod", function()
describe("__index", function()
it("can be set to table", function()
local A = create_classes("A")
function A:upper(str) -- luacheck: no unused args
return string.upper(str)
end
A.__index = {
upper = function(_, str)
return str
end,
lower = function(_, str)
return string.lower(str)
end,
}
local a = A()
h.eq(a:upper("y"), "Y")
h.eq(a:lower("Y"), "y")
A.__index = nil
h.eq(type(a.lower), "nil")
end)
it("can be set to function", function()
local A = create_classes("A")
function A:upper(str) -- luacheck: no unused args
return string.upper(str)
end
local index = {
upper = function(self, str) -- luacheck: no unused args
return str
end,
lower = function(self, str) -- luacheck: no unused args
return string.lower(str)
end,
}
A.__index = function(self, key) -- luacheck: no unused args
return index[key]
end
local a = A()
h.eq(a:upper("y"), "Y")
h.eq(a:lower("Y"), "y")
A.__index = nil
h.eq(type(a.lower), "nil")
end)
end)
describe("__tostring", function()
it("can be redefined", function()
local A, B = create_classes("A", { "B", "A" })
local a = A()
h.eq(tostring(a), "instance of class A")
function A:__tostring()
return "class " .. self.class.name .. "'s child"
end
h.eq(tostring(a), "class A's child")
local b = B()
h.eq(tostring(b), "class B's child")
function B:__tostring()
return "child of " .. self.class.name
end
h.eq(tostring(b), "child of B")
B.__tostring = nil
h.eq(tostring(b), "class B's child")
end)
end)
end)
end)
end)
end)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,632 @@
pcall(require, "luacov")
local Line = require("nui.line")
local Table = require("nui.table")
local Text = require("nui.text")
local h = require("tests.helpers")
local eq = h.eq
describe("nui.table", function()
---@type number, number
local winid, bufnr
before_each(function()
winid = vim.api.nvim_get_current_win()
bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_win_set_buf(winid, bufnr)
end)
after_each(function()
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
describe("o.bufnr", function()
it("throws if missing", function()
local ok, err = pcall(Table, {})
eq(ok, false)
eq(type(string.match(err, "missing bufnr")), "string")
end)
it("throws if invalid", function()
local ok, err = pcall(Table, { bufnr = 999 })
eq(ok, false)
eq(type(string.match(err, "invalid bufnr ")), "string")
end)
it("sets t.bufnr properly", function()
local table = Table({ bufnr = bufnr })
eq(table.bufnr, bufnr)
end)
end)
describe("o.buf_options", function()
it("sets default buf options emulating scratch-buffer", function()
local table = Table({ bufnr = bufnr })
h.assert_buf_options(table.bufnr, {
bufhidden = "hide",
buflisted = false,
buftype = "nofile",
swapfile = false,
})
end)
it("locks buffer by default", function()
local table = Table({ bufnr = bufnr })
h.assert_buf_options(table.bufnr, {
modifiable = false,
readonly = true,
undolevels = 0,
})
end)
it("sets values", function()
local table = Table({
bufnr = bufnr,
buf_options = {
undolevels = -1,
},
})
h.assert_buf_options(table.bufnr, {
undolevels = -1,
})
end)
end)
describe("o.ns_id", function()
it("sets t.ns_id if o.ns_id is string", function()
local ns = "NuiTest"
local table = Table({ bufnr = bufnr, ns_id = ns })
local namespaces = vim.api.nvim_get_namespaces()
eq(table.ns_id, namespaces[ns])
end)
it("sets t.ns_id if o.ns_id is number", function()
local ns = "NuiTest"
local ns_id = vim.api.nvim_create_namespace(ns)
local table = Table({ bufnr = bufnr, ns_id = ns_id })
eq(table.ns_id, ns_id)
end)
end)
describe("o.columns", function()
describe(".id", function()
it("fallbacks t o .accessor_key", function()
local table = Table({
bufnr = bufnr,
columns = { { accessor_key = "ID" } },
data = { { ID = 42 } },
})
table:render()
vim.api.nvim_win_set_cursor(winid, { 2, 3 })
eq(table:get_cell().column.id, "ID")
end)
for header_type, header in pairs({
string = "ID",
NuiText = Text("ID"),
NuiLine = Line({ Text("I"), Text("D") }),
}) do
it(string.format("fallbacks to .header (%s)", header_type), function()
local table = Table({
bufnr = bufnr,
columns = {
{
header = header,
accessor_fn = function()
return ""
end,
},
},
data = { {} },
})
table:render()
vim.api.nvim_win_set_cursor(winid, { 4, 3 })
eq(table:get_cell().column.id, "ID")
end)
end
it("throws if missing", function()
local ok, err = pcall(function()
return Table({
bufnr = bufnr,
columns = { {} },
})
end)
eq(ok, false)
eq(type(string.match(err, "missing column id")), "string")
end)
end)
end)
describe("method :render", function()
local columns
local data
before_each(function()
columns = {
{
header = "First Name",
accessor_key = "firstName",
footer = "firstName",
},
{
header = "Last Name",
accessor_key = "lastName",
footer = "lastName",
},
}
data = {
{
firstName = "tanner",
lastName = "linsley",
age = 24,
visits = 100,
status = "In Relationship",
progress = 50,
},
{
firstName = "tandy",
lastName = "miller",
age = 40,
visits = 40,
status = "Single",
progress = 80,
},
{
firstName = "joe",
lastName = "dirte",
age = 45,
visits = 20,
status = "Complicated",
progress = 10,
},
}
end)
it("can handle empty columns", function()
local table = Table({
bufnr = bufnr,
data = data,
})
table:render()
h.assert_buf_lines(table.bufnr, { "" })
end)
it("can handle empty data", function()
local table = Table({
bufnr = bufnr,
columns = {
{
accessor_key = "firstName",
},
},
})
table:render()
h.assert_buf_lines(table.bufnr, { "" })
end)
it("can handle empty columns and data", function()
local table = Table({ bufnr = bufnr })
table:render()
h.assert_buf_lines(table.bufnr, { "" })
end)
it("works w/ header w/ footer", function()
local table = Table({
bufnr = bufnr,
columns = columns,
data = data,
})
table:render()
h.assert_buf_lines(table.bufnr, {
"┌──────────┬─────────┐",
"│First Name│Last Name│",
"├──────────┼─────────┤",
"│tanner │linsley │",
"├──────────┼─────────┤",
"│tandy │miller │",
"├──────────┼─────────┤",
"│joe │dirte │",
"├──────────┼─────────┤",
"│firstName │lastName │",
"└──────────┴─────────┘",
})
end)
it("works w/ header w/o footer", function()
for _, column in ipairs(columns) do
column.align = "center"
column.footer = nil
end
local table = Table({
bufnr = bufnr,
columns = columns,
data = data,
})
table:render()
h.assert_buf_lines(table.bufnr, {
"┌──────────┬─────────┐",
"│First Name│Last Name│",
"├──────────┼─────────┤",
"│ tanner │ linsley │",
"├──────────┼─────────┤",
"│ tandy │ miller │",
"├──────────┼─────────┤",
"│ joe │ dirte │",
"└──────────┴─────────┘",
})
end)
it("works w/o header w/ footer", function()
for _, column in ipairs(columns) do
column.header = nil
end
local table = Table({
bufnr = bufnr,
columns = columns,
data = data,
})
table:render()
h.assert_buf_lines(table.bufnr, {
"┌─────────┬────────┐",
"│tanner │linsley │",
"├─────────┼────────┤",
"│tandy │miller │",
"├─────────┼────────┤",
"│joe │dirte │",
"├─────────┼────────┤",
"│firstName│lastName│",
"└─────────┴────────┘",
})
end)
it("works w/o header w/o footer", function()
for _, column in ipairs(columns) do
column.header = nil
column.footer = nil
end
local table = Table({
bufnr = bufnr,
columns = columns,
data = data,
})
table:render()
h.assert_buf_lines(table.bufnr, {
"┌──────┬───────┐",
"│tanner│linsley│",
"├──────┼───────┤",
"│tandy │miller │",
"├──────┼───────┤",
"│joe │dirte │",
"└──────┴───────┘",
})
end)
it("supports param linenr_start", function()
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
"START: NuiTest",
"",
"END: NuiTest",
})
local table = Table({
bufnr = bufnr,
columns = columns,
data = { data[1] },
})
table:render(2)
h.assert_buf_lines(table.bufnr, {
"START: NuiTest",
"┌──────────┬─────────┐",
"│First Name│Last Name│",
"├──────────┼─────────┤",
"│tanner │linsley │",
"├──────────┼─────────┤",
"│firstName │lastName │",
"└──────────┴─────────┘",
"END: NuiTest",
})
table:render(4)
h.assert_buf_lines(table.bufnr, {
"START: NuiTest",
"",
"",
"┌──────────┬─────────┐",
"│First Name│Last Name│",
"├──────────┼─────────┤",
"│tanner │linsley │",
"├──────────┼─────────┤",
"│firstName │lastName │",
"└──────────┴─────────┘",
"END: NuiTest",
})
table:render(3)
h.assert_buf_lines(table.bufnr, {
"START: NuiTest",
"",
"┌──────────┬─────────┐",
"│First Name│Last Name│",
"├──────────┼─────────┤",
"│tanner │linsley │",
"├──────────┼─────────┤",
"│firstName │lastName │",
"└──────────┴─────────┘",
"END: NuiTest",
})
end)
describe("grouped columns", function()
local grouped_columns
before_each(function()
grouped_columns = {
{
header = "Name",
footer = function(info)
return info.column.id
end,
columns = {
{
accessor_key = "firstName",
footer = "firstName",
},
{
id = "lastName",
header = "Last Name",
accessor_key = "lastName",
footer = function(info)
return info.column.id
end,
},
},
},
{
header = "Info",
footer = function(info)
return info.column.id
end,
columns = {
{
header = "Age",
accessor_key = "age",
footer = "age",
},
{
header = "More Info",
footer = function(info)
return info.column.id
end,
columns = {
{
accessor_key = "visits",
header = "Visits",
footer = function(info)
return info.column.id
end,
},
{
accessor_key = "status",
header = "Status",
footer = function(info)
return info.column.id
end,
},
},
},
},
},
{
header = "Profile Progress",
accessor_key = "progress",
footer = function(info)
return info.column.id
end,
},
}
end)
it("is drawn correctly", function()
local table = Table({
bufnr = bufnr,
columns = grouped_columns,
data = data,
})
table:render()
h.assert_buf_lines(table.bufnr, {
"┌───────────────────┬──────────────────────────┬────────────────┐",
"│Name │Info │ │",
"├─────────┬─────────┼───┬──────────────────────┤ │",
"│ │ │ │More Info │ │",
"│ │ │ ├──────┬───────────────┤ │",
"│firstName│Last Name│Age│Visits│Status │Profile Progress│",
"├─────────┼─────────┼───┼──────┼───────────────┼────────────────┤",
"│tanner │linsley │24 │100 │In Relationship│50 │",
"├─────────┼─────────┼───┼──────┼───────────────┼────────────────┤",
"│tandy │miller │40 │40 │Single │80 │",
"├─────────┼─────────┼───┼──────┼───────────────┼────────────────┤",
"│joe │dirte │45 │20 │Complicated │10 │",
"├─────────┼─────────┼───┼──────┼───────────────┼────────────────┤",
"│firstName│lastName │age│visits│status │progress │",
"│ │ │ ├──────┴───────────────┤ │",
"│ │ │ │More Info │ │",
"├─────────┴─────────┼───┴──────────────────────┤ │",
"│Name │Info │ │",
"└───────────────────┴──────────────────────────┴────────────────┘",
})
end)
end)
end)
describe("method :get_cell", function()
it("returns nil on border", function()
local table = Table({
bufnr = bufnr,
columns = { { accessor_key = "value" } },
data = { { value = "Such Value!" } },
})
table:render()
vim.api.nvim_win_set_cursor(winid, { 1, 5 })
local cell = table:get_cell()
eq(cell, nil)
end)
it("works after shifting", function()
local table = Table({
bufnr = bufnr,
columns = { { accessor_key = "value" } },
data = { { id = 0, value = "Such Value!" } },
})
table:render()
local cell
vim.api.nvim_win_set_cursor(winid, { 2, 5 })
cell = table:get_cell()
eq(type(cell), "table")
eq(cell.row.original.id, 0)
table:render(2)
vim.api.nvim_win_set_cursor(winid, { 2, 5 })
cell = table:get_cell()
eq(type(cell), "nil")
vim.api.nvim_win_set_cursor(winid, { 3, 5 })
cell = table:get_cell()
eq(type(cell), "table")
eq(cell.row.original.id, 0)
end)
it("can take position", function()
local table = Table({
bufnr = bufnr,
columns = {
{ accessor_key = "id" },
{ accessor_key = "value" },
},
data = {
{ id = 1, value = "One" },
{ id = 2, value = "Two" },
},
})
table:render()
local cell
vim.api.nvim_win_set_cursor(winid, { 2, 3 })
cell = table:get_cell()
eq(cell.get_value(), 1)
cell = table:get_cell({ 1, 1 })
eq(cell.get_value(), "Two")
end)
end)
describe("method :refresh_cell", function()
it("can truncate NuiText on refesh", function()
local table = Table({
bufnr = bufnr,
columns = { { accessor_key = "value" } },
data = { { value = "Such Value!" } },
})
table:render()
h.assert_buf_lines(table.bufnr, {
"┌───────────┐",
"│Such Value!│",
"└───────────┘",
})
vim.api.nvim_win_set_cursor(winid, { 2, 5 })
local cell = table:get_cell()
cell.row.original.value = "Such Looooooog Value!"
table:refresh_cell(cell)
h.assert_buf_lines(table.bufnr, {
"┌───────────┐",
"│Such Loooo…│",
"└───────────┘",
})
end)
it("can truncate NuiLine on refesh", function()
local table = Table({
bufnr = bufnr,
columns = {
{
accessor_key = "value",
cell = function(cell)
return Line({ Text(tostring(cell.get_value()), "NuiTest"), Text(" years old") })
end,
},
},
data = { { value = 42 } },
})
table:render()
h.assert_buf_lines(table.bufnr, {
"┌────────────┐",
"│42 years old│",
"└────────────┘",
})
vim.api.nvim_win_set_cursor(winid, { 2, 5 })
local cell = table:get_cell()
eq(type(cell), "table")
cell.row.original.value = 100
table:refresh_cell(cell)
h.assert_buf_lines(table.bufnr, {
"┌────────────┐",
"│100 years o…│",
"└────────────┘",
})
end)
end)
end)

View file

@ -0,0 +1,284 @@
pcall(require, "luacov")
local Text = require("nui.text")
local h = require("tests.helpers")
local spy = require("luassert.spy")
local eq, tbl_omit = h.eq, h.tbl_omit
describe("nui.text", function()
local multibyte_char
before_each(function()
multibyte_char = ""
end)
it("can clone nui.text object", function()
local hl_group = "NuiTextTest"
local t1 = Text("42", hl_group)
t1.extmark.id = 42
local t2 = Text(t1)
eq(t2:content(), t1:content())
eq(t2.extmark, tbl_omit(t1.extmark, { "id" }))
t2.extmark.id = 42
local t3 = Text(t2)
eq(t3:content(), t2:content())
eq(t3.extmark, tbl_omit(t2.extmark, { "id" }))
end)
it("can clone nui.text object overriding extmark", function()
local hl_group = "NuiTextTest"
local hl_group_override = "NuiTextTestOverride"
local t1 = Text("42", hl_group)
t1.extmark.id = 42
local t2 = Text(t1, hl_group_override)
eq(t2:content(), t1:content())
eq(t2.extmark, { hl_group = hl_group_override })
local t3 = Text(t2, { id = 42, hl_group = hl_group })
eq(t3:content(), t2:content())
eq(t3.extmark, { hl_group = hl_group })
end)
describe("method :set", function()
it("works", function()
local hl_group = "NuiTextTest"
local hl_group_override = "NuiTextTestOverride"
local text = Text("42", hl_group)
eq(text:content(), "42")
eq(text:length(), 2)
eq(text.extmark, {
hl_group = hl_group,
})
text.extmark.id = 42
text:set("3")
eq(text:content(), "3")
eq(text:length(), 1)
eq(text.extmark, {
hl_group = hl_group,
id = 42,
})
text:set("9", hl_group_override)
eq(text:content(), "9")
eq(text.extmark, {
hl_group = hl_group_override,
id = 42,
})
text:set("11", { hl_group = hl_group })
eq(text:content(), "11")
eq(text.extmark, {
hl_group = hl_group,
id = 42,
})
text.extmark.id = nil
text:set("42", { id = 42, hl_group = hl_group })
eq(text:content(), "42")
eq(text.extmark, { hl_group = hl_group })
end)
end)
describe("method :content", function()
it("works", function()
local content = "42"
local text = Text(content)
eq(text:content(), content)
local multibyte_content = multibyte_char
local multibyte_text = Text(multibyte_content)
eq(multibyte_text:content(), multibyte_content)
end)
end)
describe("method :length", function()
it("works", function()
local content = "42"
local text = Text(content)
eq(text:length(), 2)
eq(text:length(), vim.fn.strlen(content))
local multibyte_content = multibyte_char
local multibyte_text = Text(multibyte_content)
eq(multibyte_text:length(), 3)
eq(multibyte_text:length(), vim.fn.strlen(multibyte_content))
end)
end)
describe("method :width", function()
it("works", function()
local content = "42"
local text = Text(content)
eq(text:width(), 2)
eq(text:width(), vim.fn.strwidth(content))
local multibyte_content = multibyte_char
local multibyte_text = Text(multibyte_content)
eq(multibyte_text:width(), 1)
eq(multibyte_text:width(), vim.fn.strwidth(multibyte_content))
end)
end)
describe("method", function()
local winid, bufnr
local initial_lines
before_each(function()
winid = vim.api.nvim_get_current_win()
bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_win_set_buf(winid, bufnr)
initial_lines = { " 1", multibyte_char .. " 2", " 3" }
end)
after_each(function()
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
local function reset_lines(lines)
initial_lines = lines or initial_lines
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, initial_lines)
end
describe(":highlight", function()
local hl_group, ns, ns_id
local linenr, byte_start
local text
before_each(function()
hl_group = "NuiTextTest"
ns = "NuiTest"
ns_id = vim.api.nvim_create_namespace(ns)
end)
it("is applied with :render", function()
reset_lines()
linenr, byte_start = 1, 0
text = Text("a", hl_group)
text:render(bufnr, ns_id, linenr, byte_start)
h.assert_highlight(bufnr, ns_id, linenr, text:content(), hl_group)
end)
it("is applied with :render_char", function()
reset_lines()
linenr, byte_start = 1, 0
text = Text(multibyte_char, hl_group)
text:render_char(bufnr, ns_id, linenr, byte_start)
h.assert_highlight(bufnr, ns_id, linenr, text:content(), hl_group)
end)
it("can highlight existing buffer text", function()
reset_lines()
linenr, byte_start = 2, 0
text = Text(initial_lines[linenr], hl_group)
text:highlight(bufnr, ns_id, linenr, byte_start)
h.assert_highlight(bufnr, ns_id, linenr, text:content(), hl_group)
end)
it("does not create multiple extmarks", function()
reset_lines()
linenr, byte_start = 2, 0
text = Text(initial_lines[linenr], hl_group)
text:highlight(bufnr, ns_id, linenr, byte_start)
h.assert_highlight(bufnr, ns_id, linenr, text:content(), hl_group)
text:highlight(bufnr, ns_id, linenr, byte_start)
h.assert_highlight(bufnr, ns_id, linenr, text:content(), hl_group)
text:highlight(bufnr, ns_id, linenr, byte_start)
h.assert_highlight(bufnr, ns_id, linenr, text:content(), hl_group)
end)
end)
describe(":render", function()
it("works on line with singlebyte characters", function()
reset_lines()
local text = Text("a")
spy.on(text, "highlight")
text:render(bufnr, -1, 1, 1)
assert.spy(text.highlight).was_called(1)
assert.spy(text.highlight).was_called_with(text, bufnr, -1, 1, 1)
h.assert_buf_lines(bufnr, {
" a1",
initial_lines[2],
initial_lines[3],
})
end)
it("works on line with multibyte characters", function()
reset_lines()
local text = Text("a")
spy.on(text, "highlight")
text:render(bufnr, -1, 2, vim.fn.strlen(multibyte_char))
assert.spy(text.highlight).was_called(1)
assert.spy(text.highlight).was_called_with(text, bufnr, -1, 2, vim.fn.strlen(multibyte_char))
h.assert_buf_lines(bufnr, {
initial_lines[1],
multibyte_char .. "a2",
initial_lines[3],
})
end)
end)
describe(":render_char", function()
it("works on line with singlebyte characters", function()
reset_lines()
local text = Text("a")
spy.on(text, "highlight")
text:render_char(bufnr, -1, 1, 1)
assert.spy(text.highlight).was_called(1)
assert.spy(text.highlight).was_called_with(text, bufnr, -1, 1, 1)
h.assert_buf_lines(bufnr, {
" a1",
initial_lines[2],
initial_lines[3],
})
end)
it("works on line with multibyte characters", function()
reset_lines()
local text = Text("a")
spy.on(text, "highlight")
text:render_char(bufnr, -1, 2, 1)
assert.spy(text.highlight).was_called(1)
assert.spy(text.highlight).was_called_with(text, bufnr, -1, 2, vim.fn.strlen(multibyte_char))
h.assert_buf_lines(bufnr, {
initial_lines[1],
multibyte_char .. "a2",
initial_lines[3],
})
end)
end)
end)
end)

View file

@ -0,0 +1,912 @@
pcall(require, "luacov")
local Text = require("nui.text")
local Tree = require("nui.tree")
local h = require("tests.helpers")
local eq = h.eq
describe("nui.tree", function()
local winid, bufnr
before_each(function()
winid = vim.api.nvim_get_current_win()
bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_win_set_buf(winid, bufnr)
end)
after_each(function()
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
describe("(#deprecated) o.winid", function()
it("throws if missing", function()
local ok, err = pcall(function()
return Tree({})
end)
eq(ok, false)
eq(type(string.match(err, "missing bufnr")), "string")
end)
it("throws if invalid", function()
local ok, err = pcall(function()
return Tree({ winid = 999 })
end)
eq(ok, false)
eq(type(string.match(err, "invalid winid ")), "string")
end)
it("sets t.winid and t.bufnr properly", function()
local tree = Tree({ winid = winid })
eq(tree.winid, winid)
eq(tree.bufnr, bufnr)
end)
end)
describe("o.bufnr", function()
it("throws if missing", function()
local ok, err = pcall(function()
return Tree({})
end)
eq(ok, false)
eq(type(string.match(err, "missing bufnr")), "string")
end)
it("throws if invalid", function()
local ok, err = pcall(function()
return Tree({ bufnr = 999 })
end)
eq(ok, false)
eq(type(string.match(err, "invalid bufnr ")), "string")
end)
it("sets t.bufnr properly", function()
local tree = Tree({ bufnr = bufnr })
eq(tree.winid, nil)
eq(tree.bufnr, bufnr)
end)
end)
it("throws on duplicated node id", function()
local ok, err = pcall(function()
return Tree({
bufnr = bufnr,
nodes = {
Tree.Node({ id = "id", text = "text" }),
Tree.Node({ id = "id", text = "text" }),
},
})
end)
eq(ok, false)
eq(type(err), "string")
end)
it("sets default buf options emulating scratch-buffer", function()
local tree = Tree({ bufnr = bufnr })
h.assert_buf_options(tree.bufnr, {
bufhidden = "hide",
buflisted = false,
buftype = "nofile",
swapfile = false,
})
end)
describe("(#deprecated) o.win_options", function()
it("sets default values for handling folds", function()
local tree = Tree({ winid = winid })
h.assert_win_options(tree.winid, {
foldmethod = "manual",
foldcolumn = "0",
wrap = false,
})
end)
it("sets values", function()
local initial_statusline = vim.api.nvim_win_get_option(winid, "statusline")
local statusline = "test: win_options " .. math.random()
local tree = Tree({
winid = winid,
win_options = {
statusline = statusline,
},
})
h.assert_win_options(tree.winid, {
statusline = statusline,
})
vim.api.nvim_win_set_option(tree.winid, "statusline", initial_statusline)
end)
it("has no effect if o.bufnr is present", function()
local initial_statusline = vim.api.nvim_win_get_option(winid, "statusline")
Tree({
bufnr = bufnr,
win_options = {
statusline = "test: win_options" .. math.random(),
},
})
h.assert_win_options(winid, {
statusline = initial_statusline,
})
end)
end)
it("sets t.ns_id if o.ns_id is string", function()
local ns = "NuiTreeTest"
local tree = Tree({ bufnr = bufnr, ns_id = ns })
local namespaces = vim.api.nvim_get_namespaces()
eq(tree.ns_id, namespaces[ns])
end)
it("sets t.ns_id if o.ns_id is number", function()
local ns = "NuiTreeTest"
local ns_id = vim.api.nvim_create_namespace(ns)
local tree = Tree({ bufnr = bufnr, ns_id = ns_id })
eq(tree.ns_id, ns_id)
end)
it("uses o.get_node_id if provided", function()
local node_d2 = Tree.Node({ key = "depth two" })
local node_d1 = Tree.Node({ key = "depth one" }, { node_d2 })
Tree({
bufnr = bufnr,
nodes = { node_d1 },
get_node_id = function(node)
return node.key
end,
})
eq(node_d1:get_id(), node_d1.key)
eq(node_d2:get_id(), node_d2.key)
end)
describe("default get_node_id", function()
it("returns id using n.id", function()
local node = Tree.Node({ id = "id", text = "text" })
Tree({ bufnr = bufnr, nodes = { node } })
eq(node:get_id(), "-id")
end)
it("returns id using parent_id + depth + n.text", function()
local node_d2 = Tree.Node({ text = { "depth two a", Text("depth two b") } })
local node_d1 = Tree.Node({ text = "depth one" }, { node_d2 })
Tree({ bufnr = bufnr, nodes = { node_d1 } })
eq(node_d1:get_id(), string.format("-%s-%s", node_d1:get_depth(), node_d1.text))
eq(
node_d2:get_id(),
string.format(
"%s-%s-%s",
node_d2:get_parent_id(),
node_d2:get_depth(),
table.concat({ node_d2.text[1], node_d2.text[2]:content() }, "-")
)
)
end)
it("returns id using random number", function()
math.randomseed(0)
local expected_id = "-" .. math.random()
math.randomseed(0)
local node = Tree.Node({})
Tree({ bufnr = bufnr, nodes = { node } })
eq(node:get_id(), expected_id)
end)
end)
it("uses o.prepare_node if provided", function()
local function prepare_node(node, parent_node)
if not parent_node then
return node.text
end
return parent_node.text .. ":" .. node.text
end
local nodes = {
Tree.Node({ text = "a" }),
Tree.Node({ text = "b" }, {
Tree.Node({ text = "b-1" }),
Tree.Node({ text = "b-2" }),
}),
Tree.Node({ text = "c" }),
}
nodes[2]:expand()
local tree = Tree({
bufnr = bufnr,
nodes = nodes,
prepare_node = prepare_node,
})
tree:render()
h.assert_buf_lines(tree.bufnr, {
"a",
"b",
"b:b-1",
"b:b-2",
"c",
})
end)
describe("default prepare_node", function()
it("throws if missing n.text", function()
local nodes = {
Tree.Node({ txt = "a" }),
Tree.Node({ txt = "b" }),
Tree.Node({ txt = "c" }),
}
local tree = Tree({
bufnr = bufnr,
nodes = nodes,
})
local ok, err = pcall(tree.render, tree)
eq(ok, false)
eq(type(err), "string")
end)
it("uses n.text", function()
local nodes = {
Tree.Node({ text = "a" }),
Tree.Node({ text = { "b-1", "b-2" } }),
Tree.Node({ text = "c" }),
}
local tree = Tree({
bufnr = bufnr,
nodes = nodes,
})
tree:render()
h.assert_buf_lines(tree.bufnr, {
" a",
" b-1",
" b-2",
" c",
})
end)
it("renders arrow if children are present", function()
local nodes = {
Tree.Node({ text = "a" }),
Tree.Node({ text = "b" }, {
Tree.Node({ text = "b-1" }),
Tree.Node({ text = { "b-2", "b-3" } }),
}),
Tree.Node({ text = "c" }),
}
local tree = Tree({
bufnr = bufnr,
nodes = nodes,
})
tree:render()
h.assert_buf_lines(tree.bufnr, {
" a",
" b",
" c",
})
nodes[2]:expand()
tree:render()
h.assert_buf_lines(tree.bufnr, {
" a",
" b",
" b-1",
" b-2",
" b-3",
" c",
})
end)
end)
describe("method :get_node", function()
it("can get node under cursor", function()
local nodes = {
Tree.Node({ text = "a" }),
Tree.Node({ text = "b" }),
Tree.Node({ text = "c" }),
}
local tree = Tree({
bufnr = bufnr,
nodes = nodes,
})
tree:render()
local linenr = 3
vim.api.nvim_win_set_cursor(winid, { linenr, 0 })
eq({ tree:get_node() }, { nodes[3], linenr, linenr })
end)
it("can get node with id", function()
local b_node_children = {
Tree.Node({ text = "b-1" }),
Tree.Node({ text = { "b-2", "b-3" } }),
}
local nodes = {
Tree.Node({ text = "a" }),
Tree.Node({ text = "b" }, b_node_children),
Tree.Node({ text = "c" }),
}
local tree = Tree({
bufnr = bufnr,
nodes = nodes,
get_node_id = function(node)
return type(node.text) == "table" and table.concat(node.text, "-") or node.text
end,
})
tree:render()
eq({ tree:get_node("b") }, { nodes[2], 2, 2 })
tree:get_node("b"):expand()
tree:render()
eq({ tree:get_node("b-2-b-3") }, { b_node_children[2], 4, 5 })
end)
it("can get node on linenr", function()
local b_node_children = {
Tree.Node({ id = "b-1-b-2", text = { "b-1", "b-2" } }),
}
local nodes = {
Tree.Node({ text = "a" }),
Tree.Node({ text = "b" }, b_node_children),
Tree.Node({ text = "c" }),
}
local tree = Tree({
bufnr = bufnr,
nodes = nodes,
})
tree:render()
eq({ tree:get_node(1) }, { nodes[1], 1, 1 })
tree:get_node(2):expand()
tree:render()
eq({ tree:get_node(3) }, { b_node_children[1], 3, 4 })
eq({ tree:get_node(4) }, { b_node_children[1], 3, 4 })
end)
end)
describe("method :get_nodes", function()
it("can get nodes at root", function()
local nodes = {
Tree.Node({ text = "a" }),
Tree.Node({ text = "b" }, {
Tree.Node({ text = "b-1" }),
}),
}
local tree = Tree({
bufnr = bufnr,
nodes = nodes,
get_node_id = function(node)
return node.text
end,
})
eq(tree:get_nodes(), nodes)
end)
it("can get nodes under parent node", function()
local child_nodes = {
Tree.Node({ text = "b-1" }),
}
local tree = Tree({
bufnr = bufnr,
nodes = {
Tree.Node({ text = "a" }),
Tree.Node({ text = "b" }, child_nodes),
},
get_node_id = function(node)
return node.text
end,
})
eq(tree:get_nodes("b"), child_nodes)
end)
end)
describe("method :add_node", function()
it("throw if invalid parent_id", function()
local tree = Tree({
bufnr = bufnr,
nodes = {
Tree.Node({ text = "x" }),
},
})
local ok, err = pcall(tree.add_node, tree, Tree.Node({ text = "y" }), "invalid_parent_id")
eq(ok, false)
eq(type(err), "string")
end)
it("can add node at root", function()
local tree = Tree({
bufnr = bufnr,
nodes = {
Tree.Node({ text = "x" }),
},
})
tree:add_node(Tree.Node({ text = "y" }))
tree:render()
h.assert_buf_lines(tree.bufnr, {
" x",
" y",
})
tree:add_node(Tree.Node({ text = "z" }))
tree:render()
h.assert_buf_lines(tree.bufnr, {
" x",
" y",
" z",
})
end)
it("can add node under parent node", function()
local nodes = {
Tree.Node({ text = "a" }),
Tree.Node({ text = "b" }, {
Tree.Node({ text = "b-1" }),
}),
Tree.Node({ text = "c" }),
}
local tree = Tree({
bufnr = bufnr,
nodes = nodes,
get_node_id = function(node)
return node.text
end,
})
tree:add_node(Tree.Node({ text = "b-2" }), "b")
tree:get_node("b"):expand()
tree:add_node(Tree.Node({ text = "c-1" }), "c")
tree:get_node("c"):expand()
tree:render()
h.assert_buf_lines(tree.bufnr, {
" a",
" b",
" b-1",
" b-2",
" c",
" c-1",
})
end)
end)
describe("method :set_nodes", function()
it("throw if invalid parent_id", function()
local tree = Tree({
bufnr = bufnr,
nodes = {
Tree.Node({ text = "x" }),
},
})
local ok, err = pcall(tree.set_nodes, tree, {}, "invalid_parent_id")
eq(ok, false)
eq(type(err), "string")
end)
it("can set nodes at root", function()
local tree = Tree({
bufnr = bufnr,
nodes = {
Tree.Node({ text = "x" }),
},
})
tree:set_nodes({
Tree.Node({ text = "a" }),
Tree.Node({ text = "b" }),
})
tree:render()
h.assert_buf_lines(tree.bufnr, {
" a",
" b",
})
tree:set_nodes({
Tree.Node({ text = "c" }),
})
tree:render()
h.assert_buf_lines(tree.bufnr, {
" c",
})
end)
it("can set nodes under parent node", function()
local nodes = {
Tree.Node({ text = "a" }),
Tree.Node({ text = "b" }, {
Tree.Node({ text = "b-1" }),
}),
Tree.Node({ text = "c" }),
}
local tree = Tree({
bufnr = bufnr,
nodes = nodes,
get_node_id = function(node)
return node.text
end,
})
tree:set_nodes({
Tree.Node({ text = "b-2" }),
}, "b")
tree:get_node("b"):expand()
tree:set_nodes({
Tree.Node({ text = "c-1" }),
Tree.Node({ text = "c-2" }),
}, "c")
tree:get_node("c"):expand()
tree:render()
h.assert_buf_lines(tree.bufnr, {
" a",
" b",
" b-2",
" c",
" c-1",
" c-2",
})
end)
end)
describe("method :remove_node", function()
it("can remove node w/o parent", function()
local nodes = {
Tree.Node({ text = "a" }),
Tree.Node({ text = "b" }, {
Tree.Node({ text = "b-1" }),
}),
Tree.Node({ text = "c" }),
}
local tree = Tree({
bufnr = bufnr,
nodes = nodes,
get_node_id = function(node)
return node.text
end,
})
tree:remove_node("a")
tree:get_node("b"):expand()
tree:render()
eq(
vim.tbl_map(function(node)
return node:get_id()
end, tree:get_nodes()),
{ "b", "c" }
)
h.assert_buf_lines(tree.bufnr, {
" b",
" b-1",
" c",
})
end)
it("can remove node w/ parent", function()
local nodes = {
Tree.Node({ text = "a" }),
Tree.Node({ text = "b" }, {
Tree.Node({ text = "b-1" }),
}),
Tree.Node({ text = "c" }),
}
local tree = Tree({
bufnr = bufnr,
nodes = nodes,
get_node_id = function(node)
return node.text
end,
})
tree:remove_node("b-1")
tree:render()
eq(tree:get_node("b"):get_child_ids(), {})
h.assert_buf_lines(tree.bufnr, {
" a",
" b",
" c",
})
end)
it("removes children nodes recursively", function()
local nodes = {
Tree.Node({ text = "a" }, {
Tree.Node({ text = "a-1" }, {
Tree.Node({ text = "a-1-x" }),
}),
}),
}
local tree = Tree({
bufnr = bufnr,
nodes = nodes,
get_node_id = function(node)
return node.text
end,
})
h.neq(tree:get_node("a"), nil)
h.neq(tree:get_node("a-1"), nil)
h.neq(tree:get_node("a-1-x"), nil)
tree:remove_node("a")
eq(tree:get_node("a"), nil)
eq(tree:get_node("a-1"), nil)
eq(tree:get_node("a-1-x"), nil)
end)
end)
describe("method :render", function()
it("handles unexpected case of missing node", function()
local nodes = {
Tree.Node({ text = "a" }),
Tree.Node({ text = "b" }),
Tree.Node({ text = "c" }),
}
local tree = Tree({
bufnr = bufnr,
nodes = nodes,
get_node_id = function(node)
return node.text
end,
})
-- this should not happen normally
tree.nodes.by_id["a"] = nil
tree:render()
h.assert_buf_lines(tree.bufnr, {
" b",
" c",
})
end)
it("skips node if o.prepare_node returns nil", function()
local nodes = {
Tree.Node({ text = "a" }),
Tree.Node({ text = "b" }),
Tree.Node({ text = "c" }),
}
local tree = Tree({
bufnr = bufnr,
nodes = nodes,
get_node_id = function(node)
return node.text
end,
prepare_node = function(node)
if node:get_id() == "b" then
return nil
end
return node.text
end,
})
tree:render()
h.assert_buf_lines(tree.bufnr, {
"a",
"c",
})
end)
it("supports param linenr_start", function()
local b_node_children = {
Tree.Node({ text = "b-1" }),
Tree.Node({ text = "b-2" }),
}
local nodes = {
Tree.Node({ text = "a" }),
Tree.Node({ text = "b" }, b_node_children),
}
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
"NuiTreeTest",
"",
"NuiTreeTest",
})
local tree = Tree({
bufnr = bufnr,
nodes = nodes,
get_node_id = function(node)
return node.text
end,
})
tree:render(2)
h.assert_buf_lines(tree.bufnr, {
"NuiTreeTest",
" a",
" b",
"NuiTreeTest",
})
nodes[2]:expand()
tree:render()
h.assert_buf_lines(tree.bufnr, {
"NuiTreeTest",
" a",
" b",
" b-1",
" b-2",
"NuiTreeTest",
})
nodes[2]:collapse()
tree:render(3)
h.assert_buf_lines(tree.bufnr, {
"NuiTreeTest",
"",
" a",
" b",
"NuiTreeTest",
})
end)
end)
end)
describe("nui.tree.Node", function()
describe("method :has_children", function()
it("works before initialization", function()
local node_wo_children = Tree.Node({ text = "a" })
local node_w_children = Tree.Node({ text = "b" }, { Tree.Node({ text = "b-1" }) })
eq(node_wo_children._initialized, false)
eq(node_wo_children:has_children(), false)
eq(node_w_children._initialized, false)
eq(type(node_w_children.__children), "table")
eq(node_w_children:has_children(), true)
end)
it("works after initialization", function()
local node_wo_children = Tree.Node({ text = "a" })
local node_w_children = Tree.Node({ text = "b" }, { Tree.Node({ text = "b-1" }) })
Tree({
bufnr = vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()),
nodes = { node_wo_children, node_w_children },
})
eq(node_wo_children._initialized, true)
eq(node_wo_children:has_children(), false)
eq(node_w_children._initialized, true)
eq(type(node_w_children.__children), "nil")
eq(node_w_children:has_children(), true)
end)
end)
describe("method :expand", function()
it("returns true if not already expanded", function()
local node = Tree.Node({ text = "b" }, { Tree.Node({ text = "b-1" }) })
eq(node:is_expanded(), false)
eq(node:expand(), true)
eq(node:is_expanded(), true)
end)
it("returns false if already expanded", function()
local node = Tree.Node({ text = "b" }, { Tree.Node({ text = "b-1" }) })
node:expand()
eq(node:is_expanded(), true)
eq(node:expand(), false)
eq(node:is_expanded(), true)
end)
it("does work w/ zero child", function()
local node = Tree.Node({ text = "a" }, {})
eq(node:is_expanded(), false)
eq(node:expand(), true)
eq(node:is_expanded(), true)
end)
it("does not work w/o children", function()
local node = Tree.Node({ text = "a" })
eq(node:is_expanded(), false)
eq(node:expand(), false)
eq(node:is_expanded(), false)
end)
end)
describe("method :collapse", function()
it("returns true if not already collapsed", function()
local node = Tree.Node({ text = "b" }, { Tree.Node({ text = "b-1" }) })
node:expand()
eq(node:is_expanded(), true)
eq(node:collapse(), true)
eq(node:is_expanded(), false)
end)
it("returns false if already collapsed", function()
local node = Tree.Node({ text = "b" }, { Tree.Node({ text = "b-1" }) })
eq(node:is_expanded(), false)
eq(node:collapse(), false)
eq(node:is_expanded(), false)
end)
it("does not work w/o children", function()
local node = Tree.Node({ text = "a" })
eq(node:is_expanded(), false)
eq(node:collapse(), false)
eq(node:is_expanded(), false)
end)
end)
end)