pytermgui.widgets.containers

The module containing all of the layout-related widgets.

   1"""The module containing all of the layout-related widgets."""
   2
   3# The widgets defined here are quite complex, so I think unrestricting them this way
   4# is more or less reasonable.
   5# pylint: disable=too-many-instance-attributes, too-many-lines, too-many-public-methods
   6
   7from __future__ import annotations
   8
   9from itertools import zip_longest
  10from typing import Any, Callable, Iterator, cast
  11
  12from ..ansi_interface import MouseAction, MouseEvent, clear, reset
  13from ..context_managers import cursor_at
  14from ..enums import (
  15    CenteringPolicy,
  16    HorizontalAlignment,
  17    Overflow,
  18    SizePolicy,
  19    VerticalAlignment,
  20    WidgetChange,
  21)
  22from ..exceptions import WidthExceededError
  23from ..input import keys
  24from ..regex import real_length, strip_markup
  25from . import boxes
  26from . import styles as w_styles
  27from .base import ScrollableWidget, Widget
  28
  29
  30class Container(ScrollableWidget):
  31    """A widget that displays other widgets, stacked vertically."""
  32
  33    styles = w_styles.StyleManager(
  34        border=w_styles.MARKUP,
  35        corner=w_styles.MARKUP,
  36        fill=w_styles.BACKGROUND,
  37    )
  38
  39    chars: dict[str, w_styles.CharType] = {
  40        "border": ["| ", "-", " |", "-"],
  41        "corner": [""] * 4,
  42    }
  43
  44    keys = {
  45        "next": {keys.DOWN, keys.CTRL_N, "j"},
  46        "previous": {keys.UP, keys.CTRL_P, "k"},
  47        "scroll_down": {keys.SHIFT_DOWN, "J"},
  48        "scroll_up": {keys.SHIFT_UP, "K"},
  49    }
  50
  51    serialized = Widget.serialized + ["centered_axis"]
  52    vertical_align = VerticalAlignment.CENTER
  53    allow_fullscreen = True
  54
  55    overflow = Overflow.get_default()
  56
  57    # TODO: Add `WidgetConvertible`? type instead of Any
  58    def __init__(self, *widgets: Any, **attrs: Any) -> None:
  59        """Initialize Container data"""
  60
  61        super().__init__(**attrs)
  62
  63        # TODO: This is just a band-aid.
  64        if not any("width" in attr for attr in attrs):
  65            self.width = 40
  66
  67        self._widgets: list[Widget] = []
  68        self.dirty_widgets: list[Widget] = []
  69        self.centered_axis: CenteringPolicy | None = None
  70
  71        self._prev_screen: tuple[int, int] = (0, 0)
  72        self._has_printed = False
  73
  74        for widget in widgets:
  75            self._add_widget(widget)
  76
  77        if "box" in attrs:
  78            self.box = attrs["box"]
  79
  80        self._mouse_target: Widget | None = None
  81
  82    @property
  83    def sidelength(self) -> int:
  84        """Gets the length of left and right borders combined.
  85
  86        Returns:
  87            An integer equal to the `pytermgui.helpers.real_length` of the concatenation of
  88                the left and right borders of this widget, both with their respective styles
  89                applied.
  90        """
  91
  92        return self.width - self.content_dimensions[0]
  93
  94    @property
  95    def content_dimensions(self) -> tuple[int, int]:
  96        """Gets the size (width, height) of the available content area."""
  97
  98        if not "border" in self.chars:
  99            return self.width, self.height
 100
 101        chars = self._get_char("border")
 102
 103        assert isinstance(chars, list)
 104
 105        left, top, right, bottom = chars
 106
 107        return (
 108            self.width - real_length(self.styles.border(left + right)),
 109            self.height - sum(1 if real_length(char) else 0 for char in [top, bottom]),
 110        )
 111
 112    @property
 113    def selectables(self) -> list[tuple[Widget, int]]:
 114        """Gets all selectable widgets and their inner indices.
 115
 116        This is used in order to have a constant reference to all selectable indices within this
 117        widget.
 118
 119        Returns:
 120            A list of tuples containing a widget and an integer each. For each widget that is
 121            withing this one, it is added to this list as many times as it has selectables. Each
 122            of the integers correspond to a selectable_index within the widget.
 123
 124            For example, a Container with a Button, InputField and an inner Container containing
 125            3 selectables might return something like this:
 126
 127            ```
 128            [
 129                (Button(...), 0),
 130                (InputField(...), 0),
 131                (Container(...), 0),
 132                (Container(...), 1),
 133                (Container(...), 2),
 134            ]
 135            ```
 136        """
 137
 138        _selectables: list[tuple[Widget, int]] = []
 139        for widget in self._widgets:
 140            if not widget.is_selectable:
 141                continue
 142
 143            for i, (inner, _) in enumerate(widget.selectables):
 144                _selectables.append((inner, i))
 145
 146        return _selectables
 147
 148    @property
 149    def selectables_length(self) -> int:
 150        """Gets the length of the selectables list.
 151
 152        Returns:
 153            An integer equal to the length of `self.selectables`.
 154        """
 155
 156        return len(self.selectables)
 157
 158    @property
 159    def selected(self) -> Widget | None:
 160        """Returns the currently selected object
 161
 162        Returns:
 163            The currently selected widget if selected_index is not None,
 164            otherwise None.
 165        """
 166
 167        # TODO: Add deeper selection
 168
 169        if self.selected_index is None:
 170            return None
 171
 172        if self.selected_index >= len(self.selectables):
 173            return None
 174
 175        return self.selectables[self.selected_index][0]
 176
 177    @property
 178    def box(self) -> boxes.Box:
 179        """Returns current box setting
 180
 181        Returns:
 182            The currently set box instance.
 183        """
 184
 185        return self._box
 186
 187    @box.setter
 188    def box(self, new: str | boxes.Box) -> None:
 189        """Applies a new box.
 190
 191        Args:
 192            new: Either a `pytermgui.boxes.Box` instance or a string
 193                analogous to one of the default box names.
 194        """
 195
 196        if isinstance(new, str):
 197            from_module = vars(boxes).get(new)
 198            if from_module is None:
 199                raise ValueError(f"Unknown box type {new}.")
 200
 201            new = from_module
 202
 203        assert isinstance(new, boxes.Box)
 204        self._box = new
 205        new.set_chars_of(self)
 206
 207    def get_change(self) -> WidgetChange | None:
 208        """Determines whether widget lines changed since the last call to this function."""
 209
 210        change = super().get_change()
 211
 212        if change is None:
 213            return None
 214
 215        for widget in self._widgets:
 216            if widget.get_change() is not None:
 217                self.dirty_widgets.append(widget)
 218
 219        return change
 220
 221    def __iadd__(self, other: object) -> Container:
 222        """Adds a new widget, then returns self.
 223
 224        Args:
 225            other: Any widget instance, or data structure that can be turned
 226            into a widget by `Widget.from_data`.
 227
 228        Returns:
 229            A reference to self.
 230        """
 231
 232        self._add_widget(other)
 233        return self
 234
 235    def __add__(self, other: object) -> Container:
 236        """Adds a new widget, then returns self.
 237
 238        This method is analogous to `Container.__iadd__`.
 239
 240        Args:
 241            other: Any widget instance, or data structure that can be turned
 242            into a widget by `Widget.from_data`.
 243
 244        Returns:
 245            A reference to self.
 246        """
 247
 248        self.__iadd__(other)
 249        return self
 250
 251    def __iter__(self) -> Iterator[Widget]:
 252        """Gets an iterator of self._widgets.
 253
 254        Yields:
 255            The next widget.
 256        """
 257
 258        for widget in self._widgets:
 259            yield widget
 260
 261    def __len__(self) -> int:
 262        """Gets the length of the widgets list.
 263
 264        Returns:
 265            An integer describing len(self._widgets).
 266        """
 267
 268        return len(self._widgets)
 269
 270    def __getitem__(self, sli: int | slice) -> Widget | list[Widget]:
 271        """Gets an item from self._widgets.
 272
 273        Args:
 274            sli: Slice of the list.
 275
 276        Returns:
 277            The slice in the list.
 278        """
 279
 280        return self._widgets[sli]
 281
 282    def __setitem__(self, index: int, value: Any) -> None:
 283        """Sets an item in self._widgets.
 284
 285        Args:
 286            index: The index to be set.
 287            value: The new widget at this index.
 288        """
 289
 290        self._widgets[index] = value
 291
 292    def __contains__(self, other: object) -> bool:
 293        """Determines if self._widgets contains other widget.
 294
 295        Args:
 296            other: Any widget-like.
 297
 298        Returns:
 299            A boolean describing whether `other` is in `self.widgets`
 300        """
 301
 302        if other in self._widgets:
 303            return True
 304
 305        for widget in self._widgets:
 306            if isinstance(widget, Container) and other in widget:
 307                return True
 308
 309        return False
 310
 311    def _add_widget(self, other: object, run_get_lines: bool = True) -> Widget:
 312        """Adds other to this widget.
 313
 314        Args:
 315            other: Any widget-like object.
 316            run_get_lines: Boolean controlling whether the self.get_lines is ran.
 317
 318        Returns:
 319            The added widget. This is useful when data conversion took place in this
 320            function, e.g. a string was converted to a Label.
 321        """
 322
 323        if not isinstance(other, Widget):
 324            to_widget = Widget.from_data(other)
 325            if to_widget is None:
 326                raise ValueError(
 327                    f"Could not convert {other} of type {type(other)} to a Widget!"
 328                )
 329
 330            other = to_widget
 331
 332        # This is safe to do, as it would've raised an exception above already
 333        assert isinstance(other, Widget)
 334
 335        self._widgets.append(other)
 336        if isinstance(other, Container):
 337            other.set_recursive_depth(self.depth + 2)
 338        else:
 339            other.depth = self.depth + 1
 340
 341        other.get_lines()
 342        other.parent = self
 343
 344        if run_get_lines:
 345            self.get_lines()
 346
 347        return other
 348
 349    def _get_aligners(
 350        self, widget: Widget, borders: tuple[str, str]
 351    ) -> tuple[Callable[[str], str], int]:
 352        """Gets an aligning method and position offset.
 353
 354        Args:
 355            widget: The widget to align.
 356            borders: The left and right borders to put the widget within.
 357
 358        Returns:
 359            A tuple of a method that, when called with a line, will return that line
 360            centered using the passed in widget's parent_align and width, as well as
 361            the horizontal offset resulting from the widget being aligned.
 362        """
 363
 364        left, right = self.styles.border(borders[0]), self.styles.border(borders[1])
 365        char = " "
 366
 367        fill = self.styles.fill
 368
 369        def _align_left(text: str) -> str:
 370            """Align line to the left"""
 371
 372            padding = self.width - real_length(left + right) - real_length(text)
 373            return left + text + fill(padding * char) + right
 374
 375        def _align_center(text: str) -> str:
 376            """Align line to the center"""
 377
 378            total = self.width - real_length(left + right) - real_length(text)
 379            padding, offset = divmod(total, 2)
 380            return (
 381                left
 382                + fill((padding + offset) * char)
 383                + text
 384                + fill(padding * char)
 385                + right
 386            )
 387
 388        def _align_right(text: str) -> str:
 389            """Align line to the right"""
 390
 391            padding = self.width - real_length(left + right) - real_length(text)
 392            return left + fill(padding * char) + text + right
 393
 394        if widget.parent_align == HorizontalAlignment.CENTER:
 395            total = self.width - real_length(left + right) - widget.width
 396            padding, offset = divmod(total, 2)
 397            return _align_center, real_length(left) + padding + offset
 398
 399        if widget.parent_align == HorizontalAlignment.RIGHT:
 400            return _align_right, self.width - real_length(left) - widget.width
 401
 402        # Default to left-aligned
 403        return _align_left, real_length(left)
 404
 405    def _update_width(self, widget: Widget) -> None:
 406        """Updates the width of widget or self.
 407
 408        This method respects widget.size_policy.
 409
 410        Args:
 411            widget: The widget to update/base updates on.
 412
 413        Raises:
 414            ValueError: Widget has SizePolicy.RELATIVE, but relative_width is None.
 415            WidthExceededError: Widget and self both have static widths, and widget's
 416                is larger than what is available.
 417        """
 418
 419        available = self.width - self.sidelength
 420
 421        if widget.size_policy == SizePolicy.FILL:
 422            widget.width = available
 423            return
 424
 425        if widget.size_policy == SizePolicy.RELATIVE:
 426            if widget.relative_width is None:
 427                raise ValueError(f'Widget "{widget}"\'s relative width cannot be None.')
 428
 429            widget.width = int(widget.relative_width * available)
 430            return
 431
 432        if widget.width > available:
 433            if widget.size_policy == self.size_policy == SizePolicy.STATIC:
 434                raise WidthExceededError(
 435                    f"Widget {widget}'s static width of {widget.width}"
 436                    + f" exceeds its parent's available width {available}."
 437                    ""
 438                )
 439
 440            if widget.size_policy == SizePolicy.STATIC:
 441                self.width = widget.width + self.sidelength
 442
 443            else:
 444                widget.width = available
 445
 446    def _apply_vertalign(
 447        self, lines: list[str], diff: int, padder: str
 448    ) -> tuple[int, list[str]]:
 449        """Insert padder line into lines diff times, depending on self.vertical_align.
 450
 451        Args:
 452            lines: The list of lines to align.
 453            diff: The available height.
 454            padder: The line to use to pad.
 455
 456        Returns:
 457            A tuple containing the vertical offset as well as the padded list of lines.
 458
 459        Raises:
 460            NotImplementedError: The given vertical alignment is not implemented.
 461        """
 462
 463        if self.vertical_align == VerticalAlignment.BOTTOM:
 464            for _ in range(diff):
 465                lines.insert(0, padder)
 466
 467            return diff, lines
 468
 469        if self.vertical_align == VerticalAlignment.TOP:
 470            for _ in range(diff):
 471                lines.append(padder)
 472
 473            return 0, lines
 474
 475        if self.vertical_align == VerticalAlignment.CENTER:
 476            top, extra = divmod(diff, 2)
 477            bottom = top + extra
 478
 479            for _ in range(top):
 480                lines.insert(0, padder)
 481
 482            for _ in range(bottom):
 483                lines.append(padder)
 484
 485            return top, lines
 486
 487        raise NotImplementedError(
 488            f"Vertical alignment {self.vertical_align} is not implemented for {type(self)}."
 489        )
 490
 491    def lazy_add(self, other: object) -> None:
 492        """Adds `other` without running get_lines.
 493
 494        This is analogous to `self._add_widget(other, run_get_lines=False).
 495
 496        Args:
 497            other: The object to add.
 498        """
 499
 500        self._add_widget(other, run_get_lines=False)
 501
 502    def get_lines(self) -> list[str]:
 503        """Gets all lines by spacing out inner widgets.
 504
 505        This method reflects & applies both width settings, as well as
 506        the `parent_align` field.
 507
 508        Returns:
 509            A list of all lines that represent this Container.
 510        """
 511
 512        def _get_border(left: str, char: str, right: str) -> str:
 513            """Gets a top or bottom border.
 514
 515            Args:
 516                left: Left corner character.
 517                char: Border character filling between left & right.
 518                right: Right corner character.
 519
 520            Returns:
 521                The border line.
 522            """
 523
 524            offset = real_length(strip_markup(left + right))
 525            return (
 526                self.styles.corner(left)
 527                + self.styles.border(char * (self.width - offset))
 528                + self.styles.corner(right)
 529            )
 530
 531        lines: list[str] = []
 532
 533        borders = self._get_char("border")
 534        corners = self._get_char("corner")
 535
 536        has_top_bottom = (real_length(borders[1]) > 0, real_length(borders[3]) > 0)
 537
 538        align, offset = self._get_aligners(self, (borders[0], borders[2]))
 539
 540        overflow = self.overflow
 541
 542        self.positioned_line_buffer = []
 543
 544        for widget in self._widgets:
 545            align, offset = self._get_aligners(widget, (borders[0], borders[2]))
 546
 547            self._update_width(widget)
 548
 549            widget.pos = (
 550                self.pos[0] + offset,
 551                self.pos[1] + len(lines) + (1 if has_top_bottom[0] else 0),
 552            )
 553
 554            widget_lines: list[str] = []
 555            for line in widget.get_lines():
 556                if len(lines) + len(widget_lines) >= self.height - sum(has_top_bottom):
 557                    if overflow is Overflow.HIDE:
 558                        break
 559
 560                    if overflow == Overflow.AUTO:
 561                        overflow = Overflow.SCROLL
 562
 563                widget_lines.append(align(line))
 564
 565            lines.extend(widget_lines)
 566
 567            self.positioned_line_buffer.extend(widget.positioned_line_buffer)
 568            widget.positioned_line_buffer = []
 569
 570        if overflow == Overflow.SCROLL:
 571            self._max_scroll = len(lines) - self.height + sum(has_top_bottom)
 572            height = self.height - sum(has_top_bottom)
 573
 574            self._scroll_offset = max(0, min(self._scroll_offset, len(lines) - height))
 575            lines = lines[self._scroll_offset : self._scroll_offset + height]
 576
 577        elif overflow == Overflow.RESIZE:
 578            self.height = len(lines) + sum(has_top_bottom)
 579
 580        vertical_offset, lines = self._apply_vertalign(
 581            lines, self.height - len(lines) - sum(has_top_bottom), align("")
 582        )
 583
 584        for widget in self._widgets:
 585            widget.pos = (widget.pos[0], widget.pos[1] + vertical_offset)
 586
 587            if widget.is_selectable:
 588                widget.get_lines()
 589
 590        if has_top_bottom[0]:
 591            lines.insert(0, _get_border(corners[0], borders[1], corners[1]))
 592
 593        if has_top_bottom[1]:
 594            lines.append(_get_border(corners[3], borders[3], corners[2]))
 595
 596        self.height = len(lines)
 597        return lines
 598
 599    def set_widgets(self, new: list[Widget]) -> None:
 600        """Sets new list in place of self._widgets.
 601
 602        Args:
 603            new: The new widget list.
 604        """
 605
 606        self._widgets = []
 607        for widget in new:
 608            self._add_widget(widget)
 609
 610    def serialize(self) -> dict[str, Any]:
 611        """Serializes this Container, adding in serializations of all widgets.
 612
 613        See `pytermgui.widgets.base.Widget.serialize` for more info.
 614
 615        Returns:
 616            The dictionary containing all serialized data.
 617        """
 618
 619        out = super().serialize()
 620        out["_widgets"] = []
 621
 622        for widget in self._widgets:
 623            out["_widgets"].append(widget.serialize())
 624
 625        return out
 626
 627    def pop(self, index: int = -1) -> Widget:
 628        """Pops widget from self._widgets.
 629
 630        Analogous to self._widgets.pop(index).
 631
 632        Args:
 633            index: The index to operate on.
 634
 635        Returns:
 636            The widget that was popped off the list.
 637        """
 638
 639        return self._widgets.pop(index)
 640
 641    def remove(self, other: Widget) -> None:
 642        """Remove widget from self._widgets
 643
 644        Analogous to self._widgets.remove(other).
 645
 646        Args:
 647            widget: The widget to remove.
 648        """
 649
 650        return self._widgets.remove(other)
 651
 652    def set_recursive_depth(self, value: int) -> None:
 653        """Set depth for this Container and all its children.
 654
 655        All inner widgets will receive value+1 as their new depth.
 656
 657        Args:
 658            value: The new depth to use as the base depth.
 659        """
 660
 661        self.depth = value
 662        for widget in self._widgets:
 663            if isinstance(widget, Container):
 664                widget.set_recursive_depth(value + 1)
 665            else:
 666                widget.depth = value
 667
 668    def select(self, index: int | None = None) -> None:
 669        """Selects inner subwidget.
 670
 671        Args:
 672            index: The index to select.
 673
 674        Raises:
 675            IndexError: The index provided was beyond len(self.selectables).
 676        """
 677
 678        # Unselect all sub-elements
 679        for other in self._widgets:
 680            if other.selectables_length > 0:
 681                other.select(None)
 682
 683        if index is not None:
 684            index = max(0, min(index, len(self.selectables) - 1))
 685            widget, inner_index = self.selectables[index]
 686            widget.select(inner_index)
 687
 688        self.selected_index = index
 689
 690    def center(
 691        self, where: CenteringPolicy | None = None, store: bool = True
 692    ) -> Container:
 693        """Centers this object to the given axis.
 694
 695        Args:
 696            where: A CenteringPolicy describing the place to center to
 697            store: When set, this centering will be reapplied during every
 698                print, as well as when calling this method with no arguments.
 699
 700        Returns:
 701            This Container.
 702        """
 703
 704        # Refresh in case changes happened
 705        self.get_lines()
 706
 707        if where is None:
 708            # See `enums.py` for explanation about this ignore.
 709            where = CenteringPolicy.get_default()  # type: ignore
 710
 711        centerx = centery = where is CenteringPolicy.ALL
 712        centerx |= where is CenteringPolicy.HORIZONTAL
 713        centery |= where is CenteringPolicy.VERTICAL
 714
 715        pos = list(self.pos)
 716        if centerx:
 717            pos[0] = (self.terminal.width - self.width + 2) // 2
 718
 719        if centery:
 720            pos[1] = (self.terminal.height - self.height + 2) // 2
 721
 722        self.pos = (pos[0], pos[1])
 723
 724        if store:
 725            self.centered_axis = where
 726
 727        self._prev_screen = self.terminal.size
 728
 729        return self
 730
 731    def handle_mouse(self, event: MouseEvent) -> bool:
 732        """Handles mouse events.
 733
 734        This, like all mouse handlers should, calls super()'s implementation first,
 735        to allow usage of `on_{event}`-type callbacks. After that, it tries to find
 736        a target widget within itself to handle the event.
 737
 738        Each handler will return a boolean. This boolean is then used to figure out
 739        whether the targeted widget should be "sticky", i.e. a slider. Returning
 740        True will set that widget as the current mouse target, and all mouse events will
 741        be sent to it as long as it returns True.
 742
 743        Args:
 744            event: The event to handle.
 745
 746        Returns:
 747            Whether the parent of this widget should treat it as one to "stick" events
 748            to, e.g. to keep sending mouse events to it. One can "unstick" a widget by
 749            returning False in the handler.
 750        """
 751
 752        def _handle_scrolling() -> bool:
 753            """Scrolls the container."""
 754
 755            if self.overflow != Overflow.SCROLL:
 756                return False
 757
 758            if event.action is MouseAction.SCROLL_UP:
 759                return self.scroll(-1)
 760
 761            if event.action is MouseAction.SCROLL_DOWN:
 762                return self.scroll(1)
 763
 764            return False
 765
 766        if super().handle_mouse(event):
 767            return True
 768
 769        if event.action is MouseAction.RELEASE and self._mouse_target is not None:
 770            return self._mouse_target.handle_mouse(event)
 771
 772        if (
 773            self._mouse_target is not None
 774            and (
 775                event.action.value.endswith("drag")
 776                or event.action.value.startswith("scroll")
 777            )
 778            and self._mouse_target.handle_mouse(event)
 779        ):
 780            return True
 781
 782        release = MouseEvent(MouseAction.RELEASE, event.position)
 783
 784        selectables_index = 0
 785        event.position = (event.position[0], event.position[1] + self._scroll_offset)
 786
 787        handled = False
 788        for widget in self._widgets:
 789            if (
 790                widget.pos[1] - self.pos[1] - self._scroll_offset
 791                > self.content_dimensions[1]
 792            ):
 793                break
 794
 795            if widget.contains(event.position):
 796                handled = widget.handle_mouse(event)
 797                selectables_index += widget.selected_index or 0
 798
 799                # TODO: This really should be customizable somehow.
 800                if event.action is MouseAction.LEFT_CLICK:
 801                    if handled and selectables_index < len(self.selectables):
 802                        self.select(selectables_index)
 803
 804                if self._mouse_target is not None and self._mouse_target is not widget:
 805                    self._mouse_target.handle_mouse(release)
 806
 807                self._mouse_target = widget
 808
 809                break
 810
 811            if widget.is_selectable:
 812                selectables_index += widget.selectables_length
 813
 814        handled = handled or _handle_scrolling()
 815
 816        return handled
 817
 818    def execute_binding(self, key: str) -> bool:
 819        """Executes a binding on self, and then on self._widgets.
 820
 821        If a widget.execute_binding call returns True this function will too. Note
 822        that on success the function returns immediately; no further widgets are
 823        checked.
 824
 825        Args:
 826            key: The binding key.
 827
 828        Returns:
 829            True if any widget returned True, False otherwise.
 830        """
 831
 832        if super().execute_binding(key):
 833            return True
 834
 835        selectables_index = 0
 836        for widget in self._widgets:
 837            if widget.execute_binding(key):
 838                selectables_index += widget.selected_index or 0
 839                self.select(selectables_index)
 840                return True
 841
 842            if widget.is_selectable:
 843                selectables_index += widget.selectables_length
 844
 845        return False
 846
 847    def handle_key(  # pylint: disable=too-many-return-statements, too-many-branches
 848        self, key: str
 849    ) -> bool:
 850        """Handles a keypress, returns its success.
 851
 852        Args:
 853            key: A key str.
 854
 855        Returns:
 856            A boolean showing whether the key was handled.
 857        """
 858
 859        def _is_nav(key: str) -> bool:
 860            """Determine if a key is in the navigation sets"""
 861
 862            return key in self.keys["next"] | self.keys["previous"]
 863
 864        if self.selected is not None and self.selected.handle_key(key):
 865            return True
 866
 867        scroll_actions = {
 868            **{key: 1 for key in self.keys["scroll_down"]},
 869            **{key: -1 for key in self.keys["scroll_up"]},
 870        }
 871
 872        if key in self.keys["scroll_down"] | self.keys["scroll_up"]:
 873            for widget in self._widgets:
 874                if isinstance(widget, Container) and self.selected in widget:
 875                    widget.handle_key(key)
 876
 877            self.scroll(scroll_actions[key])
 878            return True
 879
 880        # Only use navigation when there is more than one selectable
 881        if self.selectables_length >= 1 and _is_nav(key):
 882            if self.selected_index is None:
 883                self.select(0)
 884                return True
 885
 886            handled = False
 887
 888            assert isinstance(self.selected_index, int)
 889
 890            if key in self.keys["previous"]:
 891                # No more selectables left, user wants to exit Container
 892                # upwards.
 893                if self.selected_index == 0:
 894                    return False
 895
 896                self.select(self.selected_index - 1)
 897                handled = True
 898
 899            elif key in self.keys["next"]:
 900                # Stop selection at last element, return as unhandled
 901                new = self.selected_index + 1
 902                if new == len(self.selectables):
 903                    return False
 904
 905                self.select(new)
 906                handled = True
 907
 908            if handled:
 909                return True
 910
 911        if key == keys.ENTER:
 912            if self.selected_index is None and self.selectables_length > 0:
 913                self.select(0)
 914
 915            if self.selected is not None:
 916                self.selected.handle_key(key)
 917                return True
 918
 919        for widget in self._widgets:
 920            if widget.execute_binding(key):
 921                return True
 922
 923        return False
 924
 925    def wipe(self) -> None:
 926        """Wipes the characters occupied by the object"""
 927
 928        with cursor_at(self.pos) as print_here:
 929            for line in self.get_lines():
 930                print_here(real_length(line) * " ")
 931
 932    def print(self) -> None:
 933        """Prints this Container.
 934
 935        If the screen size has changed since last `print` call, the object
 936        will be centered based on its `centered_axis`.
 937        """
 938
 939        if not self.terminal.size == self._prev_screen:
 940            clear()
 941            self.center(self.centered_axis)
 942
 943        self._prev_screen = self.terminal.size
 944
 945        if self.allow_fullscreen:
 946            self.pos = self.terminal.origin
 947
 948        with cursor_at(self.pos) as print_here:
 949            for line in self.get_lines():
 950                print_here(line)
 951
 952        self._has_printed = True
 953
 954    def debug(self) -> str:
 955        """Returns a string with identifiable information on this widget.
 956
 957        Returns:
 958            A str in the form of a class construction. This string is in a form that
 959            __could have been__ used to create this Container.
 960        """
 961
 962        return (
 963            f"{type(self).__name__}(width={self.width}, height={self.height}"
 964            + (f", id={self.id}" if self.id is not None else "")
 965            + ")"
 966        )
 967
 968
 969class Splitter(Container):
 970    """A widget that displays other widgets, stacked horizontally."""
 971
 972    styles = w_styles.StyleManager(separator=w_styles.MARKUP, fill=w_styles.BACKGROUND)
 973
 974    chars: dict[str, list[str] | str] = {"separator": " | "}
 975    keys = {
 976        "previous": {keys.LEFT, "h", keys.CTRL_B},
 977        "next": {keys.RIGHT, "l", keys.CTRL_F},
 978    }
 979
 980    parent_align = HorizontalAlignment.RIGHT
 981
 982    def _align(
 983        self, alignment: HorizontalAlignment, target_width: int, line: str
 984    ) -> tuple[int, str]:
 985        """Align a line
 986
 987        r/wordavalanches"""
 988
 989        available = target_width - real_length(line)
 990        fill_style = self._get_style("fill")
 991
 992        char = fill_style(" ")
 993        line = fill_style(line)
 994
 995        if alignment == HorizontalAlignment.CENTER:
 996            padding, offset = divmod(available, 2)
 997            return padding, padding * char + line + (padding + offset) * char
 998
 999        if alignment == HorizontalAlignment.RIGHT:
