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.
777 lines
31 KiB
Python
777 lines
31 KiB
Python
""" Render-layer tests against a fake view.
|
|
|
|
The live harness exercises the emulator only, so regressions in render.py used
|
|
to reach Sublime unnoticed. Run with: python3 tests/test_render.py
|
|
"""
|
|
import _bootstrap # sets sys.path for the imports below
|
|
from _bootstrap import FakeProcess
|
|
|
|
import sublime
|
|
from vterm.commands import VtermKeypressCommand
|
|
from vterm.emulator import VtScreen, VtStream
|
|
from vterm.mouse import VtermMousePressCommand, VtermMouseReleaseCommand, VtermScrollCommand
|
|
from vterm.render import (
|
|
CHUNK,
|
|
RenderState,
|
|
VtermRenderCommand,
|
|
focus_cursor,
|
|
following,
|
|
scroll_to_cursor,
|
|
)
|
|
from vterm.scheme import get_scheme_manager
|
|
from vterm.terminal import Terminal
|
|
from vterm.utils import SETTINGS_FILE
|
|
|
|
|
|
LINE_HEIGHT = 20
|
|
|
|
|
|
class FakeSelection(list):
|
|
def clear(self):
|
|
del self[:]
|
|
|
|
def add(self, region):
|
|
self.append(region)
|
|
|
|
|
|
class FakeView:
|
|
"""Enough of the view API for the trim paths, with real line arithmetic."""
|
|
|
|
def __init__(self, text=""):
|
|
self.text = text
|
|
self.selection = FakeSelection()
|
|
self.regions = {}
|
|
self.commands = []
|
|
self.folds = []
|
|
self._name = ""
|
|
self.viewport = (0, 0)
|
|
self.viewport_rows = 5
|
|
|
|
def size(self):
|
|
return len(self.text)
|
|
|
|
def rowcol(self, point):
|
|
head = self.text[:point]
|
|
row = head.count("\n")
|
|
return (row, point - (head.rfind("\n") + 1))
|
|
|
|
def text_point(self, row, col):
|
|
lines = self.text.split("\n")
|
|
start = sum(len(l) + 1 for l in lines[:row])
|
|
return start + min(col, len(lines[row]) if row < len(lines) else 0)
|
|
|
|
def line(self, point):
|
|
start = self.text.rfind("\n", 0, point) + 1
|
|
end = self.text.find("\n", point)
|
|
return sublime.Region(start, len(self.text) if end == -1 else end)
|
|
|
|
def full_line(self, point):
|
|
region = self.line(point)
|
|
return sublime.Region(region.begin(),
|
|
min(region.end() + 1, len(self.text)))
|
|
|
|
def lines(self, region):
|
|
out = []
|
|
point = region.begin()
|
|
while point <= region.end():
|
|
line = self.line(point)
|
|
out.append(line)
|
|
point = line.end() + 1
|
|
return out
|
|
|
|
def substr(self, region):
|
|
return self.text[region.begin():region.end()]
|
|
|
|
def erase(self, edit, region):
|
|
self.text = self.text[:region.begin()] + self.text[region.end():]
|
|
|
|
def replace(self, edit, region, value):
|
|
self.text = self.text[:region.begin()] + value + self.text[region.end():]
|
|
# sublime maps a caret inside the replaced span onto the new text,
|
|
# which is where the artefact selection came from
|
|
for i, sel in enumerate(list(self.selection)):
|
|
if sel.empty() and region.begin() <= sel.begin() <= region.end():
|
|
self.selection[i] = sublime.Region(region.begin(),
|
|
region.begin() + len(value))
|
|
|
|
def insert(self, edit, point, value):
|
|
self.text = self.text[:point] + value + self.text[point:]
|
|
return len(value)
|
|
|
|
def fold(self, region):
|
|
self.folds.append((region.begin(), region.end()))
|
|
return True
|
|
|
|
def unfold(self, region):
|
|
unfolded = self.folds
|
|
self.folds = []
|
|
return unfolded
|
|
|
|
def folded_regions(self):
|
|
return [sublime.Region(b, e) for b, e in self.folds]
|
|
|
|
def erase_regions(self, key):
|
|
self.regions.pop(key, None)
|
|
|
|
def add_regions(self, key, regions, scope):
|
|
self.regions[key] = (scope, [(r.begin(), r.end()) for r in regions])
|
|
|
|
def get_regions(self, key):
|
|
_, pairs = self.regions.get(key, ("", []))
|
|
return [sublime.Region(a, b) for a, b in pairs]
|
|
|
|
def settings(self):
|
|
return sublime.load_settings("fake")
|
|
|
|
def viewport_position(self):
|
|
return self.viewport
|
|
|
|
def set_viewport_position(self, xy, animate=True):
|
|
# scroll_past_end is off, so sublime clamps to the layout
|
|
maxy = max(0, self.layout_extent()[1] - self.viewport_extent()[1])
|
|
self.viewport = (xy[0], max(0, min(xy[1], maxy)))
|
|
|
|
def line_height(self):
|
|
return LINE_HEIGHT
|
|
|
|
# one layout row per line; the viewport shows viewport_rows of them
|
|
def layout_extent(self):
|
|
return (0, (self.text.count("\n") + 1) * LINE_HEIGHT)
|
|
|
|
def viewport_extent(self):
|
|
return (0, self.viewport_rows * LINE_HEIGHT)
|
|
|
|
def text_to_layout(self, point):
|
|
return (0, self.rowcol(point)[0] * LINE_HEIGHT)
|
|
|
|
def name(self):
|
|
return self._name
|
|
|
|
def set_name(self, name):
|
|
self._name = name
|
|
|
|
def run_command(self, name, args=None):
|
|
self.commands.append(name)
|
|
|
|
def sel(self):
|
|
return self.selection
|
|
|
|
|
|
def make_terminal(rows=10, cols=20, scrollback=None):
|
|
# real __init__ for the plain attributes; process/screen stand in for start()
|
|
terminal = Terminal(FakeView(), get_scheme_manager("default"))
|
|
if scrollback is not None:
|
|
terminal.scrollback_size = scrollback
|
|
terminal.process = FakeProcess()
|
|
terminal.screen = VtScreen(cols, rows, process=terminal.process)
|
|
return terminal, VtStream(terminal.screen)
|
|
|
|
|
|
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
|
|
terminal.render_state = RenderState()
|
|
command.state = terminal.render_state
|
|
return command
|
|
|
|
|
|
# an app on the alternate screen must not have its view trimmed away: the
|
|
# emulator's history deque is maxlen=0 there, so it cannot be the row cap
|
|
terminal, stream = make_terminal(rows=10)
|
|
stream.feed("\x1b[?1049h")
|
|
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)
|
|
before = terminal.view.text
|
|
command.trim_history(None, terminal)
|
|
assert terminal.view.text == before, \
|
|
"alt screen view was trimmed: {!r}".format(terminal.view.text)
|
|
|
|
# the configured cap still applies on the primary screen: that many rows of
|
|
# scrollback above the screen
|
|
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)
|
|
command.trim_history(None, terminal)
|
|
rows_left = terminal.view.text.count("\n") + 1
|
|
assert rows_left <= 25, rows_left
|
|
assert "row 39" in terminal.view.text, "newest rows must survive"
|
|
assert "row 0" not in terminal.view.text, "oldest rows must be dropped"
|
|
|
|
# a cap below the screen height (0: no scrollback) must never cut the screen
|
|
terminal, stream = make_terminal(rows=10, cols=20, scrollback=0)
|
|
command = make_command(terminal)
|
|
for i in range(3):
|
|
stream.feed("\r\n".join("row{}".format(i * 10 + j) for j in range(10)) + "\r\n")
|
|
command.update_lines(None, terminal)
|
|
command.trim_trailing_spaces(None, terminal)
|
|
command.trim_history(None, terminal)
|
|
terminal.screen.dirty.clear()
|
|
assert terminal.offset >= 0, terminal.offset
|
|
assert "row29" in terminal.view.text, terminal.view.text
|
|
|
|
# below the cap nothing is 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)
|
|
command.trim_history(None, terminal)
|
|
assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \
|
|
terminal.view.text
|
|
|
|
# nor may its blank rows be trimmed: an app that blanks and refills its last
|
|
# row (vim's showcmd corner) would resize the view under a pinned viewport
|
|
terminal, stream = make_terminal(rows=4)
|
|
stream.feed("\x1b[?1049h")
|
|
terminal.view = FakeView("top\nrow\n\n")
|
|
command = make_command(terminal)
|
|
before = terminal.view.text
|
|
command.trim_trailing_spaces(None, terminal)
|
|
assert terminal.view.text == before, \
|
|
"alt screen blank tail was trimmed: {!r}".format(terminal.view.text)
|
|
|
|
# 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)
|
|
command.trim_trailing_spaces(None, terminal)
|
|
assert terminal.view.text == "top\nrow", terminal.view.text
|
|
|
|
print("render tests passed (alt screen keeps its view, cap honoured)")
|
|
|
|
|
|
# a selection the user made must survive app output: focus_cursor runs on every
|
|
# frame, and clearing it there made click-drag selection impossible to hold
|
|
terminal, stream = make_terminal(rows=5, cols=20)
|
|
terminal.view = FakeView("hello world\nsecond line\n")
|
|
stream.feed("x")
|
|
selection = terminal.view.sel()
|
|
selection.add(sublime.Region(0, 5)) # user selected "hello"
|
|
focus_cursor(terminal.view, None, terminal)
|
|
assert len(selection) == 1 and selection[0].begin() == 0 and selection[0].end() == 5, \
|
|
"user selection was clobbered by a render"
|
|
|
|
# an empty selection (a plain click, or none at all) still gets the caret placed
|
|
# at the terminal cursor
|
|
selection.clear()
|
|
focus_cursor(terminal.view, None, terminal)
|
|
assert len(selection) == 1 and selection[0].empty(), \
|
|
"caret must be placed when nothing is selected"
|
|
|
|
print("selection tests passed (user selection survives renders, caret still tracked)")
|
|
|
|
|
|
# an app that hides the cursor still needs the caret: sublime drops a space
|
|
# keypress in a view that has no caret at all
|
|
selection.clear()
|
|
stream.feed("\x1b[?25l")
|
|
focus_cursor(terminal.view, None, terminal)
|
|
assert len(selection) == 1 and selection[0].empty(), \
|
|
"caret must survive DECTCEM, or the space key stops working"
|
|
|
|
print("cursor visibility tests passed (hidden cursor keeps the caret)")
|
|
|
|
|
|
# colorize_line positions regions by arithmetic from the line start now, and
|
|
# 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)
|
|
stream.feed("\x1b[31mRED\x1b[0m..\x1b[31mred\x1b[0m..\x1b[32mgreen")
|
|
command.update_lines(None, terminal)
|
|
|
|
painted = terminal.view.regions
|
|
scopes = sorted(scope for scope, _ in painted.values())
|
|
assert scopes == ["vterm.green.default", "vterm.red.default"], scopes
|
|
red = next(spans for scope, spans in painted.values() if scope.endswith("red.default"))
|
|
assert red == [(0, 3), (5, 8)], "two red runs must share one key: {}".format(red)
|
|
green = next(spans for scope, spans in painted.values() if scope.endswith("green.default"))
|
|
assert green == [(10, 15)], green
|
|
assert terminal.view.text.startswith("RED..red..green"), repr(terminal.view.text)
|
|
|
|
# the default-on-default run between them gets no region at all
|
|
assert len(painted) == 2, painted
|
|
|
|
# keys are unique per view and recorded so decolorize_line can erase them
|
|
assert sorted(terminal.view.regions) == sorted(command.state.colored_lines[0]), \
|
|
"colored_lines must track exactly the keys that were added"
|
|
command.decolorize_line(0)
|
|
assert terminal.view.regions == {}, "decolorize must erase every key it added"
|
|
|
|
print("colorize tests passed (arithmetic offsets, scope batching, key bookkeeping)")
|
|
|
|
|
|
# sublime rebuilds every TextCommand instance whenever any plugin loads; the
|
|
# bookkeeping lives on the terminal so a rebuilt command still erases what the
|
|
# old one painted, and its keys never collide with keys still in use
|
|
terminal, stream = make_terminal(rows=3, cols=20)
|
|
terminal.view = FakeView()
|
|
command = make_command(terminal)
|
|
stream.feed("\x1b[31mred line\x1b[0m\r\nplain")
|
|
command.update_lines(None, terminal)
|
|
terminal.screen.dirty.clear()
|
|
assert terminal.view.regions, "the red row must be painted"
|
|
# a reload: new command, same terminal
|
|
reloaded = VtermRenderCommand(terminal.view)
|
|
reloaded._scheme = terminal.scheme
|
|
reloaded.state = terminal.render_state
|
|
stream.feed("\x1b[H\x1b[2Kgone")
|
|
reloaded.update_lines(None, terminal)
|
|
assert terminal.view.regions == {}, \
|
|
"a rebuilt command must erase the old overlay: {}".format(terminal.view.regions)
|
|
assert reloaded.next_key() != "vterm#1", "keys must continue, not restart"
|
|
|
|
print("reload tests passed (render state survives a rebuilt command)")
|
|
|
|
|
|
# rows that leave the screen have their colours merged onto shared per-(scope,
|
|
# chunk) keys once the chunk closes; per-row keys would otherwise accumulate for
|
|
# 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)
|
|
for i in range(300):
|
|
stream.feed("\x1b[31mred{}\x1b[0m\r\n".format(i))
|
|
command.update_lines(None, terminal)
|
|
terminal.screen.dirty.clear()
|
|
|
|
regions = terminal.view.regions
|
|
chunk = "vterm@0@vterm.red.default"
|
|
assert chunk in regions, sorted(regions)
|
|
_, spans = regions[chunk]
|
|
assert len(spans) == CHUNK, "the closed chunk merges its rows: {}".format(len(spans))
|
|
assert "vterm@1@vterm.red.default" not in regions, "an open chunk must not be sent yet"
|
|
row_keys = [k for k in regions if k.startswith("vterm#")]
|
|
assert len(row_keys) == 300 - CHUNK, \
|
|
"merged rows drop their per-row keys, the open chunk keeps them: {}".format(len(row_keys))
|
|
|
|
# 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)
|
|
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()
|
|
# rows new to the view went in as one insert, coloured straight onto their
|
|
# chunk keys; only row 0 and the two coloured screen rows have a key of their own
|
|
assert set(command.state.archived_chunks) == {0, 1}, command.state.archived_chunks
|
|
assert len(terminal.view.regions["vterm@1@vterm.red.default"][1]) == terminal.offset - CHUNK
|
|
row_keys = [k for k in terminal.view.regions if k.startswith("vterm#")]
|
|
assert len(row_keys) == 3, row_keys
|
|
assert terminal.view.text.split("\n")[:3] == ["r0", "r1", "r2"], terminal.view.text[:20]
|
|
command.trim_history(None, terminal)
|
|
assert "vterm@0@vterm.red.default" not in terminal.view.regions, "trimmed chunk key left behind"
|
|
assert 0 not in command.state.archived_chunks
|
|
assert "vterm@1@vterm.red.default" in terminal.view.regions, "boundary chunk must survive"
|
|
|
|
print("scrollback archive tests passed (chunk keys bounded, trim erases whole chunks)")
|
|
|
|
|
|
# a fresh terminal must not be left with the prompt selected: our own replace
|
|
# expands the caret across the inserted text, and sublime paints that with the
|
|
# selection colour (its default is pure blue)
|
|
terminal, stream = make_terminal(rows=3, cols=20)
|
|
terminal.view = FakeView()
|
|
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)
|
|
stream.feed("[vterm] %")
|
|
command.run(None)
|
|
assert all(r.empty() for r in terminal.view.sel()), \
|
|
"prompt was left selected: {}".format([(r.begin(), r.end()) for r in terminal.view.sel()])
|
|
|
|
# but a selection the user already had is still respected
|
|
terminal.view.sel().clear()
|
|
terminal.view.sel().add(sublime.Region(2, 8))
|
|
stream.feed("\x1b[2Kmore output")
|
|
command.run(None)
|
|
kept = [(r.begin(), r.end()) for r in terminal.view.sel()]
|
|
assert any(b != e for b, e in kept), "user selection was dropped: {}".format(kept)
|
|
|
|
# the dirty set is a repaint hint: rows whose content did not change must not
|
|
# be rewritten (each rewrite is a cross-process call and an on_modified)
|
|
rewritten = []
|
|
_replace = terminal.view.replace
|
|
terminal.view.replace = lambda edit, region, value: (rewritten.append(value), _replace(edit, region, value))[1]
|
|
terminal.screen.dirty.update(range(terminal.screen.lines))
|
|
command.run(None)
|
|
terminal.view.replace = _replace
|
|
# blank tail rows still churn via trim_trailing_spaces; content rows must not
|
|
assert all(text == "" for text in rewritten), "unchanged rows were rewritten: {}".format(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
|
|
assert blend("#303841", "#d8dee9", 0.3) == "#626a73"
|
|
|
|
print("selection artefact tests passed (fresh prompt unselected, user selection kept)")
|
|
|
|
|
|
# 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)
|
|
settings = terminal.view.settings()
|
|
|
|
command.sync_cursor_style(terminal)
|
|
assert settings.get("block_caret") is True, settings.get("block_caret")
|
|
assert settings.get("caret_style") == "blink", settings.get("caret_style")
|
|
|
|
stream.feed("\x1b[2 q")
|
|
command.sync_cursor_style(terminal)
|
|
assert settings.get("block_caret") is True, settings.get("block_caret")
|
|
assert settings.get("caret_style") == "solid", settings.get("caret_style")
|
|
|
|
stream.feed("\x1b[5 q")
|
|
command.sync_cursor_style(terminal)
|
|
assert settings.get("block_caret") is False, settings.get("block_caret")
|
|
assert settings.get("caret_style") == "blink", settings.get("caret_style")
|
|
|
|
stream.feed("\x1b[0 q")
|
|
command.sync_cursor_style(terminal)
|
|
assert settings.get("block_caret") is True, settings.get("block_caret")
|
|
assert settings.get("caret_style") == "blink", settings.get("caret_style")
|
|
|
|
# a hidden cursor outranks DECSCUSR: the caret stays for typing, as a block on
|
|
# the cell the app draws its own cursor over
|
|
stream.feed("\x1b[5 q\x1b[?25l")
|
|
command.sync_cursor_style(terminal)
|
|
assert settings.get("block_caret") is True, settings.get("block_caret")
|
|
assert settings.get("caret_style") == "solid", settings.get("caret_style")
|
|
|
|
stream.feed("\x1b[?25h")
|
|
command.sync_cursor_style(terminal)
|
|
assert settings.get("block_caret") is False, settings.get("block_caret")
|
|
assert settings.get("caret_style") == "blink", settings.get("caret_style")
|
|
|
|
print("cursor style tests passed (DECSCUSR maps to caret settings, 0 is the default block)")
|
|
|
|
|
|
# wheel over an alt-screen app that does not track the mouse must fall back to
|
|
# arrow keys (alternate scroll); with tracking on, SGR reports as before
|
|
terminal, stream = make_terminal(rows=5, cols=20)
|
|
Terminal._terminals[id(terminal)] = terminal
|
|
terminal.view.id = lambda: id(terminal)
|
|
scroll = VtermScrollCommand.__new__(VtermScrollCommand)
|
|
scroll.view = terminal.view
|
|
# three per notch, so "one report per line" is visible
|
|
sublime.load_settings(SETTINGS_FILE).set("wheel_lines", 3)
|
|
|
|
stream.feed("\x1b[?1049h")
|
|
scroll.run(None, up=True)
|
|
assert terminal.process.written == "\x1b[A" * 3, repr(terminal.process.written)
|
|
|
|
# application cursor mode switches the arrow encoding
|
|
terminal.process.written = ""
|
|
stream.feed("\x1b[?1h")
|
|
scroll.run(None, up=False)
|
|
assert terminal.process.written == "\x1bOB" * 3, repr(terminal.process.written)
|
|
|
|
# with mouse tracking on, the wheel is reported, not translated: one report per
|
|
# line, aimed mid-screen when there is no event to place it
|
|
terminal.process.written = ""
|
|
stream.feed("\x1b[?1002h\x1b[?1006h")
|
|
scroll.run(None, up=True)
|
|
assert terminal.process.writes == ["\x1b[<64;1;3M"] * 3, terminal.process.writes
|
|
|
|
# on the primary screen without tracking the command stays inert
|
|
terminal.process.written = ""
|
|
stream.feed("\x1b[?1002l\x1b[?1006l\x1b[?1049l")
|
|
scroll.run(None, up=True)
|
|
assert terminal.process.written == "", repr(terminal.process.written)
|
|
|
|
print("alternate scroll tests passed (3 lines per tick: arrows on alt screen, SGR when tracking, inert otherwise)")
|
|
|
|
|
|
# with the configured modifier PgUp/PgDn page the view and Up/Down move it one
|
|
# row, instead of reaching the app; every other combination is sent as before.
|
|
# 24 scrollback rows above an 8-row screen, viewport at the follow anchor
|
|
terminal, stream = make_terminal(rows=8, cols=20)
|
|
Terminal._terminals[id(terminal)] = terminal
|
|
terminal.view.id = lambda: id(terminal)
|
|
terminal.view.text = "\n".join("row {}".format(i) for i in range(32))
|
|
terminal.view.viewport_rows = terminal.screen.lines
|
|
terminal.offset = 24
|
|
keypress = VtermKeypressCommand.__new__(VtermKeypressCommand)
|
|
keypress.view = terminal.view
|
|
plugin_settings = sublime.load_settings(SETTINGS_FILE)
|
|
PAGE = terminal.screen.lines * LINE_HEIGHT
|
|
anchor = 24 * LINE_HEIGHT
|
|
|
|
|
|
def press(**kwargs):
|
|
terminal.process.written = ""
|
|
keypress.run(None, **kwargs)
|
|
return terminal.view.viewport_position()[1]
|
|
|
|
|
|
scroll_to_cursor(terminal.view, terminal)
|
|
assert following(terminal.view, terminal)
|
|
|
|
y = press(key="pageup", shift=True)
|
|
assert y == anchor - PAGE, y
|
|
assert terminal.process.written == "", repr(terminal.process.written)
|
|
# output must not yank the viewport back while the user reads
|
|
assert not following(terminal.view, terminal)
|
|
|
|
# never above the top, never below the anchor
|
|
for _ in range(3):
|
|
y = press(key="pageup", shift=True)
|
|
assert y == 0, y
|
|
for _ in range(5):
|
|
y = press(key="pagedown", shift=True)
|
|
assert y == anchor, y
|
|
# paging back onto the anchor re-opens the gate
|
|
assert following(terminal.view, terminal)
|
|
|
|
# the arrows move one row rather than a screen
|
|
y = press(key="up", shift=True)
|
|
assert y == anchor - LINE_HEIGHT, y
|
|
assert terminal.process.written == "", repr(terminal.process.written)
|
|
# one row up is the edge of the gate's one-line tolerance; it must still close
|
|
assert not following(terminal.view, terminal)
|
|
|
|
for _ in range(30):
|
|
y = press(key="up", shift=True)
|
|
assert y == 0, y
|
|
for _ in range(30):
|
|
y = press(key="down", shift=True)
|
|
assert y == anchor, y
|
|
assert following(terminal.view, terminal)
|
|
|
|
# a bare or differently modified key still belongs to the app
|
|
press(key="pageup")
|
|
assert terminal.process.written == "\x1b[5~", repr(terminal.process.written)
|
|
press(key="pageup", ctrl=True)
|
|
assert terminal.process.written == "\x1b[5;5~", repr(terminal.process.written)
|
|
y = press(key="pageup", ctrl=True, shift=True)
|
|
assert terminal.process.written == "\x1b[5;6~", repr(terminal.process.written)
|
|
assert y == anchor, "the app's keys must not scroll"
|
|
press(key="up")
|
|
assert terminal.process.written == "\x1b[A", repr(terminal.process.written)
|
|
y = press(key="up", ctrl=True, shift=True)
|
|
assert terminal.process.written == "\x1b[1;6A", repr(terminal.process.written)
|
|
assert y == anchor, "the app's keys must not scroll"
|
|
|
|
# the alt screen is folded away: nothing to scroll, so the app keeps the keys
|
|
stream.feed("\x1b[?1049h")
|
|
press(key="pageup", shift=True)
|
|
assert terminal.process.written == "\x1b[5;2~", repr(terminal.process.written)
|
|
press(key="up", shift=True)
|
|
assert terminal.process.written == "\x1b[1;2A", repr(terminal.process.written)
|
|
stream.feed("\x1b[?1049l")
|
|
|
|
# the modifier is configurable, and null hands every key back to the app
|
|
plugin_settings.set("scroll_modifier", "ctrl")
|
|
y = press(key="pageup", ctrl=True)
|
|
assert y == anchor - PAGE, y
|
|
y = press(key="up", ctrl=True)
|
|
assert y == anchor - PAGE - LINE_HEIGHT, y
|
|
press(key="pageup", shift=True)
|
|
assert terminal.process.written == "\x1b[5;2~", repr(terminal.process.written)
|
|
press(key="up", shift=True)
|
|
assert terminal.process.written == "\x1b[1;2A", repr(terminal.process.written)
|
|
|
|
plugin_settings.set("scroll_modifier", None)
|
|
press(key="pageup", shift=True)
|
|
assert terminal.process.written == "\x1b[5;2~", repr(terminal.process.written)
|
|
press(key="up", shift=True)
|
|
assert terminal.process.written == "\x1b[1;2A", repr(terminal.process.written)
|
|
press(key="pagedown", ctrl=True)
|
|
assert terminal.process.written == "\x1b[6;5~", repr(terminal.process.written)
|
|
plugin_settings.set("scroll_modifier", "shift")
|
|
|
|
print("view scroll tests passed (a screen or a row per press, clamped to the anchor, configurable modifier)")
|
|
|
|
|
|
# an app that repaints its block shorter than the screen (pi on every SIGWINCH)
|
|
# leaves the live screen shorter than the viewport once the blank tail is
|
|
# trimmed. the anchor must stay where sublime can actually put the viewport, or
|
|
# the follow gate closes for good and the output is never followed again
|
|
short, _ = make_terminal(rows=8, cols=20)
|
|
short.view.viewport_rows = short.screen.lines
|
|
# 24 scrollback rows, then a live screen holding 3 rows instead of 8
|
|
short.view.text = "\n".join("row {}".format(i) for i in range(27))
|
|
short.offset = 24
|
|
scroll_to_cursor(short.view, short)
|
|
assert short.viewport_y == short.view.viewport_position()[1], \
|
|
(short.viewport_y, short.view.viewport_position()[1])
|
|
assert following(short.view, short), "follow gate latched shut on a short screen"
|
|
|
|
print("short screen tests passed (the anchor stays reachable, so the view keeps following)")
|
|
|
|
|
|
# shift+enter through the whole keypress path: ESC CR by default, and the CSI 27
|
|
# form once the app turned modifyOtherKeys on, which it reaches for because a
|
|
# bare ESC CR reads as alt+enter
|
|
press(key="enter", shift=True)
|
|
assert terminal.process.written == "\x1b\r", repr(terminal.process.written)
|
|
stream.feed("\x1b[>4;2m")
|
|
press(key="enter", shift=True)
|
|
assert terminal.process.written == "\x1b[27;2;13~", repr(terminal.process.written)
|
|
press(key="enter")
|
|
assert terminal.process.written == "\r", "unmodified enter still submits"
|
|
press(key="c", ctrl=True)
|
|
assert terminal.process.written == "\x03", "ctrl+c stays a control byte"
|
|
stream.feed("\x1b[>4;0m")
|
|
press(key="enter", shift=True)
|
|
assert terminal.process.written == "\x1b\r", repr(terminal.process.written)
|
|
|
|
print("modifyOtherKeys tests passed (shift+enter as CSI 27 while the app asks for it)")
|
|
|
|
|
|
# a click in an unfocused group must focus the tab instead of reporting: we
|
|
# replaced drag_select, which is what sublime focuses the group from
|
|
class FakeWindow:
|
|
def __init__(self, active):
|
|
self.active = active
|
|
|
|
def active_view(self):
|
|
return self.active
|
|
|
|
def focus_view(self, view):
|
|
self.active = view
|
|
|
|
|
|
terminal, stream = make_terminal(rows=5, cols=20)
|
|
Terminal._terminals[id(terminal)] = terminal
|
|
terminal.view.id = lambda: id(terminal)
|
|
terminal.view.window_to_text = lambda xy: 0
|
|
window = FakeWindow(active=object())
|
|
terminal.view.window = lambda: window
|
|
stream.feed("\x1b[?1002h\x1b[?1006h")
|
|
mouse_press = VtermMousePressCommand.__new__(VtermMousePressCommand)
|
|
mouse_press.view = terminal.view
|
|
mouse_release = VtermMouseReleaseCommand.__new__(VtermMouseReleaseCommand)
|
|
mouse_release.view = terminal.view
|
|
event = {"x": 0, "y": 0, "button": 1}
|
|
|
|
mouse_press.run(None, event)
|
|
assert window.active_view() is terminal.view, "click did not focus the tab"
|
|
mouse_release.run(None, event)
|
|
assert terminal.process.written == "", repr(terminal.process.written)
|
|
|
|
# once focused, press and release at one cell report a click
|
|
mouse_press.run(None, event)
|
|
mouse_release.run(None, event)
|
|
assert terminal.process.written == "\x1b[<0;1;1M\x1b[<0;1;1m", repr(terminal.process.written)
|
|
|
|
# a release elsewhere reports one drag motion first
|
|
terminal.process.writes.clear()
|
|
terminal.view.text = "r0 \nr1 \nr2 \nr3 \nr4 \n"
|
|
mouse_press.run(None, event)
|
|
terminal.view.window_to_text = lambda xy: terminal.view.text_point(2, 4)
|
|
mouse_release.run(None, event)
|
|
assert terminal.process.written == "\x1b[<0;1;1M\x1b[<32;5;3M\x1b[<0;5;3m", repr(terminal.process.written)
|
|
|
|
# the column is counted in cells: a wide char before the point takes two
|
|
terminal.process.writes.clear()
|
|
terminal.view.text = "\u65e5\u672cx \nr1 \nr2 \nr3 \nr4 \n"
|
|
terminal.view.window_to_text = lambda xy: terminal.view.text_point(0, 2)
|
|
mouse_press.run(None, event)
|
|
mouse_release.run(None, event)
|
|
assert terminal.process.written == "\x1b[<0;5;1M\x1b[<0;5;1m", repr(terminal.process.written)
|
|
|
|
print("click tests passed (first click focuses only; press, drag and release report; wide chars)")
|
|
|
|
|
|
# entering the alt screen folds the scrollback away (kitty hides it); leaving
|
|
# unfolds it, and the fold resets the follow anchor so the gate re-engages.
|
|
# the fold ends one character before the screen's first row, out of reach of
|
|
# the per-frame replaces of that row
|
|
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)
|
|
fold_end = terminal.view.text_point(3, 0) - 1
|
|
|
|
stream.feed("\x1b[?1049h")
|
|
command.sync_alt_fold(terminal)
|
|
assert terminal.view.folds == [(0, fold_end)], terminal.view.folds
|
|
assert terminal.viewport_y == 0, terminal.viewport_y
|
|
|
|
# a second render leaves the fold alone
|
|
command.sync_alt_fold(terminal)
|
|
assert terminal.view.folds == [(0, fold_end)], terminal.view.folds
|
|
|
|
# a disturbed fold is re-asserted on the next render, keeping the anchor:
|
|
# apps repaint the boundary row every scroll, and a per-repair reset dropped
|
|
# the user's scrollback position on every frame
|
|
terminal.viewport_y = 60
|
|
terminal.view.folds = [(0, fold_end - 2)]
|
|
command.sync_alt_fold(terminal)
|
|
assert terminal.view.folds == [(0, fold_end)], terminal.view.folds
|
|
assert terminal.viewport_y == 60, terminal.viewport_y
|
|
|
|
stream.feed("\x1b[?1049l")
|
|
command.sync_alt_fold(terminal)
|
|
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)
|
|
stream.feed("\x1b[?1049h")
|
|
command.sync_alt_fold(terminal)
|
|
assert terminal.view.folds == [], terminal.view.folds
|
|
|
|
print("alt fold tests passed (scrollback folded on entry, restored on exit)")
|
|
|
|
|
|
|
|
# a row whose first cells were never written: they read back as default cells
|
|
# (StaticDefaultDict), so they form their own uncoloured run and the coloured
|
|
# run that follows keeps the right column
|
|
from pyte.screens import StaticDefaultDict
|
|
from vterm.emulator import Char
|
|
from vterm.render import segment_buffer_line
|
|
|
|
line = StaticDefaultDict(Char(" "))
|
|
line[3] = Char("X", "red", "default")
|
|
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
|
|
assert runs == [(" ", 0, 3, "default", "default", ""),
|
|
("X", 3, 4, "red", "default", "")], runs
|
|
|
|
# wide characters consume two columns but one character of text
|
|
line = StaticDefaultDict(Char(" "))
|
|
line[0] = Char("漢", "red", "default")
|
|
line[1] = Char("", "red", "default")
|
|
line[2] = Char("x", "blue", "default")
|
|
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
|
|
assert runs == [("漢", 0, 1, "red", "default", ""), ("x", 1, 2, "blue", "default", "")], runs
|
|
|
|
# an orphaned wide-char filler (its wide half was overwritten with a narrow
|
|
# char) still occupies a column: rendered as a space so later spans stay aligned
|
|
line = StaticDefaultDict(Char(" "))
|
|
line[0] = Char("a", "default", "default")
|
|
line[1] = Char("", "red", "default")
|
|
line[2] = Char("x", "blue", "default")
|
|
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
|
|
assert runs == [("a", 0, 1, "default", "default", ""),
|
|
(" ", 1, 2, "red", "default", ""),
|
|
("x", 2, 3, "blue", "default", "")], runs
|
|
|
|
# reverse video swaps the pair, and default becomes reverse_default
|
|
line = StaticDefaultDict(Char(" "))
|
|
line[0] = Char("r", "red", "default", False, False, False, False, True)
|
|
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
|
|
assert runs == [("r", 0, 1, "reverse_default", "red", "")], runs
|
|
|
|
# a cell can hold two code points (a mark NFC could not compose): the spans
|
|
# that follow are offsets into the row text, so they move by two
|
|
line = StaticDefaultDict(Char(" "))
|
|
line[0] = Char("q\u0301", "default", "default")
|
|
line[1] = Char("x", "blue", "default")
|
|
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
|
|
assert runs == [("q\u0301", 0, 2, "default", "default", ""), ("x", 2, 3, "blue", "default", "")], runs
|
|
|
|
print("segmenter tests passed (leading gap, wide char, orphan filler, reverse, two-code-point cell)")
|