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