pytermgui.terminal

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

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

Sets the terminal instance to be used by the module.

#   def get_terminal() -> pytermgui.terminal.Terminal:
View Source
542def get_terminal() -> Terminal:
543    """Gets the default terminal instance used by the module."""
544
545    return terminal

Gets the default terminal instance used by the module.

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

A class to store & access data about a terminal.

#   Terminal( stream: typing.TextIO | None = None, *, size: tuple[int, int] | None = None, pixel_size: tuple[int, int] | None = None )
View Source
229    def __init__(
230        self,
231        stream: TextIO | None = None,
232        *,
233        size: tuple[int, int] | None = None,
234        pixel_size: tuple[int, int] | None = None,
235    ) -> None:
236        """Initialize `_Terminal` class."""
237
238        if stream is None:
239            stream = sys.stdout
240
241        self._size = size
242        self._pixel_size = pixel_size
243        self._stream = stream or sys.stdout
244
245        self._recorder: Recorder | None = None
246
247        self.size: tuple[int, int] = self._get_size()
248        self.forced_colorsystem: ColorSystem | None = _get_env_colorsys()
249        self.pixel_size: tuple[int, int] = self._get_pixel_size()
250
251        self._listeners: dict[int, list[Callable[..., Any]]] = {}
252
253        if hasattr(signal, "SIGWINCH"):
254            signal.signal(signal.SIGWINCH, self._update_size)
255
256        # TODO: Support SIGWINCH on Windows.
257
258        self._diff_buffer = [
259            ["" for _ in range(self.width)] for y in range(self.height)
260        ]

Initialize _Terminal class.

#   RESIZE = 0

Event sent out when the terminal has been resized.

Arguments passed:

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

Not quite sure what this does at the moment.

#   displayhook_installed: bool = False

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

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

Origin of the internal coordinate system.

#   forced_colorsystem: pytermgui.terminal.ColorSystem | None

Forces a color system type on this terminal.

#   width: int

Gets the current width of the terminal.

#   height: int

Gets the current height of the terminal.

#  
@staticmethod
def is_interactive() -> bool:
View Source
325    @staticmethod
326    def is_interactive() -> bool:
327        """Determines whether shell is interactive.
328
329        A shell is interactive if it is run from `python3` or `python3 -i`.
330        """
331
332        return hasattr(sys, "ps1")

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]:
View Source
370    @contextmanager
371    def record(self) -> Generator[Recorder, None, None]:
372        """Records the terminal's stream."""
373
374        if self._recorder is not None:
375            raise RuntimeError(f"{self!r} is already recording.")
376
377        try:
378            self._recorder = Recorder()
379            yield self._recorder
380
381        finally:
382            self._recorder = None

Records the terminal's stream.

#  
@contextmanager
def no_record(self) -> Generator[NoneType, NoneType, NoneType]:
View Source
384    @contextmanager
385    def no_record(self) -> Generator[None, None, None]:
386        """Pauses recording for the duration of the context."""
387
388        recorder = self._recorder
389
390        try:
391            self._recorder = None
392            yield
393
394        finally:
395            self._recorder = recorder

Pauses recording for the duration of the context.

#  
@staticmethod
def isatty() -> bool:
View Source
397    @staticmethod
398    def isatty() -> bool:
399        """Returns whether sys.stdin is a tty."""
400
401        return sys.stdin.isatty()

Returns whether sys.stdin is a tty.

#   def replay(self, recorder: pytermgui.terminal.Recorder) -> None:
View Source
403    def replay(self, recorder: Recorder) -> None:
404        """Replays a recording."""
405
406        last_time = 0.0
407        for data, delay in recorder.recording:
408            if last_time > 0.0:
409                time.sleep(delay - last_time)
410
411            self.write(data, flush=True)
412            last_time = delay

Replays a recording.