1000            return available, available * char + line
1001
1002        return 0, line + available * char
1003
1004    @property
1005    def content_dimensions(self) -> tuple[int, int]:
1006        """Returns the available area for widgets."""
1007
1008        return self.height, self.width
1009
1010    def get_lines(self) -> list[str]:
1011        """Join all widgets horizontally."""
1012
1013        # An error will be raised if `separator` is not the correct type (str).
1014        separator = self._get_style("separator")(self._get_char("separator"))  # type: ignore
1015        separator_length = real_length(separator)
1016
1017        target_width, error = divmod(
1018            self.width - (len(self._widgets) - 1) * separator_length, len(self._widgets)
1019        )
1020
1021        vertical_lines = []
1022        total_offset = 0
1023
1024        for widget in self._widgets:
1025            inner = []
1026
1027            if widget.size_policy is SizePolicy.STATIC:
1028                target_width += target_width - widget.width
1029                width = widget.width
1030            else:
1031                widget.width = target_width + error
1032                width = widget.width
1033                error = 0
1034
1035            aligned: str | None = None
1036            for line in widget.get_lines():
1037                # See `enums.py` for information about this ignore
1038                padding, aligned = self._align(
1039                    cast(HorizontalAlignment, widget.parent_align), width, line
1040                )
1041                inner.append(aligned)
1042
1043            widget.pos = (
1044                self.pos[0] + padding + total_offset,
1045                self.pos[1] + (1 if type(widget).__name__ == "Container" else 0),
1046            )
1047
1048            if aligned is not None:
1049                total_offset += real_length(inner[-1]) + separator_length
1050
1051            vertical_lines.append(inner)
1052
1053        lines = []
1054        for horizontal in zip_longest(*vertical_lines, fillvalue=" " * target_width):
1055            lines.append((reset() + separator).join(horizontal))
1056
1057        self.height = max(widget.height for widget in self)
1058        return lines
1059
1060    def debug(self) -> str:
1061        """Return identifiable information"""
1062
1063        return super().debug().replace("Container", "Splitter", 1)
class Container(pytermgui.widgets.base.ScrollableWidget):
 31class Container(ScrollableWidget):
 32    """A widget that displays other widgets, stacked vertically."""
 33
 34    styles = w_styles.StyleManager(
 35        border=w_styles.MARKUP,
 36        corner=w_styles.MARKUP,
 37        fill=w_styles.BACKGROUND,
 38    )
 39
 40    chars: dict[str, w_styles.CharType] = {
 41        "border": ["| ", "-", " |", "-"],
 42        "corner": [""] * 4,
 43    }
 44
 45    keys = {
 46        "next": {keys.DOWN, keys.CTRL_N, "j"},
 47        "previous": {keys.UP, keys.CTRL_P, "k"},
 48        "scroll_down": {keys.SHIFT_DOWN, "J"},
 49        "scroll_up": {keys.SHIFT_UP, "K"},
 50    }
 51
 52    serialized = Widget.serialized + ["centered_axis"]
 53    vertical_align = VerticalAlignment.CENTER
 54    allow_fullscreen = True
 55
 56    overflow = Overflow.get_default()
 57
 58    # TODO: Add `WidgetConvertible`? type instead of Any
 59    def __init__(self, *widgets: Any, **attrs: Any) -> None:
 60        """Initialize Container data"""
 61
 62        super().__init__(**attrs)
 63
 64        # TODO: This is just a band-aid.
 65        if not any("width" in attr for attr in attrs):
 66            self.width = 40
 67
 68        self._widgets: list[Widget] = []
 69        self.dirty_widgets: list[Widget] = []
 70        self.centered_axis: CenteringPolicy | None = None
 71
 72        self._prev_screen: tuple[int, int] = (0, 0)
 73        self._has_printed = False
 74
 75        for widget in widgets:
 76            self._add_widget(widget)
 77
 78        if "box" in attrs:
 79            self.box = attrs["box"]
 80
 81        self._mouse_target: Widget | None = None
 82
 83    @property
 84    def sidelength(self) -> int:
 85        """Gets the length of left and right borders combined.
 86
 87        Returns:
 88            An integer equal to the `pytermgui.helpers.real_length` of the concatenation of
 89                the left and right borders of this widget, both with their respective styles
 90                applied.
 91        """
 92
 93        return self.width - self.content_dimensions[0]
 94
 95    @property
 96    def content_dimensions(self) -> tuple[int, int]:
 97        """Gets the size (width, height) of the available content area."""
 98
 99        if not "border" in self.chars:
