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

@@ -9,7 +9,7 @@
| `vterm/terminal.py` | `Terminal`: pty process, emulator ownership, the reader/renderer threads, and everything written back to the app. | | `vterm/terminal.py` | `Terminal`: pty process, emulator ownership, the reader/renderer threads, and everything written back to the app. |
| `vterm/emulator.py` | `VtScreen` and `VtStream`: pyte subclasses adding the terminal features these agents need. | | `vterm/emulator.py` | `VtScreen` and `VtStream`: pyte subclasses adding the terminal features these agents need. |
| `vterm/render.py` | Screen → view text, colour overlays, caret, folding, trimming. | | `vterm/render.py` | Screen → view text, colour overlays, caret, folding, trimming. |
| `vterm/scheme.py` | `SchemeManager`: generates the colour scheme that defines the scopes the renderer paints with. | | `vterm/scheme.py` | `SchemeManager`: generates one theme's colour scheme, the scopes the renderer paints with. `get_scheme_manager` caches one instance per theme name, each terminal frozen to the instance it was opened or rethemed with. |
| `vterm/themes.py` | The built-in palettes (`default`, `homebrew`, `pro`, `slate`, `red-alert`). | | `vterm/themes.py` | The built-in palettes (`default`, `homebrew`, `pro`, `slate`, `red-alert`). |
| `vterm/commands.py` | The Sublime commands: open, keypress, copy/paste, theme picker, clear, cleanup, close. | | `vterm/commands.py` | The Sublime commands: open, keypress, copy/paste, theme picker, clear, cleanup, close. |
| `vterm/listeners.py` | `VtermCoreEventListener`: focus, IME capture, copy/paste rerouting, the mousemap context. | | `vterm/listeners.py` | `VtermCoreEventListener`: focus, IME capture, copy/paste rerouting, the mousemap context. |

View File

@@ -104,9 +104,10 @@ commented out beside it), `debug` (verbose logging to the ST console), plus the
colours: colours:
- `theme` — `"default"` (Terminal.app Basic, light), `"homebrew"`, `"pro"`, - `theme` — `"default"` (Terminal.app Basic, light), `"homebrew"`, `"pro"`,
`"slate"`, `"red-alert"`, or `null` to follow the active Sublime colour scheme. Set it with `"slate"`, `"red-alert"`, or `null` to follow the active Sublime colour scheme. The
*vterm: Select Theme* or by hand; either way open terminals restyle in place, default for terminals opened from now on. *vterm: Select Theme* retints the
no restart needed. focused terminal immediately and sets this; other already-open terminals keep
whatever theme they had.
- `ansi_colors` — override individual base colours, e.g. `{"red": "#ff0000"}`, - `ansi_colors` — override individual base colours, e.g. `{"red": "#ff0000"}`,
applied on top of the theme. applied on top of the theme.

13
main.py
View File

