pytermgui.terminal

This module houses the Terminal class, and its provided instance.

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

Terminal instance that should be used pretty much always.

def set_global_terminal(new: pytermgui.terminal.Terminal) -> None:
586def set_global_terminal(new: Terminal) -> None:
587    """Sets the terminal instance to be used by the module."""
588
589    globals()["terminal"] = new

Sets the terminal instance to be used by the module.

def get_terminal() -> pytermgui.terminal.Terminal:
592def get_terminal() -> Terminal:
593    """Gets the default terminal instance used by the module."""
594
595    return terminal

Gets the default terminal instance used by the module.

class Terminal:
226class Terminal:  # pylint: disable=too-many-instance-attributes
227    """A class to store & access data about a terminal."""
228
229    RESIZE = 0
230    """Event sent out when the terminal has been resized.
231
232    Arguments passed:
233    - New size: tuple[int, int]
234    """
235
236    margins = [0, 0, 0, 0]
237    """Not quite sure what this does at the moment."""
238
239    displayhook_installed: bool = False
240    """This is set to True when `pretty.install` is called."""
241
242    origin: tuple[int, int] = (1, 1)
243    """Origin of the internal coordinate system."""
244
245    def __init__(
246        self,
247        stream: TextIO | None = None,
248        *,
249        size: tuple[int, int] | None = None,
250    ) -> None:
251        """Initialize `Terminal` class."""
252
253        if stream is None:
254            stream = sys.stdout
255
256        self._size = size
257        self._stream = stream or sys.stdout
258
259        self._recorder: Recorder | None = None
260
261        self.size: tuple[int, int] = self._get_size()
262        self.forced_colorsystem: ColorSystem | None = _get_env_colorsys()
263
264        self._listeners: dict[int, list[Callable[..., Any]]] = {}
265
266        if hasattr(signal, "SIGWINCH"):
267            signal.signal(signal.SIGWINCH, self._update_size)
268        else:
269            from threading import Thread  # pylint: disable=import-outside-toplevel
270
271            Thread(
272                name="windows_terminal_resize",
273                target=self._window_terminal_resize,
274                daemon=True,
275            ).start()
276
277        self._diff_buffer = [
278            ["" for _ in range(self.width)] for y in range(self.height)
279        ]
280
281    def _window_terminal_resize(self):
282        from time import sleep  # pylint: disable=import-outside-toplevel
283
284        _previous = get_terminal_size()
285        while True:
286            _next = get_terminal_size()
287            if _previous != _next:
288                self._update_size()
289                _previous = _next
290            sleep(0.001)
291
292    def __fancy_repr__(self) -> Generator[FancyYield, None, None]:
293        """Returns a cool looking repr."""
294
295        name = type(self).__name__
296
297        yield f"<{name} stream={self._stream} size={self.size}>"
298
299    @cached_property
300    def resolution(self) -> tuple[int, int]:
301        """Returns the terminal's pixel based resolution.
302
303        Only evaluated on demand.
304        """
305
306        if self.isatty():
307            sys.stdout.write("\x1b[14t")
308            sys.stdout.flush()
309
310            # Some terminals may not respond to a pixel size query, so we send
311            # a timed-out getch call with a default response of 1280x720.
312            output = getch_timeout(0.1, default="\x1b[4;720;1280t")
313            match = RE_PIXEL_SIZE.match(output)
314
315            if match is not None:
316                return (int(match[2]), int(match[1]))
317
318        return (0, 0)
319
320    @property
321    def pixel_size(self) -> tuple[int, int]:
322        """DEPRECATED: Returns the terminal's pixel resolution.
323
324        Prefer terminal.resolution.
325        """
326
327        return self.resolution
328
329    def _call_listener(self, event: int, data: Any) -> None:
330        """Calls callbacks for event.
331
332        Args:
333            event: A terminal event.
334            data: Arbitrary data passed to the callback.
335        """
336
337        if event in self._listeners:
338            for callback in self._listeners[event]:
339                callback(data)
340
341    def _get_size(self) -> tuple[int, int]:
342        """Gets the screen size with origin substracted."""
343
344        if self._size is not None:
345            return self._size
346
347        size = get_terminal_size()
348        return (size[0], size[1])
349
350    def _update_size(self, *_: Any) -> None:
351        """Resize terminal when SIGWINCH occurs, and call listeners."""
352
353        if hasattr(self, "resolution"):
354            del self.resolution
355
356        self.size = self._get_size()
357
358        self._call_listener(self.RESIZE, self.size)
359
360        # Wipe the screen in case anything got messed up
361        self.write("\x1b[2J")
362
363    @property
364    def width(self) -> int:
365        """Gets the current width of the terminal."""
366
367        return self.size[0]
368
369    @property
370    def height(self) -> int:
371        """Gets the current height of the terminal."""
372
373        return self.size[1]
374
375    @staticmethod
376    def is_interactive() -> bool:
377        """Determines whether shell is interactive.
378
379        A shell is interactive if it is run from `python3` or `python3 -i`.
380        """
381
382        return hasattr(sys, "ps1")
383
384    @property
385    def forced_colorsystem(self) -> ColorSystem | None:
386        """Forces a color system type on this terminal."""
387
388        return self._forced_colorsystem
389
390    @forced_colorsystem.setter
391    def forced_colorsystem(self, new: ColorSystem | None) -> None:
392        """Sets a colorsystem, clears colorsystem cache."""
393
394        self._forced_colorsystem = new
395
396    @property
397    def colorsystem(self) -> ColorSystem:
398        """Gets the current terminal's supported color system."""
399
400        if self.forced_colorsystem is not None:
401            return self.forced_colorsystem
402
403        if os.getenv("NO_COLOR") is not None:
404            return ColorSystem.NO_COLOR
405
406        term = os.getenv("TERM", "")
407        color_term = os.getenv("COLORTERM", "").strip().lower()
408
409        if color_term == "":
410            color_term = term.split("xterm-")[-1]
411
412        if color_term in ["24bit", "truecolor"]:
413            return ColorSystem.TRUE
414
415        if color_term == "256color":
416            return ColorSystem.EIGHT_BIT
417
418        return ColorSystem.STANDARD
419
420    @contextmanager
421    def record(self) -> Generator[Recorder, None, None]:
422        """Records the terminal's stream."""
423
424        if self._recorder is not None:
425            raise RuntimeError(f"{self!r} is already recording.")
426
427        try:
428            self._recorder = Recorder()
429            yield self._recorder
430
431        finally:
432            self._recorder = None
433
434    @contextmanager
435    def no_record(self) -> Generator[None, None, None]:
436        """Pauses recording for the duration of the context."""
437
438        recorder = self._recorder
439
440        try:
441            self._recorder = None
442            yield
443
444        finally:
445            self._recorder = recorder
446
447    @staticmethod
448    def isatty() -> bool:
449        """Returns whether sys.stdin is a tty."""
450
451        return sys.stdin.isatty()
452
453    def replay(self, recorder: Recorder) -> None:
454        """Replays a recording."""
455
456        last_time = 0.0
457        for data, delay in recorder.recording:
458            if last_time > 0.0:
459                time.sleep(delay - last_time)
460
461            self.write(data, flush=True)
462            last_time = delay
463
464    def subscribe(self, event: int, callback: Callable[..., Any]) -> None:
465        """Subcribes a callback to be called when event occurs.
466
467        Args:
468            event: The terminal event that calls callback.
469            callback: The callable to be called. The signature of this
470                callable is dependent on the event. See the documentation
471                of the specific event for more information.
472        """
473
474        if not event in self._listeners:
475            self._listeners[event] = []
476
477        self._listeners[event].append(callback)
478
479    def write(
480        self,
481        data: str,
482        pos: tuple[int, int] | None = None,
483        flush: bool = False,
484        slice_too_long: bool = True,
485    ) -> None:
486        """Writes the given data to the terminal's stream.
487
488        Args:
489            data: The data to write.
490            pos: Terminal-character space position to write the data to, (x, y).
491            flush: If set, `flush` will be called on the stream after reading.
492            slice_too_long: If set, lines that are outside of the terminal will be
493                sliced to fit. Involves a sizable performance hit.
494        """
495
496        def _slice(line: str, maximum: int) -> str:
497            length = 0
498            sliced = ""
499            for char in line:
500                sliced += char
501                if char == "\x1b":
502                    continue
503
504                if (
505                    length > maximum
506                    and real_length(sliced) > maximum
507                    and not has_open_sequence(sliced)
508                ):
509                    break
510
511                length += 1
512
513            return sliced
514
515        if "\x1b[2J" in data:
516            self.clear_stream()
517
518        if pos is not None:
519            xpos, ypos = pos
520
521            if slice_too_long:
522                if not self.height + self.origin[1] + 1 > ypos >= 0:
523                    return
524
525                maximum = self.width - xpos + self.origin[0]
526
527                if xpos < self.origin[0]:
528                    xpos = self.origin[0]
529
530                sliced = _slice(data, maximum) if len(data) > maximum else data
531
532                data = f"\x1b[{ypos};{xpos}H{sliced}\x1b[0m"
533
534            else:
535                data = f"\x1b[{ypos};{xpos}H{data}"
536
537        self._stream.write(data)
538
539        if self._recorder is not None:
540            self._recorder.write(data)
541
542        if flush:
543            self._stream.flush()
544
545    def clear_stream(self) -> None:
546        """Clears (truncates) the terminal's stream."""
547
548        try:
549            self._stream.truncate(0)
550
551        except OSError as error:
552            if error.errno != errno.EINVAL and os.name != "nt":
553                raise
554
555        self._stream.write("\x1b[2J")
556
557    def print(
558        self,
559        *items,
560        pos: tuple[int, int] | None = None,
561        sep: str = " ",
562        end="\n",
563        flush: bool = True,
564    ) -> None:
565        """Prints items to the stream.
566
567        All arguments not mentioned here are analogous to `print`.
568
569        Args:
570            pos: Terminal-character space position to write the data to, (x, y).
571
572        """
573
574        self.write(sep.join(map(str, items)) + end, pos=pos, flush=flush)
575
576    def flush(self) -> None:
577        """Flushes self._stream."""
578
579        self._stream.flush()

