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/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/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/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. |

View File

@@ -104,9 +104,10 @@ commented out beside it), `debug` (verbose logging to the ST console), plus the
colours:
- `theme` — `"default"` (Terminal.app Basic, light), `"homebrew"`, `"pro"`,
`"slate"`, `"red-alert"`, or `null` to follow the active Sublime colour scheme. Set it with
*vterm: Select Theme* or by hand; either way open terminals restyle in place,
no restart needed.
`"slate"`, `"red-alert"`, or `null` to follow the active Sublime colour scheme. The
default for terminals opened from now on. *vterm: Select Theme* retints the
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"}`,
applied on top of the theme.

13
main.py
View File

@@ -5,6 +5,8 @@ registry would orphan open terminal tabs.
"""
import logging
import sublime
# imported for sublime's command discovery, not called here
from .vterm.commands import (
VtermClearScreenCommand,
@@ -28,7 +30,7 @@ from .vterm.mouse import (
VtermMouseEventListener,
VtermScrollCommand,
)
from .vterm.scheme import scheme_manager
from .vterm.scheme import build_startup_schemes
from .vterm.utils import settings
from .vterm.render import (
VtermRenderCommand,
@@ -37,9 +39,12 @@ from .vterm.render import (
def plugin_loaded():
"""Write the colour scheme and set the log level, once per plugin host."""
# synchronous: a restored tab's color_scheme is read during startup
scheme_manager.ensure_file()
"""Write any missing theme's colour scheme and set the log level, once per
plugin host: a restored tab's color_scheme is read during startup and may
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
logging.basicConfig()

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()
for name, scheme_file in zip(ALL_THEME_NAMES, scheme_files):
assert os.path.exists(scheme_file), \
"plugin_loaded must leave the colour scheme on disk"
"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

View File

@@ -22,8 +22,9 @@
"min_columns": 20,
"max_columns": 500,
// colour palette: "default", "homebrew", "pro", "slate", or null to
// follow the active Sublime colour scheme. Applies to open tabs too.
// colour palette: "default", "homebrew", "pro", "slate", "red-alert", or null
// 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,
// override any of the 16 ANSI colors, e.g. {"red": "#ff0000"}

View File

@@ -7,9 +7,9 @@ import sublime
import sublime_plugin
from .render import CONTINUATION, scroll_view
from .scheme import SCHEME_PATH, scheme_manager
from .scheme import get_scheme_manager
from .terminal import Terminal
from .themes import THEMES
from .themes import ALL_THEME_NAMES, THEMES
from .utils import SETTINGS_FILE, settings
LOG = logging.getLogger(__name__)
@@ -43,12 +43,12 @@ class VtermOpenCommand(sublime_plugin.WindowCommand):
cwd = os.path.expanduser("~")
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.run_command("vterm_initialize_view")
view.run_command("vterm_initialize_view", {"color_scheme": scheme.path})
terminal = Terminal(view)
terminal = Terminal(view, scheme)
try:
terminal.start(cmd, cwd=cwd, env=env, default_title=title or os.path.basename(cmd[0]))
except Exception:
@@ -106,7 +106,7 @@ class VtermOpenTerminalProjectFolderCommand(sublime_plugin.WindowCommand):
class VtermInitializeViewCommand(sublime_plugin.TextCommand):
"""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."""
view = self.view
view_settings = view.settings()
@@ -125,7 +125,7 @@ class VtermInitializeViewCommand(sublime_plugin.TextCommand):
view_settings.set("draw_indent_guides", False)
view_settings.set("scroll_past_end", False)
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("spell_check", False)
view_settings.set("detect_indentation", False)
@@ -225,13 +225,20 @@ class VtermPasteSelectionCommand(sublime_plugin.TextCommand):
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):
"""Show the palette picker, starting on the configured theme."""
self.settings = settings()
self.original = self.settings.get("theme")
self.choices = [None] + sorted(THEMES)
"""Show the palette picker, starting on the focused terminal's own theme."""
view = self.window.active_view()
self.terminal = Terminal.from_id(view.id()) if view else None
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"]]
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
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):
"""Apply a palette while it is highlighted, without saving it."""
self.settings.set("theme", self.choices[index])
"""Apply a palette to the focused terminal while it is highlighted."""
self._apply(self.choices[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:
self.settings.set("theme", self.original)
self._apply(self.original)
return
self.settings.set("theme", self.choices[index])
name = self.choices[index]
self._apply(name)
settings().set("theme", name)
sublime.save_settings(SETTINGS_FILE)

View File

@@ -15,7 +15,6 @@ from wcwidth import wcswidth
# per-cell call in segment_buffer_line
cell_width = lru_cache(maxsize=4096)(wcswidth)
from .scheme import scheme_manager
from .terminal import Terminal
from .utils import rev_wcwidth
@@ -124,6 +123,8 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
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
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
@@ -156,6 +157,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
terminal = Terminal.from_id(view.id())
if not terminal:
return
self._scheme = terminal.scheme
screen = terminal.screen
# 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):
continue
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)
if lf:
@@ -391,7 +393,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
for _, a, b, fg, bg, style in segments:
if is_default_run(fg, bg, style):
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))
scopes = self._archived_chunks.setdefault(chunk, set())