@@ -5,6 +5,8 @@ registry would orphan open terminal tabs.
""" """
import logging import logging
import sublime
# imported for sublime's command discovery, not called here # imported for sublime's command discovery, not called here
from .vterm.commands import ( from .vterm.commands import (
VtermClearScreenCommand, VtermClearScreenCommand,
@@ -28,7 +30,7 @@ from .vterm.mouse import (
VtermMouseEventListener, VtermMouseEventListener,
VtermScrollCommand, VtermScrollCommand,
) )
from .vterm.scheme import scheme_manager from .vterm.scheme import build_startup_schemes
from .vterm.utils import settings from .vterm.utils import settings
from .vterm.render import ( from .vterm.render import (
VtermRenderCommand, VtermRenderCommand,
@@ -37,9 +39,12 @@ from .vterm.render import (
def plugin_loaded(): def plugin_loaded():
"""Write the colour scheme and set the log level, once per plugin host.""" """Write any missing theme's colour scheme and set the log level, once per
# synchronous: a restored tab's color_scheme is read during startup plugin host: a restored tab's color_scheme is read during startup and may
scheme_manager.ensure_file() name any of them.
"""
view = sublime.active_window().active_view() if sublime.active_window() else None
build_startup_schemes(view)
level = logging.DEBUG if settings().get("debug", False) else logging.INFO level = logging.DEBUG if settings().get("debug", False) else logging.INFO
logging.basicConfig() logging.basicConfig()

View File

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

View File

@@ -5,7 +5,10 @@ from _bootstrap import FakeProcess
import sublime import sublime
from vterm.commands import VtermPasteSelectionCommand, VtermSelectThemeCommand, selection_text from vterm.commands import VtermPasteSelectionCommand, VtermSelectThemeCommand, selection_text
from vterm.emulator import VtScreen
from vterm.render import CONTINUATION 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.utils import SETTINGS_FILE
from vterm.themes import THEMES from vterm.themes import THEMES
@@ -13,72 +16,113 @@ settings = sublime.load_settings(SETTINGS_FILE)
panel = {} 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: 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): 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)
def open_picker(current): def fake_terminal(theme_name):
settings.set("theme", current) """A terminal frozen to a theme, registered the way a real one is."""
command = VtermSelectThemeCommand(FakeWindow()) 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() command.run()
return command return command
# every theme is offered, with "no theme" first # 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 [i[0] for i in panel["items"]] == ["No theme"] + sorted(THEMES), panel["items"]
assert panel["selected_index"] == 0 assert panel["selected_index"] == 0
# the configured theme starts highlighted # the focused terminal's own theme starts highlighted, not the global default
command = open_picker("pro") settings.set("theme", "homebrew")
terminal = fake_terminal("pro")
command = open_picker(terminal)
assert panel["selected_index"] == command.choices.index("pro") 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) saves = len(sublime._saved)
panel["on_highlight"](command.choices.index("slate")) 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" 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) panel["on_done"](-1)
assert settings.get("theme") == "pro", settings.get("theme") assert terminal.scheme.theme_name == "pro", terminal.scheme.theme_name
# confirming persists # confirming retheme the focused terminal and persists the default for new terminals
command = open_picker("pro") command = open_picker(terminal)
panel["on_done"](command.choices.index("homebrew")) 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 settings.get("theme") == "homebrew", settings.get("theme")
assert sublime._saved[-1] == SETTINGS_FILE 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 # "no theme" persists as null rather than a name
command = open_picker("slate") 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")
print("theme picker tests passed ({} themes offered)".format(len(THEMES))) print("theme picker tests passed ({} themes offered)".format(len(THEMES)))
# a palette change must reach terminals that are already running: the screen # a palette change must reach the screen it was applied to: it answers OSC 10/11
# answers OSC 10/11 and DSR 996 from these values # and DSR 996 from these values
from vterm.emulator import VtScreen, VtStream from vterm.emulator import VtStream
from vterm.scheme import scheme_manager
from vterm.terminal import Terminal
recorder = FakeProcess() recorder = FakeProcess()
# real __init__ (view unused here); process/screen stand in for start() terminal = fake_terminal("default")
terminal = Terminal(None)
terminal.screen = VtScreen(20, 3, process=recorder, fg_color="000000", terminal.screen = VtScreen(20, 3, process=recorder, fg_color="000000",
bg_color="ffffff", is_dark=False) bg_color="ffffff", is_dark=False)
terminal.process = recorder terminal.process = recorder
stream = VtStream(terminal.screen) stream = VtStream(terminal.screen)
scheme_manager.foreground = "#eeeeee" terminal.scheme = get_scheme_manager("slate")
scheme_manager.background = "#111111"
terminal.apply_scheme() terminal.apply_scheme()
assert terminal.screen.default_bg_color == "111111", terminal.screen.default_bg_color assert terminal.screen.default_fg_color == terminal.scheme.foreground.lstrip("#")
assert terminal.screen.theme_is_dark is True assert terminal.screen.default_bg_color == terminal.scheme.background.lstrip("#")
# without mode 2031 the app is not interrupted # without mode 2031 the app is not interrupted
assert recorder.written == "", repr(recorder.written) 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 # and the emulator answers a colour query with the new palette
recorder.written = "" recorder.written = ""
stream.feed("\x1b]11;?\x07") 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)") 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 # 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 import os
from vterm.scheme import SCHEME_FILE_NAME
path = os.path.join(scheme_manager._directory(), SCHEME_FILE_NAME) scheme = get_scheme_manager("pro")
if os.path.exists(path): path = os.path.join(scheme._directory(), scheme.file_name)
os.remove(path) assert os.path.exists(path), "get_scheme_manager did not write the scheme"
scheme_manager.ensure_file() # the cached instance is reused, not rebuilt
assert os.path.exists(path), "ensure_file did not write the scheme" assert get_scheme_manager("pro") is scheme, "theme managers must be cached"
assert not scheme_manager._initialized, \
"ensure_file must leave the palette open to refinement by initialize()"
# a second call is a no-op: same file, untouched # build is idempotent
stamp = os.path.getmtime(path) with scheme.lock:
scheme_manager.ensure_file() first = len(scheme.rules)
assert os.path.getmtime(path) == stamp, "ensure_file rewrote an existing scheme" scheme.build()
assert len(scheme.rules) == first, \
# _build is idempotent, which is what makes the ensure_file + initialize pair safe "rules duplicated: {} -> {}".format(first, len(scheme.rules))
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))
print("scheme file tests passed ({} seeded rules, idempotent)".format(first)) 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" 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") load_sublime_json("Default (OSX).sublime-mousemap")
# scheme manager produces scopes and rules # a theme's scheme manager produces scopes and rules
import vterm.scheme as scheme_module from vterm.scheme import get_scheme_manager
from vterm.scheme import scheme_manager scope = get_scheme_manager("default").scope("ff0000", "default", "b")
scope = scheme_manager.scope("ff0000", "default", "b")
assert scope == "vterm.ff0000.default.b", scope assert scope == "vterm.ff0000.default.b", scope
# every command referenced by the palette and the keymap must exist, and the # 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") configured = load_sublime_json("vterm.sublime-settings").get("theme")
assert configured is None or configured in THEMES, configured assert configured is None or configured in THEMES, configured
# plugin_loaded must create the colour scheme: sublime loads a restored tab's # plugin_loaded must create every theme's colour scheme: sublime loads a restored
# color_scheme during startup, before any terminal opens # 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 - # 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 # 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 = types.ModuleType("vterm")
pkg.__path__ = [ROOT] pkg.__path__ = [ROOT]
sys.modules["vterm"] = pkg sys.modules["vterm"] = pkg
main = importlib.import_module("vterm.main") 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() main.plugin_loaded()
assert os.path.exists(scheme_file), \ for name, scheme_file in zip(ALL_THEME_NAMES, scheme_files):
"plugin_loaded must leave the colour scheme on disk" 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, " print("import smoke test passed ({} keymap bindings, {} pending flags, "
"{} themes)".format(len(keymap), len(flags), len(THEMES))) "{} themes)".format(len(keymap), len(flags), len(THEMES)))

