select theme applies only to current vterm and newly opened vterms

This commit is contained in:
Vaclav Tvrdik
2026-10-02 12:29:32 +02:00
parent e6aa754762
commit c2a0c75359
13 changed files with 284 additions and 203 deletions

View File

@@ -87,3 +87,7 @@ def expand_variables(value, variables):
def version():
return "4200"
def active_window():
return None

View File

@@ -5,7 +5,10 @@ from _bootstrap import FakeProcess
import sublime
from vterm.commands import VtermPasteSelectionCommand, VtermSelectThemeCommand, selection_text
from vterm.emulator import VtScreen
from vterm.render import CONTINUATION
from vterm.scheme import get_scheme_manager
from vterm.terminal import Terminal
from vterm.utils import SETTINGS_FILE
from vterm.themes import THEMES
@@ -13,72 +16,113 @@ settings = sublime.load_settings(SETTINGS_FILE)
panel = {}
class FakeTerminalView:
"""Just enough view for a Terminal to be rethemed: an id and its own settings."""
_next_id = 1
def __init__(self):
self._id = FakeTerminalView._next_id
FakeTerminalView._next_id += 1
self._settings = sublime.Settings()
def id(self):
return self._id
def settings(self):
return self._settings
def is_valid(self):
return True
def style(self):
return {}
class FakeWindow:
def __init__(self, view):
self._view = view
def active_view(self):
return self._view
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)
def open_picker(current):
settings.set("theme", current)
command = VtermSelectThemeCommand(FakeWindow())
def fake_terminal(theme_name):
"""A terminal frozen to a theme, registered the way a real one is."""
view = FakeTerminalView()
terminal = Terminal(view, get_scheme_manager(theme_name))
terminal.screen = VtScreen(20, 3, process=FakeProcess())
Terminal._terminals[view.id()] = terminal
return terminal
def open_picker(terminal):
command = VtermSelectThemeCommand(FakeWindow(terminal.view))
command.run()
return command
# every theme is offered, with "no theme" first
command = open_picker(None)
command = open_picker(fake_terminal(None))
assert [i[0] for i in panel["items"]] == ["No theme"] + sorted(THEMES), panel["items"]
assert panel["selected_index"] == 0
# the configured theme starts highlighted
command = open_picker("pro")
# the focused terminal's own theme starts highlighted, not the global default
settings.set("theme", "homebrew")
terminal = fake_terminal("pro")
command = open_picker(terminal)
assert panel["selected_index"] == command.choices.index("pro")
# highlighting previews but must not persist
# highlighting previews the focused terminal alone, leaving the global default and
# every other terminal untouched
other = fake_terminal("default")
saves = len(sublime._saved)
panel["on_highlight"](command.choices.index("slate"))
assert settings.get("theme") == "slate", settings.get("theme")
assert terminal.scheme.theme_name == "slate", terminal.scheme.theme_name
assert terminal.view.settings().get("color_scheme") == terminal.scheme.path
assert other.scheme.theme_name == "default", "another open terminal must not retheme"
assert settings.get("theme") == "homebrew", "preview must not touch the global default"
assert len(sublime._saved) == saves, "preview must not save settings"
# cancelling restores the value from before the panel opened
# cancelling restores the focused terminal's theme from before the panel opened
panel["on_done"](-1)
assert settings.get("theme") == "pro", settings.get("theme")
assert terminal.scheme.theme_name == "pro", terminal.scheme.theme_name
# confirming persists
command = open_picker("pro")
# confirming retheme the focused terminal and persists the default for new terminals
command = open_picker(terminal)
panel["on_done"](command.choices.index("homebrew"))
assert terminal.scheme.theme_name == "homebrew", terminal.scheme.theme_name
assert settings.get("theme") == "homebrew", settings.get("theme")
assert sublime._saved[-1] == SETTINGS_FILE
assert other.scheme.theme_name == "default", "another open terminal must not retheme"
# "no theme" persists as null rather than a name
command = open_picker("slate")
command = open_picker(fake_terminal("slate"))
panel["on_done"](0)
assert settings.get("theme") is None, settings.get("theme")
print("theme picker tests passed ({} themes offered)".format(len(THEMES)))
# a palette change must reach terminals that are already running: the screen
# answers OSC 10/11 and DSR 996 from these values
from vterm.emulator import VtScreen, VtStream
from vterm.scheme import scheme_manager
from vterm.terminal import Terminal
# a palette change must reach the screen it was applied to: it answers OSC 10/11
# and DSR 996 from these values
from vterm.emulator import VtStream
recorder = FakeProcess()
# real __init__ (view unused here); process/screen stand in for start()
terminal = Terminal(None)
terminal = fake_terminal("default")
terminal.screen = VtScreen(20, 3, process=recorder, fg_color="000000",
bg_color="ffffff", is_dark=False)
terminal.process = recorder
stream = VtStream(terminal.screen)
scheme_manager.foreground = "#eeeeee"
scheme_manager.background = "#111111"
terminal.scheme = get_scheme_manager("slate")
terminal.apply_scheme()
assert terminal.screen.default_bg_color == "111111", terminal.screen.default_bg_color
assert terminal.screen.theme_is_dark is True
assert terminal.screen.default_fg_color == terminal.scheme.foreground.lstrip("#")
assert terminal.screen.default_bg_color == terminal.scheme.background.lstrip("#")
# without mode 2031 the app is not interrupted
assert recorder.written == "", repr(recorder.written)
@@ -92,37 +136,30 @@ assert recorder.written == "\x1b[?997;1n", repr(recorder.written)
# and the emulator answers a colour query with the new palette
recorder.written = ""
stream.feed("\x1b]11;?\x07")
assert recorder.written == "\x1b]11;rgb:1111/1111/1111\x07", repr(recorder.written)
bg = terminal.screen.default_bg_color
expected = "\x1b]11;rgb:{0}{0}/{1}{1}/{2}{2}\x07".format(bg[0:2], bg[2:4], bg[4:6])
assert recorder.written == expected, repr(recorder.written)
print("live theme tests passed (running screen adopts palette, app notified)")
# the scheme file must exist before any restored tab's color_scheme is loaded at
# startup, and building twice must not duplicate the seeded rules
# startup, and building a theme twice must not duplicate its seeded rules
import os
from vterm.scheme import SCHEME_FILE_NAME
path = os.path.join(scheme_manager._directory(), SCHEME_FILE_NAME)
if os.path.exists(path):
os.remove(path)
scheme = get_scheme_manager("pro")
path = os.path.join(scheme._directory(), scheme.file_name)
assert os.path.exists(path), "get_scheme_manager did not write the scheme"
scheme_manager.ensure_file()
assert os.path.exists(path), "ensure_file did not write the scheme"
assert not scheme_manager._initialized, \
"ensure_file must leave the palette open to refinement by initialize()"
# the cached instance is reused, not rebuilt
assert get_scheme_manager("pro") is scheme, "theme managers must be cached"
# a second call is a no-op: same file, untouched
stamp = os.path.getmtime(path)
scheme_manager.ensure_file()
assert os.path.getmtime(path) == stamp, "ensure_file rewrote an existing scheme"
# _build is idempotent, which is what makes the ensure_file + initialize pair safe
with scheme_manager.lock:
scheme_manager._build()
first = len(scheme_manager.rules)
scheme_manager._build()
assert len(scheme_manager.rules) == first, \
"rules duplicated: {} -> {}".format(first, len(scheme_manager.rules))
# build is idempotent
with scheme.lock:
first = len(scheme.rules)
scheme.build()
assert len(scheme.rules) == first, \
"rules duplicated: {} -> {}".format(first, len(scheme.rules))
print("scheme file tests passed ({} seeded rules, idempotent)".format(first))

