From 8f5564f4aaeba18621ef939b443c215f929d201a Mon Sep 17 00:00:00 2001 From: Vaclav Tvrdik Date: Mon, 12 Oct 2026 15:38:19 +0200 Subject: [PATCH] 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. --- Architecture.md | 9 ++-- tests/test_render.py | 32 ++++++++++-- vterm/render.py | 116 +++++++++++++++++++++++++------------------ vterm/terminal.py | 1 + 4 files changed, 102 insertions(+), 56 deletions(-) diff --git a/Architecture.md b/Architecture.md index 3ba400d..6d94cd6 100644 --- a/Architecture.md +++ b/Architecture.md @@ -52,10 +52,11 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s `Char` carrying the extra `linefeed` field. - **`SchemeManager`** (`scheme.py`) — module singleton `scheme_manager`. Owns the generated scheme file and the mapping from colour combination to scope name. -- **`VtermRenderCommand`** (`render.py`) — Sublime keeps **one command instance per - view**, so its `colored_lines`, `_row_cache`, `_key_counter`, the scrollback chunk - bookkeeping (`_archived_chunks`, `_open_rows`, `_open_chunk`, `_trimmed`) and - `_lastrow` are that view's render state. +- **`VtermRenderCommand`** (`render.py`) — the per-frame logic. Sublime rebuilds every + `TextCommand` instance whenever any plugin loads, so the view's render state + (`RenderState`: `colored_lines`, `row_cache`, `key_counter`, the scrollback chunk + bookkeeping `archived_chunks`, `open_rows`, `open_chunk`, `trimmed`) lives on + `terminal.render_state`, bound as `self.state` at the top of each `run()`. ### Commands diff --git a/tests/test_render.py b/tests/test_render.py index c7797cb..f096814 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -12,6 +12,7 @@ from vterm.emulator import VtScreen, VtStream from vterm.mouse import VtermMouseClickCommand, VtermScrollCommand from vterm.render import ( CHUNK, + RenderState, VtermRenderCommand, focus_cursor, following, @@ -171,6 +172,8 @@ def make_command(terminal): # run() itself sets _scheme, but these tests call its helpers directly command = VtermRenderCommand(terminal.view) command._scheme = terminal.scheme + terminal.render_state = RenderState() + command.state = terminal.render_state return command @@ -282,7 +285,7 @@ assert terminal.view.text.startswith("RED..red..green"), repr(terminal.view.text assert len(painted) == 2, painted # 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" command.decolorize_line(0) 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)") +# 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, # 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 @@ -318,10 +344,10 @@ command = make_command(terminal) stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300))) command.update_lines(None, terminal) 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) 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" print("scrollback archive tests passed (chunk keys bounded, trim erases whole chunks)") diff --git a/vterm/render.py b/vterm/render.py index dd3b141..05b4e7d 100644 --- a/vterm/render.py +++ b/vterm/render.py @@ -116,34 +116,49 @@ def ensure_position(view, edit, row, col=0): view.insert(edit, line_region.end(), " " * (col - lastcol)) -class VtermRenderCommand(sublime_plugin.TextCommand): - """Draws the screen into the view. Sublime keeps one instance per view, so - the caches below are that view's render state.""" +class RenderState: + """A view's render bookkeeping, kept on its Terminal. - def __init__(self, *args, **kwargs): - """Set up this view's render caches.""" - super().__init__(*args, **kwargs) - # the terminal's current SchemeManager; set at the top of each run() - self._scheme = None + Sublime rebuilds every TextCommand instance of every view whenever any + plugin module loads. State held on the command is lost then: the overlays + already in the view become untracked, so a row rewrite no longer erases + them, and the key counter restarts and hands out keys still in use. + """ + + def __init__(self): + """Empty bookkeeping, for a view that has nothing painted yet.""" + # view row -> region keys painted on it self.colored_lines = {} # view row -> (segments, lf) as last rendered; the dirty set is only a # repaint hint, so most dirty rows are identical frame to frame - self._row_cache = {} - self._key_counter = 0 + self.row_cache = {} + self.key_counter = 0 # chunk ordinal -> scopes it holds a key for; ordinals are view row + # 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 - self._open_rows = [] - self._open_chunk = None - self._trimmed = 0 + self.open_rows = [] + self.open_chunk = None + 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._applied_cursor_style = None def next_key(self): """A region key unique to this view; per-row keys would collide after trim_history renumbers rows""" - self._key_counter += 1 - return "vterm#{}".format(self._key_counter) + self.state.key_counter += 1 + return "vterm#{}".format(self.state.key_counter) def ensure_row(self, edit, row): """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: return self._scheme = terminal.scheme + if terminal.render_state is None: + terminal.render_state = RenderState() + self.state = terminal.render_state screen = terminal.screen # 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 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) - for chunk, scopes in self._archived_chunks.items(): + for chunk, scopes in self.state.archived_chunks.items(): for scope in scopes: view.erase_regions(chunk_key(chunk, scope)) - self._archived_chunks.clear() - self._open_rows = [] - self._open_chunk = None - self._trimmed = 0 - self._row_cache.clear() + self.state.archived_chunks.clear() + self.state.open_rows = [] + self.state.open_chunk = None + self.state.trimmed = 0 + self.state.row_cache.clear() terminal.offset = 0 screen.history.clear() 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 # back identical, and rewriting one costs an edit plus its region churn rendered = (segments, lf) - if self._row_cache.get(line) == rendered: + if self.state.row_cache.get(line) == rendered: return - self._row_cache[line] = rendered + self.state.row_cache[line] = rendered by_scope = {} needed = 0 @@ -343,7 +361,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand): def colorize_line(self, line, line_start, by_scope): """Lay the colour overlays for one row, one add_regions per scope.""" 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(): key = self.next_key() 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): """Drop a row's colour overlays before it is rewritten or erased.""" - if line in self.colored_lines: - for key in self.colored_lines[line]: + if line in self.state.colored_lines: + for key in self.state.colored_lines[line]: self.view.erase_regions(key) - del self.colored_lines[line] + del self.state.colored_lines[line] def archive_rows(self, rows): """Queue rows that left the screen for their chunk's merge.""" for row in rows: - if row not in self.colored_lines: + if row not in self.state.colored_lines: continue - chunk = (row + self._trimmed) // CHUNK - if chunk != self._open_chunk: + chunk = (row + self.state.trimmed) // CHUNK + if chunk != self.state.open_chunk: self.flush_chunk() - self._open_chunk = chunk - self._open_rows.append(row) + self.state.open_chunk = chunk + self.state.open_rows.append(row) def flush_chunk(self): """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. """ view = self.view - rows, chunk = self._open_rows, self._open_chunk - self._open_rows = [] + rows, chunk = self.state.open_rows, self.state.open_chunk + self.state.open_rows = [] if not rows: return @@ -385,12 +403,12 @@ class VtermRenderCommand(sublime_plugin.TextCommand): stale = [] scope_of = self._scheme.scope for row in rows: - keys = self.colored_lines.pop(row, None) + keys = self.state.colored_lines.pop(row, None) if not keys: continue stale += keys # 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() for _, a, b, fg, bg, style in segments: if is_default_run(fg, bg, style): @@ -398,7 +416,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand): scope = scope_of(fg, bg, style) 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(): key = chunk_key(chunk, scope) if scope in scopes: @@ -431,7 +449,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand): while row > cursor_row: line_region = view.line(view.text_point(row, 0)) 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 else: 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()) if not trailing_region.empty() and len(view.substr(trailing_region).strip()) == 0: view.erase(edit, trailing_region) - self._row_cache.pop(row, None) + self.state.row_cache.pop(row, None) def trim_history(self, edit, terminal): """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)) for line in range(m): self.decolorize_line(line) - self.colored_lines = {k - m: v for (k, v) in self.colored_lines.items()} - self._row_cache = {k - m: v for (k, v) in self._row_cache.items() if k >= m} + self.state.colored_lines = {k - m: v for (k, v) in self.state.colored_lines.items()} + 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()) view.erase(edit, top_region) terminal.offset -= m @@ -468,12 +486,12 @@ class VtermRenderCommand(sublime_plugin.TextCommand): # chunks wholly above the cut go; the boundary chunk drops the # regions the erase collapsed to empty - self._trimmed += m - cut = self._trimmed // CHUNK - for chunk in [c for c in self._archived_chunks if c < cut]: - for scope in self._archived_chunks.pop(chunk): + self.state.trimmed += m + cut = self.state.trimmed // CHUNK + for chunk in [c for c in self.state.archived_chunks if c < cut]: + for scope in self.state.archived_chunks.pop(chunk): 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) 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): row = view.rowcol(line.begin())[0] self.decolorize_line(row) - self._row_cache.pop(row, None) + self.state.row_cache.pop(row, None) view.erase(edit, tail_region) diff --git a/vterm/terminal.py b/vterm/terminal.py index 2a48309..9763965 100644 --- a/vterm/terminal.py +++ b/vterm/terminal.py @@ -101,6 +101,7 @@ class Terminal: """State only; the pty and the threads come up in start().""" self.view = view self.scheme = scheme + self.render_state = None # process.isalive() costs two waitpid syscalls; UI hooks read this instead self.alive = True self.default_title = ""