A class to store & access data about a terminal.

Terminal( stream: typing.TextIO | None = None, *, size: tuple[int, int] | None = None)
245    def __init__(
246        self,
247        stream: TextIO | None = None,
248        *,
249        size: tuple[int, int] | None = None,
250    ) -> None:
251        """Initialize `Terminal` class."""
252
253        if stream is None:
254            stream = sys.stdout
255
256        self._size = size
257        self._stream = stream or sys.stdout
258
259        self._recorder: Recorder | None = None
260
261        self.size: tuple[int, int] = self._get_size()
262        self.forced_colorsystem: ColorSystem | None = _get_env_colorsys()
263
264        self._listeners: dict[int, list[Callable[..., Any]]] = {}
265
266        if hasattr(signal, "SIGWINCH"):
267            signal.signal(signal.SIGWINCH, self._update_size)
268        else:
269            from threading import Thread  # pylint: disable=import-outside-toplevel
270
271            Thread(
272                name="windows_terminal_resize",
273                target=self._window_terminal_resize,
274                daemon=True,
275            ).start()
276
277        self._diff_buffer = [
278            ["" for _ in range(self.width)] for y in range(self.height)
279        ]

Initialize Terminal class.

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:
375    @staticmethod
376    def is_interactive() -> bool:
377        """Determines whether shell is interactive.
378
379        A shell is interactive if it is run from `python3` or `python3 -i`.
380        """
381
382        return hasattr(sys, "ps1")