100            return self.width, self.height
101
102        chars = self._get_char("border")
103
104        assert isinstance(chars, list)
105
106        left, top, right, bottom = chars
107
108        return (
109            self.width - real_length(self.styles.border(left + right)),
110            self.height - sum(1 if real_length(char) else 0 for char in [top, bottom]),
111        )
112
113    @property
114    def selectables(self) -> list[tuple[Widget, int]]:
115        """Gets all selectable widgets and their inner indices.
116
117        This is used in order to have a constant reference to all selectable indices within this
118        widget.
119
120        Returns:
121            A list of tuples containing a widget and an integer each. For each widget that is
122            withing this one, it is added to this list as many times as it has selectables. Each
123            of the integers correspond to a selectable_index within the widget.
124
125            For example, a Container with a Button, InputField and an inner Container containing
126            3 selectables might return something like this:
127
128            ```
129            [
130                (Button(...), 0),
131                (InputField(...), 0),
132                (Container(...), 0),
133                (Container(...), 1),
134                (Container(...), 2),
135            ]
136            ```
137        """
138
139        _selectables: list[tuple[Widget, int]] = []
140        for widget in self._widgets:
141            if not widget.is_selectable:
142                continue
143
144            for i, (inner, _) in enumerate(widget.selectables):
145                _selectables.append((inner, i))
146
147        return _selectables
148
149    @property
150    def selectables_length(self) -> int:
151        """Gets the length of the selectables list.
152
153        Returns:
154            An integer equal to the length of `self.selectables`.
155        """
156
157        return len(self.selectables)
158
159    @property
160    def selected(self) -> Widget | None:
161        """Returns the currently selected object
162
163        Returns:
164            The currently selected widget if selected_index is not None,
165            otherwise None.
166        """
167
168        # TODO: Add deeper selection
169
170        if self.selected_index is None:
171            return None
172
173        if self.selected_index >= len(self.selectables):
174            return None
175
176        return self.selectables[self.selected_index][0]
177
178    @property
179    def box(self) -> boxes.Box:
180        """Returns current box setting
181
182        Returns:
183            The currently set box instance.
184        """
185
186        return self._box
187
188    @box.setter
189    def box(self, new: str | boxes.Box) -> None:
190        """Applies a new box.
191
192        Args:
193            new: Either a `pytermgui.boxes.Box` instance or a string
194                analogous to one of the default box names.
195        """
196
197        if isinstance(new, str):
198            from_module = vars(boxes).get(new)
199            if from_module is None:
200                raise ValueError(f"Unknown box type {new}.")
201
202            new = from_module
203
204        assert isinstance(new, boxes.Box)
205        self._box = new
206        new.set_chars_of(self)
207
208    def get_change(self) -> WidgetChange | None:
209        """Determines whether widget lines changed since the last call to this function."""
210
211        change = super().get_change()
212
213        if change is None:
214            return None
215
216        for widget in self._widgets:
217            if widget.get_change() is not None:
218                self.dirty_widgets.append(widget)
219
220        return change
221
222    def __iadd__(self, other: object) -> Container:
223        """Adds a new widget, then returns self.
224
225        Args:
226            other: Any widget instance, or data structure that can be turned
227            into a widget by `Widget.from_data`.
228
229        Returns:
230            A reference to self.
231        """
232
233        self._add_widget(other)
234        return self
235
236    def __add__(self, other: object) -> Container:
237        """Adds a new widget, then returns self.
238
239        This method is analogous to `Container.__iadd__`.
240
241        Args:
242            other: Any widget instance, or data structure that can be turned
243            into a widget by `Widget.from_data`.
244
245        Returns:
246            A reference to self.
247        """
248
249        self.__iadd__(other)
250        return self
251
252    def __iter__(self) -> Iterator[Widget]:
253        """Gets an iterator of self._widgets.
254
255        Yields:
256            The next widget.
257        """
258
259        for widget in self._widgets:
260            yield widget
261
262    def __len__(self) -> int:
263        """Gets the length of the widgets list.
264
265        Returns:
266            An integer describing len(self._widgets).
267        """
268
269        return len(self._widgets)
270
271    def __getitem__(self, sli: int | slice) -> Widget | list[Widget]:
272        """Gets an item from self._widgets.
273
274        Args:
275            sli: Slice of the list.
276
277        Returns:
278            The slice in the list.
279        """
280
281        return self._widgets[sli]
282
283    def __setitem__(self, index: int, value: Any) -> None:
284        """Sets an item in self._widgets.
285
286        Args:
287            index: The index to be set.
288            value: The new widget at this index.
289        """
290
291        self._widgets[index] = value
292
293    def __contains__(self, other: object) -> bool:
294        """Determines if self._widgets contains other widget.
295
296        Args:
297            other: Any widget-like.
298
299        Returns:
300            A boolean describing whether `other` is in `self.widgets`
301        """
302
303        if other in self._widgets:
304            return True
305
306        for widget in self._widgets:
307            if isinstance(widget, Container) and other in widget:
308                return True
309
310        return False
311
312    def _add_widget(self, other: object, run_get_lines: bool = True) -> Widget:
313        """Adds other to this widget.
314
315        Args:
316            other: Any widget-like object.
317            run_get_lines: Boolean controlling whether the self.get_lines is ran.
318
319        Returns:
320            The added widget. This is useful when data conversion took place in this
321            function, e.g. a string was converted to a Label.
322        """
323
324        if not isinstance(other, Widget):
325            to_widget = Widget.from_data(other)
326            if to_widget is None:
327                raise ValueError(
328                    f"Could not convert {other} of type {type(other)} to a Widget!"
329                )
330
331            other = to_widget
332
333        # This is safe to do, as it would've raised an exception above already
334        assert isinstance(other, Widget)
335
336        self._widgets.append(other)
337        if isinstance(other, Container):
338            other.set_recursive_depth(self.depth + 2)
339        else:
340            other.depth = self.depth + 1
341
342        other.get_lines()
343        other.parent = self
344
345        if run_get_lines:
346            self.get_lines()
347
348        return other
349
350    def _get_aligners(
351        self, widget: Widget, borders: tuple[str, str]
352    ) -> tuple[Callable[[str], str], int]:
353        """Gets an aligning method and position offset.
354
355        Args:
356            widget: The widget to align.
357            borders: The left and right borders to put the widget within.
358
359        Returns:
360            A tuple of a method that, when called with a line, will return that line
361            centered using the passed in widget's parent_align and width, as well as
362            the horizontal offset resulting from the widget being aligned.
363        """
364
365        left, right = self.styles.border(borders[0]), self.styles.border(borders[1])
366        char = " "
367
368        fill = self.styles.fill
369
370        def _align_left(text: str) -> str:
371            """Align line to the left"""
372
373            padding = self.width - real_length(left + right) - real_length(text)
374            return left + text + fill(padding * char) + right
375
376        def _align_center(text: str) -> str:
377            """Align line to the center"""
378
379            total = self.width - real_length(left + right) - real_length(text)
380            padding, offset = divmod(total, 2)
381            return (
382                left
383                + fill((padding + offset) * char)
384                + text
385                + fill(padding * char)
386                + right
387            )
388
389        def _align_right(text: str) -> str:
390            """Align line to the right"""
391
392            padding = self.width - real_length(left + right) - real_length(text)
393            return left + fill(padding * char) + text + right
394
395        if widget.parent_align == HorizontalAlignment.CENTER:
396            total = self.width - real_length(left + right) - widget.width
397            padding, offset = divmod(total, 2)
398            return _align_center, real_length(left) + padding + offset
399
400        if widget.parent_align == HorizontalAlignment.RIGHT:
401            return _align_right, self.width - real_length(left) - widget.width
402
403        # Default to left-aligned
404        return _align_left, real_length(left)
405
406    def _update_width(self, widget: Widget) -> None:
407        """Updates the width of widget or self.
408
409        This method respects widget.size_policy.
410
411        Args:
412            widget: The widget to update/base updates on.
413
414        Raises:
415            ValueError: Widget has SizePolicy.RELATIVE, but relative_width is None.
416            WidthExceededError: Widget and self both have static widths, and widget's
417                is larger than what is available.
418        """
419
420        available = self.width - self.sidelength
421
422        if widget.size_policy == SizePolicy.FILL:
423            widget.width = available
424            return
425
426        if widget.size_policy == SizePolicy.RELATIVE:
427            if widget.relative_width is None:
428                raise ValueError(f'Widget "{widget}"\'s relative width cannot be None.')
429
430            widget.width = int(widget.relative_width * available)
431            return
432
433        if widget.width > available:
434            if widget.size_policy == self.size_policy == SizePolicy.STATIC:
435                raise WidthExceededError(
436                    f"Widget {widget}'s static width of {widget.width}"
437                    + f" exceeds its parent's available width {available}."
438                    ""
439                )
440
441            if widget.size_policy == SizePolicy.STATIC:
442                self.width = widget.width + self.sidelength
443
444            else:
445                widget.width = available
446
447    def _apply_vertalign(
448        self, lines: list[str], diff: int, padder: str
449    ) -> tuple[int, list[str]]:
450        """Insert padder line into lines diff times, depending on self.vertical_align.
451
452        Args:
453            lines: The list of lines to align.
454            diff: The available height.
455            padder: The line to use to pad.
456
457        Returns:
458            A tuple containing the vertical offset as well as the padded list of lines.
459
460        Raises:
461            NotImplementedError: The given vertical alignment is not implemented.
462        """
463
464        if self.vertical_align == VerticalAlignment.BOTTOM:
465            for _ in range(diff):
466                lines.insert(0, padder)
467
468            return diff, lines
469
470        if self.vertical_align == VerticalAlignment.TOP:
471            for _ in range(diff):
472                lines.append(padder)
473
474            return 0, lines
475
476        if self.vertical_align == VerticalAlignment.CENTER:
477            top, extra = divmod(diff, 2)
478            bottom = top + extra
479
480            for _ in range(top):
481                lines.insert(0, padder)
482
483            for _ in range(bottom):
484                lines.append(padder)
485
486            return top, lines
487
488        raise NotImplementedError(
489            f"Vertical alignment {self.vertical_align} is not implemented for {type(self)}."
490        )
491
492    def lazy_add(self, other: object) -> None:
493        """Adds `other` without running get_lines.
494
495        This is analogous to `self._add_widget(other, run_get_lines=False).
496
497        Args:
498            other: The object to add.
499        """
500
501        self._add_widget(other, run_get_lines=False)
502
503    def get_lines(self) -> list[str]:
504        """Gets all lines by spacing out inner widgets.
505
506        This method reflects & applies both width settings, as well as
507        the `parent_align` field.
508
509        Returns:
510            A list of all lines that represent this Container.
511        """
512
513        def _get_border(left: str, char: str, right: str) -> str:
514            """Gets a top or bottom border.
515
516            Args:
517                left: Left corner character.
518                char: Border character filling between left & right.
519                right: Right corner character.
520
521            Returns:
522                The border line.
523            """
524
525            offset = real_length(strip_markup(left + right))
526            return (
527                self.styles.corner(left)
528                + self.styles.border(char * (self.width - offset))
529                + self.styles.corner(right)
530            )
531
532        lines: list[str] = []
533
534        borders = self._get_char("border")
535        corners = self._get_char("corner")
536
537        has_top_bottom = (real_length(borders[1]) > 0, real_length(borders[3]) > 0)
538
539        align, offset = self._get_aligners(self, (borders[0], borders[2]))
540
541        overflow = self.overflow
542
543        self.positioned_line_buffer = []
544
545        for widget in self._widgets:
546            align, offset = self._get_aligners(widget, (borders[0], borders[2]))
547
548            self._update_width(widget)
549
550            widget.pos = (
551                self.pos[0] + offset,
552                self.pos[1] + len(lines) + (1 if has_top_bottom[0] else 0),
553            )
554
555            widget_lines: list[str] = []
556            for line in widget.get_lines():
557                if len(lines) + len(widget_lines) >= self.height - sum(has_top_bottom):
558                    if overflow is Overflow.HIDE:
559                        break
560
561                    if overflow == Overflow.AUTO:
562                        overflow = Overflow.SCROLL
563
564                widget_lines.append(align(line))
565
566            lines.extend(widget_lines)
567
568            self.positioned_line_buffer.extend(widget.positioned_line_buffer)
569            widget.positioned_line_buffer = []
570
571        if overflow == Overflow.SCROLL:
572            self._max_scroll = len(lines) - self.height + sum(has_top_bottom)
573            height = self.height - sum(has_top_bottom)
574
575            self._scroll_offset = max(0, min(self._scroll_offset, len(lines) - height))
576            lines = lines[self._scroll_offset : self._scroll_offset + height]
577
578        elif overflow == Overflow.RESIZE:
579            self.height = len(lines) + sum(has_top_bottom)
580
581        vertical_offset, lines = self._apply_vertalign(
582            lines, self.height - len(lines) - sum(has_top_bottom), align("")
583        )
584
585        for widget in self._widgets:
586            widget.pos = (widget.pos[0], widget.pos[1] + vertical_offset)
587
588            if widget.is_selectable:
589                widget.get_lines()
590
591        if has_top_bottom[0]:
592            lines.insert(0, _get_border(corners[0], borders[1], corners[1]))
593
594        if has_top_bottom[1]:
595            lines.append(_get_border(corners[3], borders[3], corners[2]))
596
597        self.height = len(lines)
598        return lines
599
600    def set_widgets(self, new: list[Widget]) -> None:
601        """Sets new list in place of self._widgets.
602
603        Args:
604            new: The new widget list.
605        """
606
607        self._widgets = []
608        for widget in new:
609            self._add_widget(widget)
610
611    def serialize(self) -> dict[str, Any]:
612        """Serializes this Container, adding in serializations of all widgets.
613
614        See `pytermgui.widgets.base.Widget.serialize` for more info.
615
616        Returns:
617            The dictionary containing all serialized data.
618        """
619
620        out = super().serialize()
621        out["_widgets"] = []
622
623        for widget in self._widgets:
624            out["_widgets"].append(widget.serialize())
625
626        return out
627
628    def pop(self, index: int = -1) -> Widget:
629        """Pops widget from self._widgets.
630
631        Analogous to self._widgets.pop(index).
632
633        Args:
634            index: The index to operate on.
635
636        Returns:
637            The widget that was popped off the list.
638        """
639
640        return self._widgets.pop(index)
641
642    def remove(self, other: Widget) -> None:
643        """Remove widget from self._widgets
644
645        Analogous to self._widgets.remove(other).
646
647        Args:
648            widget: The widget to remove.
649        """
650
651        return self._widgets.remove(other)
652
653    def set_recursive_depth(self, value: int) -> None:
654        """Set depth for this Container and all its children.
655
656        All inner widgets will receive value+1 as their new depth.
657
658        Args:
659            value: The new depth to use as the base depth.
660        """
661
662        self.depth = value
663        for widget in self._widgets:
664            if isinstance(widget, Container):
665                widget.set_recursive_depth(value + 1)
666            else:
667                widget.depth = value
668
669    def select(self, index: int | None = None) -> None:
670        """Selects inner subwidget.
671
672        Args:
673            index: The index to select.
674
675        Raises:
676            IndexError: The index provided was beyond len(self.selectables).
677        """
678
679        # Unselect all sub-elements
680        for other in self._widgets:
681            if other.selectables_length > 0:
682                other.select(None)
683
684        if index is not None:
685            index = max(0, min(index, len(self.selectables) - 1))
686            widget, inner_index = self.selectables[index]
687            widget.select(inner_index)
688
689        self.selected_index = index
690
691    def center(
692        self, where: CenteringPolicy | None = None, store: bool = True
693    ) -> Container:
694        """Centers this object to the given axis.
695
696        Args:
697            where: A CenteringPolicy describing the place to center to
698            store: When set, this centering will be reapplied during every
699                print, as well as when calling this method with no arguments.
700
701        Returns:
702            This Container.
703        """
704
705        # Refresh in case changes happened
706        self.get_lines()
707
708        if where is None:
709            # See `enums.py` for explanation about this ignore.
710            where = CenteringPolicy.get_default()  # type: ignore
711
712        centerx = centery = where is CenteringPolicy.ALL
713        centerx |= where is CenteringPolicy.HORIZONTAL
714        centery |= where is CenteringPolicy.VERTICAL
715
716        pos = list(self.pos)
717        if centerx:
718            pos[0] = (self.terminal.width - self.width + 2) // 2
719
720        if centery:
721            pos[1] = (self.terminal.height - self.height + 2) // 2
722
723        self.pos = (pos[0], pos[1])
724
725        if store:
726            self.centered_axis = where
727
728        self._prev_screen = self.terminal.size
729
730        return self
731
732    def handle_mouse(self, event: MouseEvent) -> bool:
733        """Handles mouse events.
734
735        This, like all mouse handlers should, calls super()'s implementation first,
736        to allow usage of `on_{event}`-type callbacks. After that, it tries to find
737        a target widget within itself to handle the event.
738
739        Each handler will return a boolean. This boolean is then used to figure out
740        whether the targeted widget should be "sticky", i.e. a slider. Returning
741        True will set that widget as the current mouse target, and all mouse events will
742        be sent to it as long as it returns True.
743
744        Args:
745            event: The event to handle.
746
747        Returns:
748            Whether the parent of this widget should treat it as one to "stick" events
749            to, e.g. to keep sending mouse events to it. One can "unstick" a widget by
750            returning False in the handler.
751        """
752
753        def _handle_scrolling() -> bool:
754            """Scrolls the container."""
755
756            if self.overflow != Overflow.SCROLL:
757                return False
758
759            if event.action is MouseAction.SCROLL_UP:
760                return self.scroll(-1)
761
762            if event.action is MouseAction.SCROLL_DOWN:
763                return self.scroll(1)
764
765            return False
766
767        if super().handle_mouse(event):
768            return True
769
770        if event.action is MouseAction.RELEASE and self._mouse_target is not None:
771            return self._mouse_target.handle_mouse(event)
772
773        if (
774            self._mouse_target is not None
775            and (
776                event.action.value.endswith("drag")
777                or event.action.value.startswith("scroll")
778            )
779            and self._mouse_target.handle_mouse(event)
780        ):
781            return True
782
783        release = MouseEvent(MouseAction.RELEASE, event.position)
784
785        selectables_index = 0
786        event.position = (event.position[0], event.position[1] + self._scroll_offset)
787
788        handled = False
789        for widget in self._widgets:
790            if (
791                widget.pos[1] - self.pos[1] - self._scroll_offset
792                > self.content_dimensions[1]
793            ):
794                break
795
796            if widget.contains(event.position):
797                handled = widget.handle_mouse(event)
798                selectables_index += widget.selected_index or 0
799
800                # TODO: This really should be customizable somehow.
801                if event.action is MouseAction.LEFT_CLICK:
802                    if handled and selectables_index < len(self.selectables):
803                        self.select(selectables_index)
804
805                if self._mouse_target is not None and self._mouse_target is not widget:
806                    self._mouse_target.handle_mouse(release)
807
808                self._mouse_target = widget
809
810                break
811
812            if widget.is_selectable:
813                selectables_index += widget.selectables_length
814
815        handled = handled or _handle_scrolling()
816
817        return handled
818
819    def execute_binding(self, key: str) -> bool:
820        """Executes a binding on self, and then on self._widgets.
821
822        If a widget.execute_binding call returns True this function will too. Note
823        that on success the function returns immediately; no further widgets are
824        checked.
825
826        Args:
827            key: The binding key.
828
829        Returns:
830            True if any widget returned True, False otherwise.
831        """
832
833        if super().execute_binding(key):
834            return True
835
836        selectables_index = 0
837        for widget in self._widgets:
838            if widget.execute_binding(key):
839                selectables_index += widget.selected_index or 0
840                self.select(selectables_index)
841                return True
842
843            if widget.is_selectable:
844                selectables_index += widget.selectables_length
845
846        return False
847
848    def handle_key(  # pylint: disable=too-many-return-statements, too-many-branches
849        self, key: str
850    ) -> bool:
851        """Handles a keypress, returns its success.
852
853        Args:
854            key: A key str.
855
856        Returns:
857            A boolean showing whether the key was handled.
858        """
859
860        def _is_nav(key: str) -> bool:
861            """Determine if a key is in the navigation sets"""
862
863            return key in self.keys["next"] | self.keys["previous"]
864
865        if self.selected is not None and self.selected.handle_key(key):
866            return True
867
868        scroll_actions = {
869            **{key: 1 for key in self.keys["scroll_down"]},
870            **{key: -1 for key in self.keys["scroll_up"]},
871        }
872
873        if key in self.keys["scroll_down"] | self.keys["scroll_up"]:
874            for widget in self._widgets:
875                if isinstance(widget, Container) and self.selected in widget:
876                    widget.handle_key(key)
877
878            self.scroll(scroll_actions[key])
879            return True
880
881        # Only use navigation when there is more than one selectable
882        if self.selectables_length >= 1 and _is_nav(key):
883            if self.selected_index is None:
884                self.select(0)
885                return True
886
887            handled = False
888
889            assert isinstance(self.selected_index, int)
890
891            if key in self.keys["previous"]:
892                # No more selectables left, user wants to exit Container
893                # upwards.
894                if self.selected_index == 0:
895                    return False
896
897                self.select(self.selected_index - 1)
898                handled = True
899
900            elif key in self.keys["next"]:
901                # Stop selection at last element, return as unhandled
902                new = self.selected_index + 1
903                if new == len(self.selectables):
904                    return False
905
906                self.select(new)
907                handled = True
908
909            if handled:
910                return True
911
912        if key == keys.ENTER:
913            if self.selected_index is None and self.selectables_length > 0:
914                self.select(0)
915
916            if self.selected is not None:
917                self.selected.handle_key(key)
918                return True
919
920        for widget in self._widgets:
921            if widget.execute_binding(key):
922                return True
923
924        return False
925
926    def wipe(self) -> None:
927        """Wipes the characters occupied by the object"""
928
929        with cursor_at(self.pos) as print_here:
930            for line in self.get_lines():
931                print_here(real_length(line) * " ")
932
933    def print(self) -> None:
934        """Prints this Container.
935
936        If the screen size has changed since last `print` call, the object
937        will be centered based on its `centered_axis`.
938        """
939
940        if not self.terminal.size == self._prev_screen:
941            clear()
942            self.center(self.centered_axis)
943
944        self._prev_screen = self.terminal.size
945
946        if self.allow_fullscreen:
947            self.pos = self.terminal.origin
948
949        with cursor_at(self.pos) as print_here:
950            for line in self.get_lines():
951                print_here(line)
952
953        self._has_printed = True
954
955    def debug(self) -> str:
956        """Returns a string with identifiable information on this widget.
957
958        Returns:
959            A str in the form of a class construction. This string is in a form that
960            __could have been__ used to create this Container.
961        """
962
963        return (
964            f"{type(self).__name__}(width={self.width}, height={self.height}"
965            + (f", id={self.id}" if self.id is not None else "")
966            + ")"
967        )

