code comments changes

This commit is contained in:
Vaclav Tvrdik
2026-09-10 10:07:12 +02:00
parent d2b763a95a
commit 2a8fffb9c6
13 changed files with 123 additions and 208 deletions

View File

@@ -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()

View File

@@ -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,

View File

@@ -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~"

View File

@@ -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")

View File

@@ -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

View File

@@ -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":

View File

@@ -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:

View File

@@ -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"):

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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