Determines whether shell is interactive.

A shell is interactive if it is run from python3 or python3 -i.

Gets the current terminal's supported color system.

@contextmanager
def record(self) -> Generator[pytermgui.terminal.Recorder, NoneType, NoneType]:
420    @contextmanager
421    def record(self) -> Generator[Recorder, None, None]:
422        """Records the terminal's stream."""
423
424        if self._recorder is not None:
425            raise RuntimeError(f"{self!r} is already recording.")
426
427        try:
428            self._recorder = Recorder()
429            yield self._recorder
430
431        finally:
432            self._recorder = None

Records the terminal's stream.

@contextmanager
def no_record(self) -> Generator[NoneType, NoneType, NoneType]:
434    @contextmanager
435    def no_record(self) -> Generator[None, None, None]:
436        """Pauses recording for the duration of the context."""
437
438        recorder = self._recorder
439
440        try:
441            self._recorder = None
442            yield
443
444        finally:
445            self._recorder = recorder

Pauses recording for the duration of the context.

@staticmethod
def isatty() -> bool:
447    @staticmethod
448    def isatty() -> bool:
449        """Returns whether sys.stdin is a tty."""
450
451        return sys.stdin.isatty()

Returns whether sys.stdin is a tty.

def replay(self, recorder: pytermgui.terminal.Recorder) -> None:
453    def replay(self, recorder: Recorder) -> None:
454        """Replays a recording."""
455
456        last_time = 0.0
457        for data, delay in recorder.recording:
458            if last_time > 0.0:
459                time.sleep(delay - last_time)
460
461            self.write(data, flush=True)
462            last_time = delay

