Skip to content
Open
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
8 changes: 8 additions & 0 deletions lua/layout/features/toggle.lua
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ local function run_open(view)
return false, 'open must be a string or function', true
end

---@private
---@param open_error any
---@return boolean
local function is_invalid_window_error(open_error)
return tostring(open_error):find('Invalid window id:', 1, true) ~= nil
end

--- Collect the non-floating windows created by `fn` by diffing the
--- tabpage window list around the call.
---@private
Expand Down Expand Up @@ -135,6 +142,7 @@ function Toggle.open_group(side, group_name, selected)
opened, open_error, attempted = run_open(v.view)
end)
if not opened then
if is_invalid_window_error(open_error) then return end
vim.notify(
('[layout.nvim] Failed to open %s.%s.%s: %s'):format(side, group_name, v.name, tostring(open_error)),
vim.log.levels.ERROR
Expand Down
74 changes: 49 additions & 25 deletions lua/layout/shared/placement/engine.lua
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ local Engine = {}

---@alias Placement.WindowMap table<string, integer>

---@class Placement.WindowFixState
---@field win integer
---@field width boolean
---@field height boolean

---@private
---@param win integer?
---@return boolean
Expand Down Expand Up @@ -389,46 +394,65 @@ local function with_deterministic_options(fn, windows)
vim.api.nvim_set_option_value(name, value, {})
end)

---@type table<integer, boolean>
local panel_windows = {}
if windows then
vim.iter(pairs(windows)):each(function(label, win)
if label ~= 'C' then panel_windows[win] = true end
end)
end

---@type Placement.WindowFixState[]
local window_fix_states = {}
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if normal(win) then
window_fix_states[#window_fix_states + 1] = {
win = win,
width = vim.api.nvim_get_option_value('winfixwidth', { win = win }),
height = vim.api.nvim_get_option_value('winfixheight', { win = win }),
}
vim.api.nvim_set_option_value('winfixwidth', false, { win = win })
vim.api.nvim_set_option_value('winfixheight', false, { win = win })
end
end

local ok, err = xpcall(fn, debug.traceback)
if not ok then
-- Protect current geometry while restoring global options.
for _, state in ipairs(window_fix_states) do
if valid(state.win) then
pcall(vim.api.nvim_set_option_value, 'winfixwidth', true, { win = state.win })
pcall(vim.api.nvim_set_option_value, 'winfixheight', true, { win = state.win })
end
end
vim.iter(pairs(saved)):each(function(name, value)
pcall(vim.api.nvim_set_option_value, name, value, {})
end)
for _, state in ipairs(window_fix_states) do
if valid(state.win) then
pcall(vim.api.nvim_set_option_value, 'winfixwidth', state.width, { win = state.win })
pcall(vim.api.nvim_set_option_value, 'winfixheight', state.height, { win = state.win })
end
end
error(err)
end

-- Protect center windows so equalization triggered by restoring 'equalalways'
-- leaves them untouched. All windows that are not panel windows (L/R/B)
-- have winfixwidth and winfixheight set to 'true' before the global option
-- restoration, then their originals are restored after.
local panel_wins, center_fix = {}, {}
if windows then
vim.iter(pairs(windows)):each(function(label, win)
if label ~= 'C' then panel_wins[win] = true end
end)
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if vim.api.nvim_win_is_valid(win) and not panel_wins[win] then
center_fix[#center_fix + 1] = {
win = win,
wfw = vim.api.nvim_get_option_value('winfixwidth', { win = win }),
wfh = vim.api.nvim_get_option_value('winfixheight', { win = win }),
}
vim.api.nvim_set_option_value('winfixwidth', true, { win = win })
vim.api.nvim_set_option_value('winfixheight', true, { win = win })
end
-- Protect center windows from equalization during global option restoration.
for _, state in ipairs(window_fix_states) do
if valid(state.win) and not panel_windows[state.win] then
vim.api.nvim_set_option_value('winfixwidth', true, { win = state.win })
vim.api.nvim_set_option_value('winfixheight', true, { win = state.win })
end
end

