1

Regenerate nvim config

This commit is contained in:
2024-06-02 03:29:20 +02:00
parent 75eea0c030
commit ef2e28883d
5576 changed files with 604886 additions and 503 deletions

View File

@ -0,0 +1,117 @@
local api = vim.api
local bit = require("bit")
-----------------------------------------------------------
-- Export
-----------------------------------------------------------
local M = {}
-----------------------------------------------------------
-- Helpers
-----------------------------------------------------------
---Convert a hex color to an rgb color
---@param color string
---@return number
---@return number
---@return number
local function to_rgb(color)
return tonumber(color:sub(2, 3), 16), tonumber(color:sub(4, 5), 16), tonumber(color:sub(6), 16)
end
-- SOURCE: https://stackoverflow.com/questions/5560248/programmatically-lighten-or-darken-a-hex-color-or-rgb-and-blend-colors
-- @see: https://stackoverflow.com/questions/37796287/convert-decimal-to-hex-in-lua-4
--- Shade Color generate
--- @param color string hex color
--- @param percent number
--- @return string
function M.shade_color(color, percent)
local r, g, b = to_rgb(color)
-- If any of the colors are missing return "NONE" i.e. no highlight
if not r or not g or not b then return "NONE" end
r = math.floor(tonumber(r * (100 + percent) / 100) or 0)
g = math.floor(tonumber(g * (100 + percent) / 100) or 0)
b = math.floor(tonumber(b * (100 + percent) / 100) or 0)
r, g, b = r < 255 and r or 255, g < 255 and g or 255, b < 255 and b or 255
return "#" .. string.format("%02x%02x%02x", r, g, b)
end
--- Determine whether to use black or white text
--- Ref:
--- 1. https://stackoverflow.com/a/1855903/837964
--- 2. https://stackoverflow.com/a/596243
function M.color_is_bright(hex)
if not hex then return false end
local r, g, b = to_rgb(hex)
-- If any of the colors are missing return false
if not r or not g or not b then return false end
-- Counting the perceptive luminance - human eye favors green color
local luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
-- If luminance is > 0.5 -> Bright colors, black font else Dark colors, white font
return luminance > 0.5
end
--- Get hex color
---@param name string highlight group name
---@param attr string attr name 'bg', 'fg'
---@return string
function M.get_hex(name, attr)
local ok, hl = pcall(api.nvim_get_hl_by_name, name, true)
if not ok then return "NONE" end
hl.foreground = hl.foreground and "#" .. bit.tohex(hl.foreground, 6)
hl.background = hl.background and "#" .. bit.tohex(hl.background, 6)
attr = ({ bg = "background", fg = "foreground" })[attr] or attr
return hl[attr] or "NONE"
end
--- Check if background is bright
--- @return boolean
function M.is_bright_background()
local bg_color = M.get_hex("Normal", "bg")
return M.color_is_bright(bg_color)
end
-----------------------------------------------------------
-- Darken Terminal
-----------------------------------------------------------
local function convert_attributes(result, key, value)
local target = result
if key == "cterm" then
result.cterm = {}
target = result.cterm
end
if value:find(",") then
for _, v in vim.split(value, ",") do
target[v] = true
end
else
target[value] = true
end
end
local function convert_options(opts)
local keys = {
gui = true,
guifg = "foreground",
guibg = "background",
guisp = "sp",
cterm = "cterm",
ctermfg = "ctermfg",
ctermbg = "ctermbg",
link = "link",
}
local result = {}
for key, value in pairs(opts) do
if keys[key] then
if key == "gui" or key == "cterm" then
if value ~= "NONE" then convert_attributes(result, key, value) end
else
result[keys[key]] = value
end
end
end
return result
end
function M.set_hl(name, opts) api.nvim_set_hl(0, name, convert_options(opts)) end
return M

View File

