From 8183eb32e1af6501a3bcf2946473884e852d2ccc Mon Sep 17 00:00:00 2001 From: Riley Bruins Date: Fri, 6 Jun 2025 09:48:43 -0700 Subject: [PATCH] fix(treesitter): scope highlight state per window **Problem:** There is a lot of distracting highlight flickering when editing a buffer with multiple open windows. This is because the parsing/highlighting state is shared across all windows. **Solution:** Greatly reduce flicker in window splits by scoping the highlighter state object and the `parsing` state object to each individual window, so there is no cross-window interference. --- runtime/lua/vim/treesitter/highlighter.lua | 71 ++++++++++++---------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/runtime/lua/vim/treesitter/highlighter.lua b/runtime/lua/vim/treesitter/highlighter.lua index fa8eea9467..f6717aa7f4 100644 --- a/runtime/lua/vim/treesitter/highlighter.lua +++ b/runtime/lua/vim/treesitter/highlighter.lua @@ -63,15 +63,16 @@ end ---@field active table ---@field bufnr integer ---@field private orig_spelloptions string ---- A map of highlight states. +--- A map from window ID to highlight states. --- This state is kept during rendering across each line update. ----@field private _highlight_states vim.treesitter.highlighter.State[] +---@field private _highlight_states table ---@field private _queries table ---@field _conceal_line boolean? ---@field _conceal_checked table ---@field tree vim.treesitter.LanguageTree ---@field private redraw_count integer ----@field parsing boolean true if we are parsing asynchronously +--- A map from window ID to whether we are currently parsing that window asynchronously +---@field parsing table local TSHighlighter = { active = {}, } @@ -132,6 +133,7 @@ function TSHighlighter.new(tree, opts) self._conceal_checked = {} self._queries = {} self._highlight_states = {} + self.parsing = {} -- Queries for a specific language can be overridden by a custom -- string query... if one is not provided it will be looked up by file. @@ -185,11 +187,12 @@ function TSHighlighter:destroy() end end +---@param win integer ---@param srow integer ---@param erow integer exclusive ---@private -function TSHighlighter:prepare_highlight_states(srow, erow) - self._highlight_states = {} +function TSHighlighter:prepare_highlight_states(win, srow, erow) + self._highlight_states[win] = {} self.tree:for_each_tree(function(tstree, tree) if not tstree then @@ -212,7 +215,7 @@ function TSHighlighter:prepare_highlight_states(srow, erow) -- _highlight_states should be a list so that the highlights are added in the same order as -- for_each_tree traversal. This ensures that parents' highlight don't override children's. - table.insert(self._highlight_states, { + table.insert(self._highlight_states[win], { tstree = tstree, next_row = 0, iter = nil, @@ -221,10 +224,11 @@ function TSHighlighter:prepare_highlight_states(srow, erow) end) end +---@param win integer ---@param fn fun(state: vim.treesitter.highlighter.State) ---@package -function TSHighlighter:for_each_highlight_state(fn) - for _, state in ipairs(self._highlight_states) do +function TSHighlighter:for_each_highlight_state(win, fn) + for _, state in ipairs(self._highlight_states[win] or {}) do fn(state) end end @@ -308,13 +312,14 @@ local function get_spell(capture_name) end ---@param self vim.treesitter.highlighter +---@param win integer ---@param buf integer ---@param line integer ---@param on_spell boolean ---@param on_conceal boolean -local function on_line_impl(self, buf, line, on_spell, on_conceal) +local function on_line_impl(self, win, buf, line, on_spell, on_conceal) self._conceal_checked[line] = true - self:for_each_highlight_state(function(state) + self:for_each_highlight_state(win, function(state) local root_node = state.tstree:root() local root_start_row, _, root_end_row, _ = root_node:range() @@ -394,23 +399,24 @@ local function on_line_impl(self, buf, line, on_spell, on_conceal) end ---@private ----@param _win integer +---@param win integer ---@param buf integer ---@param line integer -function TSHighlighter._on_line(_, _win, buf, line, _) +function TSHighlighter._on_line(_, win, buf, line, _) local self = TSHighlighter.active[buf] if not self then return end - on_line_impl(self, buf, line, false, false) + on_line_impl(self, win, buf, line, false, false) end ---@private +---@param win integer ---@param buf integer ---@param srow integer ---@param erow integer -function TSHighlighter._on_spell_nav(_, _, buf, srow, _, erow, _) +function TSHighlighter._on_spell_nav(_, win, buf, srow, _, erow, _) local self = TSHighlighter.active[buf] if not self then return @@ -418,30 +424,31 @@ function TSHighlighter._on_spell_nav(_, _, buf, srow, _, erow, _) -- Do not affect potentially populated highlight state. Here we just want a temporary -- empty state so the C code can detect whether the region should be spell checked. - local highlight_states = self._highlight_states - self:prepare_highlight_states(srow, erow) + local highlight_states = self._highlight_states[win] + self:prepare_highlight_states(win, srow, erow) for row = srow, erow do - on_line_impl(self, buf, row, true, false) + on_line_impl(self, win, buf, row, true, false) end - self._highlight_states = highlight_states + self._highlight_states[win] = highlight_states end ---@private +---@param win integer ---@param buf integer ---@param row integer -function TSHighlighter._on_conceal_line(_, _, buf, row) +function TSHighlighter._on_conceal_line(_, win, buf, row) local self = TSHighlighter.active[buf] if not self or not self._conceal_line or self._conceal_checked[row] then return end -- Do not affect potentially populated highlight state. - local highlight_states = self._highlight_states + local highlight_states = self._highlight_states[win] self.tree:parse({ row, row }) - self:prepare_highlight_states(row, row) - on_line_impl(self, buf, row, false, true) - self._highlight_states = highlight_states + self:prepare_highlight_states(win, row, row) + on_line_impl(self, win, buf, row, false, true) + self._highlight_states[win] = highlight_states end ---@private @@ -466,33 +473,31 @@ function TSHighlighter._on_win(_, win, buf, topline, botline) if not self then return false end - self.parsing = self.parsing + self.parsing[win] = self.parsing[win] or nil == self.tree:parse({ topline, botline + 1 }, function(_, trees) - if trees and self.parsing then - self.parsing = false + if trees and self.parsing[win] then + self.parsing[win] = false api.nvim__redraw({ win = win, valid = false, flush = false }) end end) - if not self.parsing then + if not self.parsing[win] then self.redraw_count = self.redraw_count + 1 - self:prepare_highlight_states(topline, botline) + self:prepare_highlight_states(win, topline, botline) else - self:for_each_highlight_state(function(state) + self:for_each_highlight_state(win, function(state) -- TODO(ribru17): Inefficient. Eventually all marks should be applied in on_buf, and all -- non-folded ranges of each open window should be merged, and iterators should only be -- created over those regions. This would also fix #31777. -- -- Currently this is not possible because the parser discards previously parsed injection -- trees upon parsing a different region. - -- - -- It would also be nice if rather than re-querying extmarks for old trees, we could tell the - -- decoration provider to not clear previous ephemeral marks for this redraw cycle. state.iter = nil state.next_row = 0 end) end - return #self._highlight_states > 0 + local hl_states = self._highlight_states[win] or {} + return #hl_states > 0 end api.nvim_set_decoration_provider(ns, {