pytermgui.terminal
This module houses the Terminal
class, and its provided instance.
1"""This module houses the `Terminal` class, and its provided instance.""" 2 3# pylint: disable=cyclic-import 4 5from __future__ import annotations 6 7import errno 8import os 9import signal 10import sys 11import time 12from contextlib import contextmanager 13from datetime import datetime 14from enum import Enum 15from functools import cached_property 16from shutil import get_terminal_size 17from typing import TYPE_CHECKING, Any, Callable, Generator, TextIO 18 19from .input import getch_timeout 20from .regex import RE_PIXEL_SIZE, has_open_sequence, real_length, strip_ansi 21 22if TYPE_CHECKING: 23 from .fancy_repr import FancyYield 24 25__all__ = [ 26 "terminal", 27 "set_global_terminal", 28 "get_terminal", 29 "Terminal", 30 "Recorder", 31 "ColorSystem", 32] 33 34 35class Recorder: 36 """A class that records & exports terminal content.""" 37 38 def __init__(self) -> None: 39 """Initializes the Recorder.""" 40 41 self.recording: list[tuple[str, float]] = [] 42 self._start_stamp = time.time() 43 44 @property 45 def _content(self) -> str: 46 """Returns the str part of self._recording""" 47 48 return "".join(data for data, _ in self.recording) 49 50 def write(self, data: str) -> None: 51 """Writes to the recorder.""" 52 53 self.recording.append((data, time.time() - self._start_stamp)) 54 55 def export_text(self) -> str: 56 """Exports current content as plain text.""" 57 58 return strip_ansi(self._content) 59 60 def export_html( 61 self, prefix: str | None = None, inline_styles: bool = False 62 ) -> str: 63 """Exports current content as HTML. 64 65 For help on the arguments, see `pytermgui.html.to_html`. 66 """ 67 68 from .exporters import to_html # pylint: disable=import-outside-toplevel 69 70 return to_html(self._content, prefix=prefix, inline_styles=inline_styles) 71 72 def export_svg( 73 self, 74 prefix: str | None = None, 75 inline_styles: bool = False, 76 title: str = "PyTermGUI", 77 chrome: bool = True, 78 ) -> str: 79 """Exports current content as SVG. 80 81 For help on the arguments, see `pytermgui.html.to_svg`. 82 """ 83 84 from .exporters import to_svg # pylint: disable=import-outside-toplevel 85 86 return to_svg( 87 self._content, 88 prefix=prefix, 89 inline_styles=inline_styles, 90 title=title, 91 chrome=chrome, 92 ) 93 94 def save_plain(self, filename: str) -> None: 95 """Exports plain text content to the given file. 96 97 Args: 98 filename: The file to save to. 99 """ 100 101 with open(filename, "w", encoding="utf-8") as file: 102 file.write(self.export_text()) 103 104 def save_html( 105 self, 106 filename: str | None = None, 107 prefix: str | None = None, 108 inline_styles: bool = False, 109 ) -> None: 110 """Exports HTML content to the given file. 111 112 For help on the arguments, see `pytermgui.exporters.to_html`. 113 114 Args: 115 filename: The file to save to. If the filename does not contain the '.html' 116 extension it will be appended to the end. 117 """ 118 119 if filename is None: 120 filename = f"PTG_{time.time():%Y-%m-%d %H:%M:%S}.html" 121 122 if not filename.endswith(".html"): 123 filename += ".html" 124 125 with open(filename, "w", encoding="utf-8") as file: 126 file.write(self.export_html(prefix=prefix, inline_styles=inline_styles)) 127 128 def save_svg( # pylint: disable=too-many-arguments 129 self, 130 filename: str | None = None, 131 prefix: str | None = None, 132 chrome: bool = True, 133 inline_styles: bool = False, 134 title: str = "PyTermGUI", 135 ) -> None: 136 """Exports SVG content to the given file. 137 138 For help on the arguments, see `pytermgui.exporters.to_svg`. 139 140 Args: 141 filename: The file to save to. If the filename does not contain the '.svg' 142 extension it will be appended to the end. 143 """ 144 145 if filename is None: 146 timeval = datetime.now() 147 filename = f"PTG_{timeval:%Y-%m-%d_%H:%M:%S}.svg" 148 149 if not filename.endswith(".svg"): 150 filename += ".svg" 151 152 with open(filename, "w", encoding="utf-8") as file: 153 file.write( 154 self.export_svg( 155 prefix=prefix, 156 inline_styles=inline_styles, 157 title=title, 158 chrome=chrome, 159 ) 160 ) 161 162 163class ColorSystem(Enum): 164 """An enumeration of various terminal-supported colorsystems.""" 165 166 NO_COLOR = -1 167 """No-color terminal. See https://no-color.org/.""" 168 169 STANDARD = 0 170 """Standard 3-bit colorsystem of the basic 16 colors.""" 171 172 EIGHT_BIT = 1 173 """xterm 8-bit colors, 0-256.""" 174 175 TRUE = 2 176 """'True' color, a.k.a. 24-bit RGB colors.""" 177 178 def __ge__(self, other): 179 """Comparison: self >= other.""" 180 181 if self.__class__ is other.__class__: 182 return self.value >= other.value 183 184 return NotImplemented 185 186 def __gt__(self, other): 187 """Comparison: self > other.""" 188 189 if self.__class__ is other.__class__: 190 return self.value > other.value 191 192 return NotImplemented 193 194 def __le__(self, other): 195 """Comparison: self <= other.""" 196 197 if self.__class__ is other.__class__: 198 return self.value <= other.value 199 200 return NotImplemented 201 202 def __lt__(self, other): 203 """Comparison: self < other.""" 204 205 if self.__class__ is other.__class__: 206 return self.value < other.value 207 208 return NotImplemented 209 210 211def _get_env_colorsys() -> ColorSystem | None: 212 """Gets a colorsystem if the `PTG_COLOR_SYSTEM` env var can be linked to one.""" 213 214 colorsys = os.getenv("PTG_COLOR_SYSTEM") 215 if colorsys is None: 216 return None 217 218 try: 219 return ColorSystem[colorsys] 220 221 except NameError: 222 return None 223 224 225class Terminal: # pylint: disable=too-many-instance-attributes 226 """A class to store & access data about a terminal.""" 227 228 RESIZE = 0 229 """Event sent out when the terminal has been resized. 230 231 Arguments passed: 232 - New size: tuple[int, int] 233 """ 234 235 margins = [0, 0, 0, 0] 236 """Not quite sure what this does at the moment.""" 237 238 displayhook_installed: bool = False 239 """This is set to True when `pretty.install` is called.""" 240 241 origin: tuple[int, int] = (1, 1) 242 """Origin of the internal coordinate system.""" 243 244 def __init__( 245 self, 246 stream: TextIO | None = None, 247 *, 248 size: tuple[int, int] | None = None, 249 ) -> None: 250 """Initialize `Terminal` class.""" 251 252 if stream is None: 253 stream = sys.stdout 254 255 self._size = size 256 self._stream = stream or sys.stdout 257 258 self._recorder: Recorder | None = None 259 260 self.size: tuple[int, int] = self._get_size() 261 self.forced_colorsystem: ColorSystem | None = _get_env_colorsys() 262 263 self._listeners: dict[int, list[Callable[..., Any]]] = {} 264 265 if hasattr(signal, "SIGWINCH"): 266 signal.signal(signal.SIGWINCH, self._update_size) 267 else: 268 from threading import Thread # pylint: disable=import-outside-toplevel 269 270 Thread( 271 name="windows_terminal_resize", 272 target=self._window_terminal_resize, 273 daemon=True, 274 ).start() 275 276 self._diff_buffer = [ 277 ["" for _ in range(self.width)] for y in range(self.height) 278 ] 279 280 def _window_terminal_resize(self): 281 from time import sleep # pylint: disable=import-outside-toplevel 282 283 _previous = get_terminal_size() 284 while True: 285 _next = get_terminal_size() 286 if _previous != _next: 287 self._update_size() 288 _previous = _next 289 sleep(0.001) 290 291 def __fancy_repr__(self) -> Generator[FancyYield, None, None]: 292 """Returns a cool looking repr.""" 293 294 name = type(self).__name__ 295 296 yield f"<{name} stream={self._stream} size={self.size}>" 297 298 @cached_property 299 def resolution(self) -> tuple[int, int]: 300 """Returns the terminal's pixel based resolution. 301 302 Only evaluated on demand. 303 """ 304 305 if self.isatty(): 306 sys.stdout.write("\x1b[14t") 307 sys.stdout.flush() 308 309 # Some terminals may not respond to a pixel size query, so we send 310 # a timed-out getch call with a default response of 1280x720. 311 output = getch_timeout(0.1, default="\x1b[4;720;1280t") 312 match = RE_PIXEL_SIZE.match(output) 313 314 if match is not None: 315 return (int(match[2]), int(match[1])) 316 317 return (0, 0) 318 319 @property 320 def pixel_size(self) -> tuple[int, int]: 321 """DEPRECATED: Returns the terminal's pixel resolution. 322 323 Prefer terminal.resolution. 324 """ 325 326 return self.resolution 327 328 def _call_listener(self, event: int, data: Any) -> None: 329 """Calls callbacks for event. 330 331 Args: 332 event: A terminal event. 333 data: Arbitrary data passed to the callback. 334 """ 335 336 if event in self._listeners: 337 for callback in self._listeners[event]: 338 callback(data) 339 340 def _get_size(self) -> tuple[int, int]: 341 """Gets the screen size with origin substracted.""" 342 343 if self._size is not None: 344 return self._size 345 346 size = get_terminal_size() 347 return (size[0], size[1]) 348 349 def _update_size(self, *_: Any) -> None: 350 """Resize terminal when SIGWINCH occurs, and call listeners.""" 351 352 if hasattr(self, "resolution"): 353 del self.resolution 354 355 self.size = self._get_size() 356 357 self._call_listener(self.RESIZE, self.size) 358 359 # Wipe the screen in case anything got messed up 360 self.write("\x1b[2J") 361 362 @property 363 def width(self) -> int: 364 """Gets the current width of the terminal.""" 365 366 return self.size[0] 367 368 @property 369 def height(self) -> int: 370 """Gets the current height of the terminal.""" 371 372 return self.size[1] 373 374 @staticmethod 375 def is_interactive() -> bool: 376 """Determines whether shell is interactive. 377 378 A shell is interactive if it is run from `python3` or `python3 -i`. 379 """ 380 381 return hasattr(sys, "ps1") 382 383 @property 384 def forced_colorsystem(self) -> ColorSystem | None: 385 """Forces a color system type on this terminal.""" 386 387 return self._forced_colorsystem 388 389 @forced_colorsystem.setter 390 def forced_colorsystem(self, new: ColorSystem | None) -> None: 391 """Sets a colorsystem, clears colorsystem cache.""" 392 393 self._forced_colorsystem = new 394 395 @property 396 def colorsystem(self) -> ColorSystem: 397 """Gets the current terminal's supported color system.""" 398 399 if self.forced_colorsystem is not None: 400 return self.forced_colorsystem 401 402 if os.getenv("NO_COLOR") is not None: 403 return ColorSystem.NO_COLOR 404 405 term = os.getenv("TERM", "") 406 color_term = os.getenv("COLORTERM", "").strip().lower() 407 408 if color_term == "": 409 color_term = term.split("xterm-")[-1] 410 411 if color_term in ["24bit", "truecolor"]: 412 return ColorSystem.TRUE 413 414 if color_term == "256color": 415 return ColorSystem.EIGHT_BIT 416 417 return ColorSystem.STANDARD 418 419 @contextmanager 420 def record(self) -> Generator[Recorder, None, None]: 421 """Records the terminal's stream.""" 422 423 if self._recorder is not None: 424 raise RuntimeError(f"{self!r} is already recording.") 425 426 try: 427 self._recorder = Recorder() 428 yield self._recorder 429 430 finally: 431 self._recorder = None 432 433 @contextmanager 434 def no_record(self) -> Generator[None, None, None]: 435 """Pauses recording for the duration of the context.""" 436 437 recorder = self._recorder 438 439 try: 440 self._recorder = None 441 yield 442 443 finally: 444 self._recorder = recorder 445 446 @staticmethod 447 def isatty() -> bool: 448 """Returns whether sys.stdin is a tty.""" 449 450 return sys.stdin.isatty() 451 452 def replay(self, recorder: Recorder) -> None: 453 """Replays a recording.""" 454 455 last_time = 0.0 456 for data, delay in recorder.recording: 457 if last_time > 0.0: 458 time.sleep(delay - last_time) 459 460 self.write(data, flush=True) 461 last_time = delay 462 463 def subscribe(self, event: int, callback: Callable[..., Any]) -> None: 464 """Subcribes a callback to be called when event occurs. 465 466 Args: 467 event: The terminal event that calls callback. 468 callback: The callable to be called. The signature of this 469 callable is dependent on the event. See the documentation 470 of the specific event for more information. 471 """ 472 473 if not event in self._listeners: 474 self._listeners[event] = [] 475 476 self._listeners[event].append(callback) 477 478 def write( 479 self, 480 data: str, 481 pos: tuple[int, int] | None = None, 482 flush: bool = False, 483 slice_too_long: bool = True, 484 ) -> None: 485 """Writes the given data to the terminal's stream. 486 487 Args: 488 data: The data to write. 489 pos: Terminal-character space position to write the data to, (x, y). 490 flush: If set, `flush` will be called on the stream after reading. 491 slice_too_long: If set, lines that are outside of the terminal will be 492 sliced to fit. Involves a sizable performance hit. 493 """ 494 495 def _slice(line: str, maximum: int) -> str: 496 length = 0 497 sliced = "" 498 for char in line: 499 sliced += char 500 if char == "\x1b": 501 continue 502 503 if ( 504 length > maximum 505 and real_length(sliced) > maximum 506 and not has_open_sequence(sliced) 507 ): 508 break 509 510 length += 1 511 512 return sliced 513 514 if "\x1b[2J" in data: 515 self.clear_stream() 516 517 if pos is not None: 518 xpos, ypos = pos 519 520 if slice_too_long: 521 if not self.height + self.origin[1] + 1 > ypos >= 0: 522 return 523 524 maximum = self.width - xpos + self.origin[0] 525 526 if xpos < self.origin[0]: 527 xpos = self.origin[0] 528 529 sliced = _slice(data, maximum) if len(data) > maximum else data 530 531 data = f"\x1b[{ypos};{xpos}H{sliced}\x1b[0m" 532 533 else: 534 data = f"\x1b[{ypos};{xpos}H{data}" 535 536 self._stream.write(data) 537 538 if self._recorder is not None: 539 self._recorder.write(data) 540 541 if flush: 542 self._stream.flush() 543 544 def clear_stream(self) -> None: 545 """Clears (truncates) the terminal's stream.""" 546 547 try: 548 self._stream.truncate(0) 549 550 except OSError as error: 551 if error.errno != errno.EINVAL and os.name != "nt": 552 raise 553 554 self._stream.write("\x1b[2J") 555 556 def print( 557 self, 558 *items, 559 pos: tuple[int, int] | None = None, 560 sep: str = " ", 561 end="\n", 562 flush: bool = True, 563 ) -> None: 564 """Prints items to the stream. 565 566 All arguments not mentioned here are analogous to `print`. 567 568 Args: 569 pos: Terminal-character space position to write the data to, (x, y). 570 571 """ 572 573 self.write(sep.join(map(str, items)) + end, pos=pos, flush=flush) 574 575 def flush(self) -> None: 576 """Flushes self._stream.""" 577 578 self._stream.flush() 579 580 581terminal = Terminal() # pylint: disable=invalid-name 582"""Terminal instance that should be used pretty much always.""" 583 584 585def set_global_terminal(new: Terminal) -> None: 586 """Sets the terminal instance to be used by the module.""" 587 588 globals()["terminal"] = new 589 590 591def get_terminal() -> Terminal: 592 """Gets the default terminal instance used by the module.""" 593 594 return terminal
Terminal instance that should be used pretty much always.
586def set_global_terminal(new: Terminal) -> None: 587 """Sets the terminal instance to be used by the module.""" 588 589 globals()["terminal"] = new
Sets the terminal instance to be used by the module.
592def get_terminal() -> Terminal: 593 """Gets the default terminal instance used by the module.""" 594 595 return terminal
Gets the default terminal instance used by the module.
226class Terminal: # pylint: disable=too-many-instance-attributes 227 """A class to store & access data about a terminal.""" 228 229 RESIZE = 0 230 """Event sent out when the terminal has been resized. 231 232 Arguments passed: 233 - New size: tuple[int, int] 234 """ 235 236 margins = [0, 0, 0, 0] 237 """Not quite sure what this does at the moment.""" 238 239 displayhook_installed: bool = False 240 """This is set to True when `pretty.install` is called.""" 241 242 origin: tuple[int, int] = (1, 1) 243 """Origin of the internal coordinate system.""" 244 245 def __init__( 246 self, 247 stream: TextIO | None = None, 248 *, 249 size: tuple[int, int] | None = None, 250 ) -> None: 251 """Initialize `Terminal` class.""" 252 253 if stream is None: 254 stream = sys.stdout 255 256 self._size = size 257 self._stream = stream or sys.stdout 258 259 self._recorder: Recorder | None = None 260 261 self.size: tuple[int, int] = self._get_size() 262 self.forced_colorsystem: ColorSystem | None = _get_env_colorsys() 263 264 self._listeners: dict[int, list[Callable[..., Any]]] = {} 265 266 if hasattr(signal, "SIGWINCH"): 267 signal.signal(signal.SIGWINCH, self._update_size) 268 else: 269 from threading import Thread # pylint: disable=import-outside-toplevel 270 271 Thread( 272 name="windows_terminal_resize", 273 target=self._window_terminal_resize, 274 daemon=True, 275 ).start() 276 277 self._diff_buffer = [ 278 ["" for _ in range(self.width)] for y in range(self.height) 279 ] 280 281 def _window_terminal_resize(self): 282 from time import sleep # pylint: disable=import-outside-toplevel 283 284 _previous = get_terminal_size() 285 while True: 286 _next = get_terminal_size() 287 if _previous != _next: 288 self._update_size() 289 _previous = _next 290 sleep(0.001) 291 292 def __fancy_repr__(self) -> Generator[FancyYield, None, None]: 293 """Returns a cool looking repr.""" 294 295 name = type(self).__name__ 296 297 yield f"<{name} stream={self._stream} size={self.size}>" 298 299 @cached_property 300 def resolution(self) -> tuple[int, int]: 301 """Returns the terminal's pixel based resolution. 302 303 Only evaluated on demand. 304 """ 305 306 if self.isatty(): 307 sys.stdout.write("\x1b[14t") 308 sys.stdout.flush() 309 310 # Some terminals may not respond to a pixel size query, so we send 311 # a timed-out getch call with a default response of 1280x720. 312 output = getch_timeout(0.1, default="\x1b[4;720;1280t") 313 match = RE_PIXEL_SIZE.match(output) 314 315 if match is not None: 316 return (int(match[2]), int(match[1])) 317 318 return (0, 0) 319 320 @property 321 def pixel_size(self) -> tuple[int, int]: 322 """DEPRECATED: Returns the terminal's pixel resolution. 323 324 Prefer terminal.resolution. 325 """ 326 327 return self.resolution 328 329 def _call_listener(self, event: int, data: Any) -> None: 330 """Calls callbacks for event. 331 332 Args: 333 event: A terminal event. 334 data: Arbitrary data passed to the callback. 335 """ 336 337 if event in self._listeners: 338 for callback in self._listeners[event]: 339 callback(data) 340 341 def _get_size(self) -> tuple[int, int]: 342 """Gets the screen size with origin substracted.""" 343 344 if self._size is not None: 345 return self._size 346 347 size = get_terminal_size() 348 return (size[0], size[1]) 349 350 def _update_size(self, *_: Any) -> None: 351 """Resize terminal when SIGWINCH occurs, and call listeners.""" 352 353 if hasattr(self, "resolution"): 354 del self.resolution 355 356 self.size = self._get_size() 357 358 self._call_listener(self.RESIZE, self.size) 359 360 # Wipe the screen in case anything got messed up 361 self.write("\x1b[2J") 362 363 @property 364 def width(self) -> int: 365 """Gets the current width of the terminal.""" 366 367 return self.size[0] 368 369 @property 370 def height(self) -> int: 371 """Gets the current height of the terminal.""" 372 373 return self.size[1] 374 375 @staticmethod 376 def is_interactive() -> bool: 377 """Determines whether shell is interactive. 378 379 A shell is interactive if it is run from `python3` or `python3 -i`. 380 """ 381 382 return hasattr(sys, "ps1") 383 384 @property 385 def forced_colorsystem(self) -> ColorSystem | None: 386 """Forces a color system type on this terminal.""" 387 388 return self._forced_colorsystem 389 390 @forced_colorsystem.setter 391 def forced_colorsystem(self, new: ColorSystem | None) -> None: 392 """Sets a colorsystem, clears colorsystem cache.""" 393 394 self._forced_colorsystem = new 395 396 @property 397 def colorsystem(self) -> ColorSystem: 398 """Gets the current terminal's supported color system.""" 399 400 if self.forced_colorsystem is not None: 401 return self.forced_colorsystem 402 403 if os.getenv("NO_COLOR") is not None: 404 return ColorSystem.NO_COLOR 405 406 term = os.getenv("TERM", "") 407 color_term = os.getenv("COLORTERM", "").strip().lower() 408 409 if color_term == "": 410 color_term = term.split("xterm-")[-1] 411 412 if color_term in ["24bit", "truecolor"]: 413 return ColorSystem.TRUE 414 415 if color_term == "256color": 416 return ColorSystem.EIGHT_BIT 417 418 return ColorSystem.STANDARD 419 420 @contextmanager 421 def record(self) -> Generator[Recorder, None, None]: 422 """Records the terminal's stream.""" 423 424 if self._recorder is not None: 425 raise RuntimeError(f"{self!r} is already recording.") 426 427 try: 428 self._recorder = Recorder() 429 yield self._recorder 430 431 finally: 432 self._recorder = None 433 434 @contextmanager 435 def no_record(self) -> Generator[None, None, None]: 436 """Pauses recording for the duration of the context.""" 437 438 recorder = self._recorder 439 440 try: 441 self._recorder = None 442 yield 443 444 finally: 445 self._recorder = recorder 446 447 @staticmethod 448 def isatty() -> bool: 449 """Returns whether sys.stdin is a tty.""" 450 451 return sys.stdin.isatty() 452 453 def replay(self, recorder: Recorder) -> None: 454 """Replays a recording.""" 455 456 last_time = 0.0 457 for data, delay in recorder.recording: 458 if last_time > 0.0: 459 time.sleep(delay - last_time) 460 461 self.write(data, flush=True) 462 last_time = delay 463 464 def subscribe(self, event: int, callback: Callable[..., Any]) -> None: 465 """Subcribes a callback to be called when event occurs. 466 467 Args: 468 event: The terminal event that calls callback. 469 callback: The callable to be called. The signature of this 470 callable is dependent on the event. See the documentation 471 of the specific event for more information. 472 """ 473 474 if not event in self._listeners: 475 self._listeners[event] = [] 476 477 self._listeners[event].append(callback) 478 479 def write( 480 self, 481 data: str, 482 pos: tuple[int, int] | None = None, 483 flush: bool = False, 484 slice_too_long: bool = True, 485 ) -> None: 486 """Writes the given data to the terminal's stream. 487 488 Args: 489 data: The data to write. 490 pos: Terminal-character space position to write the data to, (x, y). 491 flush: If set, `flush` will be called on the stream after reading. 492 slice_too_long: If set, lines that are outside of the terminal will be 493 sliced to fit. Involves a sizable performance hit. 494 """ 495 496 def _slice(line: str, maximum: int) -> str: 497 length = 0 498 sliced = "" 499 for char in line: 500 sliced += char 501 if char == "\x1b": 502 continue 503 504 if ( 505 length > maximum 506 and real_length(sliced) > maximum 507 and not has_open_sequence(sliced) 508 ): 509 break 510 511 length += 1 512 513 return sliced 514 515 if "\x1b[2J" in data: 516 self.clear_stream() 517 518 if pos is not None: 519 xpos, ypos = pos 520 521 if slice_too_long: 522 if not self.height + self.origin[1] + 1 > ypos >= 0: 523 return 524 525 maximum = self.width - xpos + self.origin[0] 526 527 if xpos < self.origin[0]: 528 xpos = self.origin[0] 529 530 sliced = _slice(data, maximum) if len(data) > maximum else data 531 532 data = f"\x1b[{ypos};{xpos}H{sliced}\x1b[0m" 533 534 else: 535 data = f"\x1b[{ypos};{xpos}H{data}" 536 537 self._stream.write(data) 538 539 if self._recorder is not None: 540 self._recorder.write(data) 541 542 if flush: 543 self._stream.flush() 544 545 def clear_stream(self) -> None: 546 """Clears (truncates) the terminal's stream.""" 547 548 try: 549 self._stream.truncate(0) 550 551 except OSError as error: 552 if error.errno != errno.EINVAL and os.name != "nt": 553 raise 554 555 self._stream.write("\x1b[2J") 556 557 def print( 558 self, 559 *items, 560 pos: tuple[int, int] | None = None, 561 sep: str = " ", 562 end="\n", 563 flush: bool = True, 564 ) -> None: 565 """Prints items to the stream. 566 567 All arguments not mentioned here are analogous to `print`. 568 569 Args: 570 pos: Terminal-character space position to write the data to, (x, y). 571 572 """ 573 574 self.write(sep.join(map(str, items)) + end, pos=pos, flush=flush) 575 576 def flush(self) -> None: 577 """Flushes self._stream.""" 578 579 self._stream.flush()
A class to store & access data about a terminal.
245 def __init__( 246 self, 247 stream: TextIO | None = None, 248 *, 249 size: tuple[int, int] | None = None, 250 ) -> None: 251 """Initialize `Terminal` class.""" 252 253 if stream is None: 254 stream = sys.stdout 255 256 self._size = size 257 self._stream = stream or sys.stdout 258 259 self._recorder: Recorder | None = None 260 261 self.size: tuple[int, int] = self._get_size() 262 self.forced_colorsystem: ColorSystem | None = _get_env_colorsys() 263 264 self._listeners: dict[int, list[Callable[..., Any]]] = {} 265 266 if hasattr(signal, "SIGWINCH"): 267 signal.signal(signal.SIGWINCH, self._update_size) 268 else: 269 from threading import Thread # pylint: disable=import-outside-toplevel 270 271 Thread( 272 name="windows_terminal_resize", 273 target=self._window_terminal_resize, 274 daemon=True, 275 ).start() 276 277 self._diff_buffer = [ 278 ["" for _ in range(self.width)] for y in range(self.height) 279 ]
Initialize Terminal
class.
Event sent out when the terminal has been resized.
Arguments passed:
- New size: tuple[int, int]
Forces a color system type on this terminal.
Returns the terminal's pixel based resolution.
Only evaluated on demand.
DEPRECATED: Returns the terminal's pixel resolution.
Prefer terminal.resolution.
375 @staticmethod 376 def is_interactive() -> bool: 377 """Determines whether shell is interactive. 378 379 A shell is interactive if it is run from `python3` or `python3 -i`. 380 """ 381 382 return hasattr(sys, "ps1")
Determines whether shell is interactive.
A shell is interactive if it is run from python3
or python3 -i
.
420 @contextmanager 421 def record(self) -> Generator[Recorder, None, None]: 422 """Records the terminal's stream.""" 423 424 if self._recorder is not None: 425 raise RuntimeError(f"{self!r} is already recording.") 426 427 try: 428 self._recorder = Recorder() 429 yield self._recorder 430 431 finally: 432 self._recorder = None
Records the terminal's stream.
434 @contextmanager 435 def no_record(self) -> Generator[None, None, None]: 436 """Pauses recording for the duration of the context.""" 437 438 recorder = self._recorder 439 440 try: 441 self._recorder = None 442 yield 443 444 finally: 445 self._recorder = recorder
Pauses recording for the duration of the context.
447 @staticmethod 448 def isatty() -> bool: 449 """Returns whether sys.stdin is a tty.""" 450 451 return sys.stdin.isatty()
Returns whether sys.stdin is a tty.
453 def replay(self, recorder: Recorder) -> None: 454 """Replays a recording.""" 455 456 last_time = 0.0 457 for data, delay in recorder.recording: 458 if last_time > 0.0: 459 time.sleep(delay - last_time) 460 461 self.write(data, flush=True) 462 last_time = delay
Replays a recording.
464 def subscribe(self, event: int, callback: Callable[..., Any]) -> None: 465 """Subcribes a callback to be called when event occurs. 466 467 Args: 468 event: The terminal event that calls callback. 469 callback: The callable to be called. The signature of this 470 callable is dependent on the event. See the documentation 471 of the specific event for more information. 472 """ 473 474 if not event in self._listeners: 475 self._listeners[event] = [] 476 477 self._listeners[event].append(callback)
Subcribes a callback to be called when event occurs.
Args
- event: The terminal event that calls callback.
- callback: The callable to be called. The signature of this callable is dependent on the event. See the documentation of the specific event for more information.
479 def write( 480 self, 481 data: str, 482 pos: tuple[int, int] | None = None, 483 flush: bool = False, 484 slice_too_long: bool = True, 485 ) -> None: 486 """Writes the given data to the terminal's stream. 487 488 Args: 489 data: The data to write. 490 pos: Terminal-character space position to write the data to, (x, y). 491 flush: If set, `flush` will be called on the stream after reading. 492 slice_too_long: If set, lines that are outside of the terminal will be 493 sliced to fit. Involves a sizable performance hit. 494 """ 495 496 def _slice(line: str, maximum: int) -> str: 497 length = 0 498 sliced = "" 499 for char in line: 500 sliced += char 501 if char == "\x1b": 502 continue 503 504 if ( 505 length > maximum 506 and real_length(sliced) > maximum 507 and not has_open_sequence(sliced) 508 ): 509 break 510 511 length += 1 512 513 return sliced 514 515 if "\x1b[2J" in data: 516 self.clear_stream() 517 518 if pos is not None: 519 xpos, ypos = pos 520 521 if slice_too_long: 522 if not self.height + self.origin[1] + 1 > ypos >= 0: 523 return 524 525 maximum = self.width - xpos + self.origin[0] 526 527 if xpos < self.origin[0]: 528 xpos = self.origin[0] 529 530 sliced = _slice(data, maximum) if len(data) > maximum else data 531 532 data = f"\x1b[{ypos};{xpos}H{sliced}\x1b[0m" 533 534 else: 535 data = f"\x1b[{ypos};{xpos}H{data}" 536 537 self._stream.write(data) 538 539 if self._recorder is not None: 540 self._recorder.write(data) 541 542 if flush: 543 self._stream.flush()
Writes the given data to the terminal's stream.
Args
- data: The data to write.
- pos: Terminal-character space position to write the data to, (x, y).
- flush: If set,
flush
will be called on the stream after reading. - slice_too_long: If set, lines that are outside of the terminal will be sliced to fit. Involves a sizable performance hit.
545 def clear_stream(self) -> None: 546 """Clears (truncates) the terminal's stream.""" 547 548 try: 549 self._stream.truncate(0) 550 551 except OSError as error: 552 if error.errno != errno.EINVAL and os.name != "nt": 553 raise 554 555 self._stream.write("\x1b[2J")
Clears (truncates) the terminal's stream.
557 def print( 558 self, 559 *items, 560 pos: tuple[int, int] | None = None, 561 sep: str = " ", 562 end="\n", 563 flush: bool = True, 564 ) -> None: 565 """Prints items to the stream. 566 567 All arguments not mentioned here are analogous to `print`. 568 569 Args: 570 pos: Terminal-character space position to write the data to, (x, y). 571 572 """ 573 574 self.write(sep.join(map(str, items)) + end, pos=pos, flush=flush)
Prints items to the stream.
All arguments not mentioned here are analogous to print
.
Args
- pos: Terminal-character space position to write the data to, (x, y).
36class Recorder: 37 """A class that records & exports terminal content.""" 38 39 def __init__(self) -> None: 40 """Initializes the Recorder.""" 41 42 self.recording: list[tuple[str, float]] = [] 43 self._start_stamp = time.time() 44 45 @property 46 def _content(self) -> str: 47 """Returns the str part of self._recording""" 48 49 return "".join(data for data, _ in self.recording) 50 51 def write(self, data: str) -> None: 52 """Writes to the recorder.""" 53 54 self.recording.append((data, time.time() - self._start_stamp)) 55 56 def export_text(self) -> str: 57 """Exports current content as plain text.""" 58 59 return strip_ansi(self._content) 60 61 def export_html( 62 self, prefix: str | None = None, inline_styles: bool = False 63 ) -> str: 64 """Exports current content as HTML. 65 66 For help on the arguments, see `pytermgui.html.to_html`. 67 """ 68 69 from .exporters import to_html # pylint: disable=import-outside-toplevel 70 71 return to_html(self._content, prefix=prefix, inline_styles=inline_styles) 72 73 def export_svg( 74 self, 75 prefix: str | None = None, 76 inline_styles: bool = False, 77 title: str = "PyTermGUI", 78 chrome: bool = True, 79 ) -> str: 80 """Exports current content as SVG. 81 82 For help on the arguments, see `pytermgui.html.to_svg`. 83 """ 84 85 from .exporters import to_svg # pylint: disable=import-outside-toplevel 86 87 return to_svg( 88 self._content, 89 prefix=prefix, 90 inline_styles=inline_styles, 91 title=title, 92 chrome=chrome, 93 ) 94 95 def save_plain(self, filename: str) -> None: 96 """Exports plain text content to the given file. 97 98 Args: 99 filename: The file to save to. 100 """ 101 102 with open(filename, "w", encoding="utf-8") as file: 103 file.write(self.export_text()) 104 105 def save_html( 106 self, 107 filename: str | None = None, 108 prefix: str | None = None, 109 inline_styles: bool = False, 110 ) -> None: 111 """Exports HTML content to the given file. 112 113 For help on the arguments, see `pytermgui.exporters.to_html`. 114 115 Args: 116 filename: The file to save to. If the filename does not contain the '.html' 117 extension it will be appended to the end. 118 """ 119 120 if filename is None: 121 filename = f"PTG_{time.time():%Y-%m-%d %H:%M:%S}.html" 122 123 if not filename.endswith(".html"): 124 filename += ".html" 125 126 with open(filename, "w", encoding="utf-8") as file: 127 file.write(self.export_html(prefix=prefix, inline_styles=inline_styles)) 128 129 def save_svg( # pylint: disable=too-many-arguments 130 self, 131 filename: str | None = None, 132 prefix: str | None = None, 133 chrome: bool = True, 134 inline_styles: bool = False, 135 title: str = "PyTermGUI", 136 ) -> None: 137 """Exports SVG content to the given file. 138 139 For help on the arguments, see `pytermgui.exporters.to_svg`. 140 141 Args: 142 filename: The file to save to. If the filename does not contain the '.svg' 143 extension it will be appended to the end. 144 """ 145 146 if filename is None: 147 timeval = datetime.now() 148 filename = f"PTG_{timeval:%Y-%m-%d_%H:%M:%S}.svg" 149 150 if not filename.endswith(".svg"): 151 filename += ".svg" 152 153 with open(filename, "w", encoding="utf-8") as file: 154 file.write( 155 self.export_svg( 156 prefix=prefix, 157 inline_styles=inline_styles, 158 title=title, 159 chrome=chrome, 160 ) 161 )
A class that records & exports terminal content.
39 def __init__(self) -> None: 40 """Initializes the Recorder.""" 41 42 self.recording: list[tuple[str, float]] = [] 43 self._start_stamp = time.time()
Initializes the Recorder.
51 def write(self, data: str) -> None: 52 """Writes to the recorder.""" 53 54 self.recording.append((data, time.time() - self._start_stamp))
Writes to the recorder.
56 def export_text(self) -> str: 57 """Exports current content as plain text.""" 58 59 return strip_ansi(self._content)
Exports current content as plain text.
61 def export_html( 62 self, prefix: str | None = None, inline_styles: bool = False 63 ) -> str: 64 """Exports current content as HTML. 65 66 For help on the arguments, see `pytermgui.html.to_html`. 67 """ 68 69 from .exporters import to_html # pylint: disable=import-outside-toplevel 70 71 return to_html(self._content, prefix=prefix, inline_styles=inline_styles)
Exports current content as HTML.
For help on the arguments, see pytermgui.html.to_html
.
73 def export_svg( 74 self, 75 prefix: str | None = None, 76 inline_styles: bool = False, 77 title: str = "PyTermGUI", 78 chrome: bool = True, 79 ) -> str: 80 """Exports current content as SVG. 81 82 For help on the arguments, see `pytermgui.html.to_svg`. 83 """ 84 85 from .exporters import to_svg # pylint: disable=import-outside-toplevel 86 87 return to_svg( 88 self._content, 89 prefix=prefix, 90 inline_styles=inline_styles, 91 title=title, 92 chrome=chrome, 93 )
Exports current content as SVG.
For help on the arguments, see pytermgui.html.to_svg
.
95 def save_plain(self, filename: str) -> None: 96 """Exports plain text content to the given file. 97 98 Args: 99 filename: The file to save to. 100 """ 101 102 with open(filename, "w", encoding="utf-8") as file: 103 file.write(self.export_text())
Exports plain text content to the given file.
Args
- filename: The file to save to.
105 def save_html( 106 self, 107 filename: str | None = None, 108 prefix: str | None = None, 109 inline_styles: bool = False, 110 ) -> None: 111 """Exports HTML content to the given file. 112 113 For help on the arguments, see `pytermgui.exporters.to_html`. 114 115 Args: 116 filename: The file to save to. If the filename does not contain the '.html' 117 extension it will be appended to the end. 118 """ 119 120 if filename is None: 121 filename = f"PTG_{time.time():%Y-%m-%d %H:%M:%S}.html" 122 123 if not filename.endswith(".html"): 124 filename += ".html" 125 126 with open(filename, "w", encoding="utf-8") as file: 127 file.write(self.export_html(prefix=prefix, inline_styles=inline_styles))
Exports HTML content to the given file.
For help on the arguments, see pytermgui.exporters.to_html
.
Args
- filename: The file to save to. If the filename does not contain the '.html' extension it will be appended to the end.
129 def save_svg( # pylint: disable=too-many-arguments 130 self, 131 filename: str | None = None, 132 prefix: str | None = None, 133 chrome: bool = True, 134 inline_styles: bool = False, 135 title: str = "PyTermGUI", 136 ) -> None: 137 """Exports SVG content to the given file. 138 139 For help on the arguments, see `pytermgui.exporters.to_svg`. 140 141 Args: 142 filename: The file to save to. If the filename does not contain the '.svg' 143 extension it will be appended to the end. 144 """ 145 146 if filename is None: 147 timeval = datetime.now() 148 filename = f"PTG_{timeval:%Y-%m-%d_%H:%M:%S}.svg" 149 150 if not filename.endswith(".svg"): 151 filename += ".svg" 152 153 with open(filename, "w", encoding="utf-8") as file: 154 file.write( 155 self.export_svg( 156 prefix=prefix, 157 inline_styles=inline_styles, 158 title=title, 159 chrome=chrome, 160 ) 161 )
Exports SVG content to the given file.
For help on the arguments, see pytermgui.exporters.to_svg
.
Args
- filename: The file to save to. If the filename does not contain the '.svg' extension it will be appended to the end.
164class ColorSystem(Enum): 165 """An enumeration of various terminal-supported colorsystems.""" 166 167 NO_COLOR = -1 168 """No-color terminal. See https://no-color.org/.""" 169 170 STANDARD = 0 171 """Standard 3-bit colorsystem of the basic 16 colors.""" 172 173 EIGHT_BIT = 1 174 """xterm 8-bit colors, 0-256.""" 175 176 TRUE = 2 177 """'True' color, a.k.a. 24-bit RGB colors.""" 178 179 def __ge__(self, other): 180 """Comparison: self >= other.""" 181 182 if self.__class__ is other.__class__: 183 return self.value >= other.value 184 185 return NotImplemented 186 187 def __gt__(self, other): 188 """Comparison: self > other.""" 189 190 if self.__class__ is other.__class__: 191 return self.value > other.value 192 193 return NotImplemented 194 195 def __le__(self, other): 196 """Comparison: self <= other.""" 197 198 if self.__class__ is other.__class__: 199 return self.value <= other.value 200 201 return NotImplemented 202 203 def __lt__(self, other): 204 """Comparison: self < other.""" 205 206 if self.__class__ is other.__class__: 207 return self.value < other.value 208 209 return NotImplemented
An enumeration of various terminal-supported colorsystems.
Inherited Members
- enum.Enum
- name
- value