pytermgui.widgets.base

The basic building blocks making up the Widget system.

  1"""
  2The basic building blocks making up the Widget system.
  3"""
  4
  5# The classes defined here need more than 7 instance attributes,
  6# and there is no cyclic import during runtime.
  7# pylint: disable=too-many-instance-attributes, cyclic-import
  8
  9from __future__ import annotations
 10
 11from copy import deepcopy
 12from inspect import signature
 13from typing import Any, Callable, Generator, Iterator, Optional, Type, Union
 14
 15from ..ansi_interface import MouseAction, MouseEvent, reset
 16from ..enums import HorizontalAlignment, SizePolicy, WidgetChange
 17from ..fancy_repr import FancyYield
 18from ..helpers import break_line
 19from ..input import keys
 20from ..markup import get_markup
 21from ..regex import real_length
 22from ..terminal import Terminal, get_terminal
 23from . import styles as w_styles
 24
 25__all__ = ["Widget", "Label"]
 26
 27BoundCallback = Callable[..., Any]
 28WidgetType = Union["Widget", Type["Widget"]]
 29
 30
 31def _set_obj_or_cls_style(
 32    obj_or_cls: Type[Widget] | Widget, key: str, value: w_styles.StyleType
 33) -> Type[Widget] | Widget:
 34    """Sets a style for an object or class
 35
 36    Args:
 37        obj_or_cls: The Widget instance or type to update.
 38        key: The style key.
 39        value: The new style.
 40
 41    Returns:
 42        Type[Widget] | Widget: The updated class.
 43
 44    Raises:
 45        See `pytermgui.widgets.styles.StyleManager`.
 46    """
 47
 48    obj_or_cls.styles[key] = value
 49
 50    return obj_or_cls
 51
 52
 53def _set_obj_or_cls_char(
 54    obj_or_cls: Type[Widget] | Widget, key: str, value: w_styles.CharType
 55) -> Type[Widget] | Widget:
 56    """Sets a char for an object or class
 57
 58    Args:
 59        obj_or_cls: The Widget instance or type to update.
 60        key: The char key.
 61        value: The new char.
 62
 63    Returns:
 64        Type[Widget] | Widget: The updated class.
 65
 66    Raises:
 67        KeyError: The char key provided is invalid.
 68    """
 69
 70    if not key in obj_or_cls.chars.keys():
 71        raise KeyError(f"Char {key} is not valid for {obj_or_cls}!")
 72
 73    obj_or_cls.chars[key] = value
 74
 75    return obj_or_cls
 76
 77
 78class Widget:  # pylint: disable=too-many-public-methods
 79    """The base of the Widget system"""
 80
 81    set_style = classmethod(_set_obj_or_cls_style)
 82    set_char = classmethod(_set_obj_or_cls_char)
 83
 84    styles = w_styles.StyleManager()
 85    """Default styles for this class"""
 86
 87    chars: dict[str, w_styles.CharType] = {}
 88    """Default characters for this class"""
 89
 90    keys: dict[str, set[str]] = {}
 91    """Groups of keys that are used in `handle_key`"""
 92
 93    serialized: list[str] = [
 94        "id",
 95        "pos",
 96        "depth",
 97        "width",
 98        "height",
 99        "selected_index",
100        "selectables_length",
101    ]
102    """Fields of widget that shall be serialized by `pytermgui.serializer.Serializer`"""
103
104    # This class is loaded after this module,
105    # and thus mypy doesn't see its existence.
106    _id_manager: Optional["_IDManager"] = None  # type: ignore
107
108    is_bindable = False
109    """Allow binding support"""
110
111    size_policy = SizePolicy.get_default()
112    """`pytermgui.enums.SizePolicy` to set widget's width according to"""
113
114    parent_align = HorizontalAlignment.get_default()
115    """`pytermgui.enums.HorizontalAlignment` to align widget by"""
116
117    from_data: Callable[..., Widget | list[Widget] | None]
118
119    # We cannot import boxes here due to cyclic imports.
120    box: Any
121
122    def __init__(self, **attrs: Any) -> None:
123        """Initialize object"""
124
125        self.set_style = lambda key, value: _set_obj_or_cls_style(self, key, value)
126        self.set_char = lambda key, value: _set_obj_or_cls_char(self, key, value)
127
128        self.width = 1
129        self.height = 1
130        self.pos = self.terminal.origin
131
132        self.depth = 0
133
134        self.styles = type(self).styles.branch(self)
135        self.chars = type(self).chars.copy()
136
137        self.parent: Widget | None = None
138        self.selected_index: int | None = None
139
140        self._selectables_length = 0
141        self._id: Optional[str] = None
142        self._serialized_fields = type(self).serialized
143        self._bindings: dict[str | Type[MouseEvent], tuple[BoundCallback, str]] = {}
144        self._relative_width: float | None = None
145        self._previous_state: tuple[tuple[int, int], list[str]] | None = None
146
147        self.positioned_line_buffer: list[tuple[tuple[int, int], str]] = []
148
149        for attr, value in attrs.items():
150            setattr(self, attr, value)
151
152    def __repr__(self) -> str:
153        """Return repr string of this widget.
154
155        Returns:
156            Whatever this widget's `debug` method gives.
157        """
158
159        return self.debug()
160
161    def __fancy_repr__(self) -> Generator[FancyYield, None, None]:
162        """Yields the repr of this object, then a preview of it."""
163
164        yield self.debug()
165        yield "\n\n"
166        yield {
167            "text": "\n".join((line + reset() for line in self.get_lines())),
168            "highlight": False,
169        }
170
171    def __iter__(self) -> Iterator[Widget]:
172        """Return self for iteration"""
173
174        yield self
175
176    @property
177    def bindings(self) -> dict[str | Type[MouseEvent], tuple[BoundCallback, str]]:
178        """Gets a copy of the bindings internal dictionary.
179
180        Returns:
181            A copy of the internal bindings dictionary, such as:
182
183            ```
184            {
185                "*": (star_callback, "This is a callback activated when '*' is pressed.")
186            }
187            ```
188        """
189
190        return self._bindings.copy()
191
192    @property
193    def id(self) -> Optional[str]:  # pylint: disable=invalid-name
194        """Gets this widget's id property
195
196        Returns:
197            The id string if one is present, None otherwise.
198        """
199
200        return self._id
201
202    @id.setter
203    def id(self, value: str) -> None:  # pylint: disable=invalid-name
204        """Registers a widget to the Widget._id_manager.
205
206        If this widget already had an id, the old value is deregistered
207        before the new one is assigned.
208
209        Args:
210            value: The new id this widget will be registered as.
211        """
212
213        if self._id == value:
214            return
215
216        manager = Widget._id_manager
217        assert manager is not None
218
219        old = manager.get_id(self)
220        if old is not None:
221            manager.deregister(old)
222
223        self._id = value
224        manager.register(self)
225
226    @property
227    def selectables_length(self) -> int:
228        """Gets how many selectables this widget contains.
229
230        Returns:
231            An integer describing the amount of selectables in this widget.
232        """
233
234        return self._selectables_length
235
236    @property
237    def selectables(self) -> list[tuple[Widget, int]]:
238        """Gets a list of all selectables within this widget
239
240        Returns:
241            A list of tuples. In the default implementation this will be
242            a list of one tuple, containing a reference to `self`, as well
243            as the lowest index, 0.
244        """
245
246        return [(self, 0)]
247
248    @property
249    def is_selectable(self) -> bool:
250        """Determines whether this widget has any selectables.
251
252        Returns:
253            A boolean, representing `self.selectables_length != 0`.
254        """
255
256        return self.selectables_length != 0
257
258    @property
259    def static_width(self) -> int:
260        """Allows for a shorter way of setting a width, and SizePolicy.STATIC.
261
262        Args:
263            value: The new width integer.
264
265        Returns:
266            None, as this is setter only.
267        """
268
269        return None  # type: ignore
270
271    @static_width.setter
272    def static_width(self, value: int) -> None:
273        """See the static_width getter."""
274
275        self.width = value
276        self.size_policy = SizePolicy.STATIC
277
278    @property
279    def relative_width(self) -> float | None:
280        """Sets this widget's relative width, and changes size_policy to RELATIVE.
281
282        The value is clamped to 1.0.
283
284        If a Container holds a width of 30, and it has a subwidget with a relative
285        width of 0.5, it will be resized to 15.
286
287        Args:
288            value: The multiplier to apply to the parent's width.
289
290        Returns:
291            The current relative_width.
292        """
293
294        return self._relative_width
295
296    @relative_width.setter
297    def relative_width(self, value: float) -> None:
298        """See the relative_width getter."""
299
300        self.size_policy = SizePolicy.RELATIVE
301        self._relative_width = min(1.0, value)
302
303    @property
304    def terminal(self) -> Terminal:
305        """Returns the current global terminal instance."""
306
307        return get_terminal()
308
309    def get_change(self) -> WidgetChange | None:
310        """Determines whether widget lines changed since the last call to this function."""
311
312        lines = self.get_lines()
313
314        if self._previous_state is None:
315            self._previous_state = (self.width, self.height), lines
316            return WidgetChange.LINES
317
318        lines = self.get_lines()
319        (old_width, old_height), old_lines = self._previous_state
320
321        self._previous_state = (self.width, self.height), lines
322
323        if old_width != self.width and old_height != self.height:
324            return WidgetChange.SIZE
325
326        if old_width != self.width:
327            return WidgetChange.WIDTH
328
329        if old_height != self.height:
330            return WidgetChange.HEIGHT
331
332        if old_lines != lines:
333            return WidgetChange.LINES
334
335        return None
336
337    def contains(self, pos: tuple[int, int]) -> bool:
338        """Determines whether widget contains `pos`.
339
340        Args:
341            pos: Position to compare.
342
343        Returns:
344            Boolean describing whether the position is inside
345              this widget.
346        """
347
348        rect = self.pos, (
349            self.pos[0] + self.width,
350            self.pos[1] + self.height,
351        )
352
353        (left, top), (right, bottom) = rect
354
355        return left <= pos[0] < right and top <= pos[1] < bottom
356
357    def handle_mouse(self, event: MouseEvent) -> bool:
358        """Tries to call the most specific mouse handler function available.
359
360        This function looks for a set of mouse action handlers. Each handler follows
361        the format
362
363            on_{event_name}
364
365        For example, the handler triggered on MouseAction.LEFT_CLICK would be
366        `on_left_click`. If no handler is found nothing is done.
367
368        You can also define more general handlers, for example to group left & right
369        clicks you can use `on_click`, and to catch both up and down scroll you can use
370        `on_scroll`. General handlers are only used if they are the most specific ones,
371        i.e. there is no "specific" handler.
372
373        Args:
374            event: The event to handle.
375
376        Returns:
377            Whether the parent of this widget should treat it as one to "stick" events
378            to, e.g. to keep sending mouse events to it. One can "unstick" a widget by
379            returning False in the handler.
380        """
381
382        def _get_names(action: MouseAction) -> tuple[str, ...]:
383            if action.value in ["hover", "release"]:
384                return (action.value,)
385
386            parts = action.value.split("_")
387
388            # left click & right click
389            if parts[0] in ["left", "right"]:
390                return (action.value, parts[1])
391
392            # scroll up & down
393            return (action.value, parts[0])
394
395        possible_names = _get_names(event.action)
396        for name in possible_names:
397            if hasattr(self, f"on_{name}"):
398                handle = getattr(self, f"on_{name}")
399
400                return handle(event)
401
402        return False
403
404    def handle_key(self, key: str) -> bool:
405        """Handles a mouse event, returning its success.
406
407        Args:
408            key: String representation of input string.
409              The `pytermgui.input.keys` object can be
410              used to retrieve special keys.
411
412        Returns:
413            A boolean describing whether the key was handled.
414        """
415
416        return False and hasattr(self, key)
417
418    def serialize(self) -> dict[str, Any]:
419        """Serializes a widget.
420
421        The fields looked at are defined `Widget.serialized`. Note that
422        this method is not very commonly used at the moment, so it might
423        not have full functionality in non-nuclear widgets.
424
425        Returns:
426            Dictionary of widget attributes. The dictionary will always
427            have a `type` field. Any styles are converted into markup
428            strings during serialization, so they can be loaded again in
429            their original form.
430
431            Example return:
432            ```
433                {
434                    "type": "Label",
435                    "value": "[210 bold]I am a title",
436                    "parent_align": 0,
437                    ...
438                }
439            ```
440        """
441
442        fields = self._serialized_fields
443
444        out: dict[str, Any] = {"type": type(self).__name__}
445        for key in fields:
446            # Detect styled values
447            if key.startswith("*"):
448                style = True
449                key = key[1:]
450            else:
451                style = False
452
453            value = getattr(self, key)
454
455            # Convert styled value into markup
456            if style:
457                style_call = self._get_style(key)
458                if isinstance(value, list):
459                    out[key] = [get_markup(style_call(char)) for char in value]
460                else:
461                    out[key] = get_markup(style_call(value))
462
463                continue
464
465            out[key] = value
466
467        # The chars need to be handled separately
468        out["chars"] = {}
469        for key, value in self.chars.items():
470            style_call = self._get_style(key)
471
472            if isinstance(value, list):
473                out["chars"][key] = [get_markup(style_call(char)) for char in value]
474            else:
475                out["chars"][key] = get_markup(style_call(value))
476
477        return out
478
479    def copy(self) -> Widget:
480        """Creates a deep copy of this widget"""
481
482        return deepcopy(self)
483
484    def _get_style(self, key: str) -> w_styles.DepthlessStyleType:
485        """Gets style call from its key.
486
487        This is analogous to using `self.styles.{key}`
488
489        Args:
490            key: A key into the widget's style manager.
491
492        Returns:
493            A `pytermgui.styles.StyleCall` object containing the referenced
494            style. StyleCall objects should only be used internally inside a
495            widget.
496
497        Raises:
498            KeyError: Style key is invalid.
499        """
500
501        return self.styles[key]
502
503    def _get_char(self, key: str) -> w_styles.CharType:
504        """Gets character from its key.
505
506        Args:
507            key: A key into the widget's chars dictionary.
508
509        Returns:
510            Either a `list[str]` or a simple `str`, depending on the character.
511
512        Raises:
513            KeyError: Style key is invalid.
514        """
515
516        chars = self.chars[key]
517        if isinstance(chars, str):
518            return chars
519
520        return chars.copy()
521
522    def get_lines(self) -> list[str]:
523        """Gets lines representing this widget.
524
525        These lines have to be equal to the widget in length. All
526        widgets must provide this method. Make sure to keep it performant,
527        as it will be called very often, often multiple times per WindowManager frame.
528
529        Any longer actions should be done outside of this method, and only their
530        result should be looked up here.
531
532        Returns:
533            Nothing by default.
534
535        Raises:
536            NotImplementedError: As this method is required for **all** widgets, not
537                having it defined will raise NotImplementedError.
538        """
539
540        raise NotImplementedError(f"get_lines() is not defined for type {type(self)}.")
541
542    def bind(
543        self, key: str, action: BoundCallback, description: Optional[str] = None
544    ) -> None:
545        """Binds an action to a keypress.
546
547        This function is only called by implementations above this layer. To use this
548        functionality use `pytermgui.window_manager.WindowManager`, or write your own
549        custom layer.
550
551        Special keys:
552        - keys.ANY_KEY: Any and all keypresses execute this binding.
553        - keys.MouseAction: Any and all mouse inputs execute this binding.
554
555        Args:
556            key: The key that the action will be bound to.
557            action: The action executed when the key is pressed.
558            description: An optional description for this binding. It is not really
559                used anywhere, but you can provide a helper menu and display them.
560
561        Raises:
562            TypeError: This widget is not bindable, i.e. widget.is_bindable == False.
563        """
564
565        if not self.is_bindable:
566            raise TypeError(f"Widget of type {type(self)} does not accept bindings.")
567
568        if description is None:
569            description = f"Binding of {key} to {action}"
570
571        self._bindings[key] = (action, description)
572
573    def unbind(self, key: str) -> None:
574        """Unbinds the given key."""
575
576        del self._bindings[key]
577
578    def execute_binding(self, key: Any, ignore_any: bool = False) -> bool:
579        """Executes a binding belonging to key, when present.
580
581        Use this method inside custom widget `handle_keys` methods, or to run a callback
582        without its corresponding key having been pressed.
583
584        Args:
585            key: Usually a string, indexing into the `_bindings` dictionary. These are the
586              same strings as defined in `Widget.bind`.
587            ignore_any: If set, `keys.ANY_KEY` bindings will not be executed.
588
589        Returns:
590            True if the binding was found, False otherwise. Bindings will always be
591              executed if they are found.
592        """
593
594        # Execute special binding
595        if not ignore_any and keys.ANY_KEY in self._bindings:
596            method, _ = self._bindings[keys.ANY_KEY]
597            method(self, key)
598
599        if key in self._bindings:
600            method, _ = self._bindings[key]
601            method(self, key)
602
603            return True
604
605        return False
606
607    def select(self, index: int | None = None) -> None:
608        """Selects a part of this Widget.
609
610        Args:
611            index: The index to select.
612
613        Raises:
614            TypeError: This widget has no selectables, i.e. widget.is_selectable == False.
615        """
616
617        if not self.is_selectable:
618            raise TypeError(f"Object of type {type(self)} has no selectables.")
619
620        if index is not None:
621            index = min(max(0, index), self.selectables_length - 1)
622        self.selected_index = index
623
624    def print(self) -> None:
625        """Prints this widget"""
626
627        for line in self.get_lines():
628            print(line)
629
630    def debug(self) -> str:
631        """Returns identifiable information about this widget.
632
633        This method is used to easily differentiate between widgets. By default, all widget's
634        __repr__ method is an alias to this. The signature of each widget is used to generate
635        the return value.
636
637        Returns:
638            A string almost exactly matching the line of code that could have defined the widget.
639
640            Example return:
641
642            ```
643            Container(Label(value="This is a label", padding=0),
644            Button(label="This is a button", padding=0), **attrs)
645            ```
646
647        """
648
649        constructor = "("
650        for name in signature(getattr(self, "__init__")).parameters:
651            current = ""
652            if name == "attrs":
653                current += "**attrs"
654                continue
655
656            if len(constructor) > 1:
657                current += ", "
658
659            current += name
660
661            attr = getattr(self, name, None)
662            if attr is None:
663                continue
664
665            current += "="
666
667            if isinstance(attr, str):
668                current += f'"{attr}"'
669            else:
670                current += str(attr)
671
672            constructor += current
673
674        constructor += ")"
675
676        return type(self).__name__ + constructor
677
678
679class Label(Widget):
680    """A Widget to display a string
681
682    By default, this widget uses `pytermgui.widgets.styles.MARKUP`. This
683    allows it to house markup text that is parsed before display, such as:
684
685    ```python3
686    import pytermgui as ptg
687
688    with ptg.alt_buffer():
689        root = ptg.Container(
690            ptg.Label("[italic 141 bold]This is some [green]fancy [white inverse]text!")
691        )
692        root.print()
693        ptg.getch()
694    ```
695
696    <p style="text-align: center">
697     <img
698      src="https://github.com/bczsalba/pytermgui/blob/master/assets/docs/widgets/label.png?raw=true"
699      width=100%>
700    </p>
701    """
702
703    serialized = Widget.serialized + ["*value", "align", "padding"]
704    styles = w_styles.StyleManager(value=w_styles.MARKUP)
705
706    def __init__(
707        self,
708        value: str = "",
709        style: str | w_styles.StyleValue = "",
710        padding: int = 0,
711        non_first_padding: int = 0,
712        **attrs: Any,
713    ) -> None:
714        """Initializes a Label.
715
716        Args:
717            value: The value of this string. Using the default value style
718                (`pytermgui.widgets.styles.MARKUP`),
719            style: A pre-set value for self.styles.value.
720            padding: The number of space (" ") characters to prepend to every line after
721                line breaking.
722            non_first_padding: The number of space characters to prepend to every
723                non-first line of `get_lines`. This is applied on top of `padding`.
724        """
725
726        super().__init__(**attrs)
727
728        self.value = value
729        self.padding = padding
730        self.non_first_padding = non_first_padding
731        self.width = real_length(value) + self.padding
732
733        if style != "":
734            self.styles.value = style
735
736    def get_lines(self) -> list[str]:
737        """Get lines representing this Label, breaking lines as necessary"""
738
739        lines = []
740        limit = self.width - self.padding
741        broken = break_line(
742            self.styles.value(self.value),
743            limit=limit,
744            non_first_limit=limit - self.non_first_padding,
745        )
746
747        for i, line in enumerate(broken):
748            if i == 0:
749                lines.append(self.padding * " " + line)
750                continue
751
752            lines.append(self.padding * " " + self.non_first_padding * " " + line)
753
754        return lines or [""]
755
756
757class ScrollableWidget(Widget):
758    """A widget with some scrolling helper methods.
759
760    This is not an implementation of the scrolling behaviour itself, just the
761    user-facing API for it.
762
763    It provides a `_scroll_offset` attribute, which is an integer describing the current
764    scroll state offset from the top, as well as some methods to modify the state."""
765
766    def __init__(self, **attrs: Any) -> None:
767        """Initializes the scrollable widget."""
768
769        super().__init__(**attrs)
770
771        self._max_scroll = 0
772        self._scroll_offset = 0
773
774    def scroll(self, offset: int) -> bool:
775        """Scrolls to given offset, returns the new scroll_offset.
776
777        Args:
778            offset: The amount to scroll by. Positive offsets scroll down,
779                negative up.
780
781        Returns:
782            True if the scroll offset changed, False otherwise.
783        """
784
785        base = self._scroll_offset
786
787        self._scroll_offset = min(
788            max(0, self._scroll_offset + offset), self._max_scroll
789        )
790
791        return base != self._scroll_offset
792
793    def scroll_end(self, end: int) -> int:
794        """Scrolls to either top or bottom end of this object.
795
796        Args:
797            end: The offset to scroll to. 0 goes to the very top, -1 to the
798                very bottom.
799
800        Returns:
801            True if the scroll offset changed, False otherwise.
802        """
803
804        base = self._scroll_offset
805
806        if end == 0:
807            self._scroll_offset = 0
808
809        elif end == -1:
810            self._scroll_offset = self._max_scroll
811
812        return base != self._scroll_offset
813
814    def get_lines(self) -> list[str]:
815        ...
class Widget:
 79class Widget:  # pylint: disable=too-many-public-methods
 80    """The base of the Widget system"""
 81
 82    set_style = classmethod(_set_obj_or_cls_style)
 83    set_char = classmethod(_set_obj_or_cls_char)
 84
 85    styles = w_styles.StyleManager()
 86    """Default styles for this class"""
 87
 88    chars: dict[str, w_styles.CharType] = {}
 89    """Default characters for this class"""
 90
 91    keys: dict[str, set[str]] = {}
 92    """Groups of keys that are used in `handle_key`"""
 93
 94    serialized: list[str] = [
 95        "id",
 96        "pos",
 97        "depth",
 98        "width",
 99        "height",
100        "selected_index",
101        "selectables_length",
102    ]
103    """Fields of widget that shall be serialized by `pytermgui.serializer.Serializer`"""
104
105    # This class is loaded after this module,
106    # and thus mypy doesn't see its existence.
107    _id_manager: Optional["_IDManager"] = None  # type: ignore
108
109    is_bindable = False
110    """Allow binding support"""
111
112    size_policy = SizePolicy.get_default()
113    """`pytermgui.enums.SizePolicy` to set widget's width according to"""
114
115    parent_align = HorizontalAlignment.get_default()
116    """`pytermgui.enums.HorizontalAlignment` to align widget by"""
117
118    from_data: Callable[..., Widget | list[Widget] | None]
119
120    # We cannot import boxes here due to cyclic imports.
121    box: Any
122
123    def __init__(self, **attrs: Any) -> None:
124        """Initialize object"""
125
126        self.set_style = lambda key, value: _set_obj_or_cls_style(self, key, value)
127        self.set_char = lambda key, value: _set_obj_or_cls_char(self, key, value)
128
129        self.width = 1
130        self.height = 1
131        self.pos = self.terminal.origin
132
133        self.depth = 0
134
135        self.styles = type(self).styles.branch(self)
136        self.chars = type(self).chars.copy()
137
138        self.parent: Widget | None = None
139        self.selected_index: int | None = None
140
141        self._selectables_length = 0
142        self._id: Optional[str] = None
143        self._serialized_fields = type(self).serialized
144        self._bindings: dict[str | Type[MouseEvent], tuple[BoundCallback, str]] = {}
145        self._relative_width: float | None = None
146        self._previous_state: tuple[tuple[int, int], list[str]] | None = None
147
148        self.positioned_line_buffer: list[tuple[tuple[int, int], str]] = []
149
150        for attr, value in attrs.items():
151            setattr(self, attr, value)
152
153    def __repr__(self) -> str:
154        """Return repr string of this widget.
155
156        Returns:
157            Whatever this widget's `debug` method gives.
158        """
159
160        return self.debug()
161
162    def __fancy_repr__(self) -> Generator[FancyYield, None, None]:
163        """Yields the repr of this object, then a preview of it."""
164
165        yield self.debug()
166        yield "\n\n"
167        yield {
168            "text": "\n".join((line + reset() for line in self.get_lines())),
169            "highlight": False,
170        }
171
172    def __iter__(self) -> Iterator[Widget]:
173        """Return self for iteration"""
174
175        yield self
176
177    @property
178    def bindings(self) -> dict[str | Type[MouseEvent], tuple[BoundCallback, str]]:
179        """Gets a copy of the bindings internal dictionary.
180
181        Returns:
182            A copy of the internal bindings dictionary, such as:
183
184            ```
185            {
186                "*": (star_callback, "This is a callback activated when '*' is pressed.")
187            }
188            ```
189        """
190
191        return self._bindings.copy()
192
193    @property
194    def id(self) -> Optional[str]:  # pylint: disable=invalid-name
195        """Gets this widget's id property
196
197        Returns:
198            The id string if one is present, None otherwise.
199        """
200
201        return self._id
202
203    @id.setter
204    def id(self, value: str) -> None:  # pylint: disable=invalid-name
205        """Registers a widget to the Widget._id_manager.
206
207        If this widget already had an id, the old value is deregistered
208        before the new one is assigned.
209
210        Args:
211            value: The new id this widget will be registered as.
212        """
213
214        if self._id == value:
215            return
216
217        manager = Widget._id_manager
218        assert manager is not None
219
220        old = manager.get_id(self)
221        if old is not None:
222            manager.deregister(old)
223
224        self._id = value
225        manager.register(self)
226
227    @property
228    def selectables_length(self) -> int:
229        """Gets how many selectables this widget contains.
230
231        Returns:
232            An integer describing the amount of selectables in this widget.
233        """
234
235        return self._selectables_length
236
237    @property
238    def selectables(self) -> list[tuple[Widget, int]]:
239        """Gets a list of all selectables within this widget
240
241        Returns:
242            A list of tuples. In the default implementation this will be
243            a list of one tuple, containing a reference to `self`, as well
244            as the lowest index, 0.
245        """
246
247        return [(self, 0)]
248
249    @property
250    def is_selectable(self) -> bool:
251        """Determines whether this widget has any selectables.
252
253        Returns:
254            A boolean, representing `self.selectables_length != 0`.
255        """
256
257        return self.selectables_length != 0
258
259    @property
260    def static_width(self) -> int:
261        """Allows for a shorter way of setting a width, and SizePolicy.STATIC.
262
263        Args:
264            value: The new width integer.
265
266        Returns:
267            None, as this is setter only.
268        """
269
270        return None  # type: ignore
271
272    @static_width.setter
273    def static_width(self, value: int) -> None:
274        """See the static_width getter."""
275
276        self.width = value
277        self.size_policy = SizePolicy.STATIC
278
279    @property
280    def relative_width(self) -> float | None:
281        """Sets this widget's relative width, and changes size_policy to RELATIVE.
282
283        The value is clamped to 1.0.
284
285        If a Container holds a width of 30, and it has a subwidget with a relative
286        width of 0.5, it will be resized to 15.
287
288        Args:
289            value: The multiplier to apply to the parent's width.
290
291        Returns:
292            The current relative_width.
293        """
294
295        return self._relative_width
296
297    @relative_width.setter
298    def relative_width(self, value: float) -> None:
299        """See the relative_width getter."""
300
301        self.size_policy = SizePolicy.RELATIVE
302        self._relative_width = min(1.0, value)
303
304    @property
305    def terminal(self) -> Terminal:
306        """Returns the current global terminal instance."""
307
308        return get_terminal()
309
310    def get_change(self) -> WidgetChange | None:
311        """Determines whether widget lines changed since the last call to this function."""
312
313        lines = self.get_lines()
314
315        if self._previous_state is None:
316            self._previous_state = (self.width, self.height), lines
317            return WidgetChange.LINES
318
319        lines = self.get_lines()
320        (old_width, old_height), old_lines = self._previous_state
321
322        self._previous_state = (self.width, self.height), lines
323
324        if old_width != self.width and old_height != self.height:
325            return WidgetChange.SIZE
326
327        if old_width != self.width:
328            return WidgetChange.WIDTH
329
330        if old_height != self.height:
331            return WidgetChange.HEIGHT
332
333        if old_lines != lines:
334            return WidgetChange.LINES
335
336        return None
337
338    def contains(self, pos: tuple[int, int]) -> bool:
339        """Determines whether widget contains `pos`.
340
341        Args:
342            pos: Position to compare.
343
344        Returns:
345            Boolean describing whether the position is inside
346              this widget.
347        """
348
349        rect = self.pos, (
350            self.pos[0] + self.width,
351            self.pos[1] + self.height,
352        )
353
354        (left, top), (right, bottom) = rect
355
356        return left <= pos[0] < right and top <= pos[1] < bottom
357
358    def handle_mouse(self, event: MouseEvent) -> bool:
359        """Tries to call the most specific mouse handler function available.
360
361        This function looks for a set of mouse action handlers. Each handler follows
362        the format
363
364            on_{event_name}
365
366        For example, the handler triggered on MouseAction.LEFT_CLICK would be
367        `on_left_click`. If no handler is found nothing is done.
368
369        You can also define more general handlers, for example to group left & right
370        clicks you can use `on_click`, and to catch both up and down scroll you can use
371        `on_scroll`. General handlers are only used if they are the most specific ones,
372        i.e. there is no "specific" handler.
373
374        Args:
375            event: The event to handle.
376
377        Returns:
378            Whether the parent of this widget should treat it as one to "stick" events
379            to, e.g. to keep sending mouse events to it. One can "unstick" a widget by
380            returning False in the handler.
381        """
382
383        def _get_names(action: MouseAction) -> tuple[str, ...]:
384            if action.value in ["hover", "release"]:
385                return (action.value,)
386
387            parts = action.value.split("_")
388
389            # left click & right click
390            if parts[0] in ["left", "right"]:
391                return (action.value, parts[1])
392
393            # scroll up & down
394            return (action.value, parts[0])
395
396        possible_names = _get_names(event.action)
397        for name in possible_names:
398            if hasattr(self, f"on_{name}"):
399                handle = getattr(self, f"on_{name}")
400
401                return handle(event)
402
403        return False
404
405    def handle_key(self, key: str) -> bool:
406        """Handles a mouse event, returning its success.
407
408        Args:
409            key: String representation of input string.
410              The `pytermgui.input.keys` object can be
411              used to retrieve special keys.
412
413        Returns:
414            A boolean describing whether the key was handled.
415        """
416
417        return False and hasattr(self, key)
418
419    def serialize(self) -> dict[str, Any]:
420        """Serializes a widget.
421
422        The fields looked at are defined `Widget.serialized`. Note that
423        this method is not very commonly used at the moment, so it might
424        not have full functionality in non-nuclear widgets.
425
426        Returns:
427            Dictionary of widget attributes. The dictionary will always
428            have a `type` field. Any styles are converted into markup
429            strings during serialization, so they can be loaded again in
430            their original form.
431
432            Example return:
433            ```
434                {
435                    "type": "Label",
436                    "value": "[210 bold]I am a title",
437                    "parent_align": 0,
438                    ...
439                }
440            ```
441        """
442
443        fields = self._serialized_fields
444
445        out: dict[str, Any] = {"type": type(self).__name__}
446        for key in fields:
447            # Detect styled values
448            if key.startswith("*"):
449                style = True
450                key = key[1:]
451            else:
452                style = False
453
454            value = getattr(self, key)
455
456            # Convert styled value into markup
457            if style:
458                style_call = self._get_style(key)
459                if isinstance(value, list):
460                    out[key] = [get_markup(style_call(char)) for char in value]
461                else:
462                    out[key] = get_markup(style_call(value))
463
464                continue
465
466            out[key] = value
467
468        # The chars need to be handled separately
469        out["chars"] = {}
470        for key, value in self.chars.items():
471            style_call = self._get_style(key)
472
473            if isinstance(value, list):
474                out["chars"][key] = [get_markup(style_call(char)) for char in value]
475            else:
476                out["chars"][key] = get_markup(style_call(value))
477
478        return out
479
480    def copy(self) -> Widget:
481        """Creates a deep copy of this widget"""
482
483        return deepcopy(self)
484
485    def _get_style(self, key: str) -> w_styles.DepthlessStyleType:
486        """Gets style call from its key.
487
488        This is analogous to using `self.styles.{key}`
489
490        Args:
491            key: A key into the widget's style manager.
492
493        Returns:
494            A `pytermgui.styles.StyleCall` object containing the referenced
495            style. StyleCall objects should only be used internally inside a
496            widget.
497
498        Raises:
499            KeyError: Style key is invalid.
500        """
501
502        return self.styles[key]
503
504    def _get_char(self, key: str) -> w_styles.CharType:
505        """Gets character from its key.
506
507        Args:
508            key: A key into the widget's chars dictionary.
509
510        Returns:
511            Either a `list[str]` or a simple `str`, depending on the character.
512
513        Raises:
514            KeyError: Style key is invalid.
515        """
516
517        chars = self.chars[key]
518        if isinstance(chars, str):
519            return chars
520
521        return chars.copy()
522
523    def get_lines(self) -> list[str]:
524        """Gets lines representing this widget.
525
526        These lines have to be equal to the widget in length. All
527        widgets must provide this method. Make sure to keep it performant,
528        as it will be called very often, often multiple times per WindowManager frame.
529
530        Any longer actions should be done outside of this method, and only their
531        result should be looked up here.
532
533        Returns:
534            Nothing by default.
535
536        Raises:
537            NotImplementedError: As this method is required for **all** widgets, not
538                having it defined will raise NotImplementedError.
539        """
540
541        raise NotImplementedError(f"get_lines() is not defined for type {type(self)}.")
542
543    def bind(
544        self, key: str, action: BoundCallback, description: Optional[str] = None
545    ) -> None:
546        """Binds an action to a keypress.
547
548        This function is only called by implementations above this layer. To use this
549        functionality use `pytermgui.window_manager.WindowManager`, or write your own
550        custom layer.
551
552        Special keys:
553        - keys.ANY_KEY: Any and all keypresses execute this binding.
554        - keys.MouseAction: Any and all mouse inputs execute this binding.
555
556        Args:
557            key: The key that the action will be bound to.
558            action: The action executed when the key is pressed.
559            description: An optional description for this binding. It is not really
560                used anywhere, but you can provide a helper menu and display them.
561
562        Raises:
563            TypeError: This widget is not bindable, i.e. widget.is_bindable == False.
564        """
565
566        if not self.is_bindable:
567            raise TypeError(f"Widget of type {type(self)} does not accept bindings.")
568
569        if description is None:
570            description = f"Binding of {key} to {action}"
571
572        self._bindings[key] = (action, description)
573
574    def unbind(self, key: str) -> None:
575        """Unbinds the given key."""
576
577        del self._bindings[key]
578
579    def execute_binding(self, key: Any, ignore_any: bool = False) -> bool:
580        """Executes a binding belonging to key, when present.
581
582        Use this method inside custom widget `handle_keys` methods, or to run a callback
583        without its corresponding key having been pressed.
584
585        Args:
586            key: Usually a string, indexing into the `_bindings` dictionary. These are the
587              same strings as defined in `Widget.bind`.
588            ignore_any: If set, `keys.ANY_KEY` bindings will not be executed.
589
590        Returns:
591            True if the binding was found, False otherwise. Bindings will always be
592              executed if they are found.
593        """
594
595        # Execute special binding
596        if not ignore_any and keys.ANY_KEY in self._bindings:
597            method, _ = self._bindings[keys.ANY_KEY]
598            method(self, key)
599
600        if key in self._bindings:
601            method, _ = self._bindings[key]
602            method(self, key)
603
604            return True
605
606        return False
607
608    def select(self, index: int | None = None) -> None:
609        """Selects a part of this Widget.
610
611        Args:
612            index: The index to select.
613
614        Raises:
615            TypeError: This widget has no selectables, i.e. widget.is_selectable == False.
616        """
617
618        if not self.is_selectable:
619            raise TypeError(f"Object of type {type(self)} has no selectables.")
620
621        if index is not None:
622            index = min(max(0, index), self.selectables_length - 1)
623        self.selected_index = index
624
625    def print(self) -> None:
626        """Prints this widget"""
627
628        for line in self.get_lines():
629            print(line)
630
631    def debug(self) -> str:
632        """Returns identifiable information about this widget.
633
634        This method is used to easily differentiate between widgets. By default, all widget's
635        __repr__ method is an alias to this. The signature of each widget is used to generate
636        the return value.
637
638        Returns:
639            A string almost exactly matching the line of code that could have defined the widget.
640
641            Example return:
642
643            ```
644            Container(Label(value="This is a label", padding=0),
645            Button(label="This is a button", padding=0), **attrs)
646            ```
647
648        """
649
650        constructor = "("
651        for name in signature(getattr(self, "__init__")).parameters:
652            current = ""
653            if name == "attrs":
654                current += "**attrs"
655                continue
656
657            if len(constructor) > 1:
658                current += ", "
659
660            current += name
661
662            attr = getattr(self, name, None)
663            if attr is None:
664                continue
665
666            current += "="
667
668            if isinstance(attr, str):
669                current += f'"{attr}"'
670            else:
671                current += str(attr)
672
673            constructor += current
674
675        constructor += ")"
676
677        return type(self).__name__ + constructor

