Repository: nvim-lua/plenary.nvim
Pull request #209 (2021-08-10 14:39:16)

feat: Move all of popup into plenary
The plan is that we will deprecate (and then archive) nvim-lua/popup.nvim

There has been some confusion and overlap between the two plugins and this will make things simpler to manage and to keep them in sync. The end goal is still to upstream the work done in (now) `plenary.popup`.

@l-kershaw here is PR :)


Patch:
diff --git a/POPUP.md b/POPUP.md
new file mode 100644
index 0000000..771d581
--- /dev/null
+++ b/POPUP.md
@@ -0,0 +1,86 @@
+# Popup tracking
+
+[WIP] An implementation of the Popup API from vim in Neovim. Hope to upstream
+when complete
+
+## Goals
+
+Provide an API that is compatible with the vim `popup_*` APIs. After
+stablization and any required features are merged into Neovim, we can upstream
+this and expose the API in vimL to create better compatibility.
+
+## List of Neovim Features Required:
+
+- [ ] Add Z-index for floating windows
+    - [ ] When complete, we can add `zindex` parameter
+- [ ] Key handlers (used for `popup_filter`)
+- [ ] scrollbar for floating windows
+    - [ ] scrollbar
+    - [ ] scrollbarhighlight
+    - [ ] thumbhighlight
+
+Optional:
+
+- [ ] Add forced transparency to a floating window.
+    - Apparently overrides text?
+    - This is for the `mask` feature flag
+
+
+Unlikely (due to technical difficulties):
+
+- [ ] Add `textprop` wrappers?
+    - textprop
+    - textpropwin
+    - textpropid
+- [ ] "close"
+    - But this is mostly because I don't know how to use mouse APIs in nvim. If someone knows. please make an issue in the repo, and maybe we can get it sorted out.
+
+Unlikely (due to not sure if people are using):
+- [ ] tabpage
+
+## Progress
+
+Suported Features:
+
+- [x] what
+    - string
+    - list of strings
+- [x] popup_create-arguments
+    - [x] border
+    - [x] borderchars
+    - [x] col
+    - [x] cursorline
+    - [x] highlight
+    - [x] line
+    - [x] {max,min}{height,width}
+    - [?] moved
+        - [x] "any"
+        - [ ] "word"
+        - [ ] "WORD"
+        - [ ] "expr"
+        - [ ] (list options)
+    - [x] padding
+    - [?] pos
+        - Somewhat implemented. Doesn't work with borders though.
+    - [x] posinvert
+    - [x] time
+    - [x] title
+    - [x] wrap
+
+## All known unimplemented vim features at the moment
+
+- firstline
+- hidden
+- ~ pos
+- fixed
+- filter
+- filtermode
+- mapping
+- callback
+- mouse:
+    - mousemoved
+    - close
+    - drag
+    - resize
+
+- (not implemented in vim yet) flip
diff --git a/README.md b/README.md
index dfa2e23..47c9f36 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,8 @@ Plug 'nvim-lua/plenary.nvim'
 
 ## Modules
 
+- `plenary.async`
+- `plenary.async_lib`
 - `plenary.job`
 - `plenary.path`
 - `plenary.scandir`
@@ -26,6 +28,75 @@ Plug 'nvim-lua/plenary.nvim'
 - `plenary.filetype`
 - `plenary.strings`
 
