From fe986e5dd094b2f7e1d28e64e52ffbc5f7292191 Mon Sep 17 00:00:00 2001 From: luukvbaal Date: Mon, 20 Apr 2026 02:36:55 +0200 Subject: [PATCH] feat(options): add 'winpinned' to pin a window #39157 Problem: - Unable to "pin" a window to prevent closing without specifically being targeted. - :fclose closes hidden windows (even before visible windows). Solution: - Add 'winpinned' window-local option. When set, window is skipped by :fclose and :only. Pin the ui2 cmdline window (which should always be visible), so that it is not closed by :only/fclose. - Skip over hidden (and pinned) windows with :fclose. Co-authored-by: glepnir --- runtime/doc/news.txt | 2 +- runtime/doc/options.txt | 8 ++++++++ runtime/lua/vim/_core/ui2.lua | 2 ++ runtime/lua/vim/_meta/options.gen.lua | 10 ++++++++++ runtime/scripts/optwin.lua | 1 + src/gen/gen_eval_files.lua | 1 + src/nvim/buffer_defs.h | 2 ++ src/nvim/ex_docmd.c | 4 ++-- src/nvim/option.c | 2 ++ src/nvim/options.lua | 13 +++++++++++++ src/nvim/window.c | 23 ++++++++++++++++++----- src/nvim/winfloat.c | 3 +++ test/functional/api/window_spec.lua | 24 ++++++++++++++++++++++++ test/functional/ui/messages2_spec.lua | 2 +- 14 files changed, 88 insertions(+), 9 deletions(-) diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index ccedbee310..f287b68d29 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -156,7 +156,7 @@ the same range instances now compare equal. OPTIONS -• todo +• 'winpinned' prevents window from closing unless specifically targeted. PERFORMANCE diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt index f2bca82cc8..de92fc98ff 100644 --- a/runtime/doc/options.txt +++ b/runtime/doc/options.txt @@ -296,6 +296,7 @@ created, thus they behave slightly differently: 'winfixbuf' specific to existing window 'winfixheight' specific to existing window 'winfixwidth' specific to existing window + 'winpinned' specific to existing window Special local buffer options @@ -7626,6 +7627,13 @@ A jump table for the options with a short description can be found at |Q_op|. large number, it will cause errors when opening more than a few windows. A value of 0 to 12 is reasonable. + *'winpinned'* *'wp'* *'nowinpinned'* *'nowp'* +'winpinned' 'wp' boolean (default off) + local to window |local-noglobal| + If enabled, the window is pinned and will not be closed by |:only| + and |:fclose|. Only commands specifically targeting the window can + close it. + *'winwidth'* *'wiw'* *E592* 'winwidth' 'wiw' number (default 20) global diff --git a/runtime/lua/vim/_core/ui2.lua b/runtime/lua/vim/_core/ui2.lua index 77ea7f57b3..4c7ceefda0 100644 --- a/runtime/lua/vim/_core/ui2.lua +++ b/runtime/lua/vim/_core/ui2.lua @@ -130,6 +130,8 @@ function M.check_targets() hl = 'Normal:MsgArea' elseif type == 'msg' then hl = search_hide + elseif type == 'cmd' then + api.nvim_set_option_value('winpinned', true, { scope = 'local' }) end api.nvim_set_option_value('winhighlight', hl, { scope = 'local' }) end) diff --git a/runtime/lua/vim/_meta/options.gen.lua b/runtime/lua/vim/_meta/options.gen.lua index c151c448bb..82f53eedc7 100644 --- a/runtime/lua/vim/_meta/options.gen.lua +++ b/runtime/lua/vim/_meta/options.gen.lua @@ -8324,6 +8324,16 @@ vim.o.wmw = vim.o.winminwidth vim.go.winminwidth = vim.o.winminwidth vim.go.wmw = vim.go.winminwidth +--- If enabled, the window is pinned and will not be closed by `:only` +--- and `:fclose`. Only commands specifically targeting the window can +--- close it. +--- +--- @type boolean +vim.o.winpinned = false +vim.o.wp = vim.o.winpinned +vim.wo.winpinned = vim.o.winpinned +vim.wo.wp = vim.wo.winpinned + --- Minimal number of columns for the current window. This is not a hard --- minimum, Vim will use fewer columns if there is not enough room. If --- the current window is smaller, its size is increased, at the cost of diff --git a/runtime/scripts/optwin.lua b/runtime/scripts/optwin.lua index f91cbedc67..6961182cf3 100644 --- a/runtime/scripts/optwin.lua +++ b/runtime/scripts/optwin.lua @@ -128,6 +128,7 @@ local options_list = { { 'winfixwidth', N_ 'keep the width of the window' }, { 'winwidth', N_ 'minimal number of columns used for the current window' }, { 'winminwidth', N_ 'minimal number of columns used for any window' }, + { 'winpinned', N_ 'prevent closing window with :only and :fclose' }, { 'helpheight', N_ 'initial height of the help window' }, { 'previewheight', N_ 'default height for the preview window' }, { 'previewwindow', N_ 'identifies the preview window' }, diff --git a/src/gen/gen_eval_files.lua b/src/gen/gen_eval_files.lua index 4609509042..05f2d52fcf 100755 --- a/src/gen/gen_eval_files.lua +++ b/src/gen/gen_eval_files.lua @@ -667,6 +667,7 @@ local function option_scope_doc(o) 'syntax', 'winfixheight', 'winfixwidth', + 'winpinned', }, o.full_name) then r = r .. ' |local-noglobal|' diff --git a/src/nvim/buffer_defs.h b/src/nvim/buffer_defs.h index 6b1719cc6e..546d6e5a6a 100644 --- a/src/nvim/buffer_defs.h +++ b/src/nvim/buffer_defs.h @@ -148,6 +148,8 @@ typedef struct { #define w_p_wfh w_onebuf_opt.wo_wfh // 'winfixheight' int wo_wfw; #define w_p_wfw w_onebuf_opt.wo_wfw // 'winfixwidth' + int wo_wp; +#define w_p_wp w_onebuf_opt.wo_wp // 'winpinned' int wo_pvw; #define w_p_pvw w_onebuf_opt.wo_pvw // 'previewwindow' OptInt wo_lhi; diff --git a/src/nvim/ex_docmd.c b/src/nvim/ex_docmd.c index 34aef2e476..e527b38b6a 100644 --- a/src/nvim/ex_docmd.c +++ b/src/nvim/ex_docmd.c @@ -5329,7 +5329,7 @@ void tabpage_close(int forceit) ex_win_close(forceit, curwin, NULL); } if (!ONE_WINDOW) { - close_others(true, forceit); + close_others(true, forceit, true); } if (ONE_WINDOW) { ex_win_close(forceit, curwin, NULL); @@ -5402,7 +5402,7 @@ static void ex_only(exarg_T *eap) win_goto(wp); } } - close_others(true, eap->forceit); + close_others(true, eap->forceit, false); } static void ex_hide(exarg_T *eap) diff --git a/src/nvim/option.c b/src/nvim/option.c index 030702ccaf..51403a0fe6 100644 --- a/src/nvim/option.c +++ b/src/nvim/option.c @@ -4859,6 +4859,8 @@ void *get_varp_from(vimoption_T *p, buf_T *buf, win_T *win) return &(win->w_p_wfh); case kOptWinfixwidth: return &(win->w_p_wfw); + case kOptWinpinned: + return &(win->w_p_wp); case kOptPreviewwindow: return &(win->w_p_pvw); case kOptLhistory: diff --git a/src/nvim/options.lua b/src/nvim/options.lua index 5ec0fe842a..a24da96883 100644 --- a/src/nvim/options.lua +++ b/src/nvim/options.lua @@ -10713,6 +10713,19 @@ local options = { type = 'number', varname = 'p_wmw', }, + { + abbreviation = 'wp', + defaults = false, + desc = [=[ + If enabled, the window is pinned and will not be closed by |:only| + and |:fclose|. Only commands specifically targeting the window can + close it. + ]=], + full_name = 'winpinned', + scope = { 'win' }, + short_desc = N_('prevent closing window with :only and :fclose'), + type = 'boolean', + }, { abbreviation = 'wiw', cb = 'did_set_winwidth', diff --git a/src/nvim/window.c b/src/nvim/window.c index b7d3bce5c5..a82dfa0f80 100644 --- a/src/nvim/window.c +++ b/src/nvim/window.c @@ -4248,10 +4248,12 @@ static int frame_minwidth(frame_T *topfrp, win_T *next_curwin) /// Buffers in the other windows become hidden if 'hidden' is set, or '!' is /// used and the buffer was modified. /// -/// Used by ":bdel" and ":only". +/// Used by ":tabclose" and ":only". /// -/// @param forceit always hide all other windows -void close_others(int message, int forceit) +/// @param message if true, display error messages +/// @param forceit always hide all other windows +/// @param ignore_pinned if true, also close pinned windows (for :tabclose) +void close_others(int message, int forceit, bool ignore_pinned) { win_T *const old_curwin = curwin; @@ -4280,7 +4282,8 @@ void close_others(int message, int forceit) curbuf = curwin->w_buffer; } - if (wp == curwin) { // don't close current window + // don't close current window or pinned windows + if (wp == curwin || (wp->w_p_wp && !ignore_pinned)) { continue; } @@ -4312,7 +4315,17 @@ void close_others(int message, int forceit) } if (message && !ONE_WINDOW) { - emsg(_("E445: Other window contains changes")); + // Check if remaining windows are non-pinned + bool has_non_pinned = false; + for (win_T *wp = firstwin; wp != NULL; wp = wp->w_next) { + if (wp != curwin && !wp->w_p_wp) { + has_non_pinned = true; + break; + } + } + if (has_non_pinned) { + emsg(_("E445: Other window contains changes")); + } } } diff --git a/src/nvim/winfloat.c b/src/nvim/winfloat.c index f222f6b17e..e949aaf141 100644 --- a/src/nvim/winfloat.c +++ b/src/nvim/winfloat.c @@ -298,6 +298,9 @@ void win_float_remove(bool bang, int count) { kvec_t(win_T *) float_win_arr = KV_INITIAL_VALUE; for (win_T *wp = lastwin; wp && wp->w_floating; wp = wp->w_prev) { + if (wp->w_config.hide || wp->w_p_wp) { + continue; + } kv_push(float_win_arr, wp); } if (float_win_arr.size > 0) { diff --git a/test/functional/api/window_spec.lua b/test/functional/api/window_spec.lua index daf0ae8ce1..650a4bf5e3 100644 --- a/test/functional/api/window_spec.lua +++ b/test/functional/api/window_spec.lua @@ -3861,4 +3861,28 @@ describe('API/win', function() eq(float_win, api.nvim_get_current_win()) end) end) + + it(':fclose and :only skip hidden and pinned windows #36123', function() + local cfg = { relative = 'editor', row = 0, col = 0, width = 1, height = 1 } + local win1 = api.nvim_open_win(0, false, cfg) + command('fclose') + eq(false, api.nvim_win_is_valid(win1)) + cfg.hide = true + win1 = api.nvim_open_win(0, false, cfg) + cfg.hide, cfg.focusable = false, false + local win2 = api.nvim_open_win(0, false, cfg) + command('fclose') + eq(true, api.nvim_win_is_valid(win1)) + eq(false, api.nvim_win_is_valid(win2)) + api.nvim_win_set_config(win1, { hide = false }) + api.nvim_set_option_value('winpinned', true, { win = win1, scope = 'local' }) + win2 = api.nvim_open_win(0, false, { split = 'right' }) + api.nvim_set_option_value('winpinned', true, { win = win2, scope = 'local' }) + command('only') + eq(true, api.nvim_win_is_valid(win1)) + eq(true, api.nvim_win_is_valid(win2)) + local tab2 = api.nvim_open_tabpage(0, false, {}) + command('tabclose') + eq(tab2, api.nvim_get_current_tabpage()) + end) end) diff --git a/test/functional/ui/messages2_spec.lua b/test/functional/ui/messages2_spec.lua index c3cf060bc9..f030ca8692 100644 --- a/test/functional/ui/messages2_spec.lua +++ b/test/functional/ui/messages2_spec.lua @@ -432,7 +432,7 @@ describe('messages2', function() screen:expect([[ ^ | {1:~ }|*12 - | + foo [+1] | ]]) end)