-- Restore 'equalalways' (and others) while center windows are protected.
vim.iter(pairs(saved)):each(function(name, value)
pcall(vim.api.nvim_set_option_value, name, value, {})
end)

-- Restore center windows' original winfix values.
for _, c in ipairs(center_fix) do
if vim.api.nvim_win_is_valid(c.win) then
pcall(vim.api.nvim_set_option_value, 'winfixwidth', c.wfw, { win = c.win })
pcall(vim.api.nvim_set_option_value, 'winfixheight', c.wfh, { win = c.win })
for _, state in ipairs(window_fix_states) do
if valid(state.win) and not panel_windows[state.win] then
pcall(vim.api.nvim_set_option_value, 'winfixwidth', state.width, { win = state.win })
pcall(vim.api.nvim_set_option_value, 'winfixheight', state.height, { win = state.win })
end
end
end
Expand Down
29 changes: 29 additions & 0 deletions tests/test_features.lua
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,35 @@ describe('features.toggle', function()
expect.equality(child.lua_get([[require('layout.entities.workspace'):is_open('left', 'broken')]]), false)
end)

it('does not report an invalid window error from an open command', function()
-- Given: an opener fails while another plugin handles a stale window
local cfg = U.test_config({
right = {
groups = {
opencode = {
views = {
opencode = {
filter = 'opencode',
open = function()
error('BufWinEnter Autocommands: Invalid window id: 1000')
end,
},
},
},
},
},
})
U.setup_config(child, cfg)
child.lua([[vim.notify = function(message) _G._open_error = message end]])

-- When: the group is opened
child.lua([[require('layout.features.toggle').open_group('right', 'opencode')]])

-- Then: layout does not report the failure or mark the group open
expect.equality(child.lua_get([[_G._open_error]]), vim.NIL)
expect.equality(child.lua_get([[require('layout.entities.workspace'):is_open('right', 'opencode')]]), false)
end)

it('keeps the editor cursor on the same screen row when opening multiple full-width bottom views', function()
-- Given: splitkeep=screen and an editor cursor that remains visible while
-- two bottom windows are opened one after another.
Expand Down
44 changes: 44 additions & 0 deletions tests/test_placement.lua
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,50 @@ describe('placement.place', function()
-- multi-window stacking
--------------------------------------------------------------------------------
describe('multi-window stacking', function()
it('adds a view to a fixed panel without reporting not enough room', function()
-- Given: two fixed views already fill the left panel
local wins = U.make_scattered(child, { 'editor', 'first', 'second' })
place({
left = {
size = 30,
slots = { { winid = wins.first }, { winid = wins.second } },
},
})

-- And: a third view opens outside that panel
child.api.nvim_set_current_win(wins.editor)
child.cmd('belowright split')
local third = child.api.nvim_get_current_win()
child.api.nvim_win_set_buf(third, U.named_buf(child, 'third'))

child.lua([[
local original_splitmove = vim.fn.win_splitmove
vim.fn.win_splitmove = function(...)
for _, winid in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
local fixed_width = vim.api.nvim_get_option_value('winfixwidth', { win = winid })
local fixed_height = vim.api.nvim_get_option_value('winfixheight', { win = winid })
if fixed_width or fixed_height then error('Vim:E36: Not enough room') end
end
return original_splitmove(...)
end
]])

-- When: the third view is added to the existing panel
place({
left = {
size = 30,
slots = { { winid = wins.first }, { winid = wins.second }, { winid = third } },
},
})

-- Then: all views are stacked and placement completes without E36
expect.equality(norm_tree(), {
'row',
{ { 'col', { { 'leaf' }, { 'leaf' }, { 'leaf' } } }, { 'leaf' } },
})
expect.equality(child.api.nvim_get_option_value('winfixheight', { win = third }), true)
end)

it('stacks two views vertically in the left panel', function()
-- Given: editor + two tools
local wins, bufs = U.make_scattered(child, { 'editor', 't1', 't2' })
Expand Down
Loading