+### plenary.async
+
+A Lua module for asynchronous programming using coroutines. This library is built on native lua coroutines and `libuv`. Coroutines make it easy to avoid callback hell and allow for easy cooperative concurrency and cancellation. Apart from allowing users to perform asynchronous io easily, this library also functions as an abstraction for coroutines.
+
+#### Getting started
+
+You can do
+```lua
+local async = require "plenary.async"
+```
+All other modules are automatically required and can bet accessed by indexing `async`.
+You needn't worry about performance as this will require all the submodules lazily.
+
+#### A quick example
+
+Libuv luv provides this example of reading a file.
+
+```lua
+local uv = vim.loop
+
+local read_file = function(path, callback)
+  uv.fs_open(path, "r", 438, function(err, fd)
+    assert(not err, err)
+    uv.fs_fstat(fd, function(err, stat)
+      assert(not err, err)
+      uv.fs_read(fd, stat.size, 0, function(err, data)
+        assert(not err, err)
+        uv.fs_close(fd, function(err)
+          assert(not err, err)
+          callback(data)
+        end)
+      end)
+    end)
+  end)
+end
+```
+
+We can write it using the library like this:
+```lua
+local a = require "plenary.async"
+
+local read_file = function(path)
+  local err, fd = a.uv.fs_open(path, "r", 438)
+  assert(not err, err)
+
+  local err, stat = a.uv.fs_fstat(fd)
+  assert(not err, err)
+
+  local err, data = a.uv.fs_read(fd, stat.size, 0)
+  assert(not err, err)
+
+  local err = a.uv.fs_close(fd)
+  assert(not err, err)
+
+  return data
+end
+```
+
+#### Plugins using this
+
+- [telescope.nvim](https://github.com/nvim-telescope/telescope.nvim)
+- [gitsigns.nvim](https://github.com/lewis6991/gitsigns.nvim)
+- [vgit.nvim](https://github.com/tanvirtin/vgit.nvim)
+- [neogit](https://github.com/TimUntersberger/neogit) (using version 1)
+
+### plenary.async_lib
+
+Please use `plenary.async` instead. This was version 1 and is just here for compatibility reasons.
+
 ### plenary.job
 
 A Lua module to interact with system processes. Pass in your `command`, the desired `args`, `env` and `cwd`.
@@ -213,10 +284,7 @@ Status: WIP
 
 ### plenary.popup
 
-`popup_*` clone of Vim's commands. If it gets good enough, will submit PR to Neovim and write C wrappers
-to provide compatibility layer for Neovim.
-
-Status: WIP
+See [popup documentation](./POPUP.md) for both progress tracking and implemented APIs.
 
 ### plenary.window
 
diff --git a/lua/plenary/popup.lua b/lua/plenary/popup.lua
deleted file mode 100644
index bb53c7d..0000000
--- a/lua/plenary/popup.lua
+++ /dev/null
@@ -1,209 +0,0 @@
---- popup.lua
----
---- Wrapper to make the popup api from vim in neovim.
---- Hope to get this part merged in at some point in the future.
-
-local vim = vim
-
-local Border = require "plenary.window.border"
-
-local popup = {}
-
-popup._pos_map = {
-  topleft = "NW",
-  topright = "NE",
-  botleft = "SW",
-  botright = "SE",
-}
-
--- Keep track of hidden popups, so we can load them with popup.show()
-popup._hidden = {}
-
-local function dict_default(options, key, default)
-  if options[key] == nil then
-    return default[key]
-  else
-    return options[key]
-  end
-end
-
-function popup.popup_create(what, vim_options)
-  local bufnr
-  if type(what) == "number" then
-    bufnr = what
-  else
-    bufnr = vim.api.nvim_create_buf(false, true)
-    assert(bufnr, "Failed to create buffer")
-
-    -- TODO: Handle list of lines
-    vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, { what })
-  end
-
-  local option_defaults = {
-    posinvert = true,
-  }
-
-  local win_opts = {}
-
-  if vim_options.line then
-    -- TODO: Need to handle "cursor", "cursor+1", ...
-    win_opts.row = vim_options.line
-  else
-    -- TODO: It says it needs to be "vertically cenetered"?...
-    -- wut is that.
-    win_opts.row = 0
-  end
-
-  if vim_options.col then
-    -- TODO: Need to handle "cursor", "cursor+1", ...
-    win_opts.col = vim_options.col
-  else
-    -- TODO: It says it needs to be "horizontally cenetered"?...
-    win_opts.col = 0
-  end
-
-  if vim_options.pos then
-    if vim_options.pos == "center" then
-      -- TODO: Do centering..
-    else
-      win_opts.anchor = popup._pos_map[vim_options.pos]
-    end
-  end
-
-  -- posinvert	When FALSE the value of "pos" is always used.  When
-  -- 		TRUE (the default) and the popup does not fit
-  -- 		vertically and there is more space on the other side
-  -- 		then the popup is placed on the other side of the
-  -- 		position indicated by "line".
-  if dict_default(vim_options, "posinvert", option_defaults) then
-    -- TODO: handle the invert thing
-  end
-
-  -- 	fixed		When FALSE (the default), and:
-  -- 			 - "pos" is "botleft" or "topleft", and
-  -- 			 - "wrap" is off, and
-  -- 			 - the popup would be truncated at the right edge of
-  -- 			   the screen, then
-  -- 			the popup is moved to the left so as to fit the
-  -- 			contents on the screen.  Set to TRUE to disable this.
-
-  win_opts.style = "minimal"
-
-  -- Feels like maxheigh, minheight, maxwidth, minwidth will all be related
-  win_opts.height = 5
-  win_opts.width = 25
-
-  -- textprop	When present the popup is positioned next to a text
-  -- 		property with this name and will move when the text
-  -- 		property moves.  Use an empty string to remove.  See
-  -- 		|popup-textprop-pos|.
-  -- related:
-  --   textpropwin
-  --   textpropid
-
-  -- border
-  local border_options = {}
-  if vim_options.border then
-    local b_top, b_rgight, b_bot, b_left, b_topleft, b_topright, b_botright, b_botleft
-    if vim_options.borderchars == nil then
-      b_top, b_rgight, b_bot, b_left, b_topleft, b_topright, b_botright, b_botleft = {
-        "-",
-        "|",
-        "-",
-        "|",
-        "┌",
-        "┐",
-        "┘",
-        "└",
-      }
-    elseif #vim_options.borderchars == 1 then
-      -- TODO: Unpack 8 times cool to the same vars
-      print "..."
-    elseif #vim_options.borderchars == 2 then
-      -- TODO: Unpack to edges & corners
-      print "..."
-    elseif #vim_options.borderchars == 8 then
-      b_top, b_rgight, b_bot, b_left, b_topleft, b_topright, b_botright, b_botleft = vim_options.borderhighlight
-    end
-  end
-
-  win_opts.relative = "editor"
-
-  local win_id
-  if vim_options.hidden then
-    assert(false, "I have not implemented this yet and don't know how")
-  else
-    win_id = vim.api.nvim_open_win(bufnr, true, win_opts)
-  end
-
-  -- Moved, handled after since we need the window ID
-  if vim_options.moved then
-    if vim_options.moved == "any" then
-      vim.lsp.util.close_preview_autocmd({ "CursorMoved", "CursorMovedI" }, win_id)
-    elseif vim_options.moved == "word" then
-      -- TODO: Handle word, WORD, expr, and the range functions... which seem hard?
-    end
-  else
-    vim.cmd(string.format("autocmd BufLeave <buffer=%s> ++once call nvim_win_close(%s, v:false)", bufnr, win_id))
-  end
-
-  if vim_options.time then
-    local timer = vim.loop.new_timer()
-    timer:start(
-      vim_options.time,
-      0,
-      vim.schedule_wrap(function()
-        vim.api.nvim_close_win(win_id, false)
-      end)
-    )
-  end
-
-  -- Buffer Options
-  if vim_options.cursorline then
-    vim.api.nvim_win_set_option(0, "cursorline", true)
-  end
-
-  -- vim.api.nvim_win_set_option(0, 'wrap', dict_default(vim_options, 'wrap', option_defaults))
-
-  -- ===== Not Implemented Options =====
-  -- flip: not implemented at the time of writing
-  -- Mouse:
-  --    mousemoved: no idea how to do the things with the mouse, so it's an exercise for the reader.
-  --    drag: mouses are hard
-  --    resize: mouses are hard
-  --    close: mouses are hard
-  --
-  -- scrollbar
-  -- scrollbarhighlight
-  -- thumbhighlight
-  --
-  -- tabpage: seems useless
-
-  -- Create border
-
-  -- title
-  if vim_options.title then
-    border_options.title = vim_options.title
-
-    if vim_options.border == 0 or vim_options.border == nil then
-      vim_options.border = 1
-      border_options.width = 1
-    end
-  end
-
-  if vim_options.border then
-    Border:new(bufnr, win_id, win_opts, border_options)
-  end
-
-  -- TODO: Perhaps there's a way to return an object that looks like a window id,
-  --    but actually has some extra metadata about it.
-  --
-  --    This would make `hidden` a lot easier to manage
-  return win_id
-end
-
-function popup.show(self, asdf) end
-
-popup.show = function() end
-
-return popup
diff --git a/lua/plenary/popup/init.lua b/lua/plenary/popup/init.lua
new file mode 100644
index 0000000..c94ceea
--- /dev/null
+++ b/lua/plenary/popup/init.lua
@@ -0,0 +1,400 @@
+--- popup.lua
+---
+--- Wrapper to make the popup api from vim in neovim.
+--- Hope to get this part merged in at some point in the future.
+---
+--- Please make sure to update "POPUP.md" with any changes and/or notes.
+
+local Border = require "plenary.window.border"
+local Window = require "plenary.window"
+local utils = require "plenary.popup.utils"
+
+local popup = {}
+
+popup._pos_map = {
+  topleft = "NW",
+  topright = "NE",
+  botleft = "SW",
+  botright = "SE",
+}
+
+-- Keep track of hidden popups, so we can load them with popup.show()
+popup._hidden = {}
+
+local function dict_default(options, key, default)
+  if options[key] == nil then
+    return default[key]
+  else
+    return options[key]
+  end
+end
+
+-- Callbacks to be called later by popup.execute_callback
+popup._callbacks = {}
+
+function popup.create(what, vim_options)
+  local bufnr
+  if type(what) == "number" then
+    bufnr = what
+  else
+    bufnr = vim.api.nvim_create_buf(false, true)
+    assert(bufnr, "Failed to create buffer")
+
+    vim.api.nvim_buf_set_option(bufnr, "bufhidden", "wipe")
+
+    -- TODO: Handle list of lines
+    if type(what) == "string" then
+      what = { what }
+    else
+      assert(type(what) == "table", '"what" must be a table')
+    end
+
+    -- padding    List with numbers, defining the padding
+    --     above/right/below/left of the popup (similar to CSS).
+    --     An empty list uses a padding of 1 all around.  The
+    --     padding goes around the text, inside any border.
+    --     Padding uses the 'wincolor' highlight.
+    --     Example: [1, 2, 1, 3] has 1 line of padding above, 2
+    --     columns on the right, 1 line below and 3 columns on
+    --     the left.
+    if vim_options.padding then
+      local pad_top, pad_right, pad_below, pad_left
+      if vim.tbl_isempty(vim_options.padding) then
+        pad_top = 1
+        pad_right = 1
+        pad_below = 1
+        pad_left = 1
+      else
+        local padding = vim_options.padding
+        pad_top = padding[1] or 0
+        pad_right = padding[2] or 0
+        pad_below = padding[3] or 0
+        pad_left = padding[4] or 0
+      end
+
+      local left_padding = string.rep(" ", pad_left)
+      local right_padding = string.rep(" ", pad_right)
+      for index = 1, #what do
+        what[index] = string.format("%s%s%s", left_padding, what[index], right_padding)
+      end
+
+      for _ = 1, pad_top do
+        table.insert(what, 1, "")
+      end
+
+      for _ = 1, pad_below do
+        table.insert(what, "")
+      end
+    end
+
+    vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, what)
+  end
+
+  local option_defaults = {
+    posinvert = true,
+  }
+
+  local win_opts = {}
+  win_opts.relative = "editor"
+
+  local cursor_relative_pos = function(pos_str, dim)
+    assert(string.find(pos_str, "^cursor"), "Invalid value for " .. dim)
+    win_opts.relative = "cursor"
+    local line = 0
+    if (pos_str):match "cursor%+(%d+)" then
+      line = line + tonumber((pos_str):match "cursor%+(%d+)")
+    elseif (pos_str):match "cursor%-(%d+)" then
+      line = line - tonumber((pos_str):match "cursor%-(%d+)")
+    end
+    return line
+  end
+
+  if vim_options.line then
+    if type(vim_options.line) == "string" then
+      win_opts.row = cursor_relative_pos(vim_options.line, "row")
+    else
+      win_opts.row = vim_options.line
+    end
+  else
+    -- TODO: It says it needs to be "vertically cenetered"?...
+    -- wut is that.
+    win_opts.row = 0
+  end
+
+  if vim_options.col then
+    if type(vim_options.col) == "string" then
+      win_opts.col = cursor_relative_pos(vim_options.col, "col")
+    else
+      win_opts.col = vim_options.col
+    end
+  else
+    -- TODO: It says it needs to be "horizontally cenetered"?...
+    win_opts.col = 0
+  end
+
+  if vim_options.pos then
+    if vim_options.pos == "center" then
+      -- TODO: Do centering..
+    else
+      win_opts.anchor = popup._pos_map[vim_options.pos]
+    end
+  else
+    win_opts.anchor = "NW" -- This is the default, but makes `posinvert` easier to implement
+  end
+
+  -- , fixed    When FALSE (the default), and:
+  -- ,      - "pos" is "botleft" or "topleft", and
+  -- ,      - "wrap" is off, and
+  -- ,      - the popup would be truncated at the right edge of
+  -- ,        the screen, then
+  -- ,     the popup is moved to the left so as to fit the
+  -- ,     contents on the screen.  Set to TRUE to disable this.
+
+  win_opts.style = "minimal"
+
+  -- Feels like maxheight, minheight, maxwidth, minwidth will all be related
+  --
+  -- maxheight  Maximum height of the contents, excluding border and padding.
+  -- minheight  Minimum height of the contents, excluding border and padding.
+  -- maxwidth  Maximum width of the contents, excluding border, padding and scrollbar.
+  -- minwidth  Minimum width of the contents, excluding border, padding and scrollbar.
+  local width = vim_options.width or 1
+  local height
+  if type(what) == "number" then
+    height = vim.api.nvim_buf_line_count(what)
+  else
+    for _, v in ipairs(what) do
+      width = math.max(width, #v)
+    end
+    height = #what
+  end
+  win_opts.width = utils.bounded(width, vim_options.minwidth, vim_options.maxwidth)
+  win_opts.height = utils.bounded(height, vim_options.minheight, vim_options.maxheight)
+
+  -- posinvert, When FALSE the value of "pos" is always used.  When
+  -- ,   TRUE (the default) and the popup does not fit
+  -- ,   vertically and there is more space on the other side
+  -- ,   then the popup is placed on the other side of the
+  -- ,   position indicated by "line".
+  if dict_default(vim_options, "posinvert", option_defaults) then
+    if win_opts.anchor == "NW" or win_opts.anchor == "NE" then
+      if win_opts.row + win_opts.height > vim.o.lines and win_opts.row * 2 > vim.o.lines then
+        -- Don't know why, but this is how vim adjusts it
+        win_opts.row = win_opts.row - win_opts.height - 2
+      end
+    elseif win_opts.anchor == "SW" or win_opts.anchor == "SE" then
+      if win_opts.row - win_opts.height < 0 and win_opts.row * 2 < vim.o.lines then
+        -- Don't know why, but this is how vim adjusts it
+        win_opts.row = win_opts.row + win_opts.height + 2
+      end
+    end
+  end
+
+  -- textprop, When present the popup is positioned next to a text
+  -- ,   property with this name and will move when the text
+  -- ,   property moves.  Use an empty string to remove.  See
+  -- ,   |popup-textprop-pos|.
+  -- related:
+  --   textpropwin
+  --   textpropid
+
+  local win_id
+  if vim_options.hidden then
+    assert(false, "I have not implemented this yet and don't know how")
+  else
+    win_id = vim.api.nvim_open_win(bufnr, false, win_opts)
+  end
+
+  -- Moved, handled after since we need the window ID
+  if vim_options.moved then
+    if vim_options.moved == "any" then
+      vim.lsp.util.close_preview_autocmd({ "CursorMoved", "CursorMovedI" }, win_id)
+    elseif vim_options.moved == "word" then
+      -- TODO: Handle word, WORD, expr, and the range functions... which seem hard?
+    end
+  else
+    local silent = false
+    vim.cmd(
+      string.format(
+        "autocmd BufDelete %s <buffer=%s> ++once ++nested :lua require('plenary.window').try_close(%s, true)",
+        (silent and "<silent>") or "",
+        bufnr,
+        win_id
+      )
+    )
+  end
+
+  if vim_options.time then
+    local timer = vim.loop.new_timer()
+    timer:start(
+      vim_options.time,
+      0,
+      vim.schedule_wrap(function()
+        Window.try_close(win_id, false)
+      end)
+    )
+  end
+
+  -- Buffer Options
+  if vim_options.cursorline then
+    vim.api.nvim_win_set_option(win_id, "cursorline", true)
+  end
+
+  if vim_options.wrap ~= nil then
+    vim.api.nvim_win_set_option(win_id, "wrap", vim_options.wrap)
+  end
+
+  -- ===== Not Implemented Options =====
+  -- flip: not implemented at the time of writing
+  -- Mouse:
+  --    mousemoved: no idea how to do the things with the mouse, so it's an exercise for the reader.
+  --    drag: mouses are hard
+  --    resize: mouses are hard
+  --    close: mouses are hard
+  --
+  -- scrollbar
+  -- scrollbarhighlight
+  -- thumbhighlight
+
+  -- tabpage: seems useless
+
+  -- Create border
+
+  local should_show_border = nil
+  local border_options = {}
+
+  -- border    List with numbers, defining the border thickness
+  --     above/right/below/left of the popup (similar to CSS).
+  --     Only values of zero and non-zero are recognized.
+  --     An empty list uses a border all around.
+  if vim_options.border then
+    should_show_border = true
+
+    if type(vim_options.border) == "boolean" or vim.tbl_isempty(vim_options.border) then
+      border_options.border_thickness = Border._default_thickness
+    elseif #vim_options.border == 4 then
+      border_options.border_thickness = {
+        top = utils.bounded(vim_options.border[1], 0, 1),
+        right = utils.bounded(vim_options.border[2], 0, 1),
+        bot = utils.bounded(vim_options.border[3], 0, 1),
+        left = utils.bounded(vim_options.border[4], 0, 1),
+      }
+    else
+      error(string.format("Invalid configuration for border: %s", vim.inspect(vim_options.border)))
+    end
+  elseif vim_options.border == false then
+    should_show_border = false
+  end
+
+  if (should_show_border == nil or should_show_border) and vim_options.borderchars then
+    should_show_border = true
+
+    -- borderchars  List with characters, defining the character to use
+    --     for the top/right/bottom/left border.  Optionally
+    --     followed by the character to use for the
+    --     topleft/topright/botright/botleft corner.
+    --     Example: ['-', '|', '-', '|', '┌', '┐', '┘', '└']
+    --     When the list has one character it is used for all.
+    --     When the list has two characters the first is used for
+    --     the border lines, the second for the corners.
+    --     By default a double line is used all around when
+    --     'encoding' is "utf-8" and 'ambiwidth' is "single",
+    --     otherwise ASCII characters are used.
+
+    local b_top, b_right, b_bot, b_left, b_topleft, b_topright, b_botright, b_botleft
+    if vim_options.borderchars == nil then
+      b_top, b_right, b_bot, b_left, b_topleft, b_topright, b_botright, b_botleft =
+        "═", "║", "═", "║", "╔", "╗", "╝", "╚"
+    elseif #vim_options.borderchars == 1 then
+      local b_char = vim_options.borderchars[1]
+      b_top, b_right, b_bot, b_left, b_topleft, b_topright, b_botright, b_botleft =
+        b_char, b_char, b_char, b_char, b_char, b_char, b_char, b_char
+    elseif #vim_options.borderchars == 2 then
+      local b_char = vim_options.borderchars[1]
+      local c_char = vim_options.borderchars[2]
+      b_top, b_right, b_bot, b_left, b_topleft, b_topright, b_botright, b_botleft =
+        b_char, b_char, b_char, b_char, c_char, c_char, c_char, c_char
+    elseif #vim_options.borderchars == 8 then
+      b_top, b_right, b_bot, b_left, b_topleft, b_topright, b_botright, b_botleft = unpack(vim_options.borderchars)
+    else
+      error(string.format 'Not enough arguments for "borderchars"')
+    end
+
+    border_options.top = b_top
+    border_options.bot = b_bot
+    border_options.right = b_right
+    border_options.left = b_left
+    border_options.topleft = b_topleft
+    border_options.topright = b_topright
+    border_options.botright = b_botright
+    border_options.botleft = b_botleft
+  end
+
+  -- title
+  if vim_options.title then
+    -- TODO: Check out how title works with weird border combos.
+    border_options.title = vim_options.title
+  end
+
+  local border = nil
+  if should_show_border then
+    border = Border:new(bufnr, win_id, win_opts, border_options)
+  end
+
+  if vim_options.highlight then
+    vim.api.nvim_win_set_option(win_id, "winhl", string.format("Normal:%s", vim_options.highlight))
+  end
+
+  if vim_options.borderhighlight then
+    vim.api.nvim_win_set_option(border.win_id, "winhl", string.format("Normal:%s", vim_options.borderhighlight))
+  end
+
+  -- enter
+  local should_enter = vim_options.enter
+  if should_enter == nil then
+    should_enter = true
+  end
+
+  if should_enter then
+    -- set focus after border creation so that it's properly placed (especially
+    -- in relative cursor layout)
+    vim.api.nvim_set_current_win(win_id)
+  end
+
+  -- callback
+  if vim_options.callback then
+    popup._callbacks[bufnr] = function()
+      -- (jbyuki): Giving win_id is pointless here because it's closed right afterwards
+      -- but it might make more sense once hidden is implemented
+      local row, _ = unpack(vim.api.nvim_win_get_cursor(win_id))
+      vim_options.callback(win_id, what[row])
+      vim.api.nvim_win_close(win_id, true)
+    end
+    vim.api.nvim_buf_set_keymap(
+      bufnr,
+      "n",
+      "<CR>",
+      '<cmd>lua require"popup".execute_callback(' .. bufnr .. ")<CR>",
+      { noremap = true }
+    )
+  end
+
+  -- TODO: Perhaps there's a way to return an object that looks like a window id,
+  --    but actually has some extra metadata about it.
+  --
+  --    This would make `hidden` a lot easier to manage
+  return win_id, {
+    win_id = win_id,
+    border = border,
+  }
+end
+
+function popup.execute_callback(bufnr)
+  if popup._callbacks[bufnr] then
+    local wrapper = popup._callbacks[bufnr]
+    wrapper()
+    popup._callbacks[bufnr] = nil
+  end
+end
+
+return popup
diff --git a/lua/plenary/popup/utils.lua b/lua/plenary/popup/utils.lua
new file mode 100644
index 0000000..e665e15
--- /dev/null
+++ b/lua/plenary/popup/utils.lua
@@ -0,0 +1,33 @@
+local utils = {}
+
+utils.bounded = function(value, min, max)
+  min = min or 0
+  max = max or math.huge
+
+  if min then
+    value = math.max(value, min)
+  end
+  if max then
+    value = math.min(value, max)
+  end
+
+  return value
+end
+
+utils.apply_defaults = function(original, defaults)
+  if original == nil then
+    original = {}
+  end
+
+  original = vim.deepcopy(original)
+
+  for k, v in pairs(defaults) do
+    if original[k] == nil then
+      original[k] = v
+    end
+  end
+
+  return original
+end
+
+return utils


Test patch:
diff --git a/tests/plenary/popup_requires_spec.lua b/tests/plenary/popup_requires_spec.lua
new file mode 100644
index 0000000..2909f65
--- /dev/null
+++ b/tests/plenary/popup_requires_spec.lua
@@ -0,0 +1,42 @@
+local scandir = require "plenary.scandir"
+local Path = require "plenary.path"
+
+local eq = assert.are.same
+
+describe("plenary.popup", function()
+  local allowed_imports = {
+    ["plenary.window"] = true,
+    ["plenary.window.border"] = true,
+  }
+
+  local matches_any_import = function(line)
+    local matched = string.match(line, [[require."(.*)"]])
+    if matched and not vim.startswith(matched, "plenary.popup") then
+      if not allowed_imports[matched] then
+        return true, string.format("Not an allowed import for popup: %s. Line: %s", matched, line)
+      end
+    end
+
+    return false, nil
+  end
+
+  -- Tests to make sure that we're matching both types of requires
+  it("should match these kinds of patterns", function()
+    eq(true, matches_any_import [[local x = require "plenary.other"]])
+    eq(true, matches_any_import [[local x = require("plenary.module").something]])
+  end)
+
+  it("must not require anything other than Window and Border from plenary", function()
+    local result = scandir.scan_dir("./lua/plenary/popup", { depth = 1 })
+
+    for _, file in ipairs(result) do
+      local popup_file = Path:new(file)
+      local lines = popup_file:readlines()
+
+      for _, line in ipairs(lines) do
+        local matches, msg = matches_any_import(line)
+        eq(false, matches, msg)
+      end
+    end
+  end)
+end)
diff --git a/tests/plenary/popup_spec.lua b/tests/plenary/popup_spec.lua
new file mode 100644
index 0000000..fecf14e
--- /dev/null
+++ b/tests/plenary/popup_spec.lua
@@ -0,0 +1,132 @@
+local popup = require "plenary.popup"
+
+local eq = assert.are.same
+
+describe("plenary.popup", function()
+  before_each(function()
+    vim.cmd [[highlight PopupColor1 ctermbg=lightblue guibg=lightblue]]
+    vim.cmd [[highlight PopupColor2 ctermbg=lightcyan guibg=lightcyan]]
+  end)
+
+  -- TODO: Probably want to clear all the popups between iterations
+  -- after_each(function() end)
+
+  it("can create a very simple window", function()
+    local win_id = popup.create("hello there", {
+      line = 1,
+      col = 1,
+      width = 20,
+    })
+
+    local win_config = vim.api.nvim_win_get_config(win_id)
+    eq(20, win_config.width)
+  end)
+
+  it("can apply a highlight", function()
+    local win_id = popup.create("hello there", {
+      highlight = "PopupColor1",
+    })
+
+    eq("Normal:PopupColor1", vim.api.nvim_win_get_option(win_id, "winhl"))
+  end)
+
+  it("can create a border", function()
+    local win_id, config = popup.create("hello border", {
+      line = 2,
+      col = 3,
+      border = {},
+    })
+
+    eq(true, vim.api.nvim_win_is_valid(win_id))
+
+    local border_id = config.border.win_id
+    assert(border_id, "Has a border win id")
+    eq(true, vim.api.nvim_win_is_valid(border_id))
+  end)
+
+  it("can do basic padding", function()
+    local win_id = popup.create("12345", {
+      line = 1,
+      col = 1,
+      padding = {},
+    })
+
+    local bufnr = vim.api.nvim_win_get_buf(win_id)
+    eq({ "", " 12345 ", "" }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
+  end)
+
+  it("can do padding and border", function()
+    local win_id, config = popup.create("hello border", {
+      line = 2,
+      col = 2,
+      border = {},
+      padding = {},
+    })
+
+    local bufnr = vim.api.nvim_win_get_buf(win_id)
+    eq({ "", " hello border ", "" }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
+
+    local border_id = config.border.win_id
+    assert(border_id, "Has a border win id")
+    eq(true, vim.api.nvim_win_is_valid(border_id))
+  end)
+
+  describe("borderchars", function()
+    local test_border = function(name, borderchars, expected)
+      it(name, function()
+        local win_id, config = popup.create("all the plus signs", {
+          line = 8,
+          col = 55,
+          padding = { 0, 3, 0, 3 },
+          borderchars = borderchars,
+        })
+
+        local border_id = config.border.win_id
+        local border_bufnr = vim.api.nvim_win_get_buf(border_id)
+        eq(expected, vim.api.nvim_buf_get_lines(border_bufnr, 0, -1, false))
+      end)
+    end
+
+    test_border("can support multiple border patterns", { "+" }, {
+      "++++++++++++++++++++++++++",
+      "+                        +",
+      "++++++++++++++++++++++++++",
+    })
+
+    test_border("can support multiple patterns inside the borderchars", { "-", "+" }, {
+      "+------------------------+",
+      "-                        -",
+      "+------------------------+",
+    })
+  end)
+
+  describe("what", function()
+    it("can be an existing bufnr", function()
+      local bufnr = vim.api.nvim_create_buf(false, false)
+      vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { "pass bufnr 1", "pass bufnr 2" })
+      local win_id = popup.create(bufnr, {
+        line = 8,
+        col = 55,
+        minwidth = 20,
+      })
+
+      eq(bufnr, vim.api.nvim_win_get_buf(win_id))
+      eq({ "pass bufnr 1", "pass bufnr 2" }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
+    end)
+  end)
+
+  describe("cursor", function()
+    pending("not yet tested", function()
+      popup.create({ "option 1", "option 2" }, {
+        line = "cursor+2",
+        col = "cursor+2",
+        border = { 1, 1, 1, 1 },
+        enter = true,
+        cursorline = true,
+        callback = function(win_id, sel)
+          print(sel)
+        end,
+      })
+    end)
+  end)
+end)