├── .github ├── FUNDING.yml ├── configs │ └── commitlint.config.js └── workflows │ ├── commit-messages.yaml │ ├── lines.yaml │ ├── luacheck.yml │ ├── panvimdoc.yml │ └── release.yml ├── .gitignore ├── .luacheckrc ├── .luarc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── doc └── cokeline.txt ├── lua ├── cokeline │ ├── augroups.lua │ ├── buffers.lua │ ├── components.lua │ ├── config.lua │ ├── context.lua │ ├── handlers.lua │ ├── history.lua │ ├── hlgroups.lua │ ├── hover.lua │ ├── init.lua │ ├── lazy.lua │ ├── mappings.lua │ ├── rendering.lua │ ├── rhs.lua │ ├── sidebar.lua │ ├── sliders.lua │ ├── state.lua │ ├── tabs.lua │ └── utils.lua └── resession │ └── extensions │ └── cokeline.lua ├── neovim.yml ├── selene.toml └── stylua.toml /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: [ "https://paypal.me/noib3", "https://paypal.me/willothy" ] 2 | -------------------------------------------------------------------------------- /.github/configs/commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ["@commitlint/config-conventional"] }; 2 | -------------------------------------------------------------------------------- /.github/workflows/commit-messages.yaml: -------------------------------------------------------------------------------- 1 | name: "Commit message check" 2 | on: [push, pull_request] 3 | jobs: 4 | check-commit-message: 5 | name: Ensure messages conform to Conventional Commits standard 6 | runs-on: ubuntu-22.04 7 | steps: 8 | - uses: actions/checkout@v3 9 | with: 10 | fetch-depth: 0 11 | - name: Install required dependencies 12 | run: | 13 | sudo apt update 14 | sudo apt install -y git curl 15 | curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash - 16 | sudo DEBIAN_FRONTEND=noninteractive apt install -y nodejs 17 | - name: Print versions 18 | run: | 19 | git --version 20 | node --version 21 | npm --version 22 | npx commitlint --version 23 | - name: Install commitlint 24 | run: | 25 | npm install conventional-changelog-conventionalcommits 26 | npm install commitlint@latest 27 | npm install @commitlint/config-conventional 28 | 29 | - name: Validate current commit (last commit) with commitlint 30 | if: github.event_name == 'push' 31 | run: npx commitlint --config ".github/configs/commitlint.config.js" --from HEAD~1 --to HEAD --verbose 32 | 33 | - name: Validate PR commits with commitlint 34 | if: github.event_name == 'pull_request' 35 | run: npx commitlint --config ".github/configs/commitlint.config.js" --from ${{ github.event.pull_request.head.sha }}~${{ github.event.pull_request.commits }} --to ${{ github.event.pull_request.head.sha }} --verbose 36 | -------------------------------------------------------------------------------- /.github/workflows/lines.yaml: -------------------------------------------------------------------------------- 1 | name: "Check line width" 2 | on: [push, pull_request] 3 | jobs: 4 | # line-number: 5 | # name: Sloc < 1000 6 | # runs-on: ubuntu-latest 7 | # steps: 8 | # - uses: actions/checkout@v2 9 | # - run: cargo install tokei 10 | # - run: num_sloc=$(tokei --type=Lua,'Vim script' --output=json . | jq .Total.code); if [ ${num_sloc} -gt 999 ]; then exit 1; fi 11 | 12 | line-width: 13 | name: Line width < 80 characters 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - run: cargo install --locked ripgrep 18 | - run: max_line_width=$(rg --glob '*.{lua,vim}' --no-heading --no-filename --no-line-number . | awk '{print length}' | sort -n | tail -n 1); if [ ${max_line_width} -gt 79 ]; then exit 1; fi 19 | -------------------------------------------------------------------------------- /.github/workflows/luacheck.yml: -------------------------------------------------------------------------------- 1 | name: Luacheck 2 | 3 | on: 4 | push: 5 | branches: [ "main", "master" ] 6 | pull_request: 7 | branches: [ "main", "master" ] 8 | 9 | workflow_dispatch: 10 | 11 | 12 | jobs: 13 | luacheck: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Luacheck 19 | uses: lunarmodules/luacheck@v1.1.0 20 | -------------------------------------------------------------------------------- /.github/workflows/panvimdoc.yml: -------------------------------------------------------------------------------- 1 | name: panvimdoc 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | docs: 14 | runs-on: ubuntu-latest 15 | name: pandoc to vimdoc 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: kdheepak/panvimdoc@main 19 | with: 20 | vimdoc: cokeline 21 | version: "NVIM >=0.7" 22 | toc: true 23 | demojify: true 24 | treesitter: true 25 | - uses: stefanzweifel/git-auto-commit-action@v4 26 | with: 27 | commit_message: "chore(docs): autogenerate vimdoc" 28 | branch: ${{ github.head_ref }} 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | release-please: 14 | runs-on: ubuntu-22.04 15 | steps: 16 | - uses: google-github-actions/release-please-action@v3 17 | with: 18 | release-type: simple 19 | package-name: nvim-cokeline 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /doc/tags 2 | /TODO.md 3 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | ignore = { 2 | "631", -- max_line_length 3 | --"212/_.*", -- unused argument, for vars with "_" prefix 4 | "212", -- Unused argument, In the case of callback function, _arg_name is easier to understand than _, so this option is set to off. 5 | "121", -- setting read-only global variable 'vim' 6 | "122", -- setting read-only field of global variable 'vim' 7 | } 8 | 9 | -- Global objects defined by the C code 10 | read_globals = { 11 | "vim", 12 | } 13 | 14 | globals = { 15 | "vim.g", 16 | "vim.b", 17 | "vim.w", 18 | "vim.o", 19 | "vim.bo", 20 | "vim.wo", 21 | "vim.go", 22 | "vim.env" 23 | } 24 | -------------------------------------------------------------------------------- /.luarc.json: -------------------------------------------------------------------------------- 1 | { 2 | "workspace.checkThirdParty": false 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased](https://github.com/willothy/nvim-cokeline/tree/HEAD) 4 | 5 | [Full Changelog](https://github.com/willothy/nvim-cokeline/compare/v0.4.0...HEAD) 6 | 7 | **Merged pull requests:** 8 | 9 | - refactor: use modules, lazy requires [\#157](https://github.com/willothy/nvim-cokeline/pull/157) ([willothy](https://github.com/willothy)) 10 | - fixup: \(3682c78e\): README: Replace `focused` with `fg` in default_hl [\#152](https://github.com/willothy/nvim-cokeline/pull/152) ([UtsavBalar1231](https://github.com/UtsavBalar1231)) 11 | - feat!: support for more highlight attrs [\#150](https://github.com/willothy/nvim-cokeline/pull/150) ([willothy](https://github.com/willothy)) 12 | - Update README.md [\#146](https://github.com/willothy/nvim-cokeline/pull/146) ([sashalikesplanes](https://github.com/sashalikesplanes)) 13 | - feat: custom sorting functions [\#145](https://github.com/willothy/nvim-cokeline/pull/145) ([willothy](https://github.com/willothy)) 14 | - feat: provide require\("cokeline.sidebar"\).get_width\(\) and cache sidebar widths [\#143](https://github.com/willothy/nvim-cokeline/pull/143) ([willothy](https://github.com/willothy)) 15 | - refactor!: make scratch buffer if deleting last buffer [\#142](https://github.com/willothy/nvim-cokeline/pull/142) ([willothy](https://github.com/willothy)) 16 | - feat: global \(per buffer/tab/etc\) hover events [\#140](https://github.com/willothy/nvim-cokeline/pull/140) ([willothy](https://github.com/willothy)) 17 | 18 | ## [v0.4.0](https://github.com/willothy/nvim-cokeline/tree/v0.4.0) (2023-07-02) 19 | 20 | [Full Changelog](https://github.com/willothy/nvim-cokeline/compare/v0.3.0...v0.4.0) 21 | 22 | **Merged pull requests:** 23 | 24 | - feat: sidebars on rhs of editor [\#138](https://github.com/willothy/nvim-cokeline/pull/138) ([willothy](https://github.com/willothy)) 25 | - feat: native tab support [\#136](https://github.com/willothy/nvim-cokeline/pull/136) ([willothy](https://github.com/willothy)) 26 | - perf: use stream-based iterators where possible [\#135](https://github.com/willothy/nvim-cokeline/pull/135) ([willothy](https://github.com/willothy)) 27 | - Use named color names [\#134](https://github.com/willothy/nvim-cokeline/pull/134) ([lucassperez](https://github.com/lucassperez)) 28 | - feat\(pick\): retain pick char order when not using filename [\#132](https://github.com/willothy/nvim-cokeline/pull/132) ([willothy](https://github.com/willothy)) 29 | - feat: buffer history ringbuffer [\#131](https://github.com/willothy/nvim-cokeline/pull/131) ([willothy](https://github.com/willothy)) 30 | - feat: support for passing highlight group names [\#130](https://github.com/willothy/nvim-cokeline/pull/130) ([nenikitov](https://github.com/nenikitov)) 31 | - feat: allow multiple vert splits in sidebar [\#129](https://github.com/willothy/nvim-cokeline/pull/129) ([willothy](https://github.com/willothy)) 32 | - fix: allow multibyte characters on buffer picker [\#123](https://github.com/willothy/nvim-cokeline/pull/123) ([lucassperez](https://github.com/lucassperez)) 33 | - feat: add fallback icon by filetype [\#121](https://github.com/willothy/nvim-cokeline/pull/121) ([Webblitchy](https://github.com/Webblitchy)) 34 | - fix: index update value [\#118](https://github.com/willothy/nvim-cokeline/pull/118) ([Equilibris](https://github.com/Equilibris)) 35 | - feat: add fill_hl config option [\#114](https://github.com/willothy/nvim-cokeline/pull/114) ([FollieHiyuki](https://github.com/FollieHiyuki)) 36 | - feat: Rearrange buffers with mouse drag [\#113](https://github.com/willothy/nvim-cokeline/pull/113) ([willothy](https://github.com/willothy)) 37 | - feat\(pick\): release taken letters when deleting buffers [\#112](https://github.com/willothy/nvim-cokeline/pull/112) ([soifou](https://github.com/soifou)) 38 | - feat: hover events [\#111](https://github.com/willothy/nvim-cokeline/pull/111) ([willothy](https://github.com/willothy)) 39 | - feat: per-component click handlers [\#106](https://github.com/willothy/nvim-cokeline/pull/106) ([willothy](https://github.com/willothy)) 40 | - feat: close by step and by index [\#104](https://github.com/willothy/nvim-cokeline/pull/104) ([willothy](https://github.com/willothy)) 41 | - feat: handle click events with Lua, add bufdelete util [\#103](https://github.com/willothy/nvim-cokeline/pull/103) ([willothy](https://github.com/willothy)) 42 | - Proposal: Add sorting by directory [\#97](https://github.com/willothy/nvim-cokeline/pull/97) ([ewok](https://github.com/ewok)) 43 | - feat: `pick.use_filename` and `pick.letters` config options [\#88](https://github.com/willothy/nvim-cokeline/pull/88) ([ProspectPyxis](https://github.com/ProspectPyxis)) 44 | - fix\(mapping\): handle gracefully keyboard interrupt on buffer pick. [\#81](https://github.com/willothy/nvim-cokeline/pull/81) ([soifou](https://github.com/soifou)) 45 | - Update README.md [\#70](https://github.com/willothy/nvim-cokeline/pull/70) ([crivotz](https://github.com/crivotz)) 46 | - Adding is_last and is_first to buffer return [\#69](https://github.com/willothy/nvim-cokeline/pull/69) ([miversen33](https://github.com/miversen33)) 47 | - Add new configuration [\#68](https://github.com/willothy/nvim-cokeline/pull/68) ([danielnieto](https://github.com/danielnieto)) 48 | - fix: check for Windows always fails when computing `unique_prefix` [\#65](https://github.com/willothy/nvim-cokeline/pull/65) ([EtiamNullam](https://github.com/EtiamNullam)) 49 | - fix: cokeline-pick-close and cokeline-pick-focus in v0.7 [\#53](https://github.com/willothy/nvim-cokeline/pull/53) ([matt-riley](https://github.com/matt-riley)) 50 | 51 | ## [v0.3.0](https://github.com/willothy/nvim-cokeline/tree/v0.3.0) (2022-03-06) 52 | 53 | [Full Changelog](https://github.com/willothy/nvim-cokeline/compare/v0.2.0...v0.3.0) 54 | 55 | **Merged pull requests:** 56 | 57 | - fix: Allow focusing a buffer from a non-valid buffer [\#40](https://github.com/willothy/nvim-cokeline/pull/40) ([tamirzb](https://github.com/tamirzb)) 58 | 59 | ## [v0.2.0](https://github.com/willothy/nvim-cokeline/tree/v0.2.0) (2022-01-01) 60 | 61 | [Full Changelog](https://github.com/willothy/nvim-cokeline/compare/v0.1.0...v0.2.0) 62 | 63 | ## [v0.1.0](https://github.com/willothy/nvim-cokeline/tree/v0.1.0) (2021-12-07) 64 | 65 | [Full Changelog](https://github.com/willothy/nvim-cokeline/compare/68b23cb77e2bf76df92a8043612e655e04507ed6...v0.1.0) 66 | 67 | **Merged pull requests:** 68 | 69 | - Update README.md [\#27](https://github.com/willothy/nvim-cokeline/pull/27) ([KadoBOT](https://github.com/KadoBOT)) 70 | - Add author links to readme [\#20](https://github.com/willothy/nvim-cokeline/pull/20) ([alex-popov-tech](https://github.com/alex-popov-tech)) 71 | - fix: correct error in readme.md [\#7](https://github.com/willothy/nvim-cokeline/pull/7) ([olimorris](https://github.com/olimorris)) 72 | - :bug: fix: `unique_prefix` on windows [\#3](https://github.com/willothy/nvim-cokeline/pull/3) ([Neelfrost](https://github.com/Neelfrost)) 73 | - :bug: fix: use correct path separators for unique_prefix depending on OS [\#2](https://github.com/willothy/nvim-cokeline/pull/2) ([Neelfrost](https://github.com/Neelfrost)) 74 | 75 | \* _This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)_ 76 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Riccardo Mazzarini 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | 👃 nvim-cokeline 4 |

5 | 6 | 7 |

8 | A Neovim bufferline for people with addictive personalities 9 |