The base of the Widget system

Widget(**attrs: Any)
123    def __init__(self, **attrs: Any) -> None:
124        """Initialize object"""
125
126        self.set_style = lambda key, value: _set_obj_or_cls_style(self, key, value)
127        self.set_char = lambda key, value: _set_obj_or_cls_char(self, key, value)
128
129        self.width = 1
130        self.height = 1
131        self.pos = self.terminal.origin
132
133        self.depth = 0
134
135        self.styles = type(self).styles.branch(self)
136        self.chars = type(self).chars.copy()
137
138        self.parent: Widget | None = None
139        self.selected_index: int | None = None
140
141        self._selectables_length = 0
142        self._id: Optional[str] = None
143        self._serialized_fields = type(self).serialized
144        self._bindings: dict[str | Type[MouseEvent], tuple[BoundCallback, str]] = {}
145        self._relative_width: float | None = None
146        self._previous_state: tuple[tuple[int, int], list[str]] | None = None
147
148        self.positioned_line_buffer: list[tuple[tuple[int, int], str]] = []
149
150        for attr, value in attrs.items():
151            setattr(self, attr, value)

Initialize object

def set_style( obj_or_cls: Union[Type[pytermgui.widgets.base.Widget], pytermgui.widgets.base.Widget], key: str, value: Callable[[int, str], str]) -> Union[Type[pytermgui.widgets.base.Widget], pytermgui.widgets.base.Widget]:
32def _set_obj_or_cls_style(
33    obj_or_cls: Type[Widget] | Widget, key: str, value: w_styles.StyleType
34) -> Type[Widget] | Widget:
35    """Sets a style for an object or class
36
37    Args:
38        obj_or_cls: The Widget instance or type to update.
39        key: The style key.
40        value: The new style.
41
42    Returns:
43        Type[Widget] | Widget: The updated class.
44
45    Raises:
46        See `pytermgui.widgets.styles.StyleManager`.
47    """
48
49    obj_or_cls.styles[key] = value
50
51    return obj_or_cls

