From e478f41f7c24cd56e776bf93b0846d3c71bfb531 Mon Sep 17 00:00:00 2001 From: Vaclav Tvrdik Date: Sun, 25 Oct 2026 10:39:13 +0100 Subject: [PATCH] Fix text loss after zero-width chars, grey 248 quantization, scrollback cap, no-theme sampling, shipped terminal default - draw(): a zero-width non-combining char (VS16, ZWJ) or a stray control byte dropped the rest of the run; skip the char instead - quantize_256: grey 248 mapped to slot 256 and aborted the SGR - trim_history: the cap now counts rows above the screen, so a value below the screen height (0: no scrollback) no longer cuts the screen - "No theme" sampled the focused terminal's own colours; sample a non-terminal view instead - vterm.sublime-settings: Apple Terminal is the default again, kitty commented out, as the README says --- tests/test_commands.py | 29 ++++++++++++++++++++++++++++- tests/test_emulator.py | 17 ++++++++++++++++- tests/test_render.py | 17 +++++++++++++++-- vterm.sublime-settings | 8 ++++---- vterm/commands.py | 10 ++++++++-- vterm/emulator.py | 6 ++++-- vterm/render.py | 3 ++- 7 files changed, 77 insertions(+), 13 deletions(-) diff --git a/tests/test_commands.py b/tests/test_commands.py index 4f66726..62c5049 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -25,6 +25,7 @@ class FakeTerminalView: self._id = FakeTerminalView._next_id FakeTerminalView._next_id += 1 self._settings = sublime.Settings() + self._settings.set("vterm_view", True) def id(self): return self._id @@ -36,7 +37,24 @@ class FakeTerminalView: return True def style(self): - return {} + # a terminal view reports its own theme's colours, not the editor's + return {"foreground": "#f2f2f2", "background": "#000000"} + + +class FakeEditorView: + """An ordinary file view, the one the "No theme" palette is sampled from.""" + + def settings(self): + return sublime.Settings() + + def is_valid(self): + return True + + def style(self): + return {"foreground": "#111111", "background": "#eeeeee"} + + +editor = FakeEditorView() class FakeWindow: @@ -46,6 +64,9 @@ class FakeWindow: def active_view(self): return self._view + def views(self): + return [self._view, editor] + def show_quick_panel(self, items, on_done, selected_index=-1, on_highlight=None): panel.update(items=items, on_done=on_done, selected_index=selected_index, on_highlight=on_highlight) @@ -105,6 +126,12 @@ command = open_picker(fake_terminal("slate")) panel["on_done"](0) assert settings.get("theme") is None, settings.get("theme") +# ... and samples the editor's colours, not the themed terminal's own: that +# would retint every "no theme" terminal with the theme being left +system = get_scheme_manager(None) +assert (system.foreground, system.background) == ("#111111", "#eeeeee"), \ + (system.foreground, system.background) + print("theme picker tests passed ({} themes offered)".format(len(THEMES))) diff --git a/tests/test_emulator.py b/tests/test_emulator.py index 87a730f..25fab39 100644 --- a/tests/test_emulator.py +++ b/tests/test_emulator.py @@ -119,6 +119,21 @@ def run_case(char_by_char): assert screen.buffer[0][0].fg == "ff0000" and screen.buffer[0][0].bold, tag assert screen.buffer[0][1].fg == "ff0000", (tag, screen.buffer[0][1].fg) + # every grey lands inside the palette; 248 used to map to slot 256 and + # abort the whole SGR + from vterm.emulator import quantize_256 + assert all(0 <= quantize_256(v, v, v) <= 255 for v in range(256)) + proc, screen, stream = make() + feed(stream, "\x1b[38;2;248;248;248mX", char_by_char) + assert screen.buffer[0][0].fg == "ffffff", (tag, screen.buffer[0][0].fg) + + # a zero-width non-combining char (VS16, ZWJ) or a stray control byte is + # dropped; the text after it must survive + proc, screen, stream = make() + feed(stream, "⚠️ warn \U0001F468‍\U0001F4BB ab\x01cd", char_by_char) + row = "".join(screen.buffer[0][x].data for x in range(20)).rstrip() + assert row == "⚠ warn \U0001F468\U0001F4BB abcd", (tag, repr(row)) + # SGR 5/25 (blink): ignored, not a crash — Char has no blink field proc, screen, stream = make() feed(stream, "\x1b[5;31mX\x1b[25mY", char_by_char) @@ -315,7 +330,7 @@ GAUNTLET = ( "\x1b[2;5r\x1b[Sscrolled\x1b[T\x1b[r" "\x1b[38:5:100mcolon\x1b[m\r\n" "\x1b[2\x073m bel mid-CSI \x1b[3\x18X can cancels\r\n" - "wide: 漢字 \x1b[7m漢\x1b[m\r\n" + "wide: 漢字 \x1b[7m漢\x1b[m ⚠️ after\r\n" "\x1b[m\x1b[;5m\x1b[5;m\x1b[25m params\r\n" ) diff --git a/tests/test_render.py b/tests/test_render.py index 17ebcd4..b29d02f 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -191,17 +191,30 @@ command.trim_history(None, terminal) assert terminal.view.text == before, \ "alt screen view was trimmed: {!r}".format(terminal.view.text) -# the configured cap still applies on the primary screen +# the configured cap still applies on the primary screen: that many rows of +# scrollback above the screen terminal, stream = make_terminal(rows=5, scrollback=20) terminal.offset = 40 terminal.view = FakeView("\n".join("row {}".format(i) for i in range(40))) command = make_command(terminal) command.trim_history(None, terminal) rows_left = terminal.view.text.count("\n") + 1 -assert rows_left <= 20, rows_left +assert rows_left <= 25, rows_left assert "row 39" in terminal.view.text, "newest rows must survive" assert "row 0" not in terminal.view.text, "oldest rows must be dropped" +# a cap below the screen height (0: no scrollback) must never cut the screen +terminal, stream = make_terminal(rows=10, cols=20, scrollback=0) +command = make_command(terminal) +for i in range(3): + stream.feed("\r\n".join("row{}".format(i * 10 + j) for j in range(10)) + "\r\n") + command.update_lines(None, terminal) + command.trim_trailing_spaces(None, terminal) + command.trim_history(None, terminal) + terminal.screen.dirty.clear() +assert terminal.offset >= 0, terminal.offset +assert "row29" in terminal.view.text, terminal.view.text + # below the cap nothing is dropped terminal, stream = make_terminal(rows=5, scrollback=5000) terminal.offset = 3 diff --git a/vterm.sublime-settings b/vterm.sublime-settings index ff13167..cfc3f41 100644 --- a/vterm.sublime-settings +++ b/vterm.sublime-settings @@ -33,12 +33,12 @@ // External terminal for "vterm: Open Terminal in Project Folder". // %CWD% in parameters is replaced with the project folder. - // "terminal": "/usr/bin/open", - // "parameters": ["-a", "Terminal", "%CWD%"], + "terminal": "/usr/bin/open", + "parameters": ["-a", "Terminal", "%CWD%"], // kitty instead of Apple Terminal: - "terminal": "/opt/homebrew/bin/kitty", - "parameters": ["-d", "%CWD%"], + // "terminal": "/opt/homebrew/bin/kitty", + // "parameters": ["-d", "%CWD%"], "debug": false } diff --git a/vterm/commands.py b/vterm/commands.py index 5e1bff3..fc9e336 100644 --- a/vterm/commands.py +++ b/vterm/commands.py @@ -18,6 +18,12 @@ LOG = logging.getLogger(__name__) SCROLL_KEYS = ("pageup", "pagedown", "up", "down") +def editor_view(window): + """A view to sample the editor's colours from: never a terminal, whose + colours are its own theme's.""" + return next((v for v in window.views() if not v.settings().get("vterm_view")), None) + + class VtermOpenCommand(sublime_plugin.WindowCommand): """Open a terminal in a new tab. `vterm_open`, the only entry point users bind.""" @@ -43,7 +49,7 @@ class VtermOpenCommand(sublime_plugin.WindowCommand): cwd = os.path.expanduser("~") cwd = sublime.expand_variables(cwd, window.extract_variables()) - scheme = get_scheme_manager(settings().get("theme"), host_view=window.active_view()) + scheme = get_scheme_manager(settings().get("theme"), host_view=editor_view(window)) view = window.new_file() view.run_command("vterm_initialize_view", {"color_scheme": scheme.path}) @@ -258,7 +264,7 @@ class VtermSelectThemeCommand(sublime_plugin.WindowCommand): def _apply(self, name): """Retheme the focused terminal alone.""" - scheme = get_scheme_manager(name, host_view=self.terminal.view) + scheme = get_scheme_manager(name, host_view=editor_view(self.window)) self.terminal.retheme(scheme) def _preview(self, index): diff --git a/vterm/emulator.py b/vterm/emulator.py index 9d490a4..f53f3c7 100644 --- a/vterm/emulator.py +++ b/vterm/emulator.py @@ -83,7 +83,8 @@ def quantize_256(r, g, b): if r == g == b: if r < 8: return 16 - if r > 248: + # 238 is the top of the grey ramp (255); above the midpoint, white + if r > 246: return 231 return 232 + (r - 8) // 10 @@ -432,7 +433,8 @@ class VtScreen(pyte.Screen): self.buffer[pos[0]][pos[1]] = last._replace(data=normalized) self.dirty.add(pos[0]) else: - break + # other zero-width (VS16, ZWJ) or control: drop it, keep the rest + continue if char_width > 0: x = cursor.x + char_width diff --git a/vterm/render.py b/vterm/render.py index 05b4e7d..b54a7ff 100644 --- a/vterm/render.py +++ b/vterm/render.py @@ -470,7 +470,8 @@ class VtermRenderCommand(sublime_plugin.TextCommand): view = self.view screen = terminal.screen lastrow = view.rowcol(view.size())[0] - n = terminal.scrollback_size + # the setting counts rows above the screen; 0 keeps the screen alone + n = terminal.scrollback_size + screen.lines if lastrow + 1 > n: # queued rows are about to be renumbered or erased: merge them first self.flush_chunk()