@ -0,0 +1,198 @@
local fn = vim.fn
local u = require("toggleterm.utils")
local M = {}
local p = {
single = "'(.-)'",
double = '"(.-)"',
}
local is_windows = vim.loop.os_uname().version:match("Windows")
---@class ParsedArgs
---@field direction string?
---@field cmd string?
---@field dir string?
---@field size number?
---@field name string?
---@field go_back boolean?
---@field open boolean?
---Take a users command arguments in the format "cmd='git commit' dir=~/dotfiles"
---and parse this into a table of arguments
---{cmd = "git commit", dir = "~/dotfiles"}
---@see https://stackoverflow.com/a/27007701
---@param args string
---@return ParsedArgs
function M.parse(args)
local result = {}
if args then
local quotes = args:match(p.single) and p.single or args:match(p.double) and p.double or nil
if quotes then
-- 1. extract the quoted command
local pattern = "(%S+)=" .. quotes
for key, value in args:gmatch(pattern) do
-- Check if the current OS is Windows so we can determine if +shellslash
-- exists and if it exists, then determine if it is enabled. In that way,
-- we can determine if we should match the value with single or double quotes.
if is_windows then
quotes = not vim.opt.shellslash:get() and quotes or p.single
else
quotes = p.single
end
value = fn.shellescape(value)
result[vim.trim(key)] = fn.expandcmd(value:match(quotes))
end
-- 2. then remove it from the rest of the argument string
args = args:gsub(pattern, "")
end
for _, part in ipairs(vim.split(args, " ")) do
if #part > 1 then
local arg = vim.split(part, "=")
local key, value = arg[1], arg[2]
if key == "size" then
value = tonumber(value)
elseif key == "go_back" or key == "open" then
value = value ~= "0"
end
result[key] = value
end
end
end
return result
end
-- Get a valid base path for a user provided path
-- and an optional search term
---@param typed_path string
---@return string|nil, string|nil
function M.get_path_parts(typed_path)
if vim.fn.isdirectory(typed_path ~= "" and typed_path or ".") == 1 then
-- The string is a valid path, we just need to drop trailing slashes to
-- ease joining the base path with the suggestions
return typed_path:gsub("/$", ""), nil
elseif typed_path:find("/", 2) ~= nil then
-- Maybe the typed path is looking for a nested directory
-- we need to make sure it has at least one slash in it, and that is not
-- from a root path
local base_path = vim.fn.fnamemodify(typed_path, ":h")
local search_term = vim.fn.fnamemodify(typed_path, ":t")
if vim.fn.isdirectory(base_path) then return base_path, search_term end
end
return nil, nil
end
local term_exec_options = {
--- Suggests commands
---@param typed_cmd string|nil
cmd = function(typed_cmd)
local paths = vim.split(vim.env.PATH, ":")
local commands = {}
for _, path in ipairs(paths) do
local glob_str
if string.match(path, "%s*") then
--path with spaces
glob_str = path:gsub(" ", "\\ ") .. "/" .. (typed_cmd or "") .. "*"
else
-- path without spaces
glob_str = path .. "/" .. (typed_cmd or "") .. "*"
end
local dir_cmds = vim.split(vim.fn.glob(glob_str), "\n")
for _, cmd in ipairs(dir_cmds) do
if not u.str_is_empty(cmd) then table.insert(commands, vim.fn.fnamemodify(cmd, ":t")) end
end
end
return commands
end,
--- Suggests paths in the cwd
---@param typed_path string
dir = function(typed_path)
-- Read the typed path as the base for the directory search
local base_path, search_term = M.get_path_parts(typed_path or "")
local safe_path = base_path ~= "" and base_path or "."
local paths = vim.fn.readdir(
safe_path,
function(entry) return vim.fn.isdirectory(safe_path .. "/" .. entry) end
)
if not u.str_is_empty(search_term) then
paths = vim.tbl_filter(
function(path) return path:match("^" .. search_term .. "*") ~= nil end,
paths
)
end
return vim.tbl_map(
function(path) return u.concat_without_empty({ base_path, path }, "/") end,
paths
)
end,
--- Suggests directions for the term
---@param typed_direction string
direction = function(typed_direction)
local directions = {
"float",
"horizontal",
"tab",
"vertical",
}
if u.str_is_empty(typed_direction) then return directions end
return vim.tbl_filter(
function(direction) return direction:match("^" .. typed_direction .. "*") ~= nil end,
directions
)
end,
--- The size param takes in arbitrary numbers, we keep this function only to
--- match the signature of other options
size = function() return {} end,
--- The name param takes in arbitrary strings, we keep this function only to
--- match the signature of other options
name = function() return {} end,
}
local toggle_term_options = {
dir = term_exec_options.dir,
direction = term_exec_options.direction,
size = term_exec_options.size,
name = term_exec_options.name,
}
---@param options table a dictionary of key to function
---@return fun(lead: string, command: string, _: number)
local function complete(options)
---@param lead string the leading portion of the argument currently being completed on
---@param command string the entire command line
---@param _ number the cursor position in it (byte index)
return function(lead, command, _)
local parts = vim.split(lead, "=")
local key = parts[1]
local value = parts[2]
if options[key] then
return vim.tbl_map(function(option) return key .. "=" .. option end, options[key](value))
end
local available_options = vim.tbl_filter(
function(option) return command:match(" " .. option .. "=") == nil end,
vim.tbl_keys(options)
)
table.sort(available_options)
return vim.tbl_map(function(option) return option .. "=" end, available_options)
end
end
--- See :h :command-completion-custom
M.term_exec_complete = complete(term_exec_options)
--- See :h :command-completion-custom
M.toggle_term_complete = complete(toggle_term_options)
return M

View File

@ -0,0 +1,146 @@
local colors = require("toggleterm.colors")
local constants = require("toggleterm.constants")
local utils = require("toggleterm.utils")
local M = {}
local fmt = string.format
local function shade(color, factor) return colors.shade_color(color, factor) end
--- @alias ToggleTermHighlights table<string, table<string, string>>
---@class WinbarOpts
---@field name_formatter fun(term: Terminal):string
---@field enabled boolean
--- @class ToggleTermConfig
--- @field size number
--- @field shade_filetypes string[]
--- @field hide_numbers boolean
--- @field open_mapping string | string[]
--- @field shade_terminals boolean
--- @field insert_mappings boolean
--- @field terminal_mappings boolean
--- @field start_in_insert boolean
--- @field persist_size boolean
--- @field persist_mode boolean
--- @field close_on_exit boolean
--- @field direction '"horizontal"' | '"vertical"' | '"float"'
--- @field shading_factor number
--- @field shell string|fun():string
--- @field auto_scroll boolean
--- @field float_opts table<string, any>
--- @field highlights ToggleTermHighlights
--- @field winbar WinbarOpts
--- @field autochdir boolean
--- @field title_pos '"left"' | '"center"' | '"right"'
---@type ToggleTermConfig
local config = {
size = 12,
shade_filetypes = {},
hide_numbers = true,
shade_terminals = true,
insert_mappings = true,
terminal_mappings = true,
start_in_insert = true,
persist_size = true,
persist_mode = true,
close_on_exit = true,
direction = "horizontal",
shading_factor = constants.shading_amount,
shell = vim.o.shell,
autochdir = false,
auto_scroll = true,
winbar = {
enabled = false,
name_formatter = function(term) return fmt("%d:%s", term.id, term:_display_name()) end,
},
float_opts = {
winblend = 0,
title_pos = "left",
},
}
---Derive the highlights for a toggleterm and merge these with the user's preferences
---A few caveats must be noted. Since I link the normal and float border to the Normal
---highlight this has to be done carefully as if the user has specified any Float highlights
---themselves merging will result in a mix of user highlights and the link key which is invalid
---so I check that they have not attempted to highlight these themselves. Also
---if they have chosen to shade the terminal then this takes priority over their own highlights
---since they can't have it both ways i.e. custom highlighting and shading
---@param conf ToggleTermConfig
---@return ToggleTermHighlights
local function get_highlights(conf)
local user = conf.highlights
local defaults = {
NormalFloat = vim.F.if_nil(user.NormalFloat, { link = "Normal" }),
FloatBorder = vim.F.if_nil(user.FloatBorder, { link = "Normal" }),
StatusLine = { gui = "NONE" },
StatusLineNC = { cterm = "italic", gui = "NONE" },
}
local overrides = {}
local nightly = utils.is_nightly()
local comment_fg = colors.get_hex("Comment", "fg")
local dir_fg = colors.get_hex("Directory", "fg")
local winbar_inactive_opts = { guifg = comment_fg }
local winbar_active_opts = { guifg = dir_fg, gui = "underline" }
if conf.shade_terminals then
local is_bright = colors.is_bright_background()
local degree = is_bright and -3 or 1
local amount = conf.shading_factor * degree
local normal_bg = colors.get_hex("Normal", "bg")
local terminal_bg = conf.shade_terminals and shade(normal_bg, amount) or normal_bg
overrides = {
Normal = { guibg = terminal_bg },
SignColumn = { guibg = terminal_bg },
EndOfBuffer = { guibg = terminal_bg },
StatusLine = { guibg = terminal_bg },
StatusLineNC = { guibg = terminal_bg },
}
-- TODO: Move this to the main overrides block once nvim 0.8 is stable
if nightly then
winbar_inactive_opts.guibg = terminal_bg
winbar_active_opts.guibg = terminal_bg
overrides.WinBarNC = { guibg = terminal_bg }
overrides.WinBar = { guibg = terminal_bg }
end
end
if nightly and conf.winbar.enabled then
colors.set_hl("WinBarActive", winbar_active_opts)
colors.set_hl("WinBarInactive", winbar_inactive_opts)
end
return vim.tbl_deep_extend("force", defaults, conf.highlights, overrides)
end
--- get the full user config or just a specified value
---@param key string?
---@return any
function M.get(key)
if key then return config[key] end
return config
end
function M.reset_highlights() config.highlights = get_highlights(config) end
---@param user_conf ToggleTermConfig
---@return ToggleTermConfig
function M.set(user_conf)
user_conf = user_conf or {}
user_conf.highlights = user_conf.highlights or {}
config = vim.tbl_deep_extend("force", config, user_conf)
config.highlights = get_highlights(config)
return config
end
---@return ToggleTermConfig
return setmetatable(M, {
__index = function(_, k) return config[k] end,
})

