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 = <pytermgui.terminal.Terminal object>

Terminal instance that should be used pretty much always.

def set_global_terminal(new: pytermgui.terminal.Terminal) -> None:
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.

def get_terminal() -> pytermgui.terminal.Terminal:
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.

class Terminal:
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.

Terminal( stream: typing.TextIO | None = None, *, size: tuple[int, int] | None = None, pixel_size: tuple[int, int] | None = None)
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.

RESIZE = 0

Event sent out when the terminal has been resized.

Arguments passed:

  • New size: tuple[int, int]
margins = [0, 0, 0, 0]

Not quite sure what this does at the moment.

displayhook_installed: bool = False

This is set to True when pretty.install is called.

origin: tuple[int, int] = (1, 1)

Origin of the internal coordinate system.

forced_colorsystem: pytermgui.terminal.ColorSystem | None

Forces a color system type on this terminal.

width: int

Gets the current width of the terminal.

height: int

Gets the current height of the terminal.

@staticmethod
def is_interactive() -> bool:
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.

Gets the current terminal's supported color system.

@contextmanager
def record(self) -> Generator[pytermgui.terminal.Recorder, NoneType, NoneType]:
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.

@contextmanager
def no_record(self) -> Generator[NoneType, NoneType, NoneType]:
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.

@staticmethod
def isatty() -> bool:
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.

def replay(self, recorder: pytermgui.terminal.Recorder) -> None:
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.

def subscribe(self, event: int, callback: Callable[..., Any]) -> None:
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.
def write( self, data: str, pos: tuple[int, int] | None = None, flush: bool = False, slice_too_long: bool = True) -> None:
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.
def clear_stream(self) -> None:
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.

def print( self, *items, pos: tuple[int, int] | None = None, sep: str = ' ', end='\n', flush: bool = True) -> None:
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).
def flush(self) -> None:
527    def flush(self) -> None:
528        """Flushes self._stream."""
529
530        self._stream.flush()

Flushes self._stream.

class Recorder:
 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.

Recorder()
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.

def write(self, data: str) -> None:
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.

def export_text(self) -> str:
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.

def export_html(self, prefix: str | None = None, inline_styles: bool = False) -> str:
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.

def export_svg( self, prefix: str | None = None, inline_styles: bool = False, title: str = 'PyTermGUI') -> str:
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.

def save_plain(self, filename: str) -> None:
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.
def save_html( self, filename: str | None = None, prefix: str | None = None, inline_styles: bool = False) -> None:
 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.
def save_svg( self, filename: str | None = None, prefix: str | None = None, inline_styles: bool = False, title: str = 'PyTermGUI') -> None:
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.
class ColorSystem(enum.Enum):
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.

NO_COLOR = <ColorSystem.NO_COLOR: -1>

No-color terminal. See https://no-color.org/.

STANDARD = <ColorSystem.STANDARD: 0>

Standard 3-bit colorsystem of the basic 16 colors.

EIGHT_BIT = <ColorSystem.EIGHT_BIT: 1>

xterm 8-bit colors, 0-256.

TRUE = <ColorSystem.TRUE: 2>

'True' color, a.k.a. 24-bit RGB colors.

Inherited Members
enum.Enum
name
value