Repository: carbon-steel/detour.nvim
Branch: main
Commit: 204f61e64f87
Files: 34
Total size: 100.9 KB
Directory structure:
gitextract_mob2na4m/
├── .busted
├── .github/
│ └── workflows/
│ └── release.yml
├── .gitignore
├── CHANGELOG.md
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── detour.nvim-scm-1.rockspec
├── doc/
│ ├── detour.txt
│ └── tags
├── examples/
│ ├── help-float.md
│ └── telescope.md
├── lua/
│ └── detour/
│ ├── algorithm_doc.lua
│ ├── config.lua
│ ├── features.lua
│ ├── init.lua
│ ├── internal.lua
│ ├── movements.lua
│ ├── show_path_in_title.lua
│ ├── util.lua
│ └── windowing_algorithm.lua
├── plugin/
│ └── detour.lua
├── run-in-docker.sh
└── spec/
├── config_spec.lua
├── detour_auto_unreserve_spec.lua
├── detour_close_stack_spec.lua
├── detour_hide_reveal_spec.lua
├── detour_movements_spec.lua
├── detour_spec.lua
├── detour_uncover_spec.lua
├── internal_spec.lua
├── util_spec.lua
└── windowing_algorithm_spec.lua
================================================
FILE CONTENTS
================================================
================================================
FILE: .busted
================================================
return {
_all = {
coverage = false,
lpath = "lua/?.lua;lua/?/init.lua",
},
default = {
verbose = true,
},
tests = {
verbose = true,
},
}
================================================
FILE: .github/workflows/release.yml
================================================
name: LuaRocks release
on:
push:
tags: # Will upload to luarocks.org when a tag is pushed
- "*"
pull_request: # Will test a local install without uploading to luarocks.org
jobs:
luarocks-release:
runs-on: ubuntu-latest
name: LuaRocks upload
steps:
- name: Checkout
uses: actions/checkout@v3
- name: LuaRocks Upload
uses: nvim-neorocks/luarocks-tag-release@v7
env:
LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }}
================================================
FILE: .gitignore
================================================
# Compiled Lua sources
luac.out
# luarocks build files
*.src.rock
*.zip
*.tar.gz
# Object files
*.o
*.os
*.ko
*.obj
*.elf
# Precompiled Headers
*.gch
*.pch
# Libraries
*.lib
*.a
*.la
*.lo
*.def
*.exp
# Shared objects (inc. Windows DLLs)
*.dll
*.so
*.so.*
*.dylib
# Executables
*.exe
*.out
*.app
*.i*86
*.x86_64
*.hex
lua/.luarc.json
/luarocks
/lua_modules
/.luarocks
.nvimlog
# Temporary files for testing
image
images
================================================
FILE: CHANGELOG.md
================================================
# Changelog
## v2.0.0
### Added
* Features: Hide/reveal detours and uncover hidden base windows
* Implement `CloseCurrentStack`
* A default title to all detours based on buffer name
- [Commit](https://github.com/carbon-steel/detour.nvim/commit/6f7a718e1ea0d24daff16407b27e460e043ebf6f)
* This changelog
* Added help pages
### Fixed
* Keep cursor out of covered windows
- [Commit](https://github.com/carbon-steel/detour.nvim/commit/0c358da951addace23934db10df59cc609e81db4)
* Check window exists before updating its title
- [Commit](https://github.com/carbon-steel/detour.nvim/commit/6cd2b457e4a5502cdaaf510a3da66d2686d42cc9)
* Fix global statusline detection
- [Commit](https://github.com/carbon-steel/detour.nvim/commit/f452858a3bac44bdabb9f507ba219e3e0af4bc6c)
* Attempt to fix terminal rendering issue
- [Commit](https://github.com/carbon-steel/detour.nvim/commit/b5596b9baa61475fe5164142c7d8ca86d0cf3b37)
* Miscellaneous small fixes
- [Commit](https://github.com/carbon-steel/detour.nvim/commit/eaab89288dd14de8d7cd06a948589b8f439c12ad)
* Make updating title more responsive
- [Commit](https://github.com/carbon-steel/detour.nvim/commit/255fd9555d389d21a3bf790de47a2350b5607bf5)
* Guard title update autocmd
- [Commit](https://github.com/carbon-steel/detour.nvim/commit/0e206f5aacf9f65b2d92cc9098519a7ea3595536)
* Realigned terminal buffers after resize events
- [Commit](https://github.com/carbon-steel/detour.nvim/commit/a7935ce1283a141bcca09d6bdf07c9c1b537bbfb)
* Redid help detour example
- [Commit](https://github.com/carbon-steel/detour.nvim/commit/bf59c29a06b58cd0e9f53b04aad7646204af4479)
* Introduced nesting autocmds
- [Commit](https://github.com/carbon-steel/detour.nvim/commit/42a724730e2351057973e1231016b8918e161e4f)
### Changed
* Increase required Neovim version to `0.11`
- [Commit](https://github.com/carbon-steel/detour.nvim/commit/def7b8c2e7b930c1d9f807f4362e61fb8796f11e)
* Removed behavior that closes detour if its parents are closed.
- [Commit](https://github.com/carbon-steel/detour.nvim/commit/48d6e7031007f4ebda460b99beeecc50ef932bcc)
* Keep window sizing behavior consistent until very small sizes
- [Commit](https://github.com/carbon-steel/detour.nvim/commit/39b19018711073edb0dd69a790e2ffdb4ebeb50c)
================================================
FILE: Dockerfile
================================================
FROM alpine:3.22
ENV LUA_MAJOR_VERSION 5.1
ENV LUA_MINOR_VERSION 5
ENV LUA_VERSION ${LUA_MAJOR_VERSION}.${LUA_MINOR_VERSION}
# Dependencies
RUN apk update && apk add --update make tar unzip gcc openssl-dev readline-dev curl libc-dev
RUN apk add wget # Needed due to https://github.com/luarocks/luarocks/issues/952
RUN curl -L http://www.lua.org/ftp/lua-${LUA_VERSION}.tar.gz | tar xzf -
WORKDIR /lua-$LUA_VERSION
# build lua
RUN make linux test
RUN make install
WORKDIR /
# lua env
ENV WITH_LUA /usr/local/
ENV LUA_LIB /usr/local/lib/lua
ENV LUA_INCLUDE /usr/local/include
RUN rm /lua-$LUA_VERSION -rf
ENV LUAROCKS_VERSION 3.9.2
ENV LUAROCKS_INSTALL luarocks-$LUAROCKS_VERSION
ENV TMP_LOC /tmp/luarocks
# Build Luarocks
RUN curl -OL \
https://luarocks.org/releases/${LUAROCKS_INSTALL}.tar.gz
RUN tar xzf $LUAROCKS_INSTALL.tar.gz && \
mv $LUAROCKS_INSTALL $TMP_LOC && \
rm $LUAROCKS_INSTALL.tar.gz
WORKDIR $TMP_LOC
RUN ./configure \
--with-lua=$WITH_LUA \
--with-lua-include=$LUA_INCLUDE \
--with-lua-lib=$LUA_LIB
RUN make build
RUN make install
WORKDIR /
RUN rm $TMP_LOC -rf
WORKDIR /mnt/luarocks
RUN apk add 'neovim=0.11.1-r1'
ENV BUSTED_VERSION 2.1.2-3
RUN luarocks install busted $BUSTED_VERSION
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2023 Roger Kim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: Makefile
================================================
DOCKER_IMAGE = detour_tester
.PHONY : test
image: Dockerfile
docker build -t ${DOCKER_IMAGE} .
touch image
test: image
docker run --volume="$(shell pwd):/mnt/luarocks:Z" ${DOCKER_IMAGE} /mnt/luarocks/run-in-docker.sh
.PHONY: help
# Ensure init.lua is processed first. Exclude internal helpers from docs.
HELP_LUA_ALL := $(wildcard lua/detour/*.lua)
# Space-separated list of files to exclude from lemmy-help
HELP_EXCLUDE := lua/detour/internal.lua \
lua/detour/windowing_algorithm.lua \
lua/detour/config.lua \
lua/detour/util.lua \
lua/detour/show_path_in_title.lua
HELP_LUA_SRCS := $(filter-out $(HELP_EXCLUDE),$(HELP_LUA_ALL))
# Ensure both init.lua and features.lua are first in order
HELP_LUA_HEAD := lua/detour/init.lua lua/detour/algorithm_doc.lua lua/detour/movements.lua
HELP_LUA_TAIL := $(filter-out $(HELP_LUA_HEAD),$(HELP_LUA_SRCS))
help:
# Generate help docs via lemmy-help from Lua sources (with excludes)
@mkdir -p doc
lemmy-help $(HELP_LUA_HEAD) $(HELP_LUA_TAIL) > doc/detour.txt
# Rebuild helptags (optional)
@nvim --headless -c 'silent! helptags doc' -c 'q' || true
================================================
FILE: README.md
================================================
# detour.nvim
> It's a dangerous business, Frodo, going out your door. You step onto the road, and if you don't keep your feet, there's no knowing where you might be swept off to.
J.R.R. Tolkien, The Lord of the Rings
`detour.nvim` provides commands to open floating windows that position and shape
themselves.
# Never lose your spot!📍🗺️
| What does detour.nvim do? | |
| :--: | :--: |
| `:Detour`/`require('detour').Detour()`
opens a floating window
over all current windows |  |
| `:DetourCurrentWindow`/
`require('detour').DetourCurrentWindow()`
opens a floating window over
only the current window |  |
| Works with Neovim's `:split`/`:vsplit`/`s`/`v`/`T` commands |  |
| You can nest detour popups |  |
Neovim's floating windows are a great utility to use in plugins and
functions, but they cannot be used manually. This is because
creating floats is not simple like calling `:split` or `:vsplit`.
`vim.api.nvim_open_win(...)` requires coordinates and dimensions to make
a float which is too tedious to do by hand.
Detour.nvim brings a single new feature to Neovim: **detour windows** (aka
detours). Detours are floating windows with the ease-of-use of splits.
Detours will make sure not to overlap each other unless when a detour is
nested within another.
# Example keymaps
`detour.nvim` is designed as a utility library for keymaps people can write on their own.
**NOTE** If you'd like to share a keymap you made, please submit it in a github issue and we'll include it in the `examples` directory!
Here are a few basic examples...
### Open your Neovim config
```lua
vim.keymap.set("n", "e", function()
-- Open detour
if not require("detour").Detour() then
return
end
vim.cmd.edit(vim.fn.stdpath("config")) -- open Neovim config directory
end)
```
[Screencast from 2025-09-11 07-37-45.webm](https://github.com/user-attachments/assets/9842dde2-3c42-4ade-aa27-35d23b45b42c)
### Jump to definition in detour
```lua
vim.keymap.set("n", "gd", function()
-- Open detour with the same buffer
if not require("detour").Detour() then
return
end
vim.lsp.buf.definition() -- jump to definition
end)
```
[Screencast from 2025-09-13 18-57-09.webm](https://github.com/user-attachments/assets/5f71ace1-1392-4082-8daa-83be88669324)
### Wrap a TUI: top
You can wrap any TUI in a detour. Here is an example.
Run `top` in a detour:
```lua
vim.keymap.set("n", "p", function()
local window_id = require("detour").Detour() -- open a detour
if not window_id then
return
end
vim.cmd.terminal("top") -- open a terminal buffer
vim.bo.bufhidden = "delete" -- close the terminal when window closes
vim.wo[window_id].signcolumn = "no" -- In Neovim 0.10, the signcolumn can push the TUI a bit out of window
-- It's common for people to have `` mapped to `` for terminals.
-- This can get in the way when interacting with TUIs.
-- This maps the escape key back to itself (for this buffer) to fix this problem.
vim.keymap.set("t", "", "", { buffer = true })
vim.cmd.startinsert() -- go into insert mode
vim.api.nvim_create_autocmd({ "TermClose" }, {
buffer = vim.api.nvim_get_current_buf(),
callback = function()
-- This automated keypress skips for you the "[Process exited 0]" message
-- that the embedded terminal shows.
vim.api.nvim_feedkeys("i", "n", false)
end,
})
end)
```
||
| :--: |
| **Use keymap above -> Close window** |

# Installation
### Lazy.nvim
```lua
{ "carbon-steel/detour.nvim",
config = function ()
require("detour").setup({
-- Put custom configuration here
})
vim.keymap.set('n', '', ":Detour")
vim.keymap.set('n', '.', ":DetourCurrentWindow")
local detour_moves = require("detour.movements")
-- NOTE: While using `detour_moves` is not required to use this
-- plugin, it is strongly recommended as it makes window navigation
-- much more intuitive.
--
-- The following keymaps are drop in replacements for Vim's regular
-- window navigation commands. These replacements allows you to
-- skip over windows covered by detours (which is a much more
-- intuitive motion) but are otherwise the same as normal window
-- navigation.
--
-- This is an example set of keymaps, but if you use other keys to
-- navigate windows, changes these keymaps to suit your situation.
vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdJ)
vim.keymap.set({ "n", "t" }, "j", detour_moves.DetourWinCmdJ)
vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdJ)
vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdH)
vim.keymap.set({ "n", "t" }, "h", detour_moves.DetourWinCmdH)
vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdH)
vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdK)
vim.keymap.set({ "n", "t" }, "k", detour_moves.DetourWinCmdK)
vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdK)
vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdL)
vim.keymap.set({ "n", "t" }, "l", detour_moves.DetourWinCmdL)
vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdL)
vim.keymap.set({ "n", "t" }, "w", detour_moves.DetourWinCmdW)
vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdW)
end
},
```
# Options
| Option | Description | Default value |
| -- | -- | -- |
| `title` | "path" sets the path of the current buffer as the title of the float. "none" sets no title. | "path" |
# Advanced
Using detours can be simple, but they also come with features for power users:
* `require("detour.features").UncoverWindow`: Update all detours to uncover a
given regular window
* `require("detour.features").UncoverWindowWithMouse`: Same as above but select
which window to uncover with the mouse
* `require("detour.features").HideAllDetours`/`RevealAllDetours`: Hide and
reveal all detours so you can see the windows behind them
* `require("detour.features").CloseCurrentStack`: Closes the current detour
along with all detours it is nested within
# Development
* Build help docs: run `make help` from the repo root.
- Requires `lemmy-help` in your PATH ([repo](https://github.com/numToStr/lemmy-help/tree/master)).
- Optional: Neovim available for generating `helptags` (target does not fail if absent).
* Run test: run `make test` from the repo root
- Requires `docker`
# FAQ
> I want to convert detours to splits or tabs.
`s` and `v` can be used from within a popup to create splits. `T` creates tabs.
> My LSP keeps moving my cursor to other windows.
If your LSP movements (ex: `go-to-definition`) are opening locations in other windows, make sure that `reuse_win` is set to `false`.
> My floating windows don't look good.
Some colorschemes don't have visually clear floating window border colors. Consider customizing your colorscheme's FloatBorder to a color that makes your floating windows clearer.
> My TUI is slightly wider than the floating window it's in.
This is something I noticed happening when I upgraded to Neovim 0.10. After you create your detour floating window, make sure to turn off `signcolumn`.
```lua
vim.opt.signcolumn = "no"
```
================================================
FILE: detour.nvim-scm-1.rockspec
================================================
rockspec_format = '3.0'
package = 'detour.nvim'
version = 'scm-1'
test_dependencies = {
'lua >= 5.1',
}
source = {
url = 'git://github.com/carbon-steel/' .. package,
}
build = {
type = 'builtin',
}
================================================
FILE: doc/detour.txt
================================================
==============================================================================
*detour*
*detour-into*
Neovim/Vim's floating windows are a great utility to use in plugins and
functions, but they cannot be used manually as splits are. This is because
creating floats is not simple like calling `:split` or `:vsplit`.
`vim.api.nvim_open_win(...)` requires coordinates and dimensions to make
a float which is too tedious to do by hand.
Detour.nvim brings a single new feature to Neovim: detour windows (aka
detours). Detours are floating windows with the ease-of-use of splits.
They dynamically shape themselves to cover as much of a given area as possible.
They can cover:
* the whole screen (`require("detour").Detour()`)
* the current window (`require("detour").DetourCurrentWindow()`)
* the current detour (both of the above functions would work)
Detours will make sure not to overlap each other unless when a detour is
nested within another.
You will find that there are many cases where using a large floating window is
preferable to creating a smaller split window. On top of that, the nesting
behavior of detours allows you to take a long "detour" into other files/locations
without losing your place in your regular windows. Take a detour, look at
other locations, close the detour, and find your original windows as they
were when you left.
detour.Detour() *detour.Detour*
Open a new detour window
* If this is called from a non-detour window, the largest possible detour
window will be opened that does not overlap with any other detours.
* If this is called from a detour window, a detour will be opened nested
within just the current detour.
There are cases where there is no space for a new detour window and
this function call will do nothing.
Returns: ~
(integer|nil) returns detour's window id if successfully
created, nil otherwise
@nodiscard
Usage: ~
>lua
--- local window_id = require("detour").Detour()
--- if not window_id then
--- -- New detour could not be made so stop execution
--- return
--- end
---
--- -- New detour window is open and cursor is moved to it.
<
detour.DetourCurrentWindow() *detour.DetourCurrentWindow*
Open a detour popup covering only the current window.
There are cases where there is no space for a new detour window and
this function call will do nothing.
Returns: ~
(integer|nil) returns detour's window id if successfully
created, nil otherwise
@nodiscard
Usage: ~
>lua
--- local window_id = require("detour").DetourCurrentWindow()
--- if not window_id then
--- -- New detour could not be made so stop execution
--- return
--- end
---
--- -- New detour window is open and cursor is moved to it.
<
==============================================================================
*detour.algorithm*
*detour-algorithm*
Detour's windowing algorithm computes the largest rectangle where a
floating window (detour) can be placed without overlapping windows that must
remain visible.
Overview
Each detour has a list of "reserved" windows that it is allowed to cover.
When creating or resizing a detour, the algorithm will find the largest
rectangular area where a floating window can go that covers only reserved
windows. That rectangle will be the position and dimensions of the detour.
`Detour()` creates a detour `d` where all windows that are not currently
reserved by an existing detour are reserved by `d`.
`DetourCurrentWindow()` creates a detour `d` where only the current window
is reserved by `d`.
Resizing
Whenever windows open, close, or get resized, detours will recalculate the
largest area they can fill and dynamically reshape themselves. This allows
them to make room for new windows or to expand to take space that has been
freed up.
==============================================================================
*detour.movements*
Detour.nvim breaks window navigation commands such as
`w`, `j`, or `vim.cmd.wincmd("h")`. Instead of
using those, the user MUST use the following window navigation
commands that this plugin provides that implements moving
between windows while skipping over windows covered by detours.
NOTE: Regular window movements such as `w`, `j`,
`vim.cmd.wincmd("h")` should still work in automated
scripts/functions. Still, you may find it more useful to use detour's
"detour-aware" movement functions in your scripts/functions as well.
*detour-movements*
movements._safe_state_handler() *movements._safe_state_handler*
DO NOT USE. FOR TESTING ONLY.
movements.DetourWinCmdL() *movements.DetourWinCmdL*
Switch to a window to the right. Skip over any non-floating windows
covered by a detour.
Returns: ~
(nil)
Usage: ~
>lua
--- local detour_moves = require("detour.movements")
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdL)
--- vim.keymap.set({ "n", "t" }, "l", detour_moves.DetourWinCmdL)
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdL)
---
<
movements.DetourWinCmdH() *movements.DetourWinCmdH*
Switch to a window to the left. Skip over any non-floating windows
covered by a detour.
Returns: ~
(nil)
Usage: ~
>lua
--- local detour_moves = require("detour.movements")
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdH)
--- vim.keymap.set({ "n", "t" }, "h", detour_moves.DetourWinCmdH)
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdH)
---
<
movements.DetourWinCmdJ() *movements.DetourWinCmdJ*
Switch to a window below. Skip over any non-floating windows
covered by a detour.
Returns: ~
(nil)
Usage: ~
>lua
--- local detour_moves = require("detour.movements")
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdJ)
--- vim.keymap.set({ "n", "t" }, "j", detour_moves.DetourWinCmdJ)
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdJ)
---
<
movements.DetourWinCmdK() *movements.DetourWinCmdK*
Switch to a window above. Skip over any non-floating windows
covered by a detour.
Returns: ~
(nil)
Usage: ~
>lua
--- local detour_moves = require("detour.movements")
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdK)
--- vim.keymap.set({ "n", "t" }, "k", detour_moves.DetourWinCmdK)
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdK)
---
<
movements.DetourWinCmdW() *movements.DetourWinCmdW*
Switch windows in a cycle. Skip over any non-floating windows covered
by a detour.
Returns: ~
(nil)
Usage: ~
>lua
--- local detour_moves = require("detour.movements")
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdW)
--- vim.keymap.set({ "n", "t" }, "w", detour_moves.DetourWinCmdW)
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdW)
---
<
==============================================================================
*detour.features*
Optional detour.nvim features.
*detour-features*
features.ShowPathInTitle({popup_id}) *features.ShowPathInTitle*
Show the buffer path in the given popup's title and keep it updated.
Parameters: ~
{popup_id} (integer)
Returns: ~
(nil)
features.CloseOnLeave({popup_id}) *features.CloseOnLeave*
Close the popup when focus leaves to a non-floating window.
Parameters: ~
{popup_id} (integer)
Returns: ~
(nil)
features.UncoverWindow({window}) *features.UncoverWindow*
Prevent detours from covering the provided window.
Parameters: ~
{window} (integer)
Returns: ~
(boolean)
features.UncoverWindowWithMouse() *features.UncoverWindowWithMouse*
Prompt to click a window and mark it as uncovered by detours.
Returns: ~
(nil)
features.HideAllDetours() *features.HideAllDetours*
Temporarily hide all detours in current tabpage.
Returns: ~
(nil)
features.RevealAllDetours() *features.RevealAllDetours*
Reveal all detours previously hidden in current tabpage.
Returns: ~
(nil)
features.CloseCurrentStack() *features.CloseCurrentStack*
Closes current detour along with all detours that it covers and covers it.
When not inside a detour, this function is a no-op.
Returns: ~
(boolean) true if close operation succeeded and false otherwise
@nodiscard
vim:tw=78:ts=8:noet:ft=help:norl:
================================================
FILE: doc/tags
================================================
detour detour.txt /*detour*
detour-algorithm detour.txt /*detour-algorithm*
detour-features detour.txt /*detour-features*
detour-into detour.txt /*detour-into*
detour-movements detour.txt /*detour-movements*
detour.Detour detour.txt /*detour.Detour*
detour.DetourCurrentWindow detour.txt /*detour.DetourCurrentWindow*
detour.algorithm detour.txt /*detour.algorithm*
detour.features detour.txt /*detour.features*
detour.movements detour.txt /*detour.movements*
features.CloseCurrentStack detour.txt /*features.CloseCurrentStack*
features.CloseOnLeave detour.txt /*features.CloseOnLeave*
features.HideAllDetours detour.txt /*features.HideAllDetours*
features.RevealAllDetours detour.txt /*features.RevealAllDetours*
features.ShowPathInTitle detour.txt /*features.ShowPathInTitle*
features.UncoverWindow detour.txt /*features.UncoverWindow*
features.UncoverWindowWithMouse detour.txt /*features.UncoverWindowWithMouse*
movements.DetourWinCmdH detour.txt /*movements.DetourWinCmdH*
movements.DetourWinCmdJ detour.txt /*movements.DetourWinCmdJ*
movements.DetourWinCmdK detour.txt /*movements.DetourWinCmdK*
movements.DetourWinCmdL detour.txt /*movements.DetourWinCmdL*
movements.DetourWinCmdW detour.txt /*movements.DetourWinCmdW*
movements._safe_state_handler detour.txt /*movements._safe_state_handler*
================================================
FILE: examples/help-float.md
================================================
# Display help files in a Detour window
```lua
vim.keymap.set("n", "", function()
local popup_id = require("detour").Detour()
if popup_id then
require("telescope.builtin").live_grep({
cwd = vim.fs.joinpath(vim.env.VIMRUNTIME, "doc"),
})
else
local keys = vim.api.nvim_replace_termcodes(":h ", true, true, true)
vim.api.nvim_feedkeys(keys, "n", true)
end
end)
```
================================================
FILE: examples/telescope.md
================================================
# Telescope keymaps
Here are examples of useful keymaps where you use detour popups together with the [telescope plugin](https://github.com/nvim-telescope/telescope.nvim).
### Terminal selection
Select an existing terminal to open in a popup. If none exist, open a new one.
```lua
vim.keymap.set('n', 't', function()
local terminal_buffer_found = false
-- Check if we there are any existing terminal buffers.
for _, buf in ipairs(vim.api.nvim_list_bufs()) do -- iterate through all buffers
if vim.api.nvim_buf_is_loaded(buf) then -- only check loaded buffers
if vim.api.nvim_buf_get_option(buf, "buftype") == "terminal" then
terminal_buffer_found = true
end
end
end
require('detour').Detour() -- Open a detour popup
if terminal_buffer_found then
require('telescope.builtin').buffers({}) -- Open telescope prompt
vim.api.nvim_feedkeys("term://", "n", true) -- populate prompt with "term://"
else
-- [OPTIONAL] Set the new window's current working directory to the directory of current file.
-- You can remove this line if you would prefer to open terminals from the
-- existing working directory.
vim.cmd.lcd(vim.fn.expand("%:p:h"))
-- Since there are no existing terminal buffers, open a new one.
vim.cmd.terminal()
vim.cmd.startinsert()
end
end)
```
================================================
FILE: lua/detour/algorithm_doc.lua
================================================
---@mod detour.algorithm
---@tag detour-algorithm
---@brief [[
---Detour's windowing algorithm computes the largest rectangle where a
---floating window (detour) can be placed without overlapping windows that must
---remain visible.
---
---Overview
---
--- Each detour has a list of "reserved" windows that it is allowed to cover.
---
--- When creating or resizing a detour, the algorithm will find the largest
--- rectangular area where a floating window can go that covers only reserved
--- windows. That rectangle will be the position and dimensions of the detour.
---
--- `Detour()` creates a detour `d` where all windows that are not currently
--- reserved by an existing detour are reserved by `d`.
---
--- `DetourCurrentWindow()` creates a detour `d` where only the current window
--- is reserved by `d`.
---
---Resizing
---
--- Whenever windows open, close, or get resized, detours will recalculate the
--- largest area they can fill and dynamically reshape themselves. This allows
--- them to make room for new windows or to expand to take space that has been
--- freed up.
---@brief ]]
local algorithm_doc = {}
--- Internal anchor to ensure module docs render. Do not use.
---@private
function algorithm_doc._doc_anchor() end
return algorithm_doc
================================================
FILE: lua/detour/config.lua
================================================
---@mod detour.config
---User configuration and defaults for detour.nvim.
---@class detour.config.Options
---@field title "none"|"path"
local config = {}
---@type detour.config.Options
local defaults = {
title = "path",
}
---@type detour.config.Options
config.options = {}
---Setup detour.nvim configuration.
---@param args? detour.config.Options
config.setup = function(args)
args = args or {}
local new_options =
vim.tbl_deep_extend("force", defaults, config.options, args or {})
if not vim.tbl_contains({ "none", "path" }, new_options.title) then
vim.api.nvim_echo({
{
'"'
.. tostring(new_options.title)
.. '" is an invalid value for title. Not changing detour configs.',
},
}, true, { err = true })
return
end
config.options = new_options
end
config.setup()
return config
================================================
FILE: lua/detour/features.lua
================================================
---@mod detour.features
---@brief [[
--- Optional detour.nvim features.
---@brief ]]
---@tag detour-features
local features = {}
local util = require("detour.util")
local internal = require("detour.internal")
---Update the detour window title to the current buffer path relative to cwd.
---@param window_id integer
---@return nil
local function update_title(window_id)
local tabwin = vim.fn.win_id2tabwin(window_id)
local tabnr, winnr = unpack(tabwin)
if tabnr == 0 and winnr == 0 then
return
end
local buffer_id = vim.api.nvim_win_get_buf(window_id)
local path = vim.api.nvim_buf_get_name(buffer_id)
local home = vim.fn.getcwd(winnr, tabnr)
local title = vim.fn.fnamemodify(path, ":.")
if title:sub(1, #home) == home then
title = title:sub(#home + 1)
end
vim.api.nvim_win_set_config(
window_id,
vim.tbl_extend(
"force",
vim.api.nvim_win_get_config(window_id),
{ title = title }
)
)
end
---Show the buffer path in the given popup's title and keep it updated.
---@param popup_id integer
---@return nil
function features.ShowPathInTitle(popup_id)
require("detour.show_path_in_title")
if
next(vim.api.nvim_get_autocmds({
pattern = "DetourUpdateTitle" .. util.stringify(popup_id),
group = internal.construct_augroup_name(popup_id),
})) ~= nil
then
-- ShowPathInTitle already called for this popup.
return
end
update_title(popup_id)
vim.api.nvim_create_autocmd({ "User" }, {
pattern = "DetourUpdateTitle" .. util.stringify(popup_id),
group = internal.construct_augroup_name(popup_id),
callback = function()
if not util.is_open(popup_id) then
return true
end
update_title(popup_id)
end,
})
end
---Close the popup when focus leaves to a non-floating window.
---@param popup_id integer
---@return nil
function features.CloseOnLeave(popup_id)
-- This autocmd will close the created detour popup when you focus on a different window.
vim.api.nvim_create_autocmd({ "WinEnter" }, {
group = internal.construct_augroup_name(popup_id),
callback = function()
local curr_window = vim.api.nvim_get_current_win()
-- Skip cases where we are entering popups (eg, menus, nested popups, the detour popup itself).
if vim.api.nvim_win_get_config(curr_window).relative ~= "" then
return
end
-- Check to make sure the popup has not already been closed
if util.is_open(popup_id) then
vim.api.nvim_win_close(popup_id, false)
end
end,
nested = true,
})
end
---Prevent detours from covering the provided window.
---@param window integer
---@return boolean
function features.UncoverWindow(window)
local ok = internal.unreserve_window(window)
if ok then
vim.api.nvim_exec_autocmds("VimResized", {})
end
return ok
end
---Prompt to click a window and mark it as uncovered by detours.
---@return nil
function features.UncoverWindowWithMouse()
local prev_mouse = vim.o.mouse
if not prev_mouse:match("a") then
vim.o.mouse = "a"
end
vim.api.nvim_echo(
{ { "Click a window (Press any key to cancel)…", "Question" } },
false,
{}
)
features.HideAllDetours()
vim.g.detour_temp_uncover = 0
vim.cmd([[
let c = getchar()
if c == "\" && v:mouse_win > 0
let g:detour_temp_uncover=1
endif
]])
features.RevealAllDetours()
vim.o.mouse = prev_mouse
vim.cmd("echo '' | redraw!") -- clear the prompt
if vim.g.detour_temp_uncover == 0 then
vim.o.mouse = prev_mouse
return
end
local m = vim.fn.getmousepos()
local winid = util.base_at_screenpos(
vim.api.nvim_get_current_tabpage(),
m.screenrow,
m.screencol
)
if winid then
features.UncoverWindow(winid)
end
end
---Temporarily hide all detours in current tabpage.
---@return nil
function features.HideAllDetours()
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if internal.is_detour(win) then
vim.api.nvim_win_set_config(win, { hide = true })
end
end
vim.cmd("redraw!")
end
---Reveal all detours previously hidden in current tabpage.
---@return nil
function features.RevealAllDetours()
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if internal.is_detour(win) then
vim.api.nvim_win_set_config(win, { hide = false })
end
end
vim.cmd("redraw!")
end
--- Closes current detour along with all detours that it covers and covers it.
--- When not inside a detour, this function is a no-op.
---@return boolean success true if close operation succeeded and false otherwise
---@nodiscard
function features.CloseCurrentStack()
internal.garbage_collect()
local current_window = vim.api.nvim_get_current_win()
local covered = internal.get_reserved_windows(current_window)
while covered do
local parent = covered[1]
vim.api.nvim_win_close(current_window, false)
if vim.api.nvim_get_current_win() == current_window then
-- Close operation failed
return false
end
vim.fn.win_gotoid(parent)
current_window = parent
covered = internal.get_reserved_windows(vim.api.nvim_get_current_win())
end
return true
end
return features
================================================
FILE: lua/detour/init.lua
================================================
---@mod detour
---@tag detour-into
---@brief [[
---Neovim/Vim's floating windows are a great utility to use in plugins and
---functions, but they cannot be used manually as splits are. This is because
---creating floats is not simple like calling `:split` or `:vsplit`.
---`vim.api.nvim_open_win(...)` requires coordinates and dimensions to make
---a float which is too tedious to do by hand.
---
---Detour.nvim brings a single new feature to Neovim: detour windows (aka
---detours). Detours are floating windows with the ease-of-use of splits.
---
---They dynamically shape themselves to cover as much of a given area as possible.
---They can cover:
---* the whole screen (`require("detour").Detour()`)
---* the current window (`require("detour").DetourCurrentWindow()`)
---* the current detour (both of the above functions would work)
---
---Detours will make sure not to overlap each other unless when a detour is
---nested within another.
---
---You will find that there are many cases where using a large floating window is
---preferable to creating a smaller split window. On top of that, the nesting
---behavior of detours allows you to take a long "detour" into other files/locations
---without losing your place in your regular windows. Take a detour, look at
---other locations, close the detour, and find your original windows as they
---were when you left.
---@brief ]]
local detour = {}
local util = require("detour.util")
local internal = require("detour.internal")
local algo = require("detour.windowing_algorithm")
local settings = require("detour.config")
---Resize an existing popup and update covered windows' focusability.
---@param window_id integer
---@param new_window_opts table
local function resize_popup(window_id, new_window_opts)
local current_window_opts = vim.api.nvim_win_get_config(window_id)
vim.api.nvim_win_set_config(
window_id,
vim.tbl_extend("force", current_window_opts, new_window_opts)
)
-- Not sure why this loop is necessary.
for _, covered_window in
ipairs(internal.get_reserved_windows(window_id) or {})
do
if util.is_floating(covered_window) then
vim.api.nvim_win_set_config(
covered_window,
vim.tbl_extend(
"force",
vim.api.nvim_win_get_config(covered_window),
{ focusable = not util.overlap(covered_window, window_id) }
)
)
end
end
-- Sometimes, resizing terminal buffers can end up scrolling the terminal UI
-- horizontally so that you only see a portion of the terminal UI. This code
-- scrolls the terminal UI so that the window keeps showing the leftmost
-- column
--
-- Condition on terminal mode (instead of checking whether buffer is
-- terminal-type) just in case user was intentionally looking at a specific
-- location in visual or normal mode.
if vim.fn.mode() == "t" then
local row = vim.api.nvim_win_get_position(window_id)[1]
vim.api.nvim_win_set_cursor(window_id, { row, 0 })
end
-- Fully complete resizing before propogating event.
vim.api.nvim_exec_autocmds("User", {
pattern = "DetourPopupResized" .. util.stringify(window_id),
})
end
---Create a nested popup above the current floating window.
---@return integer|nil popup_id
local function popup_above_float()
local parent = vim.api.nvim_get_current_win()
if vim.tbl_contains(internal.list_reserved_windows(), parent) then
vim.api.nvim_echo({
{
"[detour.nvim] This popup already has a child nested inside it: "
.. parent,
},
}, true, { err = true })
return nil
end
local parent_zindex = util.get_maybe_zindex(parent) or 0
local window_opts = algo.construct_nest(parent, parent_zindex + 1)
local child =
vim.api.nvim_open_win(vim.api.nvim_win_get_buf(0), true, window_opts)
if not internal.record_popup(child, { parent }) then
vim.api.nvim_win_close(child, true)
return nil
end
local augroup_id =
vim.api.nvim_create_augroup(internal.construct_augroup_name(child), {})
vim.api.nvim_create_autocmd({ "User" }, {
pattern = "DetourPopupResized" .. util.stringify(parent),
group = augroup_id,
callback = function()
if not util.is_open(child) then
internal.teardown_detour(child)
return
end
if util.is_open(parent) then
resize_popup(child, algo.construct_nest(parent))
end
end,
})
vim.api.nvim_create_autocmd({ "WinClosed" }, {
group = augroup_id,
pattern = tostring(child),
callback = function()
internal.teardown_detour(child)
if util.is_open(parent) then
vim.fn.win_gotoid(parent)
end
end,
nested = true, -- Trigger all the autocmds for entering a new window
})
if settings.options.title == "path" then
require("detour.features").ShowPathInTitle(child)
end
-- We're running this to make sure initializing popups runs the same code
-- path as updating popups
-- We make sure to do this after all state and autocmds are set.
vim.api.nvim_exec_autocmds("User", {
pattern = "DetourPopupResized" .. util.stringify(parent),
})
return child
end
---Create a base popup covering the given windows (or all non-floating windows).
---@param bufnr integer
---@param reserve_windows integer[]?
---@return integer|nil popup_id
local function popup(bufnr, reserve_windows)
local tab_id = vim.api.nvim_get_current_tabpage()
reserve_windows = reserve_windows
or vim.tbl_filter(function(window)
return not (
util.is_floating(window)
or vim.tbl_contains(internal.list_reserved_windows(), window)
)
end, vim.api.nvim_tabpage_list_wins(tab_id))
if #reserve_windows == 0 then
vim.api.nvim_echo(
{ { "[detour.nvim] No windows provided in coverable_windows." } },
true,
{ err = true }
)
return nil
end
for _, window in ipairs(reserve_windows) do
if util.is_floating(window) then
vim.api.nvim_echo({
{
"[detour.nvim] No floating windows allowed in base (ie, non-nested) popup "
.. window,
},
}, true, { err = true })
return nil
end
if vim.tbl_contains(internal.list_reserved_windows(), window) then
vim.api.nvim_echo({
{
"[detour.nvim] This window is already reserved by another detour: "
.. window,
},
}, true, { err = true })
return nil
end
end
local window_opts = algo.construct_window_opts(reserve_windows, tab_id)
if window_opts == nil then
return nil
end
local popup_id = vim.api.nvim_open_win(bufnr, true, window_opts)
if not internal.record_popup(popup_id, reserve_windows) then
vim.api.nvim_win_close(popup_id, true)
return nil
end
local augroup_id = vim.api.nvim_create_augroup(
internal.construct_augroup_name(popup_id),
{}
)
vim.api.nvim_create_autocmd({ "WinResized" }, {
group = augroup_id,
callback = function(event)
local reserved = internal.get_reserved_windows(popup_id)
if reserved == nil then
internal.teardown_detour(popup_id)
return
end
-- WinResized populates vim.v.event.windows but VimResized does not
-- so we default to listing all windows.
-- Use event.data.windows for tests.
local windows = vim.tbl_get(vim.v["event"], "windows") == nil
and event.data.windows
or vim.v["event"]["windows"]
local changed_window =
assert(windows[1], "no windows listed in WinResized event")
local changed_tab = vim.api.nvim_win_get_tabpage(changed_window)
if tab_id == changed_tab then
local new_window_opts =
algo.construct_window_opts(reserved, tab_id)
if new_window_opts then
resize_popup(popup_id, new_window_opts)
end
end
end,
})
vim.api.nvim_create_autocmd({ "VimResized" }, {
group = augroup_id,
callback = function()
internal.garbage_collect()
local reserved = internal.get_reserved_windows(popup_id)
if reserved == nil then
internal.teardown_detour(popup_id)
return
end
local new_window_opts = algo.construct_window_opts(reserved, tab_id)
-- If there is an issue that prevents a valid configuration for the
-- detour, just leave it for the user to manually clean up.
if new_window_opts then
resize_popup(popup_id, new_window_opts)
end
end,
})
vim.api.nvim_create_autocmd({ "WinClosed" }, {
group = augroup_id,
pattern = "" .. popup_id,
callback = function()
local reserved = internal.get_reserved_windows(popup_id)
internal.teardown_detour(popup_id)
for _, base in ipairs(reserved) do
if
vim.tbl_contains(
vim.api.nvim_tabpage_list_wins(tab_id),
base
)
then
vim.fn.win_gotoid(base)
return
end
end
end,
nested = true, -- Trigger all the autocmds for entering a new window
})
if settings.options.title == "path" then
require("detour.features").ShowPathInTitle(popup_id)
end
-- We're running this to make sure initializing popups runs the same code
-- path as updating popups. We make sure to do this after all state and
-- autocmds are set.
vim.api.nvim_exec_autocmds("VimResized", {})
return popup_id
end
---Open a new detour window
---
---* If this is called from a non-detour window, the largest possible detour
---window will be opened that does not overlap with any other detours.
---* If this is called from a detour window, a detour will be opened nested
---within just the current detour.
---
---There are cases where there is no space for a new detour window and
---this function call will do nothing.
---@return integer|nil popup_id returns detour's window id if successfully
---created, nil otherwise
---@nodiscard
---@usage `
--- local window_id = require("detour").Detour()
--- if not window_id then
--- -- New detour could not be made so stop execution
--- return
--- end
---
--- -- New detour window is open and cursor is moved to it.`
detour.Detour = function()
internal.garbage_collect()
if util.is_floating(vim.api.nvim_get_current_win()) then
return popup_above_float()
end
return popup(vim.api.nvim_get_current_buf())
end
---Open a detour popup covering only the current window.
---
---There are cases where there is no space for a new detour window and
---this function call will do nothing.
---@return integer|nil popup_id returns detour's window id if successfully
---created, nil otherwise
---@nodiscard
---@usage `
--- local window_id = require("detour").DetourCurrentWindow()
--- if not window_id then
--- -- New detour could not be made so stop execution
--- return
--- end
---
--- -- New detour window is open and cursor is moved to it.`
detour.DetourCurrentWindow = function()
internal.garbage_collect()
if util.is_floating(vim.api.nvim_get_current_win()) then
return popup_above_float()
end
return popup(
vim.api.nvim_get_current_buf(),
{ vim.api.nvim_get_current_win() }
)
end
detour.setup = require("detour.config").setup
return detour
================================================
FILE: lua/detour/internal.lua
================================================
-- DO NOT DEPEND ON THIS FILE!
-- This is an "internal" file and can have breaking changes without warning.
---@mod detour.internal
---Internal implementation details. Not part of the public API.
local internal = {}
---@class detour.internal
---@field construct_augroup_name fun(window_id: integer): string
---@field teardown_detour fun(window_id: integer)
---@field record_popup fun(popup_id: integer, coverable_windows: integer[]): boolean
---@field list_popups fun(): integer[]
---@field list_reserved_windows fun(): integer[]
---@field garbage_collect fun()
---@field is_detour fun(window: integer): boolean
---@field get_reserved_windows fun(popup_id: integer): integer[]|nil
---@field unreserve_window fun(window: integer): boolean
---@type table
local popup_to_reserved_windows = {}
---@param window_id integer
---@return string
function internal.construct_augroup_name(window_id)
return "detour-" .. window_id
end
-- Needs to be idempotent
---@param window_id integer
function internal.teardown_detour(window_id)
-- Be tolerant if the augroup was already removed by another path.
pcall(
vim.api.nvim_del_augroup_by_name,
internal.construct_augroup_name(window_id)
)
for _, covered_window in
ipairs(internal.get_reserved_windows(window_id) or {})
do
if vim.api.nvim_win_get_config(covered_window).relative ~= "" then
vim.api.nvim_win_set_config(
covered_window,
vim.tbl_extend(
"force",
vim.api.nvim_win_get_config(covered_window),
{ focusable = true }
)
)
end
end
popup_to_reserved_windows[window_id] = nil
end
function internal.is_detour(window)
return popup_to_reserved_windows[window] ~= nil
end
---@param popup_id integer
---@return integer[]|nil
function internal.get_reserved_windows(popup_id)
if popup_to_reserved_windows[popup_id] == nil then
return nil
end
-- Clean up any windows that have already been closed
popup_to_reserved_windows[popup_id] = vim.tbl_filter(function(window_id)
return vim.tbl_contains(vim.api.nvim_list_wins(), window_id)
end, popup_to_reserved_windows[popup_id])
return popup_to_reserved_windows[popup_id]
end
---@param popup_id integer
---@param coverable_windows integer[]
---@return boolean
function internal.record_popup(popup_id, coverable_windows)
local open_windows = vim.api.nvim_list_wins()
coverable_windows = vim.tbl_filter(function(window_id)
return vim.tbl_contains(open_windows, window_id)
end, coverable_windows)
if #coverable_windows == 0 then
vim.api.nvim_echo({
{
"[detour.nvim] You must provide at least one valid (open) coverable window.",
},
}, true, { err = true })
return false
end
popup_to_reserved_windows[popup_id] = coverable_windows
return true
end
---@return integer[]
function internal.list_popups()
return vim.tbl_keys(popup_to_reserved_windows)
end
---@return integer[]
function internal.list_reserved_windows()
local windows = vim.api.nvim_list_wins()
return vim.iter(vim.tbl_values(popup_to_reserved_windows))
:flatten()
:filter(function(w)
return vim.tbl_contains(windows, w) -- make sure window is still open
end)
:totable()
end
---@param window integer
---@return boolean
function internal.unreserve_window(window)
internal.garbage_collect()
local changed = false
local copy = vim.tbl_extend("force", popup_to_reserved_windows, {})
for popup, reserved_windows in pairs(popup_to_reserved_windows) do
copy[popup] = vim.iter(reserved_windows)
:filter(function(reserved)
if reserved ~= window then
return true
end
changed = true
return false
end)
:totable()
if #copy[popup] == 0 then
vim.api.nvim_echo({
{
"[detour.nvim] A detour must have at least one window to float over. Detour id: "
.. popup,
},
}, true, { err = true })
return false
end
end
popup_to_reserved_windows = copy
return changed
end
-- Neovim autocmd events are quite nuanced:
-- 1. Autocmds do not trigger autocmd events by default (you need to set `nested
-- = true` to do that).
-- 2. WinClosed autocmds do not trigger WinClosed events even if `nested = true`.
-- 3. Even with `nested = true`, there is a limit to how many nested events
-- Neovim will trigger (max depth is 10).
-- Hence, there are possible cases where popup detours will be closed by the
-- user's autocmds without triggering a WinClosed event. To address this, we
-- must make sure to update the plugin's state before executing each user
-- command. Also, we must double check what windows are still open during this
-- plugin's autocmd callbacks.
function internal.garbage_collect()
for _, popup_id in ipairs(internal.list_popups()) do
if not vim.tbl_contains(vim.api.nvim_list_wins(), popup_id) then
internal.teardown_detour(popup_id)
end
end
end
assert(
vim.fn.timer_start(
300,
vim.schedule_wrap(internal.garbage_collect),
{ ["repeat"] = -1 }
) ~= -1,
"[detour.nvim] Failed to create garbage_collect timer."
)
local group = vim.api.nvim_create_augroup("detour_internal", {})
local just_entered_window = false
vim.api.nvim_create_autocmd({ "WinEnter" }, {
group = group,
callback = function()
just_entered_window = true
end,
})
-- If the user interacts with a window, we should prevent detours from covering
-- it.
vim.api.nvim_create_autocmd({ "CursorMoved", "ModeChanged" }, {
group = group,
callback = function()
-- Ignore this event if `WinEnter` just happened.
if just_entered_window == true then
just_entered_window = false
return
end
if internal.unreserve_window(vim.api.nvim_get_current_win()) then
vim.api.nvim_exec_autocmds("VimResized", {})
end
end,
})
return internal
================================================
FILE: lua/detour/movements.lua
================================================
---@mod detour.movements
---@brief [[
---Detour.nvim breaks window navigation commands such as
---`w`, `j`, or `vim.cmd.wincmd("h")`. Instead of
---using those, the user MUST use the following window navigation
---commands that this plugin provides that implements moving
---between windows while skipping over windows covered by detours.
---
---NOTE: Regular window movements such as `w`, `j`,
---`vim.cmd.wincmd("h")` should still work in automated
---scripts/functions. Still, you may find it more useful to use detour's
---"detour-aware" movement functions in your scripts/functions as well.
---@brief ]]
---@tag detour-movements
local movements = {}
local util = require("detour.util")
local internal = require("detour.internal")
local module_augroup = "detour-movements"
vim.api.nvim_create_augroup(module_augroup, { clear = true })
---DO NOT USE. FOR TESTING ONLY.
movements._safe_state_handler = function()
internal.garbage_collect()
vim.fn.win_gotoid(util.find_top_popup())
end
-- Sometimes the cursor can end up behind a detour (eg, when a window is
-- closed). In these cases just move the cursor to an appropriate place.
-- Using SafeState here means this autocmd will not interfere with automated
-- movements.
vim.api.nvim_create_autocmd({ "SafeState" }, {
group = module_augroup,
callback = movements._safe_state_handler,
nested = true,
})
--- Switch to a window to the right. Skip over any non-floating windows
--- covered by a detour.
---@return nil
---@usage `
--- local detour_moves = require("detour.movements")
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdL)
--- vim.keymap.set({ "n", "t" }, "l", detour_moves.DetourWinCmdL)
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdL)
---`
function movements.DetourWinCmdL()
local covered_bases = util.find_covered_bases(
vim.api.nvim_get_current_win()
) or { vim.api.nvim_get_current_win() }
table.sort(covered_bases, function(windowA, windowB)
local _, _, _, rightA = util.get_text_area_dimensions(windowA)
local _, _, _, rightB = util.get_text_area_dimensions(windowB)
return rightA > rightB
end)
local base = covered_bases[1]
for _, window in ipairs(covered_bases) do
local _, _, _, right = util.get_text_area_dimensions(window)
if right ~= vim.o.columns then
base = window
break
end
end
vim.fn.win_gotoid(base)
vim.cmd.wincmd("l")
-- It's possible to rely on the SafeState autocmd instead of explicitly
-- moving to the top popup, but an explicit call allows this function to work
-- properly when used in other code.
vim.fn.win_gotoid(util.find_top_popup())
end
--- Switch to a window to the left. Skip over any non-floating windows
--- covered by a detour.
---@return nil
---@usage `
--- local detour_moves = require("detour.movements")
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdH)
--- vim.keymap.set({ "n", "t" }, "h", detour_moves.DetourWinCmdH)
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdH)
---`
function movements.DetourWinCmdH()
local covered_bases = util.find_covered_bases(
vim.api.nvim_get_current_win()
) or { vim.api.nvim_get_current_win() }
table.sort(covered_bases, function(windowA, windowB)
local _, _, leftA, _ = util.get_text_area_dimensions(windowA)
local _, _, leftB, _ = util.get_text_area_dimensions(windowB)
return leftA < leftB
end)
local base = covered_bases[1]
for _, window in ipairs(covered_bases) do
local _, _, left, _ = util.get_text_area_dimensions(window)
if left ~= 0 then
base = window
break
end
end
vim.fn.win_gotoid(base)
vim.cmd.wincmd("h")
vim.fn.win_gotoid(util.find_top_popup())
end
--- Switch to a window below. Skip over any non-floating windows
--- covered by a detour.
---@return nil
---@usage `
--- local detour_moves = require("detour.movements")
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdJ)
--- vim.keymap.set({ "n", "t" }, "j", detour_moves.DetourWinCmdJ)
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdJ)
---`
function movements.DetourWinCmdJ()
local covered_bases = util.find_covered_bases(
vim.api.nvim_get_current_win()
) or { vim.api.nvim_get_current_win() }
table.sort(covered_bases, function(windowA, windowB)
local _, bottomA, _, _ = util.get_text_area_dimensions(windowA)
local _, bottomB, _, _ = util.get_text_area_dimensions(windowB)
return bottomA > bottomB
end)
local base = covered_bases[1]
for _, window in ipairs(covered_bases) do
local _, bottom, _, _ = util.get_text_area_dimensions(window)
if
bottom
~= vim.o.lines
- vim.o.cmdheight
- (vim.o.laststatus == 0 and 0 or 1)
then -- subtract one for statusline
base = window
break
end
end
vim.fn.win_gotoid(base)
vim.cmd.wincmd("j")
vim.fn.win_gotoid(util.find_top_popup())
end
--- Switch to a window above. Skip over any non-floating windows
--- covered by a detour.
---@return nil
---@usage `
--- local detour_moves = require("detour.movements")
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdK)
--- vim.keymap.set({ "n", "t" }, "k", detour_moves.DetourWinCmdK)
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdK)
---`
function movements.DetourWinCmdK()
local covered_bases = util.find_covered_bases(
vim.api.nvim_get_current_win()
) or { vim.api.nvim_get_current_win() }
table.sort(covered_bases, function(windowA, windowB)
local topA, _, _, _ = util.get_text_area_dimensions(windowA)
local topB, _, _, _ = util.get_text_area_dimensions(windowB)
return topA < topB
end)
local base = covered_bases[1]
for _, window in ipairs(covered_bases) do
local top, _, _, _ = util.get_text_area_dimensions(window)
if top ~= 0 then
base = window
break
end
end
vim.fn.win_gotoid(base)
vim.cmd.wincmd("k")
vim.fn.win_gotoid(util.find_top_popup())
end
--- Switch windows in a cycle. Skip over any non-floating windows covered
--- by a detour.
---@return nil
---@usage `
--- local detour_moves = require("detour.movements")
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdW)
--- vim.keymap.set({ "n", "t" }, "w", detour_moves.DetourWinCmdW)
--- vim.keymap.set({ "n", "t" }, "", detour_moves.DetourWinCmdW)
---`
function movements.DetourWinCmdW()
-- We do not just repeatedly do `vim.cmd.wincmd("w")` until we hit a detour
-- or uncovered window because doing so could form a cycle that does not
-- include all windows.
local windows = vim.api.nvim_tabpage_list_wins(0)
-- TODO: if there are no (visible) detours, just call vim.cmd.wincmd("w").
local current_top = util.find_top_popup()
-- Collect all the top detour or naked base windows.
local tops = {}
for _, window in ipairs(windows) do
tops[util.find_top_popup(window)] = true
end
-- Sort all the top windows. Filter out the windows ordered before the
-- current top.
local ordered_tops = {}
for top in vim.spairs(tops) do
if top > current_top then
ordered_tops[#ordered_tops + 1] = top
end
end
-- In tops is empty, add the first top so that we cycle back to the
-- beginning of the list.
for top in vim.spairs(tops) do
ordered_tops[#ordered_tops + 1] = top
break
end
vim.fn.win_gotoid(ordered_tops[1])
end
return movements
================================================
FILE: lua/detour/show_path_in_title.lua
================================================
---@mod detour.show_path_in_title
---Internal helper for updating popup titles.
-- Put this code in its own library to make sure it's only run once.
local util = require("detour.util")
local internal = require("detour.internal")
---@type integer
local ns = vim.api.nvim_create_namespace("detour.nvim-ns")
---@type uv.uv_timer_t
local timer = assert(vim.uv.new_timer())
-- This implements a trailing debouce where each call to the debounced function
-- will start a timer and cancel any existing timers for that function. The
-- function will eventually be called with the arguments from its most recent
-- call.
---@param ms integer
---@param fn fun(...: any)
---@return fun(...: any)
local function debounce(ms, fn)
return function(...)
local argv = { ... }
timer:stop()
timer:start(ms, 0, function()
vim.schedule_wrap(fn)(unpack(argv))
end)
end
end
---@param _ any
---@param window_id integer
local function update_title(_, window_id)
if vim.tbl_contains(internal.list_popups(), window_id) then
vim.api.nvim_exec_autocmds("User", {
pattern = "DetourUpdateTitle" .. util.stringify(window_id),
})
end
end
-- The reason why we're using a callback on decoration_provider instead of using an autocmd on BufEnter is because we
-- want to trigger a title update while browsing through netrw directories and that doesn't trigger BufEnter.
--
-- From `api.txt`:
-- (About nvim_set_decoration_provider) doing anything other than setting
-- extmarks is considered experimental. Doing things like changing options are
-- not explicitly forbidden, but is likely to have unexpected consequences (such
-- as 100% CPU consumption). Doing `vim.rpcnotify` should be OK, but
-- `vim.rpcrequest` is quite dubious for the moment.
--
-- I debounce `update_title` since rapidly changing the title with `on_win`
-- causes neovim to freeze.
vim.api.nvim_set_decoration_provider(ns, {
on_win = debounce(50, update_title),
})
return {}
================================================
FILE: lua/detour/util.lua
================================================
---@mod detour.util
---Utilities for window geometry, state checks, and helpers.
local util = {}
---@class detour.util
---@field Set fun(list: any[]): table
---@field contains_element fun(array: any[], target: any): boolean
---@field contains_key fun(array: table, target: any): boolean
---@field contains_value fun(array: table, target: any): boolean
---@field get_text_area_dimensions fun(window_id: integer): integer, integer, integer, integer
---@field is_floating fun(window_id: integer): boolean
---@field get_maybe_zindex fun(window_id: integer): integer|nil
---@field overlap fun(window_a: integer, window_b: integer): boolean
---@field find_top_popup fun(window?: integer): integer
---@field find_covered_bases fun(window_id: integer): integer[]|nil
---@field find_covered_windows fun(window_id: integer): integer[]
---@field is_open fun(window_id: integer): boolean
---@field stringify fun(number: integer): string
---@field pairs_by_keys fun(t: table, f?: fun(a:any,b:any):boolean): fun(): any, any
---@field is_statusline_global fun(): boolean
---@field base_at_screenpos fun(tab_id: integer, screenrow: integer, screencol: integer): integer|nil
local internal = require("detour.internal")
---@param list any[]
---@return table
function util.Set(list)
local set = {}
for _, l in ipairs(list) do
set[l] = true
end
return set
end
---@param array any[]
---@param target any
---@return boolean
function util.contains_element(array, target)
for _, value in ipairs(array) do
if value == target then
return true
end
end
return false
end
---@param array table
---@param target any
---@return boolean
function util.contains_key(array, target)
for key, _ in pairs(array) do
if key == target then
return true
end
end
return false
end
---@param array table
---@param target any
---@return boolean
function util.contains_value(array, target)
for _, value in pairs(array) do
if value == target then
return true
end
end
return false
end
--- Returns the positions of top, bottom, left, and right of a given window's text area.
--- The statusline is not included in the text area. Bottom and right are exclusive.
---@param window_id integer
---@return integer top, integer bottom, integer left, integer right
function util.get_text_area_dimensions(window_id)
local top, left = unpack(vim.api.nvim_win_get_position(window_id))
local bottom = top + vim.api.nvim_win_get_height(window_id)
local right = left + vim.api.nvim_win_get_width(window_id)
return top, bottom, left, right
end
---@param window_id integer
---@return boolean
function util.is_floating(window_id)
return vim.api.nvim_win_get_config(window_id).relative ~= ""
end
--- Returns the zindex for the given window, if floating, otherwise nil.
---@param window_id integer
---@return integer|nil
function util.get_maybe_zindex(window_id)
return vim.api.nvim_win_get_config(window_id).zindex
end
---@param positions_a { [1]: integer, [2]: integer, [3]: integer, [4]: integer }
---@param positions_b { [1]: integer, [2]: integer, [3]: integer, [4]: integer }
---@return boolean
local function overlap_helper(positions_a, positions_b)
local top_a, bottom_a, left_a, right_a = unpack(positions_a)
local top_b, bottom_b, left_b, right_b = unpack(positions_b)
if math.max(left_a, left_b) >= math.min(right_a, right_b) then
return false
end
if math.max(top_a, top_b) >= math.min(bottom_a, bottom_b) then
return false
end
return true
end
---@param window_a integer
---@param window_b integer
---@return boolean
function util.overlap(window_a, window_b)
return overlap_helper(
{ util.get_text_area_dimensions(window_a) },
{ util.get_text_area_dimensions(window_b) }
)
end
---@param window? integer
---@return integer window_id
function util.find_top_popup(window)
local window_id = window or vim.api.nvim_get_current_win()
local all_coverable_windows = internal.list_reserved_windows()
for _, popup in ipairs(internal.list_popups()) do
if
not vim.list_contains(all_coverable_windows, popup) -- ignore popups with popups nested in them
and vim.tbl_contains(util.find_covered_windows(popup), window_id)
then
return popup
end
end
return window_id -- no popup that covers the current window was found
end
-- Finds all base windows that are covered by the provided popup.
-- If the provided window is not a popup, returns the given argument.
---@param window_id integer
---@return integer[]|nil
function util.find_covered_bases(window_id)
assert(util.is_open(window_id), tostring(window_id) .. " is not open")
local current_window = window_id
local coverable_bases = nil
while internal.get_reserved_windows(current_window) do
coverable_bases = internal.get_reserved_windows(current_window) or {}
assert(
#coverable_bases > 0,
"[detour.nvim] There should never be an empty array in popup_to_covered_windows."
)
-- We iterate on only the first covered window because there are two cases:
-- A: there is exactly one covered window and it's another detour.
-- B: there is one or more covered windows and none of them are detours. We've found our covered base windows. Hence, this would be the last iteration of this loop.
current_window = coverable_bases[1]
end
-- This covers the case where the window_id is not a detour popup and we never enter the above loop.
if coverable_bases == nil then
return nil
end
return vim.tbl_filter(function(base)
return util.overlap(base, window_id)
end, coverable_bases)
end
-- Finds all windows that are covered by the provided popup.
-- If the provided window is not a popup, returns the given argument.
---@param window integer
---@return integer[]
function util.find_covered_windows(window)
local current_window = window
local coverable_windows = {}
while internal.get_reserved_windows(current_window) do
coverable_windows[#coverable_windows + 1] =
internal.get_reserved_windows(current_window)
assert(
#coverable_windows[#coverable_windows] > 0,
"[detour.nvim] There should never be an empty array in popup_to_covered_windows."
)
-- We iterate on only the first covered window because there are two cases:
-- A: there is exactly one covered window and it's another detour.
-- B: there is one or more covered windows and none of them are detours. We've found our covered base windows. Hence, this would be the last iteration of this loop.
current_window = coverable_windows[#coverable_windows][1]
end
if #coverable_windows == 0 then
return { window }
end
return vim.iter(coverable_windows)
:flatten()
:filter(function(other)
return util.overlap(other, window)
end)
:totable()
end
---@param window_id integer
---@return boolean
function util.is_open(window_id)
return vim.tbl_contains(vim.api.nvim_list_wins(), window_id)
end
---@param number integer
---@return string
function util.stringify(number)
local base = string.byte("a")
local values = {}
for digit in ("" .. number):gmatch(".") do
values[#values + 1] = tonumber(digit) + base
end
return string.char(unpack(values))
end
---@param t table
---@param f? fun(a:any,b:any):boolean
---@return fun(): any, any
function util.pairs_by_keys(t, f)
local a = {}
for n in pairs(t) do
table.insert(a, n)
end
table.sort(a, f)
local i = 0 -- iterator variable
local iter = function() -- iterator function
i = i + 1
if a[i] == nil then
return nil
else
return a[i], t[a[i]]
end
end
return iter
end
---Whether the statusline is global (laststatus == 3).
---@return boolean
function util.is_statusline_global()
-- When laststatus == 3, Neovim uses a single global statusline
-- and individual windows do not have their own.
return vim.o.laststatus == 3
end
---Find non-floating window at a given screen position (1-based)
---@param tab_id integer
---@param screenrow integer
---@param screencol integer
---@return integer|nil
function util.base_at_screenpos(tab_id, screenrow, screencol)
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tab_id)) do
if not util.is_floating(win) then
local winnr = vim.fn.win_id2win(win)
assert(winnr ~= 0, tostring(win) .. " was not found")
local top, left = unpack(vim.fn.win_screenpos(winnr))
assert(top ~= 0 and left ~= 0, tostring(winnr) .. " was not found")
local width = vim.fn.winwidth(winnr) -- includes signs/number column
local height = vim.fn.winheight(winnr)
if
screenrow >= top
and screenrow
< top
+ height
+ (util.is_statusline_global() and 0 or 1) -- for per-window statusline
+ 1 -- for the border and global statusline
and screencol >= left
and screencol < left + width
then
return win
end
end
end
return nil
end
return util
================================================
FILE: lua/detour/windowing_algorithm.lua
================================================
---@mod detour.windowing_algorithm
---Layout algorithm for computing popup positions.
local algo = {}
local util = require("detour.util")
---Compute float options to cover the given windows without overlapping others.
---@param coverable_windows integer[]
---@param tab_id integer
---@return table|nil
function algo.construct_window_opts(coverable_windows, tab_id)
local roots = {}
for _, window_id in ipairs(vim.api.nvim_tabpage_list_wins(tab_id)) do
if not util.is_floating(window_id) then
table.insert(roots, window_id)
end
end
local uncoverable_windows = {}
for _, root in ipairs(roots) do
if not util.contains_element(coverable_windows, root) then
table.insert(uncoverable_windows, root)
end
end
local horizontals = {}
local verticals = {}
for _, root in ipairs(roots) do
local top, bottom, left, right = util.get_text_area_dimensions(root)
horizontals[top] = 1
horizontals[bottom] = 1
verticals[left] = 1
verticals[right] = 1
end
local floors = {}
local sides = {}
for top, _ in pairs(horizontals) do
for bottom, _ in pairs(horizontals) do
if top < bottom then
table.insert(floors, { top, bottom })
end
end
end
for left, _ in pairs(verticals) do
for right, _ in pairs(verticals) do
if left < right then
table.insert(sides, { left, right })
end
end
end
local max_area = 0
local dimensions = nil
for _, curr_floors in ipairs(floors) do
local top, bottom = unpack(curr_floors)
for _, curr_sides in ipairs(sides) do
local left, right = unpack(curr_sides)
local legal = true
for _, uncoverable_window in ipairs(uncoverable_windows) do
local uncoverable_top, uncoverable_bottom, uncoverable_left, uncoverable_right =
util.get_text_area_dimensions(uncoverable_window)
if not util.is_statusline_global() then -- we have to worry about statuslines
-- The fact that we're starting with text area dimensions
-- means that all of the rectangles we are working with do
-- not include statuslines. This means that we need to avoid
-- inadvertantly covering a window's status line. Neovim can
-- be configured to only show statuslines when there are
-- multiple windows on the screen but we can ignore that
-- because this loop only runs when there are multiple
-- windows on the screen.
uncoverable_top = uncoverable_top - 1 -- don't cover above window's statusline
uncoverable_bottom = uncoverable_bottom + 1 -- don't cover this window's statusline
end
local lowest_top = math.max(top, uncoverable_top)
local highest_bottom = math.min(bottom, uncoverable_bottom)
local rightest_left = math.max(left, uncoverable_left)
local leftest_right = math.min(right, uncoverable_right)
if
(lowest_top < highest_bottom)
and (rightest_left < leftest_right)
then
legal = false
end
end
local area = (bottom - top) * (right - left)
if legal and (area > max_area) then
dimensions = { top, bottom, left, right }
max_area = area
end
end
end
if dimensions == nil then
vim.api.nvim_echo(
{ { "[detour.nvim] was unable to find a spot to create a popup." } },
true,
{ err = true }
)
return nil
end
local top, bottom, left, right = unpack(dimensions)
local width = right - left
local height = bottom - top
if height < 1 then
vim.api.nvim_echo({
{
"[detour.nvim] (please file a github issue!) height is supposed to be at least 1.",
},
}, true, { err = true })
return nil
end
if width < 1 then
vim.api.nvim_echo({
{
"[detour.nvim] (please file a github issue!) width is supposed to be at least 1.",
},
}, true, { err = true })
return nil
end
-- If a window's height extends below the UI, the window's border gets cut off.
-- If a window's width extends beyond the UI, the window's border still shows up at the end of the UI.
-- Using a border adds 2 to the window's height and width.
local window_opts = {
relative = "editor",
row = top,
col = left,
width = (width - 2 > 0) and (width - 2) or width, -- create some space for borders
height = (height - 2 > 0) and (height - 2) or height, -- create some space for borders
border = "rounded",
zindex = 1,
}
if window_opts.width > 4 then
window_opts.width = window_opts.width - 4
window_opts.col = window_opts.col + 2
end
if window_opts.height > 2 then
window_opts.height = window_opts.height - 2
window_opts.row = window_opts.row + 1
end
return window_opts
end
---Construct nested float window opts within a parent float.
---@param parent integer
---@param layer integer?
---@return table
function algo.construct_nest(parent, layer)
assert(
util.is_open(parent),
"trying to construct a nested window in a window that doesn't exist: ",
parent
)
local top, bottom, left, right = util.get_text_area_dimensions(parent)
local width = right - left
local height = bottom - top
local border = "rounded"
if height >= 3 then
height = height - 2
top = top + 1
end
if width >= 3 then
width = width - 2
left = left + 1
end
return {
relative = "editor",
row = top,
col = left,
width = width,
height = height,
border = border,
zindex = layer,
}
end
return algo
================================================
FILE: plugin/detour.lua
================================================
local detour = require("detour")
local features = require("detour.features")
vim.api.nvim_create_user_command("Detour", detour.Detour, {})
vim.api.nvim_create_user_command(
"DetourCurrentWindow",
detour.DetourCurrentWindow,
{}
)
vim.api.nvim_create_user_command(
"DetourUncoverWindowWithMouse",
features.UncoverWindowWithMouse,
{}
)
vim.api.nvim_create_user_command(
"DetourHideAllDetours",
features.HideAllDetours,
{}
)
vim.api.nvim_create_user_command(
"DetourRevealAllDetours",
features.RevealAllDetours,
{}
)
vim.api.nvim_create_user_command(
"DetourCloseCurrentStack",
features.CloseCurrentStack,
{}
)
================================================
FILE: run-in-docker.sh
================================================
#!/bin/sh
nvim -u NONE \
-c "lua local k,l,_=pcall(require,'luarocks.loader') _=k and l.add_context('busted','$BUSTED_VERSION')" \
-l "/usr/local/lib/luarocks/rocks-5.1/busted/$BUSTED_VERSION/bin/busted" .
================================================
FILE: spec/config_spec.lua
================================================
local detour = require("detour")
local config = require("detour.config")
describe("detour config", function()
before_each(function()
vim.g.detour_testing = true
vim.cmd([[
%bwipeout!
mapclear
nmapclear
vmapclear
xmapclear
smapclear
omapclear
mapclear
imapclear
lmapclear
cmapclear
tmapclear
]])
vim.api.nvim_clear_autocmds({}) -- delete any autocmds not in a group
for _, autocmd in ipairs(vim.api.nvim_get_autocmds({})) do
if vim.startswith(autocmd.group_name, "detour-") then
vim.api.nvim_del_autocmd(autocmd.id)
end
end
vim.o.splitbelow = true
vim.o.splitright = true
-- Reset to default configuration for each test
config.setup({ title = "path" })
end)
it("disables titles when title = 'none'", function()
config.setup({ title = "none" })
assert(detour.Detour())
-- With title disabled, floating window should not have a title
assert.is_nil(vim.api.nvim_win_get_config(0).title)
end)
it("rejects invalid options and keeps previous config", function()
local before = config.options.title
config.setup({ title = "not-a-valid-option" })
assert.same(before, config.options.title)
end)
end)
================================================
FILE: spec/detour_auto_unreserve_spec.lua
================================================
local detour = require("detour")
local util = require("detour.util")
describe("detour auto-unreserve on interaction", function()
before_each(function()
vim.g.detour_testing = true
vim.cmd([[
%bwipeout!
mapclear
nmapclear
vmapclear
xmapclear
smapclear
omapclear
mapclear
imapclear
lmapclear
cmapclear
tmapclear
]])
vim.api.nvim_clear_autocmds({}) -- delete any autocmds not in a group
for _, autocmd in ipairs(vim.api.nvim_get_autocmds({ pattern = "*" })) do
if vim.startswith(autocmd.group_name, "detour-") then
vim.api.nvim_del_autocmd(autocmd.id)
end
end
vim.o.splitbelow = true
vim.o.splitright = true
end)
it("unreserves interacted window and resizes popup", function()
-- Create a 2-column layout
local left_base = vim.api.nvim_get_current_win()
vim.cmd.vsplit()
local right_base = vim.api.nvim_get_current_win()
-- Create a detour covering both base windows
local popup = assert(detour.Detour())
assert.True(util.overlap(popup, right_base))
-- Focus the left base window, then simulate user interaction
vim.cmd.split()
vim.api.nvim_exec_autocmds("VimResized", {}) -- trigger detour resize
assert.False(util.overlap(popup, right_base))
vim.fn.win_gotoid(right_base)
vim.cmd.startinsert() -- right_base should now be unreserved
assert.are.same(util.find_covered_windows(popup), { left_base })
end)
end)
================================================
FILE: spec/detour_close_stack_spec.lua
================================================
local detour = require("detour")
local util = require("detour.util")
local features = require("detour.features")
describe("features.CloseCurrentStack", function()
before_each(function()
vim.g.detour_testing = true
vim.cmd([[
%bwipeout!
mapclear
nmapclear
vmapclear
xmapclear
smapclear
omapclear
mapclear
imapclear
lmapclear
cmapclear
tmapclear
]])
vim.api.nvim_clear_autocmds({}) -- delete any autocmds not in a group
for _, autocmd in ipairs(vim.api.nvim_get_autocmds({ pattern = "*" })) do
if vim.startswith(autocmd.group_name, "detour-") then
vim.api.nvim_del_autocmd(autocmd.id)
end
end
vim.o.splitbelow = true
vim.o.splitright = true
end)
it("closes a single detour and returns to base", function()
local base = vim.api.nvim_get_current_win()
local popup = assert(detour.Detour())
features.CloseCurrentStack()
assert.False(util.is_open(popup))
assert.same({ base }, vim.api.nvim_list_wins())
end)
it("closes all nested detours in the chain", function()
local original = vim.api.nvim_get_current_win()
vim.cmd.split()
local base = vim.api.nvim_get_current_win()
local parent = assert(detour.Detour())
local child = assert(detour.Detour())
local grandchild = assert(detour.Detour())
features.CloseCurrentStack()
assert.False(util.is_open(parent))
assert.False(util.is_open(child))
assert.False(util.is_open(grandchild))
assert.same({ original, base }, vim.api.nvim_list_wins())
vim.api.nvim_win_close(base, true)
end)
it("is a no-op when not inside a detour", function()
vim.cmd.split()
local base = vim.api.nvim_get_current_win()
local before = vim.api.nvim_list_wins()
features.CloseCurrentStack()
local after = vim.api.nvim_list_wins()
assert.same(before, after)
assert.same(base, vim.api.nvim_get_current_win())
vim.cmd.close()
end)
end)
================================================
FILE: spec/detour_hide_reveal_spec.lua
================================================
local detour = require("detour")
local util = require("detour.util")
local features = require("detour.features")
describe("detour hide/reveal", function()
before_each(function()
vim.g.detour_testing = true
vim.cmd([[
%bwipeout!
mapclear
nmapclear
vmapclear
xmapclear
smapclear
omapclear
mapclear
imapclear
lmapclear
cmapclear
tmapclear
]])
vim.api.nvim_clear_autocmds({})
for _, autocmd in ipairs(vim.api.nvim_get_autocmds({ pattern = "*" })) do
if vim.startswith(autocmd.group_name, "detour-") then
vim.api.nvim_del_autocmd(autocmd.id)
end
end
vim.o.splitbelow = true
vim.o.splitright = true
end)
it("HideAllDetours and RevealAllDetours toggle popup visibility", function()
-- Create two side-by-side base windows
vim.cmd.vsplit()
vim.cmd.wincmd("h")
local left_base = vim.api.nvim_get_current_win()
vim.cmd.wincmd("l")
local right_base = vim.api.nvim_get_current_win()
-- Create a detour over each base window
vim.fn.win_gotoid(right_base)
local right_popup = assert(detour.DetourCurrentWindow())
vim.fn.win_gotoid(left_base)
local left_popup = assert(detour.DetourCurrentWindow())
-- Initially, popups are visible
assert.False(vim.api.nvim_win_get_config(left_popup).hide or false)
assert.False(vim.api.nvim_win_get_config(right_popup).hide or false)
-- Hide all detours
features.HideAllDetours()
assert.True(vim.api.nvim_win_get_config(left_popup).hide)
assert.True(vim.api.nvim_win_get_config(right_popup).hide)
-- Reveal all detours
features.RevealAllDetours()
assert.False(vim.api.nvim_win_get_config(left_popup).hide)
assert.False(vim.api.nvim_win_get_config(right_popup).hide)
end)
end)
================================================
FILE: spec/detour_movements_spec.lua
================================================
local detour = require("detour")
local movements = require("detour.movements")
local util = require("detour.util")
function Set(list)
local set = {}
for _, l in ipairs(list) do
set[l] = true
end
return set
end
describe("detour", function()
before_each(function()
vim.cmd([[
%bwipeout!
mapclear
nmapclear
vmapclear
xmapclear
smapclear
omapclear
mapclear
imapclear
lmapclear
cmapclear
tmapclear
]])
vim.api.nvim_clear_autocmds({}) -- delete any autocmds not in a group
for _, autocmd in ipairs(vim.api.nvim_get_autocmds({ pattern = "*" })) do
if vim.startswith(autocmd.group_name, "detour-") then
vim.api.nvim_del_autocmd(autocmd.id)
end
end
vim.o.splitbelow = true
vim.o.splitright = true
end)
it("Switching horizontally between detours", function()
local left_base = vim.api.nvim_get_current_win()
vim.cmd.vsplit()
local middle_base = vim.api.nvim_get_current_win()
vim.cmd.vsplit()
local right_base = vim.api.nvim_get_current_win()
vim.fn.win_gotoid(middle_base)
local middle_popup = assert(detour.DetourCurrentWindow())
-- Enter and leave a popup from a base window in both directions
movements.DetourWinCmdH()
assert.same(vim.api.nvim_get_current_win(), left_base)
movements.DetourWinCmdL()
assert.same(vim.api.nvim_get_current_win(), middle_popup)
movements.DetourWinCmdL()
assert.same(vim.api.nvim_get_current_win(), right_base)
movements.DetourWinCmdH()
assert.same(vim.api.nvim_get_current_win(), middle_popup)
-- Create nested popup
local middle_nested_popup = assert(detour.DetourCurrentWindow())
-- Enter and leave a nested popup from a base window in both directions
movements.DetourWinCmdH()
assert.same(vim.api.nvim_get_current_win(), left_base)
movements.DetourWinCmdL()
assert.same(vim.api.nvim_get_current_win(), middle_nested_popup)
movements.DetourWinCmdL()
assert.same(vim.api.nvim_get_current_win(), right_base)
movements.DetourWinCmdH()
assert.same(vim.api.nvim_get_current_win(), middle_nested_popup)
-- Create popups on left and right
vim.fn.win_gotoid(left_base)
local left_popup = assert(detour.DetourCurrentWindow())
vim.fn.win_gotoid(right_base)
local right_popup = assert(detour.DetourCurrentWindow())
vim.fn.win_gotoid(middle_nested_popup)
-- Enter and leave a nested popup from a non-nested popup in both directions
movements.DetourWinCmdH()
assert.same(vim.api.nvim_get_current_win(), left_popup)
movements.DetourWinCmdL()
assert.same(vim.api.nvim_get_current_win(), middle_nested_popup)
movements.DetourWinCmdL()
assert.same(vim.api.nvim_get_current_win(), right_popup)
movements.DetourWinCmdH()
assert.same(vim.api.nvim_get_current_win(), middle_nested_popup)
end)
it("Switching vertically between detours", function()
local top_base = vim.api.nvim_get_current_win()
vim.cmd.split()
local middle_base = vim.api.nvim_get_current_win()
vim.cmd.split()
local bottom_base = vim.api.nvim_get_current_win()
vim.fn.win_gotoid(middle_base)
local middle_popup = assert(detour.DetourCurrentWindow())
-- Enter and leave a popup from a base window in both directions
movements.DetourWinCmdK()
assert.same(vim.api.nvim_get_current_win(), top_base)
movements.DetourWinCmdJ()
assert.same(vim.api.nvim_get_current_win(), middle_popup)
movements.DetourWinCmdJ()
assert.same(vim.api.nvim_get_current_win(), bottom_base)
movements.DetourWinCmdK()
assert.same(vim.api.nvim_get_current_win(), middle_popup)
-- Create nested popup
local middle_nested_popup = assert(detour.DetourCurrentWindow())
-- Enter and leave a nested popup from a base window in both directions
movements.DetourWinCmdK()
assert.same(vim.api.nvim_get_current_win(), top_base)
movements.DetourWinCmdJ()
assert.same(vim.api.nvim_get_current_win(), middle_nested_popup)
movements.DetourWinCmdJ()
assert.same(vim.api.nvim_get_current_win(), bottom_base)
movements.DetourWinCmdK()
assert.same(vim.api.nvim_get_current_win(), middle_nested_popup)
-- Create popups on top and bottom
vim.fn.win_gotoid(top_base)
local top_popup = assert(detour.DetourCurrentWindow())
vim.fn.win_gotoid(bottom_base)
local bottom_popup = assert(detour.DetourCurrentWindow())
vim.fn.win_gotoid(middle_nested_popup)
-- Enter and leave a nested popup from a non-nested popup in both directions
movements.DetourWinCmdK()
assert.same(vim.api.nvim_get_current_win(), top_popup)
movements.DetourWinCmdJ()
assert.same(vim.api.nvim_get_current_win(), middle_nested_popup)
movements.DetourWinCmdJ()
assert.same(vim.api.nvim_get_current_win(), bottom_popup)
movements.DetourWinCmdK()
assert.same(vim.api.nvim_get_current_win(), middle_nested_popup)
end)
it("Switch windows with w", function()
local base = vim.api.nvim_get_current_win()
local popup = detour.Detour()
vim.cmd.split()
local bottom_base = vim.api.nvim_get_current_win()
assert.is_not.same(bottom_base, popup)
movements.DetourWinCmdW()
assert.same(popup, vim.api.nvim_get_current_win())
movements.DetourWinCmdW()
assert.same(bottom_base, vim.api.nvim_get_current_win())
end)
it("Move cursor on SafeState", function()
local base = vim.api.nvim_get_current_win()
local popup = detour.Detour()
vim.opt.eventignore = "all" -- deactivate plugin
vim.api.nvim_set_current_win(base)
vim.opt.eventignore = "" -- reactivate plugin
movements._safe_state_handler()
assert.same(popup, vim.api.nvim_get_current_win())
end)
end)
================================================
FILE: spec/detour_spec.lua
================================================
local detour = require("detour")
local util = require("detour.util")
local features = require("detour.features")
function Set(list)
local set = {}
for _, l in ipairs(list) do
set[l] = true
end
return set
end
describe("detour", function()
before_each(function()
vim.g.detour_testing = true
vim.cmd([[
%bwipeout!
mapclear
nmapclear
vmapclear
xmapclear
smapclear
omapclear
mapclear
imapclear
lmapclear
cmapclear
tmapclear
]])
vim.api.nvim_clear_autocmds({}) -- delete any autocmds not in a group
for _, autocmd in ipairs(vim.api.nvim_get_autocmds({})) do
if vim.startswith(autocmd.group_name, "detour-") then
vim.api.nvim_del_autocmd(autocmd.id)
end
end
vim.o.splitbelow = true
vim.o.splitright = true
end)
-- See Issue #25 for a discussion of duplication in this test suite.
it("create popup", function()
vim.cmd.view("/tmp/detour")
local before_buffer = vim.api.nvim_get_current_buf()
local before_window = vim.api.nvim_get_current_win()
local after_window = assert(detour.Detour())
local after_buffer = vim.api.nvim_get_current_buf()
assert.are_not.same(before_window, after_window)
assert.are.same(before_buffer, after_buffer)
assert.True(util.is_floating(after_window))
assert.same(#vim.api.nvim_list_wins(), 2)
end)
it("create nested popup over non-Detour popup", function()
local base_buffer = vim.api.nvim_get_current_buf()
vim.api.nvim_open_win(
vim.api.nvim_win_get_buf(0),
true,
{ relative = "win", width = 12, height = 3, bufpos = { 100, 10 } }
)
-- See the note above regarding the duplication of this test code.
local parent_popup = vim.api.nvim_get_current_win()
local parent_buffer = vim.api.nvim_get_current_buf()
assert.True(util.is_floating(parent_popup))
local parent_config = vim.api.nvim_win_get_config(parent_popup)
local child_popup = assert(detour.Detour())
assert.True(util.is_floating(child_popup))
local child_config = vim.api.nvim_win_get_config(child_popup)
local child_buffer = vim.api.nvim_get_current_buf()
-- The nested popup should always be on top of the parent popup
assert.True(child_config.zindex > parent_config.zindex)
assert.same(#vim.api.nvim_list_wins(), 3)
assert.same(base_buffer, parent_buffer)
assert.same(parent_buffer, child_buffer)
vim.cmd.quit()
assert.same(#vim.api.nvim_list_wins(), 2)
vim.cmd.quit()
assert.same(#vim.api.nvim_list_wins(), 1)
end)
-- TODO: Make sure popups are fully contained within their parents
it("create nested popup", function()
local base_buffer = vim.api.nvim_get_current_buf()
local parent_popup = assert(detour.Detour())
local parent_buffer = vim.api.nvim_get_current_buf()
assert.True(util.is_floating(parent_popup))
local parent_config = vim.api.nvim_win_get_config(parent_popup)
local child_popup = assert(detour.Detour())
assert.True(util.is_floating(child_popup))
local child_config = vim.api.nvim_win_get_config(child_popup)
local child_buffer = vim.api.nvim_get_current_buf()
-- The nested popup should always be on top of the parent popup
assert.True(child_config.zindex > parent_config.zindex)
assert.same(#vim.api.nvim_list_wins(), 3)
assert.same(base_buffer, parent_buffer)
assert.same(parent_buffer, child_buffer)
vim.cmd.quit()
assert.same(#vim.api.nvim_list_wins(), 2)
vim.cmd.quit()
assert.same(#vim.api.nvim_list_wins(), 1)
end)
it("react to a coverable window closing", function()
vim.cmd.wincmd("v")
local coverable_window = vim.api.nvim_get_current_win()
local popup = assert(detour.Detour())
assert.True(util.overlap(popup, coverable_window))
vim.fn.win_gotoid(coverable_window)
vim.cmd.wincmd("s")
local uncoverable_win = vim.api.nvim_get_current_win()
vim.api.nvim_exec_autocmds(
"WinResized",
{ data = { windows = { coverable_window } } }
)
assert.False(util.overlap(popup, uncoverable_win))
vim.api.nvim_win_close(coverable_window, true)
assert.False(util.overlap(popup, uncoverable_win))
vim.api.nvim_win_close(uncoverable_win, true)
end)
it("create popup over current window", function()
local window_a = vim.api.nvim_get_current_win()
vim.cmd.wincmd("s")
local window_b = vim.api.nvim_get_current_win()
local popup_b = assert(detour.DetourCurrentWindow())
assert.False(util.overlap(window_a, popup_b))
assert.True(util.overlap(window_b, popup_b))
vim.fn.win_gotoid(window_a)
local popup_a = assert(detour.Detour())
assert.True(util.overlap(window_a, popup_a))
assert.False(util.overlap(window_b, popup_a))
end)
it("Do not allow two popups over the same window", function()
local win = vim.api.nvim_get_current_win()
local popup = assert(detour.Detour())
vim.fn.win_gotoid(win)
assert.Nil(detour.Detour())
assert.same(Set({ win, popup }), Set(vim.api.nvim_tabpage_list_wins(0)))
vim.fn.win_gotoid(win)
assert.Nil(detour.DetourCurrentWindow())
assert.same(Set({ win, popup }), Set(vim.api.nvim_tabpage_list_wins(0)))
end)
it(
"Do not allow two 'current window' popups over the same window",
function()
local win = vim.api.nvim_get_current_win()
local popup = assert(detour.DetourCurrentWindow())
vim.fn.win_gotoid(win)
assert.Nil(detour.DetourCurrentWindow())
assert.same(
Set({ win, popup }),
Set(vim.api.nvim_tabpage_list_wins(0))
)
vim.fn.win_gotoid(win)
assert.Nil(detour.Detour())
assert.same(
Set({ win, popup }),
Set(vim.api.nvim_tabpage_list_wins(0))
)
end
)
it("Switch focus to a popup's floating parent when it's closed", function()
vim.api.nvim_open_win(
vim.api.nvim_win_get_buf(0),
true,
{ relative = "win", width = 12, height = 3, bufpos = { 100, 10 } }
)
local wins = { vim.api.nvim_get_current_win() }
for _ = 1, 10 do
table.insert(wins, detour.Detour())
for j, win in ipairs(wins) do
assert(win)
assert.same(
vim.api.nvim_win_get_config(win).focusable,
j == #wins
)
end
end
for _ = 1, 10 do
table.remove(wins, #wins)
vim.cmd.close()
assert.same(vim.api.nvim_get_current_win(), wins[#wins])
for j, win in ipairs(wins) do
assert.same(
vim.api.nvim_win_get_config(win).focusable,
j == #wins
)
end
end
end)
it("Switch focus to a popup's parent when it's closed", function()
local wins = { vim.api.nvim_get_current_win() }
for _ = 1, 10 do
table.insert(wins, detour.Detour())
for j, win in ipairs(wins) do
assert(win)
if j > 1 then -- the base window cannot be unfocusable
assert.same(
vim.api.nvim_win_get_config(win).focusable,
j == #wins
)
end
end
end
for _ = 1, 10 do
table.remove(wins, #wins)
vim.cmd.close()
assert.same(vim.api.nvim_get_current_win(), wins[#wins])
for j, win in ipairs(wins) do
if j > 1 then -- the base window cannot be unfocusable
assert.same(
vim.api.nvim_win_get_config(win).focusable,
j == #wins
)
end
end
end
end)
it(
"Handle cases when popups close without throwing a WinClosed event",
function()
local popup = assert(detour.DetourCurrentWindow())
vim.api.nvim_create_autocmd({ "WinLeave" }, {
callback = function()
vim.api.nvim_win_close(0, true)
return true
end,
})
vim.cmd.wincmd("h") -- Close popup without WinClosed event
assert.False(util.is_open(popup))
assert(detour.DetourCurrentWindow())
end
)
it("Test CloseOnLeave", function()
local popup_id = assert(detour.Detour())
features.CloseOnLeave(popup_id)
assert.True(util.is_open(popup_id))
vim.cmd.wincmd("w")
assert.False(util.is_open(popup_id))
popup_id = assert(detour.Detour())
features.CloseOnLeave(popup_id)
assert.True(util.is_open(popup_id))
vim.cmd.split()
assert.False(util.is_open(popup_id))
end)
it("Test ShowPathInTitle", function()
vim.cmd.file("/tmp/detour_test_a")
local popup_id = assert(detour.Detour())
assert.same(
"/tmp/detour_test_a",
vim.api.nvim_win_get_config(0).title[1][1]
)
vim.cmd.file("/tmp/detour_test_b")
-- Simulate the event triggered by the `on_win` decorator
vim.cmd.doautocmd("User DetourUpdateTitle" .. util.stringify(popup_id))
assert.same(
"/tmp/detour_test_b",
vim.api.nvim_win_get_config(0).title[1][1]
)
end)
end)
================================================
FILE: spec/detour_uncover_spec.lua
================================================
local detour = require("detour")
local util = require("detour.util")
local features = require("detour.features")
local function Set(list)
local set = {}
for _, l in ipairs(list) do
set[l] = true
end
return set
end
describe("detour uncover feature", function()
before_each(function()
vim.g.detour_testing = true
vim.cmd([[
%bwipeout!
mapclear
nmapclear
vmapclear
xmapclear
smapclear
omapclear
mapclear
imapclear
lmapclear
cmapclear
tmapclear
]])
vim.api.nvim_clear_autocmds({}) -- delete any autocmds not in a group
for _, autocmd in ipairs(vim.api.nvim_get_autocmds({ pattern = "*" })) do
if vim.startswith(autocmd.group_name, "detour-") then
vim.api.nvim_del_autocmd(autocmd.id)
end
end
vim.o.splitbelow = true
vim.o.splitright = true
end)
it("uncover one base window and resize popup accordingly", function()
-- Create a simple 2-column layout
local left_base = vim.api.nvim_get_current_win()
vim.cmd.vsplit()
local right_base = vim.api.nvim_get_current_win()
-- Create a detour covering both base windows
local popup = assert(detour.Detour())
-- Sanity: popup overlaps both bases initially
assert.True(util.overlap(popup, left_base))
assert.True(util.overlap(popup, right_base))
-- Uncover the left base window
assert.True(features.UncoverWindow(left_base))
-- The popup should no longer overlap the left base
assert.False(util.overlap(popup, left_base))
-- The popup should still overlap the right base
assert.True(util.overlap(popup, right_base))
-- Covered windows should only include the right base now
local covered = Set(util.find_covered_windows(popup))
assert.True(covered[right_base])
assert.is_nil(covered[left_base])
end)
it("prevent uncovering the last remaining base window", function()
-- Create a simple 2-column layout
local left_base = vim.api.nvim_get_current_win()
vim.cmd.vsplit()
local right_base = vim.api.nvim_get_current_win()
-- Create a detour covering both base windows
local popup = assert(detour.Detour())
-- Uncover one window first
assert.True(features.UncoverWindow(left_base))
-- Attempt to uncover the last remaining window should fail
assert.False(features.UncoverWindow(right_base))
-- State should remain unchanged: popup still overlaps right_base
assert.True(util.overlap(popup, right_base))
assert.False(util.overlap(popup, left_base))
end)
end)
================================================
FILE: spec/internal_spec.lua
================================================
local detour = require("detour")
local internal = require("detour.internal")
local util = require("detour.util")
describe("detour internal", function()
before_each(function()
vim.g.detour_testing = true
vim.cmd([[
%bwipeout!
mapclear
nmapclear
vmapclear
xmapclear
smapclear
omapclear
mapclear
imapclear
lmapclear
cmapclear
tmapclear
]])
vim.api.nvim_clear_autocmds({}) -- delete any autocmds not in a group
for _, autocmd in ipairs(vim.api.nvim_get_autocmds({})) do
if vim.startswith(autocmd.group_name, "detour-") then
vim.api.nvim_del_autocmd(autocmd.id)
end
end
vim.o.splitbelow = true
vim.o.splitright = true
end)
it("teardown is idempotent and clears reservations", function()
local popup = assert(detour.Detour())
internal.teardown_detour(popup)
internal.teardown_detour(popup)
assert.is_nil(internal.get_reserved_windows(popup))
end)
it("garbage_collect removes closed popups", function()
local popup = assert(detour.Detour())
vim.api.nvim_win_close(popup, true)
internal.garbage_collect()
assert.False(vim.tbl_contains(internal.list_popups(), popup))
assert.is_nil(internal.get_reserved_windows(popup))
end)
it("get_reserved_windows filters out closed windows", function()
vim.cmd.vsplit()
local right = vim.api.nvim_get_current_win()
local popup = assert(detour.Detour())
assert.True(util.overlap(popup, right))
-- Close one base and ensure it is removed from reservations
vim.fn.win_gotoid(right)
vim.api.nvim_win_close(right, true)
local reserved = internal.get_reserved_windows(popup)
assert.truthy(reserved)
assert.False(vim.tbl_contains(reserved, right))
end)
end)
================================================
FILE: spec/util_spec.lua
================================================
local util = require("detour.util")
describe("detour util", function()
before_each(function()
vim.g.detour_testing = true
vim.cmd([[
%bwipeout!
mapclear
nmapclear
vmapclear
xmapclear
smapclear
omapclear
mapclear
imapclear
lmapclear
cmapclear
tmapclear
]])
for _, autocmd in ipairs(vim.api.nvim_get_autocmds({})) do
if vim.startswith(autocmd.group_name, "detour-") then
vim.api.nvim_del_autocmd(autocmd.id)
end
end
vim.o.splitbelow = true
vim.o.splitright = true
end)
it("pairs_by_keys iterates keys in order", function()
local t = { c = 3, a = 1, b = 2 }
local keys = {}
for k in util.pairs_by_keys(t) do
table.insert(keys, k)
end
assert.same({ "a", "b", "c" }, keys)
end)
it("stringify maps digits to letters", function()
assert.same("bc", util.stringify(12))
assert.same("bcd", util.stringify(123))
end)
it("base_at_screenpos finds base window by coordinates", function()
local tab = vim.api.nvim_get_current_tabpage()
-- two columns layout
local left = vim.api.nvim_get_current_win()
vim.cmd.vsplit()
local right = vim.api.nvim_get_current_win()
local function inside(win)
local top, _, leftx, _ = util.get_text_area_dimensions(win)
return top + 1, leftx + 1
end
local r, c = inside(left)
assert.same(left, util.base_at_screenpos(tab, r, c))
r, c = inside(right)
assert.same(right, util.base_at_screenpos(tab, r, c))
end)
end)
================================================
FILE: spec/windowing_algorithm_spec.lua
================================================
local algo = require("detour.windowing_algorithm")
local util = require("detour.util")
describe("detour windowing_algorithm", function()
before_each(function()
vim.g.detour_testing = true
vim.cmd([[
%bwipeout!
mapclear
nmapclear
vmapclear
xmapclear
smapclear
omapclear
mapclear
imapclear
lmapclear
cmapclear
tmapclear
]])
vim.api.nvim_clear_autocmds({})
vim.o.splitbelow = true
vim.o.splitright = true
end)
it("construct_nest returns inner rectangle inside parent float", function()
-- create a parent float with known geometry and no border
local parent = vim.api.nvim_open_win(vim.api.nvim_get_current_buf(), true, {
relative = "editor",
row = 5,
col = 10,
width = 30,
height = 10,
border = "none",
zindex = 1,
})
assert.truthy(parent)
local top, bottom, left, right = util.get_text_area_dimensions(parent)
local expected_width = (right - left)
local expected_height = (bottom - top)
if expected_height >= 3 then
expected_height = expected_height - 2
top = top + 1
end
if expected_width >= 3 then
expected_width = expected_width - 2
left = left + 1
end
local opts = algo.construct_nest(parent, 2)
assert.same({
relative = "editor",
row = top,
col = left,
width = expected_width,
height = expected_height,
border = "rounded",
zindex = 2,
}, opts)
end)
end)