Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ return {
},
-- TIP: find a buffer's filetype with :lua print(vim.bo.filetype)
top = {
{ filetype = "gitcommit", replace = false },
{ filetype = "fugitive" },
{ filetype = "man" },
{ filetype = "help" },
{ filetype = "fugitive" },
},
right = {
{ filetype = "*", min_width = 46 },
Expand Down Expand Up @@ -80,16 +81,13 @@ programs.nvf.settings.vim.lazy.plugins = {
event = [ "BufEnter" ];
setupModule = "zen";
setupOpts = {
main = {
width = 148;
};
main.width = 148;
# TIP: find a buffer's filetype with :lua print(vim.bo.filetype)
top = [
{ filetype = "gitcommit"; replace = false; }
{ filetype = "fugitive"; }
{ filetype = "git"; }
{ filetype = "man"; }
{ filetype = "help"; }
{ filetype = "gitcommit"; }
];
right = [
{ filetype = "*"; min_width = 46; }
Expand Down
172 changes: 135 additions & 37 deletions lua/zen/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
--- @class Integration
--- @field filetype Filetype
--- @field min_width? number
--- @field replace? boolean

--- @class Config
--- @field main? { width: number | fun(): number; }
Expand Down Expand Up @@ -196,14 +197,6 @@ local function get_vsplits()
return vsplits
end

---@return boolean
local function is_hsplit(buf)
local win_id = vim.fn.bufwinid(buf)
local width = vim.api.nvim_win_get_width(win_id)
local height = vim.api.nvim_win_get_height(win_id)
return width > height
end

---@param position "top" | "right" | "bottom" | "left"
---@return boolean
local function is_integration_open(position)
Expand Down Expand Up @@ -233,12 +226,113 @@ local function get_window_by_filetype(filetype)
return nil
end

local function adjust_top_bottom_window_hack(target_window, position)
if target_window then
vim.api.nvim_win_call(target_window, function()
vim.cmd("wincmd " .. position)
end)
---@param position "top" | "bottom"
---@return number[]
local function get_ordered_stack_windows(position)
local windows = {}
for _, integration in ipairs(opts[position]) do
if type(integration) == "table" and integration.filetype ~= "*" then
local win = get_window_by_filetype(integration.filetype)
if win then
table.insert(windows, win)
end
end
end
return windows
end

--- Stacks the open `top`/`bottom` integration windows on top of each other,
--- full width, in the order they appear in the config: for `top`, the first
--- configured integration ends up closest to the very top edge of the screen
--- and the last one closest to the main area; for `bottom` it is the mirror
--- (first closest to the main area, last closest to the very bottom edge).
---@param position "top" | "bottom"
local function reposition_stack(position)
local windows = get_ordered_stack_windows(position)
if #windows == 0 then
return
end

-- already full width and in the desired relative order (top-to-bottom):
-- nothing to do, since re-splitting would needlessly renegotiate heights
local in_order = true
for _, win in ipairs(windows) do
if vim.api.nvim_win_get_width(win) ~= vim.o.columns then
in_order = false
break
end
end
if in_order then
for i = 2, #windows do
if vim.api.nvim_win_get_position(windows[i])[1] < vim.api.nvim_win_get_position(windows[i - 1])[1] then
in_order = false
break
end
end
end
if in_order then
return
end

-- splitting a window relative to another renegotiates both windows'
-- heights, so snapshot and restore them once the whole stack is in order
local heights = {}
for _, win in ipairs(windows) do
heights[win] = vim.api.nvim_win_get_height(win)
end

if position == "top" then
vim.api.nvim_win_set_config(windows[1], { split = "above", win = -1 })
for i = 2, #windows do
vim.api.nvim_win_set_config(windows[i], { split = "below", win = windows[i - 1] })
end
else
vim.api.nvim_win_set_config(windows[#windows], { split = "below", win = -1 })
for i = #windows - 1, 1, -1 do
vim.api.nvim_win_set_config(windows[i], { split = "above", win = windows[i + 1] })
end
end

for _, win in ipairs(windows) do
vim.api.nvim_win_set_height(win, heights[win])
vim.api.nvim_win_set_width(win, vim.o.columns)
end
end

---@param position "top" | "bottom"
---@return number height, number count combined height and number of open stack windows
local function stack_metrics(position)
local windows = get_ordered_stack_windows(position)
local height = 0
for _, win in ipairs(windows) do
height = height + vim.api.nvim_win_get_height(win)
end
return height, #windows
end

--- Closing a stacked `top`/`bottom` window frees its content plus its one
--- statusline row, but Neovim may hand that freed row to the main area instead
--- of the surviving stack window. Given the stack's height/count from before
--- the close, grow the survivor closest to the main area back to the height the
--- stack should now have (its old height plus the closed window's freed row).
---@param position "top" | "bottom"
---@param before_height number stack height captured before the close
---@param before_count number stack window count captured before the close
local function reclaim_stack_height(position, before_height, before_count)
local windows = get_ordered_stack_windows(position)
-- nothing to do if this stack didn't lose a window, or lost them all
if #windows == 0 or #windows >= before_count then
return
end

local height = stack_metrics(position)
local deficit = before_height + 1 - height
if deficit <= 0 then
return
end

local boundary = position == "top" and windows[#windows] or windows[1]
vim.api.nvim_win_set_height(boundary, vim.api.nvim_win_get_height(boundary) + deficit)
end

local function resize_side_buffers()
Expand Down Expand Up @@ -408,6 +502,19 @@ local function setup(options)
return
end

-- `WinClosed` fires just before the window is removed, so the stacks
-- still have their pre-close heights here. Capture them and reclaim
-- any slack from `WinResized`, which fires once the close has settled.
local top_height, top_count = stack_metrics("top")
local bottom_height, bottom_count = stack_metrics("bottom")
vim.api.nvim_create_autocmd("WinResized", {
once = true,
callback = function()
reclaim_stack_height("top", top_height, top_count)
reclaim_stack_height("bottom", bottom_height, bottom_count)
end,
})

local buf_id = vim.fn.winbufnr(win_id)
local file_type = vim.api.nvim_get_option_value("filetype", { buf = buf_id })
local buf_type = vim.api.nvim_get_option_value("buftype", { buf = buf_id })
Expand All @@ -434,12 +541,8 @@ local function setup(options)
vim.cmd("wincmd h")
end

for _, integration in pairs(opts.top) do
adjust_top_bottom_window_hack(get_window_by_filetype(integration.filetype), "K")
end
for _, integration in pairs(opts.bottom) do
adjust_top_bottom_window_hack(get_window_by_filetype(integration.filetype), "J")
end
reposition_stack("top")
reposition_stack("bottom")
resize_side_buffers()
end,
desc = "Recreate the side buffers if they are closed.",
Expand Down Expand Up @@ -471,14 +574,17 @@ local function setup(options)
for _, integration in pairs(opts[position]) do
if type(integration) == "table" and is_filetype(filetype, integration.filetype) then
close_side_buffer(position)
for _, position_inner in ipairs(positions) do
for _, integration_inner in pairs(opts[position_inner]) do
if
position_inner == position
and type(integration_inner) == "table"
and not is_filetype(filetype, integration_inner.filetype)
then
close(integration_inner.filetype)
if integration.replace ~= false then
for _, position_inner in ipairs(positions) do
for _, integration_inner in pairs(opts[position_inner]) do
if
position_inner == position
and type(integration_inner) == "table"
and integration_inner.replace ~= false
and not is_filetype(filetype, integration_inner.filetype)
then
close(integration_inner.filetype)
end
end
end
end
Expand All @@ -499,16 +605,8 @@ local function setup(options)
end
end

for _, integration in pairs(opts.top) do
if not is_hsplit(args.buf) then
adjust_top_bottom_window_hack(get_window_by_filetype(integration.filetype), "K")
end
end
for _, integration in pairs(opts.bottom) do
if not is_hsplit(args.buf) then
adjust_top_bottom_window_hack(get_window_by_filetype(integration.filetype), "J")
end
end
reposition_stack("top")
reposition_stack("bottom")
resize_side_buffers()
end,
desc = "Close side buffer plugins if another plugin is already occupying that side.",
Expand Down
1 change: 1 addition & 0 deletions tests/scripts/init_with_zen.lua
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require("CopilotChat").setup({})

require("zen").setup({
top = {
{ filetype = "gitcommit", replace = false },
{ filetype = "fugitive" },
{ filetype = "man" },
},
Expand Down
14 changes: 14 additions & 0 deletions tests/scripts/init_with_zen_top_order.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
vim.cmd([[let &rtp.=','.getcwd()]])
vim.opt.packpath:prepend("deps")

vim.o.columns = 240
vim.o.lines = 52

require("mini.test").setup()

require("zen").setup({
top = {
{ filetype = "fugitive" },
{ filetype = "gitcommit", replace = false },
},
})
84 changes: 84 additions & 0 deletions tests/test_integrations.lua
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,90 @@ T["top integration"]["opening an integration should close the existing integrati
})
end


for _, case in ipairs({ { name = "above", }, { name = "below", } }) do
T["top integration"]["stacks " .. case.name .. " when configured " .. case.name .. " with replace=false"] = function()
if case.name == "below" then
child.restart({ "-u", "tests/scripts/init_with_zen_top_order.lua" })
end

child.lua([[
local tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, "p")
vim.fn.system({ "git", "init", tmpdir })
vim.fn.system({ "git", "-C", tmpdir, "config", "user.name", "Test" })
vim.fn.system({ "git", "-C", tmpdir, "config", "user.email", "test@test.com" })
vim.fn.system({ "git", "-C", tmpdir, "commit", "--allow-empty", "-m", "initial" })
vim.fn.writefile({ "hello" }, tmpdir .. "/file")
vim.cmd("edit " .. tmpdir .. "/file")
]])

child.cmd("Git")

Helpers.expect.layout(child, {
type = "col",
children = {
{ type = "leaf", filetype = "fugitive", buftype = "nowrite", width = 240, height = 25 },
{
type = "row",
children = {
{ type = "leaf", filetype = "zen-left", buftype = "nofile", width = 46, height = 24 },
{ type = "leaf", filetype = "", buftype = "", width = 146, height = 24 },
{ type = "leaf", filetype = "zen-right", buftype = "nofile", width = 46, height = 24 },
},
},
},
})

child.cmd("Git commit --allow-empty")

Helpers.expect.layout(child, {
type = "col",
children = {
{
type = "leaf",
filetype = case.name == "above" and "gitcommit" or "fugitive",
buftype = case.name == "above" and "" or "nowrite",
width = 240,
height = 12,
},
{
type = "leaf",
filetype = case.name == "above" and "fugitive" or "gitcommit",
buftype = case.name == "above" and "nowrite" or "",
width = 240,
height = 12,
},
{
type = "row",
children = {
{ type = "leaf", filetype = "zen-left", buftype = "nofile", width = 46, height = 24 },
{ type = "leaf", filetype = "", buftype = "", width = 146, height = 24 },
{ type = "leaf", filetype = "zen-right", buftype = "nofile", width = 46, height = 24 },
},
},
},
})

child.cmd("q")

Helpers.expect.layout(child, {
type = "col",
children = {
{ type = "leaf", filetype = "fugitive", buftype = "nowrite", width = 240, height = 25 },
{
type = "row",
children = {
{ type = "leaf", filetype = "zen-left", buftype = "nofile", width = 46, height = 24 },
{ type = "leaf", filetype = "", buftype = "", width = 146, height = 24 },
{ type = "leaf", filetype = "zen-right", buftype = "nofile", width = 46, height = 24 },
},
},
},
})
end
end

T["bottom integration"] = MiniTest.new_set({})

T["bottom integration"]["opening"] = function()
Expand Down
Loading