Files
jj.nvim/lua/jj/core/buffer.lua
T
Jonas ElhsandGitHub 46141a282e feat!(terminal): unify terminal window behavior and centralize sizing config (#102)
- add terminal.window.type support (hsplit, vsplit, floating, tab)
- add terminal.window.split_size for split-based layouts
- rename floating size options to terminal.window.floating_width/floating_height
- validate terminal window ratios during setup
- make terminal.run/run_floating merge default keymaps internally
- remove cmd.split width/height sizing in favor of terminal.window config
- improve floating/log buffer state handling and refresh behavior
- update README examples and configuration docs

BREAKING CHANGE: terminal sizing/config moved to terminal.window.*.
Removed cmd.split.width/cmd.split.height.
Renamed terminal.window.width/height to terminal.window.floating_width/floating_height
2026-04-07 10:31:12 +02:00

471 lines
14 KiB
Lua

--- @class jj.core.buffer
local M = {}
--- @class jj.core.buffer.opts
--- @field name? string Buffer name
--- @field split? "horizontal"|"vertical"|"tab"|"current" Split type (default: "horizontal")
--- @field direction? "left"|"right"|"top"|"bottom" Split direction (left/right for vertical, top/bottom for horizontal)
--- @field size? number Split size in lines/columns
--- @field modifiable? boolean Whether buffer is modifiable (default: true)
--- @field filetype? string Filetype to set
--- @field buftype? string Buffer type (e.g., "nofile", "acwrite", etc. - optional, defaults to scratch buffer)
--- @field bufhidden? string Buffer hidden behavior (default: "hide")
--- @field on_exit? fun(buf: number) Callback when buffer is closed
--- @field keymaps? jj.core.buffer.keymap[] Keymaps to set on the buffer
--- @field win_options? table Window-specific options to set
--- @class jj.core.buffer.keymap
--- @field modes? string|string[] Modes for the keymap (default: "n")
--- @field mode? string Alias for modes
--- @field lhs string Left-hand side of the keymap
--- @field rhs string|fun() Right-hand side of the keymap (string or function
--- @field opts? table Additional keymap options
--- @class jj.core.buffer.float_opts
--- @field width? number Window width (default: 80% of columns)
--- @field height? number Window height (default: 80% of lines)
--- @field row? number Window row position (default: centered)
--- @field col? number Window column position (default: centered)
--- @field relative? string Relative positioning (default: "editor")
--- @field style? string Window style (default: "minimal")
--- @field border? string Border style (default: "rounded")
--- @field title? string Window title (default: none)
--- @field title_pos? string Title position (default: "center")
--- @field enter? boolean Whether to enter the window after creation (default: false)
--- @field modifiable? boolean Whether buffer is modifiable (default: true)
--- @field filetype? string Filetype to set
--- @field buftype? string Buffer type (e.g., "nofile", "acwrite", etc. - optional, defaults to scratch buffer)
--- @field bufhidden? string Buffer hidden behavior (default: "hide")
--- @field on_exit? fun(buf: number) Callback when buffer is closed
--- @field keymaps? jj.core.buffer.keymap[] Keymaps to set on the buffer
--- @field win_options? table Window-specific options to set
--- @field zindex? number Stacking order (default: 50)
--- Create and configure a new buffer
--- @param opts jj.core.buffer.opts Buffer configuration options
--- @return number buf Buffer handle
--- @return number? win Window handle (nil if using current window)
function M.create(opts)
opts = opts or {}
local win = nil
-- Handle window/split creation
local buf
if opts.split == "vertical" then
local direction = opts.direction or "right"
local width = opts.size or math.floor(vim.o.columns / 2)
if direction == "left" then
vim.cmd("leftabove vsplit")
else
vim.cmd("vsplit")
end
vim.cmd(string.format("vertical resize %d", width))
win = vim.api.nvim_get_current_win()
buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_win_set_buf(win, buf)
elseif opts.split == "tab" then
vim.cmd("tabnew")
win = vim.api.nvim_get_current_win()
-- Use the buffer that tabnew created
buf = vim.api.nvim_get_current_buf()
elseif opts.split == "current" then
win = vim.api.nvim_get_current_win()
buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_win_set_buf(win, buf)
else -- horizontal (default)
local direction = opts.direction or "bottom"
local height = opts.size or math.floor(vim.o.lines / 2)
if direction == "top" then
vim.cmd("topleft split")
else
vim.cmd("split")
end
vim.cmd(string.format("horizontal resize %d", height))
win = vim.api.nvim_get_current_win()
buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_win_set_buf(win, buf)
end
-- Set buffer name if provided (only if it doesn't already exist)
if opts.name then
pcall(vim.api.nvim_buf_set_name, buf, opts.name)
end
-- Set buffer options
if opts.buftype then
vim.bo[buf].buftype = opts.buftype
end
vim.bo[buf].modifiable = opts.modifiable ~= nil and opts.modifiable or true
vim.bo[buf].swapfile = false
vim.bo[buf].buflisted = false
if opts.bufhidden then
vim.bo[buf].bufhidden = opts.bufhidden
end
-- Set filetype if provided
if opts.filetype then
vim.bo[buf].filetype = opts.filetype
end
-- Set keymaps if provided
if opts.keymaps then
M.set_keymaps(buf, opts.keymaps)
end
-- Set window options
if opts.win_options then
for option, value in pairs(opts.win_options) do
vim.wo[win][option] = value
end
end
-- Set up cleanup autocmd if on_exit callback provided
if opts.on_exit then
vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, {
buffer = buf,
once = true,
callback = function()
opts.on_exit(buf)
end,
})
end
return buf, win
end
--- Create and configure a floating window buffer
--- @param opts jj.core.buffer.float_opts Floating window configuration options
--- @return number buf Buffer handle
--- @return number win Window handle
function M.create_float(opts)
opts = opts or {}
-- Default config
local width = opts.width or math.floor(vim.o.columns * 0.8)
local height = opts.height or math.floor(vim.o.lines * 0.8)
local row = opts.row or math.floor((vim.o.lines - height) / 2)
local col = opts.col or math.floor((vim.o.columns - width) / 2)
local win_config = {
relative = opts.relative or "editor",
width = width,
height = height,
row = row,
col = col,
zindex = opts.zindex,
style = opts.style or "minimal",
border = opts.border or "rounded",
}
-- Add optional title
if opts.title then
win_config.title = opts.title
win_config.title_pos = opts.title_pos or "center"
end
-- Create buffer
local buf = vim.api.nvim_create_buf(false, true)
-- Create floating window
local win = vim.api.nvim_open_win(buf, opts.enter or false, win_config)
-- Set buffer options
if opts.buftype then
vim.bo[buf].buftype = opts.buftype
end
vim.bo[buf].modifiable = opts.modifiable ~= nil and opts.modifiable or true
vim.bo[buf].swapfile = false
vim.bo[buf].buflisted = false
if opts.bufhidden then
vim.bo[buf].bufhidden = opts.bufhidden
end
-- Set filetype if provided
if opts.filetype then
vim.bo[buf].filetype = opts.filetype
end
-- Set window options
if opts.win_options then
for option, value in pairs(opts.win_options) do
vim.wo[win][option] = value
end
end
-- Set keymaps if provided
if opts.keymaps then
M.set_keymaps(buf, opts.keymaps)
end
-- Set up cleanup autocmd if on_exit callback provided
if opts.on_exit then
vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, {
buffer = buf,
once = true,
callback = function()
opts.on_exit(buf)
end,
})
end
-- Set up auto-resize on VimResized
vim.api.nvim_create_autocmd("VimResized", {
buffer = buf,
callback = function()
if not vim.api.nvim_win_is_valid(win) then
return true -- Remove autocmd if window is invalid
end
-- Recalculate dimensions
local new_width = opts.width or math.floor(vim.o.columns * 0.8)
local new_height = opts.height or math.floor(vim.o.lines * 0.8)
local new_row = opts.row or math.floor((vim.o.lines - new_height) / 2)
local new_col = opts.col or math.floor((vim.o.columns - new_width) / 2)
-- Update window configuration
vim.api.nvim_win_set_config(win, {
relative = opts.relative or "editor",
width = new_width,
height = new_height,
row = new_row,
col = new_col,
})
end,
})
return buf, win
end
--- Creates a tooltip floating buffer
--- @param opts {text: string, timeout?: number, title?: string} Tooltip options
--- @return number buf Buffer handle
--- @return number win Window handle
function M.create_tooltip(opts)
opts = opts or {}
-- Calculate size based on text
local text = opts.text or ""
local lines = vim.split(text, "\n", { trimempty = false })
local max_width = 0
for _, line in ipairs(lines) do
max_width = math.max(max_width, vim.fn.strdisplaywidth(line))
end
-- Create float with tooltip defaults
local buf, win = M.create_float({
width = math.min(max_width + 2, vim.o.columns - 4),
height = #lines,
relative = "cursor",
row = 1,
col = 0,
style = "minimal",
border = "rounded",
title = opts.title,
modifiable = false,
buftype = "nofile",
bufhidden = "wipe",
})
-- Set the text
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].modified = false
-- Close when cursor moves in other windows
vim.api.nvim_create_autocmd("CursorMoved", {
callback = function()
if vim.api.nvim_win_is_valid(win) and vim.api.nvim_get_current_win() ~= win then
M.close(buf, true)
end
end,
})
-- Add Esc/q keymap to close tooltip
vim.keymap.set("n", "<Esc>", function()
if vim.api.nvim_buf_is_valid(buf) then
M.close(buf, true)
end
end, { buffer = buf, silent = true })
vim.keymap.set("n", "q", function()
if vim.api.nvim_buf_is_valid(buf) then
M.close(buf, true)
end
end, { buffer = buf, silent = true })
return buf, win
end
--- Close/wipe a buffer safely
--- @param buf number Buffer handle
--- @param force? boolean Force close (default: true)
function M.close(buf, force)
if not buf or not vim.api.nvim_buf_is_valid(buf) then
return
end
local cmd = force ~= false and "bwipeout!" or "bwipeout"
vim.cmd(cmd .. " " .. buf)
end
--- Add keymaps to a buffer
--- @param buf number Buffer handle
--- @param keymaps jj.core.buffer.keymap[] Array of keymap definitions
function M.set_keymaps(buf, keymaps)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
for _, keymap in ipairs(keymaps) do
local modes = keymap.modes or keymap.mode or "n"
local lhs = keymap.lhs or keymap[1]
local rhs = keymap.rhs or keymap[2]
local opts = vim.tbl_extend("force", {
buffer = buf,
noremap = true,
silent = true,
}, keymap.opts or {})
vim.keymap.set(modes, lhs, rhs, opts)
end
end
--- Remove keymaps from a buffer
--- @param buf number Buffer handle
--- @param keymaps jj.core.buffer.keymap[] Array of keymap definitions with modes and lhs
function M.remove_keymaps(buf, keymaps)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
for _, keymap in ipairs(keymaps) do
local modes = keymap.modes or keymap.mode or "n"
local lhs = keymap.lhs or keymap[1]
local modes_list = type(modes) == "table" and modes or { modes }
for _, mode in ipairs(modes_list) do
pcall(vim.keymap.del, mode, lhs, { buffer = buf })
end
end
end
--- Set buffer as modifiable or not
--- @param buf number Buffer handle
--- @param modifiable boolean Whether buffer should be modifiable
function M.set_modifiable(buf, modifiable)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
vim.bo[buf].modifiable = modifiable
end
--- Set buffer as modified or not
--- @param buf number Buffer handle
--- @param modified boolean Whether buffer should be modifiable
function M.set_modified(buf, modified)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
vim.bo[buf].modified = modified
end
--- Stop insert mode if in the given buffer if the cursor is currently in that buffer
--- @param buf number Buffer handle
function M.stop_insert(buf)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
if vim.api.nvim_get_current_buf() ~= buf then
return
end
vim.cmd("stopinsert")
end
--- Start insert mode in the given buffer if the cursor is currently in that buffer
--- @param buf number Buffer handle
function M.start_insert(buf)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
if vim.api.nvim_get_current_buf() ~= buf then
return
end
vim.cmd("startinsert")
end
--- Get cursor position for a buffer
--- @param buf number Buffer handle
--- @return number[]|nil Cursor position as {line, col} or nil if buffer not visible
function M.get_cursor(buf)
if not vim.api.nvim_buf_is_valid(buf) then
return nil
end
local winid = vim.fn.bufwinid(buf)
if winid == -1 then
return nil
end
return vim.api.nvim_win_get_cursor(winid)
end
--- Clamp cursor position to valid buffer bounds
--- @param buf number Buffer handle
--- @param pos number[] Cursor position as {line, col}
--- @return number[] Clamped position as {line, col}
local function clamp_cursor_position(buf, pos)
-- Validate and clamp position to buffer bounds
local line_count = vim.api.nvim_buf_line_count(buf)
local target_line = math.max(1, math.min(pos[1], line_count))
-- Get the actual line content to validate column
local line_content = vim.api.nvim_buf_get_lines(buf, target_line - 1, target_line, false)[1] or ""
local max_col = #line_content
local target_col = math.max(0, math.min(pos[2], max_col))
return { target_line, target_col }
end
--- Set cursor position for a buffer
--- Automatically validates and clamps position to buffer bounds:
--- - Line number is clamped to [1, line_count]
--- - Column is clamped to [0, line_length] based on actual line content
--- For terminal buffers, uses defer_fn to allow terminal rendering to stabilize
--- @param buf number Buffer handle
--- @param pos number[] Cursor position as {line, col} (1-indexed line, 0-indexed column)
--- @param opts? {delay?: number} Optional delay in ms for terminal buffers (default: 10)
function M.set_cursor(buf, pos, opts)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
opts = opts or {}
local delay = opts.delay or 10
local winid = vim.fn.bufwinid(buf)
if winid == -1 then
return
end
-- For terminal buffers, we delay cursor positioning to allow async rendering to complete.
-- The position is validated inside the deferred function because buffer content
-- may not be stable at function call time
if vim.bo[buf].buftype == "terminal" or delay > 0 then
vim.defer_fn(function()
if not vim.api.nvim_buf_is_valid(buf) or not vim.api.nvim_win_is_valid(winid) then
return
end
local clamped_pos = clamp_cursor_position(buf, pos)
vim.api.nvim_win_set_cursor(winid, clamped_pos)
end, delay)
else
local clamped_pos = clamp_cursor_position(buf, pos)
vim.api.nvim_win_set_cursor(winid, clamped_pos)
end
end
return M