View File

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

View File

@@ -22,8 +22,9 @@
"min_columns": 20, "min_columns": 20,
"max_columns": 500, "max_columns": 500,
// colour palette: "default", "homebrew", "pro", "slate", or null to // colour palette: "default", "homebrew", "pro", "slate", "red-alert", or null
// follow the active Sublime colour scheme. Applies to open tabs too. // to follow the active Sublime colour scheme. Default for terminals opened
// from now on; vterm: Select Theme retints the focused terminal and sets this.
"theme": null, "theme": null,
// override any of the 16 ANSI colors, e.g. {"red": "#ff0000"} // override any of the 16 ANSI colors, e.g. {"red": "#ff0000"}

View File

@@ -7,9 +7,9 @@ import sublime
import sublime_plugin import sublime_plugin
from .render import CONTINUATION, scroll_view from .render import CONTINUATION, scroll_view
from .scheme import SCHEME_PATH, scheme_manager from .scheme import get_scheme_manager
from .terminal import Terminal from .terminal import Terminal
from .themes import THEMES from .themes import ALL_THEME_NAMES, THEMES
from .utils import SETTINGS_FILE, settings from .utils import SETTINGS_FILE, settings
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@@ -43,12 +43,12 @@ 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_manager.initialize(window.active_view()) scheme = get_scheme_manager(settings().get("theme"), host_view=window.active_view())
view = window.new_file() view = window.new_file()
view.run_command("vterm_initialize_view") view.run_command("vterm_initialize_view", {"color_scheme": scheme.path})
terminal = Terminal(view) terminal = Terminal(view, scheme)
try: try:
terminal.start(cmd, cwd=cwd, env=env, default_title=title or os.path.basename(cmd[0])) terminal.start(cmd, cwd=cwd, env=env, default_title=title or os.path.basename(cmd[0]))
except Exception: except Exception:
@@ -106,7 +106,7 @@ class VtermOpenTerminalProjectFolderCommand(sublime_plugin.WindowCommand):
class VtermInitializeViewCommand(sublime_plugin.TextCommand): class VtermInitializeViewCommand(sublime_plugin.TextCommand):
"""Stamps a fresh view with the settings that make it a terminal.""" """Stamps a fresh view with the settings that make it a terminal."""
def run(self, _): def run(self, _, color_scheme):
"""Apply the view settings; the pty starts only after this.""" """Apply the view settings; the pty starts only after this."""
view = self.view view = self.view
view_settings = view.settings() view_settings = view.settings()
@@ -125,7 +125,7 @@ class VtermInitializeViewCommand(sublime_plugin.TextCommand):
view_settings.set("draw_indent_guides", False) view_settings.set("draw_indent_guides", False)
view_settings.set("scroll_past_end", False) view_settings.set("scroll_past_end", False)
view_settings.set("scroll_context_lines", 0) view_settings.set("scroll_context_lines", 0)
view_settings.set("color_scheme", SCHEME_PATH) view_settings.set("color_scheme", color_scheme)
view_settings.set("syntax", "Packages/vterm/vterm.sublime-syntax") view_settings.set("syntax", "Packages/vterm/vterm.sublime-syntax")
view_settings.set("spell_check", False) view_settings.set("spell_check", False)
view_settings.set("detect_indentation", False) view_settings.set("detect_indentation", False)
@@ -225,13 +225,20 @@ class VtermPasteSelectionCommand(sublime_plugin.TextCommand):
class VtermSelectThemeCommand(sublime_plugin.WindowCommand): class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
"""Pick a colour palette, previewing each as it is highlighted""" """Retheme the focused terminal, previewing each choice as it is highlighted.
The chosen theme also becomes the default for terminals opened from now on;
it does not touch any other already-open terminal.
"""
def run(self): def run(self):
"""Show the palette picker, starting on the configured theme.""" """Show the palette picker, starting on the focused terminal's own theme."""
self.settings = settings() view = self.window.active_view()
self.original = self.settings.get("theme") self.terminal = Terminal.from_id(view.id()) if view else None
self.choices = [None] + sorted(THEMES) if not self.terminal:
return
self.original = self.terminal.scheme.theme_name
self.choices = ALL_THEME_NAMES
items = [["No theme", "follow the Sublime color scheme"]] items = [["No theme", "follow the Sublime color scheme"]]
for name in self.choices[1:]: for name in self.choices[1:]:
@@ -241,16 +248,32 @@ class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
current = self.choices.index(self.original) if self.original in self.choices else 0 current = self.choices.index(self.original) if self.original in self.choices else 0
self.window.show_quick_panel(items, self._done, selected_index=current, on_highlight=self._preview) self.window.show_quick_panel(items, self._done, selected_index=current, on_highlight=self._preview)
def is_enabled(self):
"""Greys the palette entry out when the focused view is not a terminal."""
view = self.window.active_view()
return view is not None and Terminal.from_id(view.id()) is not None
def _apply(self, name):
"""Retheme the focused terminal alone."""
scheme = get_scheme_manager(name, host_view=self.terminal.view)
self.terminal.retheme(scheme)
def _preview(self, index): def _preview(self, index):
"""Apply a palette while it is highlighted, without saving it.""" """Apply a palette to the focused terminal while it is highlighted."""
self.settings.set("theme", self.choices[index]) self._apply(self.choices[index])
def _done(self, index): def _done(self, index):
"""Persist the choice, or restore the original if the panel was cancelled.""" """On cancel, restore the focused terminal's original theme.
On confirm, keep the focused terminal on its new theme and persist the
choice as the default for terminals opened from now on.
"""
if index == -1: if index == -1:
self.settings.set("theme", self.original) self._apply(self.original)
return return
self.settings.set("theme", self.choices[index]) name = self.choices[index]
self._apply(name)
settings().set("theme", name)
sublime.save_settings(SETTINGS_FILE) sublime.save_settings(SETTINGS_FILE)

