fix(treesitter): scope highlight state per window #34347

**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.
This commit is contained in:
Riley Bruins
2025-06-07 09:27:46 -07:00
committed by GitHub
parent 96a0e5f265
commit faae1e72a2

View File

@@ -63,15 +63,16 @@ end
---@field active table<integer,vim.treesitter.highlighter> ---@field active table<integer,vim.treesitter.highlighter>
---@field bufnr integer ---@field bufnr integer
---@field private orig_spelloptions string ---@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. --- This state is kept during rendering across each line update.
---@field private _highlight_states vim.treesitter.highlighter.State[] ---@field private _highlight_states table<integer, vim.treesitter.highlighter.State[]>
---@field private _queries table<string,vim.treesitter.highlighter.Query> ---@field private _queries table<string,vim.treesitter.highlighter.Query>
---@field _conceal_line boolean? ---@field _conceal_line boolean?
---@field _conceal_checked table<integer, boolean> ---@field _conceal_checked table<integer, boolean>
---@field tree vim.treesitter.LanguageTree ---@field tree vim.treesitter.LanguageTree
---@field private redraw_count integer ---@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<integer, boolean>
local TSHighlighter = { local TSHighlighter = {
active = {}, active = {},
} }
@@ -141,6 +142,7 @@ function TSHighlighter.new(tree, opts)
self._conceal_checked = {} self._conceal_checked = {}
self._queries = {} self._queries = {}
self._highlight_states = {} self._highlight_states = {}
self.parsing = {}
-- Queries for a specific language can be overridden by a custom -- 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. -- string query... if one is not provided it will be looked up by file.
@@ -194,11 +196,12 @@ function TSHighlighter:destroy()
end end
end end
---@param win integer
---@param srow integer ---@param srow integer
---@param erow integer exclusive ---@param erow integer exclusive
---@private ---@private
function TSHighlighter:prepare_highlight_states(srow, erow) function TSHighlighter:prepare_highlight_states(win, srow, erow)
self._highlight_states = {} self._highlight_states[win] = {}
self.tree:for_each_tree(function(tstree, tree) self.tree:for_each_tree(function(tstree, tree)
if not tstree then if not tstree then
@@ -221,7 +224,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 -- _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. -- 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, tstree = tstree,
next_row = 0, next_row = 0,
iter = nil, iter = nil,
@@ -230,10 +233,11 @@ function TSHighlighter:prepare_highlight_states(srow, erow)
end) end)
end end
---@param win integer
---@param fn fun(state: vim.treesitter.highlighter.State) ---@param fn fun(state: vim.treesitter.highlighter.State)
---@package ---@package
function TSHighlighter:for_each_highlight_state(fn) function TSHighlighter:for_each_highlight_state(win, fn)
for _, state in ipairs(self._highlight_states) do for _, state in ipairs(self._highlight_states[win] or {}) do
fn(state) fn(state)
end end
end end
@@ -317,13 +321,14 @@ local function get_spell(capture_name)
end end
---@param self vim.treesitter.highlighter ---@param self vim.treesitter.highlighter
---@param win integer
---@param buf integer ---@param buf integer
---@param line integer ---@param line integer
---@param on_spell boolean ---@param on_spell boolean
---@param on_conceal 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] = self._conceal_line and true or nil self._conceal_checked[line] = self._conceal_line and true or nil
self:for_each_highlight_state(function(state) self:for_each_highlight_state(win, function(state)
local root_node = state.tstree:root() local root_node = state.tstree:root()
local root_start_row, _, root_end_row, _ = root_node:range() local root_start_row, _, root_end_row, _ = root_node:range()
@@ -411,23 +416,24 @@ local function on_line_impl(self, buf, line, on_spell, on_conceal)
end end
---@private ---@private
---@param _win integer ---@param win integer
---@param buf integer ---@param buf integer
---@param line integer ---@param line integer
function TSHighlighter._on_line(_, _win, buf, line, _) function TSHighlighter._on_line(_, win, buf, line, _)
local self = TSHighlighter.active[buf] local self = TSHighlighter.active[buf]
if not self then if not self then
return return
end end
on_line_impl(self, buf, line, false, false) on_line_impl(self, win, buf, line, false, false)
end end
---@private ---@private
---@param win integer
---@param buf integer ---@param buf integer
---@param srow integer ---@param srow integer
---@param erow 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] local self = TSHighlighter.active[buf]
if not self then if not self then
return return
@@ -435,30 +441,31 @@ function TSHighlighter._on_spell_nav(_, _, buf, srow, _, erow, _)
-- Do not affect potentially populated highlight state. Here we just want a temporary -- 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. -- empty state so the C code can detect whether the region should be spell checked.
local highlight_states = self._highlight_states local highlight_states = self._highlight_states[win]
self:prepare_highlight_states(srow, erow) self:prepare_highlight_states(win, srow, erow)
for row = srow, erow do for row = srow, erow do
on_line_impl(self, buf, row, true, false) on_line_impl(self, win, buf, row, true, false)
end end
self._highlight_states = highlight_states self._highlight_states[win] = highlight_states
end end
---@private ---@private
---@param win integer
---@param buf integer ---@param buf integer
---@param row integer ---@param row integer
function TSHighlighter._on_conceal_line(_, _, buf, row) function TSHighlighter._on_conceal_line(_, win, buf, row)
local self = TSHighlighter.active[buf] local self = TSHighlighter.active[buf]
if not self or not self._conceal_line or self._conceal_checked[row] then if not self or not self._conceal_line or self._conceal_checked[row] then
return return
end end
-- Do not affect potentially populated highlight state. -- 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.tree:parse({ row, row })
self:prepare_highlight_states(row, row) self:prepare_highlight_states(win, row, row)
on_line_impl(self, buf, row, false, true) on_line_impl(self, win, buf, row, false, true)
self._highlight_states = highlight_states self._highlight_states[win] = highlight_states
end end
---@private ---@private
@@ -470,33 +477,31 @@ function TSHighlighter._on_win(_, win, buf, topline, botline)
if not self then if not self then
return false return false
end end
self.parsing = self.parsing self.parsing[win] = self.parsing[win]
or nil or nil
== self.tree:parse({ topline, botline + 1 }, function(_, trees) == self.tree:parse({ topline, botline + 1 }, function(_, trees)
if trees and self.parsing then if trees and self.parsing[win] then
self.parsing = false self.parsing[win] = false
api.nvim__redraw({ win = win, valid = false, flush = false }) api.nvim__redraw({ win = win, valid = false, flush = false })
end end
end) end)
if not self.parsing then if not self.parsing[win] then
self.redraw_count = self.redraw_count + 1 self.redraw_count = self.redraw_count + 1
self:prepare_highlight_states(topline, botline) self:prepare_highlight_states(win, topline, botline)
else 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 -- 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 -- non-folded ranges of each open window should be merged, and iterators should only be
-- created over those regions. This would also fix #31777. -- created over those regions. This would also fix #31777.
-- --
-- Currently this is not possible because the parser discards previously parsed injection -- Currently this is not possible because the parser discards previously parsed injection
-- trees upon parsing a different region. -- 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.iter = nil
state.next_row = 0 state.next_row = 0
end) end)
end end
return #self._highlight_states > 0 local hl_states = self._highlight_states[win] or {}
return #hl_states > 0
end end
api.nvim_set_decoration_provider(ns, { api.nvim_set_decoration_provider(ns, {