View File

@ -0,0 +1,11 @@
local M = {}
-----------------------------------------------------------
-- Constants
-----------------------------------------------------------
M.FILETYPE = "toggleterm"
-- -30 is a magic number based on manual testing of what looks good
M.shading_amount = -30
-- Highlight group name prefix
M.highlight_group_name_prefix = "ToggleTerm"
return M

View File

@ -0,0 +1,17 @@
local lazy = {}
--- Require on index.
---
--- Will only require the module after the first index of a module.
--- Only works for modules that export a table.
---@param require_path string
---@return table
lazy.require = function(require_path)
return setmetatable({}, {
__index = function(_, key) return require(require_path)[key] end,
__newindex = function(_, key, value) require(require_path)[key] = value end,
})
end
return lazy

View File

@ -0,0 +1,595 @@
local M = {}
local lazy = require("toggleterm.lazy")
---@module "toggleterm.ui"
local ui = lazy.require("toggleterm.ui")
---@module "toggleterm.config"
local config = lazy.require("toggleterm.config")
---@module "toggleterm.utils"
local utils = lazy.require("toggleterm.utils")
---@module "toggleterm.constants"
local constants = lazy.require("toggleterm.constants")
local api = vim.api
local fmt = string.format
local fn = vim.fn
local mode = {
INSERT = "i",
NORMAL = "n",
UNSUPPORTED = "?",
}
local AUGROUP = api.nvim_create_augroup("ToggleTermBuffer", { clear = true })
local is_windows = fn.has("win32") == 1
local function is_cmd(shell) return shell:find("cmd") end
local function is_pwsh(shell) return shell:find("pwsh") or shell:find("powershell") end
local function is_nushell(shell) return shell:find("nu") end
local function get_command_sep() return is_windows and is_cmd(vim.o.shell) and "&" or ";" end
local function get_comment_sep() return is_windows and is_cmd(vim.o.shell) and "::" or "#" end
local function get_newline_chr()
local shell = config.get("shell")
if type(shell) == "function" then shell = shell() end
if is_windows then
return is_pwsh(shell) and "\r" or "\r\n"
elseif is_nushell(shell) then
return "\r"
else
return "\n"
end
end
---@alias Mode "n" | "i" | "?"
--- @class TerminalState
--- @field mode Mode
---@type Terminal[]
local terminals = {}
--- @class TermCreateArgs
--- @field newline_chr? string user specified newline chararacter
--- @field cmd? string a custom command to run
--- @field direction? string the layout style for the terminal
--- @field id number?
--- @field highlights table<string, table<string, string>>?
--- @field dir string? the directory for the terminal
--- @field count number? the count that triggers that specific terminal
--- @field display_name string?
--- @field hidden boolean? whether or not to include this terminal in the terminals list
--- @field close_on_exit boolean? whether or not to close the terminal window when the process exits
--- @field auto_scroll boolean? whether or not to scroll down on terminal output
--- @field float_opts table<string, any>?
--- @field on_stdout fun(t: Terminal, job: number, data: string[]?, name: string?)?
--- @field on_stderr fun(t: Terminal, job: number, data: string[], name: string)?
--- @field on_exit fun(t: Terminal, job: number, exit_code: number?, name: string?)?
--- @field on_create fun(term:Terminal)?
--- @field on_open fun(term:Terminal)?
--- @field on_close fun(term:Terminal)?
--- @class Terminal
--- @field newline_chr string
--- @field cmd string
--- @field direction string the layout style for the terminal
--- @field id number
--- @field bufnr number
--- @field window number
--- @field job_id number
--- @field highlights table<string, table<string, string>>
--- @field dir string the directory for the terminal
--- @field name string the name of the terminal
--- @field count number the count that triggers that specific terminal
--- @field hidden boolean whether or not to include this terminal in the terminals list
--- @field close_on_exit boolean? whether or not to close the terminal window when the process exits
--- @field auto_scroll boolean? whether or not to scroll down on terminal output
--- @field float_opts table<string, any>?
--- @field display_name string?
--- @field env table<string, string> environmental variables passed to jobstart()
--- @field clear_env boolean use clean job environment, passed to jobstart()
--- @field on_stdout fun(t: Terminal, job: number, data: string[]?, name: string?)?
--- @field on_stderr fun(t: Terminal, job: number, data: string[], name: string)?
--- @field on_exit fun(t: Terminal, job: number, exit_code: number?, name: string?)?
--- @field on_create fun(term:Terminal)?
--- @field on_open fun(term:Terminal)?
--- @field on_close fun(term:Terminal)?
--- @field _display_name fun(term: Terminal): string
--- @field __state TerminalState
local Terminal = {}
--- Get the next available id based on the next number in the sequence that
--- hasn't already been allocated e.g. in a list of {1,2,5,6} the next id should
--- be 3 then 4 then 7
---@return integer
local function next_id()
local all = M.get_all(true)
for index, term in pairs(all) do
if index ~= term.id then return index end
end
return #all + 1
end
---Get an opened (valid) toggle terminal by id, defaults to the first opened
---@param position number?
---@return number?
function M.get_toggled_id(position)
position = position or 1
local t = M.get_all()
return t[position] and t[position].id or nil
end
---Return currently focused terminal id.
---@return number?
function M.get_focused_id()
for _, term in pairs(terminals) do
if term:is_focused() then return term.id end
end
return nil
end
function M.get_last_focused()
local last_focus = ui.get_terminal_view().focus_term_id
return M.get(last_focus, true)
end
--- @param bufnr number
local function setup_buffer_mappings(bufnr)
local mapping = config.open_mapping
if mapping and config.terminal_mappings then
utils.key_map("t", mapping, "<Cmd>ToggleTerm<CR>", { buffer = bufnr, silent = true })
end
end
---@param id number terminal id
local function on_vim_resized(id)
local term = M.get(id, true)
if not term or not term:is_float() or not term:is_open() then return end
ui.update_float(term)
end
--- Remove the in memory reference to the no longer open terminal
--- @param num number
local function delete(num)
if terminals[num] then terminals[num] = nil end
end
---Terminal buffer autocommands
---@param term Terminal
local function setup_buffer_autocommands(term)
api.nvim_create_autocmd("TermClose", {
buffer = term.bufnr,
group = AUGROUP,
callback = function() delete(term.id) end,
})
if term:is_float() then
api.nvim_create_autocmd("VimResized", {
buffer = term.bufnr,
group = AUGROUP,
callback = function() on_vim_resized(term.id) end,
})
end
if config.start_in_insert then
-- Avoid entering insert mode when spawning terminal in the background
if term.window == api.nvim_get_current_win() then vim.cmd("startinsert") end
end
end
---get the directory for the terminal parsing special arguments
---@param dir string?
---@return string
local function _get_dir(dir)
if dir == "git_dir" then dir = utils.git_dir() end
if dir then
return fn.expand(dir)
else
return vim.loop.cwd()
end
end
---Create a new terminal object
---@param term TermCreateArgs?
---@return Terminal
function Terminal:new(term)
term = term or {}
--- If we try to create a new terminal, but the id is already
--- taken, return the terminal with the containing id
local id = term.count or term.id
if id and terminals[id] then return terminals[id] end
local conf = config.get()
self.__index = self
term.newline_chr = term.newline_chr or get_newline_chr()
term.direction = term.direction or conf.direction
term.id = id or next_id()
term.display_name = term.display_name
term.float_opts = vim.tbl_deep_extend("keep", term.float_opts or {}, conf.float_opts)
term.clear_env = term.clear_env
term.auto_scroll = vim.F.if_nil(term.auto_scroll, conf.auto_scroll)
term.env = vim.F.if_nil(term.env, conf.env)
term.hidden = vim.F.if_nil(term.hidden, false)
term.on_create = vim.F.if_nil(term.on_create, conf.on_create)
term.on_open = vim.F.if_nil(term.on_open, conf.on_open)
term.on_close = vim.F.if_nil(term.on_close, conf.on_close)
term.on_stdout = vim.F.if_nil(term.on_stdout, conf.on_stdout)
term.on_stderr = vim.F.if_nil(term.on_stderr, conf.on_stderr)
term.on_exit = vim.F.if_nil(term.on_exit, conf.on_exit)
term.__state = { mode = "?" }
if term.close_on_exit == nil then term.close_on_exit = conf.close_on_exit end
-- Add the newly created terminal to the list of all terminals
---@diagnostic disable-next-line: return-type-mismatch
return setmetatable(term, self)
end
---@package
---Add a terminal to the list of terminals
function Terminal:__add()
if terminals[self.id] and terminals[self.id] ~= self then self.id = next_id() end
if not terminals[self.id] then terminals[self.id] = self end
return self
end
function Terminal:is_float() return self.direction == "float" and ui.is_float(self.window) end
function Terminal:is_split()
return (self.direction == "vertical" or self.direction == "horizontal")
and not ui.is_float(self.window)
end
function Terminal:is_tab() return self.direction == "tab" and not ui.is_float(self.window) end
function Terminal:resize(size)
if self:is_split() then ui.resize_split(self, size) end
end
function Terminal:is_open()
if not self.window then return false end
local win_type = fn.win_gettype(self.window)
-- empty string window type corresponds to a normal window
local win_open = win_type == "" or win_type == "popup"
return win_open and api.nvim_win_get_buf(self.window) == self.bufnr
end
---@package
function Terminal:__restore_mode() self:set_mode(self.__state.mode) end
--- Set the terminal's mode
---@param m Mode
function Terminal:set_mode(m)
if m == mode.INSERT then
vim.cmd("startinsert")
elseif m == mode.NORMAL then
vim.cmd("stopinsert")
elseif m == mode.UNSUPPORTED and config.get("start_in_insert") then
vim.cmd("startinsert")
end
end
function Terminal:persist_mode()
local raw_mode = api.nvim_get_mode().mode
local m = "?"
if raw_mode:match("nt") then -- nt is normal mode in the terminal
m = mode.NORMAL
elseif raw_mode:match("t") then -- t is insert mode in the terminal
m = mode.INSERT
end
self.__state.mode = m
end
---@package
function Terminal:_display_name() return self.display_name or vim.split(self.name, ";")[1] end
function Terminal:close()
if self.on_close then self:on_close() end
ui.close(self)
ui.stopinsert()
ui.update_origin_window(self.window)
end
function Terminal:shutdown()
if self:is_open() then self:close() end
ui.delete_buf(self)
delete(self.id)
end
---Combine arguments into strings separated by new lines
---@vararg string
---@param newline_chr string
---@return string
local function with_cr(newline_chr, ...)
local result = {}
for _, str in ipairs({ ... }) do
table.insert(result, str .. newline_chr)
end
return table.concat(result, "")
end
function Terminal:scroll_bottom()
if not api.nvim_buf_is_loaded(self.bufnr) or not api.nvim_buf_is_valid(self.bufnr) then return end
if ui.term_has_open_win(self) then api.nvim_buf_call(self.bufnr, ui.scroll_to_bottom) end
end
function Terminal:is_focused() return self.window == api.nvim_get_current_win() end
function Terminal:focus()
if ui.term_has_open_win(self) then api.nvim_set_current_win(self.window) end
end
---Send a command to a running terminal
---@param cmd string|string[]
---@param go_back boolean? whether or not to return to original window
function Terminal:send(cmd, go_back)
cmd = type(cmd) == "table" and with_cr(self.newline_chr, unpack(cmd))
or with_cr(self.newline_chr, cmd --[[@as string]])
fn.chansend(self.job_id, cmd)
self:scroll_bottom()
if go_back and self:is_focused() then
ui.goto_previous()
ui.stopinsert()
elseif not go_back and not self:is_focused() then
self:focus()
end
end
--check for os type and perform os specific clear command
function Terminal:clear()
local clear = is_windows and "cls" or "clear"
self:send(clear)
end
---Update the directory of an already opened terminal
---@param dir string
function Terminal:change_dir(dir, go_back)
dir = _get_dir(dir)
if self.dir == dir then return end
self:send({ fmt("cd %s", dir), self:clear() }, go_back)
self.dir = dir
end
---Update the direction of an already opened terminal
---@param direction string
function Terminal:change_direction(direction)
self.direction = direction
self.window = nil
end
--- Handle when a terminal process exits
---@param term Terminal
local function __handle_exit(term)
return function(...)
if term.on_exit then term:on_exit(...) end
if term.close_on_exit then
term:close()
if api.nvim_buf_is_loaded(term.bufnr) then
api.nvim_buf_delete(term.bufnr, { force = true })
end
end
end
end
---@private
---Prepare callback for terminal output handling
---If `auto_scroll` is active, will create a handler that scrolls on terminal output
---If `handler` is present, will call it passing `self` as the first parameter
---If none of the above is applicable, will not return a handler
---@param handler function? a custom callback function for output handling
function Terminal:__make_output_handler(handler)
if self.auto_scroll or handler then
return function(...)
if self.auto_scroll then self:scroll_bottom() end
if handler then handler(self, ...) end
end
end
end
---@private
function Terminal:__spawn()
local cmd = self.cmd or config.get("shell")
if type(cmd) == "function" then cmd = cmd() end
local command_sep = get_command_sep()
local comment_sep = get_comment_sep()
cmd = table.concat({
cmd,
command_sep,
comment_sep,
constants.FILETYPE,
comment_sep,
self.id,
})
local dir = _get_dir(self.dir)
self.job_id = fn.termopen(cmd, {
detach = 1,
cwd = dir,
on_exit = __handle_exit(self),
on_stdout = self:__make_output_handler(self.on_stdout),
on_stderr = self:__make_output_handler(self.on_stderr),
env = self.env,
clear_env = self.clear_env,
})
self.name = cmd
self.dir = dir
end
---@package
---Add an orphaned terminal to the list of terminal and re-apply settings
function Terminal:__resurrect()
self:__add()
if self:is_split() then ui.resize_split(self) end
-- set the window options including fixing height or width once the window is resized
self:__set_options()
ui.hl_term(self)
end
---@package
function Terminal:__set_ft_options()
local buf = vim.bo[self.bufnr]
buf.filetype = constants.FILETYPE
buf.buflisted = false
end
---@package
function Terminal:__set_win_options()
if self:is_split() then
local field = self.direction == "vertical" and "winfixwidth" or "winfixheight"
utils.wo_setlocal(self.window, field, true)
end
if config.hide_numbers then
utils.wo_setlocal(self.window, "number", false)
utils.wo_setlocal(self.window, "relativenumber", false)
end
end
---@package
function Terminal:__set_options()
self:__set_ft_options()
self:__set_win_options()
vim.b[self.bufnr].toggle_number = self.id
end
---Open a terminal in a type of window i.e. a split,full window or tab
---@param size number
---@param term table
local function opener(size, term)
local direction = term.direction
if term:is_split() then
ui.open_split(size, term)
elseif direction == "tab" then
ui.open_tab(term)
elseif direction == "float" then
ui.open_float(term)
else
error("Invalid terminal direction")
end
end
---Spawn terminal background job in a buffer without a window
function Terminal:spawn()
if not self.bufnr or not api.nvim_buf_is_valid(self.bufnr) then self.bufnr = ui.create_buf() end
self:__add()
if api.nvim_get_current_buf() ~= self.bufnr then
api.nvim_buf_call(self.bufnr, function() self:__spawn() end)
else
self:__spawn()
end
setup_buffer_autocommands(self)
setup_buffer_mappings(self.bufnr)
if self.on_create then self:on_create() end
end
---Open a terminal window
---@param size number?
---@param direction string?
function Terminal:open(size, direction)
local cwd = fn.getcwd()
self.dir = _get_dir(config.autochdir and cwd or self.dir)
ui.set_origin_window()
if direction then self:change_direction(direction) end
if not self.bufnr or not api.nvim_buf_is_valid(self.bufnr) then
local ok, err = pcall(opener, size, self)
if not ok and err then return utils.notify(err, "error") end
self:spawn()
else
local ok, err = pcall(opener, size, self)
if not ok and err then return utils.notify(err, "error") end
ui.switch_buf(self.bufnr)
if config.autochdir and self.dir ~= cwd then self:change_dir(cwd) end
end
ui.hl_term(self)
-- NOTE: it is important that this function is called at this point. i.e. the buffer has been correctly assigned
if self.on_open then self:on_open() end
end
---Open if closed and close if opened
---@param size number?
---@param direction string?
function Terminal:toggle(size, direction)
if self:is_open() then
self:close()
else
self:open(size, direction)
end
return self
end
--- get the toggle term number from
--- the name e.g. term://~/.dotfiles//3371887:/usr/bin/zsh;#toggleterm#1
--- the number in this case is 1
--- @param name string?
--- @return number?
--- @return Terminal?
function M.identify(name)
name = name or api.nvim_buf_get_name(api.nvim_get_current_buf())
local comment_sep = get_comment_sep()
local parts = vim.split(name, comment_sep)
local id = tonumber(parts[#parts])
return id, terminals[id]
end
---get existing terminal or create an empty term table
---@param num number?
---@param dir string?
---@param direction string?
---@param name string?
---@return Terminal
---@return boolean
function M.get_or_create_term(num, dir, direction, name)
local term = M.get(num)
if term then return term, false end
if dir and fn.isdirectory(fn.expand(dir)) == 0 then dir = nil end
return Terminal:new({ id = num, dir = dir, direction = direction, display_name = name }), true
end
---Get a single terminal by id, unless it is hidden
---@param id number?
---@param include_hidden boolean? whether or nor to filter out hidden
---@return Terminal?
function M.get(id, include_hidden)
local term = terminals[id]
return (term and (include_hidden == true or not term.hidden)) and term or nil
end
---Get the first terminal that matches a predicate
---@param predicate fun(term: Terminal): boolean
---@return Terminal?
function M.find(predicate)
if type(predicate) ~= "function" then
utils.notify("terminal.find expects a function, got " .. type(predicate), "error")
return
end
for _, term in pairs(terminals) do
if predicate(term) then return term end
end
return nil
end
---Return the potentially non contiguous map of terminals as a sorted array
---@param include_hidden boolean? whether or nor to filter out hidden
---@return Terminal[]
function M.get_all(include_hidden)
local result = {}
for _, v in pairs(terminals) do
if include_hidden or (not include_hidden and not v.hidden) then table.insert(result, v) end
end
table.sort(result, function(a, b) return a.id < b.id end)
return result
end
if _G.IS_TEST then
function M.__reset()
for _, term in pairs(terminals) do
term:shutdown()
end
end
M.__next_id = next_id
end
M.Terminal = Terminal
M.mode = mode
return M

View File

@ -0,0 +1,472 @@
local M = {}
local lazy = require("toggleterm.lazy")
---@module "toggleterm.constants"
local constants = lazy.require("toggleterm.constants")
---@module "toggleterm.utils"
local utils = lazy.require("toggleterm.utils")
---@module "toggleterm.colors"
local colors = lazy.require("toggleterm.colors")
---@module "toggleterm.config"
local config = lazy.require("toggleterm.config")
---@module "toggleterm.terminal"
local terms = lazy.require("toggleterm.terminal")
local fn = vim.fn
local fmt = string.format
local api = vim.api
local origin_window
local persistent = {}
---@alias TerminalView {terminals: number[], focus_term_id: number}
---@type TerminalView
local terminal_view = {
---@type number[]
-- A list of terminal IDs that are saved from the view on smart toggle.
terminals = {},
---@type number
---Last focused terminal ID in the view.
focus_term_id = nil,
}
--- @class TerminalWindow
--- @field term_id number ID for the terminal in the window
--- @field window number window handle
--
--- Save the size of a split window before it is hidden
--- @param direction string
--- @param window number
function M.save_window_size(direction, window)
if direction == "horizontal" then
persistent.horizontal = api.nvim_win_get_height(window)
elseif direction == "vertical" then
persistent.vertical = api.nvim_win_get_width(window)
end
end
--- Explicitly set the persistent size of a direction
--- @param direction string
--- @param size number
function M.save_direction_size(direction, size) persistent[direction] = size end
--- @param direction string
--- @return boolean
function M.has_saved_size(direction) return persistent[direction] ~= nil end
--- Get the size of the split. Order of priority is as follows:
--- 1. The size argument is a valid number > 0
--- 2. There is persistent width/height information from prev open state
--- 3. Default/base case config size
---
--- If `config.persist_size = false` then option `2` in the
--- list is skipped.
--- @param size number?
--- @param direction string?
function M.get_size(size, direction)
local valid_size = size ~= nil and size > 0
if not config.persist_size then return valid_size and size or config.size end
return valid_size and size or persistent[direction] or config.size
end
local function hl(name) return "%#" .. name .. "#" end
local hl_end = "%*"
--- Create terminal window bar
---@param id number
---@return string
function M.winbar(id)
local terms = require("toggleterm.terminal").get_all()
local str = " "
for _, t in pairs(terms) do
local h = id == t.id and "WinBarActive" or "WinBarInactive"
str = str
.. fmt("%%%d@v:lua.___toggleterm_winbar_click@", t.id)
.. hl(h)
.. config.winbar.name_formatter(t)
.. hl_end
.. " "
end
return str
end
---@param term Terminal?
function M.set_winbar(term)
if
not config.winbar.enabled
or not term
or term:is_float() -- TODO: make this configurable
or fn.exists("+winbar") ~= 1
or not term.window
or not api.nvim_win_is_valid(term.window)
then
return
end
local value = fmt('%%{%%v:lua.require("toggleterm.ui").winbar(%d)%%}', term.id)
utils.wo_setlocal(term.window, "winbar", value)
end
---apply highlights to a terminal
---if no term is passed in we use default values instead
---@param term Terminal?
function M.hl_term(term)
local hls = (term and term.highlights and not vim.tbl_isempty(term.highlights))
and term.highlights
or config.highlights
if not hls or vim.tbl_isempty(hls) then return end
local window = term and term.window or api.nvim_get_current_win()
local id = term and term.id or "Default"
local is_float = M.is_float(window)
-- If the terminal is a floating window we only want to set the background and border
-- not the statusline etc. which are not applicable to floating windows
local hl_names = vim.tbl_filter(
function(name)
return not is_float or (is_float and vim.tbl_contains({ "FloatBorder", "NormalFloat" }, name))
end,
vim.tbl_keys(hls)
)
local highlights = vim.tbl_map(function(hl_group_name)
local name = constants.highlight_group_name_prefix .. id .. hl_group_name
local hi_target = fmt("%s:%s", hl_group_name, name)
local attrs = hls[hl_group_name]
attrs.default = true
colors.set_hl(name, attrs)
return hi_target
end, hl_names)
utils.wo_setlocal(window, "winhighlight", table.concat(highlights, ","))
end
---Create a terminal buffer with the correct buffer/window options
---then set it to current window
---@param term Terminal
local function create_term_buf_if_needed(term)
local valid_win = term.window and api.nvim_win_is_valid(term.window)
local window = valid_win and term.window or api.nvim_get_current_win()
-- If the buffer doesn't exist create a new one
local valid_buf = term.bufnr and api.nvim_buf_is_valid(term.bufnr)
local bufnr = valid_buf and term.bufnr or api.nvim_create_buf(false, false)
-- Assign buf to window to ensure window options are set correctly
api.nvim_win_set_buf(window, bufnr)
term.window, term.bufnr = window, bufnr
term:__set_options()
api.nvim_set_current_buf(bufnr)
end
function M.create_buf() return api.nvim_create_buf(false, false) end
function M.delete_buf(term)
if term.bufnr and api.nvim_buf_is_valid(term.bufnr) then
api.nvim_buf_delete(term.bufnr, { force = true })
end
end
function M.set_origin_window() origin_window = api.nvim_get_current_win() end
function M.get_origin_window() return origin_window end
function M.update_origin_window(term_window)
local curr_win = api.nvim_get_current_win()
if term_window ~= curr_win then origin_window = curr_win end
end
function M.scroll_to_bottom()
local info = vim.api.nvim_get_mode()
if info and (info.mode == "n" or info.mode == "nt") then vim.cmd("normal! G") end
end
function M.goto_previous() vim.cmd("wincmd p") end
function M.stopinsert() vim.cmd("stopinsert!") end
---@param buf integer
---@return boolean
local function default_compare(buf)
return vim.bo[buf].filetype == constants.FILETYPE or vim.b[buf].toggle_number ~= nil
end
--- Find the first open terminal window
--- by iterating all windows and matching the
--- containing buffers filetype with the passed in
--- comparator function or the default which matches
--- the filetype
--- @param comparator function?
--- @return boolean, TerminalWindow[]
function M.find_open_windows(comparator)
comparator = comparator or default_compare
local term_wins, is_open = {}, false
for _, tab in ipairs(api.nvim_list_tabpages()) do
for _, win in pairs(api.nvim_tabpage_list_wins(tab)) do
local buf = api.nvim_win_get_buf(win)
if comparator(buf) then
is_open = true
table.insert(term_wins, { window = win, term_id = vim.b[buf].toggle_number })
end
end
end
return is_open, term_wins
end
---Switch to the given buffer without changing the alternate
---@param buf number
function M.switch_buf(buf)
-- don't change the alternate buffer so that <c-^><c-^> does nothing in the terminal split
local cur_buf = api.nvim_get_current_buf()
if cur_buf ~= buf then vim.cmd(fmt("keepalt buffer %d", buf)) end
end
local split_commands = {
horizontal = {
existing = "rightbelow vsplit",
new = "botright split",
resize = "resize",
},
vertical = {
existing = "rightbelow split",
new = "botright vsplit",
resize = "vertical resize",
},
}
---Guess whether or not the window is a horizontal or vertical split
---this only works if either of the two are full size
---@return string?
function M.guess_direction()
-- current window is full height vertical split
-- NOTE: add one for tabline and one for status
local ui_lines = (vim.o.tabline ~= "" and 1 or 0) + (vim.o.laststatus ~= 0 and 1 or 0)
if api.nvim_win_get_height(0) + vim.o.cmdheight + ui_lines == vim.o.lines then
return "vertical"
end
-- current window is full width horizontal split
if api.nvim_win_get_width(0) == vim.o.columns then return "horizontal" end
return nil
end
--- @private
--- @param size number|function
--- @param term Terminal?
--- @return number?
function M._resolve_size(size, term)
if not size then
return
elseif type(size) == "number" then
return size
elseif term and type(size) == "function" then
return size(term)
end
utils.notify(fmt('The input %s is not of type "number" or "function".', size), "error")
end
local curved = { "", "", "", "", "", "", "", "" }
--- @private
--- @param term Terminal
--- @param opening boolean
function M._get_float_config(term, opening)
local opts = term.float_opts or {}
local border = opts.border == "curved" and curved or opts.border or "single"
local width = math.ceil(math.min(vim.o.columns, math.max(80, vim.o.columns - 20)))
local height = math.ceil(math.min(vim.o.lines, math.max(20, vim.o.lines - 10)))
width = vim.F.if_nil(M._resolve_size(opts.width, term), width)
height = vim.F.if_nil(M._resolve_size(opts.height, term), height)
local row = math.ceil(vim.o.lines - height) * 0.5 - 1
local col = math.ceil(vim.o.columns - width) * 0.5 - 1
row = vim.F.if_nil(M._resolve_size(opts.row, term), row)
col = vim.F.if_nil(M._resolve_size(opts.col, term), col)
local version = vim.version()
local float_config = {
row = row,
col = col,
relative = opts.relative or "editor",
style = opening and "minimal" or nil,
width = width,
height = height,
border = opening and border or nil,
zindex = opts.zindex or nil,
}
if version.major > 0 or version.minor >= 9 then
float_config.title_pos = term.display_name and opts.title_pos or nil
float_config.title = term.display_name
end
return float_config
end
--- @param size number
--- @param term Terminal
function M.open_split(size, term)
local has_open, windows = M.find_open_windows()
local commands = split_commands[term.direction]
if has_open then
-- we need to be in the terminal window most recently opened
-- in order to split it
local split_win = windows[#windows]
if config.persist_size then M.save_window_size(term.direction, split_win.window) end
api.nvim_set_current_win(split_win.window)
vim.cmd(commands.existing)
else
vim.cmd(commands.new)
end
M.resize_split(term, size)
create_term_buf_if_needed(term)
end
--- @param term Terminal
function M.open_tab(term)
-- Open the current buffer in a tab (use tabnew due to issue #95)
vim.cmd("tabedit new")
-- tabnew creates an empty no name buffer so we set it to be wiped once it's replaced
-- by the terminal buffer
vim.bo.bufhidden = "wipe"
-- Replace the current window with a tab
create_term_buf_if_needed(term)
end
---@param term Terminal
local function close_tab(term)
if #vim.api.nvim_list_tabpages() == 1 then
return utils.notify("You cannot close the last tab! This will exit neovim", "error")
end
api.nvim_win_close(term.window, true)
end
---Close terminal window
---@param term Terminal
local function close_split(term)
if term.window and api.nvim_win_is_valid(term.window) then
local persist_size = require("toggleterm.config").get("persist_size")
if persist_size then M.save_window_size(term.direction, term.window) end
api.nvim_win_close(term.window, true)
end
if origin_window and api.nvim_win_is_valid(origin_window) then
api.nvim_set_current_win(origin_window)
else
origin_window = nil
end
end
---Open a floating window
---@param term Terminal
function M.open_float(term)
local opts = term.float_opts or {}
local valid_buf = term.bufnr and api.nvim_buf_is_valid(term.bufnr)
local buf = valid_buf and term.bufnr or api.nvim_create_buf(false, false)
local win = api.nvim_open_win(buf, true, M._get_float_config(term, true))
term.window, term.bufnr = win, buf
-- partial fix for #391
utils.wo_setlocal(win, "sidescrolloff", 0)
if opts.winblend then utils.wo_setlocal(win, "winblend", opts.winblend) end
term:__set_options()
end
---Updates the floating terminal size
---@param term Terminal
function M.update_float(term)
if not vim.api.nvim_win_is_valid(term.window) then return end
vim.api.nvim_win_set_config(term.window, M._get_float_config(term, false))
end
---Close given terminal's ui
---@param term Terminal
function M.close(term)
if term:is_split() then
close_split(term)
elseif term:is_tab() then
close_tab(term)
elseif term.window and api.nvim_win_is_valid(term.window) then
api.nvim_win_close(term.window, true)
end
end
---Resize a split window
---@param term Terminal
---@param size number?
function M.resize_split(term, size)
size = M._resolve_size(M.get_size(size, term.direction), term)
if config.persist_size and size then M.save_direction_size(term.direction, size) end
vim.cmd(split_commands[term.direction].resize .. " " .. size)
end
---Determine if a window is a float
---@param window number
function M.is_float(window) return fn.win_gettype(window) == "popup" end
--- @param bufnr number
function M.find_windows_by_bufnr(bufnr) return fn.win_findbuf(bufnr) end
---Return whether or not the terminal passed in has an open window
---@param term Terminal
---@return boolean
function M.term_has_open_win(term)
if not term.window then return false end
local wins = {}
for _, tab in ipairs(api.nvim_list_tabpages()) do
vim.list_extend(wins, api.nvim_tabpage_list_wins(tab))
end
return vim.tbl_contains(wins, term.window)
end
---Close and save terminals that are currently in view.
---@param windows TerminalWindow[]
function M.close_and_save_terminal_view(windows)
local terminals = {}
local focused_term_id = terms.get_focused_id()
-- NOTE: Use windows to close terminals in order they are being shown on
-- the view.
for _, window in pairs(windows) do
local term = terms.get(window.term_id)
if term then
table.insert(terminals, term.id)
term:close()
end
end
M.save_terminal_view(terminals, focused_term_id)
end
---Open terminals that were saved in the last terminal view.
---@return boolean
function M.open_terminal_view(size, direction)
local opened = false
if not vim.tbl_isempty(terminal_view.terminals) then
for _, term_id in pairs(terminal_view.terminals) do
local term = terms.get(term_id)
if term then
term:open(size, direction)
opened = true
end
end
local focus_term = terms.get(terminal_view.focus_term_id)
if focus_term then focus_term:focus() end
M.save_terminal_view({}, nil)
end
return opened
end
---Save the terminal view with the just closed terminals and the previously
--focused terminal.
---@param terminals number[]
---@param focus_term_id number?
function M.save_terminal_view(terminals, focus_term_id)
terminal_view = { terminals = terminals, focus_term_id = focus_term_id }
end
---@return TerminalView
function M.get_terminal_view() return terminal_view end
return M

View File

@ -0,0 +1,135 @@
local M = {}
local fn, api, opt = vim.fn, vim.api, vim.opt
local fmt = string.format
local levels = vim.log.levels
function M.is_nightly()
local v = vim.version()
return v.minor >= 8
end
---@alias error_types 'error' | 'info' | 'warn'
---Inform a user about something
---@param msg string
---@param level error_types
function M.notify(msg, level)
local err = level:upper()
level = level and levels[err] or levels.INFO
vim.schedule(function() vim.notify(msg, level, { title = "Toggleterm" }) end)
end
---@private
---Helper function to derive the current git directory path
---@return string|nil
function M.git_dir()
local gitdir = fn.system(fmt("git -C %s rev-parse --show-toplevel", fn.expand("%:p:h")))
local isgitdir = fn.matchstr(gitdir, "^fatal:.*") == ""
if not isgitdir then return end
return vim.trim(gitdir)
end
---@param str string|nil
---@return boolean
function M.str_is_empty(str) return str == nil or str == "" end
---@param tbl table
---@return table
function M.tbl_filter_empty(tbl)
return vim.tbl_filter(
---@param str string|nil
function(str) return not M.str_is_empty(str) end,
tbl
)
end
--- Concats a table ignoring empty entries
---@param tbl table
---@param sep string
function M.concat_without_empty(tbl, sep) return table.concat(M.tbl_filter_empty(tbl), sep) end
-- Key mapping function
---@param mod string | string[]
---@param lhs string | string[]
---@param rhs string | function
---@param opts table?
function M.key_map(mod, lhs, rhs, opts)
if type(lhs) == "string" then
vim.keymap.set(mod, lhs, rhs, opts)
elseif type(lhs) == "table" then
for _, key in pairs(lhs) do
vim.keymap.set(mod, key, rhs, opts)
end
end
end
---@param mode "visual" | "motion"
---@return table
function M.get_line_selection(mode)
local start_char, end_char = unpack(({
visual = { "'<", "'>" },
motion = { "'[", "']" },
})[mode])
-- '< marks are only updated when one leaves visual mode.
-- When calling lua functions directly from a mapping, need to
-- explicitly exit visual with the escape key to ensure those marks are
-- accurate.
vim.cmd("normal! ")
-- Get the start and the end of the selection
local start_line, start_col = unpack(fn.getpos(start_char), 2, 3)
local end_line, end_col = unpack(fn.getpos(end_char), 2, 3)
local selected_lines = api.nvim_buf_get_lines(0, start_line - 1, end_line, false)
return {
start_pos = { start_line, start_col },
end_pos = { end_line, end_col },
selected_lines = selected_lines,
}
end
function M.get_visual_selection(res, motion)
motion = motion or false
local mode = fn.visualmode()
if motion then mode = "v" end
-- line-visual
-- return lines encompassed by the selection; already in res object
if mode == "V" then return res.selected_lines end
if mode == "v" then
-- regular-visual
-- return the buffer text encompassed by the selection
local start_line, start_col = unpack(res.start_pos)
local end_line, end_col = unpack(res.end_pos)
-- exclude the last char in text if "selection" is set to "exclusive"
if opt.selection:get() == "exclusive" then end_col = end_col - 1 end
return api.nvim_buf_get_text(0, start_line - 1, start_col - 1, end_line - 1, end_col, {})
end
-- block-visual
-- return the lines encompassed by the selection, each truncated by the start and end columns
if mode == "\x16" then
local _, start_col = unpack(res.start_pos)
local _, end_col = unpack(res.end_pos)
-- exclude the last col of the block if "selection" is set to "exclusive"
if opt.selection:get() == "exclusive" then end_col = end_col - 1 end
-- exchange start and end columns for proper substring indexing if needed
-- e.g. instead of str:sub(10, 5), do str:sub(5, 10)
if start_col > end_col then
start_col, end_col = end_col, start_col
end
-- iterate over lines, truncating each one
return vim.tbl_map(function(line) return line:sub(start_col, end_col) end, res.selected_lines)
end
end
--- Sets a local window option, like `:setlocal`
--- TODO: replace with double-indexing on `vim.wo` when neovim/neovim#20288 (hopefully) merges
---@param win number
---@param option string
---@param value any
function M.wo_setlocal(win, option, value)
api.nvim_set_option_value(option, value, { scope = "local", win = win })
end
return M