small refactoring

This commit is contained in:
Vaclav Tvrdik
2026-09-08 12:48:52 +02:00
parent f672bff6a0
commit eafe53b2fb
9 changed files with 118 additions and 59 deletions

View File

@@ -338,8 +338,9 @@ handle): `auto_close`, `scrollback_history_size`, `honor_erase_scrollback`,
`parameters`, `debug`. `page_scroll_modifier` is the modifier that turns PgUp/PgDn into a `parameters`, `debug`. `page_scroll_modifier` is the modifier that turns PgUp/PgDn into a
page scroll of the view instead of a key for the app. `terminal` and page scroll of the view instead of a key for the app. `terminal` and
`parameters` name the external terminal spawned by *vterm: Open Terminal in Project `parameters` name the external terminal spawned by *vterm: Open Terminal in Project
Folder* (`%CWD%` in `parameters` becomes the project folder). `honor_erase_scrollback` is off, so ED 3 Folder* (`%CWD%` in `parameters` becomes the project folder). ED 3 never touches the visible screen (as in
blanks the screen without dropping the scrollback: pi and Claude Code repaint by erasing xterm), and `honor_erase_scrollback` is off, so it does not drop the scrollback either:
pi and Claude Code repaint by erasing
saved lines, which would take the history with them (`cmd+shift+k` still empties the tab). Changing `theme` fires `SchemeManager.reload`, which rebuilds the saved lines, which would take the history with them (`cmd+shift+k` still empties the tab). Changing `theme` fires `SchemeManager.reload`, which rebuilds the
palette and restyles open terminals in place. palette and restyles open terminals in place.

View File

@@ -203,6 +203,24 @@ 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
# the clamp draw() writes to rows above or below the screen
proc, screen, stream = make(10, 8)
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
screen.resize(4, 10)
assert screen.cursor.y == 0, screen.cursor.y
proc, screen, stream = make(10, 8)
feed(stream, "top\x1b[7;1H", False) # cursor on a blank row below content
screen.resize(4, 10)
assert screen.cursor.y == 3, screen.cursor.y
proc, screen, stream = make(20, 4)
feed(stream, "x" * 15, False)
screen.resize(4, 10)
assert screen.cursor.x == 10, screen.cursor.x
# mode 2031 is how a live theme change reaches the app # mode 2031 is how a live theme change reaches the app
proc, screen, stream = make() proc, screen, stream = make()
assert not screen.theme_notify_enabled() assert not screen.theme_notify_enabled()
@@ -381,8 +399,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,
# and (as in pyte/Terminus) also blanks the screen. Both agents pair it # but (as in xterm, unlike pyte) leaves the visible screen alone
# with 2J and repaint, so the render side must not wipe or nudge again.
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))
@@ -391,9 +408,10 @@ def run_clear_cases():
assert len(screen.history) > 0 assert len(screen.history) > 0
feed(stream, "\x1b[3J", False) feed(stream, "\x1b[3J", False)
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]
# with erase_scrollback off, ED 3 still blanks the screen but the history # with erase_scrollback off, ED 3 is a no-op: agents repaint with it and
# and the view survive: agents repaint with it and would take both # would take the history and the view with them
cleared = [] cleared = []
proc = FakeProcess() proc = FakeProcess()
screen = VtScreen(10, 4, process=proc, erase_scrollback=False, screen = VtScreen(10, 4, process=proc, erase_scrollback=False,
@@ -405,7 +423,7 @@ def run_clear_cases():
del cleared[:] # the screen's own reset() fired one at construction del cleared[:] # the screen's own reset() fired one at construction
feed(stream, "\x1b[3J", False) feed(stream, "\x1b[3J", False)
assert len(screen.history) == kept and not cleared, (len(screen.history), cleared) assert len(screen.history) == kept and not cleared, (len(screen.history), cleared)
assert screen.buffer[0][0].data == " ", screen.buffer[0][0] assert screen.buffer[0][0].data == "a", screen.buffer[0][0]
print("all clear-screen tests passed") print("all clear-screen tests passed")

View File

@@ -81,7 +81,7 @@ for entry in load_sublime_json("vterm.sublime-commands") + keymap:
# every pending_* name mentioned in the modules must be a real Terminal # every pending_* name mentioned in the modules must be a real Terminal
# attribute, so a renamed flag cannot leave a writer pointing at nothing # attribute, so a renamed flag cannot leave a writer pointing at nothing
flags = {f for f in vars(Terminal(None)) if f.startswith("pending_")} flags = {f for f in vars(Terminal(None)) if f.startswith("pending_")}
assert flags == {"pending_clear", "pending_clear_screen"}, flags assert flags == {"pending_clear", "pending_clear_screen", "pending_render"}, flags
for module in (commands, render, terminal_module): for module in (commands, render, terminal_module):
with open(module.__file__, encoding="utf-8") as f: with open(module.__file__, encoding="utf-8") as f:
# (?<!_) skips the private _pending_to_send_string write-queue flag # (?<!_) skips the private _pending_to_send_string write-queue flag

View File

@@ -595,10 +595,21 @@ line[2] = Char("x", "blue", "default")
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]] runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
assert runs == [("漢", 0, 1, "red", "default", ""), ("x", 1, 2, "blue", "default", "")], runs assert runs == [("漢", 0, 1, "red", "default", ""), ("x", 1, 2, "blue", "default", "")], runs
# an orphaned wide-char filler (its wide half was overwritten with a narrow
# char) still occupies a column: rendered as a space so later spans stay aligned
line = StaticDefaultDict(Char(" "))
line[0] = Char("a", "default", "default")
line[1] = Char("", "red", "default")
line[2] = Char("x", "blue", "default")
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
assert runs == [("a", 0, 1, "default", "default", ""),
(" ", 1, 2, "red", "default", ""),
("x", 2, 3, "blue", "default", "")], runs
# reverse video swaps the pair, and default becomes reverse_default # reverse video swaps the pair, and default becomes reverse_default
line = StaticDefaultDict(Char(" ")) line = StaticDefaultDict(Char(" "))
line[0] = Char("r", "red", "default", False, False, False, False, True) line[0] = Char("r", "red", "default", False, False, False, False, True)
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]] runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
assert runs == [("r", 0, 1, "reverse_default", "red", "")], runs assert runs == [("r", 0, 1, "reverse_default", "red", "")], runs
print("segmenter tests passed (leading gap, wide char, reverse)") print("segmenter tests passed (leading gap, wide char, orphan filler, reverse)")