View File

@@ -15,7 +15,6 @@ from wcwidth import wcswidth
# per-cell call in segment_buffer_line # per-cell call in segment_buffer_line
cell_width = lru_cache(maxsize=4096)(wcswidth) cell_width = lru_cache(maxsize=4096)(wcswidth)
from .scheme import scheme_manager
from .terminal import Terminal from .terminal import Terminal
from .utils import rev_wcwidth from .utils import rev_wcwidth
@@ -124,6 +123,8 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Set up this view's render caches.""" """Set up this view's render caches."""
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# the terminal's current SchemeManager; set at the top of each run()
self._scheme = None
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
@@ -156,6 +157,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
terminal = Terminal.from_id(view.id()) terminal = Terminal.from_id(view.id())
if not terminal: if not terminal:
return return
self._scheme = terminal.scheme
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
@@ -320,7 +322,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
if is_default_run(fg, bg, style): if is_default_run(fg, bg, style):
continue continue
needed = max(needed, end) needed = max(needed, end)
by_scope.setdefault(scheme_manager.scope(fg, bg, style), []).append((start, end)) by_scope.setdefault(self._scheme.scope(fg, bg, style), []).append((start, end))
text = "".join(s[0] for s in segments) text = "".join(s[0] for s in segments)
if lf: if lf:
@@ -391,7 +393,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
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):
continue continue
scope = scheme_manager.scope(fg, bg, style) scope = self._scheme.scope(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._archived_chunks.setdefault(chunk, set())

View File

@@ -8,16 +8,12 @@ import sublime
from .emulator import ANSI_COLORS from .emulator import ANSI_COLORS
from .utils import settings from .utils import settings
from .themes import THEMES from .themes import ALL_THEME_NAMES, THEMES
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
# written under Packages/User so it survives a package reinstall # written under Packages/User so it survives a package reinstall
SCHEME_DIR_NAME = "User/vterm" SCHEME_DIR_NAME = "User/vterm"
# "hidden-" keeps the generated scheme out of the scheme picker
SCHEME_FILE_NAME = "vterm.hidden-color-scheme"
# the value terminal views get as their color_scheme setting
SCHEME_PATH = "Packages/{}/{}".format(SCHEME_DIR_NAME, SCHEME_FILE_NAME)
# each write makes sublime reload the scheme and restyle every view # each write makes sublime reload the scheme and restyle every view
WRITE_DEBOUNCE_MS = 250 WRITE_DEBOUNCE_MS = 250
@@ -69,104 +65,61 @@ def _is_dark(color_text):
class SchemeManager: class SchemeManager:
"""Owns the generated scheme file and the set of known scopes.""" """Owns one theme's generated scheme file and set of known scopes."""
def __init__(self): def __init__(self, theme_name):
"""Start on the built-in palette; initialize() picks up the real one.""" """State only; build() resolves the palette and writes the file."""
self.theme_name = theme_name
self.file_name = "vterm-{}.hidden-color-scheme".format(theme_name or "system")
self.path = "Packages/{}/{}".format(SCHEME_DIR_NAME, self.file_name)
self.lock = threading.Lock() self.lock = threading.Lock()
self.rules = {} self.rules = {}
# key -> scope name; the render thread reads it without the lock, so it # key -> scope name; the render thread reads it without the lock, so it
# must never outlive self.rules (cleared together in _build) # must never outlive self.rules (cleared together in build)
self._names = {} self._names = {}
self.foreground = "#ffffff" self.foreground = "#ffffff"
self.background = "#000000" self.background = "#000000"
self._editor_fg = "#ffffff"
self._editor_bg = "#000000"
self.ansi = dict(DEFAULT_ANSI) self.ansi = dict(DEFAULT_ANSI)
self._write_pending = False self._write_pending = False
self._initialized = False
self._dir = None self._dir = None
self._host_view = None
self._listeners = []
def initialize(self, view): def build(self, host_view=None):
"""Pick up the palette, seed the rules, write the file. Main thread only"""
if self._initialized:
return
self._initialized = True
self._host_view = view
settings().add_on_change("vterm.theme", self.reload)
self._build()
def ensure_file(self):
"""Write the scheme if missing: a restored tab's color_scheme is loaded during startup"""
if os.path.exists(os.path.join(self._directory(), SCHEME_FILE_NAME)):
return
with self.lock:
self._build()
def add_listener(self, callback):
"""Called after every palette rebuild"""
self._listeners.append(callback)
def remove_listener(self, callback):
"""Drop a rebuild subscriber; terminals unsubscribe when they die."""
if callback in self._listeners:
self._listeners.remove(callback)
def reload(self):
"""Rebuild every rule for a new palette, then notify the terminals"""
if not self._initialized:
# no scheme file to maintain yet; initialize() will read the setting
return
with self.lock:
self._build()
for callback in list(self._listeners):
try:
callback()
except Exception:
LOG.warning("scheme listener failed", exc_info=True)
def _build(self):
"""Resolve the palette and regenerate every rule, then write the file. """Resolve the palette and regenerate every rule, then write the file.
Rules cannot be appended after a palette change: each one stores resolved Rules cannot be appended after a palette change: each one stores resolved
colours, so they all have to be rebuilt. Caller holds the lock. colours, so they all have to be rebuilt. Callable again later (e.g. to
re-sample the "No theme" palette from the editor's current colours).
""" """
self._names = {} with self.lock:
self.rules = {} self._names = {}
self.ansi = dict(DEFAULT_ANSI) self.rules = {}
self.ansi = dict(DEFAULT_ANSI)
theme = THEMES.get(settings().get("theme")) theme = THEMES.get(self.theme_name)
if theme: if theme:
self.foreground = theme["foreground"] self.foreground = theme["foreground"]
self.background = theme["background"] self.background = theme["background"]
self.ansi.update({name: theme[name] for name in ANSI_COLORS if name in theme}) self.ansi.update({name: theme[name] for name in ANSI_COLORS if name in theme})
else: elif host_view is not None and host_view.is_valid():
# captured values outlive the view they came from style = host_view.style()
if self._host_view is not None and self._host_view.is_valid(): self.foreground = style.get("foreground", self.foreground)
style = self._host_view.style() self.background = style.get("background", self.background)
self._editor_fg = style.get("foreground", self._editor_fg)
self._editor_bg = style.get("background", self._editor_bg)
self.foreground = self._editor_fg
self.background = self._editor_bg
# user overrides sit on top of whichever palette was chosen # user overrides sit on top of whichever palette was chosen
user_ansi = settings().get("ansi_colors", {}) user_ansi = settings().get("ansi_colors", {})
for name in ANSI_COLORS: for name in ANSI_COLORS:
if name in user_ansi: if name in user_ansi:
self.ansi[name] = user_ansi[name] self.ansi[name] = user_ansi[name]
# seeded so the first frame does not reference scopes the file lacks # seeded so the first frame does not reference scopes the file lacks
for fg in ANSI_COLORS: for fg in ANSI_COLORS:
self._add_rule(fg, "default", "") self._add_rule(fg, "default", "")
self._add_rule(fg, "default", "b") self._add_rule(fg, "default", "b")
self._add_rule("default", "default", "b") self._add_rule("default", "default", "b")
self._add_rule("reverse_default", "reverse_default", "") self._add_rule("reverse_default", "reverse_default", "")
self._write_file() self._write_file()
def is_dark(self): def is_dark(self):
"""Whether the active palette is dark; reported to the app via OSC 11 and DSR 996.""" """Whether the active palette is dark; reported to the app via OSC 11 and DSR 996."""
@@ -258,11 +211,49 @@ class SchemeManager:
}, },
"rules": list(self.rules.values()), "rules": list(self.rules.values()),
} }
path = os.path.join(self._directory(), SCHEME_FILE_NAME) path = os.path.join(self._directory(), self.file_name)
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
# compact: sublime reparses the whole file on every write # compact: sublime reparses the whole file on every write
json.dump(scheme, f, separators=(",", ":")) json.dump(scheme, f, separators=(",", ":"))
LOG.debug("scheme written: rules=%s", len(self.rules)) LOG.debug("scheme written: rules=%s", len(self.rules))
scheme_manager = SchemeManager() # one manager per theme name, built once and kept for the plugin host's life: the
# built-in theme set is small and fixed, so there is nothing to garbage-collect
_managers = {}
def get_scheme_manager(theme_name, host_view=None):
"""The manager for a theme name (None for "follow the Sublime colour scheme"),
building and caching it on first use.
The None entry is rebuilt on every call instead: it is a live sample of the
editor's current colours, not a static palette, so every caller handing a
terminal a "no theme" scheme gets one that is actually current.
"""
manager = _managers.get(theme_name)
if manager is None:
manager = SchemeManager(theme_name)
_managers[theme_name] = manager
manager.build(host_view)
elif theme_name is None:
manager.build(host_view)
return manager
def build_startup_schemes(host_view=None):
"""Make sure every theme's colour scheme is on disk before Sublime reads a
restored tab's color_scheme at startup.
A named theme is static, so an existing file is left alone - rebuilding it
would just rewrite the same bytes, and each write makes sublime reload the
scheme and restyle every view using it. None always rebuilds, same as
get_scheme_manager.
"""
for name in ALL_THEME_NAMES:
if name is None:
get_scheme_manager(None, host_view=host_view)
continue
manager = SchemeManager(name)
if not os.path.exists(os.path.join(manager._directory(), manager.file_name)):
get_scheme_manager(name)

View File

@@ -13,7 +13,6 @@ from pyte import modes as mo
from .emulator import APP_CURSOR_MODE, VtScreen, VtStream from .emulator import APP_CURSOR_MODE, VtScreen, VtStream
from .keys import get_key_code from .keys import get_key_code
from .scheme import scheme_manager
from .utils import intermission, responsive, settings from .utils import intermission, responsive, settings
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@@ -98,9 +97,10 @@ class Terminal:
_terminals = {} _terminals = {}
def __init__(self, view): def __init__(self, view, scheme=None):
"""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
# 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 = ""
@@ -198,12 +198,11 @@ class Terminal:
erase_scrollback=settings().get("honor_erase_scrollback", False), erase_scrollback=settings().get("honor_erase_scrollback", False),
clear_callback=self._clear_callback, clear_callback=self._clear_callback,
clipboard_callback=self._clipboard_callback, clipboard_callback=self._clipboard_callback,
fg_color=scheme_manager.foreground.lstrip("#"), fg_color=self.scheme.foreground.lstrip("#"),
bg_color=scheme_manager.background.lstrip("#"), bg_color=self.scheme.background.lstrip("#"),
is_dark=scheme_manager.is_dark(), is_dark=self.scheme.is_dark(),
) )
self.stream = VtStream(self.screen) self.stream = VtStream(self.screen)
scheme_manager.add_listener(self.apply_scheme)
self._start_rendering() self._start_rendering()
@@ -211,18 +210,23 @@ class Terminal:
"""Adopt a new palette; mode 2031 subscribers are told so they can restyle""" """Adopt a new palette; mode 2031 subscribers are told so they can restyle"""
# the screen answers OSC 10/11 and DSR 996 from these values # the screen answers OSC 10/11 and DSR 996 from these values
screen = self.screen screen = self.screen
screen.default_fg_color = scheme_manager.foreground.lstrip("#") screen.default_fg_color = self.scheme.foreground.lstrip("#")
screen.default_bg_color = scheme_manager.background.lstrip("#") screen.default_bg_color = self.scheme.background.lstrip("#")
screen.theme_is_dark = scheme_manager.is_dark() screen.theme_is_dark = self.scheme.is_dark()
if screen.theme_notify_enabled(): if screen.theme_notify_enabled():
self.send_string(screen.theme_report(), normalized=False) self.send_string(screen.theme_report(), normalized=False)
def retheme(self, scheme):
"""Switch to a different scheme: vterm: Select Theme, scoped to this terminal."""
self.scheme = scheme
self.view.settings().set("color_scheme", scheme.path)
self.apply_scheme()
def kill(self): def kill(self):
"""Terminate the child and drop this terminal from the registry.""" """Terminate the child and drop this terminal from the registry."""
LOG.debug("[kill] terminating process") LOG.debug("[kill] terminating process")
self.alive = False self.alive = False
self._wake.set() self._wake.set()
scheme_manager.remove_listener(self.apply_scheme)
try: try:
self.process.terminate() self.process.terminate()
except Exception: except Exception:

View File

@@ -71,3 +71,6 @@ THEMES = {
"light_white": "#ffffff", "light_white": "#ffffff",
}, },
} }
# every selectable theme, including None for "follow the active Sublime colour scheme"
ALL_THEME_NAMES = [None] + sorted(THEMES)