#   def subscribe(self, event: int, callback: Callable[..., Any]) -> None:
View Source
414    def subscribe(self, event: int, callback: Callable[..., Any]) -> None:
415        """Subcribes a callback to be called when event occurs.
416
417        Args:
418            event: The terminal event that calls callback.
419            callback: The callable to be called. The signature of this
420                callable is dependent on the event. See the documentation
421                of the specific event for more information.
422        """
423
424        if not event in self._listeners:
425            self._listeners[event] = []
426
427        self._listeners[event].append(callback)

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:
View Source
429    def write(
430        self,
431        data: str,
432        pos: tuple[int, int] | None = None,
433        flush: bool = False,
434        slice_too_long: bool = True,
435    ) -> None:
436        """Writes the given data to the terminal's stream.
437
438        Args:
439            data: The data to write.
440            pos: Terminal-character space position to write the data to, (x, y).
441            flush: If set, `flush` will be called on the stream after reading.
442            slice_too_long: If set, lines that are outside of the terminal will be
443                sliced to fit. Involves a sizable performance hit.
444        """
445
446        def _slice(line: str, maximum: int) -> str:
447            length = 0
448            sliced = ""
449            for char in line:
450                sliced += char
451                if char == "\x1b":
452                    continue
453
454                if (
455                    length > maximum
456                    and real_length(sliced) > maximum
457                    and not has_open_sequence(sliced)
458                ):
459                    break
460
461                length += 1
462
463            return sliced
464
465        if "\x1b[2J" in data:
466            self.clear_stream()
467
468        if pos is not None:
469            xpos, ypos = pos
470
471            if slice_too_long:
472                if not self.height + self.origin[1] + 1 > ypos >= 0:
473                    return
474
475                maximum = self.width - xpos + self.origin[0]
476
477                if xpos < self.origin[0]:
478                    xpos = self.origin[0]
479
480                sliced = _slice(data, maximum) if len(data) > maximum else data
481
482                data = f"\x1b[{ypos};{xpos}H{sliced}\x1b[0m"
483
484            else:
485                data = f"\x1b[{ypos};{xpos}H{data}"
486
487        self._stream.write(data)
488
489        if self._recorder is not None:
490            self._recorder.write(data)
491
492        if flush:
493            self._stream.flush()

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:
View Source
495    def clear_stream(self) -> None:
496        """Clears (truncates) the terminal's stream."""
497
498        try:
499            self._stream.truncate(0)
500
501        except OSError as error:
502            if error.errno != errno.EINVAL and os.name != "nt":
503                raise
504
505        self._stream.write("\x1b[2J")

Clears (truncates) the terminal's stream.

#   def print( self, *items, pos: tuple[int, int] | None = None, sep: str = ' ', end='\n', flush: bool = True ) -> None:
View Source
507    def print(
508        self,
509        *items,
510        pos: tuple[int, int] | None = None,
511        sep: str = " ",
512        end="\n",
513        flush: bool = True,
514    ) -> None:
515        """Prints items to the stream.
516
517        All arguments not mentioned here are analogous to `print`.
518
519        Args:
520            pos: Terminal-character space position to write the data to, (x, y).
521
522        """
523
524        self.write(sep.join(map(str, items)) + end, pos=pos, flush=flush)

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:
View Source
526    def flush(self) -> None:
527        """Flushes self._stream."""
528
529        self._stream.flush()

Flushes self._stream.

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

A class that records & exports terminal content.

#   Recorder()
View Source
34    def __init__(self) -> None:
35        """Initializes the Recorder."""
36
37        self.recording: list[tuple[str, float]] = []
38        self._start_stamp = time.time()

Initializes the Recorder.

#   def write(self, data: str) -> None:
View Source
46    def write(self, data: str) -> None:
47        """Writes to the recorder."""
48
49        self.recording.append((data, time.time() - self._start_stamp))

Writes to the recorder.

#   def export_text(self) -> str:
View Source
51    def export_text(self) -> str:
52        """Exports current content as plain text."""
53
54        return strip_ansi(self._content)

Exports current content as plain text.

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

Exports current content as HTML.

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