View File

@@ -44,6 +44,8 @@ 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
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
# 1049: alternate screen, a full-screen app owns the viewport # 1049: alternate screen, a full-screen app owns the viewport
@@ -91,7 +93,7 @@ def quantize_256(r, g, b):
return 16 + 36 * level(r) + 6 * level(g) + level(b) return 16 + 36 * level(r) + 6 * level(g) + level(b)
# linefeed marks a hard wrap, so copy can re-join soft-wrapped lines # linefeed marks an autowrap (soft wrap), so copy can re-join the halves
Char = namedtuple( Char = namedtuple(
"Char", "Char",
["data", "fg", "bg", "bold", "italics", "underscore", "strikethrough", "reverse", "linefeed"], ["data", "fg", "bg", "bold", "italics", "underscore", "strikethrough", "reverse", "linefeed"],
@@ -227,6 +229,10 @@ 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
# negative or past the new edge. x == columns keeps a pending wrap
self.cursor.x = min(self.cursor.x, columns)
self.cursor.y = min(max(self.cursor.y, 0), lines - 1)
self.set_margins() self.set_margins()
self.tabstops = set(range(8, self.columns, 8)) self.tabstops = set(range(8, self.columns, 8))
@@ -364,21 +370,14 @@ class VtScreen(pyte.Screen):
elif char_width > 0: elif char_width > 0:
cursor.x -= char_width cursor.x -= char_width
if char_width == 1: if char_width == 1 or char_width == 2:
if insert_mode: if insert_mode:
self.insert_characters(char_width) self.insert_characters(char_width)
cell = cache.get(char) cell = cache.get(char)
if cell is None: if cell is None:
cell = cache[char] = cursor.attrs._replace(data=char) cell = cache[char] = cursor.attrs._replace(data=char)
line[cursor.x] = cell line[cursor.x] = cell
elif char_width == 2: if char_width == 2 and cursor.x + 1 < columns:
if insert_mode:
self.insert_characters(char_width)
cell = cache.get(char)
if cell is None:
cell = cache[char] = cursor.attrs._replace(data=char)
line[cursor.x] = cell
if cursor.x + 1 < columns:
filler = cache.get("") filler = cache.get("")
if filler is None: if filler is None:
filler = cache[""] = cursor.attrs._replace(data="") filler = cache[""] = cursor.attrs._replace(data="")
@@ -430,11 +429,18 @@ class VtScreen(pyte.Screen):
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()
if how == 3:
# xterm's ED 3 erases the scrollback only; the screen stays
if self._erase_scrollback:
self.history.clear()
self._clear_callback()
return
if how == 0: if how == 0:
interval = range(self.cursor.y + 1, self.lines) interval = range(self.cursor.y + 1, self.lines)
elif how == 1: elif how == 1:
interval = range(self.cursor.y) interval = range(self.cursor.y)
elif how == 2 or how == 3: elif how == 2:
interval = range(self.lines) interval = range(self.lines)
else: else:
return return
@@ -451,10 +457,6 @@ class VtScreen(pyte.Screen):
if how == 0 or how == 1: if how == 0 or how == 1:
self.erase_in_line(how) self.erase_in_line(how)
if how == 3 and self._erase_scrollback:
self.history.clear()
self._clear_callback()
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.
@@ -490,9 +492,9 @@ class VtScreen(pyte.Screen):
if attr[1:] != "blink": if attr[1:] != "blink":
replace[attr[1:]] = attr.startswith("+") replace[attr[1:]] = attr.startswith("+")
elif attr in FG_AIXTERM: elif attr in FG_AIXTERM:
replace.update(fg=FG_AIXTERM[attr]) replace["fg"] = FG_AIXTERM[attr]
elif attr in BG_AIXTERM: elif attr in BG_AIXTERM:
replace.update(bg=BG_AIXTERM[attr]) replace["bg"] = BG_AIXTERM[attr]
elif attr in (g.FG_256, g.BG_256): elif attr in (g.FG_256, g.BG_256):
key = "fg" if attr == g.FG_256 else "bg" key = "fg" if attr == g.FG_256 else "bg"
try: try:
@@ -592,7 +594,11 @@ class VtScreen(pyte.Screen):
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, pi asks with a 2s timeout
self.write_process_input("\x1b[?997;{}n".format(1 if self.theme_is_dark else 2)) self.write_process_input(self.theme_report())
def theme_report(self):
"""DSR 997 report: the palette is dark (1) or light (2). Also sent on a live theme change."""
return "\x1b[?997;{}n".format(1 if self.theme_is_dark else 2)
def report_xtversion(self): def report_xtversion(self):
"""XTVERSION: naming ourselves is what unlocks ?2026 for apps on a generic TERM.""" """XTVERSION: naming ourselves is what unlocks ?2026 for apps on a generic TERM."""

View File

@@ -25,8 +25,10 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
terminal.focused = True terminal.focused = True
terminal.send_focus(True) terminal.send_focus(True)
view.run_command("vterm_clear_undo_stack") view.run_command("vterm_clear_undo_stack")
# catch up on the frames, title and caret skipped while in the background # catch up on the frames, title and caret skipped while in the
view.run_command("vterm_render") # background — via the renderer tick: a render here would race the
# renderer thread, which owns the emulator
terminal.pending_render = True
view.run_command("vterm_show_cursor", {"scroll": False}) view.run_command("vterm_show_cursor", {"scroll": False})
elif view.settings().get("vterm_view") and not view.settings().get("vterm_view.finished"): elif view.settings().get("vterm_view") and not view.settings().get("vterm_view.finished"):
# the plugin host restarted; this tab's terminal is unreachable # the plugin host restarted; this tab's terminal is unreachable

View File

@@ -39,7 +39,7 @@ class VtermMouseEventListener(sublime_plugin.EventListener):
if not terminal.screen.mouse_reporting_enabled(): if not terminal.screen.mouse_reporting_enabled():
# leave selection, caret and scrolling to sublime # leave selection, caret and scrolling to sublime
return return
if name == "drag_select" and args and "event" in args and args.get("event", {}).get("button") == 1 and len(args) == 1: if name == "drag_select" and args and args.get("event", {}).get("button") == 1 and len(args) == 1:
return ("vterm_mouse_click", {"event": args["event"]}) return ("vterm_mouse_click", {"event": args["event"]})

View File

@@ -49,6 +49,11 @@ STYLE_FLAGS = (("bold", "b"), ("italics", "i"), ("underscore", "u"))
ATTRS = slice(1, 8) ATTRS = slice(1, 8)
def is_default_run(fg, bg, style):
"""True for a run that needs no colour overlay."""
return fg == "default" and bg == "default" and not style
def segment_buffer_line(buffer_line): def segment_buffer_line(buffer_line):
"""Yield (text, start, end, fg, bg, style) runs of a screen row. """Yield (text, start, end, fg, bg, style) runs of a screen row.
@@ -82,7 +87,9 @@ def segment_buffer_line(buffer_line):
start = column start = column
attrs = char[ATTRS] attrs = char[ATTRS]
run.append(char.data) # an orphaned wide-char filler (its first half was overwritten) still
# occupies a column; render a space so later spans stay aligned
run.append(char.data or " ")
column += 1 column += 1
if attrs is not None: if attrs is not None:
@@ -283,8 +290,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
for line in range(popped): for line in range(popped):
buffer_line = history.pop() buffer_line = history.pop()
lf = buffer_line[columns - 1].linefeed self.update_line(edit, offset - line - 1, buffer_line, columns)
self.update_line(edit, offset - line - 1, buffer_line, lf)
# after the loop: a pop edits an earlier point than the one before # after the loop: a pop edits an earlier point than the one before
# it, so spans measured mid-loop would be stale # it, so spans measured mid-loop would be stale
@@ -292,17 +298,18 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
for line in dirty_lines: for line in dirty_lines:
buffer_line = screen.buffer[line] buffer_line = screen.buffer[line]
lf = buffer_line[columns - 1].linefeed self.update_line(edit, line + offset, buffer_line, columns)
self.update_line(edit, line + offset, buffer_line, lf)
def update_line(self, edit, line, buffer_line, lf): 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: Args:
line: absolute view row. line: absolute view row.
lf: the row ended in a real newline, not a soft wrap. 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
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 # pyte's 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
@@ -314,7 +321,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
by_scope = {} by_scope = {}
needed = 0 needed = 0
for _, start, end, fg, bg, style in segments: for _, start, end, fg, bg, style in segments:
if fg == "default" and bg == "default" and not style: if is_default_run(fg, bg, style):
continue continue
needed = max(needed, end) needed = max(needed, end)
by_scope.setdefault(scheme_manager.scope(fg, bg, style), []).append((start, end)) by_scope.setdefault(scheme_manager.scope(fg, bg, style), []).append((start, end))
@@ -392,7 +399,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
segments = self._row_cache[row][0] segments = self._row_cache[row][0]
start = view.line(view.text_point(row, 0)).begin() start = view.line(view.text_point(row, 0)).begin()
for _, a, b, fg, bg, style in segments: for _, a, b, fg, bg, style in segments:
if fg == "default" and bg == "default" and not style: if is_default_run(fg, bg, style):
continue continue
scope = scheme_manager.scope(fg, bg, style) scope = scheme_manager.scope(fg, bg, style)
grouped.setdefault(scope, []).append(sublime.Region(start + a, start + b)) grouped.setdefault(scope, []).append(sublime.Region(start + a, start + b))

View File

@@ -9,8 +9,9 @@ from queue import Empty, Queue
import sublime import sublime
from ptyprocess import PtyProcess from ptyprocess import PtyProcess
from pyte import modes as mo
from .emulator import VtScreen, VtStream from .emulator import APP_CURSOR_MODE, VtScreen, VtStream
from .keys import get_key_code from .keys import get_key_code
from .scheme import scheme_manager from .scheme import scheme_manager
from .utils import intermission, responsive, settings from .utils import intermission, responsive, settings
@@ -93,7 +94,7 @@ class Terminal:
self.default_title = "" self.default_title = ""
self.offset = 0 self.offset = 0
self.viewport_y = 0 self.viewport_y = 0
self.scrollback_size = 5000 self.scrollback_size = 50000
self._hosted = True self._hosted = True
self._hosted_checked = 0 self._hosted_checked = 0
self.focused = True self.focused = True
@@ -104,10 +105,15 @@ class Terminal:
self.applied_alt_screen = False self.applied_alt_screen = False
self._strings = Queue() self._strings = Queue()
self._pending_to_send_string = False self._pending_to_send_string = False
# guards the queue/flag handoff between send_string and the drain thread
self._send_lock = threading.Lock()
self._wheel_captured = False self._wheel_captured = False
self._scroll_pending = False self._scroll_pending = False
self.pending_clear = False self.pending_clear = False
self.pending_clear_screen = False self.pending_clear_screen = False
# a forced frame (set on activation); rendering stays on the renderer
# thread, which owns the emulator
self.pending_render = False
# a UTF-8 sequence split across read chunks must not be dropped # a UTF-8 sequence split across read chunks must not be dropped
self._decoder = codecs.getincrementaldecoder("utf-8")("replace") self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
@@ -192,7 +198,7 @@ class Terminal:
screen.default_bg_color = scheme_manager.background.lstrip("#") screen.default_bg_color = scheme_manager.background.lstrip("#")
screen.theme_is_dark = scheme_manager.is_dark() screen.theme_is_dark = scheme_manager.is_dark()
if screen.theme_notify_enabled(): if screen.theme_notify_enabled():
self.send_string("\x1b[?997;{}n".format(1 if screen.theme_is_dark else 2), normalized=False) self.send_string(screen.theme_report(), normalized=False)
def kill(self): def kill(self):
"""Terminate the child and drop this terminal from the registry.""" """Terminate the child and drop this terminal from the registry."""
@@ -218,8 +224,9 @@ class Terminal:
def _need_to_render(self): def _need_to_render(self):
"""Whether anything changed since the last frame: dirty rows, a clear, or the cursor.""" """Whether anything changed since the last frame: dirty rows, a clear, or the cursor."""
screen = self.screen screen = self.screen
if self.pending_clear or self.pending_clear_screen: if self.pending_render or self.pending_clear or self.pending_clear_screen:
# a pending clear must render even when the app is idle # a pending clear or forced frame must render even when the app is idle
self.pending_render = False
return True return True
cursor = (screen.cursor.x, screen.cursor.y, screen.cursor_hidden(), screen.cursor_style) cursor = (screen.cursor.x, screen.cursor.y, screen.cursor_hidden(), screen.cursor_style)
if screen.dirty or cursor != self._cached_cursor: if screen.dirty or cursor != self._cached_cursor:
@@ -329,8 +336,8 @@ class Terminal:
def send_key(self, **kwargs): def send_key(self, **kwargs):
"""Encode a key name plus modifiers for the app's current modes and send it.""" """Encode a key name plus modifiers for the app's current modes and send it."""
kwargs["application_mode"] = (1 << 5) in self.screen.mode kwargs["application_mode"] = APP_CURSOR_MODE in self.screen.mode
kwargs["new_line_mode"] = 20 in self.screen.mode kwargs["new_line_mode"] = mo.LNM in self.screen.mode
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):
@@ -342,22 +349,24 @@ class Terminal:
""" """
if normalized: if normalized:
string = string.replace("\r\n", "\n") string = string.replace("\r\n", "\n")
if 20 in self.screen.mode: if mo.LNM in self.screen.mode:
string = string.replace("\n", "\r\n") string = string.replace("\n", "\r\n")
else: else:
string = string.replace("\n", "\r") string = string.replace("\n", "\r")
# chunked so a big paste cannot overrun the pty buffer; the blocking # chunked so a big paste cannot overrun the pty buffer; the blocking
# pty write is the backpressure, no pacing sleep needed # pty write is the backpressure, no pacing sleep needed
with self._send_lock:
no_queue = not self._pending_to_send_string no_queue = not self._pending_to_send_string
if no_queue and len(string) <= 4096: if not (no_queue and len(string) <= 4096):
self.process.write(string)
else:
for i in range(0, len(string), 4096): for i in range(0, len(string), 4096):
self._strings.put(string[i : i + 4096]) self._strings.put(string[i : i + 4096])
if no_queue: if no_queue:
self._pending_to_send_string = True self._pending_to_send_string = True
threading.Thread(target=self._process_send_string, daemon=True).start() threading.Thread(target=self._process_send_string, daemon=True).start()
return
# outside the lock: the pty write can block on backpressure
self.process.write(string)
def _process_send_string(self): def _process_send_string(self):
"""Drain the queued paste chunks; the blocking pty write paces them.""" """Drain the queued paste chunks; the blocking pty write paces them."""
@@ -365,10 +374,15 @@ class Terminal:
try: try:
# linger so a burst of sends does not respawn the thread # linger so a burst of sends does not respawn the thread
string = self._strings.get(timeout=0.2) string = self._strings.get(timeout=0.2)
self.process.write(string)
except Empty: except Empty:
# under the lock: a chunk enqueued after the timeout but before
# the flag drops would otherwise strand until the next send
with self._send_lock:
if self._strings.empty():
self._pending_to_send_string = False self._pending_to_send_string = False
return return
continue
self.process.write(string)
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."""