A floating vim.ui.input buffer that auto-resizes to fit its contents.
0

Configure Feed

Select the types of activity you want to include in your feed.

refactor: extract input prompt logic into dedicated modules

move input prompt creation and management to lua/lib/input.lua
define configuration types and defaults in lua/lib/config.lua
extract common utility functions to lua/lib/utils.lua
lua/wrapinput.lua now serves as the main entry point
improves code organization and maintainability

r0nsha (Jun 27, 2025, 2:09 PM +0300) 213c0c38 47eaccad

+244 -185
+34
lua/lib/config.lua
··· 1 + ---@class wrapinput.Config 2 + ---@field opts wrapinput.Opts 3 + ---@field width wrapinput.Limits 4 + ---@field height wrapinput.Limits 5 + ---@field padding integer How much padding will be added to the end of the input buffer 6 + ---@field win vim.api.keyset.win_config 7 + 8 + ---@class wrapinput.Opts 9 + ---@field numbers "always" | "multiline" | "never" When to show line numbers 10 + --- "always" will always show line numbers 11 + --- "multiline" will only show line numbers if the input's height is > 1 12 + --- "never" will never show line numbers 13 + 14 + ---@class wrapinput.Limits 15 + ---@field min integer 16 + ---@field max integer 17 + 18 + return { 19 + ---@type wrapinput.Config 20 + defaults = { 21 + opts = { numbers = "multiline" }, 22 + padding = 5, 23 + width = { min = 20, max = 60 }, 24 + height = { min = 1, max = 6 }, 25 + win = { 26 + title = "Input: ", 27 + style = "minimal", 28 + focusable = true, 29 + relative = "cursor", 30 + col = -1, 31 + height = 1, 32 + }, 33 + }, 34 + }
+149
lua/lib/input.lua
··· 1 + local utils = require("lib.utils") 2 + 3 + ---@class wrapinput.Input 4 + ---@field config wrapinput.Config 5 + ---@field winnr integer 6 + ---@field bufnr integer 7 + ---@field on_confirm fun(input?: string) 8 + local Input = {} 9 + 10 + local defaults = { 11 + opts = { numbers = "multiline" }, 12 + padding = 5, 13 + width = { min = 20, max = 60 }, 14 + height = { min = 1, max = 6 }, 15 + win = { 16 + title = "Input: ", 17 + style = "minimal", 18 + focusable = true, 19 + relative = "cursor", 20 + col = -1, 21 + height = 1, 22 + }, 23 + } 24 + 25 + local group = vim.api.nvim_create_augroup("wrapinput.nvim", { clear = true }) 26 + 27 + ---@param config wrapinput.Config 28 + ---@param opts any 29 + ---@param on_confirm fun(input?: string) 30 + function Input:new(config, opts, on_confirm) 31 + local i = {} 32 + i.config = vim.tbl_deep_extend("force", defaults, config, { win = { title = opts.prompt or "Input: " } }) 33 + i.default = opts.default or "" 34 + i.on_confirm = on_confirm or function() end 35 + setmetatable(i, self) 36 + self.__index = self 37 + return i 38 + end 39 + 40 + ---@param default string 41 + function Input:open(default) 42 + local width = 43 + utils.clamp(vim.fn.strdisplaywidth(default) + self.config.padding, self.config.width.min, self.config.width.max) 44 + 45 + -- Position window relative to the cursor, such that it doesn't overlap with the cursor's line. 46 + local curr_win = vim.api.nvim_get_current_win() 47 + local cursor_row = vim.api.nvim_win_get_cursor(curr_win)[1] 48 + local win_config = (cursor_row <= 3) and { anchor = "NW", row = 1, width = width } 49 + or { anchor = "SW", row = 0, width = width } 50 + self.config = vim.tbl_deep_extend("keep", self.config, { win = win_config }) 51 + 52 + -- Create buffer and floating window. 53 + self.bufnr = vim.api.nvim_create_buf(false, true) 54 + utils.set_options( 55 + { buftype = "prompt", bufhidden = "wipe", textwidth = self.config.width.max }, 56 + { buf = self.bufnr } 57 + ) 58 + vim.fn.prompt_setprompt(self.bufnr, "") 59 + 60 + self.winnr = vim.api.nvim_open_win(self.bufnr, true, self.config.win) 61 + utils.set_options({ wrap = true, linebreak = true, winhighlight = "Search:None" }, { win = self.winnr }) 62 + 63 + -- Write default value and put cursor at the end 64 + vim.api.nvim_buf_set_text(self.bufnr, 0, 0, 0, 0, { default }) 65 + vim.cmd("startinsert") 66 + vim.api.nvim_win_set_cursor(self.winnr, { 1, vim.str_utfindex(default, "utf-8") + 1 }) 67 + 68 + self:resize() 69 + self:autocmds() 70 + self:mappings() 71 + end 72 + 73 + ---@param result string? 74 + function Input:close(result) 75 + vim.cmd("stopinsert") 76 + vim.api.nvim_win_close(self.winnr, true) 77 + self.on_confirm(result) 78 + end 79 + 80 + function Input:resize() 81 + local text = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) 82 + local line = table.concat(text, "") 83 + 84 + if line == "" then 85 + vim.api.nvim_win_set_width( 86 + self.winnr, 87 + utils.clamp(self.config.padding, self.config.width.min, self.config.width.max) 88 + ) 89 + vim.api.nvim_win_set_height(self.winnr, 1) 90 + return 91 + end 92 + 93 + local lines = utils.split_wrapped_lines(line, self.config.width.max) 94 + 95 + local lens = vim.tbl_map(function(l) 96 + return vim.fn.strdisplaywidth(l) 97 + end, lines) 98 + local width = 99 + utils.clamp(math.max(unpack(lens)) + self.config.padding, self.config.width.min, self.config.width.max + #lines) 100 + vim.api.nvim_win_set_width(self.winnr, width) 101 + 102 + local height = utils.clamp(#lines, self.config.height.min, self.config.height.max) 103 + vim.api.nvim_win_set_height(self.winnr, height) 104 + 105 + if self.config.opts.numbers == "always" or (self.config.opts.numbers == "multiline" and height > 1) then 106 + utils.set_option_if_globally_enabled("number", self.winnr) 107 + utils.set_option_if_globally_enabled("relativenumber", self.winnr) 108 + end 109 + 110 + if 111 + vim.api.nvim_get_option_value("number", { win = self.winnr }) 112 + or vim.api.nvim_get_option_value("relativenumber", { win = self.winnr }) 113 + then 114 + vim.api.nvim_win_set_width(self.winnr, width + utils.get_linenr_width()) 115 + end 116 + end 117 + 118 + function Input:autocmds() 119 + vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { 120 + group = group, 121 + buffer = self.bufnr, 122 + callback = function() 123 + self:resize() 124 + end, 125 + }) 126 + end 127 + 128 + function Input:mappings() 129 + ---@param mode string|string[] 130 + ---@param lhs string 131 + ---@param rhs string|function 132 + local function map(mode, lhs, rhs) 133 + vim.keymap.set(mode, lhs, rhs, { buffer = self.bufnr }) 134 + end 135 + 136 + map({ "n", "i", "v" }, "<cr>", function() 137 + self:close(vim.api.nvim_buf_get_lines(self.bufnr, 0, 1, false)[1]) 138 + end) 139 + 140 + map("n", "<esc>", function() 141 + self:close() 142 + end) 143 + 144 + map("n", "q", function() 145 + self:close() 146 + end) 147 + end 148 + 149 + return Input
+58
lua/lib/utils.lua
··· 1 + local M = {} 2 + 3 + ---@param options table<string, any> 4 + ---@param opts vim.api.keyset.option 5 + function M.set_options(options, opts) 6 + for k, v in pairs(options) do 7 + vim.api.nvim_set_option_value(k, v, opts) 8 + end 9 + end 10 + 11 + ---@param option string 12 + ---@param winnr integer 13 + function M.set_option_if_globally_enabled(option, winnr) 14 + if vim.api.nvim_get_option_value(option, { scope = "global" }) then 15 + vim.api.nvim_set_option_value(option, true, { win = winnr }) 16 + end 17 + end 18 + 19 + ---@param value number 20 + ---@param min number 21 + ---@param max number 22 + ---@return number 23 + function M.clamp(value, min, max) 24 + return math.min(math.max(value, min), max) 25 + end 26 + 27 + ---@param text string 28 + ---@param width integer 29 + ---@return string[] 30 + function M.split_wrapped_lines(text, width) 31 + if text == "" then 32 + return {} 33 + end 34 + 35 + ---@type string[] 36 + local lines = {} 37 + local textlen = vim.fn.strchars(text, true) 38 + 39 + local i = 0 40 + while i < textlen do 41 + local len = i + width <= textlen and width or textlen - i 42 + local new_line = vim.fn.strcharpart(text, i, len) 43 + 44 + table.insert(lines, new_line) 45 + i = i + len 46 + end 47 + 48 + return lines 49 + end 50 + 51 + function M.get_linenr_width() 52 + local line_count = vim.api.nvim_buf_line_count(0) 53 + local line_digits = math.floor(math.log10(math.max(1, line_count))) + 1 54 + local numberwidth_opt = vim.opt.numberwidth:get() 55 + return math.max(line_digits, numberwidth_opt) 56 + end 57 + 58 + return M
+3 -185
lua/wrapinput.lua
··· 1 - ---@class wrapinput.Config 2 - ---@field width wrapinput.Limits 3 - ---@field height wrapinput.Limits 4 - ---@field padding integer 5 - ---@field win vim.api.keyset.win_config 6 - 7 - ---@class wrapinput.Limits 8 - ---@field min integer 9 - ---@field max integer 10 - 11 - local group = vim.api.nvim_create_augroup("wrapinput.nvim", { clear = true }) 12 - 13 - ---@type wrapinput.Config 14 - local defaults = { 15 - padding = 5, 16 - width = { min = 20, max = 60 }, 17 - height = { min = 1, max = 6 }, 18 - win = { 19 - title = "Input: ", 20 - style = "minimal", 21 - focusable = true, 22 - relative = "cursor", 23 - col = -1, 24 - height = 1, 25 - }, 26 - } 27 - 28 - ---@param value number 29 - ---@param min number 30 - ---@param max number 31 - ---@return number 32 - local function clamp(value, min, max) 33 - return math.min(math.max(value, min), max) 34 - end 35 - 36 - ---@return vim.api.keyset.win_config 37 - local function get_relative_win_config() 38 - local curr_win = vim.api.nvim_get_current_win() 39 - local cursor_row = vim.api.nvim_win_get_cursor(curr_win)[1] 40 - if cursor_row <= 3 then 41 - return { anchor = "NW", row = 1 } 42 - else 43 - return { anchor = "SW", row = 0 } 44 - end 45 - end 46 - 47 - ---@param text string 48 - ---@param width integer 49 - ---@return string[] 50 - local function split_wrapped_lines(text, width) 51 - if text == "" then 52 - return {} 53 - end 54 - 55 - ---@type string[] 56 - local lines = {} 57 - local textlen = vim.fn.strchars(text, true) 58 - 59 - local i = 0 60 - while i < textlen do 61 - local len = i + width <= textlen and width or textlen - i 62 - local new_line = vim.fn.strcharpart(text, i, len) 63 - 64 - table.insert(lines, new_line) 65 - i = i + len 66 - end 67 - 68 - return lines 69 - end 70 - 71 - ---@param option string 72 - ---@param winnr integer 73 - local function set_option_if_globally_enabled(option, winnr) 74 - if vim.api.nvim_get_option_value(option, { scope = "global" }) then 75 - vim.api.nvim_set_option_value(option, true, { win = winnr }) 76 - end 77 - end 78 - 79 - ---@param winnr integer 80 - ---@param bufnr integer 81 - ---@param config wrapinput.Config 82 - local function resize(winnr, bufnr, config) 83 - local text = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) 84 - local line = table.concat(text, "") 85 - 86 - if line == "" then 87 - vim.api.nvim_win_set_width(winnr, clamp(config.padding, config.width.min, config.width.max)) 88 - vim.api.nvim_win_set_height(winnr, 1) 89 - return 90 - end 91 - 92 - local lines = split_wrapped_lines(line, config.width.max) 93 - 94 - local lens = vim.tbl_map(function(l) 95 - return vim.fn.strdisplaywidth(l) 96 - end, lines) 97 - local width = clamp(math.max(unpack(lens)) + config.padding + #lines, config.width.min, config.width.max + #lines) 98 - vim.api.nvim_win_set_width(winnr, width) 99 - 100 - local height = clamp(#lines, config.height.min, config.height.max) 101 - vim.api.nvim_win_set_height(winnr, height) 102 - 103 - if height > 1 then 104 - set_option_if_globally_enabled("number", winnr) 105 - set_option_if_globally_enabled("relativenumber", winnr) 106 - end 107 - end 108 - 109 - ---@param winnr integer 110 - ---@param bufnr integer 111 - ---@param config wrapinput.Config 112 - local function setup_autocmds(winnr, bufnr, config) 113 - vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { 114 - group = group, 115 - buffer = bufnr, 116 - callback = function() 117 - resize(winnr, bufnr, config) 118 - end, 119 - }) 120 - end 121 - 122 - ---@param options table<string, any> 123 - ---@param opts vim.api.keyset.option 124 - local function set_options(options, opts) 125 - for k, v in pairs(options) do 126 - vim.api.nvim_set_option_value(k, v, opts) 127 - end 128 - end 129 - 130 - ---@param winnr integer 131 - ---@param bufnr integer 132 - ---@param on_confirm fun(input?: string) 133 - local function setup_mappings(winnr, bufnr, on_confirm) 134 - ---@param result string? 135 - local function close(result) 136 - vim.cmd("stopinsert") 137 - vim.api.nvim_win_close(winnr, true) 138 - on_confirm(result) 139 - end 140 - 141 - ---@param mode string|string[] 142 - ---@param lhs string 143 - ---@param rhs string|function 144 - local function map(mode, lhs, rhs) 145 - vim.keymap.set(mode, lhs, rhs, { buffer = bufnr }) 146 - end 147 - 148 - map({ "n", "i", "v" }, "<cr>", function() 149 - close(vim.api.nvim_buf_get_lines(bufnr, 0, 1, false)[1]) 150 - end) 151 - map("n", "<esc>", close) 152 - map("n", "q", close) 153 - end 1 + local Input = require("lib.input") 154 2 155 3 local M = {} 156 4 157 - ---@param config wrapinput.Config 158 - ---@param opts table 159 - ---@param on_confirm fun(input: string?) 160 - function M.input(config, opts, on_confirm) 161 - config = vim.tbl_deep_extend("force", defaults, config, { win = { title = opts.prompt } }) 162 - local default = opts.default or "" 163 - on_confirm = on_confirm or function() end 164 - 165 - local width = clamp(vim.fn.strdisplaywidth(default) + config.padding, config.width.min, config.width.max) 166 - vim.notify("width: " .. tostring(width)) 167 - config = vim.tbl_deep_extend("keep", config, { win = get_relative_win_config() }, { win = { width = width } }) 168 - 169 - -- Create buffer and floating window. 170 - local bufnr = vim.api.nvim_create_buf(false, true) 171 - set_options({ buftype = "prompt", bufhidden = "wipe", textwidth = config.width.max }, { buf = bufnr }) 172 - vim.fn.prompt_setprompt(bufnr, "") 173 - 174 - local winnr = vim.api.nvim_open_win(bufnr, true, config.win) 175 - set_options({ wrap = true, linebreak = true, winhighlight = "Search:None" }, { win = winnr }) 176 - 177 - -- write default value and put cursor at the end 178 - vim.api.nvim_buf_set_text(bufnr, 0, 0, 0, 0, { default }) 179 - vim.cmd("startinsert") 180 - vim.api.nvim_win_set_cursor(winnr, { 1, vim.str_utfindex(default, "utf-8") + 1 }) 181 - 182 - resize(winnr, bufnr, config) 183 - 184 - setup_mappings(winnr, bufnr, on_confirm) 185 - setup_autocmds(winnr, bufnr, config) 186 - end 187 - 188 5 ---@param config? wrapinput.Config 189 6 function M.setup(config) 190 7 vim.ui.input = function(opts, on_confirm) 191 - M.input(config or {}, opts or {}, on_confirm) 8 + local input = Input:new(config or {}, opts or {}, on_confirm) 9 + input:open(opts.default or "") 192 10 end 193 11 end 194 12