mirror of
https://github.com/python/cpython
synced 2026-11-13 10:37:32 +03:00
gh-152907: Restore cooked output flags around the input hook in the new REPL (GH-153389)
* gh-152907: Restore cooked output flags around the input hook in the new REPL
pyrepl clears OPOST for its own cursor rendering but calls PyOS_InputHook
from inside the raw-mode read loop, so output written by an input hook
(GUI toolkit event loops, and any warning/traceback/print they emit) is
emitted with bare '\n' and no '\r'. Restore the terminal's saved output
flags around the hook call and re-enter raw mode afterwards; only oflag is
toggled so ECHO/ICANON stay off at the prompt.
* Skip the input-hook test on platforms without pty devices
The Emscripten buildbot has the pty module but no pty devices, so
pty.openpty() raises OSError("out of pty devices"). Guard the test
class the same way Lib/test/test_pty.py does.
* Propagate the input hook's return value and drop the sleep from the test
* Trim comments
* Observe the hook's output synchronously instead of via the reader thread
The reader thread no longer feeds any assertion: the hook drains the pty
master itself, so the check is an exact comparison. A drainer is still
needed for restore(), which writes before switching modes.
---------
(cherry picked from commit 46ee3580c0)
Co-authored-by: Harjoth Khara <harjoth.khara@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1085 lines
35 KiB
Python
1085 lines
35 KiB
Python
# Copyright 2000-2010 Michael Hudson-Doyle <micahel@gmail.com>
|
|
# Antonio Cuni
|
|
# Armin Rigo
|
|
#
|
|
# All Rights Reserved
|
|
#
|
|
#
|
|
# Permission to use, copy, modify, and distribute this software and
|
|
# its documentation for any purpose is hereby granted without fee,
|
|
# provided that the above copyright notice appear in all copies and
|
|
# that both that copyright notice and this permission notice appear in
|
|
# supporting documentation.
|
|
#
|
|
# THE AUTHOR MICHAEL HUDSON DISCLAIMS ALL WARRANTIES WITH REGARD TO
|
|
# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
# AND FITNESS, IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL,
|
|
# INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER
|
|
# RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF
|
|
# CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
|
# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
|
|
from __future__ import annotations
|
|
|
|
import errno
|
|
import os
|
|
import re
|
|
import select
|
|
import signal
|
|
import struct
|
|
import termios
|
|
import time
|
|
import types
|
|
import platform
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from fcntl import ioctl
|
|
from typing import TYPE_CHECKING, cast, overload
|
|
|
|
from _colorize import ANSIColors
|
|
|
|
from . import terminfo
|
|
from .console import Console, Event
|
|
from .fancy_termios import tcgetattr, tcsetattr, TermState
|
|
from .render import (
|
|
EMPTY_RENDER_LINE,
|
|
LineUpdate,
|
|
RenderLine,
|
|
RenderedScreen,
|
|
requires_cursor_resync,
|
|
diff_render_lines,
|
|
render_cells,
|
|
)
|
|
from .trace import trace, trace_text
|
|
from .unix_eventqueue import EventQueue
|
|
|
|
# declare posix optional to allow None assignment on other platforms
|
|
posix: types.ModuleType | None
|
|
try:
|
|
import posix
|
|
except ImportError:
|
|
posix = None
|
|
|
|
# types
|
|
if TYPE_CHECKING:
|
|
from typing import AbstractSet, IO, Literal
|
|
|
|
type _MoveFunc = Callable[[int, int], None]
|
|
type _PendingWrite = tuple[str | bytes, bool]
|
|
|
|
|
|
class InvalidTerminal(RuntimeError):
|
|
def __init__(self, message: str) -> None:
|
|
super().__init__(errno.EIO, message)
|
|
|
|
|
|
_error = (termios.error, InvalidTerminal)
|
|
_error_codes_to_ignore = frozenset([errno.EIO, errno.ENXIO, errno.EPERM])
|
|
|
|
SIGWINCH_EVENT = "repaint"
|
|
|
|
FIONREAD = getattr(termios, "FIONREAD", None)
|
|
TIOCGWINSZ = getattr(termios, "TIOCGWINSZ", None)
|
|
|
|
# ------------ start of baudrate definitions ------------
|
|
|
|
# Add (possibly) missing baudrates (check termios man page) to termios
|
|
|
|
|
|
def add_baudrate_if_supported(dictionary: dict[int, int], rate: int) -> None:
|
|
baudrate_name = "B%d" % rate
|
|
if hasattr(termios, baudrate_name):
|
|
dictionary[getattr(termios, baudrate_name)] = rate
|
|
|
|
|
|
# Check the termios man page (Line speed) to know where these
|
|
# values come from.
|
|
potential_baudrates = [
|
|
0,
|
|
110,
|
|
115200,
|
|
1200,
|
|
134,
|
|
150,
|
|
1800,
|
|
19200,
|
|
200,
|
|
230400,
|
|
2400,
|
|
300,
|
|
38400,
|
|
460800,
|
|
4800,
|
|
50,
|
|
57600,
|
|
600,
|
|
75,
|
|
9600,
|
|
]
|
|
|
|
ratedict: dict[int, int] = {}
|
|
for rate in potential_baudrates:
|
|
add_baudrate_if_supported(ratedict, rate)
|
|
|
|
# Clean up variables to avoid unintended usage
|
|
del rate, add_baudrate_if_supported
|
|
|
|
# ------------ end of baudrate definitions ------------
|
|
|
|
delayprog = re.compile(b"\\$<([0-9]+)((?:/|\\*){0,2})>")
|
|
|
|
try:
|
|
poll: type[select.poll] = select.poll
|
|
except AttributeError:
|
|
# this is exactly the minimum necessary to support what we
|
|
# do with poll objects
|
|
class MinimalPoll:
|
|
def __init__(self):
|
|
pass
|
|
|
|
def register(self, fd, flag):
|
|
self.fd = fd
|
|
|
|
# note: The 'timeout' argument is received as *milliseconds*
|
|
def poll(self, timeout: float | None = None) -> list[int]:
|
|
if timeout is None:
|
|
r, w, e = select.select([self.fd], [], [])
|
|
else:
|
|
r, w, e = select.select([self.fd], [], [], timeout / 1000)
|
|
return r
|
|
|
|
poll = MinimalPoll # type: ignore[assignment]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class UnixRefreshPlan:
|
|
"""Instructions for updating the terminal after a screen change.
|
|
|
|
After the user types ``e`` to complete ``name``::
|
|
|
|
Before: >>> def greet(nam|):
|
|
▲
|
|
LineUpdate here: insert_char "e"
|
|
|
|
After: >>> def greet(name|):
|
|
▲
|
|
|
|
Only the changed cells are sent to the terminal; unchanged rows
|
|
are skipped entirely.
|
|
"""
|
|
|
|
grow_lines: int
|
|
"""Number of blank lines to append at the bottom to accommodate new content."""
|
|
use_tall_mode: bool
|
|
"""Use absolute cursor addressing via ``cup`` instead of relative moves.
|
|
Activated when content exceeds one screen height."""
|
|
offset: int
|
|
"""Vertical scroll offset: the buffer row displayed at the top of the terminal window."""
|
|
reverse_scroll: int
|
|
"""Number of lines to scroll backwards (content moves down)."""
|
|
forward_scroll: int
|
|
"""Number of lines to scroll forwards (content moves up)."""
|
|
line_updates: tuple[LineUpdate, ...]
|
|
cleared_lines: tuple[int, ...]
|
|
"""Row indices to erase (old content with no replacement)."""
|
|
rendered_screen: RenderedScreen
|
|
cursor: tuple[int, int]
|
|
|
|
|
|
class UnixConsole(Console):
|
|
__buffer: list[_PendingWrite]
|
|
__gone_tall: bool
|
|
__move: _MoveFunc
|
|
__offset: int
|
|
|
|
def __init__(
|
|
self,
|
|
f_in: IO[bytes] | int = 0,
|
|
f_out: IO[bytes] | int = 1,
|
|
term: str = "",
|
|
encoding: str = "",
|
|
):
|
|
"""
|
|
Initialize the UnixConsole.
|
|
|
|
Parameters:
|
|
- f_in (int or file-like object): Input file descriptor or object.
|
|
- f_out (int or file-like object): Output file descriptor or object.
|
|
- term (str): Terminal name.
|
|
- encoding (str): Encoding to use for I/O operations.
|
|
"""
|
|
super().__init__(f_in, f_out, term, encoding)
|
|
|
|
self.pollob = poll()
|
|
self.pollob.register(self.input_fd, select.POLLIN)
|
|
self.terminfo = terminfo.TermInfo(term or None)
|
|
self.term = term
|
|
self.is_apple_terminal = (
|
|
platform.system() == "Darwin"
|
|
and os.getenv("TERM_PROGRAM") == "Apple_Terminal"
|
|
)
|
|
|
|
try:
|
|
self.__input_fd_set(tcgetattr(self.input_fd), ignore=frozenset())
|
|
except _error as e:
|
|
raise RuntimeError(f"termios failure ({e.args[1]})")
|
|
|
|
@overload
|
|
def _my_getstr(
|
|
cap: str, optional: Literal[False] = False
|
|
) -> bytes: ...
|
|
|
|
@overload
|
|
def _my_getstr(cap: str, optional: bool) -> bytes | None: ...
|
|
|
|
def _my_getstr(cap: str, optional: bool = False) -> bytes | None:
|
|
r = self.terminfo.get(cap)
|
|
if not optional and r is None:
|
|
raise InvalidTerminal(
|
|
f"terminal doesn't have the required {cap} capability"
|
|
)
|
|
return r
|
|
|
|
self._bel = _my_getstr("bel")
|
|
self._civis = _my_getstr("civis", optional=True)
|
|
self._clear = _my_getstr("clear")
|
|
self._cnorm = _my_getstr("cnorm", optional=True)
|
|
self._cub = _my_getstr("cub", optional=True)
|
|
self._cub1 = _my_getstr("cub1", optional=True)
|
|
self._cud = _my_getstr("cud", optional=True)
|
|
self._cud1 = _my_getstr("cud1", optional=True)
|
|
self._cuf = _my_getstr("cuf", optional=True)
|
|
self._cuf1 = _my_getstr("cuf1", optional=True)
|
|
self._cup = _my_getstr("cup")
|
|
self._cuu = _my_getstr("cuu", optional=True)
|
|
self._cuu1 = _my_getstr("cuu1", optional=True)
|
|
self._dch1 = _my_getstr("dch1", optional=True)
|
|
self._dch = _my_getstr("dch", optional=True)
|
|
self._el = _my_getstr("el")
|
|
self._hpa = _my_getstr("hpa", optional=True)
|
|
self._ich = _my_getstr("ich", optional=True)
|
|
self._ich1 = _my_getstr("ich1", optional=True)
|
|
self._ind = _my_getstr("ind", optional=True)
|
|
self._pad = _my_getstr("pad", optional=True)
|
|
self._ri = _my_getstr("ri", optional=True)
|
|
self._rmkx = _my_getstr("rmkx", optional=True)
|
|
self._smkx = _my_getstr("smkx", optional=True)
|
|
|
|
self.__setup_movement()
|
|
|
|
self.event_queue = EventQueue(
|
|
self.input_fd, self.encoding, self.terminfo
|
|
)
|
|
self.cursor_visible = True
|
|
|
|
signal.signal(signal.SIGCONT, self._sigcont_handler)
|
|
|
|
def _sigcont_handler(self, signum, frame):
|
|
self.restore()
|
|
self.prepare()
|
|
|
|
def __read(self, n: int) -> bytes:
|
|
return os.read(self.input_fd, n)
|
|
|
|
def change_encoding(self, encoding: str) -> None:
|
|
"""
|
|
Change the encoding used for I/O operations.
|
|
|
|
Parameters:
|
|
- encoding (str): New encoding to use.
|
|
"""
|
|
self.encoding = encoding
|
|
|
|
def refresh(self, rendered_screen: RenderedScreen) -> None:
|
|
"""
|
|
Refresh the console screen.
|
|
|
|
Parameters:
|
|
- rendered_screen: Structured rendered screen contents and cursor.
|
|
"""
|
|
c_xy = rendered_screen.cursor
|
|
trace(
|
|
"unix.refresh start cursor={cursor} lines={lines} prev_lines={prev_lines} "
|
|
"offset={offset} posxy={posxy}",
|
|
cursor=c_xy,
|
|
lines=len(rendered_screen.composed_lines),
|
|
prev_lines=len(self._rendered_screen.composed_lines),
|
|
offset=self.__offset,
|
|
posxy=self.posxy,
|
|
)
|
|
plan = self.__plan_refresh(rendered_screen, c_xy)
|
|
self.__apply_refresh_plan(plan)
|
|
|
|
def __plan_refresh(
|
|
self,
|
|
rendered_screen: RenderedScreen,
|
|
c_xy: tuple[int, int],
|
|
) -> UnixRefreshPlan:
|
|
cx, cy = c_xy
|
|
height = self.height
|
|
old_offset = offset = self.__offset
|
|
prev_composed = self._rendered_screen.composed_lines
|
|
previous_lines = list(prev_composed)
|
|
next_lines = list(rendered_screen.composed_lines)
|
|
line_count = len(next_lines)
|
|
|
|
grow_lines = 0
|
|
if not self.__gone_tall:
|
|
grow_lines = max(
|
|
min(line_count, height) - len(prev_composed),
|
|
0,
|
|
)
|
|
previous_lines.extend([EMPTY_RENDER_LINE] * grow_lines)
|
|
elif len(previous_lines) < line_count:
|
|
previous_lines.extend([EMPTY_RENDER_LINE] * (line_count - len(previous_lines)))
|
|
|
|
use_tall_mode = self.__gone_tall or line_count > height
|
|
|
|
# we make sure the cursor is on the screen, and that we're
|
|
# using all of the screen if we can
|
|
if cy < offset:
|
|
offset = cy
|
|
elif cy >= offset + height:
|
|
offset = cy - height + 1
|
|
elif offset > 0 and line_count < offset + height:
|
|
offset = max(line_count - height, 0)
|
|
next_lines.append(EMPTY_RENDER_LINE)
|
|
|
|
oldscr = previous_lines[old_offset : old_offset + height]
|
|
newscr = next_lines[offset : offset + height]
|
|
|
|
reverse_scroll = 0
|
|
forward_scroll = 0
|
|
if old_offset > offset and self._ri:
|
|
reverse_scroll = old_offset - offset
|
|
for _ in range(reverse_scroll):
|
|
if oldscr:
|
|
oldscr.pop(-1)
|
|
oldscr.insert(0, EMPTY_RENDER_LINE)
|
|
elif old_offset < offset and self._ind:
|
|
forward_scroll = offset - old_offset
|
|
for _ in range(forward_scroll):
|
|
if oldscr:
|
|
oldscr.pop(0)
|
|
oldscr.append(EMPTY_RENDER_LINE)
|
|
|
|
line_updates: list[LineUpdate] = []
|
|
px, _ = self.posxy
|
|
for y, oldline, newline in zip(range(offset, offset + height), oldscr, newscr):
|
|
update = self.__plan_changed_line(y, oldline, newline, px)
|
|
if update is not None:
|
|
line_updates.append(update)
|
|
|
|
cleared_lines = tuple(range(offset + len(newscr), offset + len(oldscr)))
|
|
console_rendered_screen = RenderedScreen(tuple(next_lines), c_xy)
|
|
trace(
|
|
"unix.refresh plan grow={grow} tall={tall} offset={offset} "
|
|
"reverse_scroll={reverse_scroll} forward_scroll={forward_scroll} "
|
|
"updates={updates} clears={clears}",
|
|
grow=grow_lines,
|
|
tall=use_tall_mode,
|
|
offset=offset,
|
|
reverse_scroll=reverse_scroll,
|
|
forward_scroll=forward_scroll,
|
|
updates=len(line_updates),
|
|
clears=len(cleared_lines),
|
|
)
|
|
return UnixRefreshPlan(
|
|
grow_lines=grow_lines,
|
|
use_tall_mode=use_tall_mode,
|
|
offset=offset,
|
|
reverse_scroll=reverse_scroll,
|
|
forward_scroll=forward_scroll,
|
|
line_updates=tuple(line_updates),
|
|
cleared_lines=cleared_lines,
|
|
rendered_screen=console_rendered_screen,
|
|
cursor=(cx, cy),
|
|
)
|
|
|
|
def __apply_refresh_plan(self, plan: UnixRefreshPlan) -> None:
|
|
cx, cy = plan.cursor
|
|
trace(
|
|
"unix.refresh apply cursor={cursor} updates={updates} clears={clears}",
|
|
cursor=plan.cursor,
|
|
updates=len(plan.line_updates),
|
|
clears=len(plan.cleared_lines),
|
|
)
|
|
visual_style = self.begin_redraw_visualization()
|
|
screen_line_count = len(self._rendered_screen.composed_lines)
|
|
|
|
for _ in range(plan.grow_lines):
|
|
self.__hide_cursor()
|
|
if screen_line_count:
|
|
self.__move(0, screen_line_count - 1)
|
|
self.__write("\n")
|
|
self.posxy = 0, screen_line_count
|
|
screen_line_count += 1
|
|
|
|
if plan.use_tall_mode and not self.__gone_tall:
|
|
self.__gone_tall = True
|
|
self.__move = self.__move_tall
|
|
|
|
old_offset = self.__offset
|
|
if plan.reverse_scroll:
|
|
self.__hide_cursor()
|
|
self.__write_code(self._cup, 0, 0)
|
|
self.posxy = 0, old_offset
|
|
for _ in range(plan.reverse_scroll):
|
|
self.__write_code(self._ri)
|
|
elif plan.forward_scroll:
|
|
self.__hide_cursor()
|
|
self.__write_code(self._cup, self.height - 1, 0)
|
|
self.posxy = 0, old_offset + self.height - 1
|
|
for _ in range(plan.forward_scroll):
|
|
self.__write_code(self._ind)
|
|
|
|
self.__offset = plan.offset
|
|
|
|
for update in plan.line_updates:
|
|
self.__apply_line_update(update, visual_style)
|
|
|
|
for y in plan.cleared_lines:
|
|
self.__hide_cursor()
|
|
self.__move(0, y)
|
|
self.posxy = 0, y
|
|
self.__write_code(self._el)
|
|
|
|
self.__show_cursor()
|
|
self.move_cursor(cx, cy)
|
|
self.flushoutput()
|
|
self.sync_rendered_screen(plan.rendered_screen, self.posxy)
|
|
|
|
def move_cursor(self, x: int, y: int) -> None:
|
|
"""
|
|
Move the cursor to the specified position on the screen.
|
|
|
|
Parameters:
|
|
- x (int): X coordinate.
|
|
- y (int): Y coordinate.
|
|
"""
|
|
if y < self.__offset or y >= self.__offset + self.height:
|
|
trace(
|
|
"unix.move_cursor offscreen x={x} y={y} offset={offset} height={height}",
|
|
x=x,
|
|
y=y,
|
|
offset=self.__offset,
|
|
height=self.height,
|
|
)
|
|
self.event_queue.insert(Event("scroll", ""))
|
|
else:
|
|
trace("unix.move_cursor x={x} y={y}", x=x, y=y)
|
|
self.__move(x, y)
|
|
self.posxy = x, y
|
|
self.flushoutput()
|
|
|
|
def prepare(self) -> None:
|
|
"""
|
|
Prepare the console for input/output operations.
|
|
"""
|
|
trace("unix.prepare")
|
|
self.__buffer = []
|
|
|
|
self.__svtermstate = tcgetattr(self.input_fd)
|
|
raw = self.__svtermstate.copy()
|
|
raw.iflag &= ~(termios.INPCK | termios.ISTRIP | termios.IXON)
|
|
raw.oflag &= ~(termios.OPOST)
|
|
raw.cflag &= ~(termios.CSIZE | termios.PARENB)
|
|
raw.cflag |= termios.CS8
|
|
raw.iflag |= termios.BRKINT
|
|
raw.lflag &= ~(termios.ICANON | termios.ECHO | termios.IEXTEN)
|
|
raw.lflag |= termios.ISIG
|
|
raw.cc[termios.VMIN] = b"\x01"
|
|
raw.cc[termios.VTIME] = b"\x00"
|
|
self.__input_fd_set(raw)
|
|
self.__rawtermstate = raw
|
|
|
|
# Apple Terminal will re-wrap lines for us unless we preempt the
|
|
# damage.
|
|
if self.is_apple_terminal:
|
|
os.write(self.output_fd, b"\033[?7l")
|
|
|
|
self.height, self.width = self.getheightwidth()
|
|
|
|
self.posxy = 0, 0
|
|
self.__gone_tall = False
|
|
self.__move = self.__move_short
|
|
self.__offset = 0
|
|
self.sync_rendered_screen(RenderedScreen.empty(), self.posxy)
|
|
|
|
self.__maybe_write_code(self._smkx)
|
|
|
|
try:
|
|
self.old_sigwinch = signal.signal(signal.SIGWINCH, self.__sigwinch)
|
|
except ValueError:
|
|
pass
|
|
|
|
self.__enable_bracketed_paste()
|
|
|
|
def restore(self) -> None:
|
|
"""
|
|
Restore the console to the default state
|
|
"""
|
|
trace("unix.restore")
|
|
self.__write(ANSIColors.RESET)
|
|
self.__disable_bracketed_paste()
|
|
self.__maybe_write_code(self._rmkx)
|
|
self.flushoutput()
|
|
self.__input_fd_set(self.__svtermstate)
|
|
|
|
if self.is_apple_terminal:
|
|
os.write(self.output_fd, b"\033[?7h")
|
|
|
|
if hasattr(self, "old_sigwinch"):
|
|
try:
|
|
signal.signal(signal.SIGWINCH, self.old_sigwinch)
|
|
except ValueError as e:
|
|
import threading
|
|
if threading.current_thread() is threading.main_thread():
|
|
raise e
|
|
del self.old_sigwinch
|
|
|
|
def push_char(self, char: int | bytes) -> None:
|
|
"""
|
|
Push a character to the console event queue.
|
|
"""
|
|
trace("push char {char!r}", char=char)
|
|
self.event_queue.push(char)
|
|
|
|
def get_event(self, block: bool = True) -> Event | None:
|
|
"""
|
|
Get an event from the console event queue.
|
|
|
|
Parameters:
|
|
- block (bool): Whether to block until an event is available.
|
|
|
|
Returns:
|
|
- Event: Event object from the event queue.
|
|
"""
|
|
if not block and not self.wait(timeout=0):
|
|
return None
|
|
|
|
while self.event_queue.empty():
|
|
while True:
|
|
try:
|
|
self.push_char(self.__read(1))
|
|
except OSError as err:
|
|
if err.errno == errno.EINTR:
|
|
if not self.event_queue.empty():
|
|
return self.event_queue.get()
|
|
else:
|
|
continue
|
|
elif err.errno == errno.EIO:
|
|
raise SystemExit(errno.EIO)
|
|
else:
|
|
raise
|
|
else:
|
|
break
|
|
return self.event_queue.get()
|
|
|
|
def wait(self, timeout: float | None = None) -> bool:
|
|
"""
|
|
Wait for events on the console.
|
|
"""
|
|
return (
|
|
not self.event_queue.empty()
|
|
or bool(self.pollob.poll(timeout))
|
|
)
|
|
|
|
def set_cursor_vis(self, visible: bool) -> None:
|
|
"""
|
|
Set the visibility of the cursor.
|
|
|
|
Parameters:
|
|
- visible (bool): Visibility flag.
|
|
"""
|
|
if visible:
|
|
self.__show_cursor()
|
|
else:
|
|
self.__hide_cursor()
|
|
|
|
if TIOCGWINSZ:
|
|
|
|
def getheightwidth(self):
|
|
"""
|
|
Get the height and width of the console.
|
|
|
|
Returns:
|
|
- tuple: Height and width of the console.
|
|
"""
|
|
try:
|
|
return int(os.environ["LINES"]), int(os.environ["COLUMNS"])
|
|
except (KeyError, TypeError, ValueError):
|
|
try:
|
|
size = ioctl(self.input_fd, TIOCGWINSZ, b"\000" * 8)
|
|
except OSError:
|
|
return 25, 80
|
|
height, width = struct.unpack("hhhh", size)[0:2]
|
|
if not height:
|
|
return 25, 80
|
|
return height, width
|
|
|
|
else:
|
|
|
|
def getheightwidth(self):
|
|
"""
|
|
Get the height and width of the console.
|
|
|
|
Returns:
|
|
- tuple: Height and width of the console.
|
|
"""
|
|
try:
|
|
return int(os.environ["LINES"]), int(os.environ["COLUMNS"])
|
|
except (KeyError, TypeError, ValueError):
|
|
return 25, 80
|
|
|
|
def forgetinput(self):
|
|
"""
|
|
Discard any pending input on the console.
|
|
"""
|
|
termios.tcflush(self.input_fd, termios.TCIFLUSH)
|
|
|
|
def flushoutput(self):
|
|
"""
|
|
Flush the output buffer.
|
|
"""
|
|
for text, iscode in self.__buffer:
|
|
if iscode:
|
|
self.__tputs(text)
|
|
else:
|
|
os.write(self.output_fd, text.encode(self.encoding, "replace"))
|
|
del self.__buffer[:]
|
|
|
|
def finish(self):
|
|
"""
|
|
Finish console operations and flush the output buffer.
|
|
"""
|
|
rendered_lines = self._rendered_screen.composed_lines
|
|
y = len(rendered_lines) - 1
|
|
while y >= 0 and not rendered_lines[y].text:
|
|
y -= 1
|
|
self.__move(0, min(y, self.height + self.__offset - 1))
|
|
self.__write(ANSIColors.RESET)
|
|
self.__write("\n\r")
|
|
self.flushoutput()
|
|
|
|
def beep(self):
|
|
"""
|
|
Emit a beep sound.
|
|
"""
|
|
self.__maybe_write_code(self._bel)
|
|
self.flushoutput()
|
|
|
|
if FIONREAD:
|
|
|
|
def getpending(self):
|
|
"""
|
|
Get pending events from the console event queue.
|
|
|
|
Returns:
|
|
- Event: Pending event from the event queue.
|
|
"""
|
|
e = Event("key", "", b"")
|
|
|
|
while not self.event_queue.empty():
|
|
e2 = self.event_queue.get()
|
|
e.data += e2.data
|
|
e.raw += e2.raw
|
|
|
|
amount = struct.unpack("i", ioctl(self.input_fd, FIONREAD, b"\0\0\0\0"))[0]
|
|
trace("getpending({a})", a=amount)
|
|
raw = self.__read(amount)
|
|
data = str(raw, self.encoding, "replace")
|
|
e.data += data
|
|
e.raw += raw
|
|
return e
|
|
|
|
else:
|
|
|
|
def getpending(self):
|
|
"""
|
|
Get pending events from the console event queue.
|
|
|
|
Returns:
|
|
- Event: Pending event from the event queue.
|
|
"""
|
|
e = Event("key", "", b"")
|
|
|
|
while not self.event_queue.empty():
|
|
e2 = self.event_queue.get()
|
|
e.data += e2.data
|
|
e.raw += e2.raw
|
|
|
|
amount = 10000
|
|
raw = self.__read(amount)
|
|
data = str(raw, self.encoding, "replace")
|
|
e.data += data
|
|
e.raw += raw
|
|
return e
|
|
|
|
def clear(self):
|
|
"""
|
|
Clear the console screen.
|
|
"""
|
|
trace("unix.clear")
|
|
self.__write_code(self._clear)
|
|
self.__gone_tall = True
|
|
self.__move = self.__move_tall
|
|
self.posxy = 0, 0
|
|
self.sync_rendered_screen(RenderedScreen.empty(), self.posxy)
|
|
|
|
@property
|
|
def input_hook(self):
|
|
# avoid inline imports here so the repl doesn't get flooded
|
|
# with import logging from -X importtime=2
|
|
if posix is not None and posix._is_inputhook_installed():
|
|
return self.__run_input_hook
|
|
|
|
def __run_input_hook(self):
|
|
# gh-152907: input hooks expect cooked output, but pyrepl runs with
|
|
# OPOST disabled. Restore the saved output flags around the hook
|
|
# (only oflag; input must stay raw at the prompt).
|
|
cooked = self.__rawtermstate.copy()
|
|
cooked.oflag = self.__svtermstate.oflag
|
|
self.__input_fd_set(cooked)
|
|
try:
|
|
return posix._inputhook()
|
|
finally:
|
|
self.__input_fd_set(self.__rawtermstate)
|
|
|
|
def __enable_bracketed_paste(self) -> None:
|
|
os.write(self.output_fd, b"\x1b[?2004h")
|
|
|
|
def __disable_bracketed_paste(self) -> None:
|
|
os.write(self.output_fd, b"\x1b[?2004l")
|
|
|
|
def __setup_movement(self):
|
|
"""
|
|
Set up the movement functions based on the terminal capabilities.
|
|
"""
|
|
if 0 and self._hpa: # hpa don't work in windows telnet :-(
|
|
self.__move_x = self.__move_x_hpa
|
|
elif self._cub and self._cuf:
|
|
self.__move_x = self.__move_x_cub_cuf
|
|
elif self._cub1 and self._cuf1:
|
|
self.__move_x = self.__move_x_cub1_cuf1
|
|
else:
|
|
raise RuntimeError("insufficient terminal (horizontal)")
|
|
|
|
if self._cuu and self._cud:
|
|
self.__move_y = self.__move_y_cuu_cud
|
|
elif self._cuu1 and self._cud1:
|
|
self.__move_y = self.__move_y_cuu1_cud1
|
|
else:
|
|
raise RuntimeError("insufficient terminal (vertical)")
|
|
|
|
if self._dch1:
|
|
self.dch1 = self._dch1
|
|
elif self._dch:
|
|
self.dch1 = terminfo.tparm(self._dch, 1)
|
|
else:
|
|
self.dch1 = None
|
|
|
|
if self._ich1:
|
|
self.ich1 = self._ich1
|
|
elif self._ich:
|
|
self.ich1 = terminfo.tparm(self._ich, 1)
|
|
else:
|
|
self.ich1 = None
|
|
|
|
self.__move = self.__move_short
|
|
|
|
@staticmethod
|
|
def __cell_index_from_x(line: RenderLine, x_coord: int) -> int:
|
|
width = 0
|
|
index = 0
|
|
while index < len(line.cells) and width < x_coord:
|
|
width += line.cells[index].width
|
|
index += 1
|
|
return index
|
|
|
|
def __plan_changed_line(
|
|
self,
|
|
y: int,
|
|
oldline: RenderLine,
|
|
newline: RenderLine,
|
|
px_coord: int,
|
|
) -> LineUpdate | None:
|
|
# NOTE: The shared replace_char / replace_span / rewrite_suffix logic
|
|
# is duplicated in WindowsConsole.__plan_changed_line. Keep changes to
|
|
# these common cases synchronised between the two files. Yes, this is
|
|
# duplicated on purpose; the two backends agree just enough to make a
|
|
# shared helper a trap. Unix-only cases (insert_char, delete_then_insert)
|
|
# rely on terminal capabilities (ich1/dch1) that are unavailable on
|
|
# Windows.
|
|
diff = diff_render_lines(oldline, newline)
|
|
if diff is None:
|
|
return None
|
|
|
|
start_cell = diff.start_cell
|
|
start_x = diff.start_x
|
|
|
|
if (
|
|
self.ich1
|
|
and not diff.old_cells
|
|
and (visible_new_cells := tuple(
|
|
cell for cell in diff.new_cells if cell.width
|
|
))
|
|
and len(visible_new_cells) == 1
|
|
and all(cell.width == 0 for cell in diff.new_cells[1:])
|
|
and oldline.cells[start_cell:] == newline.cells[start_cell + 1 :]
|
|
):
|
|
px_cell = self.__cell_index_from_x(oldline, px_coord)
|
|
if (
|
|
y == self.posxy[1]
|
|
and start_x > self.posxy[0]
|
|
and oldline.cells[px_cell:start_cell]
|
|
== newline.cells[px_cell + 1 : start_cell + 1]
|
|
):
|
|
start_cell = px_cell
|
|
start_x = px_coord
|
|
planned_cells = diff.new_cells
|
|
changed_cell = visible_new_cells[0]
|
|
return LineUpdate(
|
|
kind="insert_char",
|
|
y=y,
|
|
start_cell=start_cell,
|
|
start_x=start_x,
|
|
cells=planned_cells,
|
|
char_width=changed_cell.width,
|
|
reset_to_margin=requires_cursor_resync(planned_cells),
|
|
)
|
|
|
|
if (
|
|
len(diff.old_cells) == 1
|
|
and len(diff.new_cells) == 1
|
|
and diff.old_cells[0].width == diff.new_cells[0].width
|
|
):
|
|
planned_cells = diff.new_cells
|
|
changed_cell = planned_cells[0]
|
|
return LineUpdate(
|
|
kind="replace_char",
|
|
y=y,
|
|
start_cell=start_cell,
|
|
start_x=start_x,
|
|
cells=planned_cells,
|
|
char_width=changed_cell.width,
|
|
reset_to_margin=requires_cursor_resync(planned_cells),
|
|
)
|
|
|
|
if diff.old_changed_width == diff.new_changed_width:
|
|
planned_cells = diff.new_cells
|
|
return LineUpdate(
|
|
kind="replace_span",
|
|
y=y,
|
|
start_cell=start_cell,
|
|
start_x=start_x,
|
|
cells=planned_cells,
|
|
char_width=diff.new_changed_width,
|
|
reset_to_margin=requires_cursor_resync(planned_cells),
|
|
)
|
|
|
|
if (
|
|
self.dch1
|
|
and self.ich1
|
|
and newline.width == self.width
|
|
and start_x < newline.width - 2
|
|
and newline.cells[start_cell + 1 : -1] == oldline.cells[start_cell:-2]
|
|
):
|
|
planned_cells = (newline.cells[start_cell],)
|
|
changed_cell = planned_cells[0]
|
|
return LineUpdate(
|
|
kind="delete_then_insert",
|
|
y=y,
|
|
start_cell=start_cell,
|
|
start_x=start_x,
|
|
cells=planned_cells,
|
|
char_width=changed_cell.width,
|
|
reset_to_margin=requires_cursor_resync(planned_cells),
|
|
)
|
|
|
|
suffix_cells = newline.cells[start_cell:]
|
|
return LineUpdate(
|
|
kind="rewrite_suffix",
|
|
y=y,
|
|
start_cell=start_cell,
|
|
start_x=start_x,
|
|
cells=suffix_cells,
|
|
char_width=sum(cell.width for cell in suffix_cells),
|
|
clear_eol=oldline.width > newline.width,
|
|
reset_to_margin=requires_cursor_resync(suffix_cells),
|
|
)
|
|
|
|
def __apply_line_update(
|
|
self,
|
|
update: LineUpdate,
|
|
visual_style: str | None = None,
|
|
) -> None:
|
|
text = render_cells(update.cells, visual_style) if visual_style else update.text
|
|
trace(
|
|
"unix.refresh update kind={kind} y={y} x={x} text={text} "
|
|
"clear_eol={clear_eol} reset_to_margin={reset}",
|
|
kind=update.kind,
|
|
y=update.y,
|
|
x=update.start_x,
|
|
text=trace_text(text),
|
|
clear_eol=update.clear_eol,
|
|
reset=update.reset_to_margin,
|
|
)
|
|
if update.kind == "insert_char":
|
|
self.__move(update.start_x, update.y)
|
|
self.__write_code(self.ich1)
|
|
self.__write(text)
|
|
self.posxy = update.start_x + update.char_width, update.y
|
|
elif update.kind in {"replace_char", "replace_span"}:
|
|
self.__move(update.start_x, update.y)
|
|
self.__write(text)
|
|
self.posxy = update.start_x + update.char_width, update.y
|
|
elif update.kind == "delete_then_insert":
|
|
self.__hide_cursor()
|
|
self.__move(self.width - 2, update.y)
|
|
self.posxy = self.width - 2, update.y
|
|
self.__write_code(self.dch1)
|
|
self.__move(update.start_x, update.y)
|
|
self.__write_code(self.ich1)
|
|
self.__write(text)
|
|
self.posxy = update.start_x + update.char_width, update.y
|
|
else:
|
|
self.__hide_cursor()
|
|
self.__move(update.start_x, update.y)
|
|
if update.clear_eol:
|
|
self.__write_code(self._el)
|
|
self.__write(text)
|
|
self.posxy = update.start_x + update.char_width, update.y
|
|
|
|
if update.reset_to_margin:
|
|
# Non-SGR terminal controls can affect the cursor position.
|
|
self.move_cursor(0, update.y)
|
|
|
|
def __write(self, text):
|
|
self.__buffer.append((text, False))
|
|
|
|
def __write_code(self, fmt, *args):
|
|
self.__buffer.append((terminfo.tparm(fmt, *args), True))
|
|
|
|
def __maybe_write_code(self, fmt, *args):
|
|
if fmt:
|
|
self.__write_code(fmt, *args)
|
|
|
|
def __move_y_cuu1_cud1(self, y):
|
|
assert self._cud1 is not None
|
|
assert self._cuu1 is not None
|
|
dy = y - self.posxy[1]
|
|
if dy > 0:
|
|
self.__write_code(dy * self._cud1)
|
|
elif dy < 0:
|
|
self.__write_code((-dy) * self._cuu1)
|
|
|
|
def __move_y_cuu_cud(self, y):
|
|
dy = y - self.posxy[1]
|
|
if dy > 0:
|
|
self.__write_code(self._cud, dy)
|
|
elif dy < 0:
|
|
self.__write_code(self._cuu, -dy)
|
|
|
|
def __move_x_hpa(self, x: int) -> None:
|
|
if x != self.posxy[0]:
|
|
self.__write_code(self._hpa, x)
|
|
|
|
def __move_x_cub1_cuf1(self, x: int) -> None:
|
|
assert self._cuf1 is not None
|
|
assert self._cub1 is not None
|
|
dx = x - self.posxy[0]
|
|
if dx > 0:
|
|
self.__write_code(self._cuf1 * dx)
|
|
elif dx < 0:
|
|
self.__write_code(self._cub1 * (-dx))
|
|
|
|
def __move_x_cub_cuf(self, x: int) -> None:
|
|
dx = x - self.posxy[0]
|
|
if dx > 0:
|
|
self.__write_code(self._cuf, dx)
|
|
elif dx < 0:
|
|
self.__write_code(self._cub, -dx)
|
|
|
|
def __move_short(self, x, y):
|
|
self.__move_x(x)
|
|
self.__move_y(y)
|
|
|
|
def __move_tall(self, x, y):
|
|
assert 0 <= y - self.__offset < self.height, y - self.__offset
|
|
self.__write_code(self._cup, y - self.__offset, x)
|
|
|
|
def __sigwinch(self, signum, frame):
|
|
self.event_queue.insert(Event("resize", ""))
|
|
|
|
def __hide_cursor(self):
|
|
if self.cursor_visible:
|
|
self.__maybe_write_code(self._civis)
|
|
self.cursor_visible = False
|
|
|
|
def __show_cursor(self):
|
|
if not self.cursor_visible:
|
|
self.__maybe_write_code(self._cnorm)
|
|
self.cursor_visible = True
|
|
|
|
def repaint(self):
|
|
composed = self._rendered_screen.composed_lines
|
|
trace(
|
|
"unix.repaint gone_tall={gone_tall} screen_lines={lines} offset={offset}",
|
|
gone_tall=self.__gone_tall,
|
|
lines=len(composed),
|
|
offset=self.__offset,
|
|
)
|
|
if not self.__gone_tall:
|
|
self.posxy = 0, self.posxy[1]
|
|
self.__write("\r")
|
|
ns = len(composed) * ["\000" * self.width]
|
|
else:
|
|
self.posxy = 0, self.__offset
|
|
self.__move(0, self.__offset)
|
|
ns = self.height * ["\000" * self.width]
|
|
self.sync_rendered_screen(
|
|
RenderedScreen.from_screen_lines(ns, self.posxy),
|
|
self.posxy,
|
|
)
|
|
|
|
def __tputs(self, fmt, prog=delayprog):
|
|
"""A Python implementation of the curses tputs function; the
|
|
curses one can't really be wrapped in a sane manner.
|
|
|
|
I have the strong suspicion that this is complexity that
|
|
will never do anyone any good."""
|
|
# using .get() means that things will blow up
|
|
# only if the bps is actually needed (which I'm
|
|
# betting is pretty unlikely)
|
|
bps = ratedict.get(self.__svtermstate.ospeed)
|
|
while True:
|
|
m = prog.search(fmt)
|
|
if not m:
|
|
os.write(self.output_fd, fmt)
|
|
break
|
|
x, y = m.span()
|
|
os.write(self.output_fd, fmt[:x])
|
|
fmt = fmt[y:]
|
|
delay = int(m.group(1))
|
|
if b"*" in m.group(2):
|
|
delay *= self.height
|
|
if self._pad and bps is not None:
|
|
nchars = (bps * delay) / 1000
|
|
os.write(self.output_fd, self._pad * nchars)
|
|
else:
|
|
time.sleep(float(delay) / 1000.0)
|
|
|
|
def __input_fd_set(
|
|
self,
|
|
state: TermState,
|
|
ignore: AbstractSet[int] = _error_codes_to_ignore,
|
|
) -> bool:
|
|
try:
|
|
tcsetattr(self.input_fd, termios.TCSADRAIN, state)
|
|
except termios.error as te:
|
|
if te.args[0] not in ignore:
|
|
raise
|
|
return False
|
|
else:
|
|
return True
|