Keep render state on the Terminal; a plugin reload rebuilds the command

Sublime recreates every TextCommand instance whenever any plugin loads,
which dropped colored_lines and restarted the region key counter: old
overlays were never erased and keys were reused. RenderState now lives on
terminal.render_state, which survives reloads.
This commit is contained in:
Vaclav Tvrdik
2026-10-12 15:38:19 +02:00
parent 9656faa385
commit ede2714b57
4 changed files with 102 additions and 56 deletions

View File

@@ -52,10 +52,11 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s
`Char` carrying the extra `linefeed` field. `Char` carrying the extra `linefeed` field.
- **`SchemeManager`** (`scheme.py`) — module singleton `scheme_manager`. Owns the - **`SchemeManager`** (`scheme.py`) — module singleton `scheme_manager`. Owns the
generated scheme file and the mapping from colour combination to scope name. generated scheme file and the mapping from colour combination to scope name.
- **`VtermRenderCommand`** (`render.py`) — Sublime keeps **one command instance per - **`VtermRenderCommand`** (`render.py`) — the per-frame logic. Sublime rebuilds every
view**, so its `colored_lines`, `_row_cache`, `_key_counter`, the scrollback chunk `TextCommand` instance whenever any plugin loads, so the view's render state
bookkeeping (`_archived_chunks`, `_open_rows`, `_open_chunk`, `_trimmed`) and (`RenderState`: `colored_lines`, `row_cache`, `key_counter`, the scrollback chunk
`_lastrow` are that view's render state. bookkeeping `archived_chunks`, `open_rows`, `open_chunk`, `trimmed`) lives on
`terminal.render_state`, bound as `self.state` at the top of each `run()`.
### Commands ### Commands

View File

@@ -12,6 +12,7 @@ from vterm.emulator import VtScreen, VtStream
from vterm.mouse import VtermMouseClickCommand, VtermScrollCommand from vterm.mouse import VtermMouseClickCommand, VtermScrollCommand
from vterm.render import ( from vterm.render import (
CHUNK, CHUNK,
RenderState,
VtermRenderCommand, VtermRenderCommand,
focus_cursor, focus_cursor,
following, following,
@@ -171,6 +172,8 @@ def make_command(terminal):
# run() itself sets _scheme, but these tests call its helpers directly # run() itself sets _scheme, but these tests call its helpers directly
command = VtermRenderCommand(terminal.view) command = VtermRenderCommand(terminal.view)
command._scheme = terminal.scheme command._scheme = terminal.scheme
terminal.render_state = RenderState()
command.state = terminal.render_state
return command return command
@@ -282,7 +285,7 @@ assert terminal.view.text.startswith("RED..red..green"), repr(terminal.view.text
assert len(painted) == 2, painted assert len(painted) == 2, painted
# keys are unique per view and recorded so decolorize_line can erase them # keys are unique per view and recorded so decolorize_line can erase them
assert sorted(terminal.view.regions) == sorted(command.colored_lines[0]), \ assert sorted(terminal.view.regions) == sorted(command.state.colored_lines[0]), \
"colored_lines must track exactly the keys that were added" "colored_lines must track exactly the keys that were added"
command.decolorize_line(0) command.decolorize_line(0)
assert terminal.view.regions == {}, "decolorize must erase every key it added" assert terminal.view.regions == {}, "decolorize must erase every key it added"
@@ -290,6 +293,29 @@ assert terminal.view.regions == {}, "decolorize must erase every key it added"
print("colorize tests passed (arithmetic offsets, scope batching, key bookkeeping)") print("colorize tests passed (arithmetic offsets, scope batching, key bookkeeping)")
# sublime rebuilds every TextCommand instance whenever any plugin loads; the
# bookkeeping lives on the terminal so a rebuilt command still erases what the
# old one painted, and its keys never collide with keys still in use
terminal, stream = make_terminal(rows=3, cols=20)
terminal.view = FakeView()
command = make_command(terminal)
stream.feed("\x1b[31mred line\x1b[0m\r\nplain")
command.update_lines(None, terminal)
terminal.screen.dirty.clear()
assert terminal.view.regions, "the red row must be painted"
# a reload: new command, same terminal
reloaded = VtermRenderCommand(terminal.view)
reloaded._scheme = terminal.scheme
reloaded.state = terminal.render_state
stream.feed("\x1b[H\x1b[2Kgone")
reloaded.update_lines(None, terminal)
assert terminal.view.regions == {}, \
"a rebuilt command must erase the old overlay: {}".format(terminal.view.regions)
assert reloaded.next_key() != "vterm#1", "keys must continue, not restart"
print("reload tests passed (render state survives a rebuilt command)")
# rows that leave the screen have their colours merged onto shared per-(scope, # rows that leave the screen have their colours merged onto shared per-(scope,
# chunk) keys once the chunk closes; per-row keys would otherwise accumulate for # chunk) keys once the chunk closes; per-row keys would otherwise accumulate for
# the whole session, and merging every frame would re-send the whole chunk # the whole session, and merging every frame would re-send the whole chunk
@@ -318,10 +344,10 @@ command = make_command(terminal)
stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300))) stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300)))
command.update_lines(None, terminal) command.update_lines(None, terminal)
terminal.screen.dirty.clear() terminal.screen.dirty.clear()
assert set(command._archived_chunks) == {0}, command._archived_chunks assert set(command.state.archived_chunks) == {0}, command.state.archived_chunks
command.trim_history(None, terminal) command.trim_history(None, terminal)
assert "vterm@0@vterm.red.default" not in terminal.view.regions, "trimmed chunk key left behind" assert "vterm@0@vterm.red.default" not in terminal.view.regions, "trimmed chunk key left behind"
assert 0 not in command._archived_chunks assert 0 not in command.state.archived_chunks
assert "vterm@1@vterm.red.default" in terminal.view.regions, "boundary chunk must survive" assert "vterm@1@vterm.red.default" in terminal.view.regions, "boundary chunk must survive"
print("scrollback archive tests passed (chunk keys bounded, trim erases whole chunks)") print("scrollback archive tests passed (chunk keys bounded, trim erases whole chunks)")

