diff --git a/lua/layout/features/toggle.lua b/lua/layout/features/toggle.lua index 28ba73d..1fcc286 100644 --- a/lua/layout/features/toggle.lua +++ b/lua/layout/features/toggle.lua @@ -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 @@ -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 diff --git a/lua/layout/shared/placement/engine.lua b/lua/layout/shared/placement/engine.lua index 71a2211..c6223bf 100644 --- a/lua/layout/shared/placement/engine.lua +++ b/lua/layout/shared/placement/engine.lua @@ -14,6 +14,11 @@ local Engine = {} ---@alias Placement.WindowMap table +---@class Placement.WindowFixState +---@field win integer +---@field width boolean +---@field height boolean + ---@private ---@param win integer? ---@return boolean @@ -389,46 +394,65 @@ local function with_deterministic_options(fn, windows) vim.api.nvim_set_option_value(name, value, {}) end) + ---@type table + 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 diff --git a/tests/test_features.lua b/tests/test_features.lua index 0ab7253..091db8a 100644 --- a/tests/test_features.lua +++ b/tests/test_features.lua @@ -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. diff --git a/tests/test_placement.lua b/tests/test_placement.lua index 69d55b8..e40b4c7 100644 --- a/tests/test_placement.lua +++ b/tests/test_placement.lua @@ -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' })