View File

@@ -63,10 +63,9 @@ assert get_key_code(key="enter", modify_other_keys=2) == "\r", "unmodified keys
assert get_key_code(key="c", ctrl=True, modify_other_keys=2) == "\x03", "ctrl+c stays a control byte"
load_sublime_json("Default (OSX).sublime-mousemap")
# scheme manager produces scopes and rules
import vterm.scheme as scheme_module
from vterm.scheme import scheme_manager
scope = scheme_manager.scope("ff0000", "default", "b")
# a theme's scheme manager produces scopes and rules
from vterm.scheme import get_scheme_manager
scope = get_scheme_manager("default").scope("ff0000", "default", "b")
assert scope == "vterm.ff0000.default.b", scope
# every command referenced by the palette and the keymap must exist, and the
@@ -114,21 +113,30 @@ for name, theme in THEMES.items():
configured = load_sublime_json("vterm.sublime-settings").get("theme")
assert configured is None or configured in THEMES, configured
# plugin_loaded must create the colour scheme: sublime loads a restored tab's
# color_scheme during startup, before any terminal opens
# plugin_loaded must create every theme's colour scheme: sublime loads a restored
# tab's color_scheme during startup, before any terminal opens, and it may name
# any of them
# import main.py the way sublime does - as a module of the package directory -
# so its relative imports and plugin_loaded body are actually executed
import sublime
from vterm.scheme import SCHEME_DIR_NAME, SchemeManager
from vterm.themes import ALL_THEME_NAMES
scheme_dir = os.path.join(sublime.packages_path(), *SCHEME_DIR_NAME.split("/"))
scheme_files = [os.path.join(scheme_dir, SchemeManager(name).file_name) for name in ALL_THEME_NAMES]
for scheme_file in scheme_files:
if os.path.exists(scheme_file):
os.remove(scheme_file)
pkg = types.ModuleType("vterm")
pkg.__path__ = [ROOT]
sys.modules["vterm"] = pkg
main = importlib.import_module("vterm.main")
scheme_file = os.path.join(main.scheme_manager._directory(), scheme_module.SCHEME_FILE_NAME)
if os.path.exists(scheme_file):
os.remove(scheme_file)
main.plugin_loaded()
assert os.path.exists(scheme_file), \
"plugin_loaded must leave the colour scheme on disk"
for name, scheme_file in zip(ALL_THEME_NAMES, scheme_files):
assert os.path.exists(scheme_file), \
"plugin_loaded must leave {}'s colour scheme on disk".format(name)
print("import smoke test passed ({} keymap bindings, {} pending flags, "
"{} themes)".format(len(keymap), len(flags), len(THEMES)))

