From e4fbe162c193f926000bc83123e40caf8faccb14 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Tue, 23 Jun 2026 05:05:11 -0500 Subject: [PATCH] fix(statusline): defer redraw in aucmd context #40309 Problem: `nvim_exec_autocmds({buf=...})` may temporarily switch curwin/curbuf through `aucmd_prepbuf()`. Requested statuslines/winbars before `aucmd_restbuf()` may erroneously see the target window as current. Solution: Track `aucmd_prepbuf()` window-switch depth and leave statusline/winbar redraws marked dirty until the original window is restored. --- src/nvim/autocmd.c | 7 ++ src/nvim/autocmd.h | 1 + src/nvim/statusline.c | 19 +++++ test/functional/ui/statusline_spec.lua | 103 +++++++++++++++++++++++-- 4 files changed, 125 insertions(+), 5 deletions(-) diff --git a/src/nvim/autocmd.c b/src/nvim/autocmd.c index 39914db140..f9f2ce84b1 100644 --- a/src/nvim/autocmd.c +++ b/src/nvim/autocmd.c @@ -1346,6 +1346,9 @@ void aucmd_prepbuf(aco_save_T *aco, buf_T *buf) curbuf = buf; aco->new_curwin_handle = curwin->handle; set_bufref(&aco->new_curbuf, curbuf); + if (aco->new_curwin_handle != aco->save_curwin_handle) { + aucmd_prepbuf_depth++; + } aco->save_VIsual_active = VIsual_active; if (!same_buffer) { @@ -1481,6 +1484,10 @@ win_found: if (VIsual_active) { check_pos(curbuf, &VIsual); } + if (aco->new_curwin_handle != aco->save_curwin_handle) { + assert(aucmd_prepbuf_depth > 0); + aucmd_prepbuf_depth--; + } } /// Schedules an autocommand event, to be executed at the next event-loop tick. diff --git a/src/nvim/autocmd.h b/src/nvim/autocmd.h index 6789dad66f..048e34e7ae 100644 --- a/src/nvim/autocmd.h +++ b/src/nvim/autocmd.h @@ -22,6 +22,7 @@ EXTERN win_T *last_cursormoved_win INIT( = NULL); EXTERN pos_T last_cursormoved INIT( = { 0, 0, 0 }); EXTERN bool autocmd_busy INIT( = false); ///< Is apply_autocmds() busy? +EXTERN int aucmd_prepbuf_depth INIT( = 0); ///< Nested aucmd_prepbuf() window switches EXTERN int autocmd_no_enter INIT( = false); ///< Buf/WinEnter autocmds disabled EXTERN int autocmd_no_leave INIT( = false); ///< Buf/WinLeave autocmds disabled diff --git a/src/nvim/statusline.c b/src/nvim/statusline.c index cfa95c13f2..9b18dd2773 100644 --- a/src/nvim/statusline.c +++ b/src/nvim/statusline.c @@ -9,6 +9,7 @@ #include "nvim/api/private/defs.h" #include "nvim/api/private/helpers.h" #include "nvim/ascii_defs.h" +#include "nvim/autocmd.h" #include "nvim/buffer.h" #include "nvim/buffer_defs.h" #include "nvim/charset.h" @@ -62,6 +63,18 @@ typedef enum { kNumBaseHexadecimal = 16, } NumberBase; +static bool stl_defer_redraw(win_T *wp) +{ + if (aucmd_prepbuf_depth == 0) { + return false; + } + + // aucmd_prepbuf() temporarily changes curwin/curbuf. Statusline and winbar + // expressions can observe that context, so evaluate them after aucmd_restbuf(). + wp->w_redr_status = true; + return true; +} + /// Redraw the status line of window `wp`. /// /// If inversion is possible we use it. Else '=' characters are used. @@ -77,6 +90,9 @@ void win_redr_status(win_T *wp) || (wild_menu_showing != 0 && !ui_has(kUIWildmenu))) { return; } + if (stl_defer_redraw(wp)) { + return; + } busy = true; wp->w_redr_status = false; if (wp->w_status_height == 0 && !(is_stl_global && wp == curwin)) { @@ -432,6 +448,9 @@ void win_redr_winbar(win_T *wp) if (entered) { return; } + if (stl_defer_redraw(wp)) { + return; + } entered = true; if (wp->w_winbar_height == 0 || !redrawing()) { diff --git a/test/functional/ui/statusline_spec.lua b/test/functional/ui/statusline_spec.lua index 7cf238fff6..3a73b30c75 100644 --- a/test/functional/ui/statusline_spec.lua +++ b/test/functional/ui/statusline_spec.lua @@ -959,13 +959,16 @@ describe('statusline', function() it('no cmdline ruler for autocmd window #39938', function() command('set ruler laststatus=2') api.nvim_create_autocmd('BufDelete', { command = 'redrawstatus' }) - api.nvim_exec_autocmds('BufDelete', { buf = api.nvim_create_buf(true, true) }) - screen:expect([[ + local expected = [[ ^ | {1:~ }|*5 - {2:[No Name] 0,0-1 All}| + {3:[No Name] 0,0-1 All}| | - ]]) + ]] + screen:expect(expected) + api.nvim_exec_autocmds('BufDelete', { buf = api.nvim_create_buf(true, true) }) + -- The first matching frame can arrive before the deferred redraw settles. + screen:expect_unchanged(true) end) it('active window is correct after nvim_exec_autocmds({buf}) #40153', function() @@ -976,17 +979,32 @@ describe('statusline', function() api.nvim_set_current_buf(target) api.nvim_set_current_win(caller_win) exec_lua(function(buf) + _G.in_aucmd_statusline = false + _G.statusline_contexts = {} _G.Status = function() - return vim.api.nvim_get_current_win() == vim.g.statusline_winid and 'CUR' or 'nc' + local current = vim.api.nvim_get_current_win() + local statusline = vim.g.statusline_winid + local focus = current == statusline + table.insert(_G.statusline_contexts, { + current = current, + focus = focus, + in_aucmd = _G.in_aucmd_statusline, + statusline = statusline, + }) + return focus and 'CUR' or 'nc' end vim.o.statusline = '%!v:lua.Status()' vim.api.nvim_create_autocmd('User', { buffer = buf, callback = function(ev) + _G.in_aucmd_statusline = true vim.api.nvim__redraw({ buf = ev.buf, statusline = true }) + _G.in_aucmd_statusline = false end, }) end, target) + -- Ignore statusline evaluations from setup; only the autocmd redraw matters. + exec_lua('_G.statusline_contexts = {}') api.nvim_exec_autocmds('User', { buf = target }) screen:expect([[ | @@ -997,6 +1015,81 @@ describe('statusline', function() {3:CUR }| | ]]) + -- `%!` may evaluate during the callback, but must not see the target as focused. + eq( + {}, + exec_lua(function(win) + return vim + .iter(_G.statusline_contexts) + :filter(function(context) + return context.in_aucmd and context.focus and context.current ~= win + end) + :totable() + end, caller_win) + ) + end) + + it('defers statusline evaluation during nvim_exec_autocmds({buf}) #40153', function() + command('set laststatus=2') + local caller_win = api.nvim_get_current_win() + command('split') + local target = api.nvim_create_buf(true, false) + api.nvim_set_current_buf(target) + api.nvim_set_current_win(caller_win) + exec_lua(function(buf) + _G.in_aucmd_statusline = false + _G.statusline_contexts = {} + _G.Status = function() + table.insert(_G.statusline_contexts, { + in_aucmd = _G.in_aucmd_statusline, + current = vim.api.nvim_get_current_win(), + actual = tonumber(vim.g.actual_curwin or -1), + }) + return vim.api.nvim_get_current_win() == tonumber(vim.g.actual_curwin or -1) and 'CUR' + or 'nc' + end + -- `%{%...%}` sets g:actual_curwin while evaluating the statusline. + vim.o.statusline = '%{%v:lua.Status()%}' + vim.api.nvim_create_autocmd('User', { + buffer = buf, + callback = function(ev) + _G.in_aucmd_statusline = true + vim.api.nvim__redraw({ buf = ev.buf, statusline = true }) + _G.in_aucmd_statusline = false + end, + }) + end, target) + screen:expect([[ + | + {1:~ }|*2 + {2:nc }| + ^ | + {1:~ }| + {3:CUR }| + | + ]]) + -- Ignore statusline evaluations from setup; only the autocmd redraw matters. + exec_lua('_G.statusline_contexts = {}') + api.nvim_exec_autocmds('User', { buf = target }) + -- No different frame should be flushed while the deferred redraw settles. + screen:expect({ unchanged = true }) + local contexts = exec_lua(function() + return _G.statusline_contexts + end) + eq(true, #contexts > 0) + eq(caller_win, contexts[#contexts].actual) + -- The statusline must be evaluated only after the autocmd context is restored. + eq( + {}, + exec_lua(function() + return vim + .iter(_G.statusline_contexts) + :filter(function(context) + return context.in_aucmd + end) + :totable() + end) + ) end) end)