#   def export_svg( self, prefix: str | None = None, inline_styles: bool = False, title: str = 'PyTermGUI' ) -> str:
View Source
68    def export_svg(
69        self,
70        prefix: str | None = None,
71        inline_styles: bool = False,
72        title: str = "PyTermGUI",
73    ) -> str:
74        """Exports current content as SVG.
75
76        For help on the arguments, see `pytermgui.html.to_svg`.
77        """
78
79        from .exporters import to_svg  # pylint: disable=import-outside-toplevel
80
81        return to_svg(
82            self._content, prefix=prefix, inline_styles=inline_styles, title=title
83        )

Exports current content as SVG.

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

#   def save_plain(self, filename: str) -> None:
View Source
85    def save_plain(self, filename: str) -> None:
86        """Exports plain text content to the given file.
87
88        Args:
89            filename: The file to save to.
90        """
91
92        with open(filename, "w", encoding="utf-8") as file:
93            file.write(self.export_text())

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:
View Source
 95    def save_html(
 96        self,
 97        filename: str | None = None,
 98        prefix: str | None = None,
 99        inline_styles: bool = False,
100    ) -> None:
101        """Exports HTML content to the given file.
102
103        For help on the arguments, see `pytermgui.exporters.to_html`.
104
105        Args:
106            filename: The file to save to. If the filename does not contain the '.html'
107                extension it will be appended to the end.
108        """
109
110        if filename is None:
111            filename = f"PTG_{time.time():%Y-%m-%d %H:%M:%S}.html"
112
113        if not filename.endswith(".html"):
114            filename += ".html"
115
116        with open(filename, "w", encoding="utf-8") as file:
117            file.write(self.export_html(prefix=prefix, inline_styles=inline_styles))

Exports HTML content to the given file.

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

Args
  • filename: The file to save to. If the filename does not contain the '.html' extension it will be appended to the end.
#   def save_svg( self, filename: str | None = None, prefix: str | None = None, inline_styles: bool = False, title: str = 'PyTermGUI' ) -> None:
View Source
119    def save_svg(
120        self,
121        filename: str | None = None,
122        prefix: str | None = None,
123        inline_styles: bool = False,
124        title: str = "PyTermGUI",
125    ) -> None:
126        """Exports SVG content to the given file.
127
128        For help on the arguments, see `pytermgui.exporters.to_svg`.
129
130        Args:
131            filename: The file to save to. If the filename does not contain the '.svg'
132                extension it will be appended to the end.
133        """
134
135        if filename is None:
136            timeval = datetime.now()
137            filename = f"PTG_{timeval:%Y-%m-%d_%H:%M:%S}.svg"
138
139        if not filename.endswith(".svg"):
140            filename += ".svg"
141
142        with open(filename, "w", encoding="utf-8") as file:
143            file.write(
144                self.export_svg(prefix=prefix, inline_styles=inline_styles, title=title)
145            )

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):
View Source
148class ColorSystem(Enum):
149    """An enumeration of various terminal-supported colorsystems."""
150
151    NO_COLOR = -1
152    """No-color terminal. See https://no-color.org/."""
153
154    STANDARD = 0
155    """Standard 3-bit colorsystem of the basic 16 colors."""
156
157    EIGHT_BIT = 1
158    """xterm 8-bit colors, 0-256."""
159
160    TRUE = 2
161    """'True' color, a.k.a. 24-bit RGB colors."""
162
163    def __ge__(self, other):
164        """Comparison: self >= other."""
165
166        if self.__class__ is other.__class__:
167            return self.value >= other.value
168
169        return NotImplemented
170
171    def __gt__(self, other):
172        """Comparison: self > other."""
173
174        if self.__class__ is other.__class__:
175            return self.value > other.value
176
177        return NotImplemented
178
179    def __le__(self, other):
180        """Comparison: self <= other."""
181
182        if self.__class__ is other.__class__:
183            return self.value <= other.value
184
185        return NotImplemented
186
187    def __lt__(self, other):
188        """Comparison: self < other."""
189
190        if self.__class__ is other.__class__:
191            return self.value < other.value
192
193        return NotImplemented

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