Sets a style for an object or class

Args
  • obj_or_cls: The Widget instance or type to update.
  • key: The style key.
  • value: The new style.
Returns

Type[Widget] | Widget: The updated class.

Raises
def set_char( obj_or_cls: Union[Type[pytermgui.widgets.base.Widget], pytermgui.widgets.base.Widget], key: str, value: Union[List[str], str]) -> Union[Type[pytermgui.widgets.base.Widget], pytermgui.widgets.base.Widget]:
54def _set_obj_or_cls_char(
55    obj_or_cls: Type[Widget] | Widget, key: str, value: w_styles.CharType
56) -> Type[Widget] | Widget:
57    """Sets a char for an object or class
58
59    Args:
60        obj_or_cls: The Widget instance or type to update.
61        key: The char key.
62        value: The new char.
63
64    Returns:
65        Type[Widget] | Widget: The updated class.
66
67    Raises:
68        KeyError: The char key provided is invalid.
69    """
70
71    if not key in obj_or_cls.chars.keys():
72        raise KeyError(f"Char {key} is not valid for {obj_or_cls}!")
73
74    obj_or_cls.chars[key] = value
75
76    return obj_or_cls

Sets a char for an object or class

Args
  • obj_or_cls: The Widget instance or type to update.
  • key: The char key.
  • value: The new char.
Returns

Type[Widget] | Widget: The updated class.

Raises
  • KeyError: The char key provided is invalid.
styles = {}

Default styles for this class

chars: dict[str, typing.Union[typing.List[str], str]] = {}

Default characters for this class

keys: dict[str, set[str]] = {}

Groups of keys that are used in handle_key

serialized: list[str] = ['id', 'pos', 'depth', 'width', 'height', 'selected_index', 'selectables_length']

Fields of widget that shall be serialized by pytermgui.serializer.Serializer

is_bindable = False

Allow binding support

size_policy = <SizePolicy.FILL: 0>

pytermgui.enums.SizePolicy to set widget's width according to

parent_align = <HorizontalAlignment.CENTER: 1>
def from_data( data: Any, **widget_args: Any) -> Union[pytermgui.widgets.base.Widget, list[pytermgui.widgets.containers.Splitter], NoneType]:
 49def auto(data: Any, **widget_args: Any) -> Optional[Widget | list[Splitter]]:
 50    """Creates a widget from specific data structures.
 51
 52    This conversion includes various widget classes, as well as some shorthands for
 53    more complex objects.  This method is called implicitly whenever a non-widget is
 54    attempted to be added to a Widget.
 55
 56
 57    Args:
 58        data: The structure to convert. See below for formats.
 59        **widget_args: Arguments passed straight to the widget constructor.
 60
 61    Returns:
 62        The widget or list of widgets created, or None if the passed structure could
 63        not be converted.
 64
 65    <br>
 66    <details style="text-align: left">
 67        <summary style="all: revert; cursor: pointer">Data structures:</summary>
 68
 69    `pytermgui.widgets.base.Label`:
 70
 71    * Created from `str`
 72    * Syntax example: `"Label value"`
 73
 74    `pytermgui.widgets.extra.Splitter`:
 75
 76    * Created from `tuple[Any]`
 77    * Syntax example: `(YourWidget(), "auto_syntax", ...)`
 78
 79    `pytermgui.widgets.extra.Splitter` prompt:
 80
 81    * Created from `dict[Any, Any]`
 82    * Syntax example: `{YourWidget(): "auto_syntax"}`
 83
 84    `pytermgui.widgets.buttons.Button`:
 85
 86    * Created from `list[str, pytermgui.widgets.buttons.MouseCallback]`
 87    * Syntax example: `["Button label", lambda target, caller: ...]`
 88
 89    `pytermgui.widgets.buttons.Checkbox`:
 90
 91    * Created from `list[bool, Callable[[bool], Any]]`
 92    * Syntax example: `[True, lambda checked: ...]`
 93
 94    `pytermgui.widgets.buttons.Toggle`:
 95
 96    * Created from `list[tuple[str, str], Callable[[str], Any]]`
 97    * Syntax example: `[("On", "Off"), lambda new_value: ...]`
 98    </details>
 99
100    Example:
101
102    ```python3
103    from pytermgui import Container
104    form = (
105        Container(id="form")
106        + "[157 bold]This is a title"
107        + ""
108        + {"[72 italic]Label1": "[210]Button1"}
109        + {"[72 italic]Label2": "[210]Button2"}
110        + {"[72 italic]Label3": "[210]Button3"}
111        + ""
112        + ["Submit", lambda _, button, your_submit_handler(button.parent)]
113    )
114    ```
115    """
116    # In my opinion, returning immediately after construction is much more readable.
117    # pylint: disable=too-many-return-statements
118
119    # Nothing to do.
120    if isinstance(data, Widget):
121        # Set all **widget_args
122        for key, value in widget_args.items():
123            setattr(data, key, value)
124
125        return data
126
127    # Label
128    if isinstance(data, str):
129        return Label(data, **widget_args)
130
131    # Splitter
132    if isinstance(data, tuple):
133        return Splitter(*data, **widget_args)
134
135    # buttons
136    if isinstance(data, list):
137        label = data[0]
138        onclick = None
139        if len(data) > 1:
140            onclick = data[1]
141
142        # Checkbox
143        if isinstance(label, bool):
144            return Checkbox(onclick, checked=label, **widget_args)
145
146        # Toggle
147        if isinstance(label, tuple):
148            assert len(label) == 2
149            return Toggle(label, onclick, **widget_args)
150
151        return Button(label, onclick, **widget_args)
152
153    # prompt splitter
154    if isinstance(data, dict):
155        rows: list[Splitter] = []
156
157        for key, value in data.items():
158            left = auto(key, parent_align=HorizontalAlignment.LEFT)
159            right = auto(value, parent_align=HorizontalAlignment.RIGHT)
160
161            rows.append(Splitter(left, right, **widget_args))
162
163        if len(rows) == 1:
164            return rows[0]
165
166        return rows
167
168    return None

Creates a widget from specific data structures.

This conversion includes various widget classes, as well as some shorthands for more complex objects. This method is called implicitly whenever a non-widget is attempted to be added to a Widget.

Args
  • data: The structure to convert. See below for formats.
  • **widget_args: Arguments passed straight to the widget constructor.
Returns

The widget or list of widgets created, or None if the passed structure could not be converted.


Data structures:

pytermgui.widgets.base.Label:

  • Created from str
  • Syntax example: "Label value"

pytermgui.widgets.extra.Splitter:

  • Created from tuple[Any]
  • Syntax example: (YourWidget(), "auto_syntax", ...)

pytermgui.widgets.extra.Splitter prompt:

  • Created from dict[Any, Any]
  • Syntax example: {YourWidget(): "auto_syntax"}

pytermgui.widgets.buttons.Button:

  • Created from list[str, pytermgui.widgets.buttons.MouseCallback]
  • Syntax example: ["Button label", lambda target, caller: ...]

pytermgui.widgets.buttons.Checkbox:

  • Created from list[bool, Callable[[bool], Any]]
  • Syntax example: [True, lambda checked: ...]

pytermgui.widgets.buttons.Toggle:

  • Created from list[tuple[str, str], Callable[[str], Any]]
  • Syntax example: [("On", "Off"), lambda new_value: ...]

Example:

from pytermgui import Container
form = (
    Container(id="form")
    + "[157 bold]This is a title"
    + ""
    + {"[72 italic]Label1": "[210]Button1"}
    + {"[72 italic]Label2": "[210]Button2"}
    + {"[72 italic]Label3": "[210]Button3"}
    + ""
    + ["Submit", lambda _, button, your_submit_handler(button.parent)]
)
bindings: dict[typing.Union[str, typing.Type[pytermgui.ansi_interface.MouseEvent]], tuple[typing.Callable[..., typing.Any], str]]

Gets a copy of the bindings internal dictionary.

Returns

A copy of the internal bindings dictionary, such as:

{
    "*": (star_callback, "This is a callback activated when '*' is pressed.")
}
id: Optional[str]

Gets this widget's id property

Returns

The id string if one is present, None otherwise.

selectables_length: int

Gets how many selectables this widget contains.

Returns

An integer describing the amount of selectables in this widget.

selectables: list[tuple[pytermgui.widgets.base.Widget, int]]

Gets a list of all selectables within this widget

Returns

A list of tuples. In the default implementation this will be a list of one tuple, containing a reference to self, as well as the lowest index, 0.

is_selectable: bool

Determines whether this widget has any selectables.

Returns

A boolean, representing self.selectables_length != 0.

static_width: int

Allows for a shorter way of setting a width, and SizePolicy.STATIC.

Args
  • value: The new width integer.
Returns

None, as this is setter only.

relative_width: float | None

Sets this widget's relative width, and changes size_policy to RELATIVE.

The value is clamped to 1.0.

If a Container holds a width of 30, and it has a subwidget with a relative width of 0.5, it will be resized to 15.

Args
  • value: The multiplier to apply to the parent's width.
Returns

The current relative_width.

Returns the current global terminal instance.

def get_change(self) -> pytermgui.enums.WidgetChange | None:
310    def get_change(self) -> WidgetChange | None:
311        """Determines whether widget lines changed since the last call to this function."""
312
313        lines = self.get_lines()
314
315        if self._previous_state is None:
316            self._previous_state = (self.width, self.height), lines
317            return WidgetChange.LINES
318
319        lines = self.get_lines()
320        (old_width, old_height), old_lines = self._previous_state
321
322        self._previous_state = (self.width, self.height), lines
323
324        if old_width != self.width and old_height != self.height:
325            return WidgetChange.SIZE
326
327        if old_width != self.width:
328            return WidgetChange.WIDTH
329
330        if old_height != self.height:
331            return WidgetChange.HEIGHT
332
333        if old_lines != lines:
334            return WidgetChange.LINES
335
336        return None

Determines whether widget lines changed since the last call to this function.

def contains(self, pos: tuple[int, int]) -> bool:
338    def contains(self, pos: tuple[int, int]) -> bool:
339        """Determines whether widget contains `pos`.
340
341        Args:
342            pos: Position to compare.
343
344        Returns:
345            Boolean describing whether the position is inside
346              this widget.
347        """
348
349        rect = self.pos, (
350            self.pos[0] + self.width,
351            self.pos[1] + self.height,
352        )
353
354        (left, top), (right, bottom) = rect
355
356        return left <= pos[0] < right and top <= pos[1] < bottom

Determines whether widget contains pos.

Args
  • pos: Position to compare.
Returns

Boolean describing whether the position is inside this widget.

def handle_mouse(self, event: pytermgui.ansi_interface.MouseEvent) -> bool:
358    def handle_mouse(self, event: MouseEvent) -> bool:
359        """Tries to call the most specific mouse handler function available.
360
361        This function looks for a set of mouse action handlers. Each handler follows
362        the format
363
364            on_{event_name}
365
366        For example, the handler triggered on MouseAction.LEFT_CLICK would be
367        `on_left_click`. If no handler is found nothing is done.
368
369        You can also define more general handlers, for example to group left & right
370        clicks you can use `on_click`, and to catch both up and down scroll you can use
371        `on_scroll`. General handlers are only used if they are the most specific ones,
372        i.e. there is no "specific" handler.
373
374        Args:
375            event: The event to handle.
376
377        Returns:
378            Whether the parent of this widget should treat it as one to "stick" events
379            to, e.g. to keep sending mouse events to it. One can "unstick" a widget by
380            returning False in the handler.
381        """
382
383        def _get_names(action: MouseAction) -> tuple[str, ...]:
384            if action.value in ["hover", "release"]:
385                return (action.value,)
386
387            parts = action.value.split("_")
388
389            # left click & right click
390            if parts[0] in ["left", "right"]:
391                return (action.value, parts[1])
392
393            # scroll up & down
394            return (action.value, parts[0])
395
396        possible_names = _get_names(event.action)
397        for name in possible_names:
398            if hasattr(self, f"on_{name}"):
399                handle = getattr(self, f"on_{name}")
400
401                return handle(event)
402
403        return False

Tries to call the most specific mouse handler function available.

This function looks for a set of mouse action handlers. Each handler follows the format

on_{event_name}

For example, the handler triggered on MouseAction.LEFT_CLICK would be on_left_click. If no handler is found nothing is done.

You can also define more general handlers, for example to group left & right clicks you can use on_click, and to catch both up and down scroll you can use on_scroll. General handlers are only used if they are the most specific ones, i.e. there is no "specific" handler.

Args
  • event: The event to handle.
Returns

Whether the parent of this widget should treat it as one to "stick" events to, e.g. to keep sending mouse events to it. One can "unstick" a widget by returning False in the handler.

def handle_key(self, key: str) -> bool:
405    def handle_key(self, key: str) -> bool:
406        """Handles a mouse event, returning its success.
407
408        Args:
409            key: String representation of input string.
410              The `pytermgui.input.keys` object can be
411              used to retrieve special keys.
412
413        Returns:
414            A boolean describing whether the key was handled.
415        """
416
417        return False and hasattr(self, key)

Handles a mouse event, returning its success.

Args
  • key: String representation of input string. The pytermgui.input.keys object can be used to retrieve special keys.
Returns

A boolean describing whether the key was handled.

def serialize(self) -> dict[str, typing.Any]:
419    def serialize(self) -> dict[str, Any]:
420        """Serializes a widget.
421
422        The fields looked at are defined `Widget.serialized`. Note that
423        this method is not very commonly used at the moment, so it might
424        not have full functionality in non-nuclear widgets.
425
426        Returns:
427            Dictionary of widget attributes. The dictionary will always
428            have a `type` field. Any styles are converted into markup
429            strings during serialization, so they can be loaded again in
430            their original form.
431
432            Example return:
433            ```
434                {
435                    "type": "Label",
436                    "value": "[210 bold]I am a title",
437                    "parent_align": 0,
438                    ...
439                }
440            ```
441        """
442
443        fields = self._serialized_fields
444
445        out: dict[str, Any] = {"type": type(self).__name__}
446        for key in fields:
447            # Detect styled values
448            if key.startswith("*"):
449                style = True
450                key = key[1:]
451            else:
452                style = False
453
454            value = getattr(self, key)
455
456            # Convert styled value into markup
457            if style:
458                style_call = self._get_style(key)
459                if isinstance(value, list):
460                    out[key] = [get_markup(style_call(char)) for char in value]
461                else:
462                    out[key] = get_markup(style_call(value))
463
464                continue
465
466            out[key] = value
467
468        # The chars need to be handled separately
469        out["chars"] = {}
470        for key, value in self.chars.items():
471            style_call = self._get_style(key)
472
473            if isinstance(value, list):
474                out["chars"][key] = [get_markup(style_call(char)) for char in value]
475            else:
476                out["chars"][key] = get_markup(style_call(value))
477
478        return out

Serializes a widget.

The fields looked at are defined Widget.serialized. Note that this method is not very commonly used at the moment, so it might not have full functionality in non-nuclear widgets.

Returns

Dictionary of widget attributes. The dictionary will always have a type field. Any styles are converted into markup strings during serialization, so they can be loaded again in their original form.

Example return:

    {
        "type": "Label",
        "value": "[210 bold]I am a title",
        "parent_align": 0,
        ...
    }
def copy(self) -> pytermgui.widgets.base.Widget:
480    def copy(self) -> Widget:
481        """Creates a deep copy of this widget"""
482
483        return deepcopy(self)

Creates a deep copy of this widget

def get_lines(self) -> list[str]:
523    def get_lines(self) -> list[str]:
524        """Gets lines representing this widget.
525
526        These lines have to be equal to the widget in length. All
527        widgets must provide this method. Make sure to keep it performant,
528        as it will be called very often, often multiple times per WindowManager frame.
529
530        Any longer actions should be done outside of this method, and only their
531        result should be looked up here.
532
533        Returns:
534            Nothing by default.
535
536        Raises:
537            NotImplementedError: As this method is required for **all** widgets, not
538                having it defined will raise NotImplementedError.
539        """
540
541        raise NotImplementedError(f"get_lines() is not defined for type {type(self)}.")

Gets lines representing this widget.

These lines have to be equal to the widget in length. All widgets must provide this method. Make sure to keep it performant, as it will be called very often, often multiple times per WindowManager frame.

Any longer actions should be done outside of this method, and only their result should be looked up here.

Returns

Nothing by default.

Raises
  • NotImplementedError: As this method is required for all widgets, not having it defined will raise NotImplementedError.
def bind( self, key: str, action: Callable[..., Any], description: Optional[str] = None) -> None:
543    def bind(
544        self, key: str, action: BoundCallback, description: Optional[str] = None
545    ) -> None:
546        """Binds an action to a keypress.
547
548        This function is only called by implementations above this layer. To use this
549        functionality use `pytermgui.window_manager.WindowManager`, or write your own
550        custom layer.
551
552        Special keys:
553        - keys.ANY_KEY: Any and all keypresses execute this binding.
554        - keys.MouseAction: Any and all mouse inputs execute this binding.
555
556        Args:
557            key: The key that the action will be bound to.
558            action: The action executed when the key is pressed.
559            description: An optional description for this binding. It is not really
560                used anywhere, but you can provide a helper menu and display them.
561
562        Raises:
563            TypeError: This widget is not bindable, i.e. widget.is_bindable == False.
564        """
565
566        if not self.is_bindable:
567            raise TypeError(f"Widget of type {type(self)} does not accept bindings.")
568
569        if description is None:
570            description = f"Binding of {key} to {action}"
571
572        self._bindings[key] = (action, description)

Binds an action to a keypress.

This function is only called by implementations above this layer. To use this functionality use pytermgui.window_manager.WindowManager, or write your own custom layer.

Special keys:

  • keys.ANY_KEY: Any and all keypresses execute this binding.
  • keys.MouseAction: Any and all mouse inputs execute this binding.
Args
  • key: The key that the action will be bound to.
  • action: The action executed when the key is pressed.
  • description: An optional description for this binding. It is not really used anywhere, but you can provide a helper menu and display them.
Raises
  • TypeError: This widget is not bindable, i.e. widget.is_bindable == False.
def unbind(self, key: str) -> None:
574    def unbind(self, key: str) -> None:
575        """Unbinds the given key."""
576
577        del self._bindings[key]

Unbinds the given key.

def execute_binding(self, key: Any, ignore_any: bool = False) -> bool:
579    def execute_binding(self, key: Any, ignore_any: bool = False) -> bool:
580        """Executes a binding belonging to key, when present.
581
582        Use this method inside custom widget `handle_keys` methods, or to run a callback
583        without its corresponding key having been pressed.
584
585        Args:
586            key: Usually a string, indexing into the `_bindings` dictionary. These are the
587              same strings as defined in `Widget.bind`.
588            ignore_any: If set, `keys.ANY_KEY` bindings will not be executed.
589
590        Returns:
591            True if the binding was found, False otherwise. Bindings will always be
592              executed if they are found.
593        """
594
595        # Execute special binding
596        if not ignore_any and keys.ANY_KEY in self._bindings:
597            method, _ = self._bindings[keys.ANY_KEY]
598            method(self, key)
599
600        if key in self._bindings:
601            method, _ = self._bindings[key]
602            method(self, key)
603
604            return True
605
606        return False

Executes a binding belonging to key, when present.

Use this method inside custom widget handle_keys methods, or to run a callback without its corresponding key having been pressed.

Args
  • key: Usually a string, indexing into the _bindings dictionary. These are the same strings as defined in Widget.bind.
  • ignore_any: If set, keys.ANY_KEY bindings will not be executed.
Returns

True if the binding was found, False otherwise. Bindings will always be executed if they are found.

def select(self, index: int | None = None) -> None:
608    def select(self, index: int | None = None) -> None:
609        """Selects a part of this Widget.
610
611        Args:
612            index: The index to select.
613
614        Raises:
615            TypeError: This widget has no selectables, i.e. widget.is_selectable == False.
616        """
617
618        if not self.is_selectable:
619            raise TypeError(f"Object of type {type(self)} has no selectables.")
620
621        if index is not None:
622            index = min(max(0, index), self.selectables_length - 1)
623        self.selected_index = index

Selects a part of this Widget.

Args
  • index: The index to select.
Raises
  • TypeError: This widget has no selectables, i.e. widget.is_selectable == False.
def print(self) -> None:
625    def print(self) -> None:
626        """Prints this widget"""
627
628        for line in self.get_lines():
629            print(line)

Prints this widget

def debug(self) -> str:
631    def debug(self) -> str:
632        """Returns identifiable information about this widget.
633
634        This method is used to easily differentiate between widgets. By default, all widget's
635        __repr__ method is an alias to this. The signature of each widget is used to generate
636        the return value.
637
638        Returns:
639            A string almost exactly matching the line of code that could have defined the widget.
640
641            Example return:
642
643            ```
644            Container(Label(value="This is a label", padding=0),
645            Button(label="This is a button", padding=0), **attrs)
646            ```
647
648        """
649
650        constructor = "("
651        for name in signature(getattr(self, "__init__")).parameters:
652            current = ""
653            if name == "attrs":
654                current += "**attrs"
655                continue
656
657            if len(constructor) > 1:
658                current += ", "
659
660            current += name
661
662            attr = getattr(self, name, None)
663            if attr is None:
664                continue
665
666            current += "="
667
668            if isinstance(attr, str):
669                current += f'"{attr}"'
670            else:
671                current += str(attr)
672
673            constructor += current
674
675        constructor += ")"
676
677        return type(self).__name__ + constructor

Returns identifiable information about this widget.

This method is used to easily differentiate between widgets. By default, all widget's __repr__ method is an alias to this. The signature of each widget is used to generate the return value.

Returns

A string almost exactly matching the line of code that could have defined the widget.

Example return:

Container(Label(value="This is a label", padding=0),
Button(label="This is a button", padding=0), **attrs)
class Label(Widget):
680class Label(Widget):
681    """A Widget to display a string
682
683    By default, this widget uses `pytermgui.widgets.styles.MARKUP`. This
684    allows it to house markup text that is parsed before display, such as:
685
686    ```python3
687    import pytermgui as ptg
688
689    with ptg.alt_buffer():
690        root = ptg.Container(
691            ptg.Label("[italic 141 bold]This is some [green]fancy [white inverse]text!")
692        )
693        root.print()
694        ptg.getch()
695    ```
696
697    <p style="text-align: center">
698     <img
699      src="https://github.com/bczsalba/pytermgui/blob/master/assets/docs/widgets/label.png?raw=true"
700      width=100%>
701    </p>
702    """
703
704    serialized = Widget.serialized + ["*value", "align", "padding"]
705    styles = w_styles.StyleManager(value=w_styles.MARKUP)
706
707    def __init__(
708        self,
709        value: str = "",
710        style: str | w_styles.StyleValue = "",
711        padding: int = 0,
712        non_first_padding: int = 0,
713        **attrs: Any,
714    ) -> None:
715        """Initializes a Label.
716
717        Args:
718            value: The value of this string. Using the default value style
719                (`pytermgui.widgets.styles.MARKUP`),
720            style: A pre-set value for self.styles.value.
721            padding: The number of space (" ") characters to prepend to every line after
722                line breaking.
723            non_first_padding: The number of space characters to prepend to every
724                non-first line of `get_lines`. This is applied on top of `padding`.
725        """
726
727        super().__init__(**attrs)
728
729        self.value = value
730        self.padding = padding
731        self.non_first_padding = non_first_padding
732        self.width = real_length(value) + self.padding
733
734        if style != "":
735            self.styles.value = style
736
737    def get_lines(self) -> list[str]:
738        """Get lines representing this Label, breaking lines as necessary"""
739
740        lines = []
741        limit = self.width - self.padding
742        broken = break_line(
743            self.styles.value(self.value),
744            limit=limit,
745            non_first_limit=limit - self.non_first_padding,
746        )
747
748        for i, line in enumerate(broken):
749            if i == 0:
750                lines.append(self.padding * " " + line)
751                continue
752
753            lines.append(self.padding * " " + self.non_first_padding * " " + line)
754
755        return lines or [""]

A Widget to display a string

By default, this widget uses pytermgui.widgets.styles.MARKUP. This allows it to house markup text that is parsed before display, such as:

import pytermgui as ptg

with ptg.alt_buffer():
    root = ptg.Container(
        ptg.Label("[italic 141 bold]This is some [green]fancy [white inverse]text!")
    )
    root.print()
    ptg.getch()

Label( value: str = '', style: 'str | w_styles.StyleValue' = '', padding: int = 0, non_first_padding: int = 0, **attrs: Any)
707    def __init__(
708        self,
709        value: str = "",
710        style: str | w_styles.StyleValue = "",
711        padding: int = 0,
712        non_first_padding: int = 0,
713        **attrs: Any,
714    ) -> None:
715        """Initializes a Label.
716
717        Args:
718            value: The value of this string. Using the default value style
719                (`pytermgui.widgets.styles.MARKUP`),
720            style: A pre-set value for self.styles.value.
721            padding: The number of space (" ") characters to prepend to every line after
722                line breaking.
723            non_first_padding: The number of space characters to prepend to every
724                non-first line of `get_lines`. This is applied on top of `padding`.
725        """
726
727        super().__init__(**attrs)
728
729        self.value = value
730        self.padding = padding
731        self.non_first_padding = non_first_padding
732        self.width = real_length(value) + self.padding
733
734        if style != "":
735            self.styles.value = style

Initializes a Label.

Args
  • value: The value of this string. Using the default value style (pytermgui.widgets.styles.MARKUP),
  • style: A pre-set value for self.styles.value.
  • padding: The number of space (" ") characters to prepend to every line after line breaking.
  • non_first_padding: The number of space characters to prepend to every non-first line of get_lines. This is applied on top of padding.
serialized: list[str] = ['id', 'pos', 'depth', 'width', 'height', 'selected_index', 'selectables_length', '*value', 'align', 'padding']

Fields of widget that shall be serialized by pytermgui.serializer.Serializer

styles = {'value': StyleCall(obj=None, method=<function <lambda>>)}

Default styles for this class

def get_lines(self) -> list[str]:
737    def get_lines(self) -> list[str]:
738        """Get lines representing this Label, breaking lines as necessary"""
739
740        lines = []
741        limit = self.width - self.padding
742        broken = break_line(
743            self.styles.value(self.value),
744            limit=limit,
745            non_first_limit=limit - self.non_first_padding,
746        )
747
748        for i, line in enumerate(broken):
749            if i == 0:
750                lines.append(self.padding * " " + line)
751                continue
752
753            lines.append(self.padding * " " + self.non_first_padding * " " + line)
754
755        return lines or [""]

Get lines representing this Label, breaking lines as necessary