code comments changes
This commit is contained in:
@@ -83,7 +83,7 @@ assert terminal.screen.theme_is_dark is True
|
|||||||
# without mode 2031 the app is not interrupted
|
# without mode 2031 the app is not interrupted
|
||||||
assert recorder.written == "", repr(recorder.written)
|
assert recorder.written == "", repr(recorder.written)
|
||||||
|
|
||||||
# with it, the dark/light report is sent so the agent can restyle itself
|
# with it, the dark/light report is sent so the app can restyle itself
|
||||||
stream.feed("\x1b[?2031h")
|
stream.feed("\x1b[?2031h")
|
||||||
recorder.written = ""
|
recorder.written = ""
|
||||||
terminal.apply_scheme()
|
terminal.apply_scheme()
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ def feed(stream, data, char_by_char=False):
|
|||||||
def run_case(char_by_char):
|
def run_case(char_by_char):
|
||||||
tag = "char-by-char" if char_by_char else "chunked"
|
tag = "char-by-char" if char_by_char else "chunked"
|
||||||
|
|
||||||
# DA1 answered (Claude Code's untimed sentinel)
|
# DA1 answered; apps can wait on it with no timeout
|
||||||
proc, screen, stream = make()
|
proc, screen, stream = make()
|
||||||
feed(stream, "\x1b[c", char_by_char)
|
feed(stream, "\x1b[c", char_by_char)
|
||||||
assert proc.written == "\x1b[?62;22c", (tag, proc.written)
|
assert proc.written == "\x1b[?62;22c", (tag, proc.written)
|
||||||
@@ -34,7 +34,7 @@ def run_case(char_by_char):
|
|||||||
feed(stream, "\x1b[5;10H\x1b[6n\x1b[?6n", char_by_char)
|
feed(stream, "\x1b[5;10H\x1b[6n\x1b[?6n", char_by_char)
|
||||||
assert proc.written == "\x1b[5;10R\x1b[?5;10R", (tag, proc.written)
|
assert proc.written == "\x1b[5;10R\x1b[?5;10R", (tag, proc.written)
|
||||||
|
|
||||||
# XTVERSION then DECRQM 2026 (the Claude Code probe sequence)
|
# XTVERSION then DECRQM 2026, the sync-output probe sequence
|
||||||
proc, screen, stream = make()
|
proc, screen, stream = make()
|
||||||
feed(stream, "\x1b[>0q\x1b[c\x1b[?2026$p\x1b[c", char_by_char)
|
feed(stream, "\x1b[>0q\x1b[c\x1b[?2026$p\x1b[c", char_by_char)
|
||||||
assert proc.written == (
|
assert proc.written == (
|
||||||
@@ -54,8 +54,8 @@ def run_case(char_by_char):
|
|||||||
# and nothing leaked to the screen
|
# and nothing leaked to the screen
|
||||||
assert screen.buffer[0][0].data == " ", (tag, screen.buffer[0][0])
|
assert screen.buffer[0][0].data == " ", (tag, screen.buffer[0][0])
|
||||||
|
|
||||||
# modifyOtherKeys: the level is tracked (pi enables it and reads shift+enter
|
# modifyOtherKeys: the level is tracked, and the sequence must not corrupt
|
||||||
# no other way), and the sequence must not corrupt SGR state
|
# SGR state
|
||||||
proc, screen, stream = make()
|
proc, screen, stream = make()
|
||||||
assert screen.modify_other_keys == 0, tag
|
assert screen.modify_other_keys == 0, tag
|
||||||
feed(stream, "\x1b[>4;2mhello", char_by_char)
|
feed(stream, "\x1b[>4;2mhello", char_by_char)
|
||||||
@@ -211,8 +211,8 @@ def run_resize_cases():
|
|||||||
feed(stream, "\x1b[?1049l", False)
|
feed(stream, "\x1b[?1049l", False)
|
||||||
assert max(screen.buffer[0]) < 8, sorted(screen.buffer[0])
|
assert max(screen.buffer[0]) < 8, sorted(screen.buffer[0])
|
||||||
|
|
||||||
# a shrink clamps the cursor (pyte does it via restore_cursor); without
|
# a shrink clamps the cursor; without the clamp draw() writes to rows
|
||||||
# the clamp draw() writes to rows above or below the screen
|
# above or below the screen
|
||||||
proc, screen, stream = make(10, 8)
|
proc, screen, stream = make(10, 8)
|
||||||
feed(stream, "\r\n".join("line{}".format(i) for i in range(8)), False)
|
feed(stream, "\r\n".join("line{}".format(i) for i in range(8)), False)
|
||||||
feed(stream, "\x1b[1;1H", False) # full screen, cursor at home
|
feed(stream, "\x1b[1;1H", False) # full screen, cursor at home
|
||||||
@@ -239,8 +239,8 @@ def run_resize_cases():
|
|||||||
|
|
||||||
|
|
||||||
def run_private_csi_cases():
|
def run_private_csi_cases():
|
||||||
"""No private CSI may raise: an exception here used to kill the renderer
|
"""No private CSI may raise: an exception here kills the renderer thread
|
||||||
thread and freeze the tab. aerc probes XTSMGRAPHICS (ESC[?2;1;0S)."""
|
and freezes the tab. aerc probes XTSMGRAPHICS (ESC[?2;1;0S)."""
|
||||||
for final in "ABCDEFGHJKLMPSTXZbcdfghlmnqrstu@":
|
for final in "ABCDEFGHJKLMPSTXZbcdfghlmnqrstu@":
|
||||||
proc, screen, stream = make()
|
proc, screen, stream = make()
|
||||||
stream.feed("\x1b[?2;1;0" + final) # multi-parameter private CSI
|
stream.feed("\x1b[?2;1;0" + final) # multi-parameter private CSI
|
||||||
@@ -269,9 +269,9 @@ def run_private_csi_cases():
|
|||||||
def run_decscnm_cases():
|
def run_decscnm_cases():
|
||||||
"""A DECSCNM flash (vim's visual bell) must not outlive itself.
|
"""A DECSCNM flash (vim's visual bell) must not outlive itself.
|
||||||
|
|
||||||
pyte points every line at the blank cell from inside set/reset_mode, so a
|
The lines point at the blank cell, so one keeping a reverse default paints
|
||||||
line kept a reverse default and painted the columns the app never wrote —
|
the columns the app never wrote — vim skips indentation with cursor-forward
|
||||||
vim skips indentation with cursor-forward — as white blocks.
|
— as white blocks.
|
||||||
"""
|
"""
|
||||||
from vterm.render import segment_buffer_line
|
from vterm.render import segment_buffer_line
|
||||||
|
|
||||||
@@ -327,7 +327,7 @@ def run_fast_path_cases():
|
|||||||
return (rows, c.x, c.y, c.attrs, set(screen.mode), screen.title)
|
return (rows, c.x, c.y, c.attrs, set(screen.mode), screen.title)
|
||||||
|
|
||||||
def parser_only(blob):
|
def parser_only(blob):
|
||||||
"""The blob through the coroutine parser alone: pyte's original feed."""
|
"""The blob through the coroutine parser alone, bypassing the fast path."""
|
||||||
proc, screen, stream = make(columns=30, lines=8)
|
proc, screen, stream = make(columns=30, lines=8)
|
||||||
import pyte
|
import pyte
|
||||||
pyte.Stream.feed(stream, blob)
|
pyte.Stream.feed(stream, blob)
|
||||||
@@ -407,7 +407,7 @@ def run_clear_cases():
|
|||||||
assert len(screen.history) > 0, "history suppressed for too long"
|
assert len(screen.history) > 0, "history suppressed for too long"
|
||||||
|
|
||||||
# ED 3 is app-driven: it clears scrollback and fires the clear callback,
|
# ED 3 is app-driven: it clears scrollback and fires the clear callback,
|
||||||
# but (as in xterm, unlike pyte) leaves the visible screen alone
|
# but (as in xterm) leaves the visible screen alone
|
||||||
cleared = []
|
cleared = []
|
||||||
proc = FakeProcess()
|
proc = FakeProcess()
|
||||||
screen = VtScreen(10, 4, process=proc, clear_callback=lambda: cleared.append(1))
|
screen = VtScreen(10, 4, process=proc, clear_callback=lambda: cleared.append(1))
|
||||||
@@ -418,8 +418,8 @@ def run_clear_cases():
|
|||||||
assert len(screen.history) == 0 and cleared, (len(screen.history), cleared)
|
assert len(screen.history) == 0 and cleared, (len(screen.history), cleared)
|
||||||
assert screen.buffer[0][0].data == "a", screen.buffer[0][0]
|
assert screen.buffer[0][0].data == "a", screen.buffer[0][0]
|
||||||
|
|
||||||
# with erase_scrollback off, ED 3 is a no-op: agents repaint with it and
|
# with erase_scrollback off, ED 3 is a no-op: an app repainting with it
|
||||||
# would take the history and the view with them
|
# would take the history and the view with it
|
||||||
cleared = []
|
cleared = []
|
||||||
proc = FakeProcess()
|
proc = FakeProcess()
|
||||||
screen = VtScreen(10, 4, process=proc, erase_scrollback=False,
|
screen = VtScreen(10, 4, process=proc, erase_scrollback=False,
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ for char in string.ascii_letters + string.digits + string.punctuation:
|
|||||||
assert get_key_code(key="space") == " ", "space must send a space"
|
assert get_key_code(key="space") == " ", "space must send a space"
|
||||||
|
|
||||||
# legacy encodings by default; while the app has modifyOtherKeys on, the keys
|
# legacy encodings by default; while the app has modifyOtherKeys on, the keys
|
||||||
# whose modified form is ambiguous take the CSI 27 form instead. pi reads a
|
# whose modified form is ambiguous take the CSI 27 form instead. a bare ESC CR
|
||||||
# bare ESC CR as alt+enter and submits on it
|
# reads as alt+enter
|
||||||
assert get_key_code(key="enter", shift=True) == "\x1b\r"
|
assert get_key_code(key="enter", shift=True) == "\x1b\r"
|
||||||
assert get_key_code(key="enter", shift=True, modify_other_keys=2) == "\x1b[27;2;13~"
|
assert get_key_code(key="enter", shift=True, modify_other_keys=2) == "\x1b[27;2;13~"
|
||||||
assert get_key_code(key="space", ctrl=True, modify_other_keys=2) == "\x1b[27;5;32~"
|
assert get_key_code(key="space", ctrl=True, modify_other_keys=2) == "\x1b[27;5;32~"
|
||||||
|
|||||||
@@ -226,8 +226,8 @@ assert len(selection) == 1 and selection[0].empty(), \
|
|||||||
print("selection tests passed (user selection survives renders, caret still tracked)")
|
print("selection tests passed (user selection survives renders, caret still tracked)")
|
||||||
|
|
||||||
|
|
||||||
# an app that hides the cursor (pi hides it for good) still needs the caret:
|
# an app that hides the cursor still needs the caret: sublime drops a space
|
||||||
# sublime drops a space keypress in a view that has no caret at all
|
# keypress in a view that has no caret at all
|
||||||
selection.clear()
|
selection.clear()
|
||||||
stream.feed("\x1b[?25l")
|
stream.feed("\x1b[?25l")
|
||||||
focus_cursor(terminal.view, None, terminal)
|
focus_cursor(terminal.view, None, terminal)
|
||||||
@@ -326,7 +326,7 @@ command.run(None)
|
|||||||
kept = [(r.begin(), r.end()) for r in terminal.view.sel()]
|
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)
|
assert any(b != e for b, e in kept), "user selection was dropped: {}".format(kept)
|
||||||
|
|
||||||
# pyte's dirty is a repaint hint: rows whose content did not change must not
|
# 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)
|
# be rewritten (each rewrite is a cross-process call and an on_modified)
|
||||||
rewritten = []
|
rewritten = []
|
||||||
_replace = terminal.view.replace
|
_replace = terminal.view.replace
|
||||||
@@ -523,8 +523,8 @@ print("view scroll tests passed (a screen or a row per press, clamped to the anc
|
|||||||
|
|
||||||
|
|
||||||
# shift+enter through the whole keypress path: ESC CR by default, and the CSI 27
|
# shift+enter through the whole keypress path: ESC CR by default, and the CSI 27
|
||||||
# form once the app turned modifyOtherKeys on. pi enables it (its kitty probe
|
# form once the app turned modifyOtherKeys on, which it reaches for because a
|
||||||
# gets no flags from us) and reads a bare ESC CR as alt+enter, which submits
|
# bare ESC CR reads as alt+enter
|
||||||
press(key="enter", shift=True)
|
press(key="enter", shift=True)
|
||||||
assert terminal.process.written == "\x1b\r", repr(terminal.process.written)
|
assert terminal.process.written == "\x1b\r", repr(terminal.process.written)
|
||||||
stream.feed("\x1b[>4;2m")
|
stream.feed("\x1b[>4;2m")
|
||||||
|
|||||||
@@ -22,14 +22,7 @@ class VtermOpenCommand(sublime_plugin.WindowCommand):
|
|||||||
"""Open a terminal in a new tab. `vterm_open`, the only entry point users bind."""
|
"""Open a terminal in a new tab. `vterm_open`, the only entry point users bind."""
|
||||||
|
|
||||||
def run(self, cmd=None, cwd=None, env=None, title=None):
|
def run(self, cmd=None, cwd=None, env=None, title=None):
|
||||||
"""Start `cmd` (default: a login shell) in a fresh view.
|
"""Start `cmd` (default: a login shell) in a fresh view"""
|
||||||
|
|
||||||
Args:
|
|
||||||
cwd: defaults to the first project folder, else the active file's
|
|
||||||
directory, else the home directory.
|
|
||||||
env: extra environment on top of the child defaults.
|
|
||||||
title: tab name; the program name unless the app sets its own.
|
|
||||||
"""
|
|
||||||
window = self.window
|
window = self.window
|
||||||
|
|
||||||
if not cmd:
|
if not cmd:
|
||||||
@@ -38,7 +31,7 @@ class VtermOpenCommand(sublime_plugin.WindowCommand):
|
|||||||
elif isinstance(cmd, str):
|
elif isinstance(cmd, str):
|
||||||
cmd = [cmd]
|
cmd = [cmd]
|
||||||
|
|
||||||
# the app may override the title via OSC 0/2 later
|
# cwd: the first project folder, else the active file's directory, else home
|
||||||
if not cwd:
|
if not cwd:
|
||||||
folders = window.folders()
|
folders = window.folders()
|
||||||
view = window.active_view()
|
view = window.active_view()
|
||||||
@@ -144,9 +137,7 @@ class VtermKeypressCommand(sublime_plugin.TextCommand):
|
|||||||
"""Sends one key to the app. Every key binding in the generated keymap lands here."""
|
"""Sends one key to the app. Every key binding in the generated keymap lands here."""
|
||||||
|
|
||||||
def run(self, _, **kwargs):
|
def run(self, _, **kwargs):
|
||||||
"""Args:
|
"""Send one key; the name and modifier flags come straight from the keymap"""
|
||||||
kwargs: key name plus modifier flags, straight from the keymap.
|
|
||||||
"""
|
|
||||||
terminal = Terminal.from_id(self.view.id())
|
terminal = Terminal.from_id(self.view.id())
|
||||||
if not terminal or not terminal.alive:
|
if not terminal or not terminal.alive:
|
||||||
return
|
return
|
||||||
@@ -158,13 +149,11 @@ class VtermKeypressCommand(sublime_plugin.TextCommand):
|
|||||||
terminal.send_key(**kwargs)
|
terminal.send_key(**kwargs)
|
||||||
|
|
||||||
def view_scrolled(self, terminal, key=None, **mods):
|
def view_scrolled(self, terminal, key=None, **mods):
|
||||||
"""Scroll the view over the scrollback, before vterm_show_cursor, whose
|
"""Whether the view consumed the key by scrolling over the scrollback.
|
||||||
scroll would pull the viewport straight back down. PgUp/PgDn move a
|
|
||||||
screen, Up/Down one line. The alt screen has its scrollback folded
|
|
||||||
away, so an app there keeps the key.
|
|
||||||
|
|
||||||
Returns:
|
Runs before vterm_show_cursor, whose scroll would pull the viewport
|
||||||
bool: the view consumed the key; False sends it to the app.
|
straight back down. PgUp/PgDn move a screen, Up/Down one line. On the
|
||||||
|
alt screen the scrollback is folded away, so the app keeps the key.
|
||||||
"""
|
"""
|
||||||
# the cheap tests first: this runs on every keystroke
|
# the cheap tests first: this runs on every keystroke
|
||||||
if key not in SCROLL_KEYS or not mods or terminal.screen.alternate_buffer_mode:
|
if key not in SCROLL_KEYS or not mods or terminal.screen.alternate_buffer_mode:
|
||||||
@@ -206,10 +195,7 @@ class VtermPasteTextCommand(sublime_plugin.TextCommand):
|
|||||||
"""Sends arbitrary text to the app, bracketed when it asked for that."""
|
"""Sends arbitrary text to the app, bracketed when it asked for that."""
|
||||||
|
|
||||||
def run(self, edit, text, bracketed=True):
|
def run(self, edit, text, bracketed=True):
|
||||||
"""Args:
|
"""Send text to the app, wrapped in paste brackets when it enabled ?2004"""
|
||||||
bracketed: wrap in paste brackets, if the app enabled ?2004. IME
|
|
||||||
text comes through unbracketed — it was typed, not pasted.
|
|
||||||
"""
|
|
||||||
terminal = Terminal.from_id(self.view.id())
|
terminal = Terminal.from_id(self.view.id())
|
||||||
if not terminal:
|
if not terminal:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
""" Terminal emulation: pyte screen and a parser that handles prefixed CSI, DECRQM and multi-digit OSC """
|
""" Terminal emulation: the screen and a parser for prefixed CSI, DECRQM and multi-digit OSC """
|
||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
@@ -36,7 +36,7 @@ ANSI_COLORS = [
|
|||||||
"light_white",
|
"light_white",
|
||||||
]
|
]
|
||||||
|
|
||||||
# SGR 90-97 / 100-107: the bright colours, which stock pyte does not map
|
# SGR 90-97 / 100-107: the bright (aixterm) colours
|
||||||
FG_AIXTERM = {90 + i: ANSI_COLORS[8 + i] for i in range(8)}
|
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)}
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ BG_AIXTERM = {100 + i: ANSI_COLORS[8 + i] for i in range(8)}
|
|||||||
REPORTABLE_MODES = (mo.DECAWM >> 5, mo.DECTCEM >> 5, 1000, 1002, 1003, 1004, 1006, 1049, 2004, 2026)
|
REPORTABLE_MODES = (mo.DECAWM >> 5, mo.DECTCEM >> 5, 1000, 1002, 1003, 1004, 1006, 1049, 2004, 2026)
|
||||||
|
|
||||||
# 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); pyte has no name for it
|
# 1: DECCKM, application cursor keys (arrows send SS3)
|
||||||
APP_CURSOR_MODE = 1 << 5
|
APP_CURSOR_MODE = 1 << 5
|
||||||
# 2026: synchronized output, the app is mid-frame and we should not draw
|
# 2026: synchronized output, the app is mid-frame and we should not draw
|
||||||
SYNC_MODE = 2026 << 5
|
SYNC_MODE = 2026 << 5
|
||||||
@@ -78,8 +78,8 @@ def _copy_line(line):
|
|||||||
def quantize_256(r, g, b):
|
def quantize_256(r, g, b):
|
||||||
"""Nearest xterm-256 index: grays to the ramp, colours to the 6x6x6 cube.
|
"""Nearest xterm-256 index: grays to the ramp, colours to the 6x6x6 cube.
|
||||||
|
|
||||||
Truecolor is folded into the 256 palette so the colour-scheme rule table
|
Truecolor is folded into the palette so the scheme rule table stays bounded:
|
||||||
stays bounded (each distinct combo becomes a scheme rule and a file write)."""
|
each distinct combo costs a rule and a file write."""
|
||||||
if r == g == b:
|
if r == g == b:
|
||||||
if r < 8:
|
if r < 8:
|
||||||
return 16
|
return 16
|
||||||
@@ -102,18 +102,11 @@ Char = namedtuple(
|
|||||||
|
|
||||||
|
|
||||||
class Cursor:
|
class Cursor:
|
||||||
"""Cursor position and pending attributes.
|
"""Cursor position and the attributes new text inherits"""
|
||||||
|
|
||||||
Replaces pyte's own so a Char carrying the extra `linefeed` field can be
|
|
||||||
the default attribute set.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = ("x", "y", "attrs", "hidden")
|
__slots__ = ("x", "y", "attrs", "hidden")
|
||||||
|
|
||||||
def __init__(self, x, y, attrs=Char(" ")):
|
def __init__(self, x, y, attrs=Char(" ")):
|
||||||
"""Args:
|
|
||||||
attrs: the Char whose styling new text inherits.
|
|
||||||
"""
|
|
||||||
self.x = x
|
self.x = x
|
||||||
self.y = y
|
self.y = y
|
||||||
self.attrs = attrs
|
self.attrs = attrs
|
||||||
@@ -121,18 +114,15 @@ class Cursor:
|
|||||||
|
|
||||||
|
|
||||||
class VtScreen(pyte.Screen):
|
class VtScreen(pyte.Screen):
|
||||||
"""Screen with the terminal features these agents rely on.
|
"""Screen with alternate buffer, scrollback, synchronized output,
|
||||||
|
|
||||||
Adds to pyte: the alternate screen, scrollback, synchronized output,
|
|
||||||
mouse/focus/paste modes, cursor styles, query replies and OSC handling.
|
mouse/focus/paste modes, cursor styles, query replies and OSC handling.
|
||||||
|
|
||||||
Mutated only by the renderer thread, so it carries no lock.
|
Mutated only by the renderer thread, so it carries no lock.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def default_char(self):
|
def default_char(self):
|
||||||
"""The blank cell an untouched column holds."""
|
"""The blank cell an untouched column holds"""
|
||||||
# pyte's base property cannot be shadowed by an instance attribute;
|
|
||||||
# the value is maintained on reset and DECSCNM flips
|
|
||||||
return self._default_char
|
return self._default_char
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -148,14 +138,7 @@ class VtScreen(pyte.Screen):
|
|||||||
bg_color="000000",
|
bg_color="000000",
|
||||||
is_dark=True,
|
is_dark=True,
|
||||||
):
|
):
|
||||||
"""Args:
|
"""Set up the buffers, the caches and the palette facts reported to the app"""
|
||||||
process: written to directly when the app queries the terminal.
|
|
||||||
history: scrollback depth, in lines.
|
|
||||||
erase_scrollback: whether ED 3 drops the scrollback, as a terminal should.
|
|
||||||
clear_callback: the app cleared the screen; the view follows.
|
|
||||||
clipboard_callback: an OSC 52 payload for the system clipboard.
|
|
||||||
fg_color, bg_color, is_dark: palette facts reported back to the app.
|
|
||||||
"""
|
|
||||||
self._process = process
|
self._process = process
|
||||||
self._erase_scrollback = erase_scrollback
|
self._erase_scrollback = erase_scrollback
|
||||||
self._clear_callback = clear_callback
|
self._clear_callback = clear_callback
|
||||||
@@ -178,7 +161,7 @@ class VtScreen(pyte.Screen):
|
|||||||
super().__init__(columns, lines)
|
super().__init__(columns, lines)
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
"""RIS: pyte's reset plus our cursor, scrollback and caches."""
|
"""RIS: reset the screen along with the cursor, scrollback and caches"""
|
||||||
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()
|
||||||
super().reset()
|
super().reset()
|
||||||
@@ -194,20 +177,12 @@ class VtScreen(pyte.Screen):
|
|||||||
self.cursor_style = style
|
self.cursor_style = style
|
||||||
|
|
||||||
def set_modify_other_keys(self, resource=0, value=0):
|
def set_modify_other_keys(self, resource=0, value=0):
|
||||||
"""xterm CSI > 4 ; n m: send modified keys as CSI 27 ; mod ; code ~.
|
"""xterm CSI > 4 ; n m: send modified keys as CSI 27 ; mod ; code ~"""
|
||||||
|
|
||||||
pi turns this on whenever the kitty probe answers with no flags, and
|
|
||||||
reads shift+enter no other way.
|
|
||||||
"""
|
|
||||||
if resource == 4:
|
if resource == 4:
|
||||||
self.modify_other_keys = value
|
self.modify_other_keys = value
|
||||||
|
|
||||||
def resize(self, lines=None, columns=None):
|
def resize(self, lines=None, columns=None):
|
||||||
"""Resize the screen, pushing scrolled-off lines into scrollback.
|
"""Resize the screen; on a shrink the rows that no longer fit go to scrollback"""
|
||||||
|
|
||||||
Unlike pyte's, shrinking preserves content: rows that no longer fit go
|
|
||||||
to history rather than being dropped.
|
|
||||||
"""
|
|
||||||
lines = lines or self.lines
|
lines = lines or self.lines
|
||||||
columns = columns or self.columns
|
columns = columns or self.columns
|
||||||
|
|
||||||
@@ -239,8 +214,7 @@ class VtScreen(pyte.Screen):
|
|||||||
line.pop(x, None)
|
line.pop(x, None)
|
||||||
|
|
||||||
self.lines, self.columns = lines, columns
|
self.lines, self.columns = lines, columns
|
||||||
# pyte clamps via restore_cursor; without it a shrink leaves the cursor
|
# a shrink can leave the cursor past the new edge; x == columns keeps a pending wrap
|
||||||
# negative or past the new edge. x == columns keeps a pending wrap
|
|
||||||
self.cursor.x = min(self.cursor.x, columns)
|
self.cursor.x = min(self.cursor.x, columns)
|
||||||
self.cursor.y = min(max(self.cursor.y, 0), lines - 1)
|
self.cursor.y = min(max(self.cursor.y, 0), lines - 1)
|
||||||
self.set_margins()
|
self.set_margins()
|
||||||
@@ -272,9 +246,8 @@ class VtScreen(pyte.Screen):
|
|||||||
if reverse != self._default_char.reverse:
|
if reverse != self._default_char.reverse:
|
||||||
self._default_char = self._default_char._replace(reverse=reverse)
|
self._default_char = self._default_char._replace(reverse=reverse)
|
||||||
self._sgr_cache.clear()
|
self._sgr_cache.clear()
|
||||||
# pyte points every line at the old blank cell from inside
|
# the lines still point at the old blank cell, which would paint
|
||||||
# set/reset_mode, before this runs; a stale one paints the columns
|
# untouched columns reversed for good
|
||||||
# the app never wrote reverse for good
|
|
||||||
for line in self.buffer.values():
|
for line in self.buffer.values():
|
||||||
line.default = self._default_char
|
line.default = self._default_char
|
||||||
|
|
||||||
@@ -340,12 +313,11 @@ class VtScreen(pyte.Screen):
|
|||||||
return BRACKETED_PASTE_MODE in self.mode
|
return BRACKETED_PASTE_MODE in self.mode
|
||||||
|
|
||||||
def draw(self, data):
|
def draw(self, data):
|
||||||
"""Write text at the cursor, wcwidth-aware, marking hard wraps.
|
"""Write text at the cursor, wcwidth-aware, marking soft wraps.
|
||||||
|
|
||||||
Differs from pyte: double-width cells claim two columns, combining marks
|
Double-width cells claim two columns, combining marks merge into the
|
||||||
are merged into the preceding cell as NFC (sublime renders decomposed
|
preceding cell as NFC, and an autowrap sets `linefeed` on the last cell
|
||||||
sequences badly), and an autowrap sets `linefeed` on the last cell so
|
so copy can tell a soft wrap from a real newline.
|
||||||
copy can tell a soft wrap from a real newline.
|
|
||||||
"""
|
"""
|
||||||
if self.charset:
|
if self.charset:
|
||||||
data = data.translate(self.g1_charset)
|
data = data.translate(self.g1_charset)
|
||||||
@@ -359,8 +331,7 @@ class VtScreen(pyte.Screen):
|
|||||||
cursor = self.cursor
|
cursor = self.cursor
|
||||||
buffer = self.buffer
|
buffer = self.buffer
|
||||||
columns = self.columns
|
columns = self.columns
|
||||||
# cells only differ by their data while attrs are unchanged, so a
|
# per-run cache: cells differ only by their data while attrs are unchanged
|
||||||
# per-run cache turns the namedtuple rebuild into a dict lookup
|
|
||||||
if cursor.attrs is not self._cache_attrs:
|
if cursor.attrs is not self._cache_attrs:
|
||||||
self._cache_attrs = cursor.attrs
|
self._cache_attrs = cursor.attrs
|
||||||
self._char_cache = {}
|
self._char_cache = {}
|
||||||
@@ -431,10 +402,10 @@ class VtScreen(pyte.Screen):
|
|||||||
super().index()
|
super().index()
|
||||||
|
|
||||||
def erase_in_display(self, how=0, *args, **kwargs):
|
def erase_in_display(self, how=0, *args, **kwargs):
|
||||||
"""ED: erase part of the screen, keeping what scrolls away.
|
"""ED: erase part of the screen.
|
||||||
|
|
||||||
Unlike pyte's, a full clear (or a clear-to-end from home) pushes the
|
A full clear (or a clear-to-end from home) pushes the screen into
|
||||||
current screen into scrollback first, so `clear` does not lose history.
|
scrollback first, so `clear` does not lose history.
|
||||||
"""
|
"""
|
||||||
if not self.alternate_buffer_mode and (how == 2 or (how == 0 and self.cursor.x == 0 and self.cursor.y == 0)):
|
if not self.alternate_buffer_mode and (how == 2 or (how == 0 and self.cursor.x == 0 and self.cursor.y == 0)):
|
||||||
self.push_lines_into_history()
|
self.push_lines_into_history()
|
||||||
@@ -470,8 +441,8 @@ class VtScreen(pyte.Screen):
|
|||||||
def select_graphic_rendition(self, *attrs, **kwargs):
|
def select_graphic_rendition(self, *attrs, **kwargs):
|
||||||
"""Apply an SGR sequence to the cursor attributes.
|
"""Apply an SGR sequence to the cursor attributes.
|
||||||
|
|
||||||
Adds to pyte: bright (aixterm) colours, and truecolor folded into the
|
Covers bright (aixterm) colours and truecolor folded into the 256
|
||||||
256 palette. Transitions are memoized — TUIs cycle through a handful of
|
palette. Transitions are memoized: TUIs cycle through a handful of
|
||||||
sequences thousands of times per frame.
|
sequences thousands of times per frame.
|
||||||
"""
|
"""
|
||||||
if not attrs or attrs == (0,):
|
if not attrs or attrs == (0,):
|
||||||
@@ -575,14 +546,12 @@ class VtScreen(pyte.Screen):
|
|||||||
count = self.first_non_empty_line_from_bottom() + 1
|
count = self.first_non_empty_line_from_bottom() + 1
|
||||||
self.history.extend(_copy_line(self.buffer[y]) for y in range(count))
|
self.history.extend(_copy_line(self.buffer[y]) for y in range(count))
|
||||||
|
|
||||||
# replies go to the pty, never to the screen
|
|
||||||
|
|
||||||
def write_process_input(self, data):
|
def write_process_input(self, data):
|
||||||
"""Send a reply to the app. Query answers go to the pty, never to the screen."""
|
"""Send a query reply to the app: it goes to the pty, never to the screen"""
|
||||||
self._process.write(data)
|
self._process.write(data)
|
||||||
|
|
||||||
def report_device_attributes(self, mode=0, **kwargs):
|
def report_device_attributes(self, mode=0, **kwargs):
|
||||||
"""DA1: claim to be a VT220 with ANSI colour. Claude Code waits on this with no timeout."""
|
"""DA1: claim to be a VT220 with ANSI colour"""
|
||||||
if kwargs.get("private"):
|
if kwargs.get("private"):
|
||||||
return
|
return
|
||||||
if mode == 0:
|
if mode == 0:
|
||||||
@@ -603,7 +572,7 @@ class VtScreen(pyte.Screen):
|
|||||||
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:
|
||||||
# color-scheme report, pi asks with a 2s timeout
|
# color-scheme report
|
||||||
self.write_process_input(self.theme_report())
|
self.write_process_input(self.theme_report())
|
||||||
|
|
||||||
def theme_report(self):
|
def theme_report(self):
|
||||||
@@ -628,7 +597,7 @@ class VtScreen(pyte.Screen):
|
|||||||
def report_kitty_keyboard(self):
|
def report_kitty_keyboard(self):
|
||||||
"""Answer the kitty keyboard protocol probe."""
|
"""Answer the kitty keyboard protocol probe."""
|
||||||
# answering at all means "supported"; 0 flags means none are enabled,
|
# answering at all means "supported"; 0 flags means none are enabled,
|
||||||
# which is what steers pi onto its modifyOtherKeys fallback
|
# which steers an app onto its modifyOtherKeys fallback
|
||||||
self.write_process_input("\x1b[?0u")
|
self.write_process_input("\x1b[?0u")
|
||||||
|
|
||||||
def handle_osc(self, code, payload, terminator):
|
def handle_osc(self, code, payload, terminator):
|
||||||
@@ -656,24 +625,21 @@ class VtScreen(pyte.Screen):
|
|||||||
# a complete plain CSI: params then a final byte. No private marker, prefix or
|
# a complete plain CSI: params then a final byte. No private marker, prefix or
|
||||||
# intermediate — those forms, and sequences split across reads, take the parser
|
# intermediate — those forms, and sequences split across reads, take the parser
|
||||||
CSI_COMPLETE = re.compile("(?:\x1b\\[|\x9b)([0-9;]*)([@-~])")
|
CSI_COMPLETE = re.compile("(?:\x1b\\[|\x9b)([0-9;]*)([@-~])")
|
||||||
# a read ending mid-CSI; held for the next read, as the parser would hold state.
|
# a read ending mid-CSI, held for the next read; bounded so a run of digits
|
||||||
# bounded so a run of digits with no final cannot buffer forever
|
# with no final byte cannot buffer forever
|
||||||
CSI_PARTIAL = re.compile("(?:\x1b\\[?|\x9b)[0-9;]{0,32}$")
|
CSI_PARTIAL = re.compile("(?:\x1b\\[?|\x9b)[0-9;]{0,32}$")
|
||||||
|
|
||||||
|
|
||||||
class VtStream(pyte.Stream):
|
class VtStream(pyte.Stream):
|
||||||
"""pyte.Stream with a parser for prefixed CSI, DECRQM and multi-digit OSC
|
"""Stream with a parser for `>`/`<`/`=` prefixed CSI, the DECRQM `$p`
|
||||||
|
intermediate and multi-digit OSC codes.
|
||||||
|
|
||||||
Stock pyte drops what these agents rely on: `>`/`<`/`=` prefixed CSI, the
|
feed() dispatches complete plain CSI and lone control characters directly,
|
||||||
DECRQM `$p` intermediate, and OSC codes longer than one digit.
|
rather than resuming the parser coroutine once per character.
|
||||||
|
|
||||||
feed() dispatches complete plain CSI and lone control characters directly:
|
|
||||||
pyte resumes the parser coroutine once per character, which was ~90% of the
|
|
||||||
cost of an escape-heavy flood.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
"""Copy the CSI table before adding to it, so pyte's class-level one is untouched."""
|
"""Copy the CSI table before adding to it: the class-level one is shared"""
|
||||||
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"
|
||||||
@@ -683,10 +649,10 @@ class VtStream(pyte.Stream):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def attach(self, screen):
|
def attach(self, screen):
|
||||||
"""Bind the fast dispatch tables when the listener is known."""
|
"""Bind the fast dispatch tables once the listener is known"""
|
||||||
super().attach(screen)
|
super().attach(screen)
|
||||||
# same construction as the parser's create_dispatcher, so an unknown
|
# built like the parser's dispatchers, so an unknown final byte lands
|
||||||
# final lands on listener.debug either way
|
# on listener.debug either way
|
||||||
self._fast_csi = defaultdict(
|
self._fast_csi = defaultdict(
|
||||||
lambda: screen.debug,
|
lambda: screen.debug,
|
||||||
{final: getattr(screen, name) for final, name in self.csi.items()},
|
{final: getattr(screen, name) for final, name in self.csi.items()},
|
||||||
@@ -696,15 +662,13 @@ class VtStream(pyte.Stream):
|
|||||||
self._fast_basic = {char: getattr(screen, name) for char, name in self.basic.items() if char not in (ctrl.SI, ctrl.SO)}
|
self._fast_basic = {char: getattr(screen, name) for char, name in self.basic.items() if char not in (ctrl.SI, ctrl.SO)}
|
||||||
|
|
||||||
def feed(self, data):
|
def feed(self, data):
|
||||||
"""pyte's feed with a ground-state fast path.
|
"""Parse data into the screen, with a ground-state fast path.
|
||||||
|
|
||||||
Plain text runs keep pyte's regex path. A complete unprefixed CSI is
|
A complete unprefixed CSI is dispatched in one call and a lone control
|
||||||
matched whole and dispatched in one call, and basic controls (CR, LF,
|
character in one lookup; everything else — OSC, charsets,
|
||||||
tab...) in one lookup, instead of one coroutine resume per character.
|
private/prefixed CSI, `:` subparams — falls through to the parser. A
|
||||||
Everything else — OSC, charsets, private/prefixed CSI, `:` subparams —
|
read ending mid-CSI is carried into the next one, so nothing dispatches
|
||||||
falls through to the parser, byte for byte as before. A read ending
|
until the sequence is whole.
|
||||||
mid-CSI is carried into the next read; the parser would hold it as
|
|
||||||
state, so nothing is dispatched either way until the sequence is whole.
|
|
||||||
"""
|
"""
|
||||||
# the tail of the previous read ended mid-CSI; complete it first
|
# the tail of the previous read ended mid-CSI; complete it first
|
||||||
if self._carry:
|
if self._carry:
|
||||||
@@ -728,7 +692,7 @@ class VtStream(pyte.Stream):
|
|||||||
offset = 0
|
offset = 0
|
||||||
while offset < length:
|
while offset < length:
|
||||||
if taking_plain_text:
|
if taking_plain_text:
|
||||||
# printable run: hand it to draw() whole (pyte's own fast path)
|
# printable run: hand it to draw() whole
|
||||||
match = match_text(data, offset)
|
match = match_text(data, offset)
|
||||||
if match:
|
if match:
|
||||||
start, offset = match.span()
|
start, offset = match.span()
|
||||||
@@ -739,7 +703,6 @@ class VtStream(pyte.Stream):
|
|||||||
# complete plain CSI: dispatch here, skipping the parser
|
# complete plain CSI: dispatch here, skipping the parser
|
||||||
match = match_csi(data, offset)
|
match = match_csi(data, offset)
|
||||||
if match:
|
if match:
|
||||||
# params memo: SGR strings repeat across a whole frame
|
|
||||||
raw = match.group(1)
|
raw = match.group(1)
|
||||||
params = cache.get(raw)
|
params = cache.get(raw)
|
||||||
if params is None:
|
if params is None:
|
||||||
@@ -750,7 +713,7 @@ class VtStream(pyte.Stream):
|
|||||||
try:
|
try:
|
||||||
csi[match.group(2)](*params)
|
csi[match.group(2)](*params)
|
||||||
except TypeError:
|
except TypeError:
|
||||||
# same guard as _dispatch_csi: excess params cost one sequence, not the chunk
|
# excess params cost one sequence, not the chunk
|
||||||
LOG.debug("malformed CSI %s%s", params, match.group(2))
|
LOG.debug("malformed CSI %s%s", params, match.group(2))
|
||||||
offset = match.end()
|
offset = match.end()
|
||||||
continue
|
continue
|
||||||
@@ -776,11 +739,9 @@ class VtStream(pyte.Stream):
|
|||||||
self._taking_plain_text = taking_plain_text
|
self._taking_plain_text = taking_plain_text
|
||||||
|
|
||||||
def _parser_fsm(self):
|
def _parser_fsm(self):
|
||||||
"""Coroutine pyte feeds one character at a time; `yield True` resumes bulk text.
|
"""Parser coroutine, fed one character at a time; `yield True` resumes bulk text.
|
||||||
|
|
||||||
A full copy of pyte's FSM rather than a hook, because the additions are
|
Locals are bound up front: this runs per byte of every escape sequence.
|
||||||
inside its inner loops. Locals are bound up front: this runs per byte of
|
|
||||||
every escape sequence.
|
|
||||||
"""
|
"""
|
||||||
basic = self.basic
|
basic = self.basic
|
||||||
listener = self.listener
|
listener = self.listener
|
||||||
@@ -829,7 +790,7 @@ class VtStream(pyte.Stream):
|
|||||||
elif char == "%":
|
elif char == "%":
|
||||||
self.select_other_charset((yield None))
|
self.select_other_charset((yield None))
|
||||||
elif char in "()":
|
elif char in "()":
|
||||||
# honoured even in UTF-8 mode, which pyte skips: smacs is ESC(0
|
# honoured even in UTF-8 mode: xterm-256color's smacs is ESC(0
|
||||||
listener.define_charset((yield None), mode=char)
|
listener.define_charset((yield None), mode=char)
|
||||||
else:
|
else:
|
||||||
escape_dispatch[char]()
|
escape_dispatch[char]()
|
||||||
@@ -873,7 +834,7 @@ class VtStream(pyte.Stream):
|
|||||||
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,
|
||||||
# ending at BEL or ST. pyte only accepts a single-digit code
|
# ending at BEL or ST
|
||||||
elif char == OSC_C1:
|
elif char == OSC_C1:
|
||||||
code = ""
|
code = ""
|
||||||
while True:
|
while True:
|
||||||
@@ -907,8 +868,8 @@ class VtStream(pyte.Stream):
|
|||||||
def _dispatch_csi(self, csi_dispatch, listener, final, params, private, prefix, intermediate):
|
def _dispatch_csi(self, csi_dispatch, listener, final, params, private, prefix, intermediate):
|
||||||
"""Route a CSI by its intermediate, prefix and private marker.
|
"""Route a CSI by its intermediate, prefix and private marker.
|
||||||
|
|
||||||
The forms pyte has no table for are handled here; a private sequence we
|
An unimplemented private sequence is dropped rather than run in its
|
||||||
do not implement is dropped rather than run in its non-private meaning.
|
non-private meaning.
|
||||||
"""
|
"""
|
||||||
if intermediate == "$":
|
if intermediate == "$":
|
||||||
if final == "p":
|
if final == "p":
|
||||||
|
|||||||
@@ -107,25 +107,18 @@ def _modifier_code(ctrl, alt, shift):
|
|||||||
|
|
||||||
def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False,
|
def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False,
|
||||||
modify_other_keys=0):
|
modify_other_keys=0):
|
||||||
"""Escape sequence for a key name and its modifiers.
|
"""Escape sequence for a key name and its modifiers, honouring the app's active modes"""
|
||||||
|
|
||||||
Args:
|
|
||||||
key: key name from the keymap, or a single literal character.
|
|
||||||
application_mode: DECCKM is set, so cursor keys take their SS3 form.
|
|
||||||
new_line_mode: LNM is set, so enter sends CR LF.
|
|
||||||
modify_other_keys: the level the app asked for with CSI > 4 ; n m.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: the bytes to write to the pty.
|
|
||||||
"""
|
|
||||||
key_lo = key.lower() if len(key) > 1 else key
|
key_lo = key.lower() if len(key) > 1 else key
|
||||||
|
|
||||||
|
# the keys with an xterm modified form of their own
|
||||||
if (ctrl or alt or shift) and key_lo in _MODIFIABLE_CSI:
|
if (ctrl or alt or shift) and key_lo in _MODIFIABLE_CSI:
|
||||||
return _MODIFIABLE_CSI[key_lo].format(_modifier_code(ctrl, alt, shift))
|
return _MODIFIABLE_CSI[key_lo].format(_modifier_code(ctrl, alt, shift))
|
||||||
|
|
||||||
|
# the ambiguous ones, while the app asked for CSI 27
|
||||||
if modify_other_keys and (ctrl or alt or shift) and key_lo in _OTHER_KEYS:
|
if modify_other_keys and (ctrl or alt or shift) and key_lo in _OTHER_KEYS:
|
||||||
return "\x1b[27;{};{}~".format(_modifier_code(ctrl, alt, shift), _OTHER_KEYS[key_lo])
|
return "\x1b[27;{};{}~".format(_modifier_code(ctrl, alt, shift), _OTHER_KEYS[key_lo])
|
||||||
|
|
||||||
|
# ctrl folds to a control byte, alt prefixes ESC, shift is bare text
|
||||||
if ctrl:
|
if ctrl:
|
||||||
base = key_lo
|
base = key_lo
|
||||||
if base in _CTRL_KEY_MAP:
|
if base in _CTRL_KEY_MAP:
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Track carets per view, for the insert-detection in on_modified."""
|
|
||||||
# view id -> last caret position, to work out what sublime just inserted
|
# view id -> last caret position, to work out what sublime just inserted
|
||||||
self._cursor = {}
|
self._cursor = {}
|
||||||
|
|
||||||
@@ -56,7 +55,6 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
|
|||||||
raise a key event — it arrives as an `insert` command. Text the emulator
|
raise a key event — it arrives as an `insert` command. Text the emulator
|
||||||
did not produce must never stay in the view.
|
did not produce must never stay in the view.
|
||||||
"""
|
"""
|
||||||
# printables and IME text arrive as sublime inserts, not keypresses
|
|
||||||
terminal = Terminal.from_id(view.id())
|
terminal = Terminal.from_id(view.id())
|
||||||
if not terminal:
|
if not terminal:
|
||||||
return
|
return
|
||||||
@@ -86,7 +84,7 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
|
|||||||
self._cursor[view.id()] = view.sel()[0].end()
|
self._cursor[view.id()] = view.sel()[0].end()
|
||||||
|
|
||||||
def on_query_context(self, view, key, operator, operand, match_all):
|
def on_query_context(self, view, key, operator, operand, match_all):
|
||||||
"""Answer the contexts the mousemap needs; keymap contexts are native."""
|
"""Answer the contexts the mousemap needs"""
|
||||||
# keymap contexts are native "setting.x"; mousemaps do not evaluate
|
# keymap contexts are native "setting.x"; mousemaps do not evaluate
|
||||||
# those the same way, so their flags still need the hook
|
# those the same way, so their flags still need the hook
|
||||||
if key not in ("vterm_view", "vterm_view.wheel_captured"):
|
if key not in ("vterm_view", "vterm_view.wheel_captured"):
|
||||||
|
|||||||
@@ -13,16 +13,15 @@ from .terminal import Terminal
|
|||||||
WHEEL_UP = 64
|
WHEEL_UP = 64
|
||||||
WHEEL_DOWN = 65
|
WHEEL_DOWN = 65
|
||||||
|
|
||||||
# lines per wheel tick, as iTerm2 delivers per notch; pi scrolls exactly one
|
# lines per wheel tick, as iTerm2 delivers per notch
|
||||||
# line per report and has no setting of its own
|
|
||||||
WHEEL_LINES = 3
|
WHEEL_LINES = 3
|
||||||
|
|
||||||
|
|
||||||
def send_scroll(terminal, up, lines, cell=None):
|
def send_scroll(terminal, up, lines, cell=None):
|
||||||
"""Report `lines` scroll lines to the app, or fake arrow keys on the alt screen.
|
"""Report `lines` scroll lines to the app, or fake arrow keys on the alt screen.
|
||||||
|
|
||||||
One report per line, never one batched write: pi matches the SGR form
|
One report per write, never a batch: an app may match the SGR form against
|
||||||
against a whole stdin chunk and drops anything longer."""
|
a whole stdin chunk and drop anything longer."""
|
||||||
if terminal.screen.mouse_reporting_enabled():
|
if terminal.screen.mouse_reporting_enabled():
|
||||||
if cell is None:
|
if cell is None:
|
||||||
cell = (1, terminal.screen.lines // 2 + 1)
|
cell = (1, terminal.screen.lines // 2 + 1)
|
||||||
@@ -38,8 +37,8 @@ def send_scroll(terminal, up, lines, cell=None):
|
|||||||
def _event_to_cell(view, terminal, event, clamp=False):
|
def _event_to_cell(view, terminal, event, clamp=False):
|
||||||
"""Map a mouse event to 1-based (col, row) on the live screen, or None.
|
"""Map a mouse event to 1-based (col, row) on the live screen, or None.
|
||||||
|
|
||||||
`clamp` pulls a point above or below the screen onto the nearest row: the
|
Clamping pulls a point above or below the screen onto the nearest row: the
|
||||||
wheel still belongs to the app, while a click there does not."""
|
wheel still belongs to the app there, a click 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)
|
||||||
row -= terminal.offset
|
row -= terminal.offset
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import sublime
|
|||||||
import sublime_plugin
|
import sublime_plugin
|
||||||
from wcwidth import wcswidth
|
from wcwidth import wcswidth
|
||||||
|
|
||||||
# per-cell call in segment_buffer_line; pyte caches its own wcwidth likewise
|
# per-cell call in segment_buffer_line
|
||||||
cell_width = lru_cache(maxsize=4096)(wcswidth)
|
cell_width = lru_cache(maxsize=4096)(wcswidth)
|
||||||
|
|
||||||
from .scheme import scheme_manager
|
from .scheme import scheme_manager
|
||||||
@@ -125,8 +125,8 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
"""Set up this view's render caches."""
|
"""Set up this view's render caches."""
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.colored_lines = {}
|
self.colored_lines = {}
|
||||||
# view row -> (segments, lf) as last rendered; pyte's dirty means
|
# view row -> (segments, lf) as last rendered; the dirty set is only a
|
||||||
# "repaint hint", so most dirty rows are identical frame to frame
|
# repaint hint, so most dirty rows are identical frame to frame
|
||||||
self._row_cache = {}
|
self._row_cache = {}
|
||||||
self._key_counter = 0
|
self._key_counter = 0
|
||||||
# chunk ordinal -> scopes it holds a key for; ordinals are view row +
|
# chunk ordinal -> scopes it holds a key for; ordinals are view row +
|
||||||
@@ -199,11 +199,11 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
screen.dirty.clear()
|
screen.dirty.clear()
|
||||||
|
|
||||||
def sync_alt_fold(self, terminal):
|
def sync_alt_fold(self, terminal):
|
||||||
"""Hide the scrollback while the app owns the alt screen: fold it away
|
"""Fold the scrollback away while the app owns the alt screen; the view text is its only copy.
|
||||||
(the view text is its only copy).
|
|
||||||
The fold is re-checked every render — the screen's first row is
|
Re-checked every render: an edit at the fold boundary can silently
|
||||||
replaced on every region scroll, and an edit at the fold boundary can
|
disturb a fold, and the screen's first row is replaced on every scroll.
|
||||||
silently disturb a fold."""
|
"""
|
||||||
view = self.view
|
view = self.view
|
||||||
alt = terminal.screen.alternate_buffer_mode
|
alt = terminal.screen.alternate_buffer_mode
|
||||||
if not alt and not terminal.applied_alt_screen:
|
if not alt and not terminal.applied_alt_screen:
|
||||||
@@ -303,17 +303,11 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
self.update_line(edit, line + offset, buffer_line, columns)
|
self.update_line(edit, line + offset, buffer_line, columns)
|
||||||
|
|
||||||
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"""
|
||||||
|
|
||||||
Args:
|
|
||||||
line: absolute view row.
|
|
||||||
columns: screen width; the last cell's linefeed flag marks a soft
|
|
||||||
wrap, which gets the CONTINUATION sentinel.
|
|
||||||
"""
|
|
||||||
view = self.view
|
view = self.view
|
||||||
lf = buffer_line[columns - 1].linefeed
|
lf = buffer_line[columns - 1].linefeed
|
||||||
segments = list(segment_buffer_line(buffer_line))
|
segments = list(segment_buffer_line(buffer_line))
|
||||||
# pyte's dirty set is a repaint hint, not a diff: most dirty rows come
|
# the dirty set is a repaint hint, not a diff: most dirty rows come
|
||||||
# back identical, and rewriting one costs an edit plus its region churn
|
# back identical, and rewriting one costs an edit plus its region churn
|
||||||
rendered = (segments, lf)
|
rendered = (segments, lf)
|
||||||
if self._row_cache.get(line) == rendered:
|
if self._row_cache.get(line) == rendered:
|
||||||
@@ -373,16 +367,10 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
def flush_chunk(self):
|
def flush_chunk(self):
|
||||||
"""Merge the queued rows onto shared per-(scope, chunk) keys.
|
"""Merge the queued rows onto shared per-(scope, chunk) keys.
|
||||||
|
|
||||||
A scrollback row is never repainted, so its per-row keys used to live
|
Every region key is a separate pass in sublime's redraw, so merging
|
||||||
until trimmed: a long coloured session held ~15k region keys, and every
|
bounds the count at scopes x (scrollback / CHUNK) plus one open chunk.
|
||||||
key is a separate pass in sublime's redraw. Merging bounds the count at
|
The merge waits for the chunk to close: add_regions replaces a key's
|
||||||
scopes x (scrollback / CHUNK).
|
whole set, so merging every frame would be quadratic in CHUNK.
|
||||||
|
|
||||||
The merge waits for the chunk to close, because add_regions replaces a
|
|
||||||
key's whole set: merging every frame re-sent everything accumulated so
|
|
||||||
far, which is quadratic in CHUNK when output arrives a few rows a frame.
|
|
||||||
Until then the rows keep their per-row keys, so the count is bounded by
|
|
||||||
scopes x chunks plus one chunk's worth.
|
|
||||||
"""
|
"""
|
||||||
view = self.view
|
view = self.view
|
||||||
rows, chunk = self._open_rows, self._open_chunk
|
rows, chunk = self._open_rows, self._open_chunk
|
||||||
@@ -526,8 +514,7 @@ def following(view, terminal):
|
|||||||
|
|
||||||
def bottom_y(view, terminal):
|
def bottom_y(view, terminal):
|
||||||
"""Viewport y that pins the bottom of the live screen to the bottom of the view."""
|
"""Viewport y that pins the bottom of the live screen to the bottom of the view."""
|
||||||
# layout_extent is maintained incrementally; text_to_layout(size()) forced
|
# layout_extent is maintained incrementally, unlike text_to_layout(size())
|
||||||
# a whole-buffer layout on every call
|
|
||||||
viewport_y = view.layout_extent()[1] - view.viewport_extent()[1]
|
viewport_y = view.layout_extent()[1] - view.viewport_extent()[1]
|
||||||
offset_y = view.text_to_layout(view.text_point(terminal.offset, 0))[1]
|
offset_y = view.text_to_layout(view.text_point(terminal.offset, 0))[1]
|
||||||
return max(offset_y, viewport_y)
|
return max(offset_y, viewport_y)
|
||||||
@@ -566,9 +553,7 @@ class VtermShowCursorCommand(sublime_plugin.TextCommand):
|
|||||||
which has no edit token of its own."""
|
which has no edit token of its own."""
|
||||||
|
|
||||||
def run(self, edit, scroll=True):
|
def run(self, edit, scroll=True):
|
||||||
"""Args:
|
"""Place the caret at the terminal cursor, optionally following the output down"""
|
||||||
scroll: also follow the output down to the cursor.
|
|
||||||
"""
|
|
||||||
view = self.view
|
view = self.view
|
||||||
terminal = Terminal.from_id(view.id())
|
terminal = Terminal.from_id(view.id())
|
||||||
if not terminal:
|
if not terminal:
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ class SchemeManager:
|
|||||||
self._listeners.remove(callback)
|
self._listeners.remove(callback)
|
||||||
|
|
||||||
def reload(self):
|
def reload(self):
|
||||||
"""Rebuild every rule for a new palette; colours resolve at add time, so they cannot be appended"""
|
"""Rebuild every rule for a new palette, then notify the terminals"""
|
||||||
if not self._initialized:
|
if not self._initialized:
|
||||||
# no scheme file to maintain yet; initialize() will read the setting
|
# no scheme file to maintain yet; initialize() will read the setting
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -150,8 +150,7 @@ class Terminal:
|
|||||||
"""Whether an unfocused terminal may draw again.
|
"""Whether an unfocused terminal may draw again.
|
||||||
|
|
||||||
A frame costs a command dispatch and a dozen UI round trips however
|
A frame costs a command dispatch and a dozen UI round trips however
|
||||||
little changed, so a terminal the user is only watching gets them at a
|
little changed, so a terminal the user only watches draws at a lower rate.
|
||||||
lower rate than one they are typing in.
|
|
||||||
"""
|
"""
|
||||||
if self.focused:
|
if self.focused:
|
||||||
return True
|
return True
|
||||||
@@ -236,8 +235,7 @@ class Terminal:
|
|||||||
|
|
||||||
def _start_rendering(self):
|
def _start_rendering(self):
|
||||||
"""Start the reader and renderer threads that drive this terminal."""
|
"""Start the reader and renderer threads that drive this terminal."""
|
||||||
# deque appends/poplefts are atomic, so the reader never blocks behind
|
# deque appends/poplefts are atomic, so the reader never blocks behind a render
|
||||||
# a render; a lock here used to backpressure the pty for whole frames
|
|
||||||
chunks = deque()
|
chunks = deque()
|
||||||
done = [False]
|
done = [False]
|
||||||
|
|
||||||
@@ -274,8 +272,8 @@ class Terminal:
|
|||||||
try:
|
try:
|
||||||
self.stream.feed("".join(parts))
|
self.stream.feed("".join(parts))
|
||||||
except Exception:
|
except Exception:
|
||||||
# pyte resets its parser before re-raising, so the terminal
|
# the parser resets itself before re-raising; letting this
|
||||||
# can carry on; letting this escape would kill the thread
|
# escape would kill the renderer thread
|
||||||
LOG.exception("stream.feed failed, dropping the rest of a chunk")
|
LOG.exception("stream.feed failed, dropping the rest of a chunk")
|
||||||
|
|
||||||
def renderer():
|
def renderer():
|
||||||
@@ -322,7 +320,7 @@ class Terminal:
|
|||||||
LOG.warning("resize failed", exc_info=True)
|
LOG.warning("resize failed", exc_info=True)
|
||||||
|
|
||||||
def nudge_repaint(self):
|
def nudge_repaint(self):
|
||||||
"""Make the app repaint by briefly narrowing the pty. Never write ctrl+L instead: it is /clear in Claude Code"""
|
"""Make the app repaint by briefly narrowing the pty. Never write ctrl+L instead: apps bind it themselves"""
|
||||||
rows, columns = self.screen.lines, self.screen.columns
|
rows, columns = self.screen.lines, self.screen.columns
|
||||||
self._set_winsize(rows, max(columns - 1, 1))
|
self._set_winsize(rows, max(columns - 1, 1))
|
||||||
sublime.set_timeout(lambda: self._set_winsize(rows, columns), int(REPAINT_NUDGE_DELAY * 1000))
|
sublime.set_timeout(lambda: self._set_winsize(rows, columns), int(REPAINT_NUDGE_DELAY * 1000))
|
||||||
@@ -342,12 +340,9 @@ class Terminal:
|
|||||||
self.send_string(get_key_code(**kwargs), normalized=False)
|
self.send_string(get_key_code(**kwargs), normalized=False)
|
||||||
|
|
||||||
def send_string(self, string, normalized=True):
|
def send_string(self, string, normalized=True):
|
||||||
"""Write text to the pty, chunking anything large.
|
"""Write text to the pty, chunking anything large"""
|
||||||
|
# escape sequences go through untouched; typed and pasted text has its
|
||||||
Args:
|
# line endings canonicalised for the app's LNM setting
|
||||||
normalized: canonicalise line endings for the app's LNM setting;
|
|
||||||
off for escape sequences, which must go through untouched.
|
|
||||||
"""
|
|
||||||
if normalized:
|
if normalized:
|
||||||
string = string.replace("\r\n", "\n")
|
string = string.replace("\r\n", "\n")
|
||||||
if mo.LNM in self.screen.mode:
|
if mo.LNM in self.screen.mode:
|
||||||
@@ -387,8 +382,6 @@ class Terminal:
|
|||||||
|
|
||||||
def _sync_wheel_flag(self):
|
def _sync_wheel_flag(self):
|
||||||
"""Publish whether the app owns the wheel, for the mousemap context."""
|
"""Publish whether the app owns the wheel, for the mousemap context."""
|
||||||
# the mousemap routes the wheel to the app while it reports the mouse
|
|
||||||
# or owns the alt screen; VtermScrollCommand picks the encoding
|
|
||||||
captured = self.screen.mouse_reporting_enabled() or self.screen.alternate_buffer_mode
|
captured = self.screen.mouse_reporting_enabled() or self.screen.alternate_buffer_mode
|
||||||
if captured != self._wheel_captured:
|
if captured != self._wheel_captured:
|
||||||
self._wheel_captured = captured
|
self._wheel_captured = captured
|
||||||
|
|||||||
@@ -52,10 +52,10 @@ def intermission(period=0.1):
|
|||||||
|
|
||||||
|
|
||||||
def rev_wcwidth(text, width):
|
def rev_wcwidth(text, width):
|
||||||
"""Return the index into `text` at which the substring reaches `width` cells.
|
"""Index into `text` at which the substring reaches `width` cells.
|
||||||
|
|
||||||
The inverse of wcswidth: maps a terminal column back onto a character
|
The inverse of wcswidth: maps a terminal column back onto a character index,
|
||||||
index, so a wide char or a tab does not put the caret in the wrong place.
|
so a wide char or a tab does not put the caret in the wrong place.
|
||||||
"""
|
"""
|
||||||
if width == 0:
|
if width == 0:
|
||||||
return -1
|
return -1
|
||||||
|
|||||||
Reference in New Issue
Block a user