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