View File

@@ -8,16 +8,12 @@ import sublime
from .emulator import ANSI_COLORS
from .utils import settings
from .themes import THEMES
from .themes import ALL_THEME_NAMES, THEMES
LOG = logging.getLogger(__name__)
# written under Packages/User so it survives a package reinstall
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
WRITE_DEBOUNCE_MS = 250
@@ -69,89 +65,46 @@ def _is_dark(color_text):
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):
"""Start on the built-in palette; initialize() picks up the real one."""
def __init__(self, theme_name):
"""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.rules = {}
# 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.foreground = "#ffffff"
self.background = "#000000"
self._editor_fg = "#ffffff"
self._editor_bg = "#000000"
self.ansi = dict(DEFAULT_ANSI)
self._write_pending = False
self._initialized = False
self._dir = None
self._host_view = None
self._listeners = []
def initialize(self, view):
"""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):
def build(self, host_view=None):
"""Resolve the palette and regenerate every rule, then write the file.
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).
"""
with self.lock:
self._names = {}
self.rules = {}
self.ansi = dict(DEFAULT_ANSI)
theme = THEMES.get(settings().get("theme"))
theme = THEMES.get(self.theme_name)
if theme:
self.foreground = theme["foreground"]
self.background = theme["background"]
self.ansi.update({name: theme[name] for name in ANSI_COLORS if name in theme})
else:
# captured values outlive the view they came from
if self._host_view is not None and self._host_view.is_valid():
style = self._host_view.style()
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
elif host_view is not None and host_view.is_valid():
style = host_view.style()
self.foreground = style.get("foreground", self.foreground)
self.background = style.get("background", self.background)
# user overrides sit on top of whichever palette was chosen
user_ansi = settings().get("ansi_colors", {})
@@ -258,11 +211,49 @@ class SchemeManager:
},
"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:
# compact: sublime reparses the whole file on every write
json.dump(scheme, f, separators=(",", ":"))
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 .keys import get_key_code
from .scheme import scheme_manager
from .utils import intermission, responsive, settings
LOG = logging.getLogger(__name__)
@@ -98,9 +97,10 @@ class Terminal:
_terminals = {}
def __init__(self, view):
def __init__(self, view, scheme=None):
"""State only; the pty and the threads come up in start()."""
self.view = view
self.scheme = scheme
# process.isalive() costs two waitpid syscalls; UI hooks read this instead
self.alive = True
self.default_title = ""
@@ -198,12 +198,11 @@ class Terminal:
erase_scrollback=settings().get("honor_erase_scrollback", False),
clear_callback=self._clear_callback,
clipboard_callback=self._clipboard_callback,
fg_color=scheme_manager.foreground.lstrip("#"),
bg_color=scheme_manager.background.lstrip("#"),
is_dark=scheme_manager.is_dark(),
fg_color=self.scheme.foreground.lstrip("#"),
bg_color=self.scheme.background.lstrip("#"),
is_dark=self.scheme.is_dark(),
)
self.stream = VtStream(self.screen)
scheme_manager.add_listener(self.apply_scheme)
self._start_rendering()
@@ -211,18 +210,23 @@ class Terminal:
"""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
screen = self.screen
screen.default_fg_color = scheme_manager.foreground.lstrip("#")
screen.default_bg_color = scheme_manager.background.lstrip("#")
screen.theme_is_dark = scheme_manager.is_dark()
screen.default_fg_color = self.scheme.foreground.lstrip("#")
screen.default_bg_color = self.scheme.background.lstrip("#")
screen.theme_is_dark = self.scheme.is_dark()
if screen.theme_notify_enabled():
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):
"""Terminate the child and drop this terminal from the registry."""
LOG.debug("[kill] terminating process")
self.alive = False
self._wake.set()
scheme_manager.remove_listener(self.apply_scheme)
try:
self.process.terminate()
except Exception:

View File

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