A widget that displays other widgets, stacked vertically.

Container(*widgets: Any, **attrs: Any)
59    def __init__(self, *widgets: Any, **attrs: Any) -> None:
60        """Initialize Container data"""
61
62        super().__init__(**attrs)
63
64        # TODO: This is just a band-aid.
65        if not any("width" in attr for attr in attrs):
66            self.width = 40
67
68        self._widgets: list[Widget] = []
69        self.dirty_widgets: list[Widget] = []
70        self.centered_axis: CenteringPolicy | None = None
71
72        self._prev_screen: tuple[int, int] = (0, 0)
73        self._has_printed = False
74
75        for widget in widgets:
76            self._add_widget(widget)
77
78        if "box" in attrs:
79            self.box = attrs["box"]
80
81        self._mouse_target: Widget | None = None

Initialize Container data

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

Default styles for this class

chars: dict[str, typing.Union[typing.List[str], str]] = {'border': ['| ', '-', ' |', '-'], 'corner': ['', '', '', '']}

Default characters for this class

keys: dict[str, set[str]] = {'next': {'\x0e', 'j', '\x1b[B'}, 'previous': {'\x10', '\x1b[A', 'k'}, 'scroll_down': {'J', '\x1b[1;2B'}, 'scroll_up': {'K', '\x1b[1;2A'}}