10 | 11 | 12 | The goal of this plugin is not to be an opinionated bufferline with (more or 13 | less) limited customization options. Rather, it tries to provide a general 14 | framework allowing you to build **_your_** ideal bufferline, whatever that 15 | might look like. 16 | 17 | 18 | ![preview](https://user-images.githubusercontent.com/38540736/226447816-c696153f-ccee-4e4a-8b6a-55e53ee737f8.png) 19 | 20 | ## :book: Table of Contents 21 | 22 | - [Features](#sparkles-features) 23 | - [Requirements](#electric_plug-requirements) 24 | - [Installation](#package-installation) 25 | - [Configuration](#wrench-configuration) 26 | - [Mappings](#musical_keyboard-mappings) 27 | 28 | 29 | 30 | ## :sparkles: Features 31 | 32 | ### Endlessly customizable 33 | 34 | `nvim-cokeline` aims to be the most customizable bufferline plugin around. If 35 | you have an idea in mind of what your bufferline should look like, you should 36 | be able to make it look that way. If you can't, open an issue and we'll try to 37 | make it happen! 38 | 39 | 40 | Here's a (very limited) showcase of what it can be configured to look like 41 | (check out the wiki for more examples): 42 | 43 |
44 | Click to see configuration 45 | 46 | ```lua 47 | local get_hex = require('cokeline.hlgroups').get_hl_attr 48 | 49 | require('cokeline').setup({ 50 | default_hl = { 51 | fg = function(buffer) 52 | return 53 | buffer.is_focused 54 | and get_hex('ColorColumn', 'bg') 55 | or get_hex('Normal', 'fg') 56 | end, 57 | bg = function(buffer) 58 | return 59 | buffer.is_focused 60 | and get_hex('Normal', 'fg') 61 | or get_hex('ColorColumn', 'bg') 62 | end, 63 | }, 64 | 65 | components = { 66 | { 67 | text = function(buffer) return ' ' .. buffer.devicon.icon end, 68 | fg = function(buffer) return buffer.devicon.color end, 69 | }, 70 | { 71 | text = function(buffer) return buffer.unique_prefix end, 72 | fg = get_hex('Comment', 'fg'), 73 | italic = true 74 | }, 75 | { 76 | text = function(buffer) return buffer.filename .. ' ' end, 77 | underline = function(buffer) 78 | return buffer.is_hovered and not buffer.is_focused 79 | end 80 | }, 81 | { 82 | text = '', 83 | on_click = function(_, _, _, _, buffer) 84 | buffer:delete() 85 | end 86 | }, 87 | { 88 | text = ' ', 89 | } 90 | }, 91 | }) 92 | ``` 93 | 94 |
95 | 96 | ![cokeline-default](https://user-images.githubusercontent.com/38540736/226447806-0d4be251-788e-495c-abf7-ae5041dcc702.png) 97 | 98 |
99 | Click to see configuration 100 | 101 | ```lua 102 | local get_hex = require('cokeline.hlgroups').get_hl_attr 103 | 104 | local green = vim.g.terminal_color_2 105 | local yellow = vim.g.terminal_color_3 106 | 107 | require('cokeline').setup({ 108 | default_hl = { 109 | fg = function(buffer) 110 | return 111 | buffer.is_focused 112 | and get_hex('Normal', 'fg') 113 | or get_hex('Comment', 'fg') 114 | end, 115 | bg = get_hex('ColorColumn', 'bg'), 116 | }, 117 | 118 | components = { 119 | { 120 | text = '|', 121 | fg = function(buffer) 122 | return 123 | buffer.is_modified and yellow or green 124 | end 125 | }, 126 | { 127 | text = function(buffer) return buffer.devicon.icon .. ' ' end, 128 | fg = function(buffer) return buffer.devicon.color end, 129 | }, 130 | { 131 | text = function(buffer) return buffer.index .. ': ' end, 132 | }, 133 | { 134 | text = function(buffer) return buffer.unique_prefix end, 135 | fg = get_hex('Comment', 'fg'), 136 | italic = true, 137 | }, 138 | { 139 | text = function(buffer) return buffer.filename .. ' ' end, 140 | bold = function(buffer) return buffer.is_focused end, 141 | }, 142 | { 143 | text = ' ', 144 | }, 145 | }, 146 | }) 147 | ``` 148 | 149 |
150 | 151 | ![cokeline-noib3](https://user-images.githubusercontent.com/38540736/226447808-fc834732-efd1-4fd1-a0de-65ebea213d3f.png) 152 | 153 |
154 | Click to see configuration 155 | 156 | ```lua 157 | local get_hex = require('cokeline.hlgroups').get_hl_attr 158 | 159 | require('cokeline').setup({ 160 | default_hl = { 161 | fg = function(buffer) 162 | return 163 | buffer.is_focused 164 | and get_hex('Normal', 'fg') 165 | or get_hex('Comment', 'fg') 166 | end, 167 | bg = 'NONE', 168 | }, 169 | components = { 170 | { 171 | text = function(buffer) return (buffer.index ~= 1) and '▏' or '' end, 172 | fg = function() return get_hex('Normal', 'fg') end 173 | }, 174 | { 175 | text = function(buffer) return ' ' .. buffer.devicon.icon end, 176 | fg = function(buffer) return buffer.devicon.color end, 177 | }, 178 | { 179 | text = function(buffer) return buffer.filename .. ' ' end, 180 | bold = function(buffer) return buffer.is_focused end 181 | }, 182 | { 183 | text = '󰖭', 184 | on_click = function(_, _, _, _, buffer) 185 | buffer:delete() 186 | end 187 | }, 188 | { 189 | text = ' ', 190 | }, 191 | }, 192 | }) 193 | ``` 194 | 195 |
196 | 197 | ![cokeline-bufferline-lua](https://user-images.githubusercontent.com/38540736/226447803-13f3d3ee-454f-42be-81b4-9254f95503e4.png) 198 | 199 | 200 | 201 | ### Dynamic rendering 202 | 203 | 204 | 205 | Even when you have a lot of buffers open, `nvim-cokeline` is rendered to always 206 | keep the focused buffer visible and in the middle of the bufferline. Also, if a 207 | buffer doesn't fit entirely we still try to include as much of it as possible 208 | before cutting off the rest. 209 | 210 | 211 | 212 | ![rendering](https://user-images.githubusercontent.com/38540736/226447817-4f3679c8-a10a-48ad-8329-b21c3ee54968.gif) 213 | 214 | 215 | 216 | ### LSP support 217 | 218 | If a buffer has an LSP client attached to it, you can configure the style of a 219 | component to change based on how many errors, warnings, infos and hints are 220 | reported by the LSP. 221 | 222 | 223 | 224 | ![lsp-styling](https://user-images.githubusercontent.com/38540736/226447813-4ec42530-9e86-43f5-98ed-fd7b4012120b.gif) 225 | 226 | 227 | 228 | ### Buffer pick 229 | 230 | You can focus and close any buffer by typing its `pick_letter`. Letters are 231 | assigned by filename by default (e.g. `foo.txt` gets the letter `f`), and by 232 | keyboard reachability if the letter is already assigned to another buffer. 233 | 234 | 235 | 236 |
237 | Click to see configuration 238 | 239 | ```lua 240 | local is_picking_focus = require('cokeline.mappings').is_picking_focus 241 | local is_picking_close = require('cokeline.mappings').is_picking_close 242 | local get_hex = require('cokeline.hlgroups').get_hl_attr 243 | 244 | local red = vim.g.terminal_color_1 245 | local yellow = vim.g.terminal_color_3 246 | 247 | require('cokeline').setup({ 248 | default_hl = { 249 | fg = function(buffer) 250 | return 251 | buffer.is_focused 252 | and get_hex('Normal', 'fg') 253 | or get_hex('Comment', 'fg') 254 | end, 255 | bg = function() return get_hex('ColorColumn', 'bg') end, 256 | }, 257 | 258 | components = { 259 | { 260 | text = function(buffer) return (buffer.index ~= 1) and '▏' or '' end, 261 | }, 262 | { 263 | text = ' ', 264 | }, 265 | { 266 | text = function(buffer) 267 | return 268 | (is_picking_focus() or is_picking_close()) 269 | and buffer.pick_letter .. ' ' 270 | or buffer.devicon.icon 271 | end, 272 | fg = function(buffer) 273 | return 274 | (is_picking_focus() and yellow) 275 | or (is_picking_close() and red) 276 | or buffer.devicon.color 277 | end, 278 | italic = function() 279 | return 280 | (is_picking_focus() or is_picking_close()) 281 | end, 282 | bold = function() 283 | return 284 | (is_picking_focus() or is_picking_close()) 285 | end 286 | }, 287 | { 288 | text = ' ', 289 | }, 290 | { 291 | text = function(buffer) return buffer.filename .. ' ' end, 292 | bold = function(buffer) return buffer.is_focused end, 293 | }, 294 | { 295 | text = '', 296 | on_click = function(_, _, _, _, buffer) 297 | buffer:delete() 298 | end, 299 | }, 300 | { 301 | text = ' ', 302 | }, 303 | }, 304 | }) 305 | ``` 306 | 307 |
308 | 309 | ![buffer-pick](https://user-images.githubusercontent.com/38540736/226447793-8e2341b3-e454-49dc-af84-72d3b56f40d3.gif) 310 | 311 | 312 | 313 | ### Sidebars 314 | 315 | You can add a left sidebar to integrate nicely with file explorer plugins like 316 | [nvim-tree.lua](https://github.com/nvim-tree/nvim-tree.lua), 317 | [CHADTree](https://github.com/ms-jpq/chadtree) or 318 | [NERDTree](https://github.com/preservim/nerdtree). 319 | 320 | 321 | 322 |
323 | Click to see configuration 324 | 325 | ```lua 326 | local get_hex = require('cokeline.hlgroups').get_hl_attr 327 | 328 | local yellow = vim.g.terminal_color_3 329 | 330 | require('cokeline').setup({ 331 | default_hl = { 332 | fg = function(buffer) 333 | return 334 | buffer.is_focused 335 | and get_hex('Normal', 'fg') 336 | or get_hex('Comment', 'fg') 337 | end, 338 | bg = function() return get_hex('ColorColumn', 'bg') end, 339 | }, 340 | 341 | sidebar = { 342 | filetype = {'NvimTree', 'neo-tree'}, 343 | components = { 344 | { 345 | text = function(buf) 346 | return buf.filetype 347 | end, 348 | fg = yellow, 349 | bg = function() return get_hex('NvimTreeNormal', 'bg') end, 350 | bold = true, 351 | }, 352 | } 353 | }, 354 | 355 | components = { 356 | { 357 | text = function(buffer) return (buffer.index ~= 1) and '▏' or '' end, 358 | }, 359 | { 360 | text = ' ', 361 | }, 362 | { 363 | text = function(buffer) 364 | return buffer.devicon.icon 365 | end, 366 | fg = function(buffer) 367 | return buffer.devicon.color 368 | end, 369 | }, 370 | { 371 | text = ' ', 372 | }, 373 | { 374 | text = function(buffer) return buffer.filename .. ' ' end, 375 | bold = function(buffer) 376 | return buffer.is_focused 377 | end, 378 | }, 379 | { 380 | text = '', 381 | on_click = function(_, _, _, _, buffer) 382 | buffer:delete() 383 | end, 384 | }, 385 | { 386 | text = ' ', 387 | }, 388 | }, 389 | }) 390 | ``` 391 | 392 |
393 | 394 | ![sidebars](https://user-images.githubusercontent.com/38540736/226447821-de543b87-909c-445f-ac6e-82f5f6bbf9aa.png) 395 | 396 | 397 | 398 | ### Unique buffer names 399 | 400 | When files with the same filename belonging to different directories are opened 401 | simultaneously, you can include a unique filetree prefix to distinguish between 402 | them: 403 | 404 | 405 | 406 | ![unique-prefix](https://user-images.githubusercontent.com/38540736/226447822-3315ad2f-35c9-4fc3-a777-c01cd8f2fe46.gif) 407 | 408 | 409 | 410 | ### Clickable buffers 411 | 412 | Left click on a buffer to focus it, and right click to delete it. Alternatively, define custom click handlers for each component that override the default behavior. 413 | 414 | 415 | 416 | ![clickable-buffers](https://user-images.githubusercontent.com/38540736/226447799-e845d266-0658-44e3-bd89-f706577844bf.gif) 417 | 418 | 419 | 420 | ### Hover events 421 | 422 | Each component has access to an is_hovered property, and can be given custom `on_mouse_enter` and `on_mouse_leave` handlers, allowing for implementations of close buttons, diagnostic previews, and more complex funcionality. 423 | 424 | Note: requires `:h 'mousemoveevent'` 425 | 426 | 427 | 428 | ![hover-events](https://github.com/willothy/nvim-cokeline/assets/38540736/fb92475f-d775-44fe-9c95-a76c1cbaf560) 429 | 430 | ![hover-events-2](https://github.com/willothy/nvim-cokeline/assets/38540736/3b319c79-0bff-41dd-9a08-36fd627b3d08) 431 | 432 | 433 | 434 | ### Buffer re-ordering (including mouse-drag reordering) 435 | 436 | 437 | 438 | ![reordering](https://user-images.githubusercontent.com/38540736/226447818-bdf63d70-e153-4353-992d-d317a5764c09.gif) 439 | 440 | 441 | 442 | ### Close icons 443 | 444 | 445 | 446 | ![close-icons](https://user-images.githubusercontent.com/38540736/226447802-29b2919e-dd20-4789-8d6a-250d6d453c64.gif) 447 | 448 | 449 | 450 | ### Buffer history tracking 451 | 452 | ```lua 453 | require("cokeline.history"):last():focus() 454 | ``` 455 | 456 | If you are a user of [`resession.nvim`](https://github.com/stevearc/resession.nvim), cokeline's history will be restored along with 457 | the rest of your sessions. 458 | 459 | ## :electric_plug: Requirements 460 | 461 | The two main requirements are Neovim 0.5+ and the `termguicolors` option to be 462 | set. If you want to display devicons in your bufferline you'll also need the 463 | [nvim-tree/nvim-web-devicons](https://github.com/nvim-tree/nvim-web-devicons) 464 | plugin and a patched font (see [Nerd Fonts](https://www.nerdfonts.com/)). 465 | 466 | As of v0.4.0, [nvim-lua/plenary.nvim](https://github.com/nvim-lua/plenary.nvim) is required as well. 467 | 468 | ## :package: Installation 469 | 470 | ### Lua 471 | 472 | #### With lazy.nvim 473 | 474 | ```lua 475 | require("lazy").setup({ 476 | { 477 | "willothy/nvim-cokeline", 478 | dependencies = { 479 | "nvim-lua/plenary.nvim", -- Required for v0.4.0+ 480 | "nvim-tree/nvim-web-devicons", -- If you want devicons 481 | "stevearc/resession.nvim" -- Optional, for persistent history 482 | }, 483 | config = true 484 | } 485 | }) 486 | ``` 487 | 488 | ### Vimscript 489 | 490 | If your config is still written in Vimscript and you use 491 | [vim-plug](https://github.com/junegunn/vim-plug): 492 | 493 | ```vim 494 | call plug#begin('~/.config/nvim/plugged') 495 | " ... 496 | Plug 'nvim-lua/plenary.nvim' " Required for v0.4.0+ 497 | Plug 'nvim-tree/nvim-web-devicons' " If you want devicons 498 | Plug 'willothy/nvim-cokeline' 499 | " ... 500 | call plug#end() 501 | 502 | set termguicolors 503 | lua << EOF 504 | require('cokeline').setup() 505 | EOF 506 | ``` 507 | 508 | ## :wrench: Configuration 509 | 510 | > **note**
511 | > Check out the [wiki](https://github.com/willothy/nvim-cokeline/wiki) for more details and API documentation. 512 | 513 | All the configuration is done by changing the contents of the Lua table passed to 514 | the `setup` function. 515 | 516 | The valid keys are: 517 | 518 | ```lua 519 | require('cokeline').setup({ 520 | -- Only show the bufferline when there are at least this many visible buffers. 521 | -- default: `1`. 522 | ---@type integer 523 | show_if_buffers_are_at_least = 1, 524 | 525 | buffers = { 526 | -- A function to filter out unwanted buffers. Takes a buffer table as a 527 | -- parameter (see the following section for more infos) and has to return 528 | -- either `true` or `false`. 529 | -- default: `false`. 530 | ---@type false | fun(buf: Buffer):boolean 531 | filter_valid = false, 532 | 533 | -- A looser version of `filter_valid`, use this function if you still 534 | -- want the `cokeline-{switch,focus}-{prev,next}` mappings to work for 535 | -- these buffers without displaying them in your bufferline. 536 | -- default: `false`. 537 | ---@type false | fun(buf: Buffer):boolean 538 | filter_visible = false, 539 | 540 | -- Which buffer to focus when a buffer is deleted, `prev` focuses the 541 | -- buffer to the left of the deleted one while `next` focuses the one the 542 | -- right. 543 | -- default: 'next'. 544 | focus_on_delete = 'prev' | 'next', 545 | 546 | -- If set to `last` new buffers are added to the end of the bufferline, 547 | -- if `next` they are added next to the current buffer. 548 | -- if set to `directory` buffers are sorted by their full path. 549 | -- if set to `number` buffers are sorted by bufnr, as in default Neovim 550 | -- default: 'last'. 551 | ---@type 'last' | 'next' | 'directory' | 'number' | fun(a: Buffer, b: Buffer):boolean 552 | new_buffers_position = 'last', 553 | 554 | -- If true, right clicking a buffer will close it 555 | -- The close button will still work normally 556 | -- Default: true 557 | ---@type boolean 558 | delete_on_right_click = true, 559 | }, 560 | 561 | mappings = { 562 | -- Controls what happens when the first (last) buffer is focused and you 563 | -- try to focus/switch the previous (next) buffer. If `true` the last 564 | -- (first) buffers gets focused/switched, if `false` nothing happens. 565 | -- default: `true`. 566 | ---@type boolean 567 | cycle_prev_next = true, 568 | 569 | -- Disables mouse mappings 570 | -- default: `false`. 571 | ---@type boolean 572 | disable_mouse = false, 573 | }, 574 | 575 | -- Maintains a history of focused buffers using a ringbuffer 576 | history = { 577 | ---@type boolean 578 | enabled = true, 579 | ---The number of buffers to save in the history 580 | ---@type integer 581 | size = 2 582 | }, 583 | 584 | rendering = { 585 | -- The maximum number of characters a rendered buffer is allowed to take 586 | -- up. The buffer will be truncated if its width is bigger than this 587 | -- value. 588 | -- default: `999`. 589 | ---@type integer 590 | max_buffer_width = 999, 591 | }, 592 | 593 | pick = { 594 | -- Whether to use the filename's first letter first before 595 | -- picking a letter from the valid letters list in order. 596 | -- default: `true` 597 | ---@type boolean 598 | use_filename = true, 599 | 600 | -- The list of letters that are valid as pick letters. Sorted by 601 | -- keyboard reachability by default, but may require tweaking for 602 | -- non-QWERTY keyboard layouts. 603 | -- default: `'asdfjkl;ghnmxcvbziowerutyqpASDFJKLGHNMXCVBZIOWERTYQP'` 604 | ---@type string 605 | letters = 'asdfjkl;ghnmxcvbziowerutyqpASDFJKLGHNMXCVBZIOWERTYQP', 606 | }, 607 | 608 | -- The default highlight group values. 609 | -- The `fg`, `bg`, and `sp` keys are either colors in hexadecimal format or 610 | -- functions taking a `buffer` parameter and returning a color in 611 | -- hexadecimal format. Style attributes work the same way, but functions 612 | -- should return boolean values. 613 | default_hl = { 614 | -- default: `ColorColumn`'s background color for focused buffers, 615 | -- `Normal`'s foreground color for unfocused ones. 616 | ---@type nil | string | fun(buffer: Buffer): string 617 | fg = function(buffer) 618 | local hlgroups = require("cokeline.hlgroups") 619 | return buffer.is_focused and hlgroups.get_hl_attr("ColorColumn", "bg") 620 | or hlgroups.get_hl_attr("Normal", "fg") 621 | end, 622 | 623 | -- default: `Normal`'s foreground color for focused buffers, 624 | -- `ColorColumn`'s background color for unfocused ones. 625 | -- default: `Normal`'s foreground color. 626 | ---@type nil | string | function(buffer: Buffer): string, 627 | bg = function(buffer) 628 | local hlgroups = require("cokeline.hlgroups") 629 | return buffer.is_focused and hlgroups.get_hl_attr("Normal", "fg") 630 | or hlgroups.get_hl_attr("ColorColumn", "bg") 631 | end, 632 | 633 | -- default: unset. 634 | ---@type nil | string | function(buffer): string, 635 | sp = nil, 636 | 637 | ---@type nil | boolean | fun(buf: Buffer):boolean 638 | bold = nil, 639 | ---@type nil | boolean | fun(buf: Buffer):boolean 640 | italic = nil, 641 | ---@type nil | boolean | fun(buf: Buffer):boolean 642 | underline = nil, 643 | ---@type nil | boolean | fun(buf: Buffer):boolean 644 | undercurl = nil, 645 | ---@type nil | boolean | fun(buf: Buffer):boolean 646 | strikethrough = nil, 647 | }, 648 | 649 | -- The highlight group used to fill the tabline space 650 | fill_hl = 'TabLineFill', 651 | 652 | -- A list of components to be rendered for each buffer. Check out the section 653 | -- below explaining what this value can be set to. 654 | -- default: see `/lua/cokeline/config.lua` 655 | ---@type Component[] 656 | components = {}, 657 | 658 | -- Custom areas can be displayed on the right hand side of the bufferline. 659 | -- They act identically to buffer components, except their methods don't take a Buffer object. 660 | -- If you want a rhs component to be stateful, you can wrap it in a closure containing state. 661 | ---@type Component[] | false 662 | rhs = {}, 663 | 664 | -- Tabpages can be displayed on either the left or right of the bufferline. 665 | -- They act the same as other components, except they are passed TabPage objects instead of 666 | -- buffer objects. 667 | ---@type table | false 668 | tabs = { 669 | placement = "left" | "right", 670 | ---@type Component[] 671 | components = {} 672 | }, 673 | 674 | -- Left sidebar to integrate nicely with file explorer plugins. 675 | -- This is a table containing a `filetype` key and a list of `components` to 676 | -- be rendered in the sidebar. 677 | -- The last component will be automatically space padded if necessary 678 | -- to ensure the sidebar and the window below it have the same width. 679 | ---@type table | false 680 | sidebar = { 681 | ---@type string | string[] 682 | filetype = { "NvimTree", "neo-tree", "SidebarNvim" }, 683 | ---@type Component[] 684 | components = {}, 685 | }, 686 | }) 687 | ``` 688 | 689 | ### So what's `function(buffer)`? 690 | 691 | Some of the configuration options can be functions that take a [`Buffer`](https://github.com/willothy/nvim-cokeline/wiki/Buffer) as a 692 | single parameter. This is useful as it allows users to set the values of 693 | components dynamically based on the buffer that component is being rendered 694 | for. 695 | 696 | The `Buffer` type is just a Lua table with the following keys: 697 | 698 | ```lua 699 | Buffer = { 700 | -- The buffer's order in the bufferline (1 for the first buffer, 2 for the 701 | -- second one, etc.). 702 | index = int, 703 | 704 | -- The buffer's internal number as reported by `:ls`. 705 | number = int, 706 | 707 | ---@type boolean 708 | is_focused = false, 709 | 710 | ---@type boolean 711 | is_modified = false, 712 | 713 | ---@type boolean 714 | is_readonly = false, 715 | 716 | -- The buffer is the first visible buffer in the tab bar 717 | ---@type boolean 718 | is_first = false, 719 | 720 | -- The buffer is the last visible buffer in the tab bar 721 | ---@type boolean 722 | is_last = false, 723 | 724 | -- The mouse is hovering over the current component in the buffer 725 | -- This is a special variable in that it will only be true for the hovered *component* 726 | -- on render. This is to allow components to respond to hover events individually without managing 727 | -- component state. 728 | ---@type boolean 729 | is_hovered = false, 730 | 731 | -- The mouse is hovering over the buffer (true for all components) 732 | ---@type boolean 733 | buf_hovered = false, 734 | 735 | -- The buffer's type as reported by `:echo &buftype`. 736 | ---@type string 737 | ---@type string 738 | type = '', 739 | 740 | -- The buffer's filetype as reported by `:echo &filetype`. 741 | ---@type string 742 | filetype = '', 743 | 744 | -- The buffer's full path. 745 | ---@type string 746 | path = '', 747 | 748 | -- The buffer's filename. 749 | ---@type string 750 | filename = 'string', 751 | 752 | -- A unique prefix used to distinguish buffers with the same filename 753 | -- stored in different directories. For example, if we have two files 754 | -- `bar/foo.md` and `baz/foo.md`, then the first will have `bar/` as its 755 | -- unique prefix and the second one will have `baz/`. 756 | ---@type string 757 | unique_prefix = '', 758 | 759 | -- The letter that is displayed when picking a buffer to either focus or 760 | -- close it. 761 | ---@type string 762 | pick_letter = 'char', 763 | 764 | -- This needs the `nvim-tree/nvim-web-devicons` plugin to be installed. 765 | devicon = { 766 | -- An icon representing the buffer's filetype. 767 | ---@type string 768 | icon = 'string', 769 | 770 | -- The colors of the devicon in hexadecimal format (useful to be passed 771 | -- to a component's `fg` field (see the `Components` section). 772 | color = '#rrggbb', 773 | }, 774 | 775 | -- The values in this table are the ones reported by Neovim's built in 776 | -- LSP interface. 777 | diagnostics = { 778 | ---@type integer 779 | errors = 0, 780 | ---@type integer 781 | warnings = 0, 782 | ---@type integer 783 | infos = 0, 784 | ---@type integer 785 | hints = 0, 786 | }, 787 | } 788 | ``` 789 | 790 | It also has methods that can be used in component event handlers: 791 | 792 | ```lua 793 | ---@param self Buffer 794 | ---Deletes the buffer 795 | function Buffer:delete() end 796 | 797 | ---@param self Buffer 798 | ---Focuses the buffer 799 | function Buffer:focus() end 800 | 801 | ---@param self Buffer 802 | ---@return number 803 | ---Returns the number of lines in the buffer 804 | function Buffer:lines() end 805 | 806 | ---@param self Buffer 807 | ---@return string[] 808 | ---Returns the buffer's lines 809 | function Buffer:text() end 810 | 811 | ---@param buf Buffer 812 | ---@return boolean 813 | ---Returns true if the buffer is valid 814 | function Buffer:is_valid() end 815 | ``` 816 | 817 | ### What about [`TabPage`](https://github.com/willothy/nvim-cokeline/wiki/TabPage)s? 818 | 819 | Each method on a tab component is passed a `TabPage` object as an argument. 820 | 821 | `TabPage`, like `Buffer`, is simply a Lua table with some relevant data attached. 822 | 823 | ```lua 824 | TabPage = { 825 | -- The tabpage number, as reported by `nvim_list_tabpages` 826 | ---@type integer 827 | number = 0, 828 | -- A list of Window objects contained in the TabPage (see wiki for more info) 829 | ---@type Window[] 830 | windows = {}, 831 | -- The currently focused window in the TabPage 832 | ---@type Window 833 | focused = nil, 834 | -- True if the TabPage is the current TabPage 835 | ---@type boolean 836 | is_active = true, 837 | -- True if the TabPage is first in the list 838 | ---@type boolean 839 | is_first = false, 840 | -- True if the TabPage is last in the list 841 | ---@type boolean 842 | is_last = false 843 | } 844 | ``` 845 | 846 | ### And [`components`](https://github.com/willothy/nvim-cokeline/wiki/Component)? 847 | 848 | You can configure what each buffer in your bufferline will be composed of by 849 | passing a list of components to the `setup` function. 850 | 851 | For example, let's imagine we want to construct a very minimal bufferline 852 | where the only things we're displaying for each buffer are its index, its 853 | filename and a close button. 854 | 855 | Then in our `setup` function we'd have: 856 | 857 | ```lua 858 | require('cokeline').setup({ 859 | -- ... 860 | components = { 861 | { 862 | text = function(buffer) return ' ' .. buffer.index end, 863 | }, 864 | { 865 | text = function(buffer) return ' ' .. buffer.filename .. ' ' end, 866 | }, 867 | { 868 | text = '󰅖', 869 | on_click = function(_, _, _, _, buffer) 870 | buffer:delete() 871 | end 872 | }, 873 | { 874 | text = ' ', 875 | } 876 | } 877 | }) 878 | ``` 879 | 880 | in this case every buffer would be composed of four components: the first 881 | displaying a space followed by the buffer index, the second one the filename 882 | padded by a space on each side, then a close button that allows us to 883 | `:bdelete` the buffer by left-clicking on it, and finally an extra space. 884 | 885 | This way of dividing each buffer into distinct components, combined with the 886 | ability to define every component's text and color depending on some property 887 | of the buffer we're rendering, allows for great customizability. 888 | 889 | Every component passed to the `components` list has to be a table of the form: 890 | 891 | ```lua 892 | { 893 | 894 | ---@type string | fun(buffer: Buffer): string 895 | text = "", 896 | 897 | -- The foreground, backgrond and style of the component 898 | ---@type nil | string | fun(buffer: Buffer): string 899 | fg = '#rrggbb', 900 | ---@type nil | string | fun(buffer: Buffer): string 901 | bg = '#rrggbb', 902 | ---@type nil | string | fun(buffer: Buffer): string 903 | sp = '#rrggbb', 904 | ---@type nil | boolean | fun(buffer: Buffer): boolean 905 | bold = false, 906 | ---@type nil | boolean | fun(buffer: Buffer): boolean 907 | italic = false, 908 | ---@type nil | boolean | fun(buffer: Buffer): boolean 909 | underline = false, 910 | ---@type nil | boolean | fun(buffer: Buffer): boolean 911 | undercurl = false, 912 | ---@type nil | boolean | fun(buffer: Buffer): boolean 913 | strikethrough = false, 914 | 915 | -- Or, alternatively, the name of the highlight group 916 | ---@type nil | string | fun(buffer: Buffer): string 917 | highlight = nil, 918 | 919 | -- If `true` the buffer will be deleted when this component is 920 | -- left-clicked (usually used to implement close buttons, overrides `on_click`). 921 | -- deprecated, it is recommended to use the Buffer:delete() method in an on_click event 922 | -- to implement close buttons instead. 923 | ---@type boolean 924 | delete_buffer_on_left_click = false, 925 | 926 | -- Handles click event for a component 927 | -- If not set, component will have the default click behavior 928 | -- buffer is a Buffer object, not a bufnr 929 | ---@type nil | fun(idx: integer, clicks: integer, button: string, mods: string, buffer: Buffer) 930 | on_click = nil, 931 | 932 | -- Called on a component when hovered 933 | ---@type nil | function(buffer: Buffer, mouse_col: integer) 934 | on_mouse_enter = nil, 935 | 936 | -- Called on a component when unhovered 937 | ---@type nil | function(buffer: Buffer, mouse_col: integer) 938 | on_mouse_leave = nil, 939 | 940 | truncation = { 941 | -- default: index of the component in the `components` table (1 for the 942 | -- first component, 2 for the second, etc.). 943 | ---@type integer 944 | priority = 1, 945 | 946 | -- default: `right`. 947 | ---@type 'left' | 'middle' | 'right' 948 | direction = 'left' | 'middle' | 'right', 949 | }, 950 | } 951 | ``` 952 | 953 | the `text` key is the only one that has to be set, all the others are optional 954 | and can be omitted. 955 | 956 | The `truncation` table controls what happens when a buffer is too long to be 957 | displayed in its entirety. 958 | 959 | More specifically, if a buffer's width (given by the sum of the widths of all 960 | its components) is bigger than the `rendering.max_buffer_width` config option, 961 | the buffer will be truncated. 962 | 963 | The default behaviour is truncate the buffer by dropping components from right 964 | to left, with the text of the last component that's included also being 965 | shortened from right to left. This can be modified by changing the values of 966 | the `truncation.priority` and `truncation.direction` keys. 967 | 968 | The `truncation.priority` controls the order in which components are dropped: 969 | the first component to be dropped will be the one with the lowest priority. If 970 | that's still not enough to bring the width of the buffer within the 971 | `rendering.max_buffer_width` limit, the component with the second lowest 972 | priority will be dropped, and so on. Note that a higher priority means a 973 | smaller integer value: a component with a priority of 5 will be dropped 974 | _after_ a component with a priority of 6, even though 6 > 5. 975 | 976 | The `truncation.direction` key simply controls from which direction a component 977 | is shortened. For example, you might want to set the `truncation.direction` of 978 | a component displaying a filename to `'middle'` or `'left'`, so that if 979 | the filename has to be shortened you'll still be able to see its extension, 980 | like in the following example (where it's set to `'left'`): 981 | 982 | 983 | 984 | ![buffer-truncation](https://user-images.githubusercontent.com/38540736/226447798-6aee2e0f-f957-42ab-96dd-3618e78ba4ba.png) 985 | 986 | 987 | 988 | #### What about [`history`](https://github.com/willothy/nvim-cokeline/wiki/History)? 989 | 990 | The History keeps track of the buffers you access using a ringbuffer, and provides 991 | an API for accessing Buffer objects from the history. 992 | 993 | You can access the history using `require("cokeline.history")`, or through the global `_G.cokeline.history`. 994 | 995 | The `History` object provides these methods: 996 | 997 | ```lua 998 | History = {} 999 | 1000 | ---Adds a Buffer object to the history 1001 | ---@type bufnr integer 1002 | function History:push(bufnr) 1003 | end 1004 | 1005 | ---Removes and returns the oldest Buffer object in the history 1006 | ---@return Buffer? 1007 | function History:pop() 1008 | end 1009 | 1010 | ---Returns a list of Buffer objects in the history, 1011 | ---ordered from oldest to newest 1012 | ---@return Buffer[] 1013 | function History:list() 1014 | end 1015 | 1016 | ---Returns an iterator of Buffer objects in the history, 1017 | ---ordered from oldest to newest 1018 | ---@return fun(): Buffer? 1019 | function History:iter() 1020 | end 1021 | 1022 | ---Get a Buffer object by history index 1023 | ---@param idx integer 1024 | ---@return Buffer? 1025 | function History:get(idx) 1026 | end 1027 | 1028 | ---Get a Buffer object representing the last-accessed buffer (before the current one) 1029 | ---@return Buffer? 1030 | function History:last() 1031 | end 1032 | 1033 | ---Returns true if the history is empty 1034 | ---@return boolean 1035 | function History:is_empty() 1036 | end 1037 | 1038 | ---Returns the maximum number of buffers that can be stored in the history 1039 | ---@return integer 1040 | function History:capacity() 1041 | end 1042 | 1043 | ---Returns true if the history contains the given buffer 1044 | ---@param bufnr integer 1045 | ---@return boolean 1046 | function History:contains(bufnr) 1047 | end 1048 | 1049 | ---Returns the number of buffers in the history 1050 | ---@return integer 1051 | function History:len() 1052 | end 1053 | ``` 1054 | 1055 | ## :musical_keyboard: Mappings 1056 | 1057 | You can use the `mappings` module to create mappings from Lua: 1058 | 1059 | ```lua 1060 | vim.keymap.set("n", "bp", function() 1061 | require('cokeline.mappings').pick("focus") 1062 | end, { desc = "Pick a buffer to focus" }) 1063 | ``` 1064 | 1065 | Alternatively, we expose the following `` mappings which can be used as the right hand 1066 | side of other mappings: 1067 | 1068 | ``` 1069 | -- Focus the previous/next buffer 1070 | (cokeline-focus-prev) 1071 | (cokeline-focus-next) 1072 | 1073 | -- Switch the position of the current buffer with the previous/next buffer. 1074 | (cokeline-switch-prev) 1075 | (cokeline-switch-next) 1076 | 1077 | -- Focuses the buffer with index `i`. 1078 | (cokeline-focus-i) 1079 | 1080 | -- Switches the position of the current buffer with the buffer of index `i`. 1081 | (cokeline-switch-i) 1082 | 1083 | -- Focus a buffer by its `pick_letter`. 1084 | (cokeline-pick-focus) 1085 | 1086 | -- Close a buffer by its `pick_letter`. 1087 | (cokeline-pick-close) 1088 | ``` 1089 | 1090 | A possible configuration could be: 1091 | 1092 | ```lua 1093 | local map = vim.api.nvim_set_keymap 1094 | 1095 | map("n", "", "(cokeline-focus-prev)", { silent = true }) 1096 | map("n", "", "(cokeline-focus-next)", { silent = true }) 1097 | map("n", "p", "(cokeline-switch-prev)", { silent = true }) 1098 | map("n", "n", "(cokeline-switch-next)", { silent = true }) 1099 | 1100 | for i = 1, 9 do 1101 | map( 1102 | "n", 1103 | (""):format(i), 1104 | ("(cokeline-focus-%s)"):format(i), 1105 | { silent = true } 1106 | ) 1107 | map( 1108 | "n", 1109 | ("%s"):format(i), 1110 | ("(cokeline-switch-%s)"):format(i), 1111 | { silent = true } 1112 | ) 1113 | end 1114 | 1115 | ``` 1116 | -------------------------------------------------------------------------------- /doc/cokeline.txt: -------------------------------------------------------------------------------- 1 | *cokeline.txt* For NVIM >=0.7 Last change: 2025 January 24 2 | 3 | ============================================================================== 4 | Table of Contents *cokeline-table-of-contents* 5 | 6 | - Features |cokeline-features| 7 | - Requirements |cokeline-requirements| 8 | - Installation |cokeline-installation| 9 | - Configuration |cokeline-configuration| 10 | - Mappings |cokeline-mappings| 11 | 12 | 13 | nvim-cokeline 14 | 15 | A Neovim bufferline for people with addictive personalities 16 | 17 | The goal of this plugin is not to be an opinionated bufferline with (more or 18 | less) limited customization options. Rather, it tries to provide a general 19 | framework allowing you to build **your** ideal bufferline, whatever that might 20 | look like. 21 | 22 | 23 | FEATURES *cokeline-features* 24 | 25 | 26 | ENDLESSLY CUSTOMIZABLE ~ 27 | 28 | `nvim-cokeline` aims to be the most customizable bufferline plugin around. If 29 | you have an idea in mind of what your bufferline should look like, you should 30 | be able to make it look that way. If you can’t, open an issue and we’ll try 31 | to make it happen! 32 | 33 | 34 | DYNAMIC RENDERING ~ 35 | 36 | Even when you have a lot of buffers open, `nvim-cokeline` is rendered to always 37 | keep the focused buffer visible and in the middle of the bufferline. Also, if a 38 | buffer doesn’t fit entirely we still try to include as much of it as possible 39 | before cutting off the rest. 40 | 41 | 42 | LSP SUPPORT ~ 43 | 44 | If a buffer has an LSP client attached to it, you can configure the style of a 45 | component to change based on how many errors, warnings, infos and hints are 46 | reported by the LSP. 47 | 48 | 49 | BUFFER PICK ~ 50 | 51 | You can focus and close any buffer by typing its `pick_letter`. Letters are 52 | assigned by filename by default (e.g. `foo.txt` gets the letter `f`), and by 53 | keyboard reachability if the letter is already assigned to another buffer. 54 | 55 | 56 | SIDEBARS ~ 57 | 58 | You can add a left sidebar to integrate nicely with file explorer plugins like 59 | nvim-tree.lua , CHADTree 60 | or NERDTree 61 | . 62 | 63 | 64 | UNIQUE BUFFER NAMES ~ 65 | 66 | When files with the same filename belonging to different directories are opened 67 | simultaneously, you can include a unique filetree prefix to distinguish between 68 | them: 69 | 70 | 71 | CLICKABLE BUFFERS ~ 72 | 73 | Left click on a buffer to focus it, and right click to delete it. 74 | Alternatively, define custom click handlers for each component that override 75 | the default behavior. 76 | 77 | 78 | HOVER EVENTS ~ 79 | 80 | Each component has access to an is_hovered property, and can be given custom 81 | `on_mouse_enter` and `on_mouse_leave` handlers, allowing for implementations of 82 | close buttons, diagnostic previews, and more complex funcionality. 83 | 84 | Note: requires |'mousemoveevent'| 85 | 86 | 87 | BUFFER RE-ORDERING (INCLUDING MOUSE-DRAG REORDERING) ~ 88 | 89 | 90 | CLOSE ICONS ~ 91 | 92 | 93 | BUFFER HISTORY TRACKING ~ 94 | 95 | >lua 96 | require("cokeline.history"):last():focus() 97 | < 98 | 99 | If you are a user of `resession.nvim` 100 | , cokeline’s history will be 101 | restored along with the rest of your sessions. 102 | 103 | 104 | REQUIREMENTS *cokeline-requirements* 105 | 106 | The two main requirements are Neovim 0.5+ and the `termguicolors` option to be 107 | set. If you want to display devicons in your bufferline you’ll also need the 108 | nvim-tree/nvim-web-devicons 109 | plugin and a patched font (see Nerd Fonts ). 110 | 111 | As of v0.4.0, nvim-lua/plenary.nvim 112 | is required as well. 113 | 114 | 115 | INSTALLATION *cokeline-installation* 116 | 117 | 118 | LUA ~ 119 | 120 | 121 | WITH LAZY.NVIM 122 | 123 | >lua 124 | require("lazy").setup({ 125 | { 126 | "willothy/nvim-cokeline", 127 | dependencies = { 128 | "nvim-lua/plenary.nvim", -- Required for v0.4.0+ 129 | "nvim-tree/nvim-web-devicons", -- If you want devicons 130 | "stevearc/resession.nvim" -- Optional, for persistent history 131 | }, 132 | config = true 133 | } 134 | }) 135 | < 136 | 137 | 138 | VIMSCRIPT ~ 139 | 140 | If your config is still written in Vimscript and you use vim-plug 141 | 142 | 143 | >vim 144 | call plug#begin('~/.config/nvim/plugged') 145 | " ... 146 | Plug 'nvim-lua/plenary.nvim' " Required for v0.4.0+ 147 | Plug 'nvim-tree/nvim-web-devicons' " If you want devicons 148 | Plug 'willothy/nvim-cokeline' 149 | " ... 150 | call plug#end() 151 | 152 | set termguicolors 153 | lua << EOF 154 | require('cokeline').setup() 155 | EOF 156 | < 157 | 158 | 159 | CONFIGURATION *cokeline-configuration* 160 | 161 | 162 | **note** Check out the wiki 163 | for more details and API documentation. 164 | All the configuration is done by changing the contents of the Lua table passed 165 | to the `setup` function. 166 | 167 | The valid keys are: 168 | 169 | >lua 170 | require('cokeline').setup({ 171 | -- Only show the bufferline when there are at least this many visible buffers. 172 | -- default: `1`. 173 | ---@type integer 174 | show_if_buffers_are_at_least = 1, 175 | 176 | buffers = { 177 | -- A function to filter out unwanted buffers. Takes a buffer table as a 178 | -- parameter (see the following section for more infos) and has to return 179 | -- either `true` or `false`. 180 | -- default: `false`. 181 | ---@type false | fun(buf: Buffer):boolean 182 | filter_valid = false, 183 | 184 | -- A looser version of `filter_valid`, use this function if you still 185 | -- want the `cokeline-{switch,focus}-{prev,next}` mappings to work for 186 | -- these buffers without displaying them in your bufferline. 187 | -- default: `false`. 188 | ---@type false | fun(buf: Buffer):boolean 189 | filter_visible = false, 190 | 191 | -- Which buffer to focus when a buffer is deleted, `prev` focuses the 192 | -- buffer to the left of the deleted one while `next` focuses the one the 193 | -- right. 194 | -- default: 'next'. 195 | focus_on_delete = 'prev' | 'next', 196 | 197 | -- If set to `last` new buffers are added to the end of the bufferline, 198 | -- if `next` they are added next to the current buffer. 199 | -- if set to `directory` buffers are sorted by their full path. 200 | -- if set to `number` buffers are sorted by bufnr, as in default Neovim 201 | -- default: 'last'. 202 | ---@type 'last' | 'next' | 'directory' | 'number' | fun(a: Buffer, b: Buffer):boolean 203 | new_buffers_position = 'last', 204 | 205 | -- If true, right clicking a buffer will close it 206 | -- The close button will still work normally 207 | -- Default: true 208 | ---@type boolean 209 | delete_on_right_click = true, 210 | }, 211 | 212 | mappings = { 213 | -- Controls what happens when the first (last) buffer is focused and you 214 | -- try to focus/switch the previous (next) buffer. If `true` the last 215 | -- (first) buffers gets focused/switched, if `false` nothing happens. 216 | -- default: `true`. 217 | ---@type boolean 218 | cycle_prev_next = true, 219 | 220 | -- Disables mouse mappings 221 | -- default: `false`. 222 | ---@type boolean 223 | disable_mouse = false, 224 | }, 225 | 226 | -- Maintains a history of focused buffers using a ringbuffer 227 | history = { 228 | ---@type boolean 229 | enabled = true, 230 | ---The number of buffers to save in the history 231 | ---@type integer 232 | size = 2 233 | }, 234 | 235 | rendering = { 236 | -- The maximum number of characters a rendered buffer is allowed to take 237 | -- up. The buffer will be truncated if its width is bigger than this 238 | -- value. 239 | -- default: `999`. 240 | ---@type integer 241 | max_buffer_width = 999, 242 | }, 243 | 244 | pick = { 245 | -- Whether to use the filename's first letter first before 246 | -- picking a letter from the valid letters list in order. 247 | -- default: `true` 248 | ---@type boolean 249 | use_filename = true, 250 | 251 | -- The list of letters that are valid as pick letters. Sorted by 252 | -- keyboard reachability by default, but may require tweaking for 253 | -- non-QWERTY keyboard layouts. 254 | -- default: `'asdfjkl;ghnmxcvbziowerutyqpASDFJKLGHNMXCVBZIOWERTYQP'` 255 | ---@type string 256 | letters = 'asdfjkl;ghnmxcvbziowerutyqpASDFJKLGHNMXCVBZIOWERTYQP', 257 | }, 258 | 259 | -- The default highlight group values. 260 | -- The `fg`, `bg`, and `sp` keys are either colors in hexadecimal format or 261 | -- functions taking a `buffer` parameter and returning a color in 262 | -- hexadecimal format. Style attributes work the same way, but functions 263 | -- should return boolean values. 264 | default_hl = { 265 | -- default: `ColorColumn`'s background color for focused buffers, 266 | -- `Normal`'s foreground color for unfocused ones. 267 | ---@type nil | string | fun(buffer: Buffer): string 268 | fg = function(buffer) 269 | local hlgroups = require("cokeline.hlgroups") 270 | return buffer.is_focused and hlgroups.get_hl_attr("ColorColumn", "bg") 271 | or hlgroups.get_hl_attr("Normal", "fg") 272 | end, 273 | 274 | -- default: `Normal`'s foreground color for focused buffers, 275 | -- `ColorColumn`'s background color for unfocused ones. 276 | -- default: `Normal`'s foreground color. 277 | ---@type nil | string | function(buffer: Buffer): string, 278 | bg = function(buffer) 279 | local hlgroups = require("cokeline.hlgroups") 280 | return buffer.is_focused and hlgroups.get_hl_attr("Normal", "fg") 281 | or hlgroups.get_hl_attr("ColorColumn", "bg") 282 | end, 283 | 284 | -- default: unset. 285 | ---@type nil | string | function(buffer): string, 286 | sp = nil, 287 | 288 | ---@type nil | boolean | fun(buf: Buffer):boolean 289 | bold = nil, 290 | ---@type nil | boolean | fun(buf: Buffer):boolean 291 | italic = nil, 292 | ---@type nil | boolean | fun(buf: Buffer):boolean 293 | underline = nil, 294 | ---@type nil | boolean | fun(buf: Buffer):boolean 295 | undercurl = nil, 296 | ---@type nil | boolean | fun(buf: Buffer):boolean 297 | strikethrough = nil, 298 | }, 299 | 300 | -- The highlight group used to fill the tabline space 301 | fill_hl = 'TabLineFill', 302 | 303 | -- A list of components to be rendered for each buffer. Check out the section 304 | -- below explaining what this value can be set to. 305 | -- default: see `/lua/cokeline/config.lua` 306 | ---@type Component[] 307 | components = {}, 308 | 309 | -- Custom areas can be displayed on the right hand side of the bufferline. 310 | -- They act identically to buffer components, except their methods don't take a Buffer object. 311 | -- If you want a rhs component to be stateful, you can wrap it in a closure containing state. 312 | ---@type Component[] | false 313 | rhs = {}, 314 | 315 | -- Tabpages can be displayed on either the left or right of the bufferline. 316 | -- They act the same as other components, except they are passed TabPage objects instead of 317 | -- buffer objects. 318 | ---@type table | false 319 | tabs = { 320 | placement = "left" | "right", 321 | ---@type Component[] 322 | components = {} 323 | }, 324 | 325 | -- Left sidebar to integrate nicely with file explorer plugins. 326 | -- This is a table containing a `filetype` key and a list of `components` to 327 | -- be rendered in the sidebar. 328 | -- The last component will be automatically space padded if necessary 329 | -- to ensure the sidebar and the window below it have the same width. 330 | ---@type table | false 331 | sidebar = { 332 | ---@type string | string[] 333 | filetype = { "NvimTree", "neo-tree", "SidebarNvim" }, 334 | ---@type Component[] 335 | components = {}, 336 | }, 337 | }) 338 | < 339 | 340 | 341 | SO WHAT’S FUNCTION(BUFFER)? ~ 342 | 343 | Some of the configuration options can be functions that take a `Buffer` 344 | as a single parameter. 345 | This is useful as it allows users to set the values of components dynamically 346 | based on the buffer that component is being rendered for. 347 | 348 | The `Buffer` type is just a Lua table with the following keys: 349 | 350 | >lua 351 | Buffer = { 352 | -- The buffer's order in the bufferline (1 for the first buffer, 2 for the 353 | -- second one, etc.). 354 | index = int, 355 | 356 | -- The buffer's internal number as reported by `:ls`. 357 | number = int, 358 | 359 | ---@type boolean 360 | is_focused = false, 361 | 362 | ---@type boolean 363 | is_modified = false, 364 | 365 | ---@type boolean 366 | is_readonly = false, 367 | 368 | -- The buffer is the first visible buffer in the tab bar 369 | ---@type boolean 370 | is_first = false, 371 | 372 | -- The buffer is the last visible buffer in the tab bar 373 | ---@type boolean 374 | is_last = false, 375 | 376 | -- The mouse is hovering over the current component in the buffer 377 | -- This is a special variable in that it will only be true for the hovered *component* 378 | -- on render. This is to allow components to respond to hover events individually without managing 379 | -- component state. 380 | ---@type boolean 381 | is_hovered = false, 382 | 383 | -- The mouse is hovering over the buffer (true for all components) 384 | ---@type boolean 385 | buf_hovered = false, 386 | 387 | -- The buffer's type as reported by `:echo &buftype`. 388 | ---@type string 389 | ---@type string 390 | type = '', 391 | 392 | -- The buffer's filetype as reported by `:echo &filetype`. 393 | ---@type string 394 | filetype = '', 395 | 396 | -- The buffer's full path. 397 | ---@type string 398 | path = '', 399 | 400 | -- The buffer's filename. 401 | ---@type string 402 | filename = 'string', 403 | 404 | -- A unique prefix used to distinguish buffers with the same filename 405 | -- stored in different directories. For example, if we have two files 406 | -- `bar/foo.md` and `baz/foo.md`, then the first will have `bar/` as its 407 | -- unique prefix and the second one will have `baz/`. 408 | ---@type string 409 | unique_prefix = '', 410 | 411 | -- The letter that is displayed when picking a buffer to either focus or 412 | -- close it. 413 | ---@type string 414 | pick_letter = 'char', 415 | 416 | -- This needs the `nvim-tree/nvim-web-devicons` plugin to be installed. 417 | devicon = { 418 | -- An icon representing the buffer's filetype. 419 | ---@type string 420 | icon = 'string', 421 | 422 | -- The colors of the devicon in hexadecimal format (useful to be passed 423 | -- to a component's `fg` field (see the `Components` section). 424 | color = '#rrggbb', 425 | }, 426 | 427 | -- The values in this table are the ones reported by Neovim's built in 428 | -- LSP interface. 429 | diagnostics = { 430 | ---@type integer 431 | errors = 0, 432 | ---@type integer 433 | warnings = 0, 434 | ---@type integer 435 | infos = 0, 436 | ---@type integer 437 | hints = 0, 438 | }, 439 | } 440 | < 441 | 442 | It also has methods that can be used in component event handlers: 443 | 444 | >lua 445 | ---@param self Buffer 446 | ---Deletes the buffer 447 | function Buffer:delete() end 448 | 449 | ---@param self Buffer 450 | ---Focuses the buffer 451 | function Buffer:focus() end 452 | 453 | ---@param self Buffer 454 | ---@return number 455 | ---Returns the number of lines in the buffer 456 | function Buffer:lines() end 457 | 458 | ---@param self Buffer 459 | ---@return string[] 460 | ---Returns the buffer's lines 461 | function Buffer:text() end 462 | 463 | ---@param buf Buffer 464 | ---@return boolean 465 | ---Returns true if the buffer is valid 466 | function Buffer:is_valid() end 467 | < 468 | 469 | 470 | WHAT ABOUT TABPAGES? ~ 471 | 472 | Each method on a tab component is passed a `TabPage` object as an argument. 473 | 474 | `TabPage`, like `Buffer`, is simply a Lua table with some relevant data 475 | attached. 476 | 477 | >lua 478 | TabPage = { 479 | -- The tabpage number, as reported by `nvim_list_tabpages` 480 | ---@type integer 481 | number = 0, 482 | -- A list of Window objects contained in the TabPage (see wiki for more info) 483 | ---@type Window[] 484 | windows = {}, 485 | -- The currently focused window in the TabPage 486 | ---@type Window 487 | focused = nil, 488 | -- True if the TabPage is the current TabPage 489 | ---@type boolean 490 | is_active = true, 491 | -- True if the TabPage is first in the list 492 | ---@type boolean 493 | is_first = false, 494 | -- True if the TabPage is last in the list 495 | ---@type boolean 496 | is_last = false 497 | } 498 | < 499 | 500 | 501 | AND COMPONENTS? ~ 502 | 503 | You can configure what each buffer in your bufferline will be composed of by 504 | passing a list of components to the `setup` function. 505 | 506 | For example, let’s imagine we want to construct a very minimal bufferline 507 | where the only things we’re displaying for each buffer are its index, its 508 | filename and a close button. 509 | 510 | Then in our `setup` function we’d have: 511 | 512 | >lua 513 | require('cokeline').setup({ 514 | -- ... 515 | components = { 516 | { 517 | text = function(buffer) return ' ' .. buffer.index end, 518 | }, 519 | { 520 | text = function(buffer) return ' ' .. buffer.filename .. ' ' end, 521 | }, 522 | { 523 | text = '󰅖', 524 | on_click = function(_, _, _, _, buffer) 525 | buffer:delete() 526 | end 527 | }, 528 | { 529 | text = ' ', 530 | } 531 | } 532 | }) 533 | < 534 | 535 | in this case every buffer would be composed of four components: the first 536 | displaying a space followed by the buffer index, the second one the filename 537 | padded by a space on each side, then a close button that allows us to 538 | `:bdelete` the buffer by left-clicking on it, and finally an extra space. 539 | 540 | This way of dividing each buffer into distinct components, combined with the 541 | ability to define every component’s text and color depending on some property 542 | of the buffer we’re rendering, allows for great customizability. 543 | 544 | Every component passed to the `components` list has to be a table of the form: 545 | 546 | >lua 547 | { 548 | 549 | ---@type string | fun(buffer: Buffer): string 550 | text = "", 551 | 552 | -- The foreground, backgrond and style of the component 553 | ---@type nil | string | fun(buffer: Buffer): string 554 | fg = '#rrggbb', 555 | ---@type nil | string | fun(buffer: Buffer): string 556 | bg = '#rrggbb', 557 | ---@type nil | string | fun(buffer: Buffer): string 558 | sp = '#rrggbb', 559 | ---@type nil | boolean | fun(buffer: Buffer): boolean 560 | bold = false, 561 | ---@type nil | boolean | fun(buffer: Buffer): boolean 562 | italic = false, 563 | ---@type nil | boolean | fun(buffer: Buffer): boolean 564 | underline = false, 565 | ---@type nil | boolean | fun(buffer: Buffer): boolean 566 | undercurl = false, 567 | ---@type nil | boolean | fun(buffer: Buffer): boolean 568 | strikethrough = false, 569 | 570 | -- Or, alternatively, the name of the highlight group 571 | ---@type nil | string | fun(buffer: Buffer): string 572 | highlight = nil, 573 | 574 | -- If `true` the buffer will be deleted when this component is 575 | -- left-clicked (usually used to implement close buttons, overrides `on_click`). 576 | -- deprecated, it is recommended to use the Buffer:delete() method in an on_click event 577 | -- to implement close buttons instead. 578 | ---@type boolean 579 | delete_buffer_on_left_click = false, 580 | 581 | -- Handles click event for a component 582 | -- If not set, component will have the default click behavior 583 | -- buffer is a Buffer object, not a bufnr 584 | ---@type nil | fun(idx: integer, clicks: integer, button: string, mods: string, buffer: Buffer) 585 | on_click = nil, 586 | 587 | -- Called on a component when hovered 588 | ---@type nil | function(buffer: Buffer, mouse_col: integer) 589 | on_mouse_enter = nil, 590 | 591 | -- Called on a component when unhovered 592 | ---@type nil | function(buffer: Buffer, mouse_col: integer) 593 | on_mouse_leave = nil, 594 | 595 | truncation = { 596 | -- default: index of the component in the `components` table (1 for the 597 | -- first component, 2 for the second, etc.). 598 | ---@type integer 599 | priority = 1, 600 | 601 | -- default: `right`. 602 | ---@type 'left' | 'middle' | 'right' 603 | direction = 'left' | 'middle' | 'right', 604 | }, 605 | } 606 | < 607 | 608 | the `text` key is the only one that has to be set, all the others are optional 609 | and can be omitted. 610 | 611 | The `truncation` table controls what happens when a buffer is too long to be 612 | displayed in its entirety. 613 | 614 | More specifically, if a buffer’s width (given by the sum of the widths of all 615 | its components) is bigger than the `rendering.max_buffer_width` config option, 616 | the buffer will be truncated. 617 | 618 | The default behaviour is truncate the buffer by dropping components from right 619 | to left, with the text of the last component that’s included also being 620 | shortened from right to left. This can be modified by changing the values of 621 | the `truncation.priority` and `truncation.direction` keys. 622 | 623 | The `truncation.priority` controls the order in which components are dropped: 624 | the first component to be dropped will be the one with the lowest priority. If 625 | that’s still not enough to bring the width of the buffer within the 626 | `rendering.max_buffer_width` limit, the component with the second lowest 627 | priority will be dropped, and so on. Note that a higher priority means a 628 | smaller integer value: a component with a priority of 5 will be dropped _after_ 629 | a component with a priority of 6, even though 6 > 5. 630 | 631 | The `truncation.direction` key simply controls from which direction a component 632 | is shortened. For example, you might want to set the `truncation.direction` of 633 | a component displaying a filename to `'middle'` or `'left'`, so that if the 634 | filename has to be shortened you’ll still be able to see its extension, like 635 | in the following example (where it’s set to `'left'`): 636 | 637 | 638 | WHAT ABOUT HISTORY? 639 | 640 | The History keeps track of the buffers you access using a ringbuffer, and 641 | provides an API for accessing Buffer objects from the history. 642 | 643 | You can access the history using `require("cokeline.history")`, or through the 644 | global `_G.cokeline.history`. 645 | 646 | The `History` object provides these methods: 647 | 648 | >lua 649 | History = {} 650 | 651 | ---Adds a Buffer object to the history 652 | ---@type bufnr integer 653 | function History:push(bufnr) 654 | end 655 | 656 | ---Removes and returns the oldest Buffer object in the history 657 | ---@return Buffer? 658 | function History:pop() 659 | end 660 | 661 | ---Returns a list of Buffer objects in the history, 662 | ---ordered from oldest to newest 663 | ---@return Buffer[] 664 | function History:list() 665 | end 666 | 667 | ---Returns an iterator of Buffer objects in the history, 668 | ---ordered from oldest to newest 669 | ---@return fun(): Buffer? 670 | function History:iter() 671 | end 672 | 673 | ---Get a Buffer object by history index 674 | ---@param idx integer 675 | ---@return Buffer? 676 | function History:get(idx) 677 | end 678 | 679 | ---Get a Buffer object representing the last-accessed buffer (before the current one) 680 | ---@return Buffer? 681 | function History:last() 682 | end 683 | 684 | ---Returns true if the history is empty 685 | ---@return boolean 686 | function History:is_empty() 687 | end 688 | 689 | ---Returns the maximum number of buffers that can be stored in the history 690 | ---@return integer 691 | function History:capacity() 692 | end 693 | 694 | ---Returns true if the history contains the given buffer 695 | ---@param bufnr integer 696 | ---@return boolean 697 | function History:contains(bufnr) 698 | end 699 | 700 | ---Returns the number of buffers in the history 701 | ---@return integer 702 | function History:len() 703 | end 704 | < 705 | 706 | 707 | MAPPINGS *cokeline-mappings* 708 | 709 | You can use the `mappings` module to create mappings from Lua: 710 | 711 | >lua 712 | vim.keymap.set("n", "bp", function() 713 | require('cokeline.mappings').pick("focus") 714 | end, { desc = "Pick a buffer to focus" }) 715 | < 716 | 717 | Alternatively, we expose the following `` mappings which can be used as 718 | the right hand side of other mappings: 719 | 720 | > 721 | -- Focus the previous/next buffer 722 | (cokeline-focus-prev) 723 | (cokeline-focus-next) 724 | 725 | -- Switch the position of the current buffer with the previous/next buffer. 726 | (cokeline-switch-prev) 727 | (cokeline-switch-next) 728 | 729 | -- Focuses the buffer with index `i`. 730 | (cokeline-focus-i) 731 | 732 | -- Switches the position of the current buffer with the buffer of index `i`. 733 | (cokeline-switch-i) 734 | 735 | -- Focus a buffer by its `pick_letter`. 736 | (cokeline-pick-focus) 737 | 738 | -- Close a buffer by its `pick_letter`. 739 | (cokeline-pick-close) 740 | < 741 | 742 | A possible configuration could be: 743 | 744 | >lua 745 | local map = vim.api.nvim_set_keymap 746 | 747 | map("n", "", "(cokeline-focus-prev)", { silent = true }) 748 | map("n", "", "(cokeline-focus-next)", { silent = true }) 749 | map("n", "p", "(cokeline-switch-prev)", { silent = true }) 750 | map("n", "n", "(cokeline-switch-next)", { silent = true }) 751 | 752 | for i = 1, 9 do 753 | map( 754 | "n", 755 | (""):format(i), 756 | ("(cokeline-focus-%s)"):format(i), 757 | { silent = true } 758 | ) 759 | map( 760 | "n", 761 | ("%s"):format(i), 762 | ("(cokeline-switch-%s)"):format(i), 763 | { silent = true } 764 | ) 765 | end 766 | < 767 | 768 | Generated by panvimdoc 769 | 770 | vim:tw=78:ts=8:noet:ft=help:norl: 771 | -------------------------------------------------------------------------------- /lua/cokeline/augroups.lua: -------------------------------------------------------------------------------- 1 | local lazy = require("cokeline.lazy") 2 | local state = lazy("cokeline.state") 3 | local buffers = lazy("cokeline.buffers") 4 | local tabs = lazy("cokeline.tabs") 5 | local config = lazy("cokeline.config") 6 | 7 | local cmd = vim.cmd 8 | local filter = vim.tbl_filter 9 | local fn = vim.fn 10 | local opt = vim.opt 11 | 12 | local toggle = function() 13 | local listed_buffers = fn.getbufinfo({ buflisted = 1 }) 14 | opt.showtabline = #listed_buffers > 0 and 2 or 0 15 | end 16 | 17 | local bufnr_to_close 18 | 19 | local close_bufnr = function() 20 | if bufnr_to_close then 21 | cmd("b" .. bufnr_to_close) 22 | bufnr_to_close = nil 23 | end 24 | end 25 | 26 | ---@param bufnr number 27 | local remember_bufnr = function(bufnr) 28 | if #state.valid_buffers == 1 then 29 | return 30 | end 31 | 32 | local deleted_buffer = filter(function(buffer) 33 | return buffer.number == bufnr 34 | end, state.valid_buffers)[1] 35 | 36 | -- Neogit buffers do some weird stuff like closing themselves on buffer 37 | -- change and seem to cause problems. We just ignore them. 38 | -- See https://github.com/noib3/nvim-cokeline/issues/43 39 | if 40 | not deleted_buffer or deleted_buffer.filename:find("Neogit", nil, true) 41 | then 42 | return 43 | end 44 | 45 | local target_index 46 | 47 | if config.buffers.focus_on_delete == "prev" then 48 | target_index = deleted_buffer._valid_index ~= 1 49 | and deleted_buffer._valid_index - 1 50 | or 2 51 | elseif config.buffers.focus_on_delete == "next" then 52 | target_index = deleted_buffer._valid_index ~= #state.valid_buffers 53 | and deleted_buffer._valid_index + 1 54 | or #state.valid_buffers - 1 55 | end 56 | 57 | bufnr_to_close = state.valid_buffers[target_index].number 58 | end 59 | 60 | local setup = function() 61 | local autocmd, augroup = 62 | vim.api.nvim_create_autocmd, vim.api.nvim_create_augroup 63 | 64 | local group = augroup("cokeline_autocmds", { clear = true }) 65 | 66 | -- Invalidate the cache on colorscheme change 67 | autocmd("ColorScheme", { 68 | group = group, 69 | callback = function() 70 | require("cokeline.hlgroups")._cache_clear() 71 | end, 72 | }) 73 | 74 | autocmd({ "VimEnter", "BufAdd" }, { 75 | group = group, 76 | callback = function() 77 | require("cokeline.augroups").toggle() 78 | end, 79 | }) 80 | autocmd({ "BufDelete", "BufWipeout" }, { 81 | group = group, 82 | callback = function(args) 83 | require("cokeline.buffers").release_taken_letter(args.buf) 84 | end, 85 | }) 86 | if config.history.enabled then 87 | autocmd("BufLeave", { 88 | group = group, 89 | callback = function(args) 90 | if vim.api.nvim_buf_is_valid(args.buf) then 91 | require("cokeline.history"):push(args.buf) 92 | end 93 | end, 94 | }) 95 | end 96 | if config.tabs then 97 | autocmd({ "TabNew", "TabClosed" }, { 98 | group = group, 99 | callback = function() 100 | tabs.fetch_tabs() 101 | end, 102 | }) 103 | autocmd("WinEnter", { 104 | group = group, 105 | callback = function() 106 | local win = vim.api.nvim_get_current_win() 107 | local tab = vim.api.nvim_win_get_tabpage(win) 108 | if not state.tab_lookup[tab] then 109 | tabs.fetch_tabs() 110 | return 111 | end 112 | tabs.update_current(tab) 113 | for _, w in ipairs(state.tab_lookup[tab].windows) do 114 | if w.number == win then 115 | state.tab_lookup[tab].focused = w 116 | break 117 | end 118 | end 119 | end, 120 | }) 121 | autocmd("BufEnter", { 122 | group = group, 123 | callback = function(args) 124 | local win = vim.api.nvim_get_current_win() 125 | local tab = vim.api.nvim_win_get_tabpage(win) 126 | if not state.tab_lookup[tab] then 127 | tabs.fetch_tabs() 128 | return 129 | end 130 | for _, w in ipairs(state.tab_lookup[tab].windows) do 131 | if w.number == win then 132 | w.buffer = buffers.get_buffer(args.buf) 133 | break 134 | end 135 | end 136 | end, 137 | }) 138 | end 139 | end 140 | 141 | return { 142 | close_bufnr = close_bufnr, 143 | remember_bufnr = remember_bufnr, 144 | setup = setup, 145 | toggle = toggle, 146 | } 147 | -------------------------------------------------------------------------------- /lua/cokeline/buffers.lua: -------------------------------------------------------------------------------- 1 | local lazy = require("cokeline.lazy") 2 | local config = lazy("cokeline.config") 3 | local state = lazy("cokeline.state") 4 | 5 | local has_devicons, rq_devicons = pcall(require, "nvim-web-devicons") 6 | 7 | local concat = table.concat 8 | local sort = table.sort 9 | 10 | local bo = vim.bo 11 | local cmd = vim.cmd 12 | local diagnostic = vim.diagnostic or vim.lsp.diagnostic 13 | local fn = vim.fn 14 | local split = vim.split 15 | 16 | local util = lazy("cokeline.utils") 17 | local iter = require("plenary.iterators").iter 18 | 19 | ---@type bufnr 20 | local current_valid_index 21 | 22 | ---@type string? 23 | local valid_pick_letters 24 | 25 | local taken_pick_letters = {} 26 | local taken_pick_indices = {} 27 | local buf_order = {} 28 | 29 | local M = {} 30 | 31 | ---@alias valid_index number 32 | ---@alias index number 33 | ---@alias bufnr number 34 | 35 | ---@class Buffer 36 | ---@field _valid_index valid_index 37 | ---@field index index 38 | ---@field number bufnr 39 | ---@field type string 40 | ---@field is_focused boolean 41 | ---@field is_modified boolean 42 | ---@field is_readonly boolean 43 | ---@field is_hovered boolean Whether the current component is hovered 44 | ---@field buf_hovered boolean Whether any component in the buffer is hovered 45 | ---@field path string 46 | ---@field unique_prefix string 47 | ---@field filename string 48 | ---@field filetype string 49 | ---@field pick_letter string 50 | ---@field devicon table 51 | ---@field diagnostics table 52 | local Buffer = {} 53 | Buffer.__index = Buffer 54 | 55 | ---@param buffers Iterator|table 56 | ---@return Iterator|table 57 | local compute_unique_prefixes = function(buffers) 58 | local is_windows = fn.has("win32") == 1 59 | 60 | local path_separator = not is_windows and "/" or "\\" 61 | 62 | local prefixes = {} 63 | local paths = {} 64 | buffers = buffers 65 | :map(function(buffer) 66 | prefixes[#prefixes + 1] = {} 67 | paths[#paths + 1] = fn.reverse( 68 | split( 69 | is_windows and buffer.path:gsub("/", "\\") or buffer.path, 70 | path_separator 71 | ) 72 | ) 73 | return buffer 74 | end) 75 | :tolist() 76 | 77 | for i = 1, #paths do 78 | for j = i + 1, #paths do 79 | local k = 1 80 | while paths[i][k] == paths[j][k] and paths[i][k] do 81 | k = k + 1 82 | prefixes[i][k - 1] = prefixes[i][k - 1] or paths[i][k] 83 | prefixes[j][k - 1] = prefixes[j][k - 1] or paths[j][k] 84 | end 85 | if k ~= 1 then 86 | prefixes[i][k - 1] = prefixes[i][k - 1] or paths[i][k] 87 | prefixes[j][k - 1] = prefixes[j][k - 1] or paths[j][k] 88 | end 89 | end 90 | end 91 | 92 | return iter(buffers):enumerate():map(function(i, buffer) 93 | buffer.unique_prefix = concat({ 94 | #prefixes[i] == #paths[i] and path_separator or "", 95 | ---@diagnostic disable-next-line: param-type-mismatch 96 | fn.join(fn.reverse(prefixes[i]), path_separator), 97 | #prefixes[i] > 0 and path_separator or "", 98 | }) 99 | return i, buffer 100 | end) 101 | end 102 | 103 | ---@param filename string 104 | ---@param bufnr bufnr 105 | ---@return string 106 | local get_pick_letter = function(filename, bufnr) 107 | local first_valid = 1 108 | 109 | -- Initialize the valid letters string, if not already initialized 110 | if not valid_pick_letters then 111 | valid_pick_letters = config.pick.letters 112 | end 113 | 114 | -- If the bufnr has already a letter associated to it return that. 115 | if taken_pick_letters[bufnr] then 116 | return taken_pick_letters[bufnr] 117 | end 118 | 119 | -- If the config option pick.use_filename is true, and the initial letter 120 | -- of the filename is valid and it hasn't already been assigned return that. 121 | if config.pick.use_filename and filename ~= "" then 122 | local init_letter = vim.fn.strcharpart(filename, 0, 1) or "" 123 | local idx = valid_pick_letters:find(init_letter, nil, true) 124 | 125 | if idx == nil or taken_pick_indices[idx] then 126 | while 127 | taken_pick_indices[first_valid] 128 | and first_valid <= vim.fn.strcharlen(valid_pick_letters) 129 | do 130 | first_valid = first_valid + 1 131 | end 132 | idx = first_valid 133 | end 134 | if idx then 135 | local letter = vim.fn.strcharpart(valid_pick_letters, idx - 1, 1) 136 | if letter and letter ~= "" then 137 | taken_pick_letters[bufnr] = letter 138 | taken_pick_indices[idx] = true 139 | 140 | return letter 141 | end 142 | end 143 | end 144 | 145 | -- Return the first valid letter if there is one. 146 | while 147 | taken_pick_indices[first_valid] 148 | and first_valid <= vim.fn.strcharlen(valid_pick_letters) 149 | do 150 | first_valid = first_valid + 1 151 | end 152 | if first_valid <= vim.fn.strcharlen(valid_pick_letters) then 153 | local letter = vim.fn.strcharpart(valid_pick_letters, first_valid - 1, 1) 154 | taken_pick_letters[bufnr] = letter 155 | taken_pick_indices[first_valid] = true 156 | return letter 157 | end 158 | 159 | -- Finally, just return a '?' (this is rarely reached, you'd need to have 160 | -- opened 54 buffers in the same session). 161 | return "?" 162 | end 163 | 164 | ---@param path string 165 | ---@param filename string 166 | ---@param buftype string 167 | ---@param filetype string 168 | ---@return table 169 | local get_devicon = function(path, filename, buftype, filetype) 170 | local name = (buftype == "terminal") and "terminal" or filename 171 | 172 | local extn = fn.fnamemodify(path, ":e") 173 | local icon, color = 174 | rq_devicons.get_icon_color(name, extn, { default = false }) 175 | if not icon then 176 | icon, color = 177 | rq_devicons.get_icon_color_by_filetype(filetype, { default = true }) 178 | end 179 | 180 | return { 181 | icon = icon .. " ", 182 | color = color, 183 | } 184 | end 185 | 186 | ---@param bufnr bufnr 187 | ---@return table 188 | local get_diagnostics = function(bufnr) 189 | local diagnostics = { 190 | errors = 0, 191 | warnings = 0, 192 | infos = 0, 193 | hints = 0, 194 | } 195 | 196 | for _, d in ipairs(diagnostic.get(bufnr)) do 197 | if d.severity == 1 then 198 | diagnostics.errors = diagnostics.errors + 1 199 | elseif d.severity == 2 then 200 | diagnostics.warnings = diagnostics.warnings + 1 201 | elseif d.severity == 3 then 202 | diagnostics.infos = diagnostics.infos + 1 203 | elseif d.severity == 4 then 204 | diagnostics.hints = diagnostics.hints + 1 205 | end 206 | end 207 | 208 | return diagnostics 209 | end 210 | 211 | ---@param b table 212 | ---@return Buffer 213 | Buffer.new = function(b) 214 | local opts = bo[b.bufnr] 215 | 216 | local number = b.bufnr 217 | local buftype = opts.buftype 218 | local path = b.name 219 | 220 | local filename = (type == "quickfix" and "quickfix") 221 | or (#path > 0 and fn.fnamemodify(path, ":t")) 222 | ---@cast filename string 223 | 224 | local filetype = not (b.variables and b.variables.netrw_browser_active) 225 | and opts.filetype 226 | or "netrw" 227 | 228 | local pick_letter = get_pick_letter(filename or "", number) 229 | 230 | local devicon = has_devicons 231 | and get_devicon(path, filename, buftype, filetype) 232 | or { icon = "", color = "" } 233 | 234 | return setmetatable({ 235 | _valid_index = buf_order[b.bufnr] or -1, 236 | index = -1, 237 | number = b.bufnr, 238 | type = opts.buftype, 239 | is_focused = (b.bufnr == vim.api.nvim_get_current_buf()), 240 | is_first = false, 241 | is_last = false, 242 | is_modified = opts.modified, 243 | is_readonly = opts.readonly, 244 | is_hovered = false, 245 | buf_hovered = false, 246 | path = b.name, 247 | unique_prefix = "", 248 | filename = filename or "[No Name]", 249 | filetype = filetype, 250 | pick_letter = pick_letter, 251 | devicon = devicon, 252 | diagnostics = get_diagnostics(number), 253 | }, Buffer) 254 | end 255 | 256 | ---@param self Buffer 257 | ---Deletes the buffer 258 | function Buffer:delete() 259 | util.buf_delete(self.number) 260 | vim.cmd.redrawtabline() 261 | end 262 | 263 | ---@param self Buffer 264 | ---Focuses the buffer 265 | function Buffer:focus() 266 | vim.api.nvim_set_current_buf(self.number) 267 | end 268 | 269 | ---@param self Buffer 270 | ---@return number 271 | ---Returns the number of lines in the buffer 272 | function Buffer:lines() 273 | return vim.api.nvim_buf_line_count(self.number) 274 | end 275 | 276 | ---@param self Buffer 277 | ---@return string[] 278 | ---Returns the buffer's lines 279 | function Buffer:text() 280 | return vim.api.nvim_buf_get_lines(self.number, 0, -1, false) 281 | end 282 | 283 | ---@return boolean 284 | ---Returns true if the buffer is valid 285 | function Buffer:is_valid() 286 | return vim.api.nvim_buf_is_valid(self.number) 287 | end 288 | 289 | ---@param buffer Buffer 290 | ---@return boolean 291 | local is_old = function(buffer) 292 | for _, buf in pairs(state.valid_buffers) do 293 | if buffer.number == buf.number then 294 | return true 295 | end 296 | end 297 | return false 298 | end 299 | 300 | ---@param buffer Buffer 301 | ---@return boolean 302 | local is_new = function(buffer) 303 | return not is_old(buffer) 304 | end 305 | 306 | ---Sorter used to open new buffers at the end of the bufferline. 307 | ---@param buffer1 Buffer 308 | ---@param buffer2 Buffer 309 | ---@return boolean 310 | local sort_by_new_after_last = function(buffer1, buffer2) 311 | if is_old(buffer1) and is_old(buffer2) then 312 | return buffer1._valid_index < buffer2._valid_index 313 | elseif is_old(buffer1) and is_new(buffer2) then 314 | return true 315 | elseif is_new(buffer1) and is_old(buffer2) then 316 | return false 317 | else 318 | return buffer1.number < buffer2.number 319 | end 320 | end 321 | 322 | ---Sorter used to open new buffers next to the current buffer. 323 | ---@param buffer1 Buffer 324 | ---@param buffer2 Buffer 325 | ---@return boolean 326 | local sort_by_new_after_current = function(buffer1, buffer2) 327 | if is_old(buffer1) and is_old(buffer2) then 328 | -- If both buffers are either before or after (inclusive) the current 329 | -- buffer, respect the current order. 330 | if 331 | (buffer1._valid_index - current_valid_index) 332 | * (buffer2._valid_index - current_valid_index) 333 | >= 0 334 | then 335 | return buffer1._valid_index < buffer2._valid_index 336 | end 337 | return buffer1._valid_index < current_valid_index 338 | elseif is_old(buffer1) and is_new(buffer2) then 339 | return buffer1._valid_index <= current_valid_index 340 | elseif is_new(buffer1) and is_old(buffer2) then 341 | return current_valid_index < buffer2._valid_index 342 | else 343 | return buffer1.number < buffer2.number 344 | end 345 | end 346 | 347 | ---Sorter used to order buffers by full path. 348 | ---@param buffer1 Buffer 349 | ---@param buffer2 Buffer 350 | ---@return boolean 351 | local sort_by_directory = function(buffer1, buffer2) 352 | return buffer1.path < buffer2.path 353 | end 354 | 355 | ---Sorted used to order buffers by number, as in the default tabline. 356 | ---@param buffer1 Buffer 357 | ---@param buffer2 Buffer 358 | ---@return boolean 359 | local sort_by_number = function(buffer1, buffer2) 360 | return buffer1.number < buffer2.number 361 | end 362 | 363 | ---@param bufnr bufnr 364 | ---@return nil 365 | function M.release_taken_letter(bufnr) 366 | if taken_pick_letters[bufnr] then 367 | local idx = (valid_pick_letters or ""):find(taken_pick_letters[bufnr]) 368 | if idx then 369 | taken_pick_indices[idx] = nil 370 | taken_pick_letters[bufnr] = nil 371 | end 372 | end 373 | end 374 | 375 | ---@param buffer Buffer 376 | ---@param target_valid_index valid_index 377 | function M.move_buffer(buffer, target_valid_index) 378 | if buffer._valid_index == target_valid_index then 379 | return 380 | end 381 | 382 | buf_order[buffer.number] = target_valid_index 383 | 384 | if buffer._valid_index < target_valid_index then 385 | for index = (buffer._valid_index + 1), target_valid_index do 386 | buf_order[state.valid_buffers[index].number] = index - 1 387 | end 388 | else 389 | for index = target_valid_index, (buffer._valid_index - 1) do 390 | buf_order[state.valid_buffers[index].number] = index + 1 391 | end 392 | end 393 | 394 | cmd("redrawtabline") 395 | end 396 | 397 | ---@return Buffer[] 398 | function M.get_valid_buffers() 399 | if not buf_order then 400 | buf_order = {} 401 | end 402 | 403 | local info = fn.getbufinfo({ buflisted = 1 }) 404 | ---@type Iterator|table 405 | local buffers = iter(info):map(Buffer.new):filter(function(buffer) 406 | return buffer.filetype ~= "netrw" 407 | end) 408 | 409 | if config.buffers.filter_valid then 410 | ---@type Iterator|table 411 | buffers = buffers:filter(config.buffers.filter_valid) 412 | end 413 | 414 | ---@type Iterator|table 415 | buffers = compute_unique_prefixes(buffers) 416 | 417 | if current_valid_index == nil then 418 | buf_order = {} 419 | buffers = buffers 420 | :map(function(i, buffer) 421 | buffer._valid_index = i 422 | buf_order[buffer.number] = buffer._valid_index 423 | if buffer.is_focused then 424 | current_valid_index = i 425 | end 426 | return buffer 427 | end) 428 | :tolist() 429 | else 430 | buffers = buffers 431 | :map(function(_, buf) 432 | return buf 433 | end) 434 | :tolist() 435 | end 436 | if not current_valid_index then 437 | current_valid_index = 0 438 | end 439 | 440 | if type(config.buffers.new_buffers_position) == "function" then 441 | sort(buffers, config.buffers.new_buffers_position) 442 | elseif config.buffers.new_buffers_position == "last" then 443 | sort(buffers, sort_by_new_after_last) 444 | elseif config.buffers.new_buffers_position == "next" then 445 | sort(buffers, sort_by_new_after_current) 446 | elseif config.buffers.new_buffers_position == "directory" then 447 | sort(buffers, sort_by_directory) 448 | elseif config.buffers.new_buffers_position == "number" then 449 | sort(buffers, sort_by_number) 450 | end 451 | 452 | buf_order = {} 453 | for i, buffer in ipairs(buffers) do 454 | buffer._valid_index = i 455 | buf_order[buffer.number] = buffer._valid_index 456 | if buffer.is_focused then 457 | current_valid_index = i 458 | end 459 | end 460 | 461 | return buffers 462 | end 463 | 464 | ---@return Buffer[] 465 | function M.get_visible() 466 | state.valid_buffers = M.get_valid_buffers() 467 | state.valid_lookup = {} 468 | 469 | local bufs = iter(state.valid_buffers):map(function(buffer) 470 | state.valid_lookup[buffer.number] = buffer 471 | return buffer 472 | end) 473 | 474 | if config.buffers.filter_visible then 475 | bufs = bufs:filter(config.buffers.filter_visible) 476 | end 477 | 478 | bufs = bufs:enumerate():map(function(i, buf) 479 | buf.index = i 480 | return buf 481 | end) 482 | 483 | if not config.pick.use_filename then 484 | bufs = bufs:map(function(buf) 485 | buf.pick_letter = get_pick_letter(buf.filename, buf.number) 486 | return buf 487 | end) 488 | end 489 | 490 | state.visible_buffers = bufs:tolist() 491 | 492 | if #state.visible_buffers > 0 then 493 | state.visible_buffers[1].is_first = true 494 | state.visible_buffers[#state.visible_buffers].is_last = true 495 | end 496 | 497 | return state.visible_buffers 498 | end 499 | 500 | ---Get Buffer 501 | ---@param bufnr number 502 | ---@return Buffer|nil 503 | function M.get_buffer(bufnr) 504 | return require("cokeline.state").valid_lookup[bufnr] 505 | end 506 | 507 | ---Wrapper around `vim.api.nvim_get_current_buf`, returns Buffer object 508 | ---@return Buffer|nil 509 | function M.get_current() 510 | local bufnr = vim.api.nvim_get_current_buf() 511 | if bufnr then 512 | return M.get_buffer(bufnr) 513 | end 514 | end 515 | 516 | ---@param bufnr bufnr 517 | ---@return boolean 518 | function M.is_visible(bufnr) 519 | local buf = M.get_buffer(bufnr) 520 | if not buf then 521 | return false 522 | end 523 | if config.buf.filter_valid and not config.buffers.filter_valid(buf) then 524 | return false 525 | end 526 | if 527 | config.buffers.filter_visible 528 | and not config.buffers.filter_visible(buf) 529 | then 530 | return false 531 | end 532 | return true 533 | end 534 | 535 | M.Buffer = Buffer 536 | 537 | return M 538 | -------------------------------------------------------------------------------- /lua/cokeline/components.lua: -------------------------------------------------------------------------------- 1 | local lazy = require("cokeline.lazy") 2 | local Hlgroup = require("cokeline.hlgroups").Hlgroup 3 | local config = lazy("cokeline.config") 4 | 5 | local rep = string.rep 6 | local insert = table.insert 7 | local remove = table.remove 8 | 9 | local iter = require("plenary.iterators").iter 10 | local fn = vim.fn 11 | 12 | ---@generic Cx 13 | ---@class Component 14 | ---@field index number 15 | ---@field text string|fun(cx: Cx): string 16 | ---@field style string|fun(cx: Cx): string 17 | ---@field fg string|fun(cx: Cx): string 18 | ---@field bg string|fun(cx: Cx): string 19 | ---@field highlight string|fun(cx: Cx): string 20 | ---@field on_click ClickHandler | nil 21 | ---@field on_mouse_enter MouseEnterHandler | nil 22 | ---@field on_mouse_leave MouseLeaveHandler | nil 23 | ---@field delete_buffer_on_left_click boolean Use the component as a close button () 24 | ---@field truncation table 25 | ---@field idx number 26 | ---@field bufnr bufnr 27 | ---@field width number 28 | ---@field hlgroup Hlgroup 29 | local Component = {} 30 | Component.__index = Component 31 | 32 | ---@generic Cx 33 | ---@param c table | Component 34 | ---@param i number 35 | ---@return Component 36 | Component.new = function(c, i, default_hl) 37 | local function attr(name, default) 38 | if c[name] ~= nil then 39 | return c[name] 40 | end 41 | if default_hl[name] ~= nil then 42 | return default_hl[name] 43 | end 44 | return default 45 | end 46 | -- `default_hl` is `nil` when called by `components.lua#63` 47 | default_hl = default_hl or config.default_hl 48 | local component = { 49 | index = i, 50 | text = c.text, 51 | fg = attr("fg", "NONE"), 52 | bg = attr("bg", "NONE"), 53 | sp = attr("sp", "NONE"), 54 | bold = attr("bold"), 55 | italic = attr("italic"), 56 | underline = attr("underline"), 57 | undercurl = attr("undercurl"), 58 | strikethrough = attr("strikethrough"), 59 | highlight = c.highlight, 60 | delete_buffer_on_left_click = c.delete_buffer_on_left_click or false, 61 | on_click = c.on_click, 62 | on_mouse_enter = c.on_mouse_enter, 63 | on_mouse_leave = c.on_mouse_leave, 64 | truncation = { 65 | priority = c.truncation and c.truncation.priority or i, 66 | direction = c.truncation and c.truncation.direction or "right", 67 | }, 68 | -- These values aren't ready yet, they will be once the component gets 69 | -- rendered for a specific buffer. 70 | width = nil, 71 | bufnr = nil, 72 | hlgroup = nil, 73 | kind = c.kind or "buffer", 74 | } 75 | setmetatable(component, Component) 76 | return component 77 | end 78 | 79 | -- Renders a component for a specific buffer. 80 | ---@generic Cx 81 | ---@param self Component 82 | ---@param context RenderContext 83 | ---@return Component 84 | Component.render = function(self, context) 85 | ---@return string 86 | local evaluate = function(field) 87 | if field == nil then 88 | return 89 | end 90 | if type(field) == "function" then 91 | return field(context.provider) 92 | end 93 | return field 94 | end 95 | 96 | local component = vim.deepcopy(self) 97 | component.text = evaluate(self.text) 98 | component.width = fn.strwidth(component.text) 99 | 100 | if 101 | context.kind == "buffer" 102 | or context.kind == "sidebar" 103 | or context.kind == "tab" 104 | then 105 | component.bufnr = context.provider.number 106 | end 107 | 108 | if component.highlight then 109 | component.hlgroup = Hlgroup.new_existing(evaluate(component.highlight)) 110 | else 111 | -- `evaluate(self.hl.*)` might return `nil`, in that case we fallback to the 112 | -- default highlight first and to NONE if that's `nil` too. 113 | -- local style = evaluate(self.style) 114 | -- or evaluate(config.default_hl.style) 115 | -- or "NONE" 116 | local fg = evaluate(self.fg) or evaluate(config.default_hl.fg) or "NONE" 117 | local bg = evaluate(self.bg) or evaluate(config.default_hl.bg) or "NONE" 118 | local sp = evaluate(self.sp) or evaluate(config.default_hl.sp) or "NONE" 119 | local attrs = {} 120 | attrs.bold = evaluate(self.bold) or evaluate(config.default_hl.bold) 121 | attrs.italic = evaluate(self.italic) or evaluate(config.default_hl.italic) 122 | attrs.underline = evaluate(self.underline) 123 | or evaluate(config.default_hl.underline) 124 | attrs.undercurl = evaluate(self.undercurl) 125 | or evaluate(config.default_hl.undercurl) 126 | attrs.strikethrough = evaluate(self.strikethrough) 127 | or evaluate(config.default_hl.strikethrough) 128 | 129 | component.hlgroup = Hlgroup.new( 130 | ("Cokeline_%s_%s"):format(component.bufnr or context.kind, self.index), 131 | fg, 132 | bg, 133 | sp, 134 | attrs 135 | ) 136 | end 137 | 138 | return component 139 | end 140 | 141 | ---@generic Cx 142 | ---@param self Component 143 | ---@param to_width number 144 | ---@param direction '"left"' | '"right"' | nil 145 | Component.shorten = function(self, to_width, direction) 146 | -- If a direction is given that means we're cutting off a component that's 147 | -- at the edge of the bufferline (either the left or the right one). In this 148 | -- case we either prepend or append a space to the ellipses. 149 | -- 150 | -- If a direction isn't given we're shortening a component within a buffer. 151 | -- Here we use that component's `truncation.direction`, and we don't add any 152 | -- additional spaces. 153 | if direction then 154 | local available_width = to_width - 2 155 | local start = direction == "left" and self.width - available_width or 0 156 | self.text = (direction == "left" and " …%s" or "%s… "):format( 157 | fn.strcharpart(self.text, start, available_width) 158 | ) 159 | elseif self.truncation.direction == "middle" then 160 | local available_width = to_width - 1 161 | local width_left = math.floor(available_width / 2) 162 | local width_right = width_left + available_width % 2 163 | self.text = ("%s…%s"):format( 164 | fn.strcharpart(self.text, 0, width_left), 165 | fn.strcharpart(self.text, self.width - width_right, width_right) 166 | ) 167 | else 168 | direction = self.truncation.direction 169 | local available_width = to_width - 1 170 | local start = direction == "left" and self.width - available_width or 0 171 | self.text = (direction == "left" and "…%s" or "%s…"):format( 172 | fn.strcharpart(self.text, start, available_width) 173 | ) 174 | end 175 | 176 | -- `fn.strcharpart` can fail with wide characters. For example, 177 | -- `fn.strcharpart("|", 0, 1)` will still return "|" since that character 178 | -- takes up two columns. This is to handle such cases. 179 | if fn.strwidth(self.text) ~= to_width then 180 | local fmt = (direction == "left" and "%s…") 181 | or (direction == "right" and "…%s") 182 | or "%s " 183 | self.text = fmt:format(rep(" ", to_width ~= nil and to_width - 1 or 0)) 184 | end 185 | 186 | self.width = fn.strwidth(self.text) 187 | end 188 | 189 | ---@param components Component[] 190 | ---@return number 191 | local width_of_components = function(components) 192 | return iter(components):fold(0, function(a, c) 193 | return a + c.width 194 | end) 195 | end 196 | 197 | -- Takes a list of components, returns a new list of components `to_width` wide 198 | -- obtained by trimming the original `components` in the given `direction`. 199 | ---@generic Cx 200 | ---@param components Component[] 201 | ---@param to_width number 202 | ---@param direction '"left"' | '"right"' | nil 203 | ---@return Component[] 204 | local shorten_components = function(components, to_width, direction) 205 | if #components == 0 then return components end 206 | 207 | local current_width = width_of_components(components) 208 | 209 | -- `extra` is the width of the extra characters that are appended when a 210 | -- component is shortened, an ellipses if we're shortening within a buffer 211 | -- (in which case the `direction` is `nil`), or an ellipses and a space if 212 | -- we're cutting off a component at the edge of the bufferline (where 213 | -- `direction` is either `"left"` or `"right"`). 214 | local extra = direction and 2 or 1 215 | local i = direction == "left" and 1 or #components 216 | -- We start from the full list of components and remove components as 217 | -- necessary. We could start from an empty list and add until there's space, 218 | -- but I think there's usually more components to keep than to throw away, so 219 | -- this should be faster in most cases. 220 | local last_removed_component 221 | while 222 | (current_width > to_width) 223 | or (current_width - components[i].width + extra > to_width) 224 | do 225 | local removed = remove(components, i) 226 | if removed then 227 | last_removed_component = removed 228 | current_width = current_width - last_removed_component.width 229 | i = direction == "left" and 1 or i - 1 230 | if #components == 0 then 231 | break 232 | end 233 | else 234 | break 235 | end 236 | end 237 | 238 | if 239 | last_removed_component 240 | and (to_width - current_width > extra or #components == 0) 241 | then 242 | i = direction == "left" and 1 or i + 1 243 | last_removed_component:shorten(to_width - current_width, direction) 244 | insert(components, i, last_removed_component) 245 | else 246 | -- Here we "shorten" a component to more than its current width. 247 | components[i]:shorten( 248 | components[i].width + to_width - current_width, 249 | direction 250 | ) 251 | end 252 | 253 | return components 254 | end 255 | 256 | -- Takes in a list of components, returns the concatenation of their rendered 257 | -- strings. 258 | ---@generic Cx 259 | ---@param components Component[] 260 | ---@return string 261 | local render_components = function(components) 262 | local embed = function(component) 263 | local text = component.hlgroup:embed(component.text) 264 | if not fn.has("tablineat") then 265 | return text 266 | else 267 | local on_click 268 | if component.delete_buffer_on_left_click then 269 | on_click = string.format( 270 | "v:lua.require'cokeline.handlers'.close_%d", 271 | component.bufnr 272 | ) 273 | else 274 | on_click = string.format( 275 | "v:lua.require'cokeline.handlers'.click_%s_%d_%d", 276 | component.kind, 277 | component.index, 278 | component.bufnr or 0 279 | ) 280 | end 281 | return ("%%%s@%s@%s%%X"):format(component.index, on_click, text) 282 | end 283 | end 284 | 285 | local concat = function(a, b) 286 | return a .. b 287 | end 288 | 289 | return iter(components):map(embed):fold("", concat) 290 | end 291 | 292 | return { 293 | Component = Component, 294 | render = render_components, 295 | shorten = shorten_components, 296 | width = width_of_components, 297 | } 298 | -------------------------------------------------------------------------------- /lua/cokeline/config.lua: -------------------------------------------------------------------------------- 1 | local lazy = require("cokeline.lazy") 2 | ---@module cokeline.state 3 | local state = lazy("cokeline.state") 4 | ---@module "cokeline.sliders" 5 | local sliders = lazy("cokeline.sliders") 6 | ---@module "cokeline.hlgroups" 7 | local hlgroups = lazy("cokeline.hlgroups") 8 | 9 | local insert = table.insert 10 | 11 | local echo = vim.api.nvim_echo 12 | local islist = vim.islist or vim.tbl_islist 13 | 14 | local defaults = { 15 | show_if_buffers_are_at_least = 1, 16 | 17 | buffers = { 18 | filter_valid = false, 19 | filter_visible = false, 20 | focus_on_delete = "next", 21 | new_buffers_position = "last", 22 | delete_on_right_click = true, 23 | }, 24 | 25 | mappings = { 26 | cycle_prev_next = true, 27 | disable_mouse = false, 28 | }, 29 | 30 | history = { 31 | enabled = true, 32 | size = 2, 33 | }, 34 | 35 | rendering = { 36 | max_buffer_width = 999, 37 | slider = sliders.center_current_buffer, 38 | }, 39 | 40 | pick = { 41 | use_filename = true, 42 | letters = "asdfjkl;ghnmxcvbziowerutyqpASDFJKLGHNMXCVBZIOWERUTYQP", 43 | }, 44 | 45 | default_hl = { 46 | fg = function(buffer) 47 | return buffer.is_focused and "TabLineSel" or "TabLine" 48 | end, 49 | bg = function(buffer) 50 | return buffer.is_focused and "TabLineSel" or "TabLine" 51 | end, 52 | }, 53 | 54 | fill_hl = "TabLineFill", 55 | 56 | components = { 57 | { 58 | text = function(buffer) 59 | return " " .. buffer.devicon.icon 60 | end, 61 | fg = function(buffer) 62 | return buffer.devicon.color 63 | end, 64 | }, 65 | { 66 | text = function(buffer) 67 | return buffer.unique_prefix 68 | end, 69 | fg = function() 70 | return hlgroups.get_hl_attr("Comment", "fg") 71 | end, 72 | italic = true, 73 | }, 74 | { 75 | text = function(buffer) 76 | return buffer.filename 77 | end, 78 | underline = function(buffer) 79 | if buffer.is_hovered and not buffer.is_focused then 80 | return true 81 | end 82 | end, 83 | }, 84 | { 85 | text = " ", 86 | }, 87 | { 88 | ---@param buffer Buffer 89 | text = function(buffer) 90 | if buffer.is_modified then 91 | return "" 92 | end 93 | if buffer.is_hovered then 94 | return "󰅙" 95 | end 96 | return "󰅖" 97 | end, 98 | on_click = function(_, _, _, _, buffer) 99 | buffer:delete() 100 | end, 101 | }, 102 | { 103 | text = " ", 104 | }, 105 | }, 106 | 107 | tabs = { 108 | placement = "right", 109 | components = {}, 110 | }, 111 | 112 | rhs = {}, 113 | 114 | sidebar = { 115 | filetype = { "NvimTree", "neo-tree", "SidebarNvim" }, 116 | components = {}, 117 | }, 118 | } 119 | 120 | -- Formats an error message. 121 | ---@param msg string 122 | local echoerr = function(msg) 123 | echo({ 124 | { "[nvim-cokeline]: ", "ErrorMsg" }, 125 | { 'Configuration option "' }, 126 | { msg, "WarningMsg" }, 127 | { '" does not exist!' }, 128 | }, true, {}) 129 | end 130 | 131 | local config = vim.deepcopy(defaults) 132 | 133 | -- Updates the `settings` table with options from `preferences`, printing an 134 | -- error message if a configuration option in `preferences` is not defined in 135 | -- `settings`. 136 | ---@param settings table 137 | ---@param preferences table 138 | ---@param key string | nil 139 | ---@return table 140 | local function update(settings, preferences, key) 141 | local updated = settings 142 | for k, v in pairs(preferences) do 143 | local key_tree = key and ("%s.%s"):format(key, k) or k 144 | if settings[k] == nil and key ~= "default_hl" then 145 | echoerr(key_tree) 146 | elseif type(v) == "table" and not islist(v) then 147 | updated[k] = update(settings[k], v, key_tree) 148 | else 149 | updated[k] = v 150 | end 151 | end 152 | return updated 153 | end 154 | 155 | local setup = function(opts) 156 | config = update(defaults, opts) 157 | state.components = {} 158 | state.rhs = {} 159 | state.sidebar = {} 160 | state.tabs = {} 161 | local Component = lazy("cokeline.components").Component 162 | if opts.buffers and opts.buffers.new_buffers_position then 163 | if not config.buffers then 164 | config.buffers = {} 165 | end 166 | config.buffers.new_buffers_position = opts.buffers.new_buffers_position 167 | end 168 | local id = 1 169 | for _, component in ipairs(config.components) do 170 | local new_component = Component.new(component, id, opts.default_hl) 171 | insert(state.components, new_component) 172 | if new_component.on_click ~= nil then 173 | require("cokeline.handlers").click:register(id, new_component.on_click) 174 | end 175 | id = id + 1 176 | end 177 | if opts.rhs then 178 | for _, component in ipairs(config.rhs) do 179 | component.kind = "rhs" 180 | local new_component = Component.new(component, id, opts.default_hl) 181 | insert(state.rhs, new_component) 182 | if new_component.on_click ~= nil then 183 | require("cokeline.handlers").click:register(id, new_component.on_click) 184 | end 185 | id = id + 1 186 | end 187 | end 188 | if config.sidebar and config.sidebar.components then 189 | for _, component in ipairs(config.sidebar.components) do 190 | component.kind = "sidebar" 191 | local new_component = Component.new(component, id, opts.default_hl) 192 | insert(state.sidebar, new_component) 193 | if new_component.on_click ~= nil then 194 | require("cokeline.handlers").click:register(id, new_component.on_click) 195 | end 196 | id = id + 1 197 | end 198 | end 199 | if config.tabs and config.tabs.components then 200 | for _, component in ipairs(config.tabs.components) do 201 | component.kind = "tab" 202 | local new_component = Component.new(component, id, opts.default_hl) 203 | insert(state.tabs, new_component) 204 | if new_component.on_click ~= nil then 205 | require("cokeline.handlers").click:register(id, new_component.on_click) 206 | end 207 | id = id + 1 208 | end 209 | end 210 | return config 211 | end 212 | 213 | return setmetatable({ 214 | setup = setup, 215 | get = function() 216 | return config 217 | end, 218 | }, { 219 | __index = function(_, k) 220 | return config[k] 221 | end, 222 | __newindex = function(_, k, v) 223 | config[k] = v 224 | end, 225 | }) 226 | -------------------------------------------------------------------------------- /lua/cokeline/context.lua: -------------------------------------------------------------------------------- 1 | ---@alias ContextKind "buffer" | "tab" | "rhs" | "sidebar" 2 | 3 | ---@class RenderContext 4 | ---@field kind ContextKind 5 | ---@field provider Buffer | TabPage | RhsContext 6 | local RenderContext = {} 7 | 8 | ---@param tab TabPage 9 | function RenderContext:tab(tab) 10 | return { 11 | provider = tab, 12 | kind = "tab", 13 | } 14 | end 15 | 16 | ---@param rhs RhsContext 17 | function RenderContext:rhs(rhs) 18 | return { 19 | provider = rhs, 20 | kind = "rhs", 21 | } 22 | end 23 | 24 | ---@param buffer Buffer 25 | function RenderContext:buffer(buffer) 26 | return { 27 | provider = buffer, 28 | kind = "buffer", 29 | } 30 | end 31 | 32 | return RenderContext 33 | -------------------------------------------------------------------------------- /lua/cokeline/handlers.lua: -------------------------------------------------------------------------------- 1 | local lazy = require("cokeline.lazy") 2 | local config = lazy("cokeline.config") 3 | local utils = lazy("cokeline.utils") 4 | local buffers = lazy("cokeline.buffers") 5 | local tabs = lazy("cokeline.tabs") 6 | 7 | ---@alias ClickHandler fun(button_id: number, clicks: number, button: string, modifiers: string, cx: table) 8 | ---@alias MouseEnterHandler fun(cx: table) 9 | ---@alias MouseLeaveHandler fun(cx: table) 10 | ---@alias Handler ClickHandler | MouseEnterHandler | MouseLeaveHandler 11 | ---@alias WrappedHandler fun(cx: table): Handler 12 | 13 | ---@class Handlers Singleton event handler manager 14 | ---@field click (fun(cx: table): Handler)[] 15 | ---@field private private table 16 | local Handlers = { 17 | click = {}, 18 | private = { 19 | click = {}, 20 | }, 21 | } 22 | 23 | ---@param kind string 24 | ---@param idx number 25 | local function unregister(kind, idx) 26 | ---@diagnostic disable: cast-local-type 27 | if idx == nil then 28 | idx = kind 29 | kind = nil 30 | end 31 | rawset(Handlers.private[kind], idx, nil) 32 | end 33 | 34 | ---Register a click handler 35 | ---@param idx number 36 | ---@param handler ClickHandler 37 | function Handlers.click:register(idx, handler) 38 | rawset(Handlers.private.click, idx, function(buffer) 39 | return function(minwid, clicks, button, modifiers) 40 | return handler(minwid, clicks, button, modifiers, buffer) 41 | end 42 | end) 43 | end 44 | 45 | ---Unregister a click handler 46 | ---@param idx number 47 | function Handlers.click:unregister(idx) 48 | unregister("click", idx) 49 | end 50 | 51 | ---Default click handler 52 | ---@param cx table 53 | ---@param kind string 54 | local function default_click(cx, kind) 55 | return function(_, _, button) 56 | if button == "l" then 57 | if kind == "tab" or kind == "buffer" then 58 | cx:focus() 59 | end 60 | elseif 61 | kind == "buffer" 62 | and button == "r" 63 | and config.buffers.delete_on_right_click 64 | then 65 | utils.buf_delete(cx.number, config.buffers.focus_on_delete) 66 | end 67 | end 68 | end 69 | 70 | ---Default close handler 71 | ---@param buffer bufnr 72 | local function default_close(buffer) 73 | return function(_, _, button) 74 | if button == "l" then 75 | utils.buf_delete(buffer.number, config.buffers.focus_on_delete) 76 | end 77 | end 78 | end 79 | 80 | --- Public handlers interface 81 | return setmetatable({}, { 82 | __index = function(_, key) 83 | --- Retrieve a helper for registering events 84 | local helper = rawget(Handlers, key) 85 | if helper ~= nil then 86 | return key ~= "private" and helper or nil 87 | end 88 | 89 | --- If there's no helper, evaluate the key to get a handler 90 | local prefix = string.sub(key, 1, 5) 91 | if prefix == "click" then 92 | local kind, id, number = string.match(key, "click_(%a+)_(%d+)_(%d+)") 93 | if number == nil then 94 | return 95 | end 96 | local cx 97 | if kind == "tab" then 98 | cx = tabs.get_tabpage(tonumber(number)) 99 | else 100 | cx = buffers.get_buffer(tonumber(number)) 101 | end 102 | 103 | if id ~= nil then 104 | local handler = Handlers.private["click"][tonumber(id)] 105 | return handler and handler(cx) or default_click(cx, kind) 106 | else 107 | return default_click(cx, kind) 108 | end 109 | elseif prefix == "close" then 110 | local bufnr = string.match(key, "close_(%d+)") 111 | if bufnr then 112 | local buffer = buffers.get_buffer(tonumber(bufnr)) 113 | return default_close(buffer) 114 | end 115 | end 116 | return nil 117 | end, 118 | __newindex = function() 119 | vim.api.nvim_err_writeln("Cannot set fields in cokeline.handlers") 120 | end, 121 | }) 122 | -------------------------------------------------------------------------------- /lua/cokeline/history.lua: -------------------------------------------------------------------------------- 1 | local lazy = require("cokeline.lazy") 2 | local buffers = lazy("cokeline.buffers") 3 | 4 | ---@class Cokeline.History 5 | ---@field _len integer 6 | ---@field _cap integer 7 | ---@field _data bufnr[] 8 | local History = { 9 | _len = 0, 10 | _cap = 4, 11 | _data = {}, 12 | _read = 1, 13 | _write = 1, 14 | } 15 | 16 | ---Creates a new History object 17 | ---@return Cokeline.History 18 | function History.setup(cap) 19 | History._cap = cap 20 | return History 21 | end 22 | 23 | ---Adds a Buffer object to the history 24 | ---@param bufnr bufnr 25 | function History:push(bufnr) 26 | self._data[self._write] = bufnr 27 | self._write = (self._write % self._cap) + 1 28 | if self._len < self._cap then 29 | self._len = self._len + 1 30 | end 31 | end 32 | 33 | ---Removes and returns the oldest Buffer object in the history 34 | ---@return Buffer|nil 35 | function History:pop() 36 | if self._len == 0 then 37 | return nil 38 | end 39 | local bufnr = self._data[self._read] 40 | self._data[self._read] = nil 41 | self._read = (self._read % self._cap) + 1 42 | if bufnr then 43 | self._len = self._len - 1 44 | end 45 | if bufnr then 46 | return buffers.get_buffer(bufnr) 47 | end 48 | end 49 | 50 | ---Returns a list of Buffer objects in the history, 51 | ---ordered from oldest to newest 52 | ---@return Buffer[] 53 | function History:list() 54 | local list = {} 55 | local read = self._read 56 | for _ = 1, self._len do 57 | local buf = self._data[read] 58 | if buf then 59 | table.insert(list, buffers.get_buffer(buf)) 60 | end 61 | read = (read % self._cap) + 1 62 | end 63 | return list 64 | end 65 | 66 | ---Returns an iterator of Buffer objects in the history, 67 | ---ordered from oldest to newest 68 | ---@return fun():Buffer? 69 | function History:iter() 70 | local read = self._read 71 | return function() 72 | local buf = self._data[read] 73 | if buf then 74 | read = read + 1 75 | return buffers.get_buffer(buf) 76 | end 77 | end 78 | end 79 | 80 | ---Get a Buffer object by history index 81 | ---@param idx integer 82 | ---@return Buffer|nil 83 | function History:get(idx) 84 | local buf = self._data[(self._read % self._cap) + idx + 1] 85 | if buf then 86 | return buffers.get_buffer(buf) 87 | end 88 | end 89 | 90 | ---Get a Buffer object representing the last-accessed buffer (before the current one) 91 | ---@return Buffer|nil 92 | function History:last() 93 | local buf = self._data[self._write == 1 and self._len or (self._write - 1)] 94 | if buf then 95 | return buffers.get_buffer(buf) 96 | end 97 | end 98 | 99 | ---Returns true if the history is empty 100 | ---@return boolean 101 | function History:is_empty() 102 | return self._read == self._write 103 | end 104 | 105 | ---Returns the maximum number of buffers that can be stored in the history 106 | ---@return integer 107 | function History:capacity() 108 | return self._cap 109 | end 110 | 111 | ---Returns true if the history contains the given buffer 112 | ---@param bufnr bufnr 113 | ---@return boolean 114 | function History:contains(bufnr) 115 | return vim.tbl_contains(self._data, bufnr) 116 | end 117 | 118 | ---Returns the number of buffers in the history 119 | ---@return integer 120 | function History:len() 121 | return self._len 122 | end 123 | 124 | return History 125 | -------------------------------------------------------------------------------- /lua/cokeline/hlgroups.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | -- These functions are called a LOT. 4 | -- Cache the results to avoid repeat API calls 5 | local cache = { 6 | hex = {}, 7 | groups = {}, 8 | } 9 | 10 | ---Clears the hlgroup cache. 11 | ---@private 12 | function M._cache_clear() 13 | cache.groups = {} 14 | end 15 | 16 | ---@param rgb integer 17 | ---@return string hex 18 | function M.hex(rgb) 19 | if cache.hex[rgb] then 20 | return cache.hex[rgb] 21 | end 22 | local band, lsr = bit.band, bit.rshift 23 | 24 | local r = lsr(band(rgb, 0xff0000), 16) 25 | local g = lsr(band(rgb, 0x00ff00), 8) 26 | local b = band(rgb, 0x0000ff) 27 | 28 | local res = ("#%02x%02x%02x"):format(r, g, b) 29 | cache.hex[rgb] = res 30 | return res 31 | end 32 | 33 | function M.get_hl(name) 34 | if cache.groups[name] then 35 | return cache.groups[name] 36 | end 37 | local hl = vim.api.nvim_get_hl(0, { name = name }) 38 | if not hl then 39 | return 40 | end 41 | if hl.fg and type(hl.fg) == "number" then 42 | hl.fg = M.hex(hl.fg) 43 | end 44 | if hl.bg and type(hl.bg) == "number" then 45 | hl.bg = M.hex(hl.bg) 46 | end 47 | if hl.sp and type(hl.sp) == "number" then 48 | hl.sp = M.hex(hl.sp) 49 | end 50 | cache.groups[name] = hl 51 | return hl 52 | end 53 | 54 | function M.get_hl_attr(name, attr) 55 | if cache.groups[name] and cache.groups[name][attr] then 56 | return cache.groups[name][attr] 57 | end 58 | local hl = M.get_hl(name) 59 | if not hl then 60 | return 61 | end 62 | return hl[attr] 63 | end 64 | 65 | ---@class Hlgroup 66 | ---@field name string 67 | ---@field gui string 68 | ---@field guifg string 69 | ---@field guibg string 70 | local Hlgroup = {} 71 | Hlgroup.__index = Hlgroup 72 | 73 | ---Sets the highlight group, then returns a new `Hlgroup` table. 74 | ---@param name string 75 | ---@param fg string 76 | ---@param bg string | nil 77 | ---@param sp string | nil 78 | ---@param gui_attrs table 79 | ---@return Hlgroup 80 | Hlgroup.new = function(name, fg, bg, sp, gui_attrs) 81 | local hlgroup = {} 82 | if fg ~= "NONE" and vim.fn.hlexists(fg) == 1 then 83 | hlgroup.fg = M.get_hl_attr(fg, "fg") 84 | else 85 | hlgroup.fg = fg or "NONE" 86 | end 87 | if bg ~= "NONE" and vim.fn.hlexists(bg) == 1 then 88 | hlgroup.bg = M.get_hl_attr(bg, "bg") 89 | else 90 | hlgroup.bg = bg or "NONE" 91 | end 92 | if sp and sp ~= "NONE" and vim.fn.hlexists(sp) == 1 then 93 | hlgroup.sp = M.get_hl_attr(sp, "sp") 94 | else 95 | hlgroup.sp = sp or "NONE" 96 | end 97 | 98 | for attr, val in pairs(gui_attrs) do 99 | if type(val) == "boolean" then 100 | hlgroup[attr] = val 101 | end 102 | end 103 | 104 | hlgroup.default = false 105 | vim.api.nvim_set_hl(0, name, hlgroup) 106 | 107 | hlgroup.name = name 108 | setmetatable(hlgroup, Hlgroup) 109 | return hlgroup 110 | end 111 | 112 | ---Using already existing highlight group, returns a new `Hlgroup` table. 113 | ---@param name string 114 | ---@return Hlgroup 115 | Hlgroup.new_existing = function(name) 116 | local hlgroup = { 117 | name = name, 118 | } 119 | setmetatable(hlgroup, Hlgroup) 120 | return hlgroup 121 | end 122 | 123 | ---Embeds some text in a highlight group. 124 | ---@param self Hlgroup 125 | ---@param text string 126 | ---@return string 127 | Hlgroup.embed = function(self, text) 128 | return ("%%#%s#%s%%*"):format(self.name, text) 129 | end 130 | 131 | M.Hlgroup = Hlgroup 132 | 133 | return M 134 | -------------------------------------------------------------------------------- /lua/cokeline/hover.lua: -------------------------------------------------------------------------------- 1 | local lazy = require("cokeline.lazy") 2 | local M = {} 3 | 4 | local version = vim.version() 5 | 6 | local buffers = lazy("cokeline.buffers") 7 | local config = lazy("cokeline.config") 8 | local tabs = lazy("cokeline.tabs") 9 | local rendering = lazy("cokeline.rendering") 10 | local iter = lazy("plenary.iterators").iter 11 | local last_position = nil 12 | 13 | local hovered 14 | local dragging 15 | 16 | function M.hovered() 17 | return hovered 18 | end 19 | 20 | function M.set_hovered(val) 21 | hovered = val 22 | end 23 | 24 | function M.clear_hovered() 25 | hovered = nil 26 | end 27 | 28 | function M.dragging() 29 | return dragging 30 | end 31 | 32 | function M.set_dragging(val) 33 | dragging = val 34 | end 35 | 36 | function M.clear_dragging() 37 | dragging = nil 38 | end 39 | 40 | function M.get_current(col) 41 | local bufs = buffers.get_visible() 42 | if not bufs then 43 | return 44 | end 45 | local cx = rendering.prepare(bufs) 46 | 47 | local current_width = 0 48 | for _, component in ipairs(cx.sidebar_left) do 49 | current_width = current_width + component.width 50 | if current_width >= col then 51 | return component, cx.sidebar_left 52 | end 53 | end 54 | if config.tabs and config.tabs.placement == "left" then 55 | for _, component in ipairs(cx.tabs) do 56 | current_width = current_width + component.width 57 | if current_width >= col then 58 | return component, cx.tabs 59 | end 60 | end 61 | end 62 | for _, component in ipairs(cx.buffers) do 63 | current_width = current_width + component.width 64 | if current_width >= col then 65 | return component, cx.buffers 66 | end 67 | end 68 | current_width = current_width + cx.gap 69 | if current_width >= col then 70 | return 71 | end 72 | for _, component in ipairs(cx.rhs) do 73 | current_width = current_width + component.width 74 | if current_width >= col then 75 | return component, cx.rhs 76 | end 77 | end 78 | if config.tabs and config.tabs.placement == "right" then 79 | for _, component in ipairs(cx.tabs) do 80 | current_width = current_width + component.width 81 | if current_width >= col then 82 | return component, cx.tabs 83 | end 84 | end 85 | end 86 | for _, component in ipairs(cx.sidebar_right) do 87 | current_width = current_width + component.width 88 | if current_width >= col then 89 | return component, cx.sidebar_right 90 | end 91 | end 92 | end 93 | 94 | local function mouse_leave(component) 95 | local cx 96 | if component.kind == "buffer" then 97 | cx = buffers.get_buffer(component.bufnr) 98 | elseif component.kind == "tab" then 99 | cx = tabs.get_tabpage(component.bufnr) 100 | elseif component.kind == "sidebar" then 101 | cx = { number = component.bufnr, side = component.sidebar } 102 | else 103 | cx = {} 104 | end 105 | if cx then 106 | cx.is_hovered = false 107 | end 108 | if component.on_mouse_leave then 109 | if 110 | (component.kind ~= "buffer" and component.kind ~= "tab") or cx ~= nil 111 | then 112 | component.on_mouse_leave(cx) 113 | end 114 | end 115 | M.clear_hovered() 116 | end 117 | 118 | local function mouse_enter(component, current) 119 | local cx 120 | if component.kind == "buffer" then 121 | cx = buffers.get_buffer(component.bufnr) 122 | elseif component.kind == "tab" then 123 | cx = tabs.get_tabpage(component.bufnr) 124 | elseif component.kind == "sidebar" then 125 | cx = { number = component.bufnr, side = component.sidebar } 126 | else 127 | cx = {} 128 | end 129 | if cx then 130 | cx.is_hovered = true 131 | end 132 | if component.on_mouse_enter then 133 | if 134 | (component.kind ~= "buffer" and component.kind ~= "tab") or cx ~= nil 135 | then 136 | component.on_mouse_enter(cx, current.screencol) 137 | end 138 | end 139 | M.set_hovered({ 140 | index = component.index, 141 | bufnr = cx and cx.number, 142 | on_mouse_leave = component.on_mouse_leave, 143 | kind = component.kind, 144 | }) 145 | end 146 | 147 | local function on_hover(current) 148 | M.clear_dragging() 149 | local hovered_component = M.hovered() 150 | if vim.o.showtabline == 0 then 151 | return 152 | end 153 | if current.screenrow == 1 then 154 | if 155 | last_position 156 | and hovered_component 157 | and last_position.screencol == current.screencol 158 | then 159 | return 160 | end 161 | local component = M.get_current(current.screencol) 162 | 163 | if 164 | component 165 | and hovered_component 166 | and component.index == hovered_component.index 167 | and component.bufnr == hovered_component.bufnr 168 | then 169 | return 170 | end 171 | 172 | if hovered_component ~= nil then 173 | mouse_leave(hovered_component) 174 | end 175 | if not component then 176 | vim.cmd.redrawtabline() 177 | return 178 | end 179 | 180 | mouse_enter(component, current) 181 | vim.cmd.redrawtabline() 182 | elseif hovered_component ~= nil then 183 | mouse_leave(hovered_component) 184 | vim.cmd.redrawtabline() 185 | end 186 | last_position = current 187 | end 188 | 189 | local function width(bufs, buf) 190 | return iter(bufs) 191 | :filter(function(v) 192 | return v.bufnr == buf 193 | end) 194 | :map(function(v) 195 | return v.width 196 | end) 197 | :fold(0, function(acc, v) 198 | return acc + v 199 | end) 200 | end 201 | 202 | local function start_pos(bufs, buf) 203 | local pos = 0 204 | for _, v in ipairs(bufs) do 205 | if v.bufnr == buf then 206 | return pos 207 | end 208 | pos = pos + v.width 209 | end 210 | return pos 211 | end 212 | 213 | local function on_drag(pos) 214 | local hovered_component = M.hovered() 215 | if hovered_component then 216 | local buf = buffers.get_buffer(hovered_component.bufnr) 217 | if buf then 218 | buf.is_hovered = false 219 | end 220 | if hovered_component.kind == "buffer" then 221 | if buf and hovered_component.on_mouse_leave then 222 | hovered_component.on_mouse_leave(buf) 223 | end 224 | elseif hovered_component.on_mouse_leave then 225 | hovered_component.on_mouse_leave() 226 | end 227 | M.clear_hovered() 228 | end 229 | if pos.screenrow ~= 1 then 230 | M.clear_dragging() 231 | return 232 | end 233 | if pos.dragging == "l" then 234 | local current, bufs = M.get_current(pos.screencol) 235 | if current == nil or bufs == nil or current.kind ~= "buffer" then 236 | return 237 | end 238 | 239 | -- if we're not dragging yet or we're dragging the same buffer, start dragging 240 | if M.dragging() == current.bufnr or not M.dragging() then 241 | M.set_dragging(current.bufnr) 242 | return 243 | end 244 | 245 | -- dragged buffer 246 | local dragged_buf = buffers.get_buffer(M.dragging()) 247 | if not dragged_buf then 248 | return 249 | end 250 | 251 | -- current (hovered) buffer 252 | local cur_buf = buffers.get_buffer(current.bufnr) 253 | if not cur_buf then 254 | return 255 | end 256 | 257 | -- start position of dragged buffer 258 | local dragging_start = start_pos(bufs, M.dragging()) 259 | -- width of the current (hovered) buffer 260 | local cur_buf_width = width(bufs, current.bufnr) 261 | 262 | if 263 | -- buffer is being dragged to the left 264 | ( 265 | dragging_start > pos.screencol 266 | and pos.screencol + cur_buf_width > dragging_start 267 | ) 268 | -- buffer is being dragged to the right 269 | or dragging_start + cur_buf_width <= pos.screencol 270 | then 271 | buffers.move_buffer(dragged_buf, cur_buf._valid_index) 272 | end 273 | end 274 | end 275 | 276 | function M.mouse_pos(drag, key) 277 | drag = drag or {} 278 | local ok, pos = pcall(vim.fn.getmousepos) 279 | if not ok then 280 | return 281 | end 282 | 283 | return { 284 | dragging = type(drag) == "table" and drag[key] or drag, 285 | screencol = pos.screencol, 286 | screenrow = pos.screenrow, 287 | line = pos.line, 288 | column = pos.column, 289 | winid = pos.winid, 290 | winrow = pos.winrow, 291 | wincol = pos.wincol, 292 | } 293 | end 294 | 295 | function M.setup() 296 | if config.mappings.disable_mouse == true or version.minor < 8 then 297 | return 298 | end 299 | 300 | if version.minor >= 9 and vim.on_key and vim.keycode then 301 | local mouse_move = vim.keycode("") 302 | local drag = { 303 | [vim.keycode("")] = "l", 304 | [vim.keycode("")] = "r", 305 | [vim.keycode("")] = "m", 306 | } 307 | vim.on_key(function(k) 308 | local data = M.mouse_pos(drag, k) 309 | if data then 310 | vim.schedule_wrap(function(key) 311 | if key == mouse_move then 312 | on_hover(data) 313 | elseif drag[key] then 314 | on_drag(data) 315 | end 316 | end)(k) 317 | end 318 | end) 319 | elseif vim.o.mousemoveevent then 320 | vim.keymap.set({ "n", "" }, "", function() 321 | local data = M.mouse_pos() 322 | if data then 323 | on_hover(data) 324 | end 325 | end) 326 | vim.keymap.set({ "n", "" }, "", function() 327 | local data = M.mouse_pos("l") 328 | if data then 329 | on_drag(data) 330 | end 331 | end) 332 | end 333 | end 334 | 335 | return M 336 | -------------------------------------------------------------------------------- /lua/cokeline/init.lua: -------------------------------------------------------------------------------- 1 | local lazy = require("cokeline.lazy") 2 | local rendering = lazy("cokeline.rendering") 3 | local history = lazy("cokeline.history") 4 | local config = lazy("cokeline.config") 5 | 6 | local opt = vim.opt 7 | 8 | _G.cokeline = {} 9 | 10 | ---@param opts table|nil 11 | local setup = function(opts) 12 | config.setup(opts or {}) 13 | if config.history and config.history.enabled then 14 | history.setup(config.history.size) 15 | end 16 | 17 | require("cokeline.mappings").setup() 18 | require("cokeline.hover").setup() 19 | require("cokeline.augroups").setup() 20 | 21 | opt.showtabline = 2 22 | opt.tabline = "%!v:lua.cokeline.tabline()" 23 | end 24 | 25 | ---@return string 26 | _G.cokeline.tabline = function() 27 | local visible_buffers = require("cokeline.buffers").get_visible() 28 | if #visible_buffers < config.show_if_buffers_are_at_least then 29 | opt.showtabline = 0 30 | return "" 31 | end 32 | return rendering.render(visible_buffers, config.fill_hl) 33 | end 34 | 35 | return { 36 | setup = setup, 37 | } 38 | -------------------------------------------------------------------------------- /lua/cokeline/lazy.lua: -------------------------------------------------------------------------------- 1 | return function(module) 2 | return setmetatable({}, { 3 | __index = function(_, k) 4 | return require(module)[k] 5 | end, 6 | __newindex = function(_, k, v) 7 | require(module)[k] = v 8 | end, 9 | }) 10 | end 11 | -------------------------------------------------------------------------------- /lua/cokeline/mappings.lua: -------------------------------------------------------------------------------- 1 | local lazy = require("cokeline.lazy") 2 | local state = lazy("cokeline.state") 3 | local buffers = lazy("cokeline.buffers") 4 | 5 | local cmd = vim.cmd 6 | local filter = vim.tbl_filter 7 | local keymap = vim.keymap 8 | local set_keymap = vim.api.nvim_set_keymap 9 | local fn = vim.fn 10 | 11 | local is_picking = { 12 | focus = false, 13 | close = false, 14 | } 15 | 16 | ---@param goal '"switch"' | '"focus"' | '"close"' | fun(buf: Buffer) 17 | ---@param index index 18 | local by_index = function(goal, index) 19 | local target_buffer = filter(function(buffer) 20 | return buffer.index == index 21 | end, state.visible_buffers)[1] 22 | 23 | if not target_buffer then 24 | return 25 | end 26 | 27 | if goal == "switch" then 28 | local focused_buffer = filter(function(buffer) 29 | return buffer.is_focused 30 | end, state.valid_buffers)[1] 31 | 32 | if not focused_buffer then 33 | return 34 | end 35 | 36 | buffers.move_buffer(focused_buffer, target_buffer._valid_index) 37 | elseif goal == "focus" then 38 | target_buffer:focus() 39 | elseif goal == "close" then 40 | target_buffer:delete() 41 | elseif type(goal) == "function" then 42 | goal(target_buffer) 43 | end 44 | end 45 | 46 | ---@param goal '"switch"' | '"focus"' |'"close"' | fun(buf: Buffer, did_fallback: boolean) 47 | ---@param step -1 | 1 48 | local by_step = function(goal, step) 49 | local config = lazy("cokeline.config") 50 | local focused_buffer = filter(function(buffer) 51 | return buffer.is_focused 52 | end, state.valid_buffers)[1] 53 | 54 | local target_buf 55 | local target_idx 56 | local did_fallback = false 57 | if focused_buffer then 58 | target_idx = focused_buffer._valid_index + step 59 | if target_idx < 1 or target_idx > #state.valid_buffers then 60 | if not config.mappings.cycle_prev_next then 61 | return 62 | end 63 | target_idx = (target_idx - 1) % #state.valid_buffers + 1 64 | end 65 | target_buf = state.valid_buffers[target_idx] 66 | elseif goal == "focus" and config.history.enabled then 67 | did_fallback = true 68 | target_buf = require("cokeline.history"):last() 69 | if step < -1 then 70 | -- The result of history:last() is the index -1, 71 | -- so we need to step back one more. 72 | step = step + 1 73 | end 74 | if target_buf and step ~= 0 then 75 | local step_buf = state.valid_buffers[target_buf._valid_index + step] 76 | if step_buf then 77 | target_buf = step_buf 78 | end 79 | end 80 | else 81 | return 82 | end 83 | 84 | if target_buf then 85 | if goal == "switch" then 86 | buffers.move_buffer(focused_buffer, target_idx) 87 | elseif goal == "focus" then 88 | target_buf:focus() 89 | elseif goal == "close" and not did_fallback then 90 | target_buf:delete() 91 | elseif type(goal) == "function" then 92 | goal(target_buf, did_fallback) 93 | end 94 | end 95 | end 96 | 97 | ---@param goal '"focus"' | '"close"' | '"close-multiple"' | fun(buf: Buffer) 98 | local pick = function(goal) 99 | local close_multiple = false 100 | if goal == "close-multiple" then 101 | goal = "close" 102 | close_multiple = true 103 | end 104 | 105 | is_picking[goal] = true 106 | cmd("redrawtabline") 107 | 108 | repeat 109 | local valid_char, char = pcall(fn.getchar) 110 | if not close_multiple then 111 | is_picking[goal] = false 112 | end 113 | 114 | -- bail out on keyboard interrupt 115 | if not valid_char then 116 | char = 0 117 | end 118 | 119 | local letter = fn.nr2char(char) 120 | local target_buffer = filter(function(buffer) 121 | return buffer.pick_letter == letter 122 | end, state.visible_buffers)[1] 123 | 124 | if not target_buffer or (goal == "focus" and target_buffer.is_focused) then 125 | is_picking[goal] = false 126 | cmd("redrawtabline") 127 | return 128 | end 129 | 130 | if goal == "focus" then 131 | target_buffer:focus() 132 | elseif goal == "close" then 133 | target_buffer:delete() 134 | elseif type(goal) == "function" then 135 | goal(target_buffer) 136 | end 137 | until not is_picking[goal] 138 | end 139 | 140 | local setup = function() 141 | if keymap then 142 | keymap.set("n", "(cokeline-switch-prev)", function() 143 | by_step("switch", -1) 144 | end) 145 | 146 | keymap.set("n", "(cokeline-switch-next)", function() 147 | by_step("switch", 1) 148 | end) 149 | 150 | keymap.set("n", "(cokeline-focus-prev)", function() 151 | by_step("focus", -1) 152 | end) 153 | 154 | keymap.set("n", "(cokeline-focus-next)", function() 155 | by_step("focus", 1) 156 | end) 157 | 158 | keymap.set("n", "(cokeline-pick-focus)", function() 159 | pick("focus") 160 | end) 161 | 162 | keymap.set("n", "(cokeline-pick-close)", function() 163 | pick("close") 164 | end) 165 | 166 | keymap.set("n", "(cokeline-pick-close-multiple)", function() 167 | pick("close-multiple") 168 | end) 169 | 170 | for i = 1, 20 do 171 | keymap.set("n", ("(cokeline-switch-%s)"):format(i), function() 172 | by_index("switch", i) 173 | end, {}) 174 | 175 | keymap.set("n", ("(cokeline-focus-%s)"):format(i), function() 176 | by_index("focus", i) 177 | end, {}) 178 | end 179 | else 180 | set_keymap( 181 | "n", 182 | "(cokeline-switch-prev)", 183 | 'lua require"cokeline/mappings".by_step("switch", -1)', 184 | {} 185 | ) 186 | set_keymap( 187 | "n", 188 | "(cokeline-switch-next)", 189 | 'lua require"cokeline/mappings".by_step("switch", 1)', 190 | {} 191 | ) 192 | set_keymap( 193 | "n", 194 | "(cokeline-focus-prev)", 195 | 'lua require"cokeline/mappings".by_step("focus", -1)', 196 | {} 197 | ) 198 | set_keymap( 199 | "n", 200 | "(cokeline-focus-next)", 201 | 'lua require"cokeline/mappings".by_step("focus", 1)', 202 | {} 203 | ) 204 | set_keymap( 205 | "n", 206 | "(cokeline-pick-focus)", 207 | 'lua require"cokeline/mappings".pick("focus")', 208 | {} 209 | ) 210 | set_keymap( 211 | "n", 212 | "(cokeline-pick-close)", 213 | 'lua require"cokeline/mappings".pick("close")', 214 | {} 215 | ) 216 | set_keymap( 217 | "n", 218 | "(cokeline-pick-close-multiple)", 219 | 'lua require"cokeline/mappings".pick("close-multiple")', 220 | {} 221 | ) 222 | for i = 1, 20 do 223 | set_keymap( 224 | "n", 225 | ("(cokeline-switch-%s)"):format(i), 226 | ('lua require"cokeline/mappings".by_index("switch", %s)'):format( 227 | i 228 | ), 229 | {} 230 | ) 231 | 232 | set_keymap( 233 | "n", 234 | ("(cokeline-focus-%s)"):format(i), 235 | ('lua require"cokeline/mappings".by_index("focus", %s)'):format( 236 | i 237 | ), 238 | {} 239 | ) 240 | end 241 | end 242 | end 243 | 244 | return { 245 | by_index = by_index, 246 | by_step = by_step, 247 | is_picking_focus = function() 248 | return is_picking.focus 249 | end, 250 | is_picking_close = function() 251 | return is_picking.close 252 | end, 253 | pick = pick, 254 | setup = setup, 255 | } 256 | -------------------------------------------------------------------------------- /lua/cokeline/rendering.lua: -------------------------------------------------------------------------------- 1 | local lazy = require("cokeline.lazy") 2 | local state = lazy("cokeline.state") 3 | local config = lazy("cokeline.config") 4 | local components = lazy("cokeline.components") 5 | local sidebar = lazy("cokeline.sidebar") 6 | local rhs = lazy("cokeline.rhs") 7 | local tabs = lazy("cokeline.tabs") 8 | local RenderContext = lazy("cokeline.context") 9 | local iter = require("plenary.iterators").iter 10 | 11 | local insert = table.insert 12 | local sort = table.sort 13 | local unpack = unpack or table.unpack 14 | 15 | local extend = vim.list_extend 16 | local o = vim.o 17 | 18 | ---@type index 19 | local current_index 20 | 21 | ---@param buffers Buffer[] 22 | ---@param previous_buffer_index index 23 | ---@return Buffer 24 | local find_current_buffer = function(buffers, previous_buffer_index) 25 | local focused_buffer = iter(buffers):find(function(buffer) 26 | return buffer.is_focused 27 | end) 28 | 29 | return focused_buffer or buffers[previous_buffer_index] or buffers[#buffers] 30 | end 31 | 32 | ---@param c1 Component 33 | ---@param c2 Component 34 | ---@return boolean 35 | local by_decreasing_priority = function(c1, c2) 36 | return c1.truncation.priority < c2.truncation.priority 37 | end 38 | 39 | ---@param c1 Component 40 | ---@param c2 Component 41 | ---@return boolean 42 | local by_increasing_index = function(c1, c2) 43 | return c1.index < c2.index 44 | end 45 | 46 | -- Takes in either a single buffer or a list of buffers, and it returns a list 47 | -- of all the rendered components together with their total combined width. 48 | ---@param context Buffer|Buffer[]|TabPage|TabPage[] 49 | ---@param complist Component[] 50 | ---@return Component, number 51 | local function to_components(context, complist) 52 | local hovered = require("cokeline.hover").hovered() 53 | -- A simple heuristic to check if we're dealing with single buffer or a list 54 | -- of them is to just check if one of they keys is defined. 55 | if context.number then 56 | local cs = {} 57 | for _, c in ipairs(complist) do 58 | local render_cx 59 | if context.filetype then 60 | render_cx = RenderContext:buffer(context) 61 | render_cx.provider.buf_hovered = hovered ~= nil 62 | and hovered.bufnr == context.number 63 | else 64 | render_cx = RenderContext:tab(context) 65 | render_cx.provider.tab_hovered = hovered ~= nil 66 | and hovered.bufnr == context.number 67 | end 68 | render_cx.provider.is_hovered = hovered ~= nil 69 | and hovered.bufnr == context.number 70 | and hovered.index == c.index 71 | local rendered = c:render(render_cx) 72 | render_cx.provider.is_hovered = false 73 | if rendered.width > 0 then 74 | insert(cs, rendered) 75 | end 76 | end 77 | 78 | local width = components.width(cs) 79 | if width <= config.rendering.max_buffer_width then 80 | return cs, width 81 | else 82 | sort(cs, by_decreasing_priority) 83 | components.shorten(cs, config.rendering.max_buffer_width) 84 | sort(cs, by_increasing_index) 85 | return cs, config.rendering.max_buffer_width 86 | end 87 | else 88 | local cs = {} 89 | local width = 0 90 | for _, buffer in ipairs(context) do 91 | local buf_components, buf_width = to_components(buffer, complist) 92 | width = width + buf_width 93 | for _, component in pairs(buf_components) do 94 | insert(cs, component) 95 | end 96 | end 97 | return cs, width 98 | end 99 | end 100 | 101 | ---This is a helper function for rendering and hover handers. It takes 102 | ---the list of visible buffers, figures out which components to display, and 103 | ---returns a list of pre-render components 104 | ---@param visible_buffers Buffer[] 105 | ---@return table|string 106 | local prepare = function(visible_buffers) 107 | local sidebar_components_l = sidebar.get_components("left") 108 | local sidebar_components_r = sidebar.get_components("right") 109 | local rhs_components = rhs.get_components() 110 | local available_width = o.columns - components.width(sidebar_components_l) 111 | if available_width == 0 then 112 | return components.render(sidebar_components_l) 113 | end 114 | 115 | local tab_placement 116 | local tab_components 117 | local tabs_width 118 | if config.tabs then 119 | tab_placement = config.tabs.placement or "left" 120 | tab_components = to_components(tabs.get_tabs(), state.tabs) 121 | tabs_width = components.width(tab_components) 122 | available_width = available_width - tabs_width 123 | if available_width == 0 then 124 | return components.render(sidebar_components_l) 125 | .. components.render(tab_components) 126 | end 127 | end 128 | 129 | local current_buffer = find_current_buffer(visible_buffers, current_index) 130 | 131 | local current_components, current_width 132 | if current_buffer then 133 | current_index = current_buffer.index 134 | 135 | current_components, current_width = 136 | to_components(current_buffer, state.components) 137 | if current_width >= available_width then 138 | sort(current_components, by_decreasing_priority) 139 | components.shorten(current_components, available_width) 140 | sort(current_components, by_increasing_index) 141 | if current_buffer.index > 1 then 142 | components.shorten(current_components, available_width, "left") 143 | end 144 | if current_buffer.index < #visible_buffers then 145 | components.shorten(current_components, available_width, "right") 146 | end 147 | 148 | return { 149 | sidebar_left = sidebar_components_l, 150 | sidebar_right = sidebar_components_r, 151 | buffers = current_components, 152 | rhs = rhs_components, 153 | tabs = tab_components, 154 | gap = math.max( 155 | 0, 156 | available_width 157 | - ( 158 | components.width(current_components) 159 | + components.width(rhs_components) 160 | ) 161 | ), 162 | } 163 | end 164 | else 165 | current_components, current_width = {}, 0 166 | end 167 | 168 | local left_components, left_width 169 | local right_components, right_width 170 | if #visible_buffers > 0 then 171 | left_components, left_width = to_components({ 172 | unpack(visible_buffers, 1, current_buffer.index - 1), 173 | }, state.components) 174 | 175 | right_components, right_width = to_components({ 176 | unpack(visible_buffers, current_buffer.index + 1, #visible_buffers), 177 | }, state.components) 178 | else 179 | left_components, left_width = {}, 0 180 | right_components, right_width = {}, 0 181 | end 182 | 183 | local rhs_width = components.width(rhs_components) 184 | + components.width(sidebar_components_r) 185 | local available_width_left, available_width_right = config.rendering.slider( 186 | available_width 187 | - current_width 188 | - rhs_width 189 | - ((tabs_width ~= nil and tab_placement == "right") and tabs_width or 0), 190 | left_width, 191 | right_width 192 | ) 193 | 194 | -- If we handled left, current and right components separately we might have 195 | -- to shorten the left or right components with a `to_width` parameter of 1, 196 | -- in which case the correct behaviour would be to "shorten" the current 197 | -- buffer components instead. 198 | -- 199 | -- To avoid this, we join all the buffer components together *before* 200 | -- checking if they need to be shortened. 201 | local buffer_components = 202 | extend(extend(left_components, current_components), right_components) 203 | 204 | if left_width > available_width_left then 205 | components.shorten( 206 | buffer_components, 207 | available_width_left + current_width + right_width, 208 | "left" 209 | ) 210 | end 211 | if right_width > available_width_right then 212 | components.shorten(buffer_components, available_width - rhs_width, "right") 213 | end 214 | 215 | local bufs_width = components.width(buffer_components) 216 | return { 217 | sidebar_left = sidebar_components_l, 218 | sidebar_right = sidebar_components_r, 219 | buffers = buffer_components, 220 | rhs = rhs_components, 221 | tabs = tab_components, 222 | gap = math.max(0, available_width - (bufs_width + rhs_width)), 223 | } 224 | end 225 | 226 | ---This is the main function responsible for rendering the bufferline. It takes 227 | ---the list of visible buffers, figures out which components to display and 228 | ---returns their rendered version. 229 | ---@param visible_buffers Buffer[] 230 | ---@param fill_hl string 231 | ---@return string 232 | local render = function(visible_buffers, fill_hl) 233 | local cx = prepare(visible_buffers) 234 | local rendered = components.render(cx.sidebar_left) .. "%#" .. fill_hl .. "#" 235 | if config.tabs and config.tabs.placement == "left" then 236 | rendered = rendered .. components.render(cx.tabs) .. "%#" .. fill_hl .. "#" 237 | end 238 | rendered = "%#" 239 | .. fill_hl 240 | .. "#" 241 | .. rendered 242 | .. components.render(cx.buffers) 243 | .. "%#" 244 | .. fill_hl 245 | .. "#" 246 | .. string.rep(" ", cx.gap) 247 | .. components.render(cx.rhs) 248 | if config.tabs and config.tabs.placement == "right" then 249 | rendered = rendered .. "%#" .. fill_hl .. "#" .. components.render(cx.tabs) 250 | end 251 | rendered = rendered .. components.render(cx.sidebar_right) 252 | return rendered 253 | end 254 | 255 | return { 256 | by_decreasing_priority = by_decreasing_priority, 257 | by_increasing_index = by_increasing_index, 258 | prepare = prepare, 259 | render = render, 260 | } 261 | -------------------------------------------------------------------------------- /lua/cokeline/rhs.lua: -------------------------------------------------------------------------------- 1 | local lazy = require("cokeline.lazy") 2 | local state = lazy("cokeline.state") 3 | local components = lazy("cokeline.components") 4 | local RenderContext = lazy("cokeline.context") 5 | 6 | local M = {} 7 | 8 | ---@class RhsContext 9 | ---@field is_hovered boolean 10 | 11 | function M.get_components() 12 | local hovered = lazy("cokeline.hover").hovered() 13 | local rhs = {} 14 | for _, c in ipairs(state.rhs) do 15 | local is_hovered = hovered and hovered.index == c.index 16 | table.insert( 17 | rhs, 18 | c:render(RenderContext:rhs({ 19 | is_hovered = is_hovered, 20 | }, "rhs")) 21 | ) 22 | end 23 | return rhs 24 | end 25 | 26 | function M.width() 27 | return components.width(M.get_components()) 28 | end 29 | 30 | return M 31 | -------------------------------------------------------------------------------- /lua/cokeline/sidebar.lua: -------------------------------------------------------------------------------- 1 | local lazy = require("cokeline.lazy") 2 | local config = lazy("cokeline.config") 3 | local state = lazy("cokeline.state") 4 | local Buffer = lazy("cokeline.buffers").Buffer 5 | local components = lazy("cokeline.components") 6 | local RenderContext = lazy("cokeline.context") 7 | 8 | local min = math.min 9 | local rep = string.rep 10 | local insert = table.insert 11 | local sort = table.sort 12 | 13 | local api = vim.api 14 | local bo = vim.bo 15 | local fn = vim.fn 16 | local o = vim.o 17 | 18 | local width_cache = {} 19 | 20 | local get_win = function(side) 21 | local layout = fn.winlayout() 22 | if not layout then 23 | return 24 | end 25 | 26 | -- If the first split level is not given by vertically split windows we 27 | -- return early and invalidate the width cache. 28 | if layout[1] ~= "row" then 29 | width_cache = {} 30 | return nil 31 | end 32 | 33 | -- The second element of the `layout` table is a nested list representing the 34 | -- tree of vertically split windows in a tabpage. For example, for a layout 35 | -- like. 36 | -- +-----+-----+-----+ 37 | -- | | | C | 38 | -- | A | B |-----| 39 | -- | | | D | 40 | -- +-----+-----+-----+ 41 | -- the associated tree would be 42 | -- / | \ 43 | -- / | \ 44 | -- A B / \ 45 | -- C D 46 | -- where each leaf is represented as a `{'leaf', }` table. 47 | local window_tree = layout[2] 48 | 49 | -- Since we're checking if we need to display sidebars we're only 50 | -- interested in the first and last window splits. 51 | -- 52 | -- However, with edgy.nvim, a sidebar can contain multiple splits nested one level in. 53 | -- So if the first window found is a leaf, we check the first window of the container. 54 | local first_split 55 | local winid 56 | if side == nil or side == "left" then 57 | first_split = window_tree[1] 58 | winid = first_split[2] 59 | if first_split[1] ~= "leaf" then 60 | local win = first_split[2][1] 61 | if win and win[1] == "leaf" then 62 | winid = win[2] 63 | else 64 | width_cache[side] = nil 65 | return nil 66 | end 67 | end 68 | else 69 | first_split = window_tree[#window_tree] 70 | winid = first_split[2] 71 | if first_split[1] ~= "leaf" then 72 | local win = first_split[2][#first_split[2]] 73 | if win and win[1] == "leaf" then 74 | winid = win[2] 75 | else 76 | width_cache[side] = nil 77 | return nil 78 | end 79 | end 80 | end 81 | width_cache[side] = api.nvim_win_get_width(winid) 82 | return winid 83 | end 84 | 85 | ---@param side "left" | "right" 86 | ---@return Component[] 87 | local get_components = function(side) 88 | if not config.sidebar then 89 | return {} 90 | end 91 | 92 | local winid = get_win(side) 93 | if not winid then 94 | return {} 95 | end 96 | 97 | local bufnr = api.nvim_win_get_buf(winid) 98 | 99 | if type(config.sidebar.filetype) == "table" then 100 | if not vim.tbl_contains(config.sidebar.filetype, bo[bufnr].filetype) then 101 | return {} 102 | end 103 | else 104 | if bo[bufnr].filetype ~= config.sidebar.filetype then 105 | return {} 106 | end 107 | end 108 | 109 | local buffer = Buffer.new({ 110 | bufnr = bufnr, 111 | name = fn.bufname(4), 112 | }) 113 | local sidebar_width = min(api.nvim_win_get_width(winid), o.columns) 114 | 115 | local sidebar_components = {} 116 | local width = 0 117 | local id = #state.components + #state.rhs + 1 118 | local hover = lazy("cokeline.hover").hovered() 119 | buffer.buf_hovered = hover ~= nil and hover.bufnr == buffer.number 120 | if not state.sidebar or not next(state.sidebar) then 121 | -- Fix(170): Return early as we have no sidebar components to handle 122 | return sidebar_components 123 | end 124 | for _, c in ipairs(state.sidebar) do 125 | c.sidebar = side 126 | buffer.is_hovered = hover ~= nil 127 | and hover.index == id 128 | and hover.bufnr == buffer.number 129 | local component = c:render(RenderContext:buffer(buffer)) 130 | buffer.is_hovered = false 131 | -- We need at least one component, otherwise we can't add padding to the 132 | -- last component if needed. 133 | if component.width > 0 or #sidebar_components == 0 then 134 | insert(sidebar_components, component) 135 | width = width + component.width 136 | end 137 | id = id + 1 138 | end 139 | 140 | local rendering = lazy("cokeline.rendering") 141 | 142 | if width > sidebar_width then 143 | sort(sidebar_components, rendering.by_decreasing_priority) 144 | components.shorten(sidebar_components, sidebar_width) 145 | sort(sidebar_components, rendering.by_increasing_index) 146 | elseif width < sidebar_width then 147 | local space_left = sidebar_width - width 148 | local last = #sidebar_components 149 | sidebar_components[last].text = sidebar_components[last].text 150 | .. rep(" ", space_left) 151 | sidebar_components[last].width = sidebar_components[last].width 152 | + space_left 153 | end 154 | 155 | return sidebar_components 156 | end 157 | 158 | return { 159 | get_win = get_win, 160 | get_components = get_components, 161 | ---@return integer 162 | get_width = function(side) 163 | return width_cache[side] or 0 164 | end, 165 | } 166 | -------------------------------------------------------------------------------- /lua/cokeline/sliders.lua: -------------------------------------------------------------------------------- 1 | -- local math_abs = math.abs 2 | local floor = math.floor 3 | local max = math.max 4 | 5 | ---@type table 6 | -- local gl_mut_prev_state 7 | 8 | -- local gl_scrolloff = 5 9 | 10 | ---@param available_space number 11 | ---@param width_left_of_current number 12 | ---@param width_right_of_current number 13 | ---@return number, number 14 | local center_current_buffer = function( 15 | available_space, 16 | width_left_of_current, 17 | width_right_of_current 18 | ) 19 | local available_space_left = floor(available_space / 2) 20 | local available_space_right = available_space_left + available_space % 2 21 | 22 | local unused_space_left = 23 | max(available_space_left - width_left_of_current, 0) 24 | local unused_space_right = 25 | max(available_space_right - width_right_of_current, 0) 26 | 27 | return available_space_left - unused_space_left + unused_space_right, 28 | available_space_right - unused_space_right + unused_space_left 29 | end 30 | 31 | ---@param available_space number 32 | ---@param width_left_of_current number 33 | ---@param width_right_of_current number 34 | ---@return number, number 35 | -- local slide_if_needed = 36 | -- function(available_space, width_left_of_current, width_right_of_current, 37 | -- current_buffer_index, prev_state) 38 | 39 | -- -- The first time the bufferline is rendered there is no previous state, so.. 40 | -- if not gl_mut_prev_state then 41 | -- return 0, available_space 42 | -- end 43 | 44 | -- -- If the new current buffer was not fully in view in the previous state we.. 45 | -- if current_buffer_index < prev_state.fully_in_view[1].index then 46 | -- local left = 47 | -- gl_scrolloff < width_left_of_current 48 | -- and gl_scrolloff 49 | -- or width_left_of_current 50 | -- local right = available_space - left 51 | -- return left, right 52 | 53 | -- elseif current_buffer_index > 54 | -- prev_state.fully_in_view[#prev_state.fully_in_view].index then 55 | -- local right = 56 | -- gl_scrolloff < width_right_of_current 57 | -- and gl_scrolloff 58 | -- or width_right_of_current 59 | -- local left = available_space - right 60 | -- return left, right 61 | -- end 62 | 63 | -- local index_range = math_abs(current_buffer_index - prev_state.current_index) 64 | -- local j = prev_state.current_index < current_buffer_index and 1 or -1 65 | 66 | -- local left, right = prev_state.spaces.left, prev_state.spaces.right 67 | -- for i=1,index_range do 68 | -- left = left + j * prev_state.fully_in_view[i].width 69 | -- right = right - j * prev_state.fully_in_view[i + 1].width 70 | -- end 71 | 72 | -- return left, right 73 | -- end 74 | 75 | return { 76 | center_current_buffer = center_current_buffer, 77 | -- slide_if_needed = slide_if_needed, 78 | } 79 | -------------------------------------------------------------------------------- /lua/cokeline/state.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | M.tab_cache = {} 4 | 5 | M.tab_lookup = {} 6 | 7 | M.valid_lookup = {} 8 | 9 | M.visible_buffers = {} 10 | 11 | M.valid_buffers = {} 12 | 13 | ---@type Component[] 14 | M.components = {} 15 | 16 | ---@type Component[] 17 | M.rhs = {} 18 | 19 | ---@type Component[] 20 | M.sidebar = {} 21 | 22 | ---@type Component[] 23 | M.tabs = {} 24 | 25 | return M 26 | -------------------------------------------------------------------------------- /lua/cokeline/tabs.lua: -------------------------------------------------------------------------------- 1 | local lazy = require("cokeline.lazy") 2 | local state = lazy("cokeline.state") 3 | local buffers = lazy("cokeline.buffers") 4 | local Buffer = buffers.Buffer 5 | 6 | local M = {} 7 | 8 | ---@class Window 9 | ---@field number window 10 | ---@field buffer Buffer 11 | local Window = {} 12 | Window.__index = Window 13 | 14 | function Window.new(winnr) 15 | local bufnr = vim.api.nvim_win_get_buf(winnr) 16 | local win = { 17 | number = winnr, 18 | buffer = buffers.get_buffer(bufnr) 19 | or Buffer.new({ bufnr = bufnr, name = vim.api.nvim_buf_get_name(bufnr) }), 20 | } 21 | return setmetatable(win, Window) 22 | end 23 | 24 | function Window:close() 25 | pcall(vim.api.nvim_win_close, self.number, false) 26 | end 27 | 28 | function Window:focus() 29 | vim.api.nvim_set_current_win(self.number) 30 | end 31 | 32 | ---@class TabPage 33 | ---@field number tabpage 34 | ---@field index number 35 | ---@field windows Window[] 36 | ---@field focused Window 37 | ---@field is_first boolean 38 | ---@field is_last boolean 39 | ---@field is_active boolean 40 | ---@field is_hovered boolean Whether the current component is hovered 41 | ---@field tab_hovered boolean Whether the tab is hovered 42 | local TabPage = {} 43 | TabPage.__index = TabPage 44 | 45 | function TabPage.new(tabnr, index, is_first, is_last, is_active) 46 | local active_win = vim.api.nvim_tabpage_get_win(tabnr) 47 | local windows = vim.api.nvim_tabpage_list_wins(tabnr) 48 | 49 | local focused 50 | for i, winnr in ipairs(windows) do 51 | windows[i] = Window.new(winnr) 52 | if winnr == active_win then 53 | focused = windows[i] 54 | end 55 | end 56 | 57 | local tab = { 58 | number = tabnr, 59 | index = index, 60 | windows = windows, 61 | focused = focused, 62 | is_active = is_active, 63 | is_first = is_first, 64 | is_last = is_last, 65 | } 66 | return setmetatable(tab, TabPage) 67 | end 68 | 69 | function TabPage:focus() 70 | vim.api.nvim_set_current_tabpage(self.number) 71 | end 72 | 73 | function TabPage:close() 74 | for _, win in ipairs(self.windows) do 75 | win:close() 76 | end 77 | end 78 | 79 | function M.update_current(tabnr) 80 | for _, t in ipairs(state.tab_cache) do 81 | if t.number == tabnr then 82 | t.is_active = true 83 | else 84 | t.is_active = false 85 | end 86 | end 87 | end 88 | 89 | function M.fetch_tabs() 90 | local tabs = {} 91 | local tabnrs = vim.api.nvim_list_tabpages() 92 | local active_tab = vim.api.nvim_get_current_tabpage() 93 | table.sort(tabnrs, function(a, b) 94 | return a < b 95 | end) 96 | for _, tabnr in ipairs(tabnrs) do 97 | local t = vim.api.nvim_tabpage_get_number(tabnr) 98 | if state.tab_lookup[tabnr] ~= nil then 99 | tabs[t] = state.tab_lookup[tabnr] 100 | tabs[t].is_active = tabnr == active_tab 101 | tabs[t].is_first = t == 1 102 | tabs[t].is_last = t == #tabnrs 103 | tabs[t].index = t 104 | local windows = vim.api.nvim_tabpage_list_wins(tabnr) 105 | for i, winnr in ipairs(windows) do 106 | if 107 | tabs[t].windows[i] == nil 108 | or tabs[t].windows[i].buffer == nil 109 | or tabs[t].windows[i].buffer.number 110 | ~= vim.api.nvim_win_get_buf(winnr) 111 | then 112 | tabs[t].windows[i] = Window.new(winnr) 113 | end 114 | end 115 | else 116 | tabs[t] = 117 | TabPage.new(tabnr, t, t == 1, t == #tabnrs, tabnr == active_tab) 118 | state.tab_lookup[tabnr] = tabs[t] 119 | end 120 | end 121 | state.tab_cache = tabs 122 | end 123 | 124 | function M.get_tabs() 125 | local cache_count = state.tab_cache and #state.tab_cache or nil 126 | if 127 | cache_count == nil 128 | or cache_count == 0 129 | or cache_count ~= #vim.api.nvim_list_tabpages() 130 | then 131 | M.fetch_tabs() 132 | end 133 | return state.tab_cache 134 | end 135 | 136 | function M.get_tabpage(tabnr) 137 | return state.tab_lookup[tabnr] 138 | end 139 | 140 | return M 141 | -------------------------------------------------------------------------------- /lua/cokeline/utils.lua: -------------------------------------------------------------------------------- 1 | ---@param bufnr number 2 | ---@param focus_next boolean 3 | ---@param wipeout boolean 4 | ---@param force boolean 5 | local function buf_del_impl(bufnr, focus_next, wipeout, force) 6 | local win = vim.fn.bufwinid(bufnr) 7 | 8 | if win ~= -1 then 9 | -- Get a list of buffers that are valid switch targets 10 | local switchable = vim.tbl_filter(function(buf) 11 | return vim.api.nvim_buf_is_valid(buf) 12 | and vim.bo[buf].buflisted 13 | and buf ~= bufnr 14 | end, vim.api.nvim_list_bufs()) 15 | 16 | local switch_target 17 | if #switchable > 0 then 18 | for _, switch_nr in ipairs(switchable) do 19 | -- If we're looking for a buffer after the current one, break here 20 | if switch_nr < bufnr then 21 | -- Keep looping to find the previous buffer 22 | -- This also serves as a fallback if there's no 23 | -- next buffer, and `focus_next` is true 24 | switch_target = switch_nr 25 | end 26 | if switch_nr > bufnr and (focus_next or switch_target == nil) then 27 | -- We found the next buffer, break 28 | switch_target = switch_nr 29 | break 30 | end 31 | end 32 | else 33 | -- If there's no possible switch target, create a new buffer and switch to it 34 | switch_target = vim.api.nvim_create_buf(false, true) 35 | if switch_target == 0 then 36 | vim.api.nvim_err_writeln("Failed to create new buffer") 37 | end 38 | end 39 | 40 | vim.api.nvim_win_set_buf(win, switch_target) 41 | end 42 | 43 | if vim.api.nvim_buf_is_valid(bufnr) then 44 | if wipeout then 45 | vim.cmd.bwipeout({ count = bufnr }) 46 | else 47 | vim.api.nvim_buf_delete(bufnr, { force = force }) 48 | end 49 | end 50 | end 51 | 52 | ---@param bufnr bufnr The buffer to delete 53 | ---@param focus "prev" | "next" | nil Buffer to focus on deletion (default: "next") 54 | ---@param wipeout boolean Whether to wipe the buffer (default: false) 55 | ---Deletes a buffer but keeps window layout 56 | local function buf_delete(bufnr, focus, wipeout) 57 | bufnr = bufnr or 0 58 | wipeout = wipeout or false 59 | local focus_next = true 60 | 61 | if focus == "prev" then 62 | focus_next = false 63 | end 64 | 65 | if vim.bo[bufnr].modified then 66 | vim.ui.select({ 67 | "Save and close", 68 | "Discard changes and close", 69 | "Cancel", 70 | }, { 71 | prompt = string.format( 72 | "Buffer %s has unsaved changes.", 73 | vim.fn.fnamemodify(vim.fn.bufname(bufnr), ":f") 74 | ), 75 | }, function(_, choice) 76 | if choice == 1 then 77 | if vim.api.nvim_buf_get_name(bufnr) == "" then 78 | vim.ui.input({ 79 | prompt = "File name: ", 80 | completion = "file", 81 | }, function(name) 82 | if name and name ~= "" then 83 | vim.api.nvim_buf_set_name(bufnr, name) 84 | vim.api.nvim_buf_call(bufnr, vim.cmd.write) 85 | buf_del_impl(bufnr, focus_next, wipeout, false) 86 | end 87 | end) 88 | else 89 | vim.api.nvim_buf_call(bufnr, vim.cmd.write) 90 | buf_del_impl(bufnr, focus_next, wipeout, false) 91 | end 92 | elseif choice == 2 then 93 | buf_del_impl(bufnr, focus_next, wipeout, true) 94 | elseif choice == 3 then 95 | return 96 | end 97 | end) 98 | elseif vim.bo[bufnr].buftype == "terminal" then 99 | vim.ui.select({ 100 | "Quit", 101 | "Cancel", 102 | }, { 103 | prompt = string.format( 104 | "Buffer %s is a terminal, and is still running.", 105 | vim.fn.fnamemodify(vim.fn.bufname(bufnr), ":t") 106 | ), 107 | }, function(_, choice) 108 | if choice == 1 then 109 | buf_del_impl(bufnr, focus_next, wipeout, true) 110 | elseif choice == 2 then 111 | return 112 | end 113 | end) 114 | else 115 | buf_del_impl(bufnr, focus_next, wipeout, false) 116 | end 117 | end 118 | 119 | return { 120 | buf_delete = buf_delete, 121 | } 122 | -------------------------------------------------------------------------------- /lua/resession/extensions/cokeline.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | function M.on_save() 4 | local files = {} 5 | for entry in require("cokeline.history"):iter() do 6 | table.insert(files, entry.path) 7 | end 8 | return files 9 | end 10 | 11 | function M.on_post_load(data) 12 | local history = require("cokeline.history") 13 | for _, path in ipairs(data) do 14 | local buf = vim.fn.bufnr(path) 15 | if buf then 16 | history:push(buf) 17 | end 18 | end 19 | end 20 | 21 | return M 22 | -------------------------------------------------------------------------------- /neovim.yml: -------------------------------------------------------------------------------- 1 | --- 2 | base: lua51 3 | 4 | globals: 5 | vim: 6 | any: true 7 | assert: 8 | args: 9 | - type: bool 10 | - type: string 11 | required: false 12 | after_each: 13 | args: 14 | - type: function 15 | before_each: 16 | args: 17 | - type: function 18 | describe: 19 | args: 20 | - type: string 21 | - type: function 22 | it: 23 | args: 24 | - type: string 25 | - type: function 26 | -------------------------------------------------------------------------------- /selene.toml: -------------------------------------------------------------------------------- 1 | std = "neovim" 2 | 3 | [rules] 4 | global_usage = "allow" 5 | multiple_statements = "allow" 6 | -------------------------------------------------------------------------------- /stylua.toml: -------------------------------------------------------------------------------- 1 | column_width = 79 2 | indent_type = "Spaces" 3 | indent_width = 2 4 | --------------------------------------------------------------------------------