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 | ![detour](https://github.com/carbon-steel/detour.nvim/assets/7697639/1eb85155-7134-473f-8df0-dd15f55c1d8c) | | `:DetourCurrentWindow`/
`require('detour').DetourCurrentWindow()`
opens a floating window over
only the current window | ![detour2](https://github.com/carbon-steel/detour.nvim/assets/7697639/d3f0db15-916b-4b17-b227-0e4aa8fc318d) | | Works with Neovim's `:split`/`:vsplit`/`s`/`v`/`T` commands | ![split](https://github.com/carbon-steel/detour.nvim/assets/7697639/4ffa7f36-8b2a-4d91-a8bb-7012f7b82015) | | You can nest detour popups | ![nest](https://github.com/carbon-steel/detour.nvim/assets/7697639/5fc3cad6-9acf-482d-97cb-c75788617cf8) | 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** | ![top](https://github.com/carbon-steel/detour.nvim/assets/7697639/49dd12ab-630b-4558-9486-fe82cc94882c) # 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)