View File

@@ -17,6 +17,7 @@ from vterm.render import (
following,
scroll_to_cursor,
)
from vterm.scheme import get_scheme_manager
from vterm.terminal import Terminal
from vterm.utils import SETTINGS_FILE
@@ -157,7 +158,7 @@ class FakeView:
def make_terminal(rows=10, cols=20, scrollback=None):
# real __init__ for the plain attributes; process/screen stand in for start()
terminal = Terminal(FakeView())
terminal = Terminal(FakeView(), get_scheme_manager("default"))
if scrollback is not None:
terminal.scrollback_size = scrollback
terminal.process = FakeProcess()
@@ -165,9 +166,12 @@ def make_terminal(rows=10, cols=20, scrollback=None):
return terminal, VtStream(terminal.screen)
def make_command(view):
# the real __init__, so the fake cannot drift from the command's attributes
return VtermRenderCommand(view)
def make_command(terminal):
# the real __init__, so the fake cannot drift from the command's attributes;
# run() itself sets _scheme, but these tests call its helpers directly
command = VtermRenderCommand(terminal.view)
command._scheme = terminal.scheme
return command
# an app on the alternate screen must not have its view trimmed away: the
@@ -178,7 +182,7 @@ assert terminal.screen.alternate_buffer_mode
assert terminal.screen.history.maxlen == 0, "alt screen zeroes the deque"
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(10)))
command = make_command(terminal.view)
command = make_command(terminal)
before = terminal.view.text
command.trim_history(None, terminal)
assert terminal.view.text == before, \
@@ -188,7 +192,7 @@ assert terminal.view.text == before, \
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.view)
command = make_command(terminal)
command.trim_history(None, terminal)
rows_left = terminal.view.text.count("\n") + 1
assert rows_left <= 20, rows_left
@@ -199,7 +203,7 @@ assert "row 0" not in terminal.view.text, "oldest rows must be dropped"
terminal, stream = make_terminal(rows=5, scrollback=5000)
terminal.offset = 3
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(8)))
command = make_command(terminal.view)
command = make_command(terminal)
command.trim_history(None, terminal)
assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \
terminal.view.text
@@ -209,7 +213,7 @@ assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \
terminal, stream = make_terminal(rows=4)
stream.feed("\x1b[?1049h")
terminal.view = FakeView("top\nrow\n\n")
command = make_command(terminal.view)
command = make_command(terminal)
before = terminal.view.text
command.trim_trailing_spaces(None, terminal)
assert terminal.view.text == before, \
@@ -218,7 +222,7 @@ assert terminal.view.text == before, \
# on the primary screen the blank tail below the cursor still goes
terminal, stream = make_terminal(rows=4)
terminal.view = FakeView("top\nrow\n\n")
command = make_command(terminal.view)
command = make_command(terminal)
command.trim_trailing_spaces(None, terminal)
assert terminal.view.text == "top\nrow", terminal.view.text
@@ -261,7 +265,7 @@ print("cursor visibility tests passed (hidden cursor keeps the caret)")
# batches the runs that share a scope into one add_regions call
terminal, stream = make_terminal(rows=3, cols=20)
terminal.view = FakeView()
command = make_command(terminal.view)
command = make_command(terminal)
stream.feed("\x1b[31mRED\x1b[0m..\x1b[31mred\x1b[0m..\x1b[32mgreen")
command.update_lines(None, terminal)
@@ -291,7 +295,7 @@ print("colorize tests passed (arithmetic offsets, scope batching, key bookkeepin
# the whole session, and merging every frame would re-send the whole chunk
terminal, stream = make_terminal(rows=3, cols=20)
terminal.view = FakeView()
command = make_command(terminal.view)
command = make_command(terminal)
for i in range(300):
stream.feed("\x1b[31mred{}\x1b[0m\r\n".format(i))
command.update_lines(None, terminal)
@@ -310,7 +314,7 @@ assert len(row_keys) == 300 - CHUNK, \
# trimming settles the open chunk, then erases the keys of chunks above the cut
terminal, stream = make_terminal(rows=3, scrollback=30)
terminal.view = FakeView()
command = make_command(terminal.view)
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()
@@ -332,7 +336,7 @@ terminal.view.sel().add(sublime.Region(0, 0)) # the caret of a new view
Terminal._terminals[id(terminal)] = terminal
terminal.view.id = lambda: id(terminal)
terminal.default_title = "zsh"
command = make_command(terminal.view)
command = make_command(terminal)
stream.feed("[vterm] %")
command.run(None)
assert all(r.empty() for r in terminal.view.sel()), \
@@ -359,10 +363,8 @@ assert all(text == "" for text in rewritten), "unchanged rows were rewritten: {}
print("render diff tests passed (unchanged dirty rows skipped)")
# the generated scheme must define a selection colour, or sublime uses blue
from vterm.scheme import blend, scheme_manager
scheme_manager.background = "#303841"
scheme_manager.foreground = "#d8dee9"
assert blend(scheme_manager.background, scheme_manager.foreground, 0.3) == "#626a73"
from vterm.scheme import blend
assert blend("#303841", "#d8dee9", 0.3) == "#626a73"
print("selection artefact tests passed (fresh prompt unselected, user selection kept)")
@@ -370,7 +372,7 @@ print("selection artefact tests passed (fresh prompt unselected, user selection
# DECSCUSR drives the caret shape through view settings; before any app asks,
# and for 0, the caret is the terminal default: a blinking block
terminal, stream = make_terminal(rows=3, cols=20)
command = make_command(terminal.view)
command = make_command(terminal)
settings = terminal.view.settings()
command.sync_cursor_style(terminal)
@@ -621,7 +623,7 @@ terminal, stream = make_terminal(rows=5, cols=20)
terminal.view = FakeView("old1\nold2\nold3\nr0\nr1\nr2\nr3\nr4\n")
terminal.offset = 3
terminal.viewport_y = 400
command = make_command(terminal.view)
command = make_command(terminal)
fold_end = terminal.view.text_point(3, 0) - 1
stream.feed("\x1b[?1049h")
@@ -649,7 +651,7 @@ assert terminal.view.folds == [], terminal.view.folds
# without scrollback there is nothing to fold
terminal, stream = make_terminal(rows=5, cols=20)
terminal.view = FakeView("r0\nr1\nr2\nr3\nr4\n")
command = make_command(terminal.view)
command = make_command(terminal)
stream.feed("\x1b[?1049h")
command.sync_alt_fold(terminal)
assert terminal.view.folds == [], terminal.view.folds