A string with no ST swallowed every later sequence, RIS included, and Clear Screen could not recover the tab. Any ESC now ends it, as in xterm, and the sequence it starts is dispatched. The shipped setting needed Kitty. Also: REP, SCOSC/SCORC, DECOM-relative CPR, `:` subparams and more DECRQM modes answered; new rows inserted in one edit; controls stripped from pastes; wide-char mouse columns.
262 lines
9.0 KiB
Python
262 lines
9.0 KiB
Python
""" Theme picker flows and the selection-to-text helper.
|
|
Run with: python3 tests/test_commands.py """
|
|
import _bootstrap # sets sys.path for the imports below
|
|
from _bootstrap import FakeProcess
|
|
|
|
import sublime
|
|
from vterm.commands import VtermPasteSelectionCommand, VtermPasteTextCommand, 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
|
|
|
|
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()
|
|
self._settings.set("vterm_view", True)
|
|
|
|
def id(self):
|
|
return self._id
|
|
|
|
def settings(self):
|
|
return self._settings
|
|
|
|
def is_valid(self):
|
|
return True
|
|
|
|
def style(self):
|
|
# a terminal view reports its own theme's colours, not the editor's
|
|
return {"foreground": "#f2f2f2", "background": "#000000"}
|
|
|
|
|
|
class FakeEditorView:
|
|
"""An ordinary file view, the one the "No theme" palette is sampled from."""
|
|
|
|
def settings(self):
|
|
return sublime.Settings()
|
|
|
|
def is_valid(self):
|
|
return True
|
|
|
|
def style(self):
|
|
return {"foreground": "#111111", "background": "#eeeeee"}
|
|
|
|
|
|
editor = FakeEditorView()
|
|
|
|
|
|
class FakeWindow:
|
|
def __init__(self, view):
|
|
self._view = view
|
|
|
|
def active_view(self):
|
|
return self._view
|
|
|
|
def views(self):
|
|
return [self._view, editor]
|
|
|
|
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 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(fake_terminal(None))
|
|
assert [i[0] for i in panel["items"]] == ["No theme"] + sorted(THEMES), panel["items"]
|
|
assert panel["selected_index"] == 0
|
|
|
|
# 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 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 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 focused terminal's theme from before the panel opened
|
|
panel["on_done"](-1)
|
|
assert terminal.scheme.theme_name == "pro", terminal.scheme.theme_name
|
|
|
|
# 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(fake_terminal("slate"))
|
|
panel["on_done"](0)
|
|
assert settings.get("theme") is None, settings.get("theme")
|
|
|
|
# ... and samples the editor's colours, not the themed terminal's own: that
|
|
# would retint every "no theme" terminal with the theme being left
|
|
system = get_scheme_manager(None)
|
|
assert (system.foreground, system.background) == ("#111111", "#eeeeee"), \
|
|
(system.foreground, system.background)
|
|
|
|
print("theme picker tests passed ({} themes offered)".format(len(THEMES)))
|
|
|
|
|
|
# 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()
|
|
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)
|
|
|
|
terminal.scheme = get_scheme_manager("slate")
|
|
terminal.apply_scheme()
|
|
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)
|
|
|
|
# with it, the dark/light report is sent so the app can restyle itself
|
|
stream.feed("\x1b[?2031h")
|
|
recorder.written = ""
|
|
terminal.apply_scheme()
|
|
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")
|
|
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 a theme twice must not duplicate its seeded rules
|
|
import os
|
|
|
|
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"
|
|
|
|
# the cached instance is reused, not rebuilt
|
|
assert get_scheme_manager("pro") is scheme, "theme managers must be cached"
|
|
|
|
# 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))
|
|
|
|
|
|
class FakeView:
|
|
"""Just enough view for selection_text and the paste-selection command."""
|
|
|
|
def __init__(self, text, regions):
|
|
self.text = text
|
|
self.regions = list(regions)
|
|
self.commands = []
|
|
|
|
def substr(self, region):
|
|
return self.text[region.begin():region.end()]
|
|
|
|
def sel(self):
|
|
return self
|
|
|
|
def __iter__(self):
|
|
return iter(self.regions)
|
|
|
|
def clear(self):
|
|
self.regions = []
|
|
|
|
def run_command(self, name, args=None):
|
|
self.commands.append((name, args))
|
|
|
|
|
|
def paste(view):
|
|
command = VtermPasteSelectionCommand.__new__(VtermPasteSelectionCommand)
|
|
command.view = view
|
|
command.run(None)
|
|
return view.commands
|
|
|
|
|
|
# soft-wrapped rows re-join: the sentinel and the newline behind it both go
|
|
wrapped = "abc" + CONTINUATION + "\ndef"
|
|
view = FakeView(wrapped, [sublime.Region(0, len(wrapped))])
|
|
assert selection_text(view) == "abcdef", repr(selection_text(view))
|
|
|
|
# a trailing sentinel with no newline is still stripped
|
|
view = FakeView("abc" + CONTINUATION, [sublime.Region(0, 3 + len(CONTINUATION))])
|
|
assert selection_text(view) == "abc", repr(selection_text(view))
|
|
|
|
# multiple regions join with a newline
|
|
view = FakeView("abcdef", [sublime.Region(0, 3), sublime.Region(3, 6)])
|
|
assert selection_text(view) == "abc\ndef", repr(selection_text(view))
|
|
|
|
# the middle button pastes the selection and drops it, so the caret follows again
|
|
view = FakeView("abcdef", [sublime.Region(0, 3)])
|
|
assert paste(view) == [("vterm_paste_text", {"text": "abc"})], view.commands
|
|
assert list(view.sel()) == [], "selection must be cleared"
|
|
|
|
# nothing selected, nothing sent
|
|
view = FakeView("abcdef", [sublime.Region(2, 2)])
|
|
assert paste(view) == [], view.commands
|
|
|
|
print("selection tests passed (sentinel stripping, paste, empty no-op)")
|
|
|
|
|
|
# a paste is a trust boundary: C0 controls bar tab/LF/CR go, so a pasted
|
|
# ESC[201~ cannot close the bracket and type the rest into the app
|
|
terminal = fake_terminal("default")
|
|
terminal.process = terminal.screen._process
|
|
terminal.screen.set_mode(2004, private=True)
|
|
terminal.view.run_command = lambda name, args=None: None
|
|
command = VtermPasteTextCommand.__new__(VtermPasteTextCommand)
|
|
command.view = terminal.view
|
|
command.run(None, "a\x1b[201~b\x00\tc\nd")
|
|
assert terminal.process.written == "\x1b[200~a[201~b\tc\rd\x1b[201~", repr(terminal.process.written)
|
|
|
|
print("paste tests passed (controls stripped, bracket kept whole)")
|