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

Terminal instance that should be used pretty much always.

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

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

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

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

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.

resolution: tuple[int, int]

Returns the terminal's pixel based resolution.

Only evaluated on demand.

pixel_size: tuple[int, int]

DEPRECATED: Returns the terminal's pixel resolution.

Prefer terminal.resolution.

width: int

Gets the current width of the terminal.

height: int

Gets the current height of the terminal.

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

Gets the current terminal's supported color system.

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

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

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

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

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

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

Flushes self._stream.

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

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

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

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

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

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

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

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