Replays a recording.

def subscribe(self, event: int, callback: Callable[..., Any]) -> None:
464    def subscribe(self, event: int, callback: Callable[..., Any]) -> None:
465        """Subcribes a callback to be called when event occurs.
466
467        Args:
468            event: The terminal event that calls callback.
469            callback: The callable to be called. The signature of this
470                callable is dependent on the event. See the documentation
471                of the specific event for more information.
472        """
473
474        if not event in self._listeners:
475            self._listeners[event] = []
476
477        self._listeners[event].append(callback)

Subcribes a callback to be called when event occurs.

Args
  • event: The terminal event that calls callback.
  • callback: The callable to be called. The signature of this callable is dependent on the event. See the documentation of the specific event for more information.
def write( self, data: str, pos: tuple[int, int] | None = None, flush: bool = False, slice_too_long: bool = True) -> None:
479    def write(
480        self,
481        data: str,
482        pos: tuple[int, int] | None = None,
483        flush: bool = False,
484        slice_too_long: bool = True,
485    ) -> None:
486        """Writes the given data to the terminal's stream.
487
488        Args:
489            data: The data to write.
490            pos: Terminal-character space position to write the data to, (x, y).
491            flush: If set, `flush` will be called on the stream after reading.
492            slice_too_long: If set, lines that are outside of the terminal will be
493                sliced to fit. Involves a sizable performance hit.
494        """
495
496        def _slice(line: str, maximum: int) -> str:
497            length = 0
498            sliced = ""
499            for char in line:
500                sliced += char
501                if char == "\x1b":
502                    continue
503
504                if (
505                    length > maximum
506                    and real_length(sliced) > maximum
507                    and not has_open_sequence(sliced)
508                ):
509                    break
510
511                length += 1
512
513            return sliced
514
515        if "\x1b[2J" in data:
516            self.clear_stream()
517
518        if pos is not None:
519            xpos, ypos = pos
520
521            if slice_too_long:
522                if not self.height + self.origin[1] + 1 > ypos >= 0:
523                    return
524
525                maximum = self.width - xpos + self.origin[0]
526
527                if xpos < self.origin[0]:
528                    xpos = self.origin[0]
529
530                sliced = _slice(data, maximum) if len(data) > maximum else data
531
532                data = f"\x1b[{ypos};{xpos}H{sliced}\x1b[0m"
533
534            else:
535                data = f"\x1b[{ypos};{xpos}H{data}"
536
537        self._stream.write(data)
538
539        if self._recorder is not None:
540            self._recorder.write(data)
541
542        if flush:
543            self._stream.flush()

Writes the given data to the terminal's stream.

Args
  • data: The data to write.
  • pos: Terminal-character space position to write the data to, (x, y).
  • flush: If set, flush will be called on the stream after reading.
  • slice_too_long: If set, lines that are outside of the terminal will be sliced to fit. Involves a sizable performance hit.
def clear_stream(self) -> None:
545    def clear_stream(self) -> None:
546        """Clears (truncates) the terminal's stream."""
547
548        try:
549            self._stream.truncate(0)
550
551        except OSError as error:
552            if error.errno != errno.EINVAL and os.name != "nt":
553                raise
554
555        self._stream.write("\x1b[2J")

Clears (truncates) the terminal's stream.

def print( self, *items, pos: tuple[int, int] | None = None, sep: str = ' ', end='\n', flush: bool = True) -> None:
557    def print(
558        self,
559        *items,
560        pos: tuple[int, int] | None = None,
561        sep: str = " ",
562        end="\n",
563        flush: bool = True,
564    ) -> None:
565        """Prints items to the stream.
566
567        All arguments not mentioned here are analogous to `print`.
568
569        Args:
570            pos: Terminal-character space position to write the data to, (x, y).
571
572        """
573
574        self.write(sep.join(map(str, items)) + end, pos=pos, flush=flush)

Prints items to the stream.

All arguments not mentioned here are analogous to print.

Args
  • pos: Terminal-character space position to write the data to, (x, y).
def flush(self) -> None:
576    def flush(self) -> None:
577        """Flushes self._stream."""
578
579        self._stream.flush()

Flushes self._stream.

class Recorder:
 36class Recorder:
 37    """A class that records & exports terminal content."""
 38
 39    def __init__(self) -> None:
 40        """Initializes the Recorder."""
 41
 42        self.recording: list[tuple[str, float]] = []
 43        self._start_stamp = time.time()
 44
 45    @property
 46    def _content(self) -> str:
 47        """Returns the str part of self._recording"""
 48
 49        return "".join(data for data, _ in self.recording)
 50
 51    def write(self, data: str) -> None:
 52        """Writes to the recorder."""
 53
 54        self.recording.append((data, time.time() - self._start_stamp))
 55
 56    def export_text(self) -> str:
 57        """Exports current content as plain text."""
 58
 59        return strip_ansi(self._content)
 60
 61    def export_html(
 62        self, prefix: str | None = None, inline_styles: bool = False
 63    ) -> str:
 64        """Exports current content as HTML.
 65
 66        For help on the arguments, see `pytermgui.html.to_html`.
 67        """
 68
 69        from .exporters import to_html  # pylint: disable=import-outside-toplevel
 70
 71        return to_html(self._content, prefix=prefix, inline_styles=inline_styles)
 72
 73    def export_svg(
 74        self,
 75        prefix: str | None = None,
 76        inline_styles: bool = False,
 77        title: str = "PyTermGUI",
 78        chrome: bool = True,
 79    ) -> str:
 80        """Exports current content as SVG.
 81
 82        For help on the arguments, see `pytermgui.html.to_svg`.
 83        """
 84
 85        from .exporters import to_svg  # pylint: disable=import-outside-toplevel
 86
 87        return to_svg(
 88            self._content,
 89            prefix=prefix,
 90            inline_styles=inline_styles,
 91            title=title,
 92            chrome=chrome,
 93        )
 94
 95    def save_plain(self, filename: str) -> None:
 96        """Exports plain text content to the given file.
 97
 98        Args:
 99            filename: The file to save to.
100        """
101
102        with open(filename, "w", encoding="utf-8") as file:
103            file.write(self.export_text())
104
105    def save_html(
106        self,
107        filename: str | None = None,
108        prefix: str | None = None,
109        inline_styles: bool = False,
110    ) -> None:
111        """Exports HTML content to the given file.
112
113        For help on the arguments, see `pytermgui.exporters.to_html`.
114
115        Args:
116            filename: The file to save to. If the filename does not contain the '.html'
117                extension it will be appended to the end.
118        """
119
120        if filename is None:
121            filename = f"PTG_{time.time():%Y-%m-%d %H:%M:%S}.html"
122
123        if not filename.endswith(".html"):
124            filename += ".html"
125
126        with open(filename, "w", encoding="utf-8") as file:
127            file.write(self.export_html(prefix=prefix, inline_styles=inline_styles))
128
129    def save_svg(  # pylint: disable=too-many-arguments
130        self,
131        filename: str | None = None,
132        prefix: str | None = None,
133        chrome: bool = True,
134        inline_styles: bool = False,
135        title: str = "PyTermGUI",
136    ) -> None:
137        """Exports SVG content to the given file.
138
139        For help on the arguments, see `pytermgui.exporters.to_svg`.
140
141        Args:
142            filename: The file to save to. If the filename does not contain the '.svg'
143                extension it will be appended to the end.
144        """
145
146        if filename is None:
147            timeval = datetime.now()
148            filename = f"PTG_{timeval:%Y-%m-%d_%H:%M:%S}.svg"
149
150        if not filename.endswith(".svg"):
151            filename += ".svg"
152
153        with open(filename, "w", encoding="utf-8") as file:
154            file.write(
155                self.export_svg(
156                    prefix=prefix,
157                    inline_styles=inline_styles,
158                    title=title,
159                    chrome=chrome,
160                )
161            )

A class that records & exports terminal content.

Recorder()
39    def __init__(self) -> None:
40        """Initializes the Recorder."""
41
42        self.recording: list[tuple[str, float]] = []
43        self._start_stamp = time.time()

Initializes the Recorder.

def write(self, data: str) -> None:
51    def write(self, data: str) -> None:
52        """Writes to the recorder."""
53
54        self.recording.append((data, time.time() - self._start_stamp))

Writes to the recorder.

def export_text(self) -> str:
56    def export_text(self) -> str:
57        """Exports current content as plain text."""
58
59        return strip_ansi(self._content)

Exports current content as plain text.

def export_html(self, prefix: str | None = None, inline_styles: bool = False) -> str:
61    def export_html(
62        self, prefix: str | None = None, inline_styles: bool = False
63    ) -> str:
64        """Exports current content as HTML.
65
66        For help on the arguments, see `pytermgui.html.to_html`.
67        """
68
69        from .exporters import to_html  # pylint: disable=import-outside-toplevel
70
71        return to_html(self._content, prefix=prefix, inline_styles=inline_styles)

Exports current content as HTML.

For help on the arguments, see pytermgui.html.to_html.

def export_svg( self, prefix: str | None = None, inline_styles: bool = False, title: str = 'PyTermGUI', chrome: bool = True) -> str:
73    def export_svg(
74        self,
75        prefix: str | None = None,
76        inline_styles: bool = False,
77        title: str = "PyTermGUI",
78        chrome: bool = True,
79    ) -> str:
80        """Exports current content as SVG.
81
82        For help on the arguments, see `pytermgui.html.to_svg`.
83        """
84
85        from .exporters import to_svg  # pylint: disable=import-outside-toplevel
86
87        return to_svg(
88            self._content,
89            prefix=prefix,
90            inline_styles=inline_styles,
91            title=title,
92            chrome=chrome,
93        )

Exports current content as SVG.

For help on the arguments, see pytermgui.html.to_svg.

def save_plain(self, filename: str) -> None:
 95    def save_plain(self, filename: str) -> None:
 96        """Exports plain text content to the given file.
 97
 98        Args:
 99            filename: The file to save to.
100        """
101
102        with open(filename, "w", encoding="utf-8") as file:
103            file.write(self.export_text())

