From c2a0c7535934f563e4d82a55ec535b4116a382d0 Mon Sep 17 00:00:00 2001 From: Vaclav Tvrdik Date: Fri, 2 Oct 2026 12:29:32 +0200 Subject: [PATCH] select theme applies only to current vterm and newly opened vterms --- Architecture.md | 2 +- README.md | 7 +- main.py | 13 +++- tests/stubs/sublime.py | 4 + tests/test_commands.py | 131 +++++++++++++++++++++------------ tests/test_imports.py | 30 +++++--- tests/test_render.py | 42 ++++++----- vterm.sublime-settings | 5 +- vterm/commands.py | 57 ++++++++++----- vterm/render.py | 8 +- vterm/scheme.py | 161 +++++++++++++++++++---------------------- vterm/terminal.py | 24 +++--- vterm/themes.py | 3 + 13 files changed, 284 insertions(+), 203 deletions(-) diff --git a/Architecture.md b/Architecture.md index 6397782..3ba400d 100644 --- a/Architecture.md +++ b/Architecture.md @@ -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. | diff --git a/README.md b/README.md index be32daa..fe9b8bd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/main.py b/main.py index 9abacc0..697cb3c 100644 --- a/main.py +++ b/main.py @@ -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() diff --git a/tests/stubs/sublime.py b/tests/stubs/sublime.py index b5801c1..a117682 100644 --- a/tests/stubs/sublime.py +++ b/tests/stubs/sublime.py @@ -87,3 +87,7 @@ def expand_variables(value, variables): def version(): return "4200" + + +def active_window(): + return None diff --git a/tests/test_commands.py b/tests/test_commands.py index bbcacd3..4f66726 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -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)) diff --git a/tests/test_imports.py b/tests/test_imports.py index 79a6089..7de4157 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -63,10 +63,9 @@ assert get_key_code(key="enter", modify_other_keys=2) == "\r", "unmodified keys assert get_key_code(key="c", ctrl=True, modify_other_keys=2) == "\x03", "ctrl+c stays a control byte" load_sublime_json("Default (OSX).sublime-mousemap") -# scheme manager produces scopes and rules -import vterm.scheme as scheme_module -from vterm.scheme import scheme_manager -scope = scheme_manager.scope("ff0000", "default", "b") +# a theme's scheme manager produces scopes and rules +from vterm.scheme import get_scheme_manager +scope = get_scheme_manager("default").scope("ff0000", "default", "b") assert scope == "vterm.ff0000.default.b", scope # every command referenced by the palette and the keymap must exist, and the @@ -114,21 +113,30 @@ for name, theme in THEMES.items(): configured = load_sublime_json("vterm.sublime-settings").get("theme") assert configured is None or configured in THEMES, configured -# plugin_loaded must create the colour scheme: sublime loads a restored tab's -# color_scheme during startup, before any terminal opens +# plugin_loaded must create every theme's colour scheme: sublime loads a restored +# tab's color_scheme during startup, before any terminal opens, and it may name +# any of them # import main.py the way sublime does - as a module of the package directory - # so its relative imports and plugin_loaded body are actually executed +import sublime +from vterm.scheme import SCHEME_DIR_NAME, SchemeManager +from vterm.themes import ALL_THEME_NAMES + +scheme_dir = os.path.join(sublime.packages_path(), *SCHEME_DIR_NAME.split("/")) +scheme_files = [os.path.join(scheme_dir, SchemeManager(name).file_name) for name in ALL_THEME_NAMES] +for scheme_file in scheme_files: + if os.path.exists(scheme_file): + os.remove(scheme_file) + pkg = types.ModuleType("vterm") pkg.__path__ = [ROOT] sys.modules["vterm"] = pkg main = importlib.import_module("vterm.main") -scheme_file = os.path.join(main.scheme_manager._directory(), scheme_module.SCHEME_FILE_NAME) -if os.path.exists(scheme_file): - os.remove(scheme_file) main.plugin_loaded() -assert os.path.exists(scheme_file), \ - "plugin_loaded must leave the colour scheme on disk" +for name, scheme_file in zip(ALL_THEME_NAMES, scheme_files): + assert os.path.exists(scheme_file), \ + "plugin_loaded must leave {}'s colour scheme on disk".format(name) print("import smoke test passed ({} keymap bindings, {} pending flags, " "{} themes)".format(len(keymap), len(flags), len(THEMES))) diff --git a/tests/test_render.py b/tests/test_render.py index df302d7..c7797cb 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -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 diff --git a/vterm.sublime-settings b/vterm.sublime-settings index 8e6b1cf..ff13167 100644 --- a/vterm.sublime-settings +++ b/vterm.sublime-settings @@ -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"} diff --git a/vterm/commands.py b/vterm/commands.py index 80bd56b..12c39aa 100644 --- a/vterm/commands.py +++ b/vterm/commands.py @@ -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) diff --git a/vterm/render.py b/vterm/render.py index 1c252ed..48f5bd6 100644 --- a/vterm/render.py +++ b/vterm/render.py @@ -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()) diff --git a/vterm/scheme.py b/vterm/scheme.py index be89213..50a27f0 100644 --- a/vterm/scheme.py +++ b/vterm/scheme.py @@ -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,104 +65,61 @@ 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). """ - self._names = {} - self.rules = {} - self.ansi = dict(DEFAULT_ANSI) + 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 + 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}) + 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", {}) - for name in ANSI_COLORS: - if name in user_ansi: - self.ansi[name] = user_ansi[name] + # user overrides sit on top of whichever palette was chosen + user_ansi = settings().get("ansi_colors", {}) + for name in ANSI_COLORS: + if name in user_ansi: + self.ansi[name] = user_ansi[name] - # seeded so the first frame does not reference scopes the file lacks - for fg in ANSI_COLORS: - self._add_rule(fg, "default", "") - self._add_rule(fg, "default", "b") - self._add_rule("default", "default", "b") - self._add_rule("reverse_default", "reverse_default", "") + # seeded so the first frame does not reference scopes the file lacks + for fg in ANSI_COLORS: + self._add_rule(fg, "default", "") + self._add_rule(fg, "default", "b") + self._add_rule("default", "default", "b") + self._add_rule("reverse_default", "reverse_default", "") - self._write_file() + self._write_file() def is_dark(self): """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()), } - 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) diff --git a/vterm/terminal.py b/vterm/terminal.py index 442caa3..b2e4f68 100644 --- a/vterm/terminal.py +++ b/vterm/terminal.py @@ -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: diff --git a/vterm/themes.py b/vterm/themes.py index 663a3fc..06dc7db 100644 --- a/vterm/themes.py +++ b/vterm/themes.py @@ -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)