View File

@@ -116,34 +116,49 @@ def ensure_position(view, edit, row, col=0):
view.insert(edit, line_region.end(), " " * (col - lastcol)) view.insert(edit, line_region.end(), " " * (col - lastcol))
class VtermRenderCommand(sublime_plugin.TextCommand): class RenderState:
"""Draws the screen into the view. Sublime keeps one instance per view, so """A view's render bookkeeping, kept on its Terminal.
the caches below are that view's render state."""
def __init__(self, *args, **kwargs): Sublime rebuilds every TextCommand instance of every view whenever any
"""Set up this view's render caches.""" plugin module loads. State held on the command is lost then: the overlays
super().__init__(*args, **kwargs) already in the view become untracked, so a row rewrite no longer erases
# the terminal's current SchemeManager; set at the top of each run() them, and the key counter restarts and hands out keys still in use.
self._scheme = None """
def __init__(self):
"""Empty bookkeeping, for a view that has nothing painted yet."""
# view row -> region keys painted on it
self.colored_lines = {} self.colored_lines = {}
# view row -> (segments, lf) as last rendered; the dirty set is only a # view row -> (segments, lf) as last rendered; the dirty set is only a
# repaint hint, so most dirty rows are identical frame to frame # repaint hint, so most dirty rows are identical frame to frame
self._row_cache = {} self.row_cache = {}
self._key_counter = 0 self.key_counter = 0
# chunk ordinal -> scopes it holds a key for; ordinals are view row + # chunk ordinal -> scopes it holds a key for; ordinals are view row +
# rows trimmed so far, so they survive trim_history renumbering # rows trimmed so far, so they survive trim_history renumbering
self._archived_chunks = {} self.archived_chunks = {}
# rows waiting for their chunk to close, and the chunk they belong to # rows waiting for their chunk to close, and the chunk they belong to
self._open_rows = [] self.open_rows = []
self._open_chunk = None self.open_chunk = None
self._trimmed = 0 self.trimmed = 0
class VtermRenderCommand(sublime_plugin.TextCommand):
"""Draws the screen into the view. Holds nothing a frame depends on: the
view's bookkeeping is `terminal.render_state` (see RenderState)."""
def __init__(self, *args, **kwargs):
"""Per-instance scratch only; the render state is bound in run()."""
super().__init__(*args, **kwargs)
# the terminal's current SchemeManager and RenderState; set at the top of each run()
self._scheme = None
self.state = None
self._lastrow = 0 self._lastrow = 0
self._applied_cursor_style = None self._applied_cursor_style = None
def next_key(self): def next_key(self):
"""A region key unique to this view; per-row keys would collide after trim_history renumbers rows""" """A region key unique to this view; per-row keys would collide after trim_history renumbers rows"""
self._key_counter += 1 self.state.key_counter += 1
return "vterm#{}".format(self._key_counter) return "vterm#{}".format(self.state.key_counter)
def ensure_row(self, edit, row): def ensure_row(self, edit, row):
"""Row-existence check for the hot path, tracking the last row in python""" """Row-existence check for the hot path, tracking the last row in python"""
@@ -158,6 +173,9 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
if not terminal: if not terminal:
return return
self._scheme = terminal.scheme self._scheme = terminal.scheme
if terminal.render_state is None:
terminal.render_state = RenderState()
self.state = terminal.render_state
screen = terminal.screen screen = terminal.screen
# the caret and the tab name are the user's; an unfocused terminal leaves # the caret and the tab name are the user's; an unfocused terminal leaves
@@ -256,16 +274,16 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
screen = terminal.screen screen = terminal.screen
view.replace(edit, sublime.Region(0, view.size()), "") view.replace(edit, sublime.Region(0, view.size()), "")
for line in list(self.colored_lines): for line in list(self.state.colored_lines):
self.decolorize_line(line) self.decolorize_line(line)
for chunk, scopes in self._archived_chunks.items(): for chunk, scopes in self.state.archived_chunks.items():
for scope in scopes: for scope in scopes:
view.erase_regions(chunk_key(chunk, scope)) view.erase_regions(chunk_key(chunk, scope))
self._archived_chunks.clear() self.state.archived_chunks.clear()
self._open_rows = [] self.state.open_rows = []
self._open_chunk = None self.state.open_chunk = None
self._trimmed = 0 self.state.trimmed = 0
self._row_cache.clear() self.state.row_cache.clear()
terminal.offset = 0 terminal.offset = 0
screen.history.clear() screen.history.clear()
if wipe_screen: if wipe_screen:
@@ -312,9 +330,9 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
# the dirty set is a repaint hint, not a diff: most dirty rows come # the dirty set is a repaint hint, not a diff: most dirty rows come
# back identical, and rewriting one costs an edit plus its region churn # back identical, and rewriting one costs an edit plus its region churn
rendered = (segments, lf) rendered = (segments, lf)
if self._row_cache.get(line) == rendered: if self.state.row_cache.get(line) == rendered:
return return
self._row_cache[line] = rendered self.state.row_cache[line] = rendered
by_scope = {} by_scope = {}
needed = 0 needed = 0
@@ -343,7 +361,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
def colorize_line(self, line, line_start, by_scope): def colorize_line(self, line, line_start, by_scope):
"""Lay the colour overlays for one row, one add_regions per scope.""" """Lay the colour overlays for one row, one add_regions per scope."""
view = self.view view = self.view
keys = self.colored_lines.setdefault(line, []) if by_scope else None keys = self.state.colored_lines.setdefault(line, []) if by_scope else None
for scope, spans in by_scope.items(): for scope, spans in by_scope.items():
key = self.next_key() key = self.next_key()
view.add_regions(key, [sublime.Region(line_start + a, line_start + b) for a, b in spans], scope) view.add_regions(key, [sublime.Region(line_start + a, line_start + b) for a, b in spans], scope)
@@ -351,21 +369,21 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
def decolorize_line(self, line): def decolorize_line(self, line):
"""Drop a row's colour overlays before it is rewritten or erased.""" """Drop a row's colour overlays before it is rewritten or erased."""
if line in self.colored_lines: if line in self.state.colored_lines:
for key in self.colored_lines[line]: for key in self.state.colored_lines[line]:
self.view.erase_regions(key) self.view.erase_regions(key)
del self.colored_lines[line] del self.state.colored_lines[line]
def archive_rows(self, rows): def archive_rows(self, rows):
"""Queue rows that left the screen for their chunk's merge.""" """Queue rows that left the screen for their chunk's merge."""
for row in rows: for row in rows:
if row not in self.colored_lines: if row not in self.state.colored_lines:
continue continue
chunk = (row + self._trimmed) // CHUNK chunk = (row + self.state.trimmed) // CHUNK
if chunk != self._open_chunk: if chunk != self.state.open_chunk:
self.flush_chunk() self.flush_chunk()
self._open_chunk = chunk self.state.open_chunk = chunk
self._open_rows.append(row) self.state.open_rows.append(row)
def flush_chunk(self): def flush_chunk(self):
"""Merge the queued rows onto shared per-(scope, chunk) keys. """Merge the queued rows onto shared per-(scope, chunk) keys.
@@ -376,8 +394,8 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
whole set, so merging every frame would be quadratic in CHUNK. whole set, so merging every frame would be quadratic in CHUNK.
""" """
view = self.view view = self.view
rows, chunk = self._open_rows, self._open_chunk rows, chunk = self.state.open_rows, self.state.open_chunk
self._open_rows = [] self.state.open_rows = []
if not rows: if not rows:
return return
@@ -385,12 +403,12 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
stale = [] stale = []
scope_of = self._scheme.scope scope_of = self._scheme.scope
for row in rows: for row in rows:
keys = self.colored_lines.pop(row, None) keys = self.state.colored_lines.pop(row, None)
if not keys: if not keys:
continue continue
stale += keys stale += keys
# the segments that were painted, straight from the row cache # the segments that were painted, straight from the row cache
segments = self._row_cache[row][0] segments = self.state.row_cache[row][0]
start = view.line(view.text_point(row, 0)).begin() start = view.line(view.text_point(row, 0)).begin()
for _, a, b, fg, bg, style in segments: for _, a, b, fg, bg, style in segments:
if is_default_run(fg, bg, style): if is_default_run(fg, bg, style):
@@ -398,7 +416,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
scope = scope_of(fg, bg, style) scope = scope_of(fg, bg, style)
grouped.setdefault(scope, []).append(sublime.Region(start + a, start + b)) grouped.setdefault(scope, []).append(sublime.Region(start + a, start + b))
scopes = self._archived_chunks.setdefault(chunk, set()) scopes = self.state.archived_chunks.setdefault(chunk, set())
for scope, regions in grouped.items(): for scope, regions in grouped.items():
key = chunk_key(chunk, scope) key = chunk_key(chunk, scope)
if scope in scopes: if scope in scopes:
@@ -431,7 +449,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
while row > cursor_row: while row > cursor_row:
line_region = view.line(view.text_point(row, 0)) line_region = view.line(view.text_point(row, 0))
text = view.substr(line_region) text = view.substr(line_region)
if len(text.strip()) == 0 and (row not in self.colored_lines or not self.colored_lines[row]): if len(text.strip()) == 0 and (row not in self.state.colored_lines or not self.state.colored_lines[row]):
row = row - 1 row = row - 1
else: else:
break break
@@ -445,7 +463,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
trailing_region = sublime.Region(line_region.begin() + rev_wcwidth(text, cursor.x) + 1, line_region.end()) trailing_region = sublime.Region(line_region.begin() + rev_wcwidth(text, cursor.x) + 1, line_region.end())
if not trailing_region.empty() and len(view.substr(trailing_region).strip()) == 0: if not trailing_region.empty() and len(view.substr(trailing_region).strip()) == 0:
view.erase(edit, trailing_region) view.erase(edit, trailing_region)
self._row_cache.pop(row, None) self.state.row_cache.pop(row, None)
def trim_history(self, edit, terminal): def trim_history(self, edit, terminal):
"""Hold the view to scrollback_size rows, and drop rows below the screen.""" """Hold the view to scrollback_size rows, and drop rows below the screen."""
@@ -459,8 +477,8 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
m = max(lastrow + 1 - n, math.ceil(n / 10)) m = max(lastrow + 1 - n, math.ceil(n / 10))
for line in range(m): for line in range(m):
self.decolorize_line(line) self.decolorize_line(line)
self.colored_lines = {k - m: v for (k, v) in self.colored_lines.items()} self.state.colored_lines = {k - m: v for (k, v) in self.state.colored_lines.items()}
self._row_cache = {k - m: v for (k, v) in self._row_cache.items() if k >= m} self.state.row_cache = {k - m: v for (k, v) in self.state.row_cache.items() if k >= m}
top_region = sublime.Region(0, view.full_line(view.text_point(m - 1, 0)).end()) top_region = sublime.Region(0, view.full_line(view.text_point(m - 1, 0)).end())
view.erase(edit, top_region) view.erase(edit, top_region)
terminal.offset -= m terminal.offset -= m
@@ -468,12 +486,12 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
# chunks wholly above the cut go; the boundary chunk drops the # chunks wholly above the cut go; the boundary chunk drops the
# regions the erase collapsed to empty # regions the erase collapsed to empty
self._trimmed += m self.state.trimmed += m
cut = self._trimmed // CHUNK cut = self.state.trimmed // CHUNK
for chunk in [c for c in self._archived_chunks if c < cut]: for chunk in [c for c in self.state.archived_chunks if c < cut]:
for scope in self._archived_chunks.pop(chunk): for scope in self.state.archived_chunks.pop(chunk):
view.erase_regions(chunk_key(chunk, scope)) view.erase_regions(chunk_key(chunk, scope))
for scope in self._archived_chunks.get(cut, ()): for scope in self.state.archived_chunks.get(cut, ()):
key = chunk_key(cut, scope) key = chunk_key(cut, scope)
view.add_regions(key, [r for r in view.get_regions(key) if not r.empty()], scope) view.add_regions(key, [r for r in view.get_regions(key) if not r.empty()], scope)
@@ -482,7 +500,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
for line in view.lines(tail_region): for line in view.lines(tail_region):
row = view.rowcol(line.begin())[0] row = view.rowcol(line.begin())[0]
self.decolorize_line(row) self.decolorize_line(row)
self._row_cache.pop(row, None) self.state.row_cache.pop(row, None)
view.erase(edit, tail_region) view.erase(edit, tail_region)

View File

@@ -101,6 +101,7 @@ class Terminal:
"""State only; the pty and the threads come up in start().""" """State only; the pty and the threads come up in start()."""
self.view = view self.view = view
self.scheme = scheme self.scheme = scheme
self.render_state = None
# process.isalive() costs two waitpid syscalls; UI hooks read this instead # process.isalive() costs two waitpid syscalls; UI hooks read this instead
self.alive = True self.alive = True
self.default_title = "" self.default_title = ""