Exports plain text content to the given file.

Args
  • filename: The file to save to.
def save_html( self, filename: str | None = None, prefix: str | None = None, inline_styles: bool = False) -> None:
105    def save_html(
106        self,
107        filename: str | None = None,
108        prefix: str | None = None,
109        inline_styles: bool = False,
110    ) -> None:
111        """Exports HTML content to the given file.
112
113        For help on the arguments, see `pytermgui.exporters.to_html`.
114
115        Args:
116            filename: The file to save to. If the filename does not contain the '.html'
117                extension it will be appended to the end.
118        """
119
120        if filename is None:
121            filename = f"PTG_{time.time():%Y-%m-%d %H:%M:%S}.html"
122
123        if not filename.endswith(".html"):
124            filename += ".html"
125
126        with open(filename, "w", encoding="utf-8") as file:
127            file.write(self.export_html(prefix=prefix, inline_styles=inline_styles))

Exports HTML content to the given file.

For help on the arguments, see pytermgui.exporters.to_html.

Args
  • filename: The file to save to. If the filename does not contain the '.html' extension it will be appended to the end.
def save_svg( self, filename: str | None = None, prefix: str | None = None, chrome: bool = True, inline_styles: bool = False, title: str = 'PyTermGUI') -> None:
129    def save_svg(  # pylint: disable=too-many-arguments
130        self,
131        filename: str | None = None,
132        prefix: str | None = None,
133        chrome: bool = True,
134        inline_styles: bool = False,
135        title: str = "PyTermGUI",
136    ) -> None:
137        """Exports SVG content to the given file.
138
139        For help on the arguments, see `pytermgui.exporters.to_svg`.
140
141        Args:
142            filename: The file to save to. If the filename does not contain the '.svg'
143                extension it will be appended to the end.
144        """
145
146        if filename is None:
147            timeval = datetime.now()
148            filename = f"PTG_{timeval:%Y-%m-%d_%H:%M:%S}.svg"
149
150        if not filename.endswith(".svg"):
151            filename += ".svg"
152
153        with open(filename, "w", encoding="utf-8") as file:
154            file.write(
155                self.export_svg(
156                    prefix=prefix,
157                    inline_styles=inline_styles,
158                    title=title,
159                    chrome=chrome,
160                )
161            )

Exports SVG content to the given file.

For help on the arguments, see pytermgui.exporters.to_svg.

Args
  • filename: The file to save to. If the filename does not contain the '.svg' extension it will be appended to the end.
class ColorSystem(enum.Enum):
164class ColorSystem(Enum):
165    """An enumeration of various terminal-supported colorsystems."""
166
167    NO_COLOR = -1
168    """No-color terminal. See https://no-color.org/."""
169
170    STANDARD = 0
171    """Standard 3-bit colorsystem of the basic 16 colors."""
172
173    EIGHT_BIT = 1
174    """xterm 8-bit colors, 0-256."""
175
176    TRUE = 2
177    """'True' color, a.k.a. 24-bit RGB colors."""
178
179    def __ge__(self, other):
180        """Comparison: self >= other."""
181
182        if self.__class__ is other.__class__:
183            return self.value >= other.value
184
185        return NotImplemented
186
187    def __gt__(self, other):
188        """Comparison: self > other."""
189
190        if self.__class__ is other.__class__:
191            return self.value > other.value
192
193        return NotImplemented
194
195    def __le__(self, other):
196        """Comparison: self <= other."""
197
198        if self.__class__ is other.__class__:
199            return self.value <= other.value
200
201        return NotImplemented
202
203    def __lt__(self, other):
204        """Comparison: self < other."""
205
206        if self.__class__ is other.__class__:
207            return self.value < other.value
208
209        return NotImplemented

An enumeration of various terminal-supported colorsystems.

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