├── .github ├── FUNDING.yml └── workflows │ ├── docgen.yaml │ └── workflow.yaml ├── .gitignore ├── .releaserc.json ├── Dockerfile ├── LICENSE ├── README.md ├── doc └── nvim-notify.txt ├── lua ├── notify │ ├── animate │ │ ├── init.lua │ │ └── spring.lua │ ├── config │ │ ├── highlights.lua │ │ └── init.lua │ ├── init.lua │ ├── instance.lua │ ├── integrations │ │ ├── fzf.lua │ │ └── init.lua │ ├── render │ │ ├── base.lua │ │ ├── compact.lua │ │ ├── default.lua │ │ ├── init.lua │ │ ├── minimal.lua │ │ ├── simple.lua │ │ ├── wrapped-compact.lua │ │ └── wrapped-default.lua │ ├── service │ │ ├── buffer │ │ │ ├── highlights.lua │ │ │ └── init.lua │ │ ├── init.lua │ │ └── notification.lua │ ├── stages │ │ ├── fade.lua │ │ ├── fade_in_slide_out.lua │ │ ├── init.lua │ │ ├── no_animation.lua │ │ ├── slide.lua │ │ ├── slide_out.lua │ │ ├── static.lua │ │ └── util.lua │ ├── util │ │ ├── init.lua │ │ └── queue.lua │ └── windows │ │ └── init.lua └── telescope │ └── _extensions │ └── notify.lua ├── scripts ├── docgen ├── gendocs.lua ├── style └── test ├── stylua.toml └── tests ├── init.vim ├── manual └── merge_duplicates.lua └── unit └── init_spec.lua /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: rcarriga 2 | -------------------------------------------------------------------------------- /.github/workflows/docgen.yaml: -------------------------------------------------------------------------------- 1 | # Taken from telescope 2 | name: Generate docs 3 | 4 | on: 5 | push: 6 | branches-ignore: 7 | - master 8 | 9 | jobs: 10 | build-sources: 11 | name: Generate docs 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | include: 17 | - os: ubuntu-20.04 18 | url: https://github.com/neovim/neovim/releases/download/v0.5.1/nvim-linux64.tar.gz 19 | steps: 20 | - uses: actions/checkout@v2 21 | - run: date +%F > todays-date 22 | - name: Restore cache for today's nightly. 23 | uses: actions/cache@v2 24 | with: 25 | path: _neovim 26 | key: ${{ runner.os }}-${{ matrix.url }}-${{ hashFiles('todays-date') }} 27 | 28 | - name: Prepare 29 | run: | 30 | test -d _neovim || { 31 | mkdir -p _neovim 32 | curl -sL ${{ matrix.url }} | tar xzf - --strip-components=1 -C "${PWD}/_neovim" 33 | } 34 | mkdir -p ~/.local/share/nvim/site/pack/vendor/start 35 | git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ~/.local/share/nvim/site/pack/vendor/start/plenary.nvim 36 | git clone --depth 1 https://github.com/tjdevries/tree-sitter-lua ~/.local/share/nvim/site/pack/vendor/start/tree-sitter-lua 37 | ln -s $(pwd) ~/.local/share/nvim/site/pack/vendor/start 38 | - name: Build parser 39 | run: | 40 | # We have to build the parser every single time to keep up with parser changes 41 | cd ~/.local/share/nvim/site/pack/vendor/start/tree-sitter-lua 42 | make dist 43 | cd - 44 | - name: Generating docs 45 | run: | 46 | export PATH="${PWD}/_neovim/bin:${PATH}" 47 | export VIM="${PWD}/_neovim/share/nvim/runtime" 48 | nvim --version 49 | ./scripts/docgen 50 | - name: Update documentation 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | COMMIT_MSG: | 54 | docs: update doc/nvim-notify.txt 55 | skip-checks: true 56 | run: | 57 | git config user.email "actions@github" 58 | git config user.name "Github Actions" 59 | git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git 60 | git add doc/ 61 | # Only commit and push if we have changes 62 | git diff --quiet && git diff --staged --quiet || (git commit -m "${COMMIT_MSG}"; git push origin HEAD:${GITHUB_REF}) 63 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yaml: -------------------------------------------------------------------------------- 1 | name: nvim-notify Workflow 2 | on: 3 | - push 4 | jobs: 5 | style: 6 | name: style 7 | runs-on: ubuntu-20.04 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: JohnnyMorganz/stylua-action@v1 11 | with: 12 | token: ${{ secrets.GITHUB_TOKEN }} 13 | version: latest 14 | args: --check lua/ tests/ 15 | 16 | tests: 17 | name: tests 18 | runs-on: ubuntu-20.04 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | include: 23 | - os: ubuntu-20.04 24 | nvim-version: '0.10.3' 25 | steps: 26 | - uses: actions/checkout@v2 27 | - id: nvim-sha 28 | run: | 29 | curl -sL https://github.com/neovim/neovim/releases/download/v${{ matrix.nvim-version }}/nvim-linux64.tar.gz.sha256sum > nvim-sha 30 | echo "cache-key=$(awk '{print $2 "-" $1}' nvim-sha)" >> "$GITHUB_OUTPUT" 31 | - name: Restore cache for today's nightly. 32 | uses: actions/cache@v2 33 | with: 34 | path: _neovim 35 | key: ${{ steps.nvim-sha.outputs.cache-key }} 36 | 37 | - name: Prepare dependencies 38 | run: | 39 | test -d _neovim || { 40 | mkdir -p _neovim 41 | curl -sL https://github.com/neovim/neovim/releases/download/v${{ matrix.nvim-version }}/nvim-linux64.tar.gz | tar xzf - --strip-components=1 -C "${PWD}/_neovim" 42 | } 43 | mkdir -p ~/.local/share/nvim/site/pack/vendor/start 44 | git clone --depth 1 https://github.com/nvim-lua/plenary.nvim ~/.local/share/nvim/site/pack/vendor/start/plenary.nvim 45 | ln -s $(pwd) ~/.local/share/nvim/site/pack/vendor/start 46 | 47 | - name: Run tests 48 | run: | 49 | export PATH="${PWD}/_neovim/bin:${PATH}" 50 | export VIM="${PWD}/_neovim/share/nvim/runtime" 51 | nvim --version 52 | ./scripts/test 53 | 54 | release: 55 | name: release 56 | if: ${{ github.ref == 'refs/heads/master' }} 57 | needs: 58 | - style 59 | - tests 60 | runs-on: ubuntu-22.04 61 | steps: 62 | - name: Checkout 63 | uses: actions/checkout@v2 64 | with: 65 | fetch-depth: 0 66 | - name: Setup Node.js 67 | uses: actions/setup-node@v1 68 | with: 69 | node-version: 20 70 | - name: Release 71 | env: 72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 73 | run: npx semantic-release 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | neovim/ 2 | plenary.nvim/ 3 | doc/tags 4 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@semantic-release/commit-analyzer", 4 | "@semantic-release/release-notes-generator", 5 | [ 6 | "@semantic-release/github", 7 | { 8 | "successComment": false 9 | } 10 | ] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | ARG NEOVIM_RELEASE=${NEOVIM_RELEASE:-https://github.com/neovim/neovim/releases/download/nightly/nvim-linux64.tar.gz} 3 | FROM ubuntu 4 | ARG NEOVIM_RELEASE 5 | 6 | RUN apt-get update 7 | RUN apt-get -y install git curl tar gcc g++ 8 | RUN mkdir /neovim 9 | RUN curl -sL ${NEOVIM_RELEASE} | tar xzf - --strip-components=1 -C "/neovim" 10 | RUN git clone --depth 1 https://github.com/nvim-lua/plenary.nvim 11 | RUN git clone --depth 1 https://github.com/tjdevries/tree-sitter-lua 12 | 13 | WORKDIR tree-sitter-lua 14 | RUN mkdir -p build parser; \ 15 | cc -o ./build/parser.so -I ./src src/parser.c src/scanner.c -shared -Os -lstdc++ -fPIC; \ 16 | ln -s ../build/parser.so parser/lua.so; 17 | 18 | RUN mkdir /notify 19 | WORKDIR /notify 20 | 21 | ENTRYPOINT ["bash", "-c", "PATH=/neovim/bin:${PATH} VIM=/neovim/share/nvim/runtime nvim --headless -c 'set rtp+=. | set rtp+=../plenary.nvim/ | set rtp+=../tree-sitter-lua/ | runtime! plugin/plenary.vim | luafile ./scripts/gendocs.lua' -c 'qa'"] 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Rónán Carrigan 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 | # nvim-notify 2 | 3 | A fancy, configurable, notification manager for NeoVim 4 | 5 |  6 | 7 | Credit to [sunjon](https://github.com/sunjon) for [the design](https://neovim.discourse.group/t/wip-animated-notifications-plugin/448) that inspired the appearance of this plugin. 8 | 9 | * [Installation](#Installation) 10 | * [Usage](#usage) 11 | - [Viewing History](#viewing-history) 12 | * [Configuration](#configuration) 13 | - [Setup](#setup) 14 | - [Highlights](#highlights) 15 | - [Render Style](#render-style) 16 | - [Animation Style](#animation-style) 17 | + [Opening the window](#opening-the-window) 18 | + [Changing the window](#changing-the-window) 19 | 20 | ## Installation 21 | 22 | ### Prerequisites 23 | 24 | Make sure to use a font which supported glyphs (icons), font can be found [here](https://github.com/ryanoasis/nerd-fonts). 25 | 26 | 24-bit colour is required, which can be enabled by adding this to your init.lua 27 | ```lua 28 | vim.opt.termguicolors = true 29 | ``` 30 | 31 | Then you can install nvim-notify with the package manager of your choice. 32 | 33 | [**dein**](https://github.com/Shougo/dein.vim): 34 | 35 | ```vim 36 | call dein#add("rcarriga/nvim-notify") 37 | ``` 38 | 39 | [**vim-plug**](https://github.com/junegunn/vim-plug): 40 | 41 | ```vim 42 | Plug 'rcarriga/nvim-notify' 43 | ``` 44 | 45 | [**packer**](https://github.com/wbthomason/packer.nvim): 46 | 47 | ```lua 48 | use 'rcarriga/nvim-notify' 49 | ``` 50 | 51 | ## Usage 52 | 53 | Simply call the module with a message! 54 | 55 | ```lua 56 | require("notify")("My super important message") 57 | ``` 58 | 59 | Other plugins can use the notification windows by setting it as your default notify function 60 | 61 | ```lua 62 | vim.notify = require("notify") 63 | ``` 64 | 65 | You can supply a level to change the border highlighting 66 | 67 | ```lua 68 | vim.notify("This is an error message", "error") 69 | ``` 70 | 71 | Updating an existing notification is also possible! 72 | 73 |  74 | 75 | 76 | Use treesitter highlighting inside notifications with opacity changing 77 | 78 |  79 | 80 | There are a number of custom options that can be supplied in a table as the third argument. 81 | See `:h NotifyOptions` for details. 82 | 83 | Sample code for the first GIF above: 84 | 85 | ```lua 86 | local plugin = "My Awesome Plugin" 87 | 88 | vim.notify("This is an error message.\nSomething went wrong!", "error", { 89 | title = plugin, 90 | on_open = function() 91 | vim.notify("Attempting recovery.", vim.log.levels.WARN, { 92 | title = plugin, 93 | }) 94 | local timer = vim.loop.new_timer() 95 | timer:start(2000, 0, function() 96 | vim.notify({ "Fixing problem.", "Please wait..." }, "info", { 97 | title = plugin, 98 | timeout = 3000, 99 | on_close = function() 100 | vim.notify("Problem solved", nil, { title = plugin }) 101 | vim.notify("Error code 0x0395AF", 1, { title = plugin }) 102 | end, 103 | }) 104 | end) 105 | end, 106 | }) 107 | ``` 108 | 109 | You can also use plenary's async library to avoid using callbacks: 110 | ```lua 111 | local async = require("plenary.async") 112 | local notify = require("notify").async 113 | 114 | async.run(function() 115 | notify("Let's wait for this to close").events.close() 116 | notify("It closed!") 117 | end) 118 | ``` 119 | 120 | Set a custom filetype to take advantage of treesitter highlighting: 121 | 122 | ```lua 123 | vim.notify(text, "info", { 124 | title = "My Awesome Plugin", 125 | on_open = function(win) 126 | local buf = vim.api.nvim_win_get_buf(win) 127 | vim.api.nvim_buf_set_option(buf, "filetype", "markdown") 128 | end, 129 | }) 130 | ``` 131 | 132 | 133 | Check out the wiki for more examples! 134 | 135 | ### Viewing History 136 | 137 | If you have [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) installed then you can use the `notify` extension to search the history: 138 | 139 | ```vim 140 | :Telescope notify 141 | ``` 142 | or in lua 143 | ```lua 144 | require('telescope').extensions.notify.notify(<opts>) 145 | ``` 146 | 147 | **Note:** If you lazy load `telescope` you should manually call `require("telescope").load_extension("notify")` before using the above commands. If you don't lazy load `telescope` then `notify` does this for you. 148 | 149 | <p align="center"> 150 | <img src="https://user-images.githubusercontent.com/24252670/136264308-2fcdfe57-a8f6-4b34-8ea1-e3a8349bc581.png" /> 151 | </p> 152 | 153 | There is a command to display a log of the history. 154 | 155 | ```vim 156 | :Notifications 157 | ``` 158 | 159 | <p align="center"> 160 | <img src="https://user-images.githubusercontent.com/24252670/136264653-83038119-634b-48e7-8e8a-0edf4546efe2.png" /> 161 | </p> 162 | 163 | You can get a list of past notifications with the history function 164 | ```lua 165 | require("notify").history() 166 | ``` 167 | 168 | There is another command to clear the history. 169 | 170 | ```vim 171 | :NotificationsClear 172 | ``` 173 | 174 | You can clear the notifications with the clear history function 175 | 176 | ```lua 177 | require("notify").clear_history() 178 | ``` 179 | 180 | ## Configuration 181 | 182 | ### Setup 183 | 184 | You can optionally call the `setup` function to provide configuration options 185 | 186 | See `:h notify.Config` for options and `:h notify.setup()` for default values. 187 | 188 | ### Highlights 189 | 190 | You can define custom highlights by supplying highlight groups for each of the levels. 191 | The naming scheme follows a simple structure: `Notify<upper case level name><section>`. 192 | If you want to use custom levels, you can define the highlights for them or 193 | they will follow the `INFO` highlights by default. 194 | 195 | Here are the defaults: 196 | 197 | ```vim 198 | highlight NotifyERRORBorder guifg=#8A1F1F 199 | highlight NotifyWARNBorder guifg=#79491D 200 | highlight NotifyINFOBorder guifg=#4F6752 201 | highlight NotifyDEBUGBorder guifg=#8B8B8B 202 | highlight NotifyTRACEBorder guifg=#4F3552 203 | highlight NotifyERRORIcon guifg=#F70067 204 | highlight NotifyWARNIcon guifg=#F79000 205 | highlight NotifyINFOIcon guifg=#A9FF68 206 | highlight NotifyDEBUGIcon guifg=#8B8B8B 207 | highlight NotifyTRACEIcon guifg=#D484FF 208 | highlight NotifyERRORTitle guifg=#F70067 209 | highlight NotifyWARNTitle guifg=#F79000 210 | highlight NotifyINFOTitle guifg=#A9FF68 211 | highlight NotifyDEBUGTitle guifg=#8B8B8B 212 | highlight NotifyTRACETitle guifg=#D484FF 213 | highlight link NotifyERRORBody Normal 214 | highlight link NotifyWARNBody Normal 215 | highlight link NotifyINFOBody Normal 216 | highlight link NotifyDEBUGBody Normal 217 | highlight link NotifyTRACEBody Normal 218 | ``` 219 | 220 | ### Render Style 221 | 222 | The appearance of the notifications can be configured, using either built-in methods or custom functions. 223 | See `:help notify-render()` for details 224 | 225 | 1. "default" 226 | 227 |  228 | 229 | 2. "minimal" 230 | 231 |  232 | 233 | 3. "simple" 234 | 235 |  236 | 237 | 4. "compact" 238 | 239 |  240 | 241 | 5. "wrapped-compact" 242 | 243 | Mostly same as `compact`, but lines are wrapped based on `max_width`, some padding is added. 244 | 245 |  246 | 247 | 5. "wrapped-default" 248 | 249 | Similar to `default`, but lines are wrapped based on `max_width`, some padding is added. 250 | 251 | Feel free to submit custom rendering functions to share with others! 252 | 253 | ### Animation Style 254 | 255 | The animation is designed to work in stages. The first stage is the opening of 256 | the window, and all subsequent stages can changes the position or opacity of 257 | the window. You can use one of the built-in styles or provide your own in the setup. 258 | 259 | 1. "fade_in_slide_out" 260 | 261 |  262 | 263 | 2. "fade" 264 | 265 |  266 | 267 | 3. "slide" 268 | 269 |  270 | 271 | 4. "static" 272 | 273 |  274 | 275 | Custom styles can be provided by setting the config `stages` value to a list of 276 | functions. 277 | 278 | If you create a custom style, feel free to open a PR to submit it as a built-in style! 279 | 280 | **NB.** This is a prototype API that is open to change. I am looking for 281 | feedback on both issues or extra data that could be useful in creating 282 | animation styles. 283 | 284 | Check the [built-in styles](./lua/notify/stages/) to see examples 285 | 286 | #### Opening the window 287 | 288 | The first function in the list should return a table to be provided to 289 | `nvim_open_win`, optionally including an extra `opacity` key which can be 290 | between 0-100. 291 | 292 | The function is given a state table that contains the following keys: 293 | 294 | - `message: table` State of the message to be shown 295 | - `width` Width of the message buffer 296 | - `height` Height of the message buffer 297 | - `open_windows: integer[]` List of all window IDs currently showing messages 298 | - `buffer: integer` The buffer containing the rendered notification message. 299 | 300 | If a notification can't be shown at the moment the function should return `nil`. 301 | 302 | #### Changing the window 303 | 304 | All following functions should return the goal values for the window to reach from it's current point. 305 | They will receive the same state object as the initial function and a second argument of the window ID. 306 | 307 | The following fields can be returned in a table: 308 | - `col` 309 | - `row` 310 | - `height` 311 | - `width` 312 | - `opacity` 313 | 314 | These can be provided as either numbers or as a table. If they are 315 | provided as numbers then they will change instantly the value given. 316 | 317 | If they are provided as a table, they will be treated as a value to animate towards. 318 | This uses a dampened spring algorithm to provide a natural feel to the movement. 319 | 320 | The table must contain the goal value as the 1st index (e.g. `{10}`) 321 | 322 | All other values are provided with keys: 323 | 324 | - `damping: number` How motion decays over time. Values less than 1 mean the spring can overshoot. 325 | - Bounds: >= 0 326 | - Default: 1 327 | - `frequency: number` How fast the spring oscillates 328 | - Bounds: >= 0 329 | - Default: 1 330 | - `complete: fun(value: number): bool` Function to determine if value has reached its goal. If not 331 | provided it will complete when the value rounded to 2 decimal places is equal 332 | to the goal. 333 | 334 | Once the last function has reached its goals, the window is removed. 335 | 336 | One of the stages should also return the key `time` set to true. This is 337 | treated as the stage which the notification is on a timer. The goals of this 338 | stage are not used to check if it is complete. The next stage will start 339 | once the notification reaches its timeout. 340 | -------------------------------------------------------------------------------- /doc/nvim-notify.txt: -------------------------------------------------------------------------------- 1 | *nvim-notify.txt* A fancy, configurable notification manager for NeoVim 2 | 3 | ============================================================================== 4 | 5 | A fancy, configurable notification manager for NeoVim 6 | 7 | notify *notify* 8 | 9 | 10 | *notify.setup()* 11 | `setup`({user_config}) 12 | 13 | Configure nvim-notify 14 | See: ~ 15 | |notify.Config| 16 | |notify-render| 17 | 18 | Parameters~ 19 | {user_config} `(notify.Config|nil)` 20 | Default values: 21 | >lua 22 | { 23 | background_colour = "NotifyBackground", 24 | fps = 30, 25 | icons = { 26 | DEBUG = "", 27 | ERROR = "", 28 | INFO = "", 29 | TRACE = "✎", 30 | WARN = "" 31 | }, 32 | level = 2, 33 | minimum_width = 50, 34 | render = "default", 35 | stages = "fade_in_slide_out", 36 | time_formats = { 37 | notification = "%T", 38 | notification_history = "%FT%T" 39 | }, 40 | timeout = 5000, 41 | top_down = true 42 | } 43 | < 44 | 45 | *notify.Options* 46 | Options for an individual notification 47 | Fields~ 48 | {title} `(string)` 49 | {icon} `(string)` 50 | {timeout} `(number|boolean)` Time to show notification in milliseconds, set to false to disable timeout. 51 | {on_open} `(function)` Callback for when window opens, receives window as argument. 52 | {on_close} `(function)` Callback for when window closes, receives window as argument. 53 | {keep} `(function)` Function to keep the notification window open after timeout, should return boolean. 54 | {render} `(function|string)` Function to render a notification buffer. 55 | {replace} `(integer|notify.Record)` Notification record or the record `id` field. Replace an existing notification if still open. All arguments not given are inherited from the replaced notification including message and level. 56 | {hide_from_history} `(boolean)` Hide this notification from the history 57 | {animate} `(boolean)` If false, the window will jump to the timed stage. Intended for use in blocking events (e.g. vim.fn.input) 58 | 59 | *notify.Events* 60 | Async events for a notification 61 | Fields~ 62 | {open} `(function)` Resolves when notification is opened 63 | {close} `(function)` Resolved when notification is closed 64 | 65 | *notify.Record* 66 | Record of a previously sent notification 67 | Fields~ 68 | {id} `(integer)` 69 | {message} `(string[])` Lines of the message 70 | {level} `(string|integer)` Log level. See vim.log.levels 71 | {title} `(string[])` Left and right sections of the title 72 | {icon} `(string)` Icon used for notification 73 | {time} `(number)` Time of message, as returned by `vim.fn.localtime()` 74 | {render} `(function)` Function to render notification buffer 75 | 76 | *notify.AsyncRecord* 77 | Inherits: `notify.Record` 78 | 79 | Fields~ 80 | {events} `(notify.Events)` 81 | 82 | *notify.notify()* 83 | `notify`({message}, {level}, {opts}) 84 | 85 | Display a notification. 86 | 87 | You can call the module directly rather than using this: 88 | >lua 89 | require("notify")(message, level, opts) 90 | < 91 | Parameters~ 92 | {message} `(string|string[])` Notification message 93 | {level} `(string|number)` Log level. See vim.log.levels 94 | {opts} `(notify.Options)` Notification options 95 | Return~ 96 | `(notify.Record)` 97 | 98 | *notify.async()* 99 | `async`({message}, {level}, {opts}) 100 | 101 | Display a notification asynchronously 102 | 103 | This uses plenary's async library, allowing a cleaner interface for 104 | open/close events. You must call this function within an async context. 105 | 106 | The `on_close` and `on_open` options are not used. 107 | 108 | Parameters~ 109 | {message} `(string|string[])` Notification message 110 | {level} `(string|number)` Log level. See vim.log.levels 111 | {opts} `(notify.Options)` Notification options 112 | Return~ 113 | `(notify.AsyncRecord)` 114 | 115 | *notify.history()* 116 | `history`({opts}) 117 | 118 | Get records of all previous notifications 119 | 120 | You can use the `:Notifications` command to display a log of previous notifications 121 | Parameters~ 122 | {opts?} `(notify.HistoryOpts)` 123 | Return~ 124 | `(notify.Record[])` 125 | 126 | *notify.HistoryOpts* 127 | Fields~ 128 | {include_hidden} `(boolean)` Include notifications hidden from history 129 | 130 | *notify.dismiss()* 131 | `dismiss`({opts}) 132 | 133 | Dismiss all notification windows currently displayed 134 | Parameters~ 135 | {opts} `(notify.DismissOpts)` 136 | 137 | *notify.DismissOpts* 138 | Fields~ 139 | {pending} `(boolean)` Clear pending notifications 140 | {silent} `(boolean)` Suppress notification that pending notifications were dismissed. 141 | 142 | *notify.open()* 143 | `open`({notif_id}, {opts}) 144 | 145 | Open a notification in a new buffer 146 | Parameters~ 147 | {notif_id} `(integer|notify.Record)` 148 | {opts} `(notify.OpenOpts)` 149 | Return~ 150 | `(notify.OpenedBuffer)` 151 | 152 | *notify.OpenOpts* 153 | Fields~ 154 | {buffer} `(integer)` Use this buffer, instead of creating a new one 155 | {max_width} `(integer)` Render message to this width (used to limit window decoration sizes) 156 | 157 | *notify.OpenedBuffer* 158 | Fields~ 159 | {buffer} `(integer)` Created buffer number 160 | {height} `(integer)` Height of the buffer content including extmarks 161 | {width} `(integer)` width of the buffer content including extmarks 162 | {highlights} `(table<string, string>)` Highlights used for the buffer contents 163 | 164 | *notify.pending()* 165 | `pending`() 166 | 167 | Number of notifications currently waiting to be displayed 168 | Return~ 169 | `(integer[])` 170 | 171 | *notify.instance()* 172 | `instance`({user_config}, {inherit}) 173 | 174 | Configure an instance of nvim-notify. 175 | You can use this to manage a separate instance of nvim-notify with completely different configuration. 176 | The returned instance will have the same functions as the notify module. 177 | Parameters~ 178 | {user_config} `(notify.Config)` 179 | {inherit?} `(boolean)` Inherit the global configuration, default true 180 | 181 | 182 | ============================================================================== 183 | notify.config *notify.config* 184 | 185 | *notify.Config* 186 | Fields~ 187 | {level} `(string|integer)` Minimum log level to display. See vim.log.levels. 188 | {timeout} `(number)` Default timeout for notification 189 | {max_width} `(number|function)` Max number of columns for messages 190 | {max_height} `(number|function)` Max number of lines for a message 191 | {stages} `(string|function[])` Animation stages 192 | {background_colour} `(string)` For stages that change opacity this is treated as the highlight behind the window. Set this to either a highlight group, an RGB hex value e.g. "#000000" or a function returning an RGB code for dynamic values 193 | {icons} `(table)` Icons for each level (upper case names) 194 | {time_formats} `(table)` Time formats for different kind of notifications 195 | {on_open} `(function)` Function called when a new window is opened, use for changing win settings/config 196 | {on_close} `(function)` Function called when a window is closed 197 | {render} `(function|string)` Function to render a notification buffer or a built-in renderer name 198 | {minimum_width} `(integer)` Minimum width for notification windows 199 | {fps} `(integer)` Frames per second for animation stages, higher value means smoother animations but more CPU usage 200 | {top_down} `(boolean)` whether or not to position the notifications at the top or not 201 | 202 | 203 | ============================================================================== 204 | notify-render *notify-render* 205 | 206 | Notification buffer rendering 207 | 208 | Custom rendering can be provided by both the user config in the setup or on 209 | an individual notification using the `render` key. 210 | The key can either be the name of a built-in renderer or a custom function. 211 | 212 | Built-in renderers: 213 | - `"default"` 214 | - `"minimal"` 215 | - `"simple"` 216 | - `"compact"` 217 | - `"wrapped-compact"` 218 | 219 | Custom functions should accept a buffer, a notification record and a highlights table 220 | 221 | > 222 | render: fun(buf: integer, notification: notify.Record, highlights: notify.Highlights, config) 223 | < 224 | You should use the provided highlight groups to take advantage of opacity 225 | changes as they will be updated as the notification is animated 226 | 227 | *notify.Highlights* 228 | Fields~ 229 | {title} `(string)` 230 | {icon} `(string)` 231 | {border} `(string)` 232 | {body} `(string)` 233 | 234 | 235 | vim:tw=78:ts=8:noet:ft=help:norl: -------------------------------------------------------------------------------- /lua/notify/animate/init.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | M.spring = require("notify.animate.spring") 4 | 5 | return M 6 | -------------------------------------------------------------------------------- /lua/notify/animate/spring.lua: -------------------------------------------------------------------------------- 1 | -- Adapted from https://gist.github.com/Fraktality/1033625223e13c01aa7144abe4aaf54d 2 | -- Explanation found here https://www.ryanjuckett.com/damped-springs/ 3 | local pi = math.pi 4 | local exp = math.exp 5 | local sin = math.sin 6 | local cos = math.cos 7 | local sqrt = math.sqrt 8 | 9 | ---@class SpringState 10 | ---@field position number 11 | ---@field velocity number | nil 12 | 13 | ---@param dt number @Step in time 14 | ---@param state SpringState 15 | return function(dt, goal, state, frequency, damping) 16 | local angular_freq = frequency * 2 * pi 17 | 18 | local cur_vel = state.velocity or 0 19 | 20 | local offset = state.position - goal 21 | local decay = exp(-dt * damping * angular_freq) 22 | 23 | local new_pos 24 | local new_vel 25 | 26 | if damping == 1 then -- critically damped 27 | new_pos = (cur_vel * dt + offset * (angular_freq * dt + 1)) * decay + goal 28 | new_vel = (cur_vel - angular_freq * dt * (offset * angular_freq + cur_vel)) * decay 29 | elseif damping < 1 then -- underdamped 30 | local c = sqrt(1 - damping * damping) 31 | 32 | local i = cos(angular_freq * c * dt) 33 | local j = sin(angular_freq * c * dt) 34 | 35 | new_pos = (i * offset + j * (cur_vel + damping * angular_freq * offset) / (angular_freq * c)) 36 | * decay 37 | + goal 38 | new_vel = (i * c * cur_vel - j * (cur_vel * damping + angular_freq * offset)) * decay / c 39 | else -- overdamped 40 | local c = sqrt(damping * damping - 1) 41 | 42 | local r1 = -angular_freq * (damping - c) 43 | local r2 = -angular_freq * (damping + c) 44 | 45 | local co2 = (cur_vel - r1 * offset) / (2 * angular_freq * c) 46 | local co1 = offset - co2 47 | 48 | local e1 = co1 * exp(r1 * dt) 49 | local e2 = co2 * exp(r2 * dt) 50 | 51 | new_pos = e1 + e2 + goal 52 | new_pos = r1 * e1 + r2 * e2 53 | end 54 | state.position = new_pos 55 | state.velocity = new_vel 56 | end 57 | -------------------------------------------------------------------------------- /lua/notify/config/highlights.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | function M.setup() 4 | vim.cmd([[ 5 | hi default link NotifyBackground Normal 6 | hi default NotifyERRORBorder guifg=#8A1F1F 7 | hi default NotifyWARNBorder guifg=#79491D 8 | hi default NotifyINFOBorder guifg=#4F6752 9 | hi default NotifyDEBUGBorder guifg=#8B8B8B 10 | hi default NotifyTRACEBorder guifg=#4F3552 11 | hi default NotifyERRORIcon guifg=#F70067 12 | hi default NotifyWARNIcon guifg=#F79000 13 | hi default NotifyINFOIcon guifg=#A9FF68 14 | hi default NotifyDEBUGIcon guifg=#8B8B8B 15 | hi default NotifyTRACEIcon guifg=#D484FF 16 | hi default NotifyERRORTitle guifg=#F70067 17 | hi default NotifyWARNTitle guifg=#F79000 18 | hi default NotifyINFOTitle guifg=#A9FF68 19 | hi default NotifyDEBUGTitle guifg=#8B8B8B 20 | hi default NotifyTRACETitle guifg=#D484FF 21 | hi default link NotifyERRORBody Normal 22 | hi default link NotifyWARNBody Normal 23 | hi default link NotifyINFOBody Normal 24 | hi default link NotifyDEBUGBody Normal 25 | hi default link NotifyTRACEBody Normal 26 | 27 | hi default link NotifyLogTime Comment 28 | hi default link NotifyLogTitle Special 29 | ]]) 30 | end 31 | 32 | M.setup() 33 | 34 | vim.cmd([[ 35 | augroup NvimNotifyRefreshHighlights 36 | autocmd! 37 | autocmd ColorScheme * lua require('notify.config.highlights').setup() 38 | augroup END 39 | ]]) 40 | 41 | return M 42 | -------------------------------------------------------------------------------- /lua/notify/config/init.lua: -------------------------------------------------------------------------------- 1 | ---@tag notify.config 2 | 3 | local Config = {} 4 | local util = require("notify.util") 5 | 6 | require("notify.config.highlights") 7 | 8 | local BUILTIN_RENDERERS = { 9 | DEFAULT = "default", 10 | MINIMAL = "minimal", 11 | } 12 | 13 | local BUILTIN_STAGES = { 14 | FADE = "fade", 15 | SLIDE = "slide", 16 | SLIDE_OUT = "slide_out", 17 | FADE_IN_SLIDE_OUT = "fade_in_slide_out", 18 | STATIC = "static", 19 | } 20 | 21 | local default_config = { 22 | level = vim.log.levels.INFO, 23 | timeout = 5000, 24 | max_width = nil, 25 | max_height = nil, 26 | stages = BUILTIN_STAGES.FADE_IN_SLIDE_OUT, 27 | render = BUILTIN_RENDERERS.DEFAULT, 28 | background_colour = "NotifyBackground", 29 | on_open = nil, 30 | on_close = nil, 31 | minimum_width = 50, 32 | fps = 30, 33 | top_down = true, 34 | merge_duplicates = true, 35 | time_formats = { 36 | notification_history = "%FT%T", 37 | notification = "%T", 38 | }, 39 | icons = { 40 | ERROR = "", 41 | WARN = "", 42 | INFO = "", 43 | DEBUG = "", 44 | TRACE = "✎", 45 | }, 46 | } 47 | 48 | ---@class notify.Config 49 | ---@field level string|integer|nil Minimum log level to display. See vim.log.levels. 50 | ---@field timeout number? Default timeout for notification 51 | ---@field max_width number|function|nil Max number of columns for messages 52 | ---@field max_height number|function|nil Max number of lines for a message 53 | ---@field stages string|function[]|nil Animation stages 54 | ---@field background_colour string? For stages that change opacity this is treated as the highlight behind the window. Set this to either a highlight group, an RGB hex value e.g. "#000000" or a function returning an RGB code for dynamic values 55 | ---@field icons table? Icons for each level (upper case names) 56 | ---@field time_formats table? Time formats for different kind of notifications 57 | ---@field on_open function? Function called when a new window is opened, use for changing win settings/config 58 | ---@field on_close function? Function called when a window is closed 59 | ---@field render function|string|nil Function to render a notification buffer or a built-in renderer name 60 | ---@field minimum_width integer? Minimum width for notification windows 61 | ---@field fps integer? Frames per second for animation stages, higher value means smoother animations but more CPU usage 62 | ---@field top_down boolean? whether or not to position the notifications at the top or not 63 | ---@field merge_duplicates boolean|integer whether to replace visible notification if new one is the same, can be an integer for min duplicate count 64 | 65 | local opacity_warned = false 66 | 67 | local function validate_highlight(colour_or_group, needs_opacity) 68 | if type(colour_or_group) == "function" then 69 | return colour_or_group 70 | end 71 | if colour_or_group:sub(1, 1) == "#" then 72 | return function() 73 | return colour_or_group 74 | end 75 | end 76 | return function() 77 | local group = vim.api.nvim_get_hl(0, { name = colour_or_group, create = false, link = false }) 78 | if not group or not group.bg then 79 | if needs_opacity and not opacity_warned then 80 | opacity_warned = true 81 | vim.schedule(function() 82 | vim.notify("Highlight group '" .. colour_or_group .. [[' has no background highlight 83 | Please provide an RGB hex value or highlight group with a background value for 'background_colour' option. 84 | This is the colour that will be used for 100% transparency. 85 | ```lua 86 | require("notify").setup({ 87 | background_colour = "#000000", 88 | }) 89 | ``` 90 | Defaulting to #000000]], "warn", { 91 | title = "nvim-notify", 92 | on_open = function(win) 93 | local buf = vim.api.nvim_win_get_buf(win) 94 | vim.api.nvim_set_option_value("filetype", "markdown", { buf = buf }) 95 | end, 96 | }) 97 | end) 98 | end 99 | return "#000000" 100 | end 101 | return string.format("#%x", group.bg) 102 | end 103 | end 104 | 105 | function Config._format_default() 106 | local lines = { "Default values:", ">lua" } 107 | for line in vim.gsplit(vim.inspect(default_config), "\n", true) do 108 | table.insert(lines, " " .. line) 109 | end 110 | table.insert(lines, "<") 111 | return lines 112 | end 113 | 114 | function Config.setup(custom_config) 115 | local user_config = vim.tbl_deep_extend("keep", custom_config or {}, default_config) 116 | local config = {} 117 | 118 | function config.merged() 119 | return user_config 120 | end 121 | 122 | function config.level() 123 | local level = user_config.level 124 | if type(level) == "number" then 125 | return level 126 | end 127 | return vim.log.levels[vim.fn.toupper(level)] or vim.log.levels.INFO 128 | end 129 | 130 | function config.fps() 131 | return user_config.fps 132 | end 133 | 134 | function config.background_colour() 135 | return tonumber(user_config.background_colour():gsub("#", "0x"), 16) 136 | end 137 | 138 | function config.time_formats() 139 | return user_config.time_formats 140 | end 141 | 142 | function config.icons() 143 | return user_config.icons 144 | end 145 | 146 | function config.stages() 147 | return user_config.stages 148 | end 149 | 150 | function config.default_timeout() 151 | return user_config.timeout 152 | end 153 | 154 | function config.on_open() 155 | return user_config.on_open 156 | end 157 | 158 | function config.top_down() 159 | return user_config.top_down 160 | end 161 | 162 | function config.merge_duplicates() 163 | return user_config.merge_duplicates 164 | end 165 | 166 | function config.on_close() 167 | return user_config.on_close 168 | end 169 | 170 | function config.render() 171 | return user_config.render 172 | end 173 | 174 | function config.minimum_width() 175 | return user_config.minimum_width 176 | end 177 | 178 | function config.max_width() 179 | return util.is_callable(user_config.max_width) and user_config.max_width() 180 | or user_config.max_width 181 | end 182 | 183 | function config.max_height() 184 | return util.is_callable(user_config.max_height) and user_config.max_height() 185 | or user_config.max_height 186 | end 187 | 188 | local stages = config.stages() 189 | 190 | local needs_opacity = 191 | vim.tbl_contains({ BUILTIN_STAGES.FADE_IN_SLIDE_OUT, BUILTIN_STAGES.FADE }, stages) 192 | 193 | if needs_opacity and not vim.opt.termguicolors:get() and vim.fn.has("nvim-0.10") == 0 then 194 | user_config.stages = BUILTIN_STAGES.STATIC 195 | vim.schedule(function() 196 | vim.notify( 197 | "Opacity changes require termguicolors to be set.\nChange to different animation stages or set termguicolors to disable this warning", 198 | "warn", 199 | { title = "nvim-notify" } 200 | ) 201 | end) 202 | end 203 | 204 | user_config.background_colour = validate_highlight(user_config.background_colour, needs_opacity) 205 | 206 | return config 207 | end 208 | 209 | return Config 210 | -------------------------------------------------------------------------------- /lua/notify/init.lua: -------------------------------------------------------------------------------- 1 | ---@text 2 | --- A fancy, configurable notification manager for NeoVim 3 | 4 | local config = require("notify.config") 5 | local instance = require("notify.instance") 6 | 7 | ---@class notify 8 | local notify = {} 9 | 10 | local global_instance, global_config 11 | 12 | --- Configure nvim-notify 13 | --- See: ~ 14 | --- |notify.Config| 15 | --- |notify-render| 16 | --- 17 | ---@param user_config notify.Config|nil 18 | ---@eval return require('notify.config')._format_default() 19 | function notify.setup(user_config) 20 | global_instance, global_config = notify.instance(user_config) 21 | local has_telescope = (vim.fn.exists("g:loaded_telescope") == 1) 22 | if has_telescope then 23 | require("telescope").load_extension("notify") 24 | end 25 | vim.cmd([[command! Notifications :lua require("notify")._print_history()<CR>]]) 26 | vim.cmd([[command! NotificationsClear :lua require("notify").clear_history()<CR>]]) 27 | end 28 | 29 | function notify._config() 30 | return config.setup(global_config) 31 | end 32 | 33 | ---@class notify.Options 34 | --- Options for an individual notification 35 | ---@field title string 36 | ---@field icon string 37 | ---@field timeout number|boolean Time to show notification in milliseconds, set to false to disable timeout. 38 | ---@field on_open function Callback for when window opens, receives window as argument. 39 | ---@field on_close function Callback for when window closes, receives window as argument. 40 | ---@field keep function Function to keep the notification window open after timeout, should return boolean. 41 | ---@field render function|string Function to render a notification buffer. 42 | ---@field replace integer|notify.Record Notification record or the record `id` field. Replace an existing notification if still open. All arguments not given are inherited from the replaced notification including message and level. 43 | ---@field hide_from_history boolean Hide this notification from the history 44 | ---@field animate boolean If false, the window will jump to the timed stage. Intended for use in blocking events (e.g. vim.fn.input) 45 | 46 | ---@class notify.Events 47 | --- Async events for a notification 48 | ---@field open function Resolves when notification is opened 49 | ---@field close function Resolved when notification is closed 50 | 51 | ---@class notify.Record 52 | --- Record of a previously sent notification 53 | ---@field id integer 54 | ---@field message string[] Lines of the message 55 | ---@field level string|integer Log level. See vim.log.levels 56 | ---@field title string[] Left and right sections of the title 57 | ---@field icon string Icon used for notification 58 | ---@field time number Time of message, as returned by `vim.fn.localtime()` 59 | ---@field render function Function to render notification buffer 60 | 61 | ---@class notify.AsyncRecord : notify.Record 62 | ---@field events notify.Events 63 | 64 | --- Display a notification. 65 | --- 66 | --- You can call the module directly rather than using this: 67 | --- >lua 68 | --- require("notify")(message, level, opts) 69 | --- < 70 | ---@param message string|string[] Notification message 71 | ---@param level string|number Log level. See vim.log.levels 72 | ---@param opts notify.Options Notification options 73 | ---@return notify.Record 74 | function notify.notify(message, level, opts) 75 | if not global_instance then 76 | notify.setup() 77 | end 78 | return global_instance.notify(message, level, opts) 79 | end 80 | 81 | --- Display a notification asynchronously 82 | --- 83 | --- This uses plenary's async library, allowing a cleaner interface for 84 | --- open/close events. You must call this function within an async context. 85 | --- 86 | --- The `on_close` and `on_open` options are not used. 87 | --- 88 | ---@param message string|string[] Notification message 89 | ---@param level string|number Log level. See vim.log.levels 90 | ---@param opts notify.Options Notification options 91 | ---@return notify.AsyncRecord 92 | function notify.async(message, level, opts) 93 | if not global_instance then 94 | notify.setup() 95 | end 96 | return global_instance.async(message, level, opts) 97 | end 98 | 99 | --- Get records of all previous notifications 100 | --- 101 | --- You can use the `:Notifications` command to display a log of previous notifications 102 | ---@param opts? notify.HistoryOpts 103 | ---@return notify.Record[] 104 | function notify.history(opts) 105 | if not global_instance then 106 | notify.setup() 107 | end 108 | return global_instance.history(opts) 109 | end 110 | 111 | ---@class notify.HistoryOpts 112 | ---@field include_hidden boolean Include notifications hidden from history 113 | 114 | --- Clear records of all previous notifications 115 | --- 116 | --- You can use the `:NotificationsClear` command to clear the log of previous notifications 117 | function notify.clear_history() 118 | if not global_instance then 119 | notify.setup() 120 | end 121 | return global_instance.clear_history() 122 | end 123 | 124 | --- Dismiss all notification windows currently displayed 125 | ---@param opts notify.DismissOpts 126 | function notify.dismiss(opts) 127 | if not global_instance then 128 | notify.setup() 129 | end 130 | return global_instance.dismiss(opts) 131 | end 132 | 133 | ---@class notify.DismissOpts 134 | ---@field pending boolean Clear pending notifications 135 | ---@field silent boolean Suppress notification that pending notifications were dismissed. 136 | 137 | --- Open a notification in a new buffer 138 | ---@param notif_id integer|notify.Record 139 | ---@param opts notify.OpenOpts 140 | ---@return notify.OpenedBuffer 141 | function notify.open(notif_id, opts) 142 | if not global_instance then 143 | notify.setup() 144 | end 145 | return global_instance.open(notif_id, opts) 146 | end 147 | 148 | ---@class notify.OpenOpts 149 | ---@field buffer integer Use this buffer, instead of creating a new one 150 | ---@field max_width integer Render message to this width (used to limit window decoration sizes) 151 | 152 | ---@class notify.OpenedBuffer 153 | ---@field buffer integer Created buffer number 154 | ---@field height integer Height of the buffer content including extmarks 155 | ---@field width integer width of the buffer content including extmarks 156 | ---@field highlights table<string, string> Highlights used for the buffer contents 157 | 158 | --- Number of notifications currently waiting to be displayed 159 | ---@return integer[] 160 | function notify.pending() 161 | if not global_instance then 162 | notify.setup() 163 | end 164 | return global_instance.pending() 165 | end 166 | 167 | function notify._print_history() 168 | if not global_instance then 169 | notify.setup() 170 | end 171 | for _, notif in ipairs(global_instance.history()) do 172 | vim.api.nvim_echo({ 173 | { 174 | vim.fn.strftime(notify._config().time_formats().notification_history, notif.time), 175 | "NotifyLogTime", 176 | }, 177 | { " ", "MsgArea" }, 178 | { notif.title[1], "NotifyLogTitle" }, 179 | { #notif.title[1] > 0 and " " or "", "MsgArea" }, 180 | { notif.icon, "Notify" .. notif.level .. "Title" }, 181 | { " ", "MsgArea" }, 182 | { notif.level, "Notify" .. notif.level .. "Title" }, 183 | { " ", "MsgArea" }, 184 | { table.concat(notif.message, "\n"), "MsgArea" }, 185 | }, false, {}) 186 | end 187 | end 188 | 189 | --- Configure an instance of nvim-notify. 190 | --- You can use this to manage a separate instance of nvim-notify with completely different configuration. 191 | --- The returned instance will have the same functions as the notify module. 192 | ---@param user_config notify.Config 193 | ---@param inherit? boolean Inherit the global configuration, default true 194 | function notify.instance(user_config, inherit) 195 | return instance(user_config, inherit, global_config) 196 | end 197 | 198 | setmetatable(notify, { 199 | __call = function(_, m, l, o) 200 | if vim.in_fast_event() or vim.fn.has("vim_starting") == 1 then 201 | vim.schedule(function() 202 | notify.notify(m, l, o) 203 | end) 204 | else 205 | return notify.notify(m, l, o) 206 | end 207 | end, 208 | }) 209 | 210 | return notify 211 | -------------------------------------------------------------------------------- /lua/notify/instance.lua: -------------------------------------------------------------------------------- 1 | local stages = require("notify.stages") 2 | local config = require("notify.config") 3 | local Notification = require("notify.service.notification") 4 | local WindowAnimator = require("notify.windows") 5 | local NotificationService = require("notify.service") 6 | local NotificationBuf = require("notify.service.buffer") 7 | local stage_util = require("notify.stages.util") 8 | 9 | local notif_cmp_keys = { 10 | "level", 11 | "message", 12 | "title", 13 | "icon", 14 | } 15 | 16 | ---@param n1 notify.Notification 17 | ---@param n2 notify.Notification 18 | ---@return boolean 19 | local function notifications_equal(n1, n2) 20 | for _, key in ipairs(notif_cmp_keys) do 21 | local v1 = n1[key] 22 | local v2 = n2[key] 23 | -- NOTE: Notification:new adds time string which causes not-equality, so compare only left title (1st element) 24 | if key == "title" then 25 | v1 = v1[1] 26 | v2 = v2[1] 27 | end 28 | if not vim.deep_equal(v1, v2) then 29 | return false 30 | end 31 | end 32 | return true 33 | end 34 | 35 | ---@param user_config notify.Config 36 | ---@param inherit? boolean Inherit the global configuration, default true 37 | ---@param global_config notify.Config 38 | return function(user_config, inherit, global_config) 39 | ---@type notify.Notification[] 40 | local notifications = {} 41 | 42 | user_config = user_config or {} 43 | if inherit ~= false and global_config then 44 | user_config = vim.tbl_deep_extend("force", global_config, user_config) 45 | end 46 | 47 | local instance_config = config.setup(user_config) 48 | 49 | local animator_stages = instance_config.stages() 50 | local direction = instance_config.top_down() and stage_util.DIRECTION.TOP_DOWN 51 | or stage_util.DIRECTION.BOTTOM_UP 52 | 53 | animator_stages = type(animator_stages) == "string" and stages[animator_stages](direction) 54 | or animator_stages 55 | local animator = WindowAnimator(animator_stages, instance_config) 56 | local service = NotificationService(instance_config, animator) 57 | 58 | local instance = {} 59 | 60 | local function get_render(render) 61 | if type(render) == "function" then 62 | return render 63 | end 64 | return require("notify.render")[render] 65 | end 66 | 67 | ---@param notif notify.Notification 68 | ---@return notify.Notification? 69 | local function find_duplicate(notif) 70 | for _, buf in pairs(animator.notif_bufs) do 71 | if notifications_equal(buf._notif, notif) then 72 | return buf._notif 73 | end 74 | end 75 | end 76 | 77 | function instance.notify(message, level, opts) 78 | opts = opts or {} 79 | 80 | if opts.replace then 81 | if type(opts.replace) == "table" then 82 | opts.replace = opts.replace.id 83 | end 84 | local existing = notifications[opts.replace] 85 | if not existing then 86 | vim.notify("Invalid notification to replace", "error", { title = "nvim-notify" }) 87 | return 88 | end 89 | local notif_keys = { 90 | "title", 91 | "icon", 92 | "timeout", 93 | "keep", 94 | "on_open", 95 | "on_close", 96 | "render", 97 | "hide_from_history", 98 | "animate", 99 | } 100 | message = message or existing.message 101 | level = level or existing.level 102 | for _, key in ipairs(notif_keys) do 103 | if opts[key] == nil then 104 | opts[key] = existing[key] 105 | end 106 | end 107 | end 108 | 109 | opts.render = get_render(opts.render or instance_config.render()) 110 | local id = #notifications + 1 111 | local notification = Notification(id, message, level, opts, instance_config) 112 | table.insert(notifications, notification) 113 | local level_num = vim.log.levels[notification.level] 114 | 115 | if not opts.replace and instance_config.merge_duplicates() then 116 | local dup = find_duplicate(notification) 117 | if dup then 118 | dup.duplicates = dup.duplicates or { dup.id } 119 | table.insert(dup.duplicates, notification.id) 120 | notification.duplicates = dup.duplicates 121 | 122 | local min_dups = instance_config.merge_duplicates() 123 | if min_dups == true or #notification.duplicates >= min_dups + 1 then 124 | opts.replace = dup.id 125 | end 126 | end 127 | end 128 | 129 | if opts.replace then 130 | service:replace(opts.replace, notification) 131 | elseif not level_num or level_num >= instance_config.level() then 132 | service:push(notification) 133 | end 134 | return { 135 | id = id, 136 | } 137 | end 138 | 139 | ---@param notif_id integer|notify.Record 140 | ---@param opts table 141 | function instance.open(notif_id, opts) 142 | opts = opts or {} 143 | if type(notif_id) == "table" then 144 | notif_id = notif_id.id 145 | end 146 | local notif = notifications[notif_id] 147 | if not notif then 148 | vim.notify( 149 | "Invalid notification id: " .. notif_id, 150 | vim.log.levels.WARN, 151 | { title = "nvim-notify" } 152 | ) 153 | return 154 | end 155 | local buf = opts.buffer or vim.api.nvim_create_buf(false, true) 156 | local notif_buf = 157 | NotificationBuf(buf, notif, vim.tbl_extend("keep", opts, { config = instance_config })) 158 | notif_buf:render() 159 | return { 160 | buffer = buf, 161 | height = notif_buf:height(), 162 | width = notif_buf:width(), 163 | highlights = { 164 | body = notif_buf.highlights.body, 165 | border = notif_buf.highlights.border, 166 | title = notif_buf.highlights.title, 167 | icon = notif_buf.highlights.icon, 168 | }, 169 | } 170 | end 171 | 172 | function instance.async(message, level, opts) 173 | opts = opts or {} 174 | local async = require("plenary.async") 175 | local send_close, wait_close = async.control.channel.oneshot() 176 | opts.on_close = send_close 177 | 178 | local send_open, wait_open = async.control.channel.oneshot() 179 | opts.on_open = send_open 180 | 181 | async.util.scheduler() 182 | local record = instance.notify(message, level, opts) 183 | return vim.tbl_extend("error", record, { 184 | events = { 185 | open = wait_open, 186 | close = wait_close, 187 | }, 188 | }) 189 | end 190 | 191 | function instance.history(args) 192 | args = args or {} 193 | local records = {} 194 | for _, notif in ipairs(notifications) do 195 | if not notif.hide_from_history or args.include_hidden then 196 | records[#records + 1] = notif:record() 197 | end 198 | end 199 | return records 200 | end 201 | 202 | function instance.dismiss(opts) 203 | if service then 204 | service:dismiss(opts or {}) 205 | end 206 | end 207 | 208 | function instance.pending() 209 | return service and service:pending() or {} 210 | end 211 | 212 | function instance.clear_history() 213 | notifications = {} 214 | end 215 | 216 | setmetatable(instance, { 217 | __call = function(_, m, l, o) 218 | if vim.in_fast_event() then 219 | vim.schedule(function() 220 | instance.notify(m, l, o) 221 | end) 222 | else 223 | return instance.notify(m, l, o) 224 | end 225 | end, 226 | }) 227 | return instance, instance_config.merged() 228 | end 229 | -------------------------------------------------------------------------------- /lua/notify/integrations/fzf.lua: -------------------------------------------------------------------------------- 1 | local notify = require("notify") 2 | local time_format = notify._config().time_formats().notification 3 | 4 | local builtin = require("fzf-lua.previewer.builtin") 5 | local fzf = require("fzf-lua") 6 | 7 | local M = {} 8 | 9 | ---@alias NotifyMessage {id: number, message: notify.Record, texts: string[][]} 10 | ---@alias NotifyEntry {ordinal: string, display: string} 11 | 12 | ---@param message NotifyMessage 13 | ---@return NotifyEntry 14 | function M.entry(message) 15 | local display = message.id .. " " ---@type string 16 | local content = "" 17 | for _, text in ipairs(message.texts) do 18 | ---@type string? 19 | local hl_group = text[2] 20 | display = display .. (hl_group and fzf.utils.ansi_from_hl(hl_group, text[1]) or text[1]) 21 | content = content .. text[1] 22 | end 23 | 24 | return { 25 | message = message.message, 26 | ordinal = content, 27 | display = display, 28 | } 29 | end 30 | 31 | function M.find() 32 | local messages = notify.history() 33 | 34 | ---@type table<number, NotifyEntry> 35 | local ret = {} 36 | 37 | for _, message in ipairs(messages) do 38 | ret[message.id] = M.entry({ 39 | id = message.id, 40 | message = message, 41 | texts = { 42 | { vim.fn.strftime(time_format, message.time) .. " ", "NotifyLogTime" }, 43 | { message.title[1] .. " ", "NotifyLogTitle" }, 44 | { message.icon .. " ", "Notify" .. message.level .. "Title" }, 45 | { message.level .. " ", "Notify" .. message.level .. "Title" }, 46 | { message.message[1], "Notify" .. message.level .. "Body" }, 47 | }, 48 | }) 49 | end 50 | 51 | return ret 52 | end 53 | 54 | function M.parse_entry(messages, entry_str) 55 | local id = tonumber(entry_str:match("^%d+")) 56 | local entry = messages[id] 57 | return entry 58 | end 59 | 60 | ---@param messages table<number, NotifyEntry> 61 | function M.previewer(messages) 62 | local previewer = builtin.buffer_or_file:extend() 63 | 64 | function previewer:new(o, opts, fzf_win) 65 | previewer.super.new(self, o, opts, fzf_win) 66 | self.title = "Message" 67 | setmetatable(self, previewer) 68 | return self 69 | end 70 | 71 | function previewer:populate_preview_buf(entry_str) 72 | local buf = self:get_tmp_buffer() 73 | local entry = M.parse_entry(messages, entry_str) 74 | 75 | if entry then 76 | local notification = entry.message 77 | notify.open(notification, { buffer = buf, max_width = 0 }) 78 | end 79 | 80 | self:set_preview_buf(buf) 81 | self.win:update_preview_title(" Message ") 82 | self.win:update_preview_scrollbar() 83 | self.win:set_winopts(self.win.preview_winid, { wrap = true }) 84 | end 85 | 86 | return previewer 87 | end 88 | 89 | ---@param opts? table<string, any> 90 | function M.open(opts) 91 | local messages = M.find() 92 | opts = vim.tbl_deep_extend("force", opts or {}, { 93 | prompt = false, 94 | winopts = { 95 | title = " Filter Notifications ", 96 | title_pos = "center", 97 | preview = { 98 | title = " Message ", 99 | title_pos = "center", 100 | }, 101 | }, 102 | previewer = M.previewer(messages), 103 | fzf_opts = { 104 | ["--no-multi"] = "", 105 | ["--with-nth"] = "2..", 106 | }, 107 | actions = { 108 | default = function(selected) 109 | if #selected == 0 then 110 | return 111 | end 112 | local notification = M.parse_entry(messages, selected[1]).message 113 | 114 | local opened_buffer = notify.open(notification) 115 | 116 | local lines = vim.opt.lines:get() 117 | local cols = vim.opt.columns:get() 118 | 119 | local win = vim.api.nvim_open_win(opened_buffer.buffer, true, { 120 | relative = "editor", 121 | row = (lines - opened_buffer.height) / 2, 122 | col = (cols - opened_buffer.width) / 2, 123 | height = opened_buffer.height, 124 | width = opened_buffer.width, 125 | border = "rounded", 126 | style = "minimal", 127 | }) 128 | -- vim.wo does not behave like setlocal, thus we use setwinvar to set local 129 | -- only options. Otherwise our changes would affect subsequently opened 130 | -- windows. 131 | -- see e.g. neovim#14595 132 | vim.fn.setwinvar( 133 | win, 134 | "&winhl", 135 | "Normal:" 136 | .. opened_buffer.highlights.body 137 | .. ",FloatBorder:" 138 | .. opened_buffer.highlights.border 139 | ) 140 | vim.fn.setwinvar(win, "&wrap", 0) 141 | end, 142 | }, 143 | }) 144 | local lines = vim.tbl_map(function(entry) 145 | return entry.display 146 | end, vim.tbl_values(messages)) 147 | return fzf.fzf_exec(lines, opts) 148 | end 149 | 150 | return M 151 | -------------------------------------------------------------------------------- /lua/notify/integrations/init.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | function M.pick() 4 | if pcall(_G.require, "telescope.config") then 5 | require("telescope").extensions.notify.noitfy({}) 6 | elseif pcall(_G.require, "fzf-lua") then 7 | require("notify.integrations.fzf").open({}) 8 | else 9 | error("No picker available") 10 | end 11 | end 12 | return M 13 | -------------------------------------------------------------------------------- /lua/notify/render/base.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local namespace = vim.api.nvim_create_namespace("nvim-notify") 4 | 5 | function M.namespace() 6 | return namespace 7 | end 8 | 9 | return M 10 | -------------------------------------------------------------------------------- /lua/notify/render/compact.lua: -------------------------------------------------------------------------------- 1 | local base = require("notify.render.base") 2 | 3 | return function(bufnr, notif, highlights) 4 | local namespace = base.namespace() 5 | local icon = notif.icon 6 | local title = notif.title[1] 7 | 8 | if type(title) == "string" and notif.duplicates then 9 | title = string.format("%s x%d", title, #notif.duplicates) 10 | end 11 | 12 | local prefix 13 | if type(title) == "string" and #title > 0 then 14 | prefix = string.format("%s | %s:", icon, title) 15 | else 16 | prefix = string.format("%s |", icon) 17 | end 18 | local message = { 19 | string.format("%s %s", prefix, notif.message[1]), 20 | unpack(notif.message, 2), 21 | } 22 | 23 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, message) 24 | 25 | local icon_length = string.len(icon) 26 | local prefix_length = string.len(prefix) 27 | 28 | vim.api.nvim_buf_set_extmark(bufnr, namespace, 0, 0, { 29 | hl_group = highlights.icon, 30 | end_col = icon_length + 1, 31 | priority = 50, 32 | }) 33 | vim.api.nvim_buf_set_extmark(bufnr, namespace, 0, icon_length + 1, { 34 | hl_group = highlights.title, 35 | end_col = prefix_length + 1, 36 | priority = 50, 37 | }) 38 | vim.api.nvim_buf_set_extmark(bufnr, namespace, 0, prefix_length + 1, { 39 | hl_group = highlights.body, 40 | end_line = #message, 41 | priority = 50, 42 | }) 43 | end 44 | -------------------------------------------------------------------------------- /lua/notify/render/default.lua: -------------------------------------------------------------------------------- 1 | local api = vim.api 2 | local base = require("notify.render.base") 3 | local util = require("notify.util") 4 | 5 | return function(bufnr, notif, highlights, config) 6 | local left_icon = notif.icon == "" and "" or notif.icon .. " " 7 | local max_message_width = util.max_line_width(notif.message) 8 | 9 | local right_title = notif.title[2] 10 | local left_title = notif.title[1] 11 | if notif.duplicates then 12 | left_title = string.format("%s (x%d)", left_title, #notif.duplicates) 13 | end 14 | local title_accum = vim.api.nvim_strwidth(left_icon) 15 | + vim.api.nvim_strwidth(right_title) 16 | + vim.api.nvim_strwidth(left_title) 17 | 18 | local left_buffer = string.rep(" ", math.max(0, max_message_width - title_accum)) 19 | 20 | local namespace = base.namespace() 21 | api.nvim_buf_set_lines(bufnr, 0, 1, false, { "", "" }) 22 | 23 | local virt_text = left_icon == "" and {} or { { " " }, { left_icon, highlights.icon } } 24 | table.insert(virt_text, { left_title .. left_buffer, highlights.title }) 25 | api.nvim_buf_set_extmark(bufnr, namespace, 0, 0, { 26 | virt_text = virt_text, 27 | virt_text_win_col = 0, 28 | priority = 10, 29 | }) 30 | api.nvim_buf_set_extmark(bufnr, namespace, 0, 0, { 31 | virt_text = { { " " }, { right_title, highlights.title }, { " " } }, 32 | virt_text_pos = "right_align", 33 | priority = 10, 34 | }) 35 | api.nvim_buf_set_extmark(bufnr, namespace, 1, 0, { 36 | virt_text = { 37 | { 38 | string.rep( 39 | "━", 40 | math.max(vim.api.nvim_strwidth(left_buffer) + title_accum + 2, config.minimum_width()) 41 | ), 42 | highlights.border, 43 | }, 44 | }, 45 | virt_text_win_col = 0, 46 | priority = 10, 47 | }) 48 | api.nvim_buf_set_lines(bufnr, 2, -1, false, notif.message) 49 | 50 | api.nvim_buf_set_extmark(bufnr, namespace, 2, 0, { 51 | hl_group = highlights.body, 52 | end_line = 1 + #notif.message, 53 | end_col = #notif.message[#notif.message], 54 | priority = 50, -- Allow treesitter to override 55 | }) 56 | end 57 | -------------------------------------------------------------------------------- /lua/notify/render/init.lua: -------------------------------------------------------------------------------- 1 | ---@tag notify-render 2 | ---@text 3 | --- Notification buffer rendering 4 | --- 5 | --- Custom rendering can be provided by both the user config in the setup or on 6 | --- an individual notification using the `render` key. 7 | --- The key can either be the name of a built-in renderer or a custom function. 8 | --- 9 | --- Built-in renderers: 10 | --- - `"default"` 11 | --- - `"minimal"` 12 | --- - `"simple"` 13 | --- - `"compact"` 14 | --- - `"wrapped-compact"` 15 | --- - `"wrapped-default"` 16 | --- 17 | --- Custom functions should accept a buffer, a notification record and a highlights table 18 | --- 19 | --- > 20 | --- render: fun(buf: integer, notification: notify.Record, highlights: notify.Highlights, config) 21 | --- < 22 | --- You should use the provided highlight groups to take advantage of opacity 23 | --- changes as they will be updated as the notification is animated 24 | 25 | ---@class notify.Highlights 26 | ---@field title string 27 | ---@field icon string 28 | ---@field border string 29 | ---@field body string 30 | 31 | local M = {} 32 | 33 | setmetatable(M, { 34 | __index = function(_, key) 35 | return require("notify.render." .. key) 36 | end, 37 | }) 38 | 39 | return M 40 | -------------------------------------------------------------------------------- /lua/notify/render/minimal.lua: -------------------------------------------------------------------------------- 1 | local api = vim.api 2 | local base = require("notify.render.base") 3 | 4 | return function(bufnr, notif, highlights) 5 | local message = notif.message 6 | if notif.duplicates then 7 | message = { 8 | string.format("x%d %s", #notif.duplicates, notif.message[1]), 9 | unpack(notif.message, 2), 10 | } 11 | end 12 | 13 | local namespace = base.namespace() 14 | api.nvim_buf_set_lines(bufnr, 0, -1, false, message) 15 | 16 | api.nvim_buf_set_extmark(bufnr, namespace, 0, 0, { 17 | hl_group = highlights.icon, 18 | end_line = #message - 1, 19 | end_col = #message[#message], 20 | priority = 50, 21 | }) 22 | end 23 | -------------------------------------------------------------------------------- /lua/notify/render/simple.lua: -------------------------------------------------------------------------------- 1 | local api = vim.api 2 | local base = require("notify.render.base") 3 | local util = require("notify.util") 4 | 5 | return function(bufnr, notif, highlights, config) 6 | local max_message_width = util.max_line_width(notif.message) 7 | 8 | local title = notif.title[1] 9 | if notif.duplicates then 10 | title = string.format("%s (x%d)", title, #notif.duplicates) 11 | end 12 | local title_accum = vim.api.nvim_strwidth(title) 13 | 14 | local title_buffer = string.rep( 15 | " ", 16 | (math.max(max_message_width, title_accum, config.minimum_width()) - title_accum) / 2 17 | ) 18 | 19 | local namespace = base.namespace() 20 | 21 | api.nvim_buf_set_lines(bufnr, 0, 1, false, { "", "" }) 22 | api.nvim_buf_set_extmark(bufnr, namespace, 0, 0, { 23 | virt_text = { 24 | { title_buffer .. title .. title_buffer, highlights.title }, 25 | }, 26 | virt_text_win_col = 0, 27 | priority = 10, 28 | }) 29 | api.nvim_buf_set_extmark(bufnr, namespace, 1, 0, { 30 | virt_text = { 31 | { 32 | string.rep("━", math.max(max_message_width, title_accum, config.minimum_width())), 33 | highlights.border, 34 | }, 35 | }, 36 | virt_text_win_col = 0, 37 | priority = 10, 38 | }) 39 | api.nvim_buf_set_lines(bufnr, 2, -1, false, notif.message) 40 | 41 | api.nvim_buf_set_extmark(bufnr, namespace, 2, 0, { 42 | hl_group = highlights.body, 43 | end_line = 1 + #notif.message, 44 | end_col = #notif.message[#notif.message], 45 | priority = 50, 46 | }) 47 | end 48 | -------------------------------------------------------------------------------- /lua/notify/render/wrapped-compact.lua: -------------------------------------------------------------------------------- 1 | -- WRAPS TEXT AND ADDS SOME PADDING. 2 | -------------------------------------------------------------------------------- 3 | 4 | ---@param line string 5 | ---@param width number 6 | ---@return string[] 7 | local function split_length(line, width) 8 | local text = {} 9 | local next_line 10 | while true do 11 | if #line == 0 then 12 | return text 13 | end 14 | next_line, line = line:sub(1, width), line:sub(width + 1) 15 | text[#text + 1] = next_line 16 | end 17 | end 18 | 19 | ---@param lines string[] 20 | ---@param max_width number 21 | ---@return string[] 22 | local function custom_wrap(lines, max_width) 23 | local right_pad = " " 24 | local wrapped_lines = {} 25 | for _, line in pairs(lines) do 26 | local new_lines = split_length(line, max_width - #right_pad) 27 | for _, nl in ipairs(new_lines) do 28 | table.insert(wrapped_lines, nl:gsub("^%s+", "") .. right_pad) 29 | end 30 | end 31 | return wrapped_lines 32 | end 33 | 34 | ---@param bufnr number 35 | ---@param notif object 36 | ---@param highlights object 37 | ---@param config object plugin config_obj 38 | return function(bufnr, notif, highlights, config) 39 | local namespace = require("notify.render.base").namespace() 40 | local icon = notif.icon 41 | local icon_length = #icon 42 | local prefix = "" 43 | local prefix_length = 0 44 | local message = custom_wrap(notif.message, config.max_width() or 80) 45 | local title = notif.title[1] 46 | local default_titles = { "Error", "Warning", "Notify" } 47 | local has_valid_manual_title = type(title) == "string" 48 | and #title > 0 49 | and not vim.tbl_contains(default_titles, title) 50 | 51 | if has_valid_manual_title then 52 | prefix = string.format("%s %s ", icon, title) 53 | if notif.duplicates then 54 | prefix = string.format("%s x%d", prefix, #notif.duplicates) 55 | end 56 | prefix_length = #prefix 57 | table.insert(message, 1, prefix) 58 | end 59 | 60 | message[1] = " " .. message[1] 61 | vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, message) 62 | 63 | if has_valid_manual_title then 64 | vim.api.nvim_buf_set_extmark(bufnr, namespace, 0, 0, { 65 | hl_group = highlights.icon, 66 | end_col = icon_length + 1, 67 | priority = 50, 68 | }) 69 | vim.api.nvim_buf_set_extmark(bufnr, namespace, 0, icon_length + 1, { 70 | hl_group = highlights.title, 71 | end_col = prefix_length + 1, 72 | priority = 50, 73 | }) 74 | end 75 | 76 | vim.api.nvim_buf_set_extmark(bufnr, namespace, 0, prefix_length + 1, { 77 | hl_group = highlights.body, 78 | end_line = #message, 79 | priority = 50, 80 | }) 81 | 82 | -- padding to the left/right 83 | for ln = 1, #message do 84 | vim.api.nvim_buf_set_extmark(bufnr, namespace, ln, 0, { 85 | virt_text = { { " ", highlights.body } }, 86 | virt_text_pos = "inline", 87 | }) 88 | vim.api.nvim_buf_set_extmark(bufnr, namespace, ln, 0, { 89 | virt_text = { { " ", highlights.body } }, 90 | virt_text_pos = "right_align", 91 | }) 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lua/notify/render/wrapped-default.lua: -------------------------------------------------------------------------------- 1 | local vim_api = vim.api 2 | local base = require("notify.render.base") 3 | 4 | ---@param line string 5 | ---@param width number 6 | ---@return table 7 | local function split_length(line, width) 8 | local text = {} 9 | local next_line 10 | while true do 11 | if #line == 0 then 12 | return text 13 | end 14 | next_line, line = line:sub(1, width), line:sub(width + 1) 15 | text[#text + 1] = next_line 16 | end 17 | end 18 | 19 | ---@param lines string[] 20 | ---@param max_width number 21 | ---@return table 22 | local function custom_wrap(lines, max_width) 23 | local wrapped_lines = {} 24 | for _, line in pairs(lines) do 25 | local new_lines = split_length(line, max_width) 26 | for _, nl in ipairs(new_lines) do 27 | nl = nl:gsub("^%s*", " "):gsub("%s*quot;, " ") 28 | table.insert(wrapped_lines, nl) 29 | end 30 | end 31 | return wrapped_lines 32 | end 33 | 34 | ---@param bufnr number 35 | ---@param notif notify.Record 36 | ---@param highlights notify.Highlights 37 | ---@param config notify.Config 38 | return function(bufnr, notif, highlights, config) 39 | local namespace = base.namespace() 40 | local icon = notif.icon .. " " 41 | local title = notif.title[1] or "Notify" 42 | 43 | local terminal_width = vim.o.columns 44 | local default_max_width = math.floor((terminal_width * 30) / 100) 45 | local max_width = config.max_width and config.max_width() or default_max_width 46 | 47 | -- Ensure max_width is within bounds 48 | max_width = math.max(10, math.min(max_width, terminal_width - 1)) 49 | 50 | local message = custom_wrap(notif.message, max_width) 51 | 52 | local prefix = string.format(" %s %s", icon, title) 53 | table.insert(message, 1, prefix) 54 | table.insert(message, 2, string.rep("━", max_width)) 55 | 56 | vim_api.nvim_buf_set_lines(bufnr, 0, -1, false, message) 57 | 58 | local prefix_length = vim.str_utfindex(prefix) 59 | prefix_length = math.min(prefix_length, max_width - 1) 60 | 61 | vim_api.nvim_buf_set_extmark(bufnr, namespace, 0, 0, { 62 | virt_text = { 63 | { " " }, 64 | { icon, highlights.icon }, 65 | { title, highlights.title }, 66 | { " " }, 67 | }, 68 | virt_text_win_col = 0, 69 | priority = 10, 70 | }) 71 | 72 | vim_api.nvim_buf_set_extmark(bufnr, namespace, 1, 0, { 73 | virt_text = { 74 | { string.rep("━", max_width), highlights.border }, 75 | }, 76 | virt_text_win_col = 0, 77 | priority = 10, 78 | }) 79 | 80 | vim_api.nvim_buf_set_extmark(bufnr, namespace, 2, 0, { 81 | hl_group = highlights.body, 82 | end_line = #message, 83 | end_col = 0, 84 | priority = 50, 85 | }) 86 | end 87 | -------------------------------------------------------------------------------- /lua/notify/service/buffer/highlights.lua: -------------------------------------------------------------------------------- 1 | local util = require("notify.util") 2 | 3 | ---@class NotifyBufHighlights 4 | ---@field groups table 5 | ---@field opacity number 6 | ---@field title string 7 | ---@field border string 8 | ---@field icon string 9 | ---@field body string 10 | ---@field buffer number 11 | ---@field _config table 12 | local NotifyBufHighlights = {} 13 | 14 | function NotifyBufHighlights:new(level, buffer, config) 15 | local function linked_group(section) 16 | local orig = "Notify" .. level .. section 17 | if vim.fn.hlID(orig) == 0 then 18 | orig = "NotifyINFO" .. section 19 | end 20 | local new = orig .. buffer 21 | 22 | vim.api.nvim_set_hl(0, new, { link = orig }) 23 | local hl = vim.api.nvim_get_hl(0, { name = orig, create = false, link = false }) 24 | -- Removes the unwanted 'default' key, as we will copy the table for updating the highlight later. 25 | hl.default = nil 26 | 27 | return new, hl 28 | end 29 | 30 | local title, title_def = linked_group("Title") 31 | local border, border_def = linked_group("Border") 32 | local body, body_def = linked_group("Body") 33 | local icon, icon_def = linked_group("Icon") 34 | 35 | local groups = { 36 | [title] = title_def, 37 | [border] = border_def, 38 | [body] = body_def, 39 | [icon] = icon_def, 40 | } 41 | local buf_highlights = { 42 | groups = groups, 43 | opacity = 100, 44 | border = border, 45 | body = body, 46 | title = title, 47 | icon = icon, 48 | buffer = buffer, 49 | _config = config, 50 | } 51 | self.__index = self 52 | setmetatable(buf_highlights, self) 53 | return buf_highlights 54 | end 55 | 56 | function NotifyBufHighlights:_redefine_treesitter() 57 | local buf_highlighter = require("vim.treesitter.highlighter").active[self.buffer] 58 | 59 | if not buf_highlighter then 60 | return 61 | end 62 | local render_namespace = vim.api.nvim_create_namespace("notify-treesitter-override") 63 | vim.api.nvim_buf_clear_namespace(self.buffer, render_namespace, 0, -1) 64 | 65 | local function link(orig) 66 | local new = orig .. self.buffer 67 | if self.groups[new] then 68 | return new 69 | end 70 | vim.api.nvim_set_hl(0, new, { link = orig }) 71 | self.groups[new] = vim.api.nvim_get_hl(0, { name = new, link = false }) 72 | return new 73 | end 74 | 75 | local matches = {} 76 | 77 | local i = 0 78 | buf_highlighter.tree:for_each_tree(function(tstree, tree) 79 | if not tstree then 80 | return 81 | end 82 | 83 | local root = tstree:root() 84 | 85 | local query = buf_highlighter:get_query(tree:lang()) 86 | 87 | -- Some injected languages may not have highlight queries. 88 | if not query:query() then 89 | return 90 | end 91 | 92 | local iter = query:query():iter_captures(root, buf_highlighter.bufnr) 93 | 94 | for capture, node, metadata in iter do 95 | -- Wait until we get at least a single capture as we don't know when parsing is complete. 96 | self._treesitter_redefined = true 97 | local hl = query.hl_cache[capture] 98 | 99 | if hl then 100 | i = i + 1 101 | local c = query._query.captures[capture] -- name of the capture in the query 102 | if c ~= nil then 103 | local capture_hl 104 | -- Removed in nightly with change of highlight names to @... 105 | -- https://github.com/neovim/neovim/pull/19931 106 | if query._get_hl_from_capture then 107 | local general_hl, is_vim_hl = query:_get_hl_from_capture(capture) 108 | capture_hl = is_vim_hl and general_hl or (tree:lang() .. general_hl) 109 | else 110 | capture_hl = query._query.captures[capture] 111 | if not vim.startswith(capture_hl, "_") then 112 | capture_hl = "@" .. capture_hl .. "." .. tree:lang() 113 | end 114 | end 115 | 116 | local start_row, start_col, end_row, end_col = node:range() 117 | local custom_hl = link(capture_hl) 118 | 119 | vim.api.nvim_buf_set_extmark(self.buffer, render_namespace, start_row, start_col, { 120 | end_row = end_row, 121 | end_col = end_col, 122 | hl_group = custom_hl, 123 | -- TODO: Not sure how neovim's highlighter doesn't have issues with overriding highlights 124 | -- Three marks on same region always show the second for some reason AFAICT 125 | priority = tonumber(metadata.priority) or i + 200, 126 | conceal = metadata.conceal, 127 | }) 128 | end 129 | end 130 | end 131 | end, true) 132 | return matches 133 | end 134 | 135 | function NotifyBufHighlights:set_opacity(alpha) 136 | if 137 | not self._treesitter_redefined 138 | and vim.api.nvim_get_option_value("filetype", { buf = self.buffer }) ~= "notify" 139 | then 140 | self:_redefine_treesitter() 141 | end 142 | self.opacity = alpha 143 | local background = self._config.background_colour() 144 | local updated = false 145 | for group, fields in pairs(self.groups) do 146 | local fg = fields.fg 147 | if fg then 148 | fg = util.blend(fg, background, alpha / 100) 149 | end 150 | local bg = fields.bg 151 | if bg then 152 | bg = util.blend(bg, background, alpha / 100) 153 | end 154 | 155 | if fg ~= fields.fg or bg ~= fields.bg then 156 | local hl = vim.tbl_extend("force", fields, { fg = fg, bg = bg }) 157 | vim.api.nvim_set_hl(0, group, hl) 158 | updated = true 159 | end 160 | end 161 | return updated 162 | end 163 | 164 | function NotifyBufHighlights:get_opacity() 165 | return self.opacity 166 | end 167 | 168 | ---@return NotifyBufHighlights 169 | return function(level, buffer, config) 170 | return NotifyBufHighlights:new(level, buffer, config) 171 | end 172 | -------------------------------------------------------------------------------- /lua/notify/service/buffer/init.lua: -------------------------------------------------------------------------------- 1 | local api = vim.api 2 | 3 | local NotifyBufHighlights = require("notify.service.buffer.highlights") 4 | 5 | ---@class NotificationBuf 6 | ---@field highlights NotifyBufHighlights 7 | ---@field _config table 8 | ---@field _notif notify.Notification 9 | ---@field _state "open" | "closed" 10 | ---@field _buffer number 11 | ---@field _height number 12 | ---@field _width number 13 | ---@field _max_width number | nil 14 | local NotificationBuf = {} 15 | 16 | local BufState = { 17 | OPEN = "open", 18 | CLOSED = "close", 19 | } 20 | 21 | function NotificationBuf:new(kwargs) 22 | local notif_buf = { 23 | _config = kwargs.config, 24 | _max_width = kwargs.max_width, 25 | _buffer = kwargs.buffer, 26 | _state = BufState.CLOSED, 27 | _width = 0, 28 | _height = 0, 29 | } 30 | setmetatable(notif_buf, self) 31 | self.__index = self 32 | notif_buf:set_notification(kwargs.notif) 33 | return notif_buf 34 | end 35 | 36 | function NotificationBuf:set_notification(notif) 37 | self._notif = notif 38 | self:_create_highlights() 39 | end 40 | 41 | function NotificationBuf:_create_highlights() 42 | local existing_opacity = self.highlights and self.highlights.opacity or 100 43 | self.highlights = NotifyBufHighlights(self._notif.level, self._buffer, self._config) 44 | if existing_opacity < 100 then 45 | self.highlights:set_opacity(existing_opacity) 46 | end 47 | end 48 | 49 | function NotificationBuf:open(win) 50 | if self._state ~= BufState.CLOSED then 51 | return 52 | end 53 | self._state = BufState.OPEN 54 | local record = self._notif:record() 55 | if self._notif.on_open then 56 | self._notif.on_open(win, record) 57 | end 58 | if self._config.on_open() then 59 | self._config.on_open()(win, record) 60 | end 61 | end 62 | 63 | function NotificationBuf:should_animate() 64 | return self._notif.animate 65 | end 66 | 67 | function NotificationBuf:close(win) 68 | if self._state ~= BufState.OPEN then 69 | return 70 | end 71 | self._state = BufState.CLOSED 72 | vim.schedule(function() 73 | if self._notif.on_close then 74 | self._notif.on_close(win) 75 | end 76 | if self._config.on_close() then 77 | self._config.on_close()(win) 78 | end 79 | pcall(api.nvim_buf_delete, self._buffer, { force = true }) 80 | end) 81 | end 82 | 83 | function NotificationBuf:height() 84 | return self._height 85 | end 86 | 87 | function NotificationBuf:width() 88 | return self._width 89 | end 90 | 91 | function NotificationBuf:should_stay() 92 | if self._notif.keep then 93 | return self._notif.keep() 94 | end 95 | return false 96 | end 97 | 98 | function NotificationBuf:render() 99 | local notif = self._notif 100 | local buf = self._buffer 101 | 102 | local render_namespace = require("notify.render.base").namespace() 103 | api.nvim_buf_set_option(buf, "filetype", "notify") 104 | api.nvim_buf_set_option(buf, "modifiable", true) 105 | api.nvim_buf_clear_namespace(buf, render_namespace, 0, -1) 106 | 107 | notif.render(buf, notif, self.highlights, self._config) 108 | 109 | api.nvim_buf_set_option(buf, "modifiable", false) 110 | 111 | local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) 112 | local width = self._config.minimum_width() 113 | for _, line in pairs(lines) do 114 | width = math.max(width, vim.api.nvim_strwidth(line)) 115 | end 116 | local success, extmarks = 117 | pcall(api.nvim_buf_get_extmarks, buf, render_namespace, 0, #lines, { details = true }) 118 | if not success then 119 | extmarks = {} 120 | end 121 | local virt_texts = {} 122 | for _, mark in ipairs(extmarks) do 123 | local details = mark[4] 124 | for _, virt_text in ipairs(details.virt_text or {}) do 125 | virt_texts[mark[2]] = (virt_texts[mark[2]] or "") .. virt_text[1] 126 | end 127 | end 128 | for _, text in pairs(virt_texts) do 129 | width = math.max(width, vim.api.nvim_strwidth(text)) 130 | end 131 | 132 | self._width = width 133 | self._height = #lines 134 | end 135 | 136 | function NotificationBuf:timeout() 137 | return self._notif.timeout 138 | end 139 | 140 | function NotificationBuf:buffer() 141 | return self._buffer 142 | end 143 | 144 | function NotificationBuf:is_valid() 145 | return self._buffer and vim.api.nvim_buf_is_valid(self._buffer) 146 | end 147 | 148 | function NotificationBuf:level() 149 | return self._notif.level 150 | end 151 | 152 | ---@param buf number 153 | ---@param notification notify.Notification;q 154 | ---@return NotificationBuf 155 | return function(buf, notification, opts) 156 | return NotificationBuf:new( 157 | vim.tbl_extend("keep", { buffer = buf, notif = notification }, opts or {}) 158 | ) 159 | end 160 | -------------------------------------------------------------------------------- /lua/notify/service/init.lua: -------------------------------------------------------------------------------- 1 | local util = require("notify.util") 2 | local NotificationBuf = require("notify.service.buffer") 3 | 4 | ---@class NotificationService 5 | ---@field private _running boolean 6 | ---@field private _pending FIFOQueue 7 | ---@field private _animator WindowAnimator 8 | ---@field private _buffers table<integer, NotificationBuf> 9 | ---@field private _fps integer 10 | local NotificationService = {} 11 | 12 | ---@class notify.ServiceConfig 13 | ---@field fps integer 14 | 15 | ---@param config notify.ServiceConfig 16 | function NotificationService:new(config, animator) 17 | local service = { 18 | _config = config, 19 | _fps = config.fps(), 20 | _animator = animator, 21 | _pending = util.FIFOQueue(), 22 | _running = false, 23 | _buffers = {}, 24 | } 25 | self.__index = self 26 | setmetatable(service, self) 27 | return service 28 | end 29 | 30 | function NotificationService:_run() 31 | self._running = true 32 | local succees, updated = 33 | pcall(self._animator.render, self._animator, self._pending, 1 / self._fps) 34 | if not succees then 35 | print("Error running notification service: " .. updated) 36 | self._running = false 37 | return 38 | end 39 | if not updated then 40 | self._running = false 41 | return 42 | end 43 | vim.defer_fn(function() 44 | self:_run() 45 | end, 1000 / self._fps) 46 | end 47 | 48 | ---@param notif notify.Notification;q 49 | ---@return integer 50 | function NotificationService:push(notif) 51 | local buf = vim.api.nvim_create_buf(false, true) 52 | local notif_buf = NotificationBuf(buf, notif, { config = self._config }) 53 | notif_buf:render() 54 | self._buffers[notif.id] = notif_buf 55 | self._pending:push(notif_buf) 56 | if not self._running then 57 | self:_run() 58 | end 59 | return buf 60 | end 61 | 62 | function NotificationService:replace(id, notif) 63 | local existing = self._buffers[id] 64 | if not (existing and existing:is_valid()) then 65 | vim.notify("No matching notification found to replace") 66 | return 67 | end 68 | existing:set_notification(notif) 69 | self._buffers[id] = nil 70 | self._buffers[notif.id] = existing 71 | pcall(existing.render, existing) 72 | local win = vim.fn.bufwinid(existing:buffer()) 73 | if win ~= -1 then 74 | -- Highlights can change name if level changed so we have to re-link 75 | -- vim.wo does not behave like setlocal, thus we use setwinvar to set a 76 | -- local option. Otherwise our changes would affect subsequently opened 77 | -- windows. 78 | -- see e.g. neovim#14595 79 | vim.fn.setwinvar( 80 | win, 81 | "&winhl", 82 | "NormalNC:NONE" .. ",Normal:" .. existing.highlights.body .. ",FloatBorder:" .. existing.highlights.border 83 | ) 84 | vim.api.nvim_win_set_option(win, 'cursorcolumn', false) 85 | vim.api.nvim_win_set_option(win, 'cursorline', false) 86 | 87 | vim.api.nvim_win_set_width(win, existing:width()) 88 | vim.api.nvim_win_set_height(win, existing:height()) 89 | 90 | self._animator:on_refresh(win) 91 | end 92 | end 93 | 94 | function NotificationService:dismiss(opts) 95 | local notif_wins = vim.tbl_keys(self._animator.win_stages) 96 | for _, win in pairs(notif_wins) do 97 | pcall(vim.api.nvim_win_close, win, true) 98 | end 99 | if opts.pending then 100 | local cleared = 0 101 | while self._pending:pop() do 102 | cleared = cleared + 1 103 | end 104 | if not opts.silent then 105 | vim.notify("Cleared " .. cleared .. " pending notifications") 106 | end 107 | end 108 | end 109 | 110 | function NotificationService:pending() 111 | return self._pending:length() 112 | end 113 | 114 | ---@return NotificationService 115 | return function(config, animator) 116 | return NotificationService:new(config, animator) 117 | end 118 | -------------------------------------------------------------------------------- /lua/notify/service/notification.lua: -------------------------------------------------------------------------------- 1 | ---@class notify.Notification 2 | ---@field id integer 3 | ---@field level string 4 | ---@field message string[] 5 | ---@field timeout number | nil 6 | ---@field title string[] 7 | ---@field icon string 8 | ---@field time number 9 | ---@field width number 10 | ---@field animate boolean 11 | ---@field hide_from_history boolean 12 | ---@field keep fun(): boolean 13 | ---@field on_open fun(win: number, record: notify.Record) | nil 14 | ---@field on_close fun(win: number, record: notify.Record) | nil 15 | ---@field render fun(buf: integer, notification: notify.Notification, highlights: table<string, string>) 16 | ---@field duplicates? integer[] shared list of duplicate notifications by id 17 | local Notification = {} 18 | 19 | local level_maps = vim.tbl_extend("keep", {}, vim.log.levels) 20 | for k, v in pairs(vim.log.levels) do 21 | level_maps[v] = k 22 | end 23 | 24 | function Notification:new(id, message, level, opts, config) 25 | if type(level) == "number" then 26 | level = level_maps[level] 27 | end 28 | if type(message) == "string" then 29 | message = vim.split(message, "\n") 30 | end 31 | level = vim.fn.toupper(level or "info") 32 | local time = vim.fn.localtime() 33 | local title = opts.title or "" 34 | if type(title) == "string" then 35 | title = { title, vim.fn.strftime(config.time_formats().notification, time) } 36 | end 37 | vim.validate('message', message, "table") 38 | vim.validate('level', level, "string") 39 | vim.validate('title', title, "table") 40 | local notif = { 41 | id = id, 42 | message = message, 43 | title = title, 44 | icon = opts.icon or config.icons()[level] or config.icons().INFO, 45 | time = time, 46 | timeout = opts.timeout, 47 | level = level, 48 | keep = opts.keep, 49 | on_open = opts.on_open, 50 | on_close = opts.on_close, 51 | animate = opts.animate ~= false, 52 | render = opts.render, 53 | hide_from_history = opts.hide_from_history, 54 | duplicates = opts.duplicates, 55 | } 56 | self.__index = self 57 | setmetatable(notif, self) 58 | return notif 59 | end 60 | 61 | function Notification:record() 62 | return { 63 | id = self.id, 64 | message = self.message, 65 | level = self.level, 66 | time = self.time, 67 | title = self.title, 68 | icon = self.icon, 69 | render = self.render, 70 | } 71 | end 72 | 73 | ---@param message string | string[] 74 | ---@param level string | number 75 | ---@param opts notify.Options 76 | return function(id, message, level, opts, config) 77 | return Notification:new(id, message, level, opts, config) 78 | end 79 | -------------------------------------------------------------------------------- /lua/notify/stages/fade.lua: -------------------------------------------------------------------------------- 1 | local stages_util = require("notify.stages.util") 2 | 3 | return function(direction) 4 | return { 5 | function(state) 6 | local next_height = state.message.height + 2 7 | local next_row = stages_util.available_slot(state.open_windows, next_height, direction) 8 | if not next_row then 9 | return nil 10 | end 11 | return { 12 | relative = "editor", 13 | anchor = "NE", 14 | width = state.message.width, 15 | height = state.message.height, 16 | col = vim.opt.columns:get(), 17 | row = next_row, 18 | border = "rounded", 19 | style = "minimal", 20 | opacity = 0, 21 | } 22 | end, 23 | function() 24 | return { 25 | opacity = { 100 }, 26 | col = { vim.opt.columns:get() }, 27 | } 28 | end, 29 | function() 30 | return { 31 | col = { vim.opt.columns:get() }, 32 | time = true, 33 | } 34 | end, 35 | function() 36 | return { 37 | opacity = { 38 | 0, 39 | frequency = 2, 40 | complete = function(cur_opacity) 41 | return cur_opacity <= 4 42 | end, 43 | }, 44 | col = { vim.opt.columns:get() }, 45 | } 46 | end, 47 | } 48 | end 49 | -------------------------------------------------------------------------------- /lua/notify/stages/fade_in_slide_out.lua: -------------------------------------------------------------------------------- 1 | local stages_util = require("notify.stages.util") 2 | 3 | return function(direction) 4 | return { 5 | function(state) 6 | local next_height = state.message.height + 2 7 | local next_row = stages_util.available_slot(state.open_windows, next_height, direction) 8 | if not next_row then 9 | return nil 10 | end 11 | return { 12 | relative = "editor", 13 | anchor = "NE", 14 | width = state.message.width, 15 | height = state.message.height, 16 | col = vim.opt.columns:get(), 17 | row = next_row, 18 | border = "rounded", 19 | style = "minimal", 20 | opacity = 0, 21 | } 22 | end, 23 | function(state, win) 24 | return { 25 | opacity = { 100 }, 26 | col = { vim.opt.columns:get() }, 27 | row = { 28 | stages_util.slot_after_previous(win, state.open_windows, direction), 29 | frequency = 3, 30 | complete = function() 31 | return true 32 | end, 33 | }, 34 | } 35 | end, 36 | function(state, win) 37 | return { 38 | col = { vim.opt.columns:get() }, 39 | time = true, 40 | row = { 41 | stages_util.slot_after_previous(win, state.open_windows, direction), 42 | frequency = 3, 43 | complete = function() 44 | return true 45 | end, 46 | }, 47 | } 48 | end, 49 | function(state, win) 50 | return { 51 | width = { 52 | 1, 53 | frequency = 2.5, 54 | damping = 0.9, 55 | complete = function(cur_width) 56 | return cur_width < 3 57 | end, 58 | }, 59 | opacity = { 60 | 0, 61 | frequency = 2, 62 | complete = function(cur_opacity) 63 | return cur_opacity <= 4 64 | end, 65 | }, 66 | col = { vim.opt.columns:get() }, 67 | row = { 68 | stages_util.slot_after_previous(win, state.open_windows, direction), 69 | frequency = 3, 70 | complete = function() 71 | return true 72 | end, 73 | }, 74 | } 75 | end, 76 | } 77 | end 78 | -------------------------------------------------------------------------------- /lua/notify/stages/init.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | ---@class MessageState 4 | ---@field width number 5 | ---@field height number 6 | 7 | ---@alias InitStage fun(open_windows: number[], message_state: MessageState): table | nil 8 | ---@alias AnimationStage fun(win: number, message_state: MessageState): table 9 | 10 | ---@alias Stage InitStage | AnimationStage 11 | ---@alias Stages Stage[] 12 | 13 | setmetatable(M, { 14 | ---@return Stages 15 | __index = function(_, key) 16 | return require("notify.stages." .. key) 17 | end, 18 | }) 19 | 20 | return M 21 | -------------------------------------------------------------------------------- /lua/notify/stages/no_animation.lua: -------------------------------------------------------------------------------- 1 | local stages_util = require("notify.stages.util") 2 | 3 | return function(direction) 4 | return { 5 | function(state) 6 | local next_height = state.message.height + 2 7 | local next_row = stages_util.available_slot(state.open_windows, next_height, direction) 8 | if not next_row then 9 | return nil 10 | end 11 | return { 12 | relative = "editor", 13 | anchor = "NE", 14 | width = state.message.width, 15 | height = state.message.height, 16 | col = vim.opt.columns:get(), 17 | row = next_row, 18 | border = "rounded", 19 | style = "minimal", 20 | } 21 | end, 22 | function(state, win) 23 | return { 24 | col = vim.opt.columns:get(), 25 | time = true, 26 | row = stages_util.slot_after_previous(win, state.open_windows, direction), 27 | } 28 | end, 29 | } 30 | end 31 | -------------------------------------------------------------------------------- /lua/notify/stages/slide.lua: -------------------------------------------------------------------------------- 1 | local stages_util = require("notify.stages.util") 2 | 3 | return function(direction) 4 | return { 5 | function(state) 6 | local next_height = state.message.height + 2 7 | local next_row = stages_util.available_slot(state.open_windows, next_height, direction) 8 | if not next_row then 9 | return nil 10 | end 11 | return { 12 | relative = "editor", 13 | anchor = "NE", 14 | width = 1, 15 | height = state.message.height, 16 | col = vim.opt.columns:get(), 17 | row = next_row, 18 | border = "rounded", 19 | style = "minimal", 20 | } 21 | end, 22 | function(state) 23 | return { 24 | width = { state.message.width, frequency = 2 }, 25 | col = { vim.opt.columns:get() }, 26 | } 27 | end, 28 | function() 29 | return { 30 | col = { vim.opt.columns:get() }, 31 | time = true, 32 | } 33 | end, 34 | function() 35 | return { 36 | width = { 37 | 1, 38 | frequency = 2.5, 39 | damping = 0.9, 40 | complete = function(cur_width) 41 | return cur_width < 2 42 | end, 43 | }, 44 | col = { vim.opt.columns:get() }, 45 | } 46 | end, 47 | } 48 | end 49 | -------------------------------------------------------------------------------- /lua/notify/stages/slide_out.lua: -------------------------------------------------------------------------------- 1 | local stages_util = require("notify.stages.util") 2 | 3 | return function(direction) 4 | return { 5 | function(state) 6 | local next_height = state.message.height + 2 7 | local next_row = stages_util.available_slot(state.open_windows, next_height, direction) 8 | if not next_row then 9 | return nil 10 | end 11 | return { 12 | relative = "editor", 13 | anchor = "NE", 14 | width = state.message.width, 15 | height = state.message.height, 16 | col = vim.opt.columns:get(), 17 | row = next_row, 18 | border = "rounded", 19 | style = "minimal", 20 | } 21 | end, 22 | function(state, win) 23 | return { 24 | col = vim.opt.columns:get(), 25 | time = true, 26 | row = stages_util.slot_after_previous(win, state.open_windows, direction), 27 | } 28 | end, 29 | function(state, win) 30 | return { 31 | width = { 32 | 1, 33 | frequency = 2.5, 34 | damping = 0.9, 35 | complete = function(cur_width) 36 | return cur_width < 3 37 | end, 38 | }, 39 | col = { vim.opt.columns:get() }, 40 | row = { 41 | stages_util.slot_after_previous(win, state.open_windows, direction), 42 | frequency = 3, 43 | complete = function() 44 | return true 45 | end, 46 | }, 47 | } 48 | end, 49 | } 50 | end 51 | -------------------------------------------------------------------------------- /lua/notify/stages/static.lua: -------------------------------------------------------------------------------- 1 | local stages_util = require("notify.stages.util") 2 | 3 | return function(direction) 4 | return { 5 | function(state) 6 | local next_height = state.message.height + 2 7 | local next_row = stages_util.available_slot(state.open_windows, next_height, direction) 8 | if not next_row then 9 | return nil 10 | end 11 | return { 12 | relative = "editor", 13 | anchor = "NE", 14 | width = state.message.width, 15 | height = state.message.height, 16 | col = vim.opt.columns:get(), 17 | row = next_row, 18 | border = "rounded", 19 | style = "minimal", 20 | } 21 | end, 22 | function() 23 | return { 24 | col = vim.opt.columns:get(), 25 | time = true, 26 | } 27 | end, 28 | } 29 | end 30 | -------------------------------------------------------------------------------- /lua/notify/stages/util.lua: -------------------------------------------------------------------------------- 1 | local max, min = math.max, math.min 2 | local util = require("notify.util") 3 | 4 | local M = {} 5 | 6 | M.DIRECTION = { 7 | TOP_DOWN = "top_down", 8 | BOTTOM_UP = "bottom_up", 9 | LEFT_RIGHT = "left_right", 10 | RIGHT_LEFT = "right_left", 11 | } 12 | 13 | local function is_increasing(direction) 14 | return (direction == M.DIRECTION.TOP_DOWN or direction == M.DIRECTION.LEFT_RIGHT) 15 | end 16 | 17 | local function moves_vertically(direction) 18 | return (direction == M.DIRECTION.TOP_DOWN or direction == M.DIRECTION.BOTTOM_UP) 19 | end 20 | 21 | function M.slot_name(direction) 22 | if moves_vertically(direction) then 23 | return "height" 24 | end 25 | return "width" 26 | end 27 | 28 | local function less(a, b) 29 | return a < b 30 | end 31 | 32 | local function greater(a, b) 33 | return a > b 34 | end 35 | 36 | local function overlaps(a, b) 37 | return a.min <= b.max and b.min <= a.max 38 | end 39 | 40 | local move_slot = function(direction, slot, delta) 41 | if is_increasing(direction) then 42 | return slot + delta 43 | end 44 | return slot - delta 45 | end 46 | 47 | local function slot_key(direction) 48 | return moves_vertically(direction) and "row" or "col" 49 | end 50 | 51 | local function space_key(direction) 52 | return moves_vertically(direction) and "height" or "width" 53 | end 54 | 55 | -- TODO: Use direction to check border lists 56 | local function border_padding(direction, win_conf) 57 | if not win_conf.border or win_conf.border == "none" then 58 | return 0 59 | end 60 | return 2 61 | end 62 | 63 | ---@param windows number[] 64 | ---@param direction string 65 | ---@return { max: integer, min: integer}[] 66 | local function window_intervals(windows, direction, cmp) 67 | local win_intervals = {} 68 | for _, w in ipairs(windows) do 69 | local exists, existing_conf = util.get_win_config(w) 70 | if exists then 71 | local border_space = border_padding(direction, existing_conf) 72 | win_intervals[#win_intervals + 1] = { 73 | min = existing_conf[slot_key(direction)], 74 | max = existing_conf[slot_key(direction)] 75 | + existing_conf[space_key(direction)] 76 | + border_space 77 | - 1, 78 | } 79 | end 80 | end 81 | table.sort(win_intervals, function(a, b) 82 | return cmp(a.min, b.min) 83 | end) 84 | return win_intervals 85 | end 86 | 87 | function M.get_slot_range(direction) 88 | local top = 0 89 | if vim.o.showtabline == 2 or (vim.o.showtabline == 1 and vim.fn.tabpagenr("quot;) > 1) then 90 | top = 1 91 | end 92 | if vim.wo.winbar ~= '' then 93 | top = top + 1 94 | end 95 | 96 | local bottom = vim.opt.lines:get() 97 | - (vim.opt.cmdheight:get() + (vim.opt.laststatus:get() > 0 and 1 or 0) + 1) 98 | local left = 1 99 | local right = vim.opt.columns:get() 100 | if M.DIRECTION.TOP_DOWN == direction then 101 | return top, bottom 102 | elseif M.DIRECTION.BOTTOM_UP == direction then 103 | return bottom, top 104 | elseif M.DIRECTION.LEFT_RIGHT == direction then 105 | return left, right 106 | elseif M.DIRECTION.RIGHT_LEFT == direction then 107 | return right, left 108 | end 109 | error(string.format("Invalid direction: %s", direction)) 110 | end 111 | 112 | ---@param existing_wins number[] Windows to avoid overlapping 113 | ---@param required_space number Window height or width including borders 114 | ---@param direction string Direction to stack windows, one of M.DIRECTION 115 | ---@return number | nil Slot to place window at or nil if no slot available 116 | function M.available_slot(existing_wins, required_space, direction) 117 | local increasing = is_increasing(direction) 118 | local cmp = increasing and less or greater 119 | local first_slot, last_slot = M.get_slot_range(direction) 120 | 121 | local function create_interval(start_slot) 122 | local end_slot = move_slot(direction, start_slot, required_space - 1) 123 | return { min = min(start_slot, end_slot), max = max(start_slot, end_slot) } 124 | end 125 | 126 | local interval = create_interval(first_slot) 127 | 128 | local intervals = window_intervals(existing_wins, direction, cmp) 129 | 130 | for _, next_interval in ipairs(intervals) do 131 | if overlaps(next_interval, interval) then 132 | interval = create_interval( 133 | move_slot(direction, increasing and next_interval.max or next_interval.min, 1) 134 | ) 135 | end 136 | end 137 | 138 | if #intervals > 0 and not cmp(is_increasing and interval.max or interval.min, last_slot) then 139 | return nil 140 | end 141 | 142 | return interval.min 143 | end 144 | 145 | ---Gets the next slot available for the given window while maintaining its position using the given list. 146 | ---@param win number 147 | ---@param open_windows number[] 148 | ---@param direction string 149 | function M.slot_after_previous(win, open_windows, direction) 150 | local key = slot_key(direction) 151 | local cmp = is_increasing(direction) and less or greater 152 | local exists, cur_win_conf = util.get_win_config(win) 153 | if not exists then 154 | return 0 155 | end 156 | 157 | local cur_slot = cur_win_conf[key] 158 | local win_confs = {} 159 | for _, w in ipairs(open_windows) do 160 | local success, conf = util.get_win_config(w) 161 | if success then 162 | win_confs[w] = conf 163 | end 164 | end 165 | 166 | local preceding_wins = vim.tbl_filter(function(open_win) 167 | return win_confs[open_win] and cmp(win_confs[open_win][key], cur_slot) 168 | end, open_windows) 169 | 170 | if #preceding_wins == 0 then 171 | local start = M.get_slot_range(direction) 172 | if is_increasing(direction) then 173 | return start 174 | end 175 | return move_slot( 176 | direction, 177 | start, 178 | cur_win_conf[space_key(direction)] + border_padding(direction, cur_win_conf) / 2 179 | ) 180 | end 181 | 182 | table.sort(preceding_wins, function(a, b) 183 | return cmp(win_confs[a][key], win_confs[b][key]) 184 | end) 185 | 186 | local last_win = preceding_wins[#preceding_wins] 187 | local last_win_conf = win_confs[last_win] 188 | 189 | if is_increasing(direction) then 190 | return move_slot( 191 | direction, 192 | last_win_conf[key], 193 | last_win_conf[space_key(direction)] + border_padding(direction, last_win_conf) 194 | ) 195 | else 196 | return move_slot( 197 | direction, 198 | last_win_conf[key], 199 | cur_win_conf[space_key(direction)] + border_padding(direction, cur_win_conf) 200 | ) 201 | end 202 | end 203 | 204 | return M 205 | -------------------------------------------------------------------------------- /lua/notify/util/init.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local min, max, floor = math.min, math.max, math.floor 4 | local rshift, lshift, band, bor = bit.rshift, bit.lshift, bit.band, bit.bor 5 | local strwidth = vim.api.nvim_strwidth or vim.fn.strchars 6 | 7 | function M.is_callable(obj) 8 | return type(obj) == "function" or (type(obj) == "table" and obj.__call) 9 | end 10 | 11 | function M.lazy_require(require_path) 12 | return setmetatable({}, { 13 | __call = function(_, ...) 14 | return require(require_path)(...) 15 | end, 16 | __index = function(_, key) 17 | return require(require_path)[key] 18 | end, 19 | __newindex = function(_, key, value) 20 | require(require_path)[key] = value 21 | end, 22 | }) 23 | end 24 | 25 | function M.pop(tbl, key, default) 26 | local val = default 27 | if tbl[key] then 28 | val = tbl[key] 29 | tbl[key] = nil 30 | end 31 | return val 32 | end 33 | 34 | function M.blend(fg_hex, bg_hex, alpha) 35 | local segment = 0xFF0000 36 | local result = 0 37 | for i = 2, 0, -1 do 38 | local blended = alpha * rshift(band(fg_hex, segment), i * 8) 39 | + (1 - alpha) * rshift(band(bg_hex, segment), i * 8) 40 | 41 | result = bor(lshift(result, 8), floor((min(max(blended, 0), 255)) + 0.5)) 42 | segment = rshift(segment, 8) 43 | end 44 | 45 | return result 46 | end 47 | 48 | function M.round(num, decimals) 49 | if decimals then 50 | return tonumber(string.format("%." .. decimals .. "f", num)) 51 | end 52 | return math.floor(num + 0.5) 53 | end 54 | 55 | function M.partial(func, ...) 56 | local args = { ... } 57 | return function(...) 58 | local final = {} 59 | vim.list_extend(final, args) 60 | vim.list_extend(final, { ... }) 61 | return func(unpack(final)) 62 | end 63 | end 64 | 65 | function M.get_win_config(win) 66 | local success, conf = pcall(vim.api.nvim_win_get_config, win) 67 | if not success or not conf.row then 68 | return false, conf 69 | end 70 | if type(conf.row) == "table" then 71 | conf.row = conf.row[false] 72 | end 73 | if type(conf.col) == "table" then 74 | conf.col = conf.col[false] 75 | end 76 | return success, conf 77 | end 78 | 79 | function M.open_win(notif_buf, enter, opts) 80 | local win = vim.api.nvim_open_win(notif_buf:buffer(), enter, opts) 81 | -- vim.wo does not behave like setlocal, thus we use setwinvar to set local 82 | -- only options. Otherwise our changes would affect subsequently opened 83 | -- windows. 84 | -- see e.g. neovim#14595 85 | vim.fn.setwinvar( 86 | win, 87 | "&winhl", 88 | "NormalNC:NONE" .. ",Normal:" .. notif_buf.highlights.body .. ",FloatBorder:" .. notif_buf.highlights.border 89 | ) 90 | vim.fn.setwinvar(win, "&wrap", 0) 91 | return win 92 | end 93 | 94 | M.FIFOQueue = require("notify.util.queue") 95 | 96 | function M.rgb_to_numbers(s) 97 | local colours = {} 98 | for a in string.gmatch(s, "[A-Fa-f0-9][A-Fa-f0-9]") do 99 | colours[#colours + 1] = tonumber(a, 16) 100 | end 101 | return colours 102 | end 103 | 104 | function M.numbers_to_rgb(colours) 105 | local colour = "#" 106 | for _, num in pairs(colours) do 107 | colour = colour .. string.format("%X", num) 108 | end 109 | return colour 110 | end 111 | 112 | function M.highlight(name, fields) 113 | local fields_string = "" 114 | for field, value in pairs(fields) do 115 | fields_string = fields_string .. " " .. field .. "=" .. value 116 | end 117 | if fields_string ~= "" then 118 | vim.cmd("hi " .. name .. fields_string) 119 | end 120 | end 121 | 122 | --- Calculate the max render width of a message 123 | ---@param msg string[]|nil 124 | ---@return integer 125 | function M.max_line_width(msg) 126 | local width = 0 127 | 128 | if msg then 129 | for i = 1, #msg do 130 | width = max(width, strwidth(msg[i])) 131 | end 132 | end 133 | 134 | return width 135 | end 136 | 137 | return M 138 | -------------------------------------------------------------------------------- /lua/notify/util/queue.lua: -------------------------------------------------------------------------------- 1 | ---@class FIFOQueue 2 | local FIFOQueue = {} 3 | 4 | function FIFOQueue:pop() 5 | if self:is_empty() then 6 | return nil 7 | end 8 | local r = self[self.pop_from] 9 | self[self.pop_from] = nil 10 | self.pop_from = self.pop_from - 1 11 | return r 12 | end 13 | 14 | function FIFOQueue:peek() 15 | return self[self.pop_from] 16 | end 17 | 18 | function FIFOQueue:push(val) 19 | self[self.push_to] = val 20 | self.push_to = self.push_to - 1 21 | end 22 | 23 | function FIFOQueue:is_empty() 24 | return self:length() == 0 25 | end 26 | 27 | function FIFOQueue:length() 28 | return self.pop_from - self.push_to 29 | end 30 | 31 | function FIFOQueue:iter() 32 | local i = self.pop_from + 1 33 | return function() 34 | if i > self.push_to + 1 then 35 | i = i - 1 36 | return self[i] 37 | end 38 | end 39 | end 40 | 41 | function FIFOQueue:new() 42 | local queue = { 43 | pop_from = 1, 44 | push_to = 1, 45 | } 46 | self.__index = self 47 | setmetatable(queue, self) 48 | return queue 49 | end 50 | 51 | ---@return FIFOQueue 52 | return function() 53 | return FIFOQueue:new() 54 | end 55 | -------------------------------------------------------------------------------- /lua/notify/windows/init.lua: -------------------------------------------------------------------------------- 1 | local api = vim.api 2 | local animate = require("notify.animate") 3 | local util = require("notify.util") 4 | local round = util.round 5 | local max = math.max 6 | 7 | ---@class WindowAnimator 8 | ---@field config table 9 | ---@field win_states table<number, table<string, SpringState>> 10 | ---@field win_stages table<number, integer> 11 | ---@field notif_bufs table<number, NotificationBuf> 12 | ---@field timers table 13 | ---@field stages table 14 | local WindowAnimator = {} 15 | 16 | function WindowAnimator:new(stages, config) 17 | local animator = { 18 | config = config, 19 | win_stages = {}, 20 | win_states = {}, 21 | notif_bufs = {}, 22 | timers = {}, 23 | stages = stages, 24 | } 25 | self.__index = self 26 | setmetatable(animator, self) 27 | return animator 28 | end 29 | 30 | function WindowAnimator:render(queue, time) 31 | self:push_pending(queue) 32 | if vim.tbl_isempty(self.win_stages) then 33 | return false 34 | end 35 | local open_windows = vim.tbl_keys(self.win_stages) 36 | for win, _ in pairs(self.win_stages) do 37 | self:_update_window(time, win, open_windows) 38 | end 39 | return true 40 | end 41 | 42 | function WindowAnimator:push_pending(queue) 43 | if queue:is_empty() then 44 | return 45 | end 46 | while not queue:is_empty() do 47 | ---@type NotificationBuf 48 | local notif_buf = queue:peek() 49 | if not notif_buf:is_valid() then 50 | queue:pop() 51 | else 52 | local windows = vim.tbl_keys(self.win_stages) 53 | local win_opts = self.stages[1]({ 54 | message = self:_get_dimensions(notif_buf), 55 | open_windows = windows, 56 | }) 57 | if not win_opts then 58 | return 59 | end 60 | local opacity = util.pop(win_opts, "opacity") 61 | if opacity then 62 | notif_buf.highlights:set_opacity(opacity) 63 | end 64 | win_opts.noautocmd = true 65 | local win = util.open_win(notif_buf, false, win_opts) 66 | vim.fn.setwinvar( 67 | win, 68 | "&winhl", 69 | "NormalNC:NONE" .. ",Normal:" .. notif_buf.highlights.body .. ",FloatBorder:" .. notif_buf.highlights.border 70 | ) 71 | self.win_stages[win] = 2 72 | self.win_states[win] = {} 73 | self.notif_bufs[win] = notif_buf 74 | queue:pop() 75 | notif_buf:open(win) 76 | end 77 | end 78 | end 79 | 80 | function WindowAnimator:_advance_win_stage(win) 81 | local cur_stage = self.win_stages[win] 82 | if not cur_stage then 83 | return 84 | end 85 | if cur_stage < #self.stages then 86 | if api.nvim_get_current_win() == win then 87 | return 88 | end 89 | self.win_stages[win] = cur_stage + 1 90 | return 91 | end 92 | 93 | self.win_stages[win] = nil 94 | 95 | local function close() 96 | if api.nvim_get_current_win() == win then 97 | return vim.defer_fn(close, 1000) 98 | end 99 | self:_remove_win(win) 100 | end 101 | 102 | close() 103 | end 104 | 105 | function WindowAnimator:_remove_win(win) 106 | pcall(api.nvim_win_close, win, true) 107 | self.win_stages[win] = nil 108 | self.win_states[win] = nil 109 | local notif_buf = self.notif_bufs[win] 110 | self.notif_bufs[win] = nil 111 | notif_buf:close(win) 112 | end 113 | 114 | function WindowAnimator:on_refresh(win) 115 | local notif_buf = self.notif_bufs[win] 116 | if not notif_buf then 117 | return 118 | end 119 | if self.timers[win] then 120 | self.timers[win]:set_repeat(notif_buf:timeout() or self.config.default_timeout()) 121 | self.timers[win]:again() 122 | end 123 | end 124 | 125 | function WindowAnimator:_start_timer(win) 126 | local buf_time = self.notif_bufs[win]:timeout() == nil and self.config.default_timeout() 127 | or self.notif_bufs[win]:timeout() 128 | if buf_time ~= false then 129 | if buf_time == true then 130 | buf_time = nil 131 | end 132 | local timer = vim.loop.new_timer() 133 | self.timers[win] = timer 134 | timer:start( 135 | buf_time, 136 | buf_time, 137 | vim.schedule_wrap(function() 138 | timer:stop() 139 | self.timers[win] = nil 140 | local notif_buf = self.notif_bufs[win] 141 | if notif_buf and notif_buf:should_stay() then 142 | return 143 | end 144 | self:_advance_win_stage(win) 145 | end) 146 | ) 147 | end 148 | end 149 | 150 | function WindowAnimator:_update_window(time, win, open_windows) 151 | local stage = self.win_stages[win] 152 | local notif_buf = self.notif_bufs[win] 153 | local win_goals = self:_get_win_goals(win, stage, open_windows) 154 | 155 | if not win_goals then 156 | self:_remove_win(win) 157 | end 158 | 159 | -- If we don't animate, then we move to all goals instantly. 160 | -- Can't just jump to the end, because we need to the intermediate changes 161 | while 162 | not notif_buf:should_animate() 163 | and win_goals.time == nil 164 | and self.win_stages[win] < #self.stages 165 | do 166 | for field, goal in pairs(win_goals) do 167 | if type(goal) == "table" then 168 | win_goals[field] = goal[1] 169 | end 170 | end 171 | self:_advance_win_state(win, win_goals, time) 172 | self:_advance_win_stage(win) 173 | stage = self.win_stages[win] 174 | win_goals = self:_get_win_goals(win, stage, open_windows) 175 | end 176 | 177 | if win_goals.time and not self.timers[win] then 178 | self:_start_timer(win) 179 | end 180 | 181 | self:_advance_win_state(win, win_goals, time) 182 | 183 | if self:_is_complete(win, win_goals) and not win_goals.time then 184 | self:_advance_win_stage(win) 185 | end 186 | end 187 | 188 | function WindowAnimator:_is_complete(win, goals) 189 | local complete = true 190 | local win_state = self.win_states[win] 191 | if not win_state then 192 | return true 193 | end 194 | for field, goal in pairs(goals) do 195 | if field ~= "time" then 196 | if type(goal) == "table" then 197 | if goal.complete then 198 | complete = goal.complete(win_state[field].position) 199 | else 200 | complete = goal[1] == round(win_state[field].position, 2) 201 | end 202 | end 203 | if not complete then 204 | break 205 | end 206 | end 207 | end 208 | return complete 209 | end 210 | 211 | function WindowAnimator:_advance_win_state(win, goals, time) 212 | local win_state = self.win_states[win] 213 | 214 | local win_configs = {} 215 | 216 | local function win_conf(win_) 217 | if win_configs[win_] then 218 | return win_configs[win_] 219 | end 220 | local exists, conf = util.get_win_config(win_) 221 | if not exists then 222 | self:_remove_win(win_) 223 | return 224 | end 225 | win_configs[win_] = conf 226 | return conf 227 | end 228 | 229 | for field, goal in pairs(goals) do 230 | if field ~= "time" then 231 | local goal_type = type(goal) 232 | -- Handle spring goal 233 | if goal_type == "table" and goal[1] then 234 | if not win_state[field] then 235 | if field == "opacity" then 236 | win_state[field] = { position = self.notif_bufs[win].highlights:get_opacity() } 237 | else 238 | local conf = win_conf(win) 239 | if not conf then 240 | return true 241 | end 242 | win_state[field] = { position = conf[field] } 243 | end 244 | end 245 | animate.spring(time, goal[1], win_state[field], goal.frequency or 1, goal.damping or 1) 246 | --- Directly move goal 247 | elseif goal_type ~= "table" then 248 | win_state[field] = { position = goal } 249 | else 250 | error("nvim-notify: Invalid stage goal: " .. vim.inspect(goal)) 251 | end 252 | end 253 | end 254 | 255 | return self:_apply_win_state(win, win_state) 256 | end 257 | 258 | function WindowAnimator:_get_win_goals(win, win_stage, open_windows) 259 | local notif_buf = self.notif_bufs[win] 260 | local win_goals = self.stages[win_stage]({ 261 | buffer = notif_buf:buffer(), 262 | message = self:_get_dimensions(notif_buf), 263 | open_windows = open_windows, 264 | }, win) 265 | return win_goals 266 | end 267 | 268 | function WindowAnimator:_get_dimensions(notif_buf) 269 | return { 270 | height = math.min(self.config.max_height() or 1000, notif_buf:height()), 271 | width = math.min(self.config.max_width() or 1000, notif_buf:width()), 272 | } 273 | end 274 | 275 | function WindowAnimator:_apply_win_state(win, win_state) 276 | local hl_updated = false 277 | if win_state.opacity then 278 | local notif_buf = self.notif_bufs[win] 279 | if notif_buf:is_valid() then 280 | hl_updated = notif_buf.highlights:set_opacity(win_state.opacity.position) 281 | vim.fn.setwinvar( 282 | win, 283 | "&winhl", 284 | "NormalNC:NONE" .. ",Normal:" .. notif_buf.highlights.body .. ",FloatBorder:" .. notif_buf.highlights.border 285 | ) 286 | end 287 | end 288 | local exists, conf = util.get_win_config(win) 289 | local new_conf = {} 290 | local win_updated = false 291 | if not exists then 292 | self:_remove_win(win) 293 | else 294 | local function set_field(field, min, round_to) 295 | if not win_state[field] then 296 | return 297 | end 298 | local new_value = max(round(win_state[field].position, round_to), min) 299 | if new_value == conf[field] then 300 | return 301 | end 302 | win_updated = true 303 | new_conf[field] = new_value 304 | end 305 | 306 | set_field("row", 0, 1) 307 | set_field("col", 0, 1) 308 | set_field("width", 1) 309 | set_field("height", 1) 310 | 311 | if win_updated then 312 | if new_conf.row or new_conf.col then 313 | new_conf.relative = conf.relative 314 | new_conf.row = new_conf.row or conf.row 315 | new_conf.col = new_conf.col or conf.col 316 | end 317 | api.nvim_win_set_config(win, new_conf) 318 | end 319 | end 320 | -- The 'flush' key is set to enforce redrawing during blocking event. 321 | pcall(vim.api.nvim__redraw, { win = win, valid = false, flush = true }) 322 | return hl_updated or win_updated 323 | end 324 | 325 | ---@return WindowAnimator 326 | return function(stages, config) 327 | return WindowAnimator:new(stages, config) 328 | end 329 | -------------------------------------------------------------------------------- /lua/telescope/_extensions/notify.lua: -------------------------------------------------------------------------------- 1 | local pickers = require("telescope.pickers") 2 | local finders = require("telescope.finders") 3 | local conf = require("telescope.config").values 4 | local actions = require("telescope.actions") 5 | local action_state = require("telescope.actions.state") 6 | local previewers = require("telescope.previewers") 7 | local entry_display = require("telescope.pickers.entry_display") 8 | local notify = require("notify") 9 | 10 | local widths = { 11 | time = 8, 12 | title = nil, 13 | icon = nil, 14 | level = nil, 15 | message = nil, 16 | } 17 | 18 | local displayer = entry_display.create({ 19 | separator = " ", 20 | items = { 21 | { width = widths.time }, 22 | { width = widths.title }, 23 | { width = widths.icon }, 24 | { width = widths.level }, 25 | { width = widths.message }, 26 | }, 27 | }) 28 | 29 | local telescope_notifications = function(opts) 30 | local time_format = require("notify")._config().time_formats().notification 31 | local notifs = require("notify").history() 32 | local reversed = {} 33 | for i, notif in ipairs(notifs) do 34 | reversed[#notifs - i + 1] = notif 35 | end 36 | pickers 37 | .new(opts, { 38 | results_title = "Notifications", 39 | prompt_title = "Filter Notifications", 40 | finder = finders.new_table({ 41 | results = reversed, 42 | entry_maker = function(notif) 43 | return { 44 | value = notif, 45 | display = function(entry) 46 | return displayer({ 47 | { vim.fn.strftime(time_format, entry.value.time), "NotifyLogTime" }, 48 | { entry.value.title[1], "NotifyLogTitle" }, 49 | { entry.value.icon, "Notify" .. entry.value.level .. "Title" }, 50 | { entry.value.level, "Notify" .. entry.value.level .. "Title" }, 51 | { entry.value.message[1], "Notify" .. entry.value.level .. "Body" }, 52 | }) 53 | end, 54 | ordinal = notif.title[1] .. " " .. notif.title[2] .. " " .. table.concat( 55 | notif.message, 56 | " " 57 | ), 58 | } 59 | end, 60 | }), 61 | sorter = conf.generic_sorter(opts), 62 | attach_mappings = function(prompt_bufnr, map) 63 | actions.select_default:replace(function() 64 | actions.close(prompt_bufnr) 65 | local selection = action_state.get_selected_entry() 66 | if selection == nil then 67 | return 68 | end 69 | 70 | local notification = selection.value 71 | local opened_buffer = notify.open(notification) 72 | 73 | local lines = vim.opt.lines:get() 74 | local cols = vim.opt.columns:get() 75 | 76 | local win = vim.api.nvim_open_win(opened_buffer.buffer, true, { 77 | relative = "editor", 78 | row = (lines - opened_buffer.height) / 2, 79 | col = (cols - opened_buffer.width) / 2, 80 | height = opened_buffer.height, 81 | width = opened_buffer.width, 82 | border = "rounded", 83 | style = "minimal", 84 | }) 85 | -- vim.wo does not behave like setlocal, thus we use setwinvar to set local 86 | -- only options. Otherwise our changes would affect subsequently opened 87 | -- windows. 88 | -- see e.g. neovim#14595 89 | vim.fn.setwinvar( 90 | win, 91 | "&winhl", 92 | "Normal:" 93 | .. opened_buffer.highlights.body 94 | .. ",FloatBorder:" 95 | .. opened_buffer.highlights.border 96 | ) 97 | vim.fn.setwinvar(win, "&wrap", 0) 98 | end) 99 | return true 100 | end, 101 | previewer = previewers.new_buffer_previewer({ 102 | title = "Message", 103 | define_preview = function(self, entry, status) 104 | local notification = entry.value 105 | local max_width = vim.api.nvim_win_get_config(status.preview_win).width 106 | vim.api.nvim_win_set_option(status.preview_win, "wrap", true) 107 | notify.open(notification, { buffer = self.state.bufnr, max_width = max_width }) 108 | end, 109 | }), 110 | }) 111 | :find() 112 | end 113 | 114 | return require("telescope").register_extension({ 115 | exports = { 116 | notify = telescope_notifications, 117 | }, 118 | }) 119 | -------------------------------------------------------------------------------- /scripts/docgen: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | nvim --headless -c "luafile ./scripts/gendocs.lua" -c 'qa' 4 | -------------------------------------------------------------------------------- /scripts/gendocs.lua: -------------------------------------------------------------------------------- 1 | -- TODO: A lot of this is private code from minidoc, which could be removed if made public 2 | 3 | local minidoc = require("mini.doc") 4 | 5 | local H = {} 6 | --stylua: ignore start 7 | H.pattern_sets = { 8 | -- Patterns for working with afterlines. At the moment deliberately crafted 9 | -- to work only on first line without indent. 10 | 11 | -- Determine if line is a function definition. Captures function name and 12 | -- arguments. For reference see '2.5.9 – Function Definitions' in Lua manual. 13 | afterline_fundef = { 14 | '^function%s+(%S-)(%b())', -- Regular definition 15 | '^local%s+function%s+(%S-)(%b())', -- Local definition 16 | '^(%S+)%s*=%s*function(%b())', -- Regular assignment 17 | '^local%s+(%S+)%s*=%s*function(%b())', -- Local assignment 18 | }, 19 | 20 | -- Determine if line is a general assignment 21 | afterline_assign = { 22 | '^(%S-)%s*=', -- General assignment 23 | '^local%s+(%S-)%s*=', -- Local assignment 24 | }, 25 | 26 | -- Patterns to work with type descriptions 27 | -- (see https://github.com/sumneko/lua-language-server/wiki/EmmyLua-Annotations#types-and-type) 28 | types = { 29 | 'table%b<>', 30 | 'fun%b(): %S+', 'fun%b()', 'async fun%b(): %S+', 'async fun%b()', 31 | 'nil', 'any', 'boolean', 'string', 'number', 'integer', 'function', 'table', 'thread', 'userdata', 'lightuserdata', 32 | '%.%.%.', 33 | "%S+", 34 | 35 | }, 36 | } 37 | 38 | 39 | H.apply_config = function(config) 40 | MiniDoc.config = config 41 | end 42 | 43 | H.is_disabled = function() 44 | return vim.g.minidoc_disable == true or vim.b.minidoc_disable == true 45 | end 46 | 47 | H.get_config = function(config) 48 | return vim.tbl_deep_extend("force", MiniDoc.config, vim.b.minidoc_config or {}, config or {}) 49 | end 50 | 51 | -- Work with project specific script ========================================== 52 | H.execute_project_script = function(input, output, config) 53 | -- Don't process script if there are more than one active `generate` calls 54 | if H.generate_is_active then 55 | return 56 | end 57 | 58 | -- Don't process script if at least one argument is not default 59 | if not (input == nil and output == nil and config == nil) then 60 | return 61 | end 62 | 63 | -- Store information 64 | local global_config_cache = vim.deepcopy(MiniDoc.config) 65 | local local_config_cache = vim.b.minidoc_config 66 | 67 | -- Pass information to a possible `generate()` call inside script 68 | H.generate_is_active = true 69 | H.generate_recent_output = nil 70 | 71 | -- Execute script 72 | local success = pcall(vim.cmd, "luafile " .. H.get_config(config).script_path) 73 | 74 | -- Restore information 75 | MiniDoc.config = global_config_cache 76 | vim.b.minidoc_config = local_config_cache 77 | H.generate_is_active = nil 78 | 79 | return success 80 | end 81 | 82 | -- Default documentation targets ---------------------------------------------- 83 | H.default_input = function() 84 | -- Search in current and recursively in other directories for files with 85 | -- 'lua' extension 86 | local res = {} 87 | for _, dir_glob in ipairs({ ".", "lua/**", "after/**", "colors/**" }) do 88 | local files = vim.fn.globpath(dir_glob, "*.lua", false, true) 89 | 90 | -- Use full paths 91 | files = vim.tbl_map(function(x) 92 | return vim.fn.fnamemodify(x, ":p") 93 | end, files) 94 | 95 | -- Put 'init.lua' first among files from same directory 96 | table.sort(files, function(a, b) 97 | if vim.fn.fnamemodify(a, ":h") == vim.fn.fnamemodify(b, ":h") then 98 | if vim.fn.fnamemodify(a, ":t") == "init.lua" then 99 | return true 100 | end 101 | if vim.fn.fnamemodify(b, ":t") == "init.lua" then 102 | return false 103 | end 104 | end 105 | 106 | return a < b 107 | end) 108 | table.insert(res, files) 109 | end 110 | 111 | return vim.tbl_flatten(res) 112 | end 113 | 114 | H.default_output = function() 115 | local cur_dir = vim.fn.fnamemodify(vim.loop.cwd(), ":t:r") 116 | return ("doc/%s.txt"):format(cur_dir) 117 | end 118 | 119 | -- Parsing -------------------------------------------------------------------- 120 | H.lines_to_block_arr = function(lines, config) 121 | local matched_prev, matched_cur 122 | 123 | local res = {} 124 | local block_raw = { annotation = {}, section_id = {}, afterlines = {}, line_begin = 1 } 125 | 126 | for i, l in ipairs(lines) do 127 | local from, to, section_id = config.annotation_extractor(l) 128 | matched_prev, matched_cur = matched_cur, from ~= nil 129 | 130 | if matched_cur then 131 | if not matched_prev then 132 | -- Finish current block 133 | block_raw.line_end = i - 1 134 | table.insert(res, H.raw_block_to_block(block_raw, config)) 135 | 136 | -- Start new block 137 | block_raw = { annotation = {}, section_id = {}, afterlines = {}, line_begin = i } 138 | end 139 | 140 | -- Add annotation line without matched annotation pattern 141 | table.insert(block_raw.annotation, ("%s%s"):format(l:sub(0, from - 1), l:sub(to + 1))) 142 | 143 | -- Add section id (it is empty string in case of no section id capture) 144 | table.insert(block_raw.section_id, section_id or "") 145 | else 146 | -- Add afterline 147 | table.insert(block_raw.afterlines, l) 148 | end 149 | end 150 | block_raw.line_end = #lines 151 | table.insert(res, H.raw_block_to_block(block_raw, config)) 152 | 153 | return res 154 | end 155 | 156 | -- Raw block structure is an intermediate step added for convenience. It is 157 | -- a table with the following keys: 158 | -- - `annotation` - lines (after removing matched annotation pattern) that were 159 | -- parsed as annotation. 160 | -- - `section_id` - array with length equal to `annotation` length with strings 161 | -- captured as section id. Empty string of no section id was captured. 162 | -- - Everything else is used as block info (like `afterlines`, etc.). 163 | H.raw_block_to_block = function(block_raw, config) 164 | if #block_raw.annotation == 0 and #block_raw.afterlines == 0 then 165 | return nil 166 | end 167 | 168 | local block = H.new_struct("block", { 169 | afterlines = block_raw.afterlines, 170 | line_begin = block_raw.line_begin, 171 | line_end = block_raw.line_end, 172 | }) 173 | local block_begin = block.info.line_begin 174 | 175 | -- Parse raw block annotation lines from top to bottom. New section starts 176 | -- when section id is detected in that line. 177 | local section_cur = H.new_struct( 178 | "section", 179 | { id = config.default_section_id, line_begin = block_begin } 180 | ) 181 | 182 | for i, annotation_line in ipairs(block_raw.annotation) do 183 | local id = block_raw.section_id[i] 184 | if id ~= "" then 185 | -- Finish current section 186 | if #section_cur > 0 then 187 | section_cur.info.line_end = block_begin + i - 2 188 | block:insert(section_cur) 189 | end 190 | 191 | -- Start new section 192 | section_cur = H.new_struct("section", { id = id, line_begin = block_begin + i - 1 }) 193 | end 194 | 195 | section_cur:insert(annotation_line) 196 | end 197 | 198 | if #section_cur > 0 then 199 | section_cur.info.line_end = block_begin + #block_raw.annotation - 1 200 | block:insert(section_cur) 201 | end 202 | 203 | return block 204 | end 205 | 206 | -- Hooks ---------------------------------------------------------------------- 207 | H.apply_structure_hooks = function(doc, hooks) 208 | for _, file in ipairs(doc) do 209 | for _, block in ipairs(file) do 210 | hooks.block_pre(block) 211 | 212 | for _, section in ipairs(block) do 213 | hooks.section_pre(section) 214 | 215 | local hook = hooks.sections[section.info.id] 216 | if hook ~= nil then 217 | hook(section) 218 | end 219 | 220 | hooks.section_post(section) 221 | end 222 | 223 | hooks.block_post(block) 224 | end 225 | 226 | hooks.file(file) 227 | end 228 | 229 | hooks.doc(doc) 230 | end 231 | 232 | H.alias_register = function(s) 233 | if #s == 0 then 234 | return 235 | end 236 | 237 | -- Remove first word (with bits of surrounding whitespace) while capturing it 238 | local alias_name 239 | s[1] = s[1]:gsub("%s*(%S+) ?", function(x) 240 | alias_name = x 241 | return "" 242 | end, 1) 243 | if alias_name == nil then 244 | return 245 | end 246 | 247 | MiniDoc.current.aliases = MiniDoc.current.aliases or {} 248 | MiniDoc.current.aliases[alias_name] = table.concat(s, "\n") 249 | end 250 | 251 | H.alias_replace = function(s) 252 | if MiniDoc.current.aliases == nil then 253 | return 254 | end 255 | 256 | for i, _ in ipairs(s) do 257 | for alias_name, alias_desc in pairs(MiniDoc.current.aliases) do 258 | -- Escape special characters. This is done here and not while registering 259 | -- alias to allow user to refer to aliases by its original name. 260 | -- Store escaped words in separate variables because `vim.pesc()` returns 261 | -- two values which might conflict if outputs are used as arguments. 262 | local name_escaped = vim.pesc(alias_name) 263 | local desc_escaped = vim.pesc(alias_desc) 264 | s[i] = s[i]:gsub(name_escaped, desc_escaped) 265 | end 266 | end 267 | end 268 | 269 | H.toc_register = function(s) 270 | MiniDoc.current.toc = MiniDoc.current.toc or {} 271 | table.insert(MiniDoc.current.toc, s) 272 | end 273 | 274 | H.toc_insert = function(s) 275 | if MiniDoc.current.toc == nil then 276 | return 277 | end 278 | 279 | -- Render table of contents 280 | local toc_lines = {} 281 | for _, toc_entry in ipairs(MiniDoc.current.toc) do 282 | local _, tag_section = toc_entry.parent:has_descendant(function(x) 283 | return type(x) == "table" and x.type == "section" and x.info.id == "@tag" 284 | end) 285 | tag_section = tag_section or {} 286 | 287 | local lines = {} 288 | for i = 1, math.max(#toc_entry, #tag_section) do 289 | local left = toc_entry[i] or "" 290 | -- Use tag refernce instead of tag enclosure 291 | local right = string.match(tag_section[i], "%*.*%*"):gsub("%*", "|") 292 | -- local right = vim.trim((tag_section[i] or ""):gsub("%*", "|")) 293 | -- Add visual line only at first entry (while not adding trailing space) 294 | local filler = i == 1 and "." or (right == "" and "" or " ") 295 | -- Make padding of 2 spaces at both left and right 296 | local n_filler = math.max(74 - H.visual_text_width(left) - H.visual_text_width(right), 3) 297 | table.insert(lines, (" %s%s%s"):format(left, filler:rep(n_filler), right)) 298 | end 299 | 300 | table.insert(toc_lines, lines) 301 | 302 | -- Don't show `toc_entry` lines in output 303 | toc_entry:clear_lines() 304 | end 305 | 306 | for _, l in ipairs(vim.tbl_flatten(toc_lines)) do 307 | s:insert(l) 308 | end 309 | end 310 | 311 | H.add_section_heading = function(s, heading) 312 | if #s == 0 or s.type ~= "section" then 313 | return 314 | end 315 | 316 | -- Add heading 317 | s:insert(1, ("%s~"):format(heading)) 318 | end 319 | 320 | H.enclose_var_name = function(s) 321 | if #s == 0 or s.type ~= "section" then 322 | return 323 | end 324 | 325 | s[1] = s[1]:gsub("(%S+)", "{%1}", 1) 326 | end 327 | 328 | ---@param init number Start of searching for first "type-like" string. It is 329 | --- needed to not detect type early. Like in `@param a_function function`. 330 | ---@private 331 | H.enclose_type = function(s, enclosure, init) 332 | if #s == 0 or s.type ~= "section" then 333 | return 334 | end 335 | enclosure = enclosure or "`%(%1%)`" 336 | init = init or 1 337 | 338 | local cur_type = H.match_first_pattern(s[1], H.pattern_sets["types"], init) 339 | if #cur_type == 0 then 340 | return 341 | end 342 | 343 | -- Add `%S*` to front and back of found pattern to support their combination 344 | -- with `|`. Also allows using `[]` and `?` prefixes. 345 | local type_pattern = ("(%%S*%s%%S*)"):format(vim.pesc(cur_type[1])) 346 | 347 | -- Avoid replacing possible match before `init` 348 | local l_start = s[1]:sub(1, init - 1) 349 | local l_end = s[1]:sub(init):gsub(type_pattern, enclosure, 1) 350 | s[1] = ("%s%s"):format(l_start, l_end) 351 | end 352 | 353 | -- Infer data from afterlines ------------------------------------------------- 354 | H.infer_header = function(b) 355 | local has_signature = b:has_descendant(function(x) 356 | return type(x) == "table" and x.type == "section" and x.info.id == "@signature" 357 | end) 358 | local has_tag = b:has_descendant(function(x) 359 | return type(x) == "table" and x.type == "section" and x.info.id == "@tag" 360 | end) 361 | 362 | if has_signature and has_tag then 363 | return 364 | end 365 | 366 | local l_all = table.concat(b.info.afterlines, " ") 367 | local tag, signature 368 | 369 | -- Try function definition 370 | local fun_capture = H.match_first_pattern(l_all, H.pattern_sets["afterline_fundef"]) 371 | if #fun_capture > 0 then 372 | tag = tag or ("%s()"):format(fun_capture[1]) 373 | signature = signature or ("%s%s"):format(fun_capture[1], fun_capture[2]) 374 | end 375 | 376 | -- Try general assignment 377 | local assign_capture = H.match_first_pattern(l_all, H.pattern_sets["afterline_assign"]) 378 | if #assign_capture > 0 then 379 | tag = tag or assign_capture[1] 380 | signature = signature or assign_capture[1] 381 | end 382 | 383 | if tag ~= nil then 384 | -- First insert signature (so that it will appear after tag section) 385 | if not has_signature then 386 | b:insert(1, H.as_struct({ signature }, "section", { id = "@signature" })) 387 | end 388 | 389 | -- Insert tag 390 | if not has_tag then 391 | b:insert(1, H.as_struct({ tag }, "section", { id = "@tag" })) 392 | end 393 | end 394 | end 395 | 396 | function H.is_module(name) 397 | if string.find(name, "%(") then 398 | return false 399 | end 400 | if string.find(name, "[A-Z]") then 401 | return false 402 | end 403 | return true 404 | end 405 | 406 | H.format_signature = function(line) 407 | -- Try capture function signature 408 | local name, args = line:match("(%S-)(%b())") 409 | 410 | 411 | -- Otherwise pick first word 412 | name = name or line:match("(%S+)") 413 | if not args and H.is_module(name) then 414 | return "" 415 | end 416 | local name_elems = vim.split(name, ".", { plain = true }) 417 | name = name_elems[#name_elems] 418 | 419 | if not name then 420 | return "" 421 | end 422 | 423 | -- Tidy arguments 424 | if args and args ~= "()" then 425 | local arg_parts = vim.split(args:sub(2, -2), ",") 426 | local arg_list = {} 427 | for _, a in ipairs(arg_parts) do 428 | -- Enclose argument in `{}` while controlling whitespace 429 | table.insert(arg_list, ("{%s}"):format(vim.trim(a))) 430 | end 431 | args = ("(%s)"):format(table.concat(arg_list, ", ")) 432 | end 433 | 434 | return ("`%s`%s"):format(name, args or "") 435 | end 436 | 437 | -- Work with structures ------------------------------------------------------- 438 | -- Constructor 439 | H.new_struct = function(struct_type, info) 440 | local output = { 441 | info = info or {}, 442 | type = struct_type, 443 | } 444 | 445 | output.insert = function(self, index, child) 446 | -- Allow both `x:insert(child)` and `x:insert(1, child)` 447 | if child == nil then 448 | child, index = index, #self + 1 449 | end 450 | 451 | if type(child) == "table" then 452 | child.parent = self 453 | child.parent_index = index 454 | end 455 | 456 | table.insert(self, index, child) 457 | 458 | H.sync_parent_index(self) 459 | end 460 | 461 | output.remove = function(self, index) 462 | index = index or #self 463 | table.remove(self, index) 464 | 465 | H.sync_parent_index(self) 466 | end 467 | 468 | output.has_descendant = function(self, predicate) 469 | local bool_res, descendant = false, nil 470 | H.apply_recursively(function(x) 471 | if not bool_res and predicate(x) then 472 | bool_res = true 473 | descendant = x 474 | end 475 | end, self) 476 | return bool_res, descendant 477 | end 478 | 479 | output.has_lines = function(self) 480 | return self:has_descendant(function(x) 481 | return type(x) == "string" 482 | end) 483 | end 484 | 485 | output.clear_lines = function(self) 486 | for i, x in ipairs(self) do 487 | if type(x) == "string" then 488 | self[i] = nil 489 | else 490 | x:clear_lines() 491 | end 492 | end 493 | end 494 | 495 | return output 496 | end 497 | 498 | H.sync_parent_index = function(x) 499 | for i, _ in ipairs(x) do 500 | if type(x[i]) == "table" then 501 | x[i].parent_index = i 502 | end 503 | end 504 | return x 505 | end 506 | 507 | -- Converter (this ensures that children have proper parent-related data) 508 | H.as_struct = function(array, struct_type, info) 509 | -- Make default info `info` for cases when structure is created manually 510 | local default_info = ({ 511 | section = { id = "@text", line_begin = -1, line_end = -1 }, 512 | block = { afterlines = {}, line_begin = -1, line_end = -1 }, 513 | file = { path = "" }, 514 | doc = { input = {}, output = "", config = H.get_config() }, 515 | })[struct_type] 516 | info = vim.tbl_deep_extend("force", default_info, info or {}) 517 | 518 | local res = H.new_struct(struct_type, info) 519 | for _, x in ipairs(array) do 520 | res:insert(x) 521 | end 522 | return res 523 | end 524 | 525 | -- Work with text ------------------------------------------------------------- 526 | H.ensure_indent = function(text, n_indent_target) 527 | local lines = vim.split(text, "\n") 528 | local n_indent, n_indent_cur = math.huge, math.huge 529 | 530 | -- Find number of characters in indent 531 | for _, l in ipairs(lines) do 532 | -- Update lines indent: minimum of all indents except empty lines 533 | if n_indent > 0 then 534 | _, n_indent_cur = l:find("^%s*") 535 | -- Condition "current n-indent equals line length" detects empty line 536 | if (n_indent_cur < n_indent) and (n_indent_cur < l:len()) then 537 | n_indent = n_indent_cur 538 | end 539 | end 540 | end 541 | 542 | -- Ensure indent 543 | local indent = string.rep(" ", n_indent_target) 544 | for i, l in ipairs(lines) do 545 | if l ~= "" then 546 | lines[i] = indent .. l:sub(n_indent + 1) 547 | end 548 | end 549 | 550 | return table.concat(lines, "\n") 551 | end 552 | 553 | H.align_text = function(text, width, direction) 554 | if type(text) ~= "string" then 555 | return 556 | end 557 | text = vim.trim(text) 558 | width = width or 78 559 | direction = direction or "left" 560 | 561 | -- Don't do anything if aligning left or line is a whitespace 562 | if direction == "left" or text:find("^%s*quot;) then 563 | return text 564 | end 565 | 566 | local n_left = math.max(0, 78 - H.visual_text_width(text)) 567 | if direction == "center" then 568 | n_left = math.floor(0.5 * n_left) 569 | end 570 | 571 | return (" "):rep(n_left) .. text 572 | end 573 | 574 | H.visual_text_width = function(text) 575 | -- Ignore concealed characters (usually "invisible" in 'help' filetype) 576 | local _, n_concealed_chars = text:gsub("([*|`])", "%1") 577 | return vim.fn.strdisplaywidth(text) - n_concealed_chars 578 | end 579 | 580 | --- Return earliest match among many patterns 581 | --- 582 | --- Logic here is to test among several patterns. If several got a match, 583 | --- return one with earliest match. 584 | --- 585 | ---@private 586 | H.match_first_pattern = function(text, pattern_set, init) 587 | local start_tbl = vim.tbl_map(function(pattern) 588 | return text:find(pattern, init) or math.huge 589 | end, pattern_set) 590 | 591 | local min_start, min_id = math.huge, nil 592 | for id, st in ipairs(start_tbl) do 593 | if st < min_start then 594 | min_start, min_id = st, id 595 | end 596 | end 597 | 598 | if min_id == nil then 599 | return {} 600 | end 601 | return { text:match(pattern_set[min_id], init) } 602 | end 603 | 604 | -- Utilities ------------------------------------------------------------------ 605 | H.apply_recursively = function(f, x, used) 606 | used = used or {} 607 | if used[x] then 608 | return 609 | end 610 | f(x) 611 | used[x] = true 612 | 613 | if type(x) == "table" then 614 | for _, t in ipairs(x) do 615 | H.apply_recursively(f, t, used) 616 | end 617 | end 618 | end 619 | 620 | H.collect_strings = function(x) 621 | local res = {} 622 | H.apply_recursively(function(y) 623 | if type(y) == "string" then 624 | -- Allow `\n` in strings 625 | table.insert(res, vim.split(y, "\n")) 626 | end 627 | end, x) 628 | -- Flatten to only have strings and not table of strings (from `vim.split`) 629 | return vim.tbl_flatten(res) 630 | end 631 | 632 | H.file_read = function(path) 633 | local file = assert(io.open(path)) 634 | local contents = file:read("*all") 635 | file:close() 636 | 637 | return vim.split(contents, "\n") 638 | end 639 | 640 | H.file_write = function(path, lines) 641 | -- Ensure target directory exists 642 | local dir = vim.fn.fnamemodify(path, ":h") 643 | vim.fn.mkdir(dir, "p") 644 | 645 | -- Write to file 646 | vim.fn.writefile(lines, path, "b") 647 | end 648 | 649 | H.full_path = function(path) 650 | return vim.fn.resolve(vim.fn.fnamemodify(path, ":p")) 651 | end 652 | 653 | H.message = function(msg) 654 | vim.cmd("echomsg " .. vim.inspect("(mini.doc) " .. msg)) 655 | end 656 | 657 | minidoc.setup({}) 658 | minidoc.generate( 659 | { 660 | "./lua/notify/init.lua", 661 | "./lua/notify/config/init.lua", 662 | "./lua/notify/render/init.lua", 663 | }, 664 | nil, 665 | { 666 | hooks = vim.tbl_extend("force", minidoc.default_hooks, { 667 | block_post = function(b) 668 | if not b:has_lines() then return end 669 | 670 | local found_param, found_field = false, false 671 | local n_tag_sections = 0 672 | H.apply_recursively(function(x) 673 | if not (type(x) == 'table' and x.type == 'section') then return end 674 | 675 | -- Add headings before first occurence of a section which type usually 676 | -- appear several times 677 | if not found_param and x.info.id == '@param' then 678 | H.add_section_heading(x, 'Parameters') 679 | found_param = true 680 | end 681 | if not found_field and x.info.id == '@field' then 682 | H.add_section_heading(x, 'Fields') 683 | found_field = true 684 | end 685 | 686 | if x.info.id == '@tag' then 687 | local text = x[1] 688 | local tag = string.match(text, "%*.*%*") 689 | local prefix = (string.sub(tag, 2, #tag - 1)) 690 | if not H.is_module(prefix) then 691 | prefix = "" 692 | end 693 | local n_filler = math.max(78 - H.visual_text_width(prefix) - H.visual_text_width(tag), 3) 694 | local line = ("%s%s%s"):format(prefix, (" "):rep(n_filler), tag) 695 | x:remove(1) 696 | x:insert(1, line) 697 | x.parent:remove(x.parent_index) 698 | n_tag_sections = n_tag_sections + 1 699 | x.parent:insert(n_tag_sections, x) 700 | end 701 | end, b) 702 | 703 | -- b:insert(1, H.as_struct({ string.rep('=', 78) }, 'section')) 704 | b:insert(H.as_struct({ '' }, 'section')) 705 | end, 706 | 707 | 708 | doc = function(d) 709 | -- Render table of contents 710 | H.apply_recursively(function(x) 711 | if not (type(x) == 'table' and x.type == 'section' and x.info.id == '@toc') then return end 712 | H.toc_insert(x) 713 | end, d) 714 | 715 | -- Insert modeline 716 | d:insert( 717 | H.as_struct( 718 | { H.as_struct({ H.as_struct({ ' vim:tw=78:ts=8:noet:ft=help:norl:' }, 'section') }, 'block') }, 719 | 'file' 720 | ) 721 | ) 722 | end, 723 | sections = { 724 | ['@generic'] = function(s) 725 | s:remove(1) 726 | end, 727 | ['@field'] = function(s) 728 | -- H.mark_optional(s) 729 | if string.find(s[1], "^private ") then 730 | s:remove(1) 731 | return 732 | end 733 | H.enclose_var_name(s) 734 | H.enclose_type(s, '`%(%1%)`', s[1]:find('%s')) 735 | end, 736 | ['@alias'] = function(s) 737 | local name = s[1]:match('%s*(%S*)') 738 | local alias = s[1]:match('%s(.*)#39;) 739 | s[1] = ("`%s` → `%s`"):format(name, alias) 740 | H.add_section_heading(s, 'Alias') 741 | s:insert(1, H.as_struct({ ("*%s*"):format(name) }, "section", { id = "@tag" })) 742 | end, 743 | 744 | ['@param'] = function(s) 745 | H.enclose_var_name(s) 746 | H.enclose_type(s, '`%(%1%)`', s[1]:find('%s')) 747 | end, 748 | ['@return'] = function(s) 749 | H.enclose_type(s, '`%(%1%)`', 1) 750 | H.add_section_heading(s, 'Return') 751 | end, 752 | ['@nodoc'] = function(s) s.parent:clear_lines() end, 753 | ['@class'] = function(s) 754 | H.enclose_var_name(s) 755 | -- Add heading 756 | local line = s[1] 757 | s:remove(1) 758 | local class_name = string.match(line, "%{(.*)%}") 759 | local inherits = string.match(line, ": (.*)") 760 | if inherits then 761 | s:insert(1, ("Inherits: `%s`"):format(inherits)) 762 | s:insert(2, "") 763 | end 764 | s:insert(1, H.as_struct({ ("*%s*"):format(class_name) }, "section", { id = "@tag" })) 765 | end, 766 | 767 | ['@signature'] = function(s) 768 | s[1] = H.format_signature(s[1]) 769 | if s[1] ~= "" then 770 | table.insert(s, "") 771 | end 772 | end, 773 | 774 | }, 775 | 776 | file = function(f) 777 | if not f:has_lines() then 778 | return 779 | end 780 | 781 | if f.info.path ~= "./lua/notify/init.lua" then 782 | f:insert(1, H.as_struct({ H.as_struct({ string.rep("=", 78) }, "section") }, "block")) 783 | f:insert(H.as_struct({ H.as_struct({ "" }, "section") }, "block")) 784 | else 785 | f:insert( 786 | 1, 787 | H.as_struct( 788 | { 789 | H.as_struct( 790 | { "*nvim-notify.txt* A fancy, configurable notification manager for NeoVim" }, 791 | "section" 792 | ), 793 | }, 794 | "block" 795 | ) 796 | ) 797 | f:insert(2, H.as_struct({ H.as_struct({ "" }, "section") }, "block")) 798 | f:insert(3, H.as_struct({ H.as_struct({ string.rep("=", 78) }, "section") }, "block")) 799 | f:insert(H.as_struct({ H.as_struct({ "" }, "section") }, "block")) 800 | end 801 | end, 802 | }), 803 | } 804 | ) 805 | -------------------------------------------------------------------------------- /scripts/style: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | stylua lua tests 4 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | tempfile=".test_output.tmp" 3 | 4 | if [[ -n $1 ]]; then 5 | nvim --headless --noplugin -u tests/init.vim -c "PlenaryBustedFile $1" | tee "${tempfile}" 6 | else 7 | nvim --headless --noplugin -u tests/init.vim -c "PlenaryBustedDirectory tests/ {minimal_init = 'tests/init.vim'}" | tee "${tempfile}" 8 | fi 9 | 10 | # Plenary doesn't emit exit code 1 when tests have errors during setup 11 | errors=$(sed 's/\x1b\[[0-9;]*m//g' "${tempfile}" | awk '/(Errors|Failed) :/ {print $3}' | grep -v '0') 12 | 13 | rm "${tempfile}" 14 | 15 | if [[ -n $errors ]]; then 16 | echo "Tests failed" 17 | exit 1 18 | fi 19 | 20 | exit 0 21 | -------------------------------------------------------------------------------- /stylua.toml: -------------------------------------------------------------------------------- 1 | column_width = 100 2 | line_endings = "Unix" 3 | indent_type = "Spaces" 4 | indent_width = 2 5 | quote_style = "AutoPreferDouble" 6 | -------------------------------------------------------------------------------- /tests/init.vim: -------------------------------------------------------------------------------- 1 | set rtp+=. 2 | set rtp+=../plenary.nvim 3 | set termguicolors 4 | runtime! plugin/plenary.vim 5 | -------------------------------------------------------------------------------- /tests/manual/merge_duplicates.lua: -------------------------------------------------------------------------------- 1 | local function coroutine_resume() 2 | local co = assert(coroutine.running()) 3 | return function(...) 4 | local ret = { coroutine.resume(co, ...) } 5 | -- Re-raise errors with correct traceback 6 | local ok, err = unpack(ret) 7 | if not ok then 8 | error(debug.traceback(co, err)) 9 | end 10 | return unpack(ret, 2) 11 | end 12 | end 13 | 14 | local function coroutine_sleep() 15 | local resume = coroutine_resume() 16 | return function(ms) 17 | vim.defer_fn(resume, ms) 18 | coroutine.yield() 19 | end 20 | end 21 | 22 | local function run() 23 | local sleep = coroutine_sleep() 24 | 25 | local countdown = function(seconds) 26 | local id 27 | for i = 1, seconds do 28 | -- local msg = string.format('Will show once again in %d seconds', seconds - i + 1) 29 | local msg = string.format("Will show once again in %d seconds", seconds - i + 1) 30 | id = vim.notify(msg, vim.log.levels.WARN, { replace = id }).id 31 | sleep(1000) 32 | end 33 | end 34 | 35 | local texts = { 36 | "AAA This is first text", 37 | "BBBBBB Second text of duplicate notifications", 38 | -- 'CCCCCCCCC Third text', 39 | } 40 | 41 | local show_all = function(level) 42 | for _, text in ipairs(texts) do 43 | vim.notify(text, level) 44 | sleep(50) 45 | end 46 | end 47 | 48 | show_all() 49 | 50 | sleep(1000) 51 | show_all() 52 | 53 | sleep(1000) 54 | show_all() 55 | 56 | sleep(1000) 57 | show_all(vim.log.levels.WARN) 58 | sleep(1000) 59 | show_all(vim.log.levels.WARN) 60 | 61 | sleep(1000) 62 | show_all() 63 | 64 | -- wait until the previous notifications disappear 65 | countdown(10) 66 | show_all() 67 | 68 | sleep(2000) 69 | for _ = 1, 41 do 70 | sleep(50) 71 | show_all() 72 | end 73 | end 74 | 75 | coroutine.wrap(run)() 76 | -------------------------------------------------------------------------------- /tests/unit/init_spec.lua: -------------------------------------------------------------------------------- 1 | local async = require("plenary.async") 2 | async.tests.add_to_env() 3 | vim.opt.termguicolors = true 4 | A = vim.schedule_wrap(function(...) 5 | print(vim.inspect(...)) 6 | end) 7 | 8 | describe("checking public interface", function() 9 | local notify = require("notify") 10 | local async_notify = require("notify").async 11 | assert:add_formatter(vim.inspect) 12 | 13 | before_each(function() 14 | notify.setup({ background_colour = "#000000" }) 15 | notify.dismiss({ pending = true, silent = true }) 16 | end) 17 | 18 | describe("notifications", function() 19 | it("returns all previous notifications", function() 20 | notify.notify("test", "error") 21 | local notifs = notify.history() 22 | assert.are.same({ 23 | { 24 | icon = "", 25 | id = 1, 26 | level = "ERROR", 27 | message = { "test" }, 28 | render = notifs[1].render, 29 | time = notifs[1].time, 30 | title = { "", notifs[1].title[2] }, 31 | }, 32 | }, notifs) 33 | end) 34 | 35 | describe("rendering", function() 36 | a.it("uses custom render in config", function() 37 | local called = false 38 | notify.setup({ 39 | background_colour = "#000000", 40 | render = function() 41 | called = true 42 | end, 43 | }) 44 | notify.async("test", "error").events.open() 45 | assert.is.True(called) 46 | end) 47 | 48 | a.it("validates max width and prefix length", function() 49 | local terminal_width = vim.o.columns 50 | notify.setup({ 51 | background_colour = "#000000", 52 | max_width = function() 53 | return math.min(terminal_width, 50) 54 | end, 55 | }) 56 | 57 | local win = notify.async("test", "info").events.open() 58 | 59 | assert.is.True(vim.api.nvim_win_get_width(win) <= terminal_width) 60 | 61 | local notif = notify.notify("Test Notification", "info", { 62 | title = "Long Title That Should Be Cut Off", 63 | }) 64 | 65 | local prefix_title = notif.title and notif.title[1] or "Default Title" 66 | 67 | local prefix_length = vim.str_utfindex(prefix_title) 68 | assert.is.True(prefix_length <= terminal_width) 69 | end) 70 | 71 | a.it("uses custom render in call", function() 72 | local called = false 73 | notify 74 | .async("test", "error", { 75 | render = function() 76 | called = true 77 | end, 78 | }).events 79 | .open() 80 | assert.is.True(called) 81 | end) 82 | end) 83 | 84 | describe("replacing", function() 85 | it("inherits options", function() 86 | local orig = notify.notify("first", "info", { title = "test", icon = "x" }) 87 | local next = notify.notify("second", nil, { replace = orig }) 88 | 89 | assert.are.same( 90 | next, 91 | vim.tbl_extend("force", orig, { id = next.id, message = next.message }) 92 | ) 93 | end) 94 | 95 | a.it("uses same window", function() 96 | local orig = async_notify("first", "info", { timeout = false }) 97 | local win = orig.events.open() 98 | async_notify("second", nil, { replace = orig, timeout = 100 }) 99 | async.util.scheduler() 100 | local found = false 101 | local bufs = vim.api.nvim_list_bufs() 102 | for _, buf in ipairs(bufs) do 103 | if vim.api.nvim_buf_get_lines(buf, 0, -1, false)[1] == "second" then 104 | assert.Not(found) 105 | assert.same(vim.fn.bufwinid(buf), win) 106 | found = true 107 | end 108 | end 109 | end) 110 | end) 111 | end) 112 | 113 | a.it("uses the configured minimum width", function() 114 | notify.setup({ 115 | background_colour = "#000000", 116 | minimum_width = 20, 117 | }) 118 | local win = notify.async("test").events.open() 119 | assert.equal(vim.api.nvim_win_get_width(win), 20) 120 | end) 121 | 122 | a.it("uses the configured max width", function() 123 | notify.setup({ 124 | background_colour = "#000000", 125 | max_width = function() 126 | return 3 127 | end, 128 | }) 129 | local win = notify.async("test").events.open() 130 | assert.equal(vim.api.nvim_win_get_width(win), 3) 131 | end) 132 | 133 | a.it("uses the configured max height", function() 134 | local instance = notify.instance({ 135 | background_colour = "#000000", 136 | max_height = function() 137 | return 3 138 | end, 139 | }, false) 140 | local win = instance.async("test").events.open() 141 | assert.equal(vim.api.nvim_win_get_height(win), 3) 142 | end) 143 | 144 | a.it("renders title as longest line", function() 145 | local instance = notify.instance({ 146 | background_colour = "#000000", 147 | minimum_width = 10, 148 | }, false) 149 | local win = instance.async("test", nil, { title = { string.rep("a", 16), "" } }).events.open() 150 | assert.equal(21, vim.api.nvim_win_get_width(win)) 151 | end) 152 | 153 | a.it("renders notification above config level", function() 154 | local win = 155 | notify.async("test", "info", { message = { string.rep("a", 16), "" } }).events.open() 156 | assert.Not.Nil(vim.api.nvim_win_get_config(win)) 157 | end) 158 | 159 | a.it("doesn't render notification below config level", function() 160 | async.run(function() 161 | local notif = notify.async("test", "debug", { message = { string.rep("a", 16), "" } }) 162 | local win = notif.events.open() 163 | async.api.nvim_buf_set_option(async.api.nvim_win_get_buf(win), "filetype", "test") 164 | end) 165 | async.util.sleep(100) 166 | local bufs = vim.api.nvim_list_bufs() 167 | for _, buf in ipairs(bufs) do 168 | assert.Not.same(vim.api.nvim_buf_get_option(buf, "filetype"), "test") 169 | end 170 | end) 171 | a.it("refreshes timeout on replace", function() 172 | -- Don't want to spend time animating 173 | notify.setup({ background_colour = "#000000", stages = "static" }) 174 | 175 | local notif = notify.async("test", "error", { timeout = 500 }) 176 | local win = notif.events.open() 177 | a.util.sleep(300) 178 | notify.async("test2", "error", { replace = notif }) 179 | a.util.sleep(300) 180 | a.util.scheduler() 181 | assert(vim.api.nvim_win_is_valid(win)) 182 | end) 183 | 184 | describe("util", function() 185 | local util = require("notify.util") 186 | 187 | describe("max_line_width()", function() 188 | it("returns the maximal width of a table of lines", function() 189 | assert.equals(5, util.max_line_width({ "12", "12345", "123" })) 190 | end) 191 | 192 | it("returns 0 for nil input", function() 193 | assert.equals(0, util.max_line_width()) 194 | end) 195 | 196 | describe("notification width", function() 197 | a.it("handles multibyte characters correctly", function() 198 | local instance = notify.instance({ 199 | background_colour = "#000000", 200 | minimum_width = 1, 201 | render = "minimal", 202 | }, false) 203 | local win = instance.async("\u{1D4AF}\u{212F}\u{1D4C8}\u{1D4C9}").events.open() -- "𝒯ℯ𝓈𝓉" 204 | assert.equal(4, vim.api.nvim_win_get_width(win)) 205 | end) 206 | 207 | a.it("handles combining character sequences correctly", function() 208 | local instance = notify.instance({ 209 | background_colour = "#000000", 210 | minimum_width = 1, 211 | render = "minimal", 212 | }, false) 213 | local win = instance 214 | .async( 215 | "T\u{0336}\u{0311}\u{0349}" 216 | .. "e\u{0336}\u{030E}\u{0332}" 217 | .. "s\u{0334}\u{0301}\u{0329}" 218 | .. "t\u{0337}\u{0301}\u{031C}" -- "T̶͉̑e̶̲̎ś̴̩t̷̜́" 219 | ).events 220 | .open() 221 | assert.equal(4, vim.api.nvim_win_get_width(win)) 222 | end) 223 | 224 | a.it("respects East Asian Width Class", function() 225 | local instance = notify.instance({ 226 | background_colour = "#000000", 227 | minimum_width = 1, 228 | render = "minimal", 229 | }, false) 230 | local win = instance.async("\u{FF34}\u{FF45}\u{FF53}\u{FF54}").events.open() -- "Test" 231 | assert.equal(8, vim.api.nvim_win_get_width(win)) 232 | end) 233 | end) 234 | end) 235 | end) 236 | end) 237 | --------------------------------------------------------------------------------