diff --git a/README.md b/README.md index 17638bf..9b7dcb7 100644 --- a/README.md +++ b/README.md @@ -548,8 +548,15 @@ require'fzf-lua'.setup { rg_opts = "--column --line-number --no-heading --color=always --smart-case --max-columns=512", grep_opts = "--binary-files=without-match --line-number --recursive --color=auto --perl-regexp", -- 'live_grep_glob' options: - glob_flag = "--iglob", -- for case sensitive globs use '--glob' - glob_separator = "%s%-%-" -- query separator pattern (lua): ' --' + glob_flag = "--iglob", -- for case sensitive globs use '--glob' + glob_separator = "%s%-%-" -- query separator pattern (lua): ' --' + actions = { + -- actions inherit from 'actions.files' and merge + -- this action toggles between 'grep' and 'live_grep' + ["ctrl-i"] = { actions.grep_lgrep } + }, + no_header = false, -- hide grep|cwd header? + no_header_i = false, -- hide interactive header? }, args = { prompt = 'Args❯ ', @@ -631,7 +638,13 @@ require'fzf-lua'.setup { -- 'tags_live_grep' options, `rg` prioritizes over `grep` rg_opts = "--no-heading --color=always --smart-case", grep_opts = "--color=auto --perl-regexp", - -- actions inherit from 'actions.files' + actions = { + -- actions inherit from 'actions.files' and merge + -- this action toggles between 'grep' and 'live_grep' + ["ctrl-i"] = { actions.grep_lgrep } + }, + no_header = false, -- hide grep|cwd header? + no_header_i = false, -- hide interactive header? }, btags = { prompt = 'BTags❯ ', diff --git a/doc/fzf-lua.txt b/doc/fzf-lua.txt index 2c1b8d8..ed9e0d3 100644 --- a/doc/fzf-lua.txt +++ b/doc/fzf-lua.txt @@ -594,8 +594,15 @@ Consult the list below for available settings: rg_opts = "--column --line-number --no-heading --color=always --smart-case --max-columns=512", grep_opts = "--binary-files=without-match --line-number --recursive --color=auto --perl-regexp", -- 'live_grep_glob' options: - glob_flag = "--iglob", -- for case sensitive globs use '--glob' - glob_separator = "%s%-%-" -- query separator pattern (lua): ' --' + glob_flag = "--iglob", -- for case sensitive globs use '--glob' + glob_separator = "%s%-%-" -- query separator pattern (lua): ' --' + actions = { + -- actions inherit from 'actions.files' and merge + -- this action toggles between 'grep' and 'live_grep' + ["ctrl-i"] = { actions.grep_lgrep } + }, + no_header = false, -- hide grep|cwd header? + no_header_i = false, -- hide interactive header? }, args = { prompt = 'Args❯ ', @@ -677,7 +684,13 @@ Consult the list below for available settings: -- 'tags_live_grep' options, `rg` prioritizes over `grep` rg_opts = "--no-heading --color=always --smart-case", grep_opts = "--color=auto --perl-regexp", - -- actions inherit from 'actions.files' + actions = { + -- actions inherit from 'actions.files' and merge + -- this action toggles between 'grep' and 'live_grep' + ["ctrl-i"] = { actions.grep_lgrep } + }, + no_header = false, -- hide grep|cwd header? + no_header_i = false, -- hide interactive header? }, btags = { prompt = 'BTags❯ ', diff --git a/lua/fzf-lua/actions.lua b/lua/fzf-lua/actions.lua index 4402295..a67cd66 100644 --- a/lua/fzf-lua/actions.lua +++ b/lua/fzf-lua/actions.lua @@ -530,4 +530,27 @@ M.arg_del = function(selected, opts) M.vimcmd_file(vimcmd, selected, opts) end +M.grep_lgrep = function(_, opts) + + -- 'FNCREF' is set only on 'M.live_grep' calls + -- 'MODULE' is set on 'M.grep' and 'live_grep' calls + assert(opts.__MODULE__ + and type(opts.__MODULE__.grep) == 'function' + or type(opts.__MODULE__.live_grep) == 'function') + + local o = vim.tbl_extend("keep", { + search = false, + continue_last_search = true, + continue_last_search_default = '', + }, opts.__call_opts or {}) + + if opts.__FNCREF__ then + opts.__MODULE__.grep(o) + -- require'fzf-lua.actions'.ensure_insert_mode() + else + opts.__MODULE__.live_grep(o) + -- require'fzf-lua.actions'.ensure_insert_mode() + end +end + return M diff --git a/lua/fzf-lua/config.lua b/lua/fzf-lua/config.lua index 4d61ebc..964b106 100644 --- a/lua/fzf-lua/config.lua +++ b/lua/fzf-lua/config.lua @@ -272,6 +272,9 @@ M.globals.grep = { grep_opts = "--binary-files=without-match --line-number --recursive --color=auto --perl-regexp", rg_opts = "--column --line-number --no-heading --color=always --smart-case --max-columns=512", _actions = function() return M.globals.actions.files end, + actions = { + ["ctrl-i"] = { actions.grep_lgrep } + }, -- live_grep_glob options glob_flag = "--iglob", -- for case sensitive globs use '--glob' glob_separator = "%s%-%-", -- query separator pattern (lua): ' --' @@ -385,6 +388,9 @@ M.globals.tags = { git_icons = true, color_icons = true, _actions = function() return M.globals.actions.files end, + actions = { + ["ctrl-i"] = { actions.grep_lgrep } + }, } M.globals.btags = { previewer = { _ctor = previewers.builtin.tags }, @@ -626,6 +632,10 @@ end function M.normalize_opts(opts, defaults) if not opts then opts = {} end + -- save the user's call parameters separately + -- we reuse those with 'actions.grep_lgrep' + opts.__call_opts = opts.__call_opts or utils.deepcopy(opts) + -- opts can also be a function that returns an opts table if type(opts) == 'function' then opts = opts() @@ -814,6 +824,7 @@ M._action_to_helpstr = { [actions.git_buf_vsplit] = "git-buffer-vsplit", [actions.arg_add] = "arg-list-add", [actions.arg_del] = "arg-list-delete", + [actions.grep_lgrep] = "grep<->lgrep", } return M diff --git a/lua/fzf-lua/core.lua b/lua/fzf-lua/core.lua index 7561b17..4fb0223 100644 --- a/lua/fzf-lua/core.lua +++ b/lua/fzf-lua/core.lua @@ -69,33 +69,35 @@ M.fzf = function(opts, contents) -- providers config.__resume_data.last_query = nil end - if opts.global_resume_query then - -- We use this option to print the query on line 1 - -- later to be removed from the result by M.fzf() - -- this providers a solution for saving the query - -- when the user pressed a valid bind but not when - -- aborting with or , see next comment - opts.fzf_opts['--print-query'] = '' - -- Signals to the win object resume is enabled - -- so we can setup the keypress event monitoring - -- since we already have the query on valid - -- exit codes we only need to monitor , - opts.fn_save_query = function(query) - config.__resume_data.last_query = query and #query>0 and query or nil - end - -- 'au InsertCharPre' would be the best option here - -- but it does not work for terminals: - -- https://github.com/neovim/neovim/issues/5018 - -- this is causing lag when typing too fast (#271) - -- also not possible with skim (no 'change' event) - --[[ if not opts._is_skim then - local raw_act = shell.raw_action(function(args) - opts.fn_save_query(args[1]) - end, "{q}") - opts._fzf_cli_args = ('--bind=change:execute-silent:%s'): - format(vim.fn.shellescape(raw_act)) - end ]] + + end + if opts.save_query or + opts.global_resume and opts.global_resume_query then + -- We use this option to print the query on line 1 + -- later to be removed from the result by M.fzf() + -- this providers a solution for saving the query + -- when the user pressed a valid bind but not when + -- aborting with or , see next comment + opts.fzf_opts['--print-query'] = '' + -- Signals to the win object resume is enabled + -- so we can setup the keypress event monitoring + -- since we already have the query on valid + -- exit codes we only need to monitor , + opts.fn_save_query = function(query) + config.__resume_data.last_query = query and #query>0 and query or nil end + -- 'au InsertCharPre' would be the best option here + -- but it does not work for terminals: + -- https://github.com/neovim/neovim/issues/5018 + -- this is causing lag when typing too fast (#271) + -- also not possible with skim (no 'change' event) + --[[ if not opts._is_skim then + local raw_act = shell.raw_action(function(args) + opts.fn_save_query(args[1]) + end, "{q}") + opts._fzf_cli_args = ('--bind=change:execute-silent:%s'): + format(vim.fn.shellescape(raw_act)) + end ]] end -- setup the fzf window and preview layout local fzf_win = win(opts) @@ -152,7 +154,7 @@ M.fzf = function(opts, contents) fzf_win:create() -- save the normalized winopts, otherwise we -- lose overrides by 'winopts_fn|winopts_raw' - opts.winopts = fzf_win.winopts + opts.winopts.preview = fzf_win.winopts.preview local selected, exit_code = fzf.raw_fzf(contents, M.build_fzf_cli(opts), { fzf_binary = opts.fzf_bin, fzf_cwd = opts.cwd }) -- This was added by 'resume': @@ -162,6 +164,10 @@ M.fzf = function(opts, contents) if selected and #selected>0 and opts.fzf_opts['--print-query'] ~= nil then if opts.fn_save_query then + -- reminder: this doesn't get called with 'live_grep' when using skim + -- due to a bug where '--print-query --interactive' combo is broken: + -- skim always prints an emtpy line where the typed query should be + -- see addtional note above 'opts.save_query' inside 'live_grep_mt' opts.fn_save_query(selected[1]) end table.remove(selected, 1) @@ -337,6 +343,7 @@ M.mt_cmd_wrapper = function(opts) "color_icons", "strip_cwd_prefix", "rg_glob", + "__module__", } -- caller reqested rg with glob support if o.rg_glob then @@ -446,30 +453,42 @@ M.set_fzf_field_index = function(opts, default_idx, default_expr) return opts end -M.set_header = function(opts, type) +M.set_header = function(opts, flags) if not opts then opts = {} end if opts.no_header then return opts end if not opts.cwd_header then opts.cwd_header = "cwd:" end - if not opts.search_header then opts.search_header = "Searching for:" end + if not opts.grep_header then opts.grep_header = "Grep string:" end if not opts.cwd and opts.show_cwd_header then opts.cwd = vim.loop.cwd() end local header_str local cwd_str = opts.cwd and (opts.show_cwd_header ~= false) and (opts.show_cwd_header or opts.cwd ~= vim.loop.cwd()) and - ("%s %s"):format(opts.cwd_header, opts.cwd:gsub("^"..vim.env.HOME, "~")) + ("%s %s"):format(opts.cwd_header, + utils.ansi_codes.red(opts.cwd:gsub("^"..vim.env.HOME, "~"))) local search_str = opts.search and #opts.search > 0 and - ("%s %s"):format(opts.search_header, opts.search) + ("%s %s"):format(opts.grep_header, utils.ansi_codes.red(opts.search)) -- 1: only search -- 2: only cwd -- otherwise, all - if type == 1 then header_str = search_str or '' - elseif type == 2 then header_str = cwd_str or '' + if flags == 1 then header_str = search_str or '' + elseif flags == 2 then header_str = cwd_str or '' else - header_str = search_str or '' - if #header_str>0 and cwd_str and #cwd_str>0 then - header_str = header_str .. ", " + header_str = ("%s%s%s"):format( + cwd_str and cwd_str or '', + cwd_str and search_str and ', ' or '', + search_str and search_str or '') + end + -- check for 'actions.grep_lgrep' and "ineteractive" header + if not opts.no_header_i then + for k, v in pairs(opts.actions) do + if type(v) == 'table' and v[1] == actions.grep_lgrep then + local to = opts.__FNCREF__ and 'Grep' or 'Live Grep' + header_str = (':: <%s> to %s%s'):format( + utils.ansi_codes.yellow(k), + utils.ansi_codes.red(to), + header_str and #header_str>0 and ", "..header_str or '') + end end - header_str = header_str .. (cwd_str or '') end if not header_str or #header_str==0 then return opts end opts.fzf_opts['--header'] = libuv.shellescape(header_str) @@ -573,12 +592,12 @@ M.set_fzf_interactive = function(opts, act_cmd, placeholder) end -- skim interactive mode does not need a piped command opts.fzf_fn = nil - opts.fzf_opts['--prompt'] = '*' .. opts.prompt - opts.fzf_opts['--cmd-prompt'] = vim.fn.shellescape(opts.prompt) + opts.fzf_opts['--prompt'] = opts.prompt:match("[^%*]+") + opts.fzf_opts['--cmd-prompt'] = libuv.shellescape(opts.prompt) opts.prompt = nil -- since we surrounded the skim placeholder with quotes -- we need to escape them in the initial query - opts.fzf_opts['--cmd-query'] = vim.fn.shellescape(utils.sk_escape(query)) + opts.fzf_opts['--cmd-query'] = libuv.shellescape(utils.sk_escape(query)) opts._fzf_cli_args = string.format( "-i -c %s", act_cmd) else -- fzf already adds single quotes @@ -586,10 +605,10 @@ M.set_fzf_interactive = function(opts, act_cmd, placeholder) opts.fzf_fn = {} if opts.exec_empty_query or (query and #query>0) then opts.fzf_fn = act_cmd:gsub(placeholder, - #query>0 and utils.lua_escape(vim.fn.shellescape(query)) or "''") + #query>0 and utils.lua_escape(libuv.shellescape(query)) or "''") end opts.fzf_opts['--phony'] = '' - opts.fzf_opts['--query'] = vim.fn.shellescape(query) + opts.fzf_opts['--query'] = libuv.shellescape(query) opts._fzf_cli_args = string.format('--bind=%s', vim.fn.shellescape(string.format("change:reload:%s || true", act_cmd))) end diff --git a/lua/fzf-lua/make_entry.lua b/lua/fzf-lua/make_entry.lua index ac0ef9e..77718e8 100644 --- a/lua/fzf-lua/make_entry.lua +++ b/lua/fzf-lua/make_entry.lua @@ -221,9 +221,11 @@ M.preprocess = function(opts) -- save our last search argument for resume if opts.argv_expr and opts.cmd:match(argvz) then local query = argv(nil, opts.debug) - set_config_section('globals.grep._last_search', - { query = query, no_esc = true }) set_config_section('__resume_data.last_query', query) + if opts.__module__ then + set_config_section(("globals.%s._last_search"):format(opts.__module__), + { query = query, no_esc = true }) + end end -- did the caller request rg with glob support? diff --git a/lua/fzf-lua/providers/grep.lua b/lua/fzf-lua/providers/grep.lua index 88bd5be..6df2aa3 100644 --- a/lua/fzf-lua/providers/grep.lua +++ b/lua/fzf-lua/providers/grep.lua @@ -4,12 +4,19 @@ local utils = require "fzf-lua.utils" local config = require "fzf-lua.config" local libuv = require "fzf-lua.libuv" -local function get_last_search() +local function get_last_search(opts) + if opts.__MODULE__ and opts.__MODULE__.get_last_search then + return opts.__MODULE__.get_last_search(opts) + end local last_search = config.globals.grep._last_search or {} return last_search.query, last_search.no_esc end -local function set_last_search(query, no_esc) +local function set_last_search(opts, query, no_esc) + if opts.__MODULE__ and opts.__MODULE__.set_last_search then + opts.__MODULE__.set_last_search(opts, query, no_esc) + return + end config.globals.grep._last_search = { query = query, no_esc = no_esc @@ -19,6 +26,11 @@ local function set_last_search(query, no_esc) end end +local function set_live_grep_prompt(prompt) + -- prefix all live_grep prompts with an asterisk + return prompt:match("^%*") and prompt or '*'..prompt +end + local M = {} local get_grep_cmd = function(opts, search_query, no_esc) @@ -72,9 +84,13 @@ M.grep = function(opts) opts = config.normalize_opts(opts, config.globals.grep) if not opts then return end + -- we need this for 'actions.grep_lgrep' + opts.__MODULE__ = opts.__MODULE__ or M + local no_esc = false if opts.continue_last_search or opts.repeat_last_search then - opts.search, no_esc = get_last_search() + opts.search, no_esc = get_last_search(opts) + opts.search = opts.search or opts.continue_last_search_default end -- if user did not provide a search term @@ -93,7 +109,7 @@ M.grep = function(opts) -- save the search query so the use can -- call the same search again - set_last_search(opts.search, no_esc or opts.no_esc) + set_last_search(opts, opts.search, no_esc or opts.no_esc) opts.cmd = get_grep_cmd(opts, opts.search, no_esc) local contents = core.mt_cmd_wrapper(opts) @@ -115,18 +131,22 @@ M.live_grep_st = function(opts) opts = config.normalize_opts(opts, config.globals.grep) if not opts then return end + -- we need this for 'actions.grep_lgrep' + opts.__MODULE__ = opts.__MODULE__ or M + opts.prompt = set_live_grep_prompt(opts.prompt) + assert(not opts.multiprocess) local no_esc = false if opts.continue_last_search or opts.repeat_last_search then - opts.search, no_esc = get_last_search() + opts.search, no_esc = get_last_search(opts) end opts.query = opts.search or '' if opts.search and #opts.search>0 then -- save the search query so the use can -- call the same search again - set_last_search(opts.search, true) + set_last_search(opts, opts.search, true) -- escape unless the user requested not to if not (no_esc or opts.no_esc) then opts.query = utils.rg_escape(opts.search) @@ -138,7 +158,7 @@ M.live_grep_st = function(opts) opts._reload_command = function(query) if query and not (opts.save_last_search == false) then - set_last_search(query, true) + set_last_search(opts, query, true) end -- can be nill when called as fzf initial command query = query or '' @@ -156,6 +176,19 @@ M.live_grep_st = function(opts) end end + -- see notes for this section in 'live_grep_mt' + if not opts._is_skim then + opts.save_query = true + opts.fn_post_fzf = function(o, _) + local last_search, _ = get_last_search(o) + local last_query = config.__resume_data and config.__resume_data.last_query + if not opts.exec_empty_query + and last_search ~= last_query then + set_last_search(opts, last_query or '') + end + end + end + -- disable global resume -- conflicts with 'change:reload' event opts.global_resume_query = false @@ -172,18 +205,23 @@ M.live_grep_mt = function(opts) opts = config.normalize_opts(opts, config.globals.grep) if not opts then return end + -- we need this for 'actions.grep_lgrep' + opts.__MODULE__ = opts.__MODULE__ or M + opts.__module__ = opts.__module__ or 'grep' + opts.prompt = set_live_grep_prompt(opts.prompt) + assert(opts.multiprocess) local no_esc = false if opts.continue_last_search or opts.repeat_last_search then - opts.search, no_esc = get_last_search() + opts.search, no_esc = get_last_search(opts) end local query = opts.search or '' if opts.search and #opts.search>0 then -- save the search query so the use can -- call the same search again - set_last_search(opts.search, no_esc or opts.no_esc) + set_last_search(opts, opts.search, no_esc or opts.no_esc) -- escape unless the user requested not to if not (no_esc or opts.no_esc) then query = utils.rg_escape(opts.search) @@ -226,7 +264,7 @@ M.live_grep_mt = function(opts) if opts._is_skim then -- skim interactive mode does not need a piped command opts.fzf_fn = nil - opts.fzf_opts['--prompt'] = '*' .. opts.prompt + opts.fzf_opts['--prompt'] = opts.prompt:match("[^%*]+") opts.fzf_opts['--cmd-prompt'] = vim.fn.shellescape(opts.prompt) opts.prompt = nil -- since we surrounded the skim placeholder with quotes @@ -247,6 +285,30 @@ M.live_grep_mt = function(opts) ("%s || true"):format(reload_command)))) end + -- when running 'live_grep' with 'exec_empty_query=false' (default) + -- an empty typed query will not be saved as the 'neovim --headless' + -- command isn't executed resulting in '_last_search.query' never + -- cleared, always having a minimum of one characer. + -- this signals 'core.fzf' to add the '--print-query' flag and + -- handle the typed query on process exit using 'opts.fn_save_query' + -- due to a skim bug this doesn't work when used in conjucntion with + -- the '--interactive' flag, the line with the typed query is printed + -- to stdout but is always empty + -- to understand this issue, run 'live_grep', type a query and then + -- delete it and press to switch to 'grep', instead of an empty + -- search the last typed character will be used as the search string + if not opts._is_skim then + opts.save_query = true + opts.fn_post_fzf = function(o, _) + local last_search, _ = get_last_search(o) + local last_query = config.__resume_data and config.__resume_data.last_query + if not opts.exec_empty_query + and last_search ~= last_query then + set_last_search(opts, last_query or '') + end + end + end + -- disable global resume -- conflicts with 'change:reload' event opts.global_resume_query = false diff --git a/lua/fzf-lua/providers/tags.lua b/lua/fzf-lua/providers/tags.lua index 2b67a8b..5a651c0 100644 --- a/lua/fzf-lua/providers/tags.lua +++ b/lua/fzf-lua/providers/tags.lua @@ -7,6 +7,21 @@ local make_entry = require "fzf-lua.make_entry" local M = {} +function M.get_last_search(_) + local last_search = config.globals.tags._last_search or {} + return last_search.query, last_search.no_esc +end + +function M.set_last_search(_, query, no_esc) + config.globals.tags._last_search = { + query = query, + no_esc = no_esc + } + if config.__resume_data then + config.__resume_data.last_query = query + end +end + local function get_tags_cmd(opts, flags) local query = nil local cmd = "grep" @@ -27,6 +42,10 @@ end local function tags(opts) + -- we need this for 'actions.grep_lgrep' + opts.__MODULE__ = opts.__MODULE__ or M + opts.__module__ = opts.__module__ or 'tags' + -- signal actions this is a ctag opts._ctag = true opts.ctags_file = opts.ctags_file and vim.fn.expand(opts.ctags_file) or "tags" @@ -63,7 +82,6 @@ local function tags(opts) if opts.lgrep then -- live_grep requested by caller ('tags_live_grep') - opts.prompt = opts.prompt:match("^*") and opts.prompt or '*' .. opts.prompt opts.filename = opts._ctags_file if opts.multiprocess then return require'fzf-lua.providers.grep'.live_grep_mt(opts) @@ -76,6 +94,10 @@ local function tags(opts) end end + -- save the search query so the use can + -- call the same search again + M.set_last_search(opts, opts.search, opts.no_esc) + opts._curr_file = opts._curr_file and path.relative(opts._curr_file, opts.cwd or vim.loop.cwd()) opts.cmd = opts.cmd or get_tags_cmd(opts) @@ -105,6 +127,11 @@ end M.grep = function(opts) opts = opts or {} + if opts.continue_last_search or opts.repeat_last_search then + opts.search, opts.no_esc = M.get_last_search(opts) + opts.search = opts.search or opts.continue_last_search_default + end + if not opts.search then opts.search = vim.fn.input(opts.input_prompt or 'Grep For> ') end diff --git a/lua/fzf-lua/utils.lua b/lua/fzf-lua/utils.lua index 32f3422..2fd4930 100644 --- a/lua/fzf-lua/utils.lua +++ b/lua/fzf-lua/utils.lua @@ -104,7 +104,7 @@ function M.rg_escape(str) if not str then return str end -- [(~'"\/$?'`*&&||;[]<>)] -- escape "\~$?*|[()^-." - return str:gsub('[\\~$?*|{\\[()^%-%.]', function(x) + return str:gsub('[\\~$?*|{\\[()^%-%.%+]', function(x) return '\\' .. x end) end diff --git a/lua/fzf-lua/win.lua b/lua/fzf-lua/win.lua index 18b6e77..e2ac74c 100644 --- a/lua/fzf-lua/win.lua +++ b/lua/fzf-lua/win.lua @@ -20,7 +20,11 @@ function FzfWin.save_query(key) if not self then return end local lines = vim.api.nvim_buf_get_lines(self.fzf_bufnr, 0, 1, false) if not lines or vim.tbl_isempty(lines) then return end - local query = lines[1]:gsub("^"..self.prompt, ""):match("[^<]+") + -- 'live_grep' prepends an asterisk to the prompt + -- remove '*' from the start of the line & prompt + local query = lines[1]:gsub("^%*+", "") + :gsub("^"..utils.lua_escape(self.prompt:match("[^%*]+")), "") + :match("[^<]+") -- trim whitespaces at the end query = query and query:gsub("%s*$", "") if self.fn_save_query then @@ -1078,13 +1082,16 @@ function FzfWin.toggle_help() if k == 'default' then k = 'enter' end if type(v) =='table' then v = config.get_action_helpstr(v[1]) or v - else + elseif v then v = config.get_action_helpstr(v) or v end - table.insert(keymaps, - format_bind('action', k, - ("%s"):format(v):gsub(" ", ""), - opts.mode_width, opts.keybind_width, opts.name_width)) + if v then + -- skips 'v == false' + table.insert(keymaps, + format_bind('action', k, + ("%s"):format(v):gsub(" ", ""), + opts.mode_width, opts.keybind_width, opts.name_width)) + end end end