Groups of keys that are used in handle_key

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

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

vertical_align = <VerticalAlignment.CENTER: 1>
allow_fullscreen = True
overflow = <Overflow.RESIZE: 2>
sidelength: int

Gets the length of left and right borders combined.

Returns

An integer equal to the pytermgui.helpers.real_length of the concatenation of the left and right borders of this widget, both with their respective styles applied.

content_dimensions: tuple[int, int]

Gets the size (width, height) of the available content area.

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

Gets all selectable widgets and their inner indices.

This is used in order to have a constant reference to all selectable indices within this widget.

Returns

A list of tuples containing a widget and an integer each. For each widget that is withing this one, it is added to this list as many times as it has selectables. Each of the integers correspond to a selectable_index within the widget.

For example, a Container with a Button, InputField and an inner Container containing 3 selectables might return something like this:

[
    (Button(...), 0),
    (InputField(...), 0),
    (Container(...), 0),
    (Container(...), 1),
    (Container(...), 2),
]
selectables_length: int

Gets the length of the selectables list.

Returns

An integer equal to the length of self.selectables.

Returns the currently selected object

Returns

The currently selected widget if selected_index is not None, otherwise None.

Returns current box setting

Returns

The currently set box instance.

def get_change(self) -> pytermgui.enums.WidgetChange | None:
208    def get_change(self) -> WidgetChange | None:
209        """Determines whether widget lines changed since the last call to this function."""
210
211        change = super().get_change()
212
213        if change is None:
214            return None
215
216        for widget in self._widgets:
217            if widget.get_change() is not None:
218                self.dirty_widgets.append(widget)
219
220        return change

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

