Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2abbc503cf |
@@ -296,11 +296,12 @@ def drive(feed, need_to_render, render, data, tick):
|
|||||||
def run_vterm(data, tick):
|
def run_vterm(data, tick):
|
||||||
from vterm.emulator import VtScreen, VtStream
|
from vterm.emulator import VtScreen, VtStream
|
||||||
from vterm.render import VtermRenderCommand
|
from vterm.render import VtermRenderCommand
|
||||||
|
from vterm.scheme import get_scheme_manager
|
||||||
from vterm.terminal import Terminal
|
from vterm.terminal import Terminal
|
||||||
|
|
||||||
counter = collections.Counter()
|
counter = collections.Counter()
|
||||||
view = CountingView(counter)
|
view = CountingView(counter)
|
||||||
terminal = Terminal(view)
|
terminal = Terminal(view, get_scheme_manager("default"))
|
||||||
terminal.scrollback_size = SCROLLBACK
|
terminal.scrollback_size = SCROLLBACK
|
||||||
terminal.process = FakeProcess()
|
terminal.process = FakeProcess()
|
||||||
terminal.screen = VtScreen(COLUMNS, ROWS, process=terminal.process, history=SCROLLBACK)
|
terminal.screen = VtScreen(COLUMNS, ROWS, process=terminal.process, history=SCROLLBACK)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import _bootstrap # sets sys.path for the imports below
|
|||||||
from _bootstrap import FakeProcess
|
from _bootstrap import FakeProcess
|
||||||
|
|
||||||
import sublime
|
import sublime
|
||||||
from vterm.commands import VtermPasteSelectionCommand, VtermSelectThemeCommand, selection_text
|
from vterm.commands import VtermPasteSelectionCommand, VtermPasteTextCommand, VtermSelectThemeCommand, selection_text
|
||||||
from vterm.emulator import VtScreen
|
from vterm.emulator import VtScreen
|
||||||
from vterm.render import CONTINUATION
|
from vterm.render import CONTINUATION
|
||||||
from vterm.scheme import get_scheme_manager
|
from vterm.scheme import get_scheme_manager
|
||||||
@@ -245,3 +245,17 @@ view = FakeView("abcdef", [sublime.Region(2, 2)])
|
|||||||
assert paste(view) == [], view.commands
|
assert paste(view) == [], view.commands
|
||||||
|
|
||||||
print("selection tests passed (sentinel stripping, paste, empty no-op)")
|
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)")
|
||||||
|
|||||||
@@ -454,8 +454,67 @@ def run_clear_cases():
|
|||||||
print("all clear-screen tests passed")
|
print("all clear-screen tests passed")
|
||||||
|
|
||||||
|
|
||||||
|
def run_parser_edge_cases():
|
||||||
|
"""Sequences the stock collector misread: intermediates, `:` subparams, REP, marks."""
|
||||||
|
for char_by_char in (False, True):
|
||||||
|
tag = "char-by-char" if char_by_char else "chunked"
|
||||||
|
|
||||||
|
# DECSTR (`reset` sends it): `!` is an intermediate, the `p` must not be drawn
|
||||||
|
proc, screen, stream = make(20, 4)
|
||||||
|
feed(stream, "\x1b[!pX", char_by_char)
|
||||||
|
assert screen.display[0].rstrip() == "X", (tag, repr(screen.display[0]))
|
||||||
|
|
||||||
|
# `4:3` is curly underline, not SGR 43 (yellow background)
|
||||||
|
proc, screen, stream = make(20, 4)
|
||||||
|
feed(stream, "\x1b[4:3mX\x1b[38:2:1:2:3mY", char_by_char)
|
||||||
|
cell = screen.buffer[0][0]
|
||||||
|
assert (cell.bg, cell.underscore) == ("default", True), (tag, cell)
|
||||||
|
assert screen.buffer[0][1].data == "Y", (tag, screen.buffer[0][1])
|
||||||
|
|
||||||
|
# REP repeats the last printable; ncurses emits it for runs
|
||||||
|
proc, screen, stream = make(20, 4)
|
||||||
|
feed(stream, "a\x1b[3b\x1b[31mb\x1b[b", char_by_char)
|
||||||
|
assert screen.display[0].rstrip() == "aaaabb", (tag, repr(screen.display[0]))
|
||||||
|
assert screen.buffer[0][5].fg == "red", (tag, screen.buffer[0][5])
|
||||||
|
|
||||||
|
# a Thai vowel sign has combining class 0 but is still a mark: merged, not dropped
|
||||||
|
proc, screen, stream = make(20, 4)
|
||||||
|
feed(stream, "\u0e01\u0e31x", char_by_char)
|
||||||
|
assert screen.buffer[0][0].data == "\u0e01\u0e31", (tag, screen.buffer[0][0])
|
||||||
|
assert screen.buffer[0][1].data == "x", (tag, screen.buffer[0][1])
|
||||||
|
|
||||||
|
# DECRQM: the modes we honour answer set/reset, never "unrecognized"
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[?2031$p\x1b[?1h\x1b[?1$p\x1b[4$p\x1b[20$p", char_by_char)
|
||||||
|
assert proc.written == "\x1b[?2031;2$y\x1b[?1;1$y\x1b[4;2$y\x1b[20;2$y", (tag, proc.written)
|
||||||
|
|
||||||
|
# RIS on the alternate screen lands back on a cleared primary screen
|
||||||
|
proc, screen, stream = make(20, 4)
|
||||||
|
feed(stream, "primary\x1b[?1049halt\x1bc", char_by_char)
|
||||||
|
assert not screen.alternate_buffer_mode and not screen.savepoints, tag
|
||||||
|
assert screen.display[0].strip() == "", (tag, repr(screen.display[0]))
|
||||||
|
feed(stream, "\r\n" * 4, char_by_char)
|
||||||
|
assert len(screen.history) == 1, (tag, len(screen.history))
|
||||||
|
|
||||||
|
# SCOSC/SCORC, and CPR relative to the region under DECOM
|
||||||
|
proc, screen, stream = make(20, 4)
|
||||||
|
feed(stream, "ab\x1b[s\x1b[3;3H\x1b[uX\x1b[2;3r\x1b[?6h\x1b[6n", char_by_char)
|
||||||
|
assert screen.display[0].rstrip() == "abX", (tag, repr(screen.display[0]))
|
||||||
|
assert proc.written == "\x1b[1;1R", (tag, proc.written)
|
||||||
|
|
||||||
|
# a string with no terminator ends at the next ESC, as in xterm; a stray
|
||||||
|
# ESC P from a binary must not swallow the rest of the session
|
||||||
|
proc, screen, stream = make(20, 4)
|
||||||
|
feed(stream, "\x1bPgarbage\x1bcX\x1b]0;title\x1b[1mY", char_by_char)
|
||||||
|
assert screen.display[0].rstrip() == "XY", (tag, repr(screen.display[0]))
|
||||||
|
assert screen.title == "title" and screen.buffer[0][1].bold, (tag, screen.title)
|
||||||
|
|
||||||
|
print("all parser edge-case tests passed")
|
||||||
|
|
||||||
|
|
||||||
run_case(char_by_char=False)
|
run_case(char_by_char=False)
|
||||||
run_case(char_by_char=True)
|
run_case(char_by_char=True)
|
||||||
|
run_parser_edge_cases()
|
||||||
run_clear_cases()
|
run_clear_cases()
|
||||||
run_resize_cases()
|
run_resize_cases()
|
||||||
run_private_csi_cases()
|
run_private_csi_cases()
|
||||||
|
|||||||
@@ -357,7 +357,13 @@ command = make_command(terminal)
|
|||||||
stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300)))
|
stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300)))
|
||||||
command.update_lines(None, terminal)
|
command.update_lines(None, terminal)
|
||||||
terminal.screen.dirty.clear()
|
terminal.screen.dirty.clear()
|
||||||
assert set(command.state.archived_chunks) == {0}, command.state.archived_chunks
|
# 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)
|
command.trim_history(None, terminal)
|
||||||
assert "vterm@0@vterm.red.default" not in terminal.view.regions, "trimmed chunk key left behind"
|
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 0 not in command.state.archived_chunks
|
||||||
@@ -665,7 +671,15 @@ terminal.view.window_to_text = lambda xy: terminal.view.text_point(2, 4)
|
|||||||
mouse_release.run(None, event)
|
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)
|
assert terminal.process.written == "\x1b[<0;1;1M\x1b[<32;5;3M\x1b[<0;5;3m", repr(terminal.process.written)
|
||||||
|
|
||||||
print("click tests passed (first click focuses only; press, drag and release report)")
|
# 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
|
# entering the alt screen folds the scrollback away (kitty hides it); leaving
|
||||||
@@ -751,4 +765,12 @@ 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]]
|
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
|
||||||
assert runs == [("r", 0, 1, "reverse_default", "red", "")], runs
|
assert runs == [("r", 0, 1, "reverse_default", "red", "")], runs
|
||||||
|
|
||||||
print("segmenter tests passed (leading gap, wide char, orphan filler, reverse)")
|
# 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)")
|
||||||
|
|||||||
@@ -33,12 +33,12 @@
|
|||||||
|
|
||||||
// External terminal for "vterm: Open Terminal in Project Folder".
|
// External terminal for "vterm: Open Terminal in Project Folder".
|
||||||
// %CWD% in parameters is replaced with the project folder.
|
// %CWD% in parameters is replaced with the project folder.
|
||||||
// "terminal": "/usr/bin/open",
|
"terminal": "/usr/bin/open",
|
||||||
// "parameters": ["-a", "Terminal", "%CWD%"],
|
"parameters": ["-a", "Terminal", "%CWD%"],
|
||||||
|
|
||||||
// kitty instead of Apple Terminal:
|
// kitty instead of Apple Terminal:
|
||||||
"terminal": "/usr/bin/open",
|
// "terminal": "/usr/bin/open",
|
||||||
"parameters": ["-a", "Kitty", "%CWD%"],
|
// "parameters": ["-a", "Kitty", "%CWD%"],
|
||||||
|
|
||||||
"debug": false
|
"debug": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ FG_AIXTERM = {90 + i: ANSI_COLORS[8 + i] for i in range(8)}
|
|||||||
BG_AIXTERM = {100 + i: ANSI_COLORS[8 + i] for i in range(8)}
|
BG_AIXTERM = {100 + i: ANSI_COLORS[8 + i] for i in range(8)}
|
||||||
|
|
||||||
# DECRQM answers, as wire numbers (pyte stores modes shifted << 5)
|
# DECRQM answers, as wire numbers (pyte stores modes shifted << 5)
|
||||||
REPORTABLE_MODES = (mo.DECAWM >> 5, mo.DECTCEM >> 5, 1000, 1002, 1003, 1004, 1006, 1049, 2004, 2026)
|
REPORTABLE_MODES = (1, mo.DECAWM >> 5, mo.DECTCEM >> 5, 1000, 1002, 1003, 1004, 1006, 1049, 2004, 2026, 2031)
|
||||||
|
|
||||||
# DEC private modes, pre-shifted into pyte's internal representation.
|
# DEC private modes, pre-shifted into pyte's internal representation.
|
||||||
# 1: DECCKM, application cursor keys (arrows send SS3)
|
# 1: DECCKM, application cursor keys (arrows send SS3)
|
||||||
@@ -153,6 +153,8 @@ class VtScreen(pyte.Screen):
|
|||||||
self._char_caches = {}
|
self._char_caches = {}
|
||||||
self._char_cache = {}
|
self._char_cache = {}
|
||||||
self._cache_attrs = None
|
self._cache_attrs = None
|
||||||
|
# last printable drawn, for REP
|
||||||
|
self._last_char = ""
|
||||||
self.history = deque(maxlen=history)
|
self.history = deque(maxlen=history)
|
||||||
self.alternate_buffer_mode = False
|
self.alternate_buffer_mode = False
|
||||||
self._sync_frame_start = 0
|
self._sync_frame_start = 0
|
||||||
@@ -165,11 +167,16 @@ class VtScreen(pyte.Screen):
|
|||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
"""RIS: reset the screen along with the cursor, scrollback and caches"""
|
"""RIS: reset the screen along with the cursor, scrollback and caches"""
|
||||||
|
if self.alternate_buffer_mode:
|
||||||
|
# the primary screen is what a reset clears; leave the alt one behind
|
||||||
|
self.reset_mode(1049, private=True)
|
||||||
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
|
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
|
||||||
self._sgr_cache.clear()
|
self._sgr_cache.clear()
|
||||||
self._char_caches.clear()
|
self._char_caches.clear()
|
||||||
self._cache_attrs = None
|
self._cache_attrs = None
|
||||||
|
self._last_char = ""
|
||||||
super().reset()
|
super().reset()
|
||||||
|
self.savepoints = []
|
||||||
self.cursor = Cursor(0, 0)
|
self.cursor = Cursor(0, 0)
|
||||||
self.cursor_style = 0
|
self.cursor_style = 0
|
||||||
self.modify_other_keys = 0
|
self.modify_other_keys = 0
|
||||||
@@ -376,6 +383,7 @@ class VtScreen(pyte.Screen):
|
|||||||
dirty.add(cursor.y)
|
dirty.add(cursor.y)
|
||||||
cursor.x = x + take
|
cursor.x = x + take
|
||||||
pos = end
|
pos = end
|
||||||
|
self._last_char = data[-1]
|
||||||
return
|
return
|
||||||
|
|
||||||
line = buffer[cursor.y]
|
line = buffer[cursor.y]
|
||||||
@@ -400,6 +408,7 @@ class VtScreen(pyte.Screen):
|
|||||||
if cell is None:
|
if cell is None:
|
||||||
cell = cache[char] = attrs._replace(data=char)
|
cell = cache[char] = attrs._replace(data=char)
|
||||||
line[cursor.x] = cell
|
line[cursor.x] = cell
|
||||||
|
self._last_char = char
|
||||||
if char_width == 2 and cursor.x + 1 < columns:
|
if char_width == 2 and cursor.x + 1 < columns:
|
||||||
filler = cache.get("")
|
filler = cache.get("")
|
||||||
if filler is None:
|
if filler is None:
|
||||||
@@ -407,7 +416,10 @@ class VtScreen(pyte.Screen):
|
|||||||
line[cursor.x + 1] = filler
|
line[cursor.x + 1] = filler
|
||||||
# a combining mark belongs to the cell before it, which may be the
|
# a combining mark belongs to the cell before it, which may be the
|
||||||
# last column of the previous row
|
# last column of the previous row
|
||||||
elif char_width == 0 and unicodedata.combining(char):
|
# a mark: by category, not combining(), which is 0 for Thai/Indic
|
||||||
|
# vowel signs. Variation selectors are marks too but stay dropped:
|
||||||
|
# merged, VS16 would make the cell width 2 and swallow its neighbour
|
||||||
|
elif char_width == 0 and unicodedata.category(char)[0] == "M" and not "\ufe00" <= char <= "\ufe0f":
|
||||||
# sublime renders decomposed combining chars poorly; merge as NFC
|
# sublime renders decomposed combining chars poorly; merge as NFC
|
||||||
pos = None
|
pos = None
|
||||||
last = None
|
last = None
|
||||||
@@ -439,6 +451,11 @@ class VtScreen(pyte.Screen):
|
|||||||
|
|
||||||
self.dirty.add(cursor.y)
|
self.dirty.add(cursor.y)
|
||||||
|
|
||||||
|
def repeat_character(self, count=None):
|
||||||
|
"""REP: draw the last printable again `count` times."""
|
||||||
|
if self._last_char:
|
||||||
|
self.draw(self._last_char * (count or 1))
|
||||||
|
|
||||||
def index(self):
|
def index(self):
|
||||||
"""Line feed; at the bottom of the primary screen the top row goes to scrollback."""
|
"""Line feed; at the bottom of the primary screen the top row goes to scrollback."""
|
||||||
# a region spanning the whole screen (CSI 1;<lines> r) is no region
|
# a region spanning the whole screen (CSI 1;<lines> r) is no region
|
||||||
@@ -615,6 +632,8 @@ class VtScreen(pyte.Screen):
|
|||||||
elif mode == 6:
|
elif mode == 6:
|
||||||
x = min(self.cursor.x, self.columns - 1) + 1
|
x = min(self.cursor.x, self.columns - 1) + 1
|
||||||
y = self.cursor.y + 1
|
y = self.cursor.y + 1
|
||||||
|
if mo.DECOM in self.mode and self.margins:
|
||||||
|
y -= self.margins.top
|
||||||
marker = "?" if private else ""
|
marker = "?" if private else ""
|
||||||
self.write_process_input("\x1b[{}{};{}R".format(marker, y, x))
|
self.write_process_input("\x1b[{}{};{}R".format(marker, y, x))
|
||||||
elif mode == 996 and private:
|
elif mode == 996 and private:
|
||||||
@@ -632,7 +651,10 @@ class VtScreen(pyte.Screen):
|
|||||||
def report_mode_status(self, mode, private):
|
def report_mode_status(self, mode, private):
|
||||||
"""DECRQM: answer 1 set, 2 reset, 0 for modes we do not implement."""
|
"""DECRQM: answer 1 set, 2 reset, 0 for modes we do not implement."""
|
||||||
if not private:
|
if not private:
|
||||||
self.write_process_input("\x1b[{};0$y".format(mode))
|
value = 0
|
||||||
|
if mode in (mo.IRM, mo.LNM):
|
||||||
|
value = 1 if mode in self.mode else 2
|
||||||
|
self.write_process_input("\x1b[{};{}$y".format(mode, value))
|
||||||
return
|
return
|
||||||
if mode in REPORTABLE_MODES:
|
if mode in REPORTABLE_MODES:
|
||||||
value = 1 if (mode << 5) in self.mode else 2
|
value = 1 if (mode << 5) in self.mode else 2
|
||||||
@@ -683,6 +705,9 @@ class VtStream(pyte.Stream):
|
|||||||
self.csi = dict(self.csi)
|
self.csi = dict(self.csi)
|
||||||
self.csi["S"] = "scroll_up"
|
self.csi["S"] = "scroll_up"
|
||||||
self.csi["T"] = "scroll_down"
|
self.csi["T"] = "scroll_down"
|
||||||
|
self.csi["b"] = "repeat_character"
|
||||||
|
self.csi["s"] = "save_cursor"
|
||||||
|
self.csi["u"] = "restore_cursor"
|
||||||
self._carry = ""
|
self._carry = ""
|
||||||
# param-string -> parsed tuple; SGR strings repeat across a whole frame
|
# param-string -> parsed tuple; SGR strings repeat across a whole frame
|
||||||
self._params_cache = {}
|
self._params_cache = {}
|
||||||
@@ -746,7 +771,8 @@ class VtStream(pyte.Stream):
|
|||||||
raw = match.group(1)
|
raw = match.group(1)
|
||||||
params = cache.get(raw)
|
params = cache.get(raw)
|
||||||
if params is None:
|
if params is None:
|
||||||
params = tuple(min(int(p), 9999) if p else 0 for p in raw.split(";"))
|
# no parameters is (), not (0,): save_cursor takes none
|
||||||
|
params = tuple(min(int(p), 9999) if p else 0 for p in raw.split(";")) if raw else ()
|
||||||
if len(cache) > 4096:
|
if len(cache) > 4096:
|
||||||
cache.clear()
|
cache.clear()
|
||||||
cache[raw] = params
|
cache[raw] = params
|
||||||
@@ -805,24 +831,33 @@ class VtStream(pyte.Stream):
|
|||||||
escape_dispatch = create_dispatcher(self.escape)
|
escape_dispatch = create_dispatcher(self.escape)
|
||||||
csi_dispatch = create_dispatcher(self.csi)
|
csi_dispatch = create_dispatcher(self.csi)
|
||||||
|
|
||||||
|
# the byte after an ESC that cut a string short: any ESC ends a string,
|
||||||
|
# as in xterm, or one with no ST would swallow every later sequence
|
||||||
|
pending = None
|
||||||
while True:
|
while True:
|
||||||
char = yield True
|
char = (yield True) if pending is None else ESC
|
||||||
|
|
||||||
# ESC introducer: fold the 7-bit forms onto their C1 equivalents
|
# ESC introducer: fold the 7-bit forms onto their C1 equivalents
|
||||||
if char == ESC:
|
if char == ESC:
|
||||||
char = yield None
|
if pending is None:
|
||||||
|
char = yield None
|
||||||
|
else:
|
||||||
|
char, pending = pending, None
|
||||||
if char == "[":
|
if char == "[":
|
||||||
char = CSI_C1
|
char = CSI_C1
|
||||||
elif char == "]":
|
elif char == "]":
|
||||||
char = OSC_C1
|
char = OSC_C1
|
||||||
elif char in "PX^_":
|
elif char in "PX^_":
|
||||||
# consume the payload so it never reaches the screen
|
# consume the payload so it never reaches the screen
|
||||||
prev = ""
|
|
||||||
while True:
|
while True:
|
||||||
c = yield None
|
c = yield None
|
||||||
if c == ctrl.ST_C1 or (prev == ESC and c == "\\"):
|
if c == ESC:
|
||||||
|
c = yield None
|
||||||
|
if c != "\\":
|
||||||
|
pending = c
|
||||||
|
break
|
||||||
|
if c == ctrl.ST_C1:
|
||||||
break
|
break
|
||||||
prev = c
|
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
if char == "#":
|
if char == "#":
|
||||||
@@ -848,13 +883,15 @@ class VtStream(pyte.Stream):
|
|||||||
private = False
|
private = False
|
||||||
prefix = ""
|
prefix = ""
|
||||||
intermediate = ""
|
intermediate = ""
|
||||||
|
# inside a `:` subparameter list, which is dropped (4:3 must not read as 43)
|
||||||
|
sub = False
|
||||||
while True:
|
while True:
|
||||||
char = yield None
|
char = yield None
|
||||||
if char == "?":
|
if char == "?":
|
||||||
private = True
|
private = True
|
||||||
elif char in "><=":
|
elif char in "><=":
|
||||||
prefix = char
|
prefix = char
|
||||||
elif char in " $":
|
elif " " <= char <= "/":
|
||||||
intermediate = char
|
intermediate = char
|
||||||
elif char in ALLOWED_IN_CSI:
|
elif char in ALLOWED_IN_CSI:
|
||||||
basic_dispatch[char]()
|
basic_dispatch[char]()
|
||||||
@@ -862,15 +899,17 @@ class VtStream(pyte.Stream):
|
|||||||
draw(char)
|
draw(char)
|
||||||
break
|
break
|
||||||
elif char.isdigit():
|
elif char.isdigit():
|
||||||
current += char
|
if not sub:
|
||||||
|
current += char
|
||||||
elif char == ":":
|
elif char == ":":
|
||||||
# SGR subparameters, which we do not support
|
sub = True
|
||||||
pass
|
|
||||||
elif char == ";":
|
elif char == ";":
|
||||||
params.append(min(int(current or 0), 9999))
|
params.append(min(int(current or 0), 9999))
|
||||||
current = ""
|
current = ""
|
||||||
|
sub = False
|
||||||
else:
|
else:
|
||||||
params.append(min(int(current or 0), 9999))
|
if current or params:
|
||||||
|
params.append(min(int(current or 0), 9999))
|
||||||
self._dispatch_csi(csi_dispatch, listener, char, params, private, prefix, intermediate)
|
self._dispatch_csi(csi_dispatch, listener, char, params, private, prefix, intermediate)
|
||||||
break
|
break
|
||||||
# OSC: a code of up to 8 digits, then a payload up to MAX_OSC,
|
# OSC: a code of up to 8 digits, then a payload up to MAX_OSC,
|
||||||
@@ -885,18 +924,19 @@ class VtStream(pyte.Stream):
|
|||||||
break
|
break
|
||||||
|
|
||||||
payload = ""
|
payload = ""
|
||||||
if char == ";" or (char not in OSC_TERMINATORS and char != ESC):
|
if char == ";":
|
||||||
while True:
|
char = yield None
|
||||||
|
while char not in OSC_TERMINATORS:
|
||||||
|
if char == ESC:
|
||||||
char = yield None
|
char = yield None
|
||||||
if char == ESC:
|
if char != "\\":
|
||||||
char += yield None
|
pending = char
|
||||||
if char in OSC_TERMINATORS:
|
# char holds the whole terminator, ST for a cut string
|
||||||
break
|
char = ctrl.ST_C0
|
||||||
if len(payload) < MAX_OSC:
|
break
|
||||||
payload += char
|
if len(payload) < MAX_OSC:
|
||||||
elif char == ESC:
|
payload += char
|
||||||
# take the '\' of ST, so char holds the whole terminator
|
char = yield None
|
||||||
char += yield None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
listener.handle_osc(int(code or 0), payload, char)
|
listener.handle_osc(int(code or 0), payload, char)
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ The trackpad is not handled here. On macOS it never reaches a mousemap binding
|
|||||||
continuous scrolling into discrete wheel events; those arrive as ordinary wheel
|
continuous scrolling into discrete wheel events; those arrive as ordinary wheel
|
||||||
notches and take the path below.
|
notches and take the path below.
|
||||||
"""
|
"""
|
||||||
|
import sublime
|
||||||
import sublime_plugin
|
import sublime_plugin
|
||||||
|
from wcwidth import wcswidth
|
||||||
|
|
||||||
from .terminal import Terminal
|
from .terminal import Terminal
|
||||||
from .utils import settings
|
from .utils import settings
|
||||||
@@ -41,6 +43,7 @@ def _event_to_cell(view, terminal, event, clamp=False):
|
|||||||
wheel and a release still belong to the app there, a press does not."""
|
wheel and a release still belong to the app there, a press does not."""
|
||||||
pt = view.window_to_text((event["x"], event["y"]))
|
pt = view.window_to_text((event["x"], event["y"]))
|
||||||
row, col = view.rowcol(pt)
|
row, col = view.rowcol(pt)
|
||||||
|
col = wcswidth(view.substr(sublime.Region(pt - col, pt)))
|
||||||
row -= terminal.offset
|
row -= terminal.offset
|
||||||
screen = terminal.screen
|
screen = terminal.screen
|
||||||
if row < 0 or row >= screen.lines:
|
if row < 0 or row >= screen.lines:
|
||||||
|
|||||||
105
vterm/render.py
105
vterm/render.py
@@ -1,11 +1,13 @@
|
|||||||
""" Rendering the emulator screen into the Sublime view
|
""" Rendering the emulator screen into the Sublime view
|
||||||
|
|
||||||
The view holds real text: scrollback above `terminal.offset`, the live screen
|
The view holds real text: scrollback above `terminal.offset`, the live screen
|
||||||
window from `offset` on. Dirty rows are whole-line replaced; colors are
|
window from `offset` on. Dirty rows are whole-line replaced, and rows the view
|
||||||
add_regions overlays with scopes served by the dynamic scheme. The ST caret
|
does not hold yet are appended in one insert; colors are add_regions overlays
|
||||||
|
with scopes served by the dynamic scheme. The ST caret
|
||||||
doubles as the terminal cursor.
|
doubles as the terminal cursor.
|
||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
|
from bisect import bisect_left
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
import sublime
|
import sublime
|
||||||
@@ -88,8 +90,10 @@ def segment_buffer_line(buffer_line):
|
|||||||
|
|
||||||
# an orphaned wide-char filler (its first half was overwritten) still
|
# an orphaned wide-char filler (its first half was overwritten) still
|
||||||
# occupies a column; render a space so later spans stay aligned
|
# occupies a column; render a space so later spans stay aligned
|
||||||
run.append(char.data or " ")
|
data = char.data
|
||||||
column += 1
|
run.append(data or " ")
|
||||||
|
# a cell can hold more than one code point: a mark NFC could not compose
|
||||||
|
column += len(data) or 1
|
||||||
|
|
||||||
if attrs is not None:
|
if attrs is not None:
|
||||||
yield _run(run, start, column, attrs)
|
yield _run(run, start, column, attrs)
|
||||||
@@ -304,23 +308,33 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
# valid for this method only; the trim paths erase rows
|
# valid for this method only; the trim paths erase rows
|
||||||
self._lastrow = self.view.rowcol(self.view.size())[0]
|
self._lastrow = self.view.rowcol(self.view.size())[0]
|
||||||
if dirty_lines:
|
if dirty_lines:
|
||||||
# history pops newest-first, so it fills the rows below `offset` backwards
|
|
||||||
history = screen.history
|
history = screen.history
|
||||||
popped = len(history)
|
popped = len(history)
|
||||||
terminal.offset += popped
|
terminal.offset += popped
|
||||||
offset = terminal.offset
|
offset = terminal.offset
|
||||||
|
first = offset - popped
|
||||||
|
retired = list(history)
|
||||||
|
history.clear()
|
||||||
|
|
||||||
for line in range(popped):
|
# rows the view already holds are rewritten one by one; the rest
|
||||||
buffer_line = history.pop()
|
# are new and go in as one insert, after those edits moved the end
|
||||||
self.update_line(edit, offset - line - 1, buffer_line, columns)
|
split = min(popped, self._lastrow + 1 - first)
|
||||||
|
for i in range(split):
|
||||||
|
self.update_line(edit, first + i, retired[i], columns)
|
||||||
|
if split < popped:
|
||||||
|
self.append_rows(edit, retired[split:], columns, scrollback=True)
|
||||||
|
|
||||||
# after the loop: a pop edits an earlier point than the one before
|
# after the edits: spans measured mid-loop would be stale
|
||||||
# it, so spans measured mid-loop would be stale
|
self.archive_rows(range(first, offset))
|
||||||
self.archive_rows(range(offset - popped, offset))
|
|
||||||
|
|
||||||
for line in dirty_lines:
|
# screen rows past the view's end go in as one insert too
|
||||||
buffer_line = screen.buffer[line]
|
start = self._lastrow + 1 - offset
|
||||||
self.update_line(edit, line + offset, buffer_line, columns)
|
cut = bisect_left(dirty_lines, start)
|
||||||
|
for line in dirty_lines[:cut]:
|
||||||
|
self.update_line(edit, line + offset, screen.buffer[line], columns)
|
||||||
|
if cut < len(dirty_lines):
|
||||||
|
rows = range(start, dirty_lines[-1] + 1)
|
||||||
|
self.append_rows(edit, [screen.buffer[y] for y in rows], columns, scrollback=False)
|
||||||
|
|
||||||
def update_line(self, edit, line, buffer_line, columns):
|
def update_line(self, edit, line, buffer_line, columns):
|
||||||
"""Rewrite one view row from a screen row, unless it is unchanged"""
|
"""Rewrite one view row from a screen row, unless it is unchanged"""
|
||||||
@@ -333,7 +347,17 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
if self.state.row_cache.get(line) == rendered:
|
if self.state.row_cache.get(line) == rendered:
|
||||||
return
|
return
|
||||||
self.state.row_cache[line] = rendered
|
self.state.row_cache[line] = rendered
|
||||||
|
text, by_scope = self.row_text(segments, lf)
|
||||||
|
|
||||||
|
self.ensure_row(edit, line)
|
||||||
|
line_region = view.line(view.text_point(line, 0))
|
||||||
|
self.decolorize_line(line)
|
||||||
|
view.replace(edit, line_region, text)
|
||||||
|
# replacing a line cannot move its start
|
||||||
|
self.colorize_line(line, line_region.begin(), by_scope)
|
||||||
|
|
||||||
|
def row_text(self, segments, lf):
|
||||||
|
"""The view text of a row and its colour spans by scope, as char offsets."""
|
||||||
by_scope = {}
|
by_scope = {}
|
||||||
needed = 0
|
needed = 0
|
||||||
scope_of = self._scheme.scope
|
scope_of = self._scheme.scope
|
||||||
@@ -350,13 +374,54 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
if len(text) < needed:
|
if len(text) < needed:
|
||||||
# pad only to the last coloured run, in the same edit as the text
|
# pad only to the last coloured run, in the same edit as the text
|
||||||
text += " " * (needed - len(text))
|
text += " " * (needed - len(text))
|
||||||
|
return text, by_scope
|
||||||
|
|
||||||
self.ensure_row(edit, line)
|
def append_rows(self, edit, buffer_lines, columns, scrollback):
|
||||||
line_region = view.line(view.text_point(line, 0))
|
"""Add rows below the view's end in one insert.
|
||||||
self.decolorize_line(line)
|
|
||||||
view.replace(edit, line_region, text)
|
Scrollback rows are never rewritten, so their colours go straight onto
|
||||||
# replacing a line cannot move its start
|
the chunk keys with no per-row key for flush_chunk to merge. Screen
|
||||||
self.colorize_line(line, line_region.begin(), by_scope)
|
rows are coloured per row as usual; a trailing blank run the trim
|
||||||
|
already erased is still cached as rendered and is left out.
|
||||||
|
"""
|
||||||
|
view = self.view
|
||||||
|
rows = [(line, list(segment_buffer_line(buffer_line)), buffer_line[columns - 1].linefeed)
|
||||||
|
for line, buffer_line in enumerate(buffer_lines, self._lastrow + 1)]
|
||||||
|
if not scrollback:
|
||||||
|
while rows and self.state.row_cache.get(rows[-1][0]) == rows[-1][1:]:
|
||||||
|
rows.pop()
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
point = view.size() + 1
|
||||||
|
texts = []
|
||||||
|
# scrollback: (chunk, scope) -> regions; screen: (line, start, by_scope)
|
||||||
|
painted = {}
|
||||||
|
colored = []
|
||||||
|
trimmed = self.state.trimmed
|
||||||
|
for line, segments, lf in rows:
|
||||||
|
self.state.row_cache[line] = (segments, lf)
|
||||||
|
text, by_scope = self.row_text(segments, lf)
|
||||||
|
texts.append(text)
|
||||||
|
if scrollback:
|
||||||
|
chunk = (line + trimmed) // CHUNK
|
||||||
|
for scope, spans in by_scope.items():
|
||||||
|
painted.setdefault((chunk, scope), []).extend(sublime.Region(point + a, point + b) for a, b in spans)
|
||||||
|
elif by_scope:
|
||||||
|
colored.append((line, point, by_scope))
|
||||||
|
point += len(text) + 1
|
||||||
|
view.insert(edit, view.size(), "\n" + "\n".join(texts))
|
||||||
|
self._lastrow = rows[-1][0]
|
||||||
|
|
||||||
|
for (chunk, scope), regions in painted.items():
|
||||||
|
key = chunk_key(chunk, scope)
|
||||||
|
scopes = self.state.archived_chunks.setdefault(chunk, set())
|
||||||
|
if scope in scopes:
|
||||||
|
regions = view.get_regions(key) + regions
|
||||||
|
view.add_regions(key, regions, scope)
|
||||||
|
scopes.add(scope)
|
||||||
|
for line, start, by_scope in colored:
|
||||||
|
self.colorize_line(line, start, by_scope)
|
||||||
|
|
||||||
def colorize_line(self, line, line_start, by_scope):
|
def colorize_line(self, line, line_start, by_scope):
|
||||||
"""Lay the colour overlays for one row, one add_regions per scope."""
|
"""Lay the colour overlays for one row, one add_regions per scope."""
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import codecs
|
import codecs
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
@@ -40,6 +41,10 @@ IDLE_TICK = 0.2
|
|||||||
# a millisecond apart, and drawing between them flashes its showcmd echo
|
# a millisecond apart, and drawing between them flashes its showcmd echo
|
||||||
SETTLE = 0.005
|
SETTLE = 0.005
|
||||||
|
|
||||||
|
# C0 controls bar tab/LF/CR, stripped from text sent as typed: a pasted
|
||||||
|
# ESC[201~ would end the bracket early and type the rest into the app
|
||||||
|
CONTROL_CHARS = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
||||||
|
|
||||||
# frames between undo-stack clears: every render edit lands on the view's undo
|
# frames between undo-stack clears: every render edit lands on the view's undo
|
||||||
# stack, which only focus changes clear otherwise, and undo is swallowed anyway
|
# stack, which only focus changes clear otherwise, and undo is swallowed anyway
|
||||||
UNDO_CLEAR_FRAMES = 100
|
UNDO_CLEAR_FRAMES = 100
|
||||||
@@ -394,7 +399,7 @@ class Terminal:
|
|||||||
# escape sequences go through untouched; typed and pasted text has its
|
# escape sequences go through untouched; typed and pasted text has its
|
||||||
# line endings canonicalised for the app's LNM setting
|
# line endings canonicalised for the app's LNM setting
|
||||||
if normalized:
|
if normalized:
|
||||||
string = string.replace("\r\n", "\n")
|
string = CONTROL_CHARS.sub("", string).replace("\r\n", "\n")
|
||||||
if mo.LNM in self.screen.mode:
|
if mo.LNM in self.screen.mode:
|
||||||
string = string.replace("\n", "\r\n")
|
string = string.replace("\n", "\r\n")
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user