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
This commit is contained in:
Vaclav Tvrdik
2026-10-25 10:39:13 +01:00
parent 7b536a7efa
commit e478f41f7c
7 changed files with 77 additions and 13 deletions

View File

@@ -25,6 +25,7 @@ class FakeTerminalView:
self._id = FakeTerminalView._next_id self._id = FakeTerminalView._next_id
FakeTerminalView._next_id += 1 FakeTerminalView._next_id += 1
self._settings = sublime.Settings() self._settings = sublime.Settings()
self._settings.set("vterm_view", True)
def id(self): def id(self):
return self._id return self._id
@@ -36,7 +37,24 @@ class FakeTerminalView:
return True return True
def style(self): 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: class FakeWindow:
@@ -46,6 +64,9 @@ class FakeWindow:
def active_view(self): def active_view(self):
return self._view return self._view
def views(self):
return [self._view, editor]
def show_quick_panel(self, items, on_done, selected_index=-1, on_highlight=None): def show_quick_panel(self, items, on_done, selected_index=-1, on_highlight=None):
panel.update(items=items, on_done=on_done, panel.update(items=items, on_done=on_done,
selected_index=selected_index, on_highlight=on_highlight) selected_index=selected_index, on_highlight=on_highlight)
@@ -105,6 +126,12 @@ command = open_picker(fake_terminal("slate"))
panel["on_done"](0) panel["on_done"](0)
assert settings.get("theme") is None, settings.get("theme") 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))) print("theme picker tests passed ({} themes offered)".format(len(THEMES)))

View File

@@ -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][0].fg == "ff0000" and screen.buffer[0][0].bold, tag
assert screen.buffer[0][1].fg == "ff0000", (tag, screen.buffer[0][1].fg) 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 # SGR 5/25 (blink): ignored, not a crash — Char has no blink field
proc, screen, stream = make() proc, screen, stream = make()
feed(stream, "\x1b[5;31mX\x1b[25mY", char_by_char) 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[2;5r\x1b[Sscrolled\x1b[T\x1b[r"
"\x1b[38:5:100mcolon\x1b[m\r\n" "\x1b[38:5:100mcolon\x1b[m\r\n"
"\x1b[2\x073m bel mid-CSI \x1b[3\x18X can cancels\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" "\x1b[m\x1b[;5m\x1b[5;m\x1b[25m params\r\n"
) )

View File

@@ -191,17 +191,30 @@ command.trim_history(None, terminal)
assert terminal.view.text == before, \ assert terminal.view.text == before, \
"alt screen view was trimmed: {!r}".format(terminal.view.text) "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, stream = make_terminal(rows=5, scrollback=20)
terminal.offset = 40 terminal.offset = 40
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(40))) terminal.view = FakeView("\n".join("row {}".format(i) for i in range(40)))
command = make_command(terminal) command = make_command(terminal)
command.trim_history(None, terminal) command.trim_history(None, terminal)
rows_left = terminal.view.text.count("\n") + 1 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 39" in terminal.view.text, "newest rows must survive"
assert "row 0" not in terminal.view.text, "oldest rows must be dropped" 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 # below the cap nothing is dropped
terminal, stream = make_terminal(rows=5, scrollback=5000) terminal, stream = make_terminal(rows=5, scrollback=5000)
terminal.offset = 3 terminal.offset = 3

View File

@@ -33,12 +33,12 @@
// External terminal for "vterm: Open Terminal in Project Folder". // External terminal for "vterm: Open Terminal in Project Folder".
// %CWD% in parameters is replaced with the project folder. // %CWD% in parameters is replaced with the project folder.
// "terminal": "/usr/bin/open", "terminal": "/usr/bin/open",
// "parameters": ["-a", "Terminal", "%CWD%"], "parameters": ["-a", "Terminal", "%CWD%"],
// kitty instead of Apple Terminal: // kitty instead of Apple Terminal:
"terminal": "/opt/homebrew/bin/kitty", // "terminal": "/opt/homebrew/bin/kitty",
"parameters": ["-d", "%CWD%"], // "parameters": ["-d", "%CWD%"],
"debug": false "debug": false
} }

View File

@@ -18,6 +18,12 @@ LOG = logging.getLogger(__name__)
SCROLL_KEYS = ("pageup", "pagedown", "up", "down") 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): class VtermOpenCommand(sublime_plugin.WindowCommand):
"""Open a terminal in a new tab. `vterm_open`, the only entry point users bind.""" """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 = os.path.expanduser("~")
cwd = sublime.expand_variables(cwd, window.extract_variables()) 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 = window.new_file()
view.run_command("vterm_initialize_view", {"color_scheme": scheme.path}) view.run_command("vterm_initialize_view", {"color_scheme": scheme.path})
@@ -258,7 +264,7 @@ class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
def _apply(self, name): def _apply(self, name):
"""Retheme the focused terminal alone.""" """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) self.terminal.retheme(scheme)
def _preview(self, index): def _preview(self, index):

View File

@@ -83,7 +83,8 @@ def quantize_256(r, g, b):
if r == g == b: if r == g == b:
if r < 8: if r < 8:
return 16 return 16
if r > 248: # 238 is the top of the grey ramp (255); above the midpoint, white
if r > 246:
return 231 return 231
return 232 + (r - 8) // 10 return 232 + (r - 8) // 10
@@ -432,7 +433,8 @@ class VtScreen(pyte.Screen):
self.buffer[pos[0]][pos[1]] = last._replace(data=normalized) self.buffer[pos[0]][pos[1]] = last._replace(data=normalized)
self.dirty.add(pos[0]) self.dirty.add(pos[0])
else: else:
break # other zero-width (VS16, ZWJ) or control: drop it, keep the rest
continue
if char_width > 0: if char_width > 0:
x = cursor.x + char_width x = cursor.x + char_width

View File

@@ -470,7 +470,8 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
view = self.view view = self.view
screen = terminal.screen screen = terminal.screen
lastrow = view.rowcol(view.size())[0] 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: if lastrow + 1 > n:
# queued rows are about to be renumbered or erased: merge them first # queued rows are about to be renumbered or erased: merge them first
self.flush_chunk() self.flush_chunk()