def lazy_add(self, other: object) -> None:
492    def lazy_add(self, other: object) -> None:
493        """Adds `other` without running get_lines.
494
495        This is analogous to `self._add_widget(other, run_get_lines=False).
496
497        Args:
498            other: The object to add.
499        """
500
501        self._add_widget(other, run_get_lines=False)

Adds other without running get_lines.

This is analogous to `self._add_widget(other, run_get_lines=False).

Args
  • other: The object to add.
def get_lines(self) -> list[str]:
503    def get_lines(self) -> list[str]:
504        """Gets all lines by spacing out inner widgets.
505
506        This method reflects & applies both width settings, as well as
507        the `parent_align` field.
508
509        Returns:
510            A list of all lines that represent this Container.
511        """
512
513        def _get_border(left: str, char: str, right: str) -> str:
514            """Gets a top or bottom border.
515
516            Args:
517                left: Left corner character.
518                char: Border character filling between left & right.
519                right: Right corner character.
520
521            Returns:
522                The border line.
523            """
524
525            offset = real_length(strip_markup(left + right))
526            return (
527                self.styles.corner(left)
528                + self.styles.border(char * (self.width - offset))
529                + self.styles.corner(right)
530            )
531
532        lines: list[str] = []
533
534        borders = self._get_char("border")
535        corners = self._get_char("corner")
536
537        has_top_bottom = (real_length(borders[1]) > 0, real_length(borders[3]) > 0)
538
539        align, offset = self._get_aligners(self, (borders[0], borders[2]))
540
541        overflow = self.overflow
542
543        self.positioned_line_buffer = []
544
545        for widget in self._widgets:
546            align, offset = self._get_aligners(widget, (borders[0], borders[2]))
547
548            self._update_width(widget)
549
550            widget.pos = (
551                self.pos[0] + offset,
552                self.pos[1] + len(lines) + (1 if has_top_bottom[0] else 0),
553            )
554
555            widget_lines: list[str] = []
556            for line in widget.get_lines():
557                if len(lines) + len(widget_lines) >= self.height - sum(has_top_bottom):
558                    if overflow is Overflow.HIDE:
559                        break
560
561                    if overflow == Overflow.AUTO:
562                        overflow = Overflow.SCROLL
563
564                widget_lines.append(align(line))
565
566            lines.extend(widget_lines)
567
568            self.positioned_line_buffer.extend(widget.positioned_line_buffer)
569            widget.positioned_line_buffer = []
570
571        if overflow == Overflow.SCROLL:
572            self._max_scroll = len(lines) - self.height + sum(has_top_bottom)
573            height = self.height - sum(has_top_bottom)
574
575            self._scroll_offset = max(0, min(self._scroll_offset, len(lines) - height))
576            lines = lines[self._scroll_offset : self._scroll_offset + height]
577
578        elif overflow == Overflow.RESIZE:
579            self.height = len(lines) + sum(has_top_bottom)
580
581        vertical_offset, lines = self._apply_vertalign(
582            lines, self.height - len(lines) - sum(has_top_bottom), align("")
583        )
584
585        for widget in self._widgets:
586            widget.pos = (widget.pos[0], widget.pos[1] + vertical_offset)
587
588            if widget.is_selectable:
589                widget.get_lines()
590
591        if has_top_bottom[0]:
592            lines.insert(0, _get_border(corners[0], borders[1], corners[1]))
593
594        if has_top_bottom[1]:
595            lines.append(_get_border(corners[3], borders[3], corners[2]))
596
597        self.height = len(lines)
598        return lines

Gets all lines by spacing out inner widgets.

This method reflects & applies both width settings, as well as the parent_align field.

Returns

A list of all lines that represent this Container.

def set_widgets(self, new: list[pytermgui.widgets.base.Widget]) -> None:
600    def set_widgets(self, new: list[Widget]) -> None:
601        """Sets new list in place of self._widgets.
602
603        Args:
604            new: The new widget list.
605        """
606
607        self._widgets = []
608        for widget in new:
609            self._add_widget(widget)

Sets new list in place of self._widgets.

Args
  • new: The new widget list.
def serialize(self) -> dict[str, typing.Any]:
611    def serialize(self) -> dict[str, Any]:
612        """Serializes this Container, adding in serializations of all widgets.
613
614        See `pytermgui.widgets.base.Widget.serialize` for more info.
615
616        Returns:
617            The dictionary containing all serialized data.
618        """
619
620        out = super().serialize()
621        out["_widgets"] = []
622
623        for widget in self._widgets:
624            out["_widgets"].append(widget.serialize())
625
626        return out

Serializes this Container, adding in serializations of all widgets.

See pytermgui.widgets.base.Widget.serialize for more info.

Returns

The dictionary containing all serialized data.

def pop(self, index: int = -1) -> pytermgui.widgets.base.Widget:
628    def pop(self, index: int = -1) -> Widget:
629        """Pops widget from self._widgets.
630
631        Analogous to self._widgets.pop(index).
632
633        Args:
634            index: The index to operate on.
635
636        Returns:
637            The widget that was popped off the list.
638        """
639
640        return self._widgets.pop(index)

Pops widget from self._widgets.

Analogous to self._widgets.pop(index).

Args
  • index: The index to operate on.
Returns

The widget that was popped off the list.

def remove(self, other: pytermgui.widgets.base.Widget) -> None:
642    def remove(self, other: Widget) -> None:
643        """Remove widget from self._widgets
644
645        Analogous to self._widgets.remove(other).
646
647        Args:
648            widget: The widget to remove.
649        """
650
651        return self._widgets.remove(other)

Remove widget from self._widgets

Analogous to self._widgets.remove(other).

Args
  • widget: The widget to remove.
def set_recursive_depth(self, value: int) -> None:
653    def set_recursive_depth(self, value: int) -> None:
654        """Set depth for this Container and all its children.
655
656        All inner widgets will receive value+1 as their new depth.
657
658        Args:
659            value: The new depth to use as the base depth.
660        """
661
662        self.depth = value
663        for widget in self._widgets:
664            if isinstance(widget, Container):
665                widget.set_recursive_depth(value + 1)
666            else:
667                widget.depth = value

Set depth for this Container and all its children.

All inner widgets will receive value+1 as their new depth.

Args
  • value: The new depth to use as the base depth.
def select(self, index: int | None = None) -> None:
669    def select(self, index: int | None = None) -> None:
670        """Selects inner subwidget.
671
672        Args:
673            index: The index to select.
674
675        Raises:
676            IndexError: The index provided was beyond len(self.selectables).
677        """
678
679        # Unselect all sub-elements
680        for other in self._widgets:
681            if other.selectables_length > 0:
682                other.select(None)
683
684        if index is not None:
685            index = max(0, min(index, len(self.selectables) - 1))
686            widget, inner_index = self.selectables[index]
687            widget.select(inner_index)
688
689        self.selected_index = index

Selects inner subwidget.

Args
  • index: The index to select.
Raises
  • IndexError: The index provided was beyond len(self.selectables).
def center( self, where: pytermgui.enums.CenteringPolicy | None = None, store: bool = True) -> pytermgui.widgets.containers.Container:
691    def center(
692        self, where: CenteringPolicy | None = None, store: bool = True
693    ) -> Container:
694        """Centers this object to the given axis.
695
696        Args:
697            where: A CenteringPolicy describing the place to center to
698            store: When set, this centering will be reapplied during every
699                print, as well as when calling this method with no arguments.
700
701        Returns:
702            This Container.
703        """
704
705        # Refresh in case changes happened
706        self.get_lines()
707
708        if where is None:
709            # See `enums.py` for explanation about this ignore.
710            where = CenteringPolicy.get_default()  # type: ignore
711
712        centerx = centery = where is CenteringPolicy.ALL
713        centerx |= where is CenteringPolicy.HORIZONTAL
714        centery |= where is CenteringPolicy.VERTICAL
715
716        pos = list(self.pos)
717        if centerx:
718            pos[0] = (self.terminal.width - self.width + 2) // 2
719
720        if centery:
721            pos[1] = (self.terminal.height - self.height + 2) // 2
722
723        self.pos = (pos[0], pos[1])
724
725        if store:
726            self.centered_axis = where
727
728        self._prev_screen = self.terminal.size
729
730        return self

Centers this object to the given axis.

Args
  • where: A CenteringPolicy describing the place to center to
  • store: When set, this centering will be reapplied during every print, as well as when calling this method with no arguments.
Returns

This Container.

def handle_mouse(self, event: pytermgui.ansi_interface.MouseEvent) -> bool:
732    def handle_mouse(self, event: MouseEvent) -> bool:
733        """Handles mouse events.
734
735        This, like all mouse handlers should, calls super()'s implementation first,
736        to allow usage of `on_{event}`-type callbacks. After that, it tries to find
737        a target widget within itself to handle the event.
738
739        Each handler will return a boolean. This boolean is then used to figure out
740        whether the targeted widget should be "sticky", i.e. a slider. Returning
741        True will set that widget as the current mouse target, and all mouse events will
742        be sent to it as long as it returns True.
743
744        Args:
745            event: The event to handle.
746
747        Returns:
748            Whether the parent of this widget should treat it as one to "stick" events
749            to, e.g. to keep sending mouse events to it. One can "unstick" a widget by
750            returning False in the handler.
751        """
752
753        def _handle_scrolling() -> bool:
754            """Scrolls the container."""
755
756            if self.overflow != Overflow.SCROLL:
757                return False
758
759            if event.action is MouseAction.SCROLL_UP:
760                return self.scroll(-1)
761
762            if event.action is MouseAction.SCROLL_DOWN:
763                return self.scroll(1)
764
765            return False
766
767        if super().handle_mouse(event):
768            return True
769
770        if event.action is MouseAction.RELEASE and self._mouse_target is not None:
771            return self._mouse_target.handle_mouse(event)
772
773        if (
774            self._mouse_target is not None
775            and (
776                event.action.value.endswith("drag")
777                or event.action.value.startswith("scroll")
778            )
779            and self._mouse_target.handle_mouse(event)
780        ):
781            return True
782
783        release = MouseEvent(MouseAction.RELEASE, event.position)
784
785        selectables_index = 0
786        event.position = (event.position[0], event.position[1] + self._scroll_offset)
787
788        handled = False
789        for widget in self._widgets:
790            if (
791                widget.pos[1] - self.pos[1] - self._scroll_offset
792                > self.content_dimensions[1]
793            ):
794                break
795
796            if widget.contains(event.position):
797                handled = widget.handle_mouse(event)
798                selectables_index += widget.selected_index or 0
799
800                # TODO: This really should be customizable somehow.
801                if event.action is MouseAction.LEFT_CLICK:
802                    if handled and selectables_index < len(self.selectables):
803                        self.select(selectables_index)
804
805                if self._mouse_target is not None and self._mouse_target is not widget:
806                    self._mouse_target.handle_mouse(release)
807
808                self._mouse_target = widget
809
810                break
811
812            if widget.is_selectable:
813                selectables_index += widget.selectables_length
814
815        handled = handled or _handle_scrolling()
816
817        return handled

Handles mouse events.

This, like all mouse handlers should, calls super()'s implementation first, to allow usage of on_{event}-type callbacks. After that, it tries to find a target widget within itself to handle the event.

Each handler will return a boolean. This boolean is then used to figure out whether the targeted widget should be "sticky", i.e. a slider. Returning True will set that widget as the current mouse target, and all mouse events will be sent to it as long as it returns True.

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 execute_binding(self, key: str) -> bool:
819    def execute_binding(self, key: str) -> bool:
820        """Executes a binding on self, and then on self._widgets.
821
822        If a widget.execute_binding call returns True this function will too. Note
823        that on success the function returns immediately; no further widgets are
824        checked.
825
826        Args:
827            key: The binding key.
828
829        Returns:
830            True if any widget returned True, False otherwise.
831        """
832
833        if super().execute_binding(key):
834            return True
835
836        selectables_index = 0
837        for widget in self._widgets:
838            if widget.execute_binding(key):
839                selectables_index += widget.selected_index or 0
840                self.select(selectables_index)
841                return True
842
843            if widget.is_selectable:
844                selectables_index += widget.selectables_length
845
846        return False

Executes a binding on self, and then on self._widgets.

If a widget.execute_binding call returns True this function will too. Note that on success the function returns immediately; no further widgets are checked.

Args
  • key: The binding key.
Returns

True if any widget returned True, False otherwise.

def handle_key(self, key: str) -> bool:
848    def handle_key(  # pylint: disable=too-many-return-statements, too-many-branches
849        self, key: str
850    ) -> bool:
851        """Handles a keypress, returns its success.
852
853        Args:
854            key: A key str.
855
856        Returns:
857            A boolean showing whether the key was handled.
858        """
859
860        def _is_nav(key: str) -> bool:
861            """Determine if a key is in the navigation sets"""
862
863            return key in self.keys["next"] | self.keys["previous"]
864
865        if self.selected is not None and self.selected.handle_key(key):
866            return True
867
868        scroll_actions = {
869            **{key: 1 for key in self.keys["scroll_down"]},
870            **{key: -1 for key in self.keys["scroll_up"]},
871        }
872
873        if key in self.keys["scroll_down"] | self.keys["scroll_up"]:
874            for widget in self._widgets:
875                if isinstance(widget, Container) and self.selected in widget:
876                    widget.handle_key(key)
877
878            self.scroll(scroll_actions[key])
879            return True
880
881        # Only use navigation when there is more than one selectable
882        if self.selectables_length >= 1 and _is_nav(key):
883            if self.selected_index is None:
884                self.select(0)
885                return True
886
887            handled = False
888
889            assert isinstance(self.selected_index, int)
890
891            if key in self.keys["previous"]:
892                # No more selectables left, user wants to exit Container
893                # upwards.
894                if self.selected_index == 0:
895                    return False
896
897                self.select(self.selected_index - 1)
898                handled = True
899
900            elif key in self.keys["next"]:
901                # Stop selection at last element, return as unhandled
902                new = self.selected_index + 1
903                if new == len(self.selectables):
904                    return False
905
906                self.select(new)
907                handled = True
908
909            if handled:
910                return True
911
912        if key == keys.ENTER:
913            if self.selected_index is None and self.selectables_length > 0:
914                self.select(0)
915
916            if self.selected is not None:
917                self.selected.handle_key(key)
918                return True
919
920        for widget in self._widgets:
921            if widget.execute_binding(key):
922                return True
923
924        return False

Handles a keypress, returns its success.

Args
  • key: A key str.
Returns

A boolean showing whether the key was handled.

def wipe(self) -> None:
926    def wipe(self) -> None:
927        """Wipes the characters occupied by the object"""
928
929        with cursor_at(self.pos) as print_here:
930            for line in self.get_lines():
931                print_here(real_length(line) * " ")

Wipes the characters occupied by the object

def print(self) -> None:
933    def print(self) -> None:
934        """Prints this Container.
935
936        If the screen size has changed since last `print` call, the object
937        will be centered based on its `centered_axis`.
938        """
939
940        if not self.terminal.size == self._prev_screen:
941            clear()
942            self.center(self.centered_axis)
943
944        self._prev_screen = self.terminal.size
945
946        if self.allow_fullscreen:
947            self.pos = self.terminal.origin
948
949        with cursor_at(self.pos) as print_here:
950            for line in self.get_lines():
951                print_here(line)
952
953        self._has_printed = True

Prints this Container.

If the screen size has changed since last print call, the object will be centered based on its centered_axis.

def debug(self) -> str:
955    def debug(self) -> str:
956        """Returns a string with identifiable information on this widget.
957
958        Returns:
959            A str in the form of a class construction. This string is in a form that
960            __could have been__ used to create this Container.
961        """
962
963        return (
964            f"{type(self).__name__}(width={self.width}, height={self.height}"
965            + (f", id={self.id}" if self.id is not None else "")
966            + ")"
967        )

Returns a string with identifiable information on this widget.

Returns

A str in the form of a class construction. This string is in a form that __could have been__ used to create this Container.

class Splitter(Container):
 970class Splitter(Container):
 971    """A widget that displays other widgets, stacked horizontally."""
 972
 973    styles = w_styles.StyleManager(separator=w_styles.MARKUP, fill=w_styles.BACKGROUND)
 974
 975    chars: dict[str, list[str] | str] = {"separator": " | "}
 976    keys = {
 977        "previous": {keys.LEFT, "h", keys.CTRL_B},
 978        "next": {keys.RIGHT, "l", keys.CTRL_F},
 979    }
 980
 981    parent_align = HorizontalAlignment.RIGHT
 982
 983    def _align(
 984        self, alignment: HorizontalAlignment, target_width: int, line: str
 985    ) -> tuple[int, str]:
 986        """Align a line
 987
 988        r/wordavalanches"""
 989
 990        available = target_width - real_length(line)
 991        fill_style = self._get_style("fill")
 992
 993        char = fill_style(" ")
 994        line = fill_style(line)
 995
 996        if alignment == HorizontalAlignment.CENTER:
 997            padding, offset = divmod(available, 2)
 998            return padding, padding * char + line + (padding + offset) * char
 999
1000        if alignment == HorizontalAlignment.RIGHT:
1001            return available, available * char + line
1002
1003        return 0, line + available * char
1004
1005    @property
1006    def content_dimensions(self) -> tuple[int, int]:
1007        """Returns the available area for widgets."""
1008
1009        return self.height, self.width
1010
1011    def get_lines(self) -> list[str]:
1012        """Join all widgets horizontally."""
1013
1014        # An error will be raised if `separator` is not the correct type (str).
1015        separator = self._get_style("separator")(self._get_char("separator"))  # type: ignore
1016        separator_length = real_length(separator)
1017
1018        target_width, error = divmod(
1019            self.width - (len(self._widgets) - 1) * separator_length, len(self._widgets)
1020        )
1021
1022        vertical_lines = []
1023        total_offset = 0
1024
1025        for widget in self._widgets:
1026            inner = []
1027
1028            if widget.size_policy is SizePolicy.STATIC:
1029                target_width += target_width - widget.width
1030                width = widget.width
1031            else:
1032                widget.width = target_width + error
1033                width = widget.width
1034                error = 0
1035
1036            aligned: str | None = None
1037            for line in widget.get_lines():
1038                # See `enums.py` for information about this ignore
1039                padding, aligned = self._align(
1040                    cast(HorizontalAlignment, widget.parent_align), width, line
1041                )
1042                inner.append(aligned)
1043
1044            widget.pos = (
1045                self.pos[0] + padding + total_offset,
1046                self.pos[1] + (1 if type(widget).__name__ == "Container" else 0),
1047            )
1048
1049            if aligned is not None:
1050                total_offset += real_length(inner[-1]) + separator_length
1051
1052            vertical_lines.append(inner)
1053
1054        lines = []
1055        for horizontal in zip_longest(*vertical_lines, fillvalue=" " * target_width):
1056            lines.append((reset() + separator).join(horizontal))
1057
1058        self.height = max(widget.height for widget in self)
1059        return lines
1060
1061    def debug(self) -> str:
1062        """Return identifiable information"""
1063
1064        return super().debug().replace("Container", "Splitter", 1)

A widget that displays other widgets, stacked horizontally.

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

Default styles for this class

chars: dict[str, list[str] | str] = {'separator': ' | '}

Default characters for this class

keys: dict[str, set[str]] = {'previous': {'\x1b[D', 'h', '\x02'}, 'next': {'\x06', 'l', '\x1b[C'}}

Groups of keys that are used in handle_key

parent_align = <HorizontalAlignment.RIGHT: 2>
content_dimensions: tuple[int, int]

Returns the available area for widgets.

def get_lines(self) -> list[str]:
1011    def get_lines(self) -> list[str]:
1012        """Join all widgets horizontally."""
1013
1014        # An error will be raised if `separator` is not the correct type (str).
1015        separator = self._get_style("separator")(self._get_char("separator"))  # type: ignore
1016        separator_length = real_length(separator)
1017
1018        target_width, error = divmod(
1019            self.width - (len(self._widgets) - 1) * separator_length, len(self._widgets)
1020        )
1021
1022        vertical_lines = []
1023        total_offset = 0
1024
1025        for widget in self._widgets:
1026            inner = []
1027
1028            if widget.size_policy is SizePolicy.STATIC:
1029                target_width += target_width - widget.width
1030                width = widget.width
1031            else:
1032                widget.width = target_width + error
1033                width = widget.width
1034                error = 0
1035
1036            aligned: str | None = None
1037            for line in widget.get_lines():
1038                # See `enums.py` for information about this ignore
1039                padding, aligned = self._align(
1040                    cast(HorizontalAlignment, widget.parent_align), width, line
1041                )
1042                inner.append(aligned)
1043
1044            widget.pos = (
1045                self.pos[0] + padding + total_offset,
1046                self.pos[1] + (1 if type(widget).__name__ == "Container" else 0),
1047            )
1048
1049            if aligned is not None:
1050                total_offset += real_length(inner[-1]) + separator_length
1051
1052            vertical_lines.append(inner)
1053
1054        lines = []
1055        for horizontal in zip_longest(*vertical_lines, fillvalue=" " * target_width):
1056            lines.append((reset() + separator).join(horizontal))
1057
1058        self.height = max(widget.height for widget in self)
1059        return lines

Join all widgets horizontally.

def debug(self) -> str:
1061    def debug(self) -> str:
1062        """Return identifiable information"""
1063
1064        return super().debug().replace("Container", "Splitter", 1)

Return identifiable information