codekingpro/portable-devtools
114k
1# Urwid terminal emulation widget2# Copyright (C) 2010 aszlig3# Copyright (C) 2011 Ian Ward4#5# This library is free software; you can redistribute it and/or6# modify it under the terms of the GNU Lesser General Public7# License as published by the Free Software Foundation; either8# version 2.1 of the License, or (at your option) any later version.9#10# This library is distributed in the hope that it will be useful,11# but WITHOUT ANY WARRANTY; without even the implied warranty of12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU13# Lesser General Public License for more details.14#15# You should have received a copy of the GNU Lesser General Public16# License along with this library; if not, write to the Free Software17# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA18#19# Urwid web site: https://urwid.org/20 21 22from __future__ import annotations23 24import atexit25import copy26import errno27import fcntl28import os29import pty30import selectors31import signal32import struct33import sys34import termios35import time36import traceback37import typing38from collections import deque39from contextlib import suppress40from dataclasses import dataclass41 42from urwid import event_loop, util43from urwid.canvas import Canvas44from urwid.display import AttrSpec, RealTerminal45from urwid.display.escape import ALT_DEC_SPECIAL_CHARS, DEC_SPECIAL_CHARS46from urwid.widget import Sizing, Widget47 48from .display.common import _BASIC_COLORS, _color_desc_256, _color_desc_true49 50if typing.TYPE_CHECKING:51 from collections.abc import Callable, Iterable, Mapping, Sequence52 53 from typing_extensions import Literal54 55EOF = b""56ESC = chr(27)57ESC_B = b"\x1b"58 59KEY_TRANSLATIONS = {60 "enter": "\r",61 "backspace": chr(127),62 "tab": "\t",63 "esc": ESC,64 "up": f"{ESC}[A",65 "down": f"{ESC}[B",66 "right": f"{ESC}[C",67 "left": f"{ESC}[D",68 "home": f"{ESC}[1~",69 "insert": f"{ESC}[2~",70 "delete": f"{ESC}[3~",71 "end": f"{ESC}[4~",72 "page up": f"{ESC}[5~",73 "page down": f"{ESC}[6~",74 "begin paste": f"{ESC}[200~",75 "end paste": f"{ESC}[201~",76 "f1": f"{ESC}[[A",77 "f2": f"{ESC}[[B",78 "f3": f"{ESC}[[C",79 "f4": f"{ESC}[[D",80 "f5": f"{ESC}[[E",81 "f6": f"{ESC}[17~",82 "f7": f"{ESC}[18~",83 "f8": f"{ESC}[19~",84 "f9": f"{ESC}[20~",85 "f10": f"{ESC}[21~",86 "f11": f"{ESC}[23~",87 "f12": f"{ESC}[24~",88}89 90KEY_TRANSLATIONS_DECCKM = {91 "up": f"{ESC}OA",92 "down": f"{ESC}OB",93 "right": f"{ESC}OC",94 "left": f"{ESC}OD",95 "f1": f"{ESC}OP",96 "f2": f"{ESC}OQ",97 "f3": f"{ESC}OR",98 "f4": f"{ESC}OS",99 "f5": f"{ESC}[15~",100}101 102 103class CSIAlias(typing.NamedTuple):104 alias_mark: str # can not have constructor with default first and non-default second arg105 alias: bytes106 107 108class CSICommand(typing.NamedTuple):109 num_args: int110 default: int111 callback: Callable[[TermCanvas, list[int], bool], typing.Any] # return value ignored112 113 114CSI_COMMANDS: dict[bytes, CSIAlias | CSICommand] = {115 # possible values:116 # None -> ignore sequence117 # (<minimum number of args>, <fallback if no argument>, callback)118 # ('alias', <symbol>)119 #120 # while callback is executed as:121 # callback(<instance of TermCanvas>, arguments, has_question_mark)122 b"@": CSICommand(1, 1, lambda s, number, q: s.insert_chars(chars=number[0])),123 b"A": CSICommand(1, 1, lambda s, rows, q: s.move_cursor(0, -rows[0], relative=True)),124 b"B": CSICommand(1, 1, lambda s, rows, q: s.move_cursor(0, rows[0], relative=True)),125 b"C": CSICommand(1, 1, lambda s, cols, q: s.move_cursor(cols[0], 0, relative=True)),126 b"D": CSICommand(1, 1, lambda s, cols, q: s.move_cursor(-cols[0], 0, relative=True)),127 b"E": CSICommand(1, 1, lambda s, rows, q: s.move_cursor(0, rows[0], relative_y=True)),128 b"F": CSICommand(1, 1, lambda s, rows, q: s.move_cursor(0, -rows[0], relative_y=True)),129 b"G": CSICommand(1, 1, lambda s, col, q: s.move_cursor(col[0] - 1, 0, relative_y=True)),130 b"H": CSICommand(2, 1, lambda s, x_y, q: s.move_cursor(x_y[1] - 1, x_y[0] - 1)),131 b"J": CSICommand(1, 0, lambda s, mode, q: s.csi_erase_display(mode[0])),132 b"K": CSICommand(1, 0, lambda s, mode, q: s.csi_erase_line(mode[0])),133 b"L": CSICommand(1, 1, lambda s, number, q: s.insert_lines(lines=number[0])),134 b"M": CSICommand(1, 1, lambda s, number, q: s.remove_lines(lines=number[0])),135 b"P": CSICommand(1, 1, lambda s, number, q: s.remove_chars(chars=number[0])),136 b"X": CSICommand(137 1,138 1,139 lambda s, number, q: s.erase(s.term_cursor, (s.term_cursor[0] + number[0] - 1, s.term_cursor[1])),140 ),141 b"a": CSIAlias("alias", b"C"),142 b"c": CSICommand(0, 0, lambda s, none, q: s.csi_get_device_attributes(q)),143 b"d": CSICommand(1, 1, lambda s, row, q: s.move_cursor(0, row[0] - 1, relative_x=True)),144 b"e": CSIAlias("alias", b"B"),145 b"f": CSIAlias("alias", b"H"),146 b"g": CSICommand(1, 0, lambda s, mode, q: s.csi_clear_tabstop(mode[0])),147 b"h": CSICommand(1, 0, lambda s, modes, q: s.csi_set_modes(modes, q)),148 b"l": CSICommand(1, 0, lambda s, modes, q: s.csi_set_modes(modes, q, reset=True)),149 b"m": CSICommand(1, 0, lambda s, attrs, q: s.csi_set_attr(attrs)),150 b"n": CSICommand(1, 0, lambda s, mode, q: s.csi_status_report(mode[0])),151 b"q": CSICommand(1, 0, lambda s, mode, q: s.csi_set_keyboard_leds(mode[0])),152 b"r": CSICommand(2, 0, lambda s, t_b, q: s.csi_set_scroll(t_b[0], t_b[1])),153 b"s": CSICommand(0, 0, lambda s, none, q: s.save_cursor()),154 b"u": CSICommand(0, 0, lambda s, none, q: s.restore_cursor()),155 b"`": CSIAlias("alias", b"G"),156}157 158CHARSET_DEFAULT: Literal[1] = 1 # type annotated exclusively for buggy IDE159CHARSET_UTF8: Literal[2] = 2160 161 162@dataclass(eq=True, order=False)163class TermModes:164 # ECMA-48165 display_ctrl: bool = False166 insert: bool = False167 lfnl: bool = False168 169 # DEC private modes170 keys_decckm: bool = False171 reverse_video: bool = False172 constrain_scrolling: bool = False173 autowrap: bool = True174 visible_cursor: bool = True175 bracketed_paste: bool = False176 177 # charset stuff178 main_charset: Literal[1, 2] = CHARSET_DEFAULT179 180 def reset(self) -> None:181 # ECMA-48182 self.display_ctrl = False183 self.insert = False184 self.lfnl = False185 186 # DEC private modes187 self.keys_decckm = False188 self.reverse_video = False189 self.constrain_scrolling = False190 self.autowrap = True191 self.visible_cursor = True192 193 # charset stuff194 self.main_charset = CHARSET_DEFAULT195 196 197class TermCharset:198 __slots__ = ("_g", "_sgr_mapping", "active", "current")199 200 MAPPING: typing.ClassVar[dict[str, str | None]] = {201 "default": None,202 "vt100": "0",203 "ibmpc": "U",204 "user": None,205 }206 207 def __init__(self) -> None:208 self._g = [209 "default",210 "vt100",211 ]212 213 self._sgr_mapping = False214 215 # prepare defaults216 self.active = 0217 self.current: str | None = None218 219 self.activate(0)220 221 def define(self, g: int, charset: str) -> None:222 """223 Redefine G'g' with new mapping.224 """225 self._g[g] = charset226 self.activate(g=self.active)227 228 def activate(self, g: int) -> None:229 """230 Activate the given charset slot.231 """232 self.active = g233 self.current = self.MAPPING.get(self._g[g], None)234 235 def set_sgr_ibmpc(self) -> None:236 """237 Set graphics rendition mapping to IBM PC CP437.238 """239 self._sgr_mapping = True240 241 def reset_sgr_ibmpc(self) -> None:242 """243 Reset graphics rendition mapping to IBM PC CP437.244 """245 self._sgr_mapping = False246 self.activate(g=self.active)247 248 def apply_mapping(self, char: bytes) -> bytes:249 if self._sgr_mapping or self._g[self.active] == "ibmpc":250 if (dec_pos := DEC_SPECIAL_CHARS.find(char.decode("cp437"))) >= 0:251 self.current = "0"252 return ALT_DEC_SPECIAL_CHARS[dec_pos].encode("cp437")253 254 self.current = "U"255 return char256 257 return char258 259 260class TermCanvas(Canvas):261 cacheable = False262 263 def __init__(self, width: int, height: int, widget: Terminal) -> None:264 super().__init__()265 266 self.width, self.height = width, height267 self.widget = widget268 self.modes: TermModes = widget.term_modes269 self.has_focus = False270 271 self.scrollback_buffer: deque[list[tuple[AttrSpec | None, str | None, bytes]]] = deque(maxlen=10000)272 self.scrolling_up = 0273 274 self.utf8_eat_bytes: int | None = None275 self.utf8_buffer = bytearray()276 self.escbuf = b""277 278 self.coords["cursor"] = (0, 0, None)279 280 self.term_cursor: tuple[int, int] = (0, 0) # do not allow to shoot in the leg at `set_term_cursor`281 282 self.within_escape = False283 self.parsestate = 0284 285 self.attrspec: AttrSpec | None = None286 287 self.charset = TermCharset()288 289 self.saved_cursor: tuple[int, int] | None = None290 self.saved_attrs: tuple[AttrSpec | None, TermCharset] | None = None291 292 self.is_rotten_cursor = False293 294 self.scrollregion_start = 0295 self.scrollregion_end = self.height - 1296 297 self.tabstops: list[int] = []298 self.term: list[list[tuple[AttrSpec | None, str | None, bytes]]] = []299 300 self.reset()301 302 def set_term_cursor(self, x: int | None = None, y: int | None = None) -> None:303 """304 Set terminal cursor to x/y and update canvas cursor. If one or both axes305 are omitted, use the values of the current position.306 """307 if x is None:308 x = self.term_cursor[0]309 if y is None:310 y = self.term_cursor[1]311 312 self.term_cursor = self.constrain_coords(x, y)313 314 if self.has_focus and self.modes.visible_cursor and self.scrolling_up < self.height - y:315 self.cursor = (x, y + self.scrolling_up)316 else:317 self.cursor = None318 319 def reset_scroll(self) -> None:320 """321 Reset scrolling region to full terminal size.322 """323 self.scrollregion_start = 0324 self.scrollregion_end = self.height - 1325 326 def scroll_buffer(self, up: bool = True, reset: bool = False, lines: int | None = None) -> None:327 """328 Scroll the scrolling buffer up (up=True) or down (up=False) the given329 amount of lines or half the screen height.330 331 If just 'reset' is True, set the scrollbuffer view to the current332 terminal content.333 """334 if reset:335 self.scrolling_up = 0336 self.set_term_cursor()337 return338 339 if lines is None:340 lines = self.height // 2341 342 if not up:343 lines = -lines344 345 maxscroll = len(self.scrollback_buffer)346 self.scrolling_up += lines347 348 if self.scrolling_up > maxscroll:349 self.scrolling_up = maxscroll350 elif self.scrolling_up < 0:351 self.scrolling_up = 0352 353 self.set_term_cursor()354 355 def reset(self) -> None:356 """357 Reset the terminal.358 """359 self.escbuf = b""360 self.within_escape = False361 self.parsestate = 0362 363 self.attrspec = None364 self.charset = TermCharset()365 366 self.saved_cursor = None367 self.saved_attrs = None368 369 self.is_rotten_cursor = False370 371 self.reset_scroll()372 373 self.init_tabstops()374 375 # terminal modes376 self.modes.reset()377 378 # initialize self.term379 self.clear()380 381 def init_tabstops(self, extend: bool = False) -> None:382 tablen, mod = divmod(self.width, 8)383 if mod > 0:384 tablen += 1385 386 if extend:387 while len(self.tabstops) < tablen:388 self.tabstops.append(1 << 0)389 else:390 self.tabstops = [1 << 0] * tablen391 392 def set_tabstop(self, x: int | None = None, remove: bool = False, clear: bool = False) -> None:393 if clear:394 for tab in range(len(self.tabstops)):395 self.tabstops[tab] = 0396 return397 398 if x is None:399 x = self.term_cursor[0]400 401 div, mod = divmod(x, 8)402 if remove:403 self.tabstops[div] &= ~(1 << mod)404 else:405 self.tabstops[div] |= 1 << mod406 407 def is_tabstop(self, x: int | None = None) -> bool:408 if x is None:409 x = self.term_cursor[0]410 411 div, mod = divmod(x, 8)412 return (self.tabstops[div] & (1 << mod)) > 0413 414 def empty_line(self, char: bytes = b" ") -> list[tuple[AttrSpec | None, str | None, bytes]]:415 return [self.empty_char(char)] * self.width416 417 def empty_char(self, char: bytes = b" ") -> tuple[AttrSpec | None, str | None, bytes]:418 return (self.attrspec, self.charset.current, char)419 420 def addstr(self, data: Iterable[int]) -> None:421 if self.width <= 0 or self.height <= 0:422 # not displayable, do nothing!423 return424 425 for byte in data:426 self.addbyte(byte)427 428 def resize(self, width: int, height: int) -> None:429 """430 Resize the terminal to the given width and height.431 """432 x, y = self.term_cursor433 434 if width > self.width:435 # grow436 for y in range(self.height):437 self.term[y] += [self.empty_char()] * (width - self.width)438 elif width < self.width:439 # shrink440 for y in range(self.height):441 self.term[y] = self.term[y][:width]442 443 self.width = width444 445 if height > self.height:446 # grow447 for _y in range(self.height, height):448 try:449 last_line = self.scrollback_buffer.pop()450 except IndexError:451 # nothing in scrollback buffer, append an empty line452 self.term.append(self.empty_line())453 self.scrollregion_end += 1454 continue455 456 # adjust x axis of scrollback buffer to the current width457 if (padding := self.width - len(last_line)) > 0:458 last_line += [self.empty_char()] * padding459 else:460 last_line = last_line[: self.width]461 462 self.term.insert(0, last_line)463 elif height < self.height:464 # shrink465 for _y in range(height, self.height):466 self.scrollback_buffer.append(self.term.pop(0))467 468 self.height = height469 470 self.reset_scroll()471 472 x, y = self.constrain_coords(x, y)473 self.set_term_cursor(x, y)474 475 # extend tabs476 self.init_tabstops(extend=True)477 478 def set_g01(self, char: bytes, mod: bytes) -> None:479 """480 Set G0 or G1 according to 'char' and modifier 'mod'.481 """482 if self.modes.main_charset != CHARSET_DEFAULT:483 return484 485 if mod == b"(":486 g = 0487 else:488 g = 1489 490 if char == b"0":491 cset = "vt100"492 elif char == b"U":493 cset = "ibmpc"494 elif char == b"K":495 cset = "user"496 else:497 cset = "default"498 499 self.charset.define(g, cset)500 501 def parse_csi(self, char: bytes) -> None:502 """503 Parse ECMA-48 CSI (Control Sequence Introducer) sequences.504 """505 qmark = self.escbuf.startswith(b"?")506 507 escbuf = []508 for arg in self.escbuf[1 if qmark else 0 :].split(b";"):509 try:510 num = int(arg)511 except ValueError:512 num = None513 514 escbuf.append(num)515 516 if (cmd_ := CSI_COMMANDS[char]) is not None:517 if isinstance(cmd_, CSIAlias):518 csi_cmd: CSICommand = CSI_COMMANDS[cmd_.alias] # type: ignore[assignment]519 elif isinstance(cmd_, CSICommand):520 csi_cmd = cmd_521 elif cmd_[0] == "alias": # fallback, hard deprecated522 csi_cmd = CSI_COMMANDS[CSIAlias(*cmd_).alias]523 else:524 csi_cmd = CSICommand(*cmd_) # fallback, hard deprecated525 526 number_of_args, default_value, cmd = csi_cmd527 while len(escbuf) < number_of_args:528 escbuf.append(default_value)529 for i in range(len(escbuf)):530 if escbuf[i] is None or escbuf[i] == 0:531 escbuf[i] = default_value532 533 with suppress(ValueError):534 cmd(self, escbuf, qmark)535 # ignore commands that don't match the536 # unpacked tuples in CSI_COMMANDS.537 538 def parse_noncsi(self, char: bytes, mod: bytes = b"") -> None:539 """540 Parse escape sequences which are not CSI.541 """542 if mod == b"#" and char == b"8":543 self.decaln()544 elif mod == b"%": # select main character set545 if char == b"@":546 self.modes.main_charset = CHARSET_DEFAULT547 elif char in b"G8":548 # 8 is obsolete and only for backwards compatibility549 self.modes.main_charset = CHARSET_UTF8550 elif mod in {b"(", b")"}: # define G0/G1551 self.set_g01(char, mod)552 elif char == b"M": # reverse line feed553 self.linefeed(reverse=True)554 elif char == b"D": # line feed555 self.linefeed()556 elif char == b"c": # reset terminal557 self.reset()558 elif char == b"E": # newline559 self.newline()560 elif char == b"H": # set tabstop561 self.set_tabstop()562 elif char == b"Z": # DECID563 self.widget.respond(f"{ESC}[?6c")564 elif char == b"7": # save current state565 self.save_cursor(with_attrs=True)566 elif char == b"8": # restore current state567 self.restore_cursor(with_attrs=True)568 569 def parse_osc(self, buf: bytes) -> None:570 """571 Parse operating system command.572 """573 if buf.startswith((b";", b"0;", b"2;")):574 # set window title575 self.widget.set_title(buf.decode().partition(";")[2])576 577 def parse_escape(self, char: bytes) -> None:578 if self.parsestate == 1:579 # within CSI580 if char in CSI_COMMANDS:581 self.parse_csi(char)582 self.parsestate = 0583 elif char in b"0123456789;" or (not self.escbuf and char == b"?"):584 self.escbuf += char585 return586 elif self.parsestate == 0 and char == b"]":587 # start of OSC588 self.escbuf = b""589 self.parsestate = 2590 return591 elif self.parsestate == 2 and char == b"\a":592 # end of OSC593 self.parse_osc(self.escbuf.lstrip(b"0"))594 elif self.parsestate == 2 and self.escbuf[-1:] + char == f"{ESC}\\".encode("iso8859-1"):595 # end of OSC596 self.parse_osc(self.escbuf[:-1].lstrip(b"0"))597 elif self.parsestate == 2 and self.escbuf.startswith(b"P") and len(self.escbuf) == 8:598 # set palette (ESC]Pnrrggbb)599 pass600 elif self.parsestate == 2 and not self.escbuf and char == b"R":601 # reset palette602 pass603 elif self.parsestate == 2:604 self.escbuf += char605 return606 elif self.parsestate == 0 and char == b"[":607 # start of CSI608 self.escbuf = b""609 self.parsestate = 1610 return611 elif self.parsestate == 0 and char in {b"%", b"#", b"(", b")"}:612 # non-CSI sequence613 self.escbuf = char614 self.parsestate = 3615 return616 elif self.parsestate == 3:617 self.parse_noncsi(char, self.escbuf)618 elif char in {b"c", b"D", b"E", b"H", b"M", b"Z", b"7", b"8", b">", b"="}:619 self.parse_noncsi(char)620 621 self.leave_escape()622 623 def leave_escape(self) -> None:624 self.within_escape = False625 self.parsestate = 0626 self.escbuf = b""627 628 def get_utf8_len(self, bytenum: int) -> int:629 """630 Process startbyte and return the number of bytes following it to get a631 valid UTF-8 multibyte sequence.632 633 bytenum -- an integer ordinal634 """635 length = 0636 637 while bytenum & 0x40:638 bytenum <<= 1639 length += 1640 641 return length642 643 def addbyte(self, byte: int) -> None:644 """645 Parse main charset and add the processed byte(s) to the terminal state646 machine.647 648 byte -- an integer ordinal649 """650 if self.modes.main_charset == CHARSET_UTF8 or util.get_encoding() == "utf8":651 if byte >= 0xC0:652 # start multibyte sequence653 self.utf8_eat_bytes = self.get_utf8_len(byte)654 self.utf8_buffer = bytearray([byte])655 return656 if 0x80 <= byte < 0xC0 and self.utf8_eat_bytes is not None:657 if self.utf8_eat_bytes > 1:658 # continue multibyte sequence659 self.utf8_eat_bytes -= 1660 self.utf8_buffer.append(byte)661 return662 663 # end multibyte sequence664 self.utf8_eat_bytes = None665 666 if sequence := (self.utf8_buffer + bytes([byte])).decode("utf-8", "ignore"):667 char = sequence.encode(util.get_encoding(), "replace")668 669 else:670 # invalid multibyte sequence, stop processing671 return672 else:673 self.utf8_eat_bytes = None674 char = bytes([byte])675 else:676 char = bytes([byte])677 678 self.process_char(char)679 680 def process_char(self, char: int | bytes) -> None:681 """682 Process a single character (single- and multi-byte).683 684 char -- a byte string685 """686 x, y = self.term_cursor687 688 if isinstance(char, int):689 char = char.to_bytes(1, "little")690 691 dc = self.modes.display_ctrl692 693 if char == ESC_B and self.parsestate != 2: # escape694 self.within_escape = True695 elif not dc and char == b"\r": # carriage return CR696 self.carriage_return()697 elif not dc and char == b"\x0f": # activate G0698 self.charset.activate(0)699 elif not dc and char == b"\x0e": # activate G1700 self.charset.activate(1)701 elif not dc and char in b"\n\v\f": # line feed LF/VT/FF702 self.linefeed()703 if self.modes.lfnl:704 self.carriage_return()705 elif not dc and char == b"\t": # char tab706 self.tab()707 elif not dc and char == b"\b": # backspace BS708 if x > 0:709 self.set_term_cursor(x - 1, y)710 elif not dc and char == b"\a" and self.parsestate != 2: # BEL711 # we need to check if we're in parsestate 2, as an OSC can be712 # terminated by the BEL character!713 self.widget.beep()714 elif not dc and char in b"\x18\x1a": # CAN/SUB715 self.leave_escape()716 elif not dc and char in b"\x00\x7f": # NUL/DEL717 pass # this is ignored718 elif self.within_escape:719 self.parse_escape(char)720 elif not dc and char == b"\x9b": # CSI (equivalent to "ESC [")721 self.within_escape = True722 self.escbuf = b""723 self.parsestate = 1724 else:725 self.push_cursor(char)726 727 def set_char(self, char: bytes, x: int | None = None, y: int | None = None) -> None:728 """729 Set character of either the current cursor position730 or a position given by 'x' and/or 'y' to 'char'.731 """732 if x is None:733 x = self.term_cursor[0]734 if y is None:735 y = self.term_cursor[1]736 737 x, y = self.constrain_coords(x, y)738 self.term[y][x] = (self.attrspec, self.charset.current, char)739 740 def constrain_coords(self, x: int, y: int, ignore_scrolling: bool = False) -> tuple[int, int]:741 """742 Checks if x/y are within the terminal and returns the corrected version.743 If 'ignore_scrolling' is set, constrain within the full size of the744 screen and not within scrolling region.745 """746 if x >= self.width:747 x = self.width - 1748 elif x < 0:749 x = 0750 751 if self.modes.constrain_scrolling and not ignore_scrolling:752 if y > self.scrollregion_end:753 y = self.scrollregion_end754 elif y < self.scrollregion_start:755 y = self.scrollregion_start756 else: # noqa: PLR5501 # pylint: disable=else-if-used # readability757 if y >= self.height:758 y = self.height - 1759 elif y < 0:760 y = 0761 762 return x, y763 764 def linefeed(self, reverse: bool = False) -> None:765 """766 Move the cursor down (or up if reverse is True) one line but don't reset767 horizontal position.768 """769 x, y = self.term_cursor770 771 if reverse:772 if y <= 0 < self.scrollregion_start:773 pass774 elif y == self.scrollregion_start:775 self.scroll(reverse=True)776 else:777 y -= 1778 else: # noqa: PLR5501 # pylint: disable=else-if-used # readability779 if y >= self.height - 1 > self.scrollregion_end:780 pass781 elif y == self.scrollregion_end:782 self.scroll()783 else:784 y += 1785 786 self.set_term_cursor(x, y)787 788 def carriage_return(self) -> None:789 self.set_term_cursor(0, self.term_cursor[1])790 791 def newline(self) -> None:792 """793 Do a carriage return followed by a line feed.794 """795 self.carriage_return()796 self.linefeed()797 798 def move_cursor(799 self,800 x: int,801 y: int,802 relative_x: bool = False,803 relative_y: bool = False,804 relative: bool = False,805 ) -> None:806 """807 Move cursor to position x/y while constraining terminal sizes.808 If 'relative' is True, x/y is relative to the current cursor809 position. 'relative_x' and 'relative_y' is the same but just with810 the corresponding axis.811 """812 if relative:813 relative_y = relative_x = True814 815 if relative_x:816 x += self.term_cursor[0]817 818 if relative_y:819 y += self.term_cursor[1]820 elif self.modes.constrain_scrolling:821 y += self.scrollregion_start822 823 self.set_term_cursor(x, y)824 825 def push_char(self, char: bytes | None, x: int, y: int) -> None:826 """827 Push one character to current position and advance cursor to x/y.828 """829 if char is not None:830 char = self.charset.apply_mapping(char)831 if self.modes.insert:832 self.insert_chars(char=char)833 else:834 self.set_char(char)835 836 self.set_term_cursor(x, y)837 838 def push_cursor(self, char: bytes | None = None) -> None:839 """840 Move cursor one character forward wrapping lines as needed.841 If 'char' is given, put the character into the former position.842 """843 x, y = self.term_cursor844 845 if self.modes.autowrap:846 if x + 1 >= self.width and not self.is_rotten_cursor:847 # "rotten cursor" - this is when the cursor gets to the rightmost848 # position of the screen, the cursor position remains the same but849 # one last set_char() is allowed for that piece of sh^H^H"border".850 self.is_rotten_cursor = True851 self.push_char(char, x, y)852 else:853 x += 1854 855 if x >= self.width and self.is_rotten_cursor:856 if y >= self.scrollregion_end:857 self.scroll()858 else:859 y += 1860 861 x = 1862 863 self.set_term_cursor(0, y)864 865 self.push_char(char, x, y)866 867 self.is_rotten_cursor = False868 else:869 if x + 1 < self.width:870 x += 1871 872 self.is_rotten_cursor = False873 self.push_char(char, x, y)874 875 def save_cursor(self, with_attrs: bool = False) -> None:876 self.saved_cursor = tuple(self.term_cursor)877 if with_attrs:878 self.saved_attrs = (copy.copy(self.attrspec), copy.copy(self.charset))879 880 def restore_cursor(self, with_attrs: bool = False) -> None:881 if self.saved_cursor is None:882 return883 884 x, y = self.saved_cursor885 self.set_term_cursor(x, y)886 887 if with_attrs and self.saved_attrs is not None:888 self.attrspec, self.charset = (copy.copy(self.saved_attrs[0]), copy.copy(self.saved_attrs[1]))889 890 def tab(self, tabstop: int = 8) -> None:891 """892 Moves cursor to the next 'tabstop' filling everything in between893 with spaces.894 """895 x, y = self.term_cursor896 897 while x < self.width - 1:898 self.set_char(b" ")899 x += 1900 901 if self.is_tabstop(x):902 break903 904 self.is_rotten_cursor = False905 self.set_term_cursor(x, y)906 907 def scroll(self, reverse: bool = False) -> None:908 """909 Append a new line at the bottom and put the topmost line into the910 scrollback buffer.911 912 If reverse is True, do exactly the opposite, but don't save into913 scrollback buffer.914 """915 if reverse:916 self.term.pop(self.scrollregion_end)917 self.term.insert(self.scrollregion_start, self.empty_line())918 else:919 killed = self.term.pop(self.scrollregion_start)920 self.scrollback_buffer.append(killed)921 self.term.insert(self.scrollregion_end, self.empty_line())922 923 def decaln(self) -> None:924 """925 DEC screen alignment test: Fill screen with E's.926 """927 for row in range(self.height):928 self.term[row] = self.empty_line(b"E")929 930 def blank_line(self, row: int) -> None:931 """932 Blank a single line at the specified row, without modifying other lines.933 """934 self.term[row] = self.empty_line()935 936 def insert_chars(937 self,938 position: tuple[int, int] | None = None,939 chars: int = 1,940 char: bytes | None = None,941 ) -> None:942 """943 Insert 'chars' number of either empty characters - or those specified by944 'char' - before 'position' (or the current position if not specified)945 pushing subsequent characters of the line to the right without wrapping.946 """947 if position is None:948 position = self.term_cursor949 950 if chars == 0:951 chars = 1952 953 if char is None:954 char_spec = self.empty_char()955 else:956 char_spec = (self.attrspec, self.charset.current, char)957 958 x, y = position959 960 while chars > 0:961 self.term[y].insert(x, char_spec)962 self.term[y].pop()963 chars -= 1964 965 def remove_chars(self, position: tuple[int, int] | None = None, chars: int = 1) -> None:966 """967 Remove 'chars' number of empty characters from 'position' (or the current968 position if not specified) pulling subsequent characters of the line to969 the left without joining any subsequent lines.970 """971 if position is None:972 position = self.term_cursor973 974 if chars == 0:975 chars = 1976 977 x, y = position978 979 while chars > 0:980 self.term[y].pop(x)981 self.term[y].append(self.empty_char())982 chars -= 1983 984 def insert_lines(self, row: int | None = None, lines: int = 1) -> None:985 """986 Insert 'lines' of empty lines after the specified row, pushing all987 subsequent lines to the bottom. If no 'row' is specified, the current988 row is used.989 """990 if row is None:991 row = self.term_cursor[1]992 else:993 row = self.scrollregion_start994 995 if lines == 0:996 lines = 1997 998 while lines > 0:999 self.term.insert(row, self.empty_line())1000 self.term.pop(self.scrollregion_end)1001 lines -= 11002 1003 def remove_lines(self, row: int | None = None, lines: int = 1) -> None:1004 """1005 Remove 'lines' number of lines at the specified row, pulling all1006 subsequent lines to the top. If no 'row' is specified, the current row1007 is used.1008 """1009 if row is None:1010 row = self.term_cursor[1]1011 else:1012 row = self.scrollregion_start1013 1014 if lines == 0:1015 lines = 11016 1017 while lines > 0:1018 self.term.pop(row)1019 self.term.insert(self.scrollregion_end, self.empty_line())1020 lines -= 11021 1022 def erase(1023 self,1024 start: tuple[int, int] | tuple[int, int, bool],1025 end: tuple[int, int] | tuple[int, int, bool],1026 ) -> None:1027 """1028 Erase a region of the terminal. The 'start' tuple (x, y) defines the1029 starting position of the erase, while end (x, y) the last position.1030 1031 For example if the terminal size is 4x3, start=(1, 1) and end=(1, 2)1032 would erase the following region:1033 1034 ....1035 .XXX1036 XX..1037 """1038 sx, sy = self.constrain_coords(*start)1039 ex, ey = self.constrain_coords(*end)1040 1041 # within a single row1042 if sy == ey:1043 for x in range(sx, ex + 1):1044 self.term[sy][x] = self.empty_char()1045 return1046 1047 # spans multiple rows1048 y = sy1049 while y <= ey:1050 if y == sy:1051 for x in range(sx, self.width):1052 self.term[y][x] = self.empty_char()1053 elif y == ey:1054 for x in range(ex + 1):1055 self.term[y][x] = self.empty_char()1056 else:1057 self.blank_line(y)1058 1059 y += 11060 1061 def sgi_to_attrspec(1062 self,1063 attrs: Sequence[int],1064 fg: int,1065 bg: int,1066 attributes: set[str],1067 prev_colors: int,1068 ) -> AttrSpec | None:1069 """1070 Parse SGI sequence and return an AttrSpec representing the sequence1071 including all earlier sequences specified as 'fg', 'bg' and1072 'attributes'.1073 """1074 1075 idx = 01076 colors = prev_colors1077 1078 while idx < len(attrs):1079 attr = attrs[idx]1080 if 30 <= attr <= 37:1081 fg = attr - 301082 colors = max(16, colors)1083 elif 40 <= attr <= 47:1084 bg = attr - 401085 colors = max(16, colors)1086 # AIXTERM bright color spec1087 # https://en.wikipedia.org/wiki/ANSI_escape_code1088 elif 90 <= attr <= 97:1089 fg = attr - 90 + 81090 colors = max(16, colors)1091 elif 100 <= attr <= 107:1092 bg = attr - 100 + 81093 colors = max(16, colors)1094 elif attr in {38, 48}:1095 if idx + 2 < len(attrs) and attrs[idx + 1] == 5:1096 # 8 bit color specification1097 color = attrs[idx + 2]1098 colors = max(256, colors)1099 if attr == 38:1100 fg = color1101 else:1102 bg = color1103 idx += 21104 elif idx + 4 < len(attrs) and attrs[idx + 1] == 2:1105 # 24 bit color specification1106 color = (attrs[idx + 2] << 16) + (attrs[idx + 3] << 8) + attrs[idx + 4]1107 colors = 2**241108 if attr == 38:1109 fg = color1110 else:1111 bg = color1112 idx += 41113 elif attr == 39:1114 # set default foreground color1115 fg = None1116 elif attr == 49:1117 # set default background color1118 bg = None1119 elif attr == 10:1120 self.charset.reset_sgr_ibmpc()1121 self.modes.display_ctrl = False1122 elif attr in {11, 12}:1123 self.charset.set_sgr_ibmpc()1124 self.modes.display_ctrl = True1125 1126 # set attributes1127 elif attr == 1:1128 attributes.add("bold")1129 elif attr == 4:1130 attributes.add("underline")1131 elif attr == 5:1132 attributes.add("blink")1133 elif attr == 7:1134 attributes.add("standout")1135 1136 # unset attributes1137 elif attr == 24:1138 attributes.discard("underline")1139 elif attr == 25:1140 attributes.discard("blink")1141 elif attr == 27:1142 attributes.discard("standout")1143 elif attr == 0:1144 # clear all attributes1145 fg = bg = None1146 attributes.clear()1147 1148 idx += 11149 1150 if "bold" in attributes and colors == 16 and fg is not None and fg < 8:1151 fg += 81152 1153 def _defaulter(color: int | None, colors: int) -> str:1154 if color is None:1155 return "default"1156 # Note: we can't detect 88 color mode1157 if color > 255 or colors == 2**24:1158 return _color_desc_true(color)1159 if color > 15 or colors == 256:1160 return _color_desc_256(color)1161 return _BASIC_COLORS[color]1162 1163 decoded_fg = _defaulter(fg, colors)1164 decoded_bg = _defaulter(bg, colors)1165 1166 if attributes:1167 decoded_fg = ",".join((decoded_fg, *list(attributes)))1168 1169 if decoded_fg == decoded_bg == "default":1170 return None1171 1172 if colors:1173 return AttrSpec(decoded_fg, decoded_bg, colors=colors)1174 1175 return AttrSpec(decoded_fg, decoded_bg)1176 1177 def csi_set_attr(self, attrs: Sequence[int]) -> None:1178 """1179 Set graphics rendition.1180 """1181 if attrs[-1] == 0:1182 self.attrspec = None1183 1184 attributes = set()1185 if self.attrspec is None:1186 fg = bg = None1187 else:1188 # set default values from previous attrspec1189 if "default" in self.attrspec.foreground:1190 fg = None1191 else:1192 fg = self.attrspec.foreground_number1193 if fg >= 8 and self.attrspec.colors == 16:1194 fg -= 81195 1196 if "default" in self.attrspec.background:1197 bg = None1198 else:1199 bg = self.attrspec.background_number1200 if bg >= 8 and self.attrspec.colors == 16: