Simplify per-terminal theme code; note ansi_colors needs restart

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vaclav Tvrdik
2026-10-04 21:42:20 +02:00
parent c2a0c75359
commit 20e84ea532
7 changed files with 31 additions and 21 deletions

View File

@@ -109,7 +109,7 @@ colours:
focused terminal immediately and sets this; other already-open terminals keep focused terminal immediately and sets this; other already-open terminals keep
whatever theme they had. 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. Takes effect after a Sublime restart.
A light theme is reported to the running agent (OSC 11, DSR 996), so `pi` and A light theme is reported to the running agent (OSC 11, DSR 996), so `pi` and
`claude` switch to their light UI to match. An app subscribed to mode 2031 is also `claude` switch to their light UI to match. An app subscribed to mode 2031 is also

View File

@@ -43,7 +43,8 @@ def plugin_loaded():
plugin host: a restored tab's color_scheme is read during startup and may plugin host: a restored tab's color_scheme is read during startup and may
name any of them. name any of them.
""" """
view = sublime.active_window().active_view() if sublime.active_window() else None window = sublime.active_window()
view = window.active_view() if window else None
build_startup_schemes(view) 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

View File

@@ -88,7 +88,7 @@ for entry in load_sublime_json("vterm.sublime-commands") + keymap:
# every pending_* name mentioned in the modules must be a real Terminal # every pending_* name mentioned in the modules must be a real Terminal
# attribute, so a renamed flag cannot leave a writer pointing at nothing # attribute, so a renamed flag cannot leave a writer pointing at nothing
flags = {f for f in vars(Terminal(None)) if f.startswith("pending_")} flags = {f for f in vars(Terminal(None, None)) if f.startswith("pending_")}
assert flags == {"pending_clear", "pending_clear_screen", "pending_render"}, flags assert flags == {"pending_clear", "pending_clear_screen", "pending_render"}, flags
for module in (commands, render, terminal_module): for module in (commands, render, terminal_module):
with open(module.__file__, encoding="utf-8") as f: with open(module.__file__, encoding="utf-8") as f:
@@ -118,12 +118,10 @@ assert configured is None or configured in THEMES, configured
# any of them # 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 SchemeManager
from vterm.scheme import SCHEME_DIR_NAME, SchemeManager
from vterm.themes import ALL_THEME_NAMES from vterm.themes import ALL_THEME_NAMES
scheme_dir = os.path.join(sublime.packages_path(), *SCHEME_DIR_NAME.split("/")) scheme_files = [SchemeManager(name).file_path() for name in ALL_THEME_NAMES]
scheme_files = [os.path.join(scheme_dir, SchemeManager(name).file_name) for name in ALL_THEME_NAMES]
for scheme_file in scheme_files: for scheme_file in scheme_files:
if os.path.exists(scheme_file): if os.path.exists(scheme_file):
os.remove(scheme_file) os.remove(scheme_file)

View File

@@ -233,8 +233,7 @@ class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
def run(self): def run(self):
"""Show the palette picker, starting on the focused terminal's own theme.""" """Show the palette picker, starting on the focused terminal's own theme."""
view = self.window.active_view() self.terminal = self._focused_terminal()
self.terminal = Terminal.from_id(view.id()) if view else None
if not self.terminal: if not self.terminal:
return return
self.original = self.terminal.scheme.theme_name self.original = self.terminal.scheme.theme_name
@@ -250,8 +249,12 @@ class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
def is_enabled(self): def is_enabled(self):
"""Greys the palette entry out when the focused view is not a terminal.""" """Greys the palette entry out when the focused view is not a terminal."""
return self._focused_terminal() is not None
def _focused_terminal(self):
"""The terminal in the focused view, if any."""
view = self.window.active_view() view = self.window.active_view()
return view is not None and Terminal.from_id(view.id()) is not None return Terminal.from_id(view.id()) if view else None
def _apply(self, name): def _apply(self, name):
"""Retheme the focused terminal alone.""" """Retheme the focused terminal alone."""

View File

@@ -318,11 +318,12 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
by_scope = {} by_scope = {}
needed = 0 needed = 0
scope_of = self._scheme.scope
for _, start, end, fg, bg, style in segments: for _, start, end, fg, bg, style in segments:
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(self._scheme.scope(fg, bg, style), []).append((start, end)) by_scope.setdefault(scope_of(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:
@@ -382,6 +383,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
grouped = {} grouped = {}
stale = [] stale = []
scope_of = self._scheme.scope
for row in rows: for row in rows:
keys = self.colored_lines.pop(row, None) keys = self.colored_lines.pop(row, None)
if not keys: if not keys:
@@ -393,7 +395,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 = self._scheme.scope(fg, bg, style) scope = scope_of(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

@@ -181,6 +181,10 @@ class SchemeManager:
rule["font_style"] = " ".join(font_style) rule["font_style"] = " ".join(font_style)
self.rules[(fg, bg, style)] = rule self.rules[(fg, bg, style)] = rule
def file_path(self):
"""Filesystem path of the generated scheme file."""
return os.path.join(self._directory(), self.file_name)
def _directory(self): def _directory(self):
"""Path of the generated scheme's directory, created on first use.""" """Path of the generated scheme's directory, created on first use."""
if self._dir is None: if self._dir is None:
@@ -211,8 +215,7 @@ class SchemeManager:
}, },
"rules": list(self.rules.values()), "rules": list(self.rules.values()),
} }
path = os.path.join(self._directory(), self.file_name) with open(self.file_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))
@@ -227,8 +230,8 @@ def get_scheme_manager(theme_name, host_view=None):
"""The manager for a theme name (None for "follow the Sublime colour scheme"), """The manager for a theme name (None for "follow the Sublime colour scheme"),
building and caching it on first use. building and caching it on first use.
The None entry is rebuilt on every call instead: it is a live sample of the The None entry is rebuilt whenever the editor's colours have changed since: it
editor's current colours, not a static palette, so every caller handing a is a live sample of them, not a static palette, so every caller handing a
terminal a "no theme" scheme gets one that is actually current. terminal a "no theme" scheme gets one that is actually current.
""" """
manager = _managers.get(theme_name) manager = _managers.get(theme_name)
@@ -236,8 +239,10 @@ def get_scheme_manager(theme_name, host_view=None):
manager = SchemeManager(theme_name) manager = SchemeManager(theme_name)
_managers[theme_name] = manager _managers[theme_name] = manager
manager.build(host_view) manager.build(host_view)
elif theme_name is None: elif theme_name is None and host_view is not None and host_view.is_valid():
manager.build(host_view) style = host_view.style()
if (style.get("foreground"), style.get("background")) != (manager.foreground, manager.background):
manager.build(host_view)
return manager return manager
@@ -254,6 +259,5 @@ def build_startup_schemes(host_view=None):
if name is None: if name is None:
get_scheme_manager(None, host_view=host_view) get_scheme_manager(None, host_view=host_view)
continue continue
manager = SchemeManager(name) if not os.path.exists(SchemeManager(name).file_path()):
if not os.path.exists(os.path.join(manager._directory(), manager.file_name)):
get_scheme_manager(name) get_scheme_manager(name)

View File

@@ -97,7 +97,7 @@ class Terminal:
_terminals = {} _terminals = {}
def __init__(self, view, scheme=None): def __init__(self, view, scheme):
"""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 self.scheme = scheme
@@ -218,6 +218,8 @@ class Terminal:
def retheme(self, scheme): def retheme(self, scheme):
"""Switch to a different scheme: vterm: Select Theme, scoped to this terminal.""" """Switch to a different scheme: vterm: Select Theme, scoped to this terminal."""
if scheme is self.scheme:
return
self.scheme = scheme self.scheme = scheme
self.view.settings().set("color_scheme", scheme.path) self.view.settings().set("color_scheme", scheme.path)
self.apply_scheme() self.apply_scheme()