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        for widget in self._widgets:
 543            align, offset = self._get_aligners(widget, (borders[0], borders[2]))
 544
 545            self._update_width(widget)
 546
 547            widget.pos = (
 548                self.pos[0] + offset,
 549                self.pos[1] + len(lines) + (1 if has_top_bottom[0] else 0),
 550            )
 551
 552            widget_lines: list[str] = []
 553            for line in widget.get_lines():
 554                if len(lines) + len(widget_lines) >= self.height - sum(has_top_bottom):
 555                    if overflow is Overflow.HIDE:
 556                        break
 557
 558                    if overflow == Overflow.AUTO:
 559                        overflow = Overflow.SCROLL
 560
 561                widget_lines.append(align(line))
 562
 563            lines.extend(widget_lines)
 564
 565        if overflow == Overflow.SCROLL:
 566            self._max_scroll = len(lines) - self.height + sum(has_top_bottom)
 567            height = self.height - sum(has_top_bottom)
 568
 569            self._scroll_offset = max(0, min(self._scroll_offset, len(lines) - height))
 570            lines = lines[self._scroll_offset : self._scroll_offset + height]
 571
 572        elif overflow == Overflow.RESIZE:
 573            self.height = len(lines) + sum(has_top_bottom)
 574
 575        vertical_offset, lines = self._apply_vertalign(
 576            lines, self.height - len(lines) - sum(has_top_bottom), align("")
 577        )
 578
 579        for widget in self._widgets:
 580            widget.pos = (widget.pos[0], widget.pos[1] + vertical_offset)
 581
 582            if widget.is_selectable:
 583                # This buffer will be out of position, so we must clear it.
 584                widget.positioned_line_buffer = []
 585                widget.get_lines()
 586
 587            self.positioned_line_buffer.extend(widget.positioned_line_buffer)
 588
 589            widget.positioned_line_buffer = []
 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: Any, ignore_any: bool = False) -> 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            ignore_any: If set, `keys.ANY_KEY` bindings will not be executed.
 829
 830        Returns:
 831            True if any widget returned True, False otherwise.
 832        """
 833
 834        if super().execute_binding(key, ignore_any=ignore_any):
 835            return True
 836
 837        selectables_index = 0
 838        for widget in self._widgets:
 839            if widget.execute_binding(key):
 840                selectables_index += widget.selected_index or 0
 841                self.select(selectables_index)
 842                return True
 843
 844            if widget.is_selectable:
 845                selectables_index += widget.selectables_length
 846
 847        return False
 848
 849    def handle_key(  # pylint: disable=too-many-return-statements, too-many-branches
 850        self, key: str
 851    ) -> bool:
 852        """Handles a keypress, returns its success.
 853
 854        Args:
 855            key: A key str.
 856
 857        Returns:
 858            A boolean showing whether the key was handled.
 859        """
 860
 861        def _is_nav(key: str) -> bool:
 862            """Determine if a key is in the navigation sets"""
 863
 864            return key in self.keys["next"] | self.keys["previous"]
 865
 866        if self.selected is not None and self.selected.handle_key(key):
 867            return True
 868
 869        scroll_actions = {
 870            **{key: 1 for key in self.keys["scroll_down"]},
 871            **{key: -1 for key in self.keys["scroll_up"]},
 872        }
 873
 874        if key in self.keys["scroll_down"] | self.keys["scroll_up"]:
 875            for widget in self._widgets:
 876                if isinstance(widget, Container) and self.selected in widget:
 877                    widget.handle_key(key)
 878
 879            self.scroll(scroll_actions[key])
 880            return True
 881
 882        # Only use navigation when there is more than one selectable
 883        if self.selectables_length >= 1 and _is_nav(key):
 884            if self.selected_index is None:
 885                self.select(0)
 886                return True
 887
 888            handled = False
 889
 890            assert isinstance(self.selected_index, int)
 891
 892            if key in self.keys["previous"]:
 893                # No more selectables left, user wants to exit Container
 894                # upwards.
 895                if self.selected_index == 0:
 896                    return False
 897
 898                self.select(self.selected_index - 1)
 899                handled = True
 900
 901            elif key in self.keys["next"]:
 902                # Stop selection at last element, return as unhandled
 903                new = self.selected_index + 1
 904                if new == len(self.selectables):
 905                    return False
 906
 907                self.select(new)
 908                handled = True
 909
 910            if handled:
 911                return True
 912
 913        if key == keys.ENTER:
 914            if self.selected_index is None and self.selectables_length > 0:
 915                self.select(0)
 916
 917            if self.selected is not None:
 918                self.selected.handle_key(key)
 919                return True
 920
 921        for widget in self._widgets:
 922            if widget.execute_binding(key):
 923                return True
 924
 925        return False
 926
 927    def wipe(self) -> None:
 928        """Wipes the characters occupied by the object"""
 929
 930        with cursor_at(self.pos) as print_here:
 931            for line in self.get_lines():
 932                print_here(real_length(line) * " ")
 933
 934    def print(self) -> None:
 935        """Prints this Container.
 936
 937        If the screen size has changed since last `print` call, the object
 938        will be centered based on its `centered_axis`.
 939        """
 940
 941        if not self.terminal.size == self._prev_screen:
 942            clear()
 943            self.center(self.centered_axis)
 944
 945        self._prev_screen = self.terminal.size
 946
 947        if self.allow_fullscreen:
 948            self.pos = self.terminal.origin
 949
 950        with cursor_at(self.pos) as print_here:
 951            for line in self.get_lines():
 952                print_here(line)
 953
 954        self._has_printed = True
 955
 956    def debug(self) -> str:
 957        """Returns a string with identifiable information on this widget.
 958
 959        Returns:
 960            A str in the form of a class construction. This string is in a form that
 961            __could have been__ used to create this Container.
 962        """
 963
 964        return (
 965            f"{type(self).__name__}(width={self.width}, height={self.height}"
 966            + (f", id={self.id}" if self.id is not None else "")
 967            + ")"
 968        )
 969
 970
 971class Splitter(Container):
 972    """A widget that displays other widgets, stacked horizontally."""
 973
 974    styles = w_styles.StyleManager(separator=w_styles.MARKUP, fill=w_styles.BACKGROUND)
 975
 976    chars: dict[str, list[str] | str] = {"separator": " | "}
 977    keys = {
 978        "previous": {keys.LEFT, "h", keys.CTRL_B},
 979        "next": {keys.RIGHT, "l", keys.CTRL_F},
 980    }
 981
 982    parent_align = HorizontalAlignment.RIGHT
 983
 984    def _align(
 985        self, alignment: HorizontalAlignment, target_width: int, line: str
 986    ) -> tuple[int, str]:
 987        """Align a line
 988
 989        r/wordavalanches"""
 990
 991        available = target_width - real_length(line)
 992        fill_style = self._get_style("fill")
 993
 994        char = fill_style(" ")
 995        line = fill_style(line)
 996
 997        if alignment == HorizontalAlignment.CENTER:
 998            padding, offset = divmod(available, 2)
 999            return padding, padding * char + line + (padding + offset) * char
1000
1001        if alignment == HorizontalAlignment.RIGHT:
1002            return available, available * char + line
1003
1004        return 0, line + available * char
1005
1006    @property
1007    def content_dimensions(self) -> tuple[int, int]:
1008        """Returns the available area for widgets."""
1009
1010        return self.height, self.width
1011
1012    def get_lines(self) -> list[str]:
1013        """Join all widgets horizontally."""
1014
1015        # An error will be raised if `separator` is not the correct type (str).
1016        separator = self._get_style("separator")(self._get_char("separator"))  # type: ignore
1017        separator_length = real_length(separator)
1018
1019        target_width, error = divmod(
1020            self.width - (len(self._widgets) - 1) * separator_length, len(self._widgets)
1021        )
1022
1023        vertical_lines = []
1024        total_offset = 0
1025
1026        for widget in self._widgets:
1027            inner = []
1028
1029            if widget.size_policy is SizePolicy.STATIC:
1030                target_width += target_width - widget.width
1031                width = widget.width
1032            else:
1033                widget.width = target_width + error
1034                width = widget.width
1035                error = 0
1036
1037            aligned: str | None = None
1038            for line in widget.get_lines():
1039                # See `enums.py` for information about this ignore
1040                padding, aligned = self._align(
1041                    cast(HorizontalAlignment, widget.parent_align), width, line
1042                )
1043                inner.append(aligned)
1044
1045            widget.pos = (
1046                self.pos[0] + padding + total_offset,
1047                self.pos[1] + (1 if type(widget).__name__ == "Container" else 0),
1048            )
1049
1050            if aligned is not None:
1051                total_offset += real_length(inner[-1]) + separator_length
1052
1053            vertical_lines.append(inner)
1054
1055        lines = []
1056        for horizontal in zip_longest(*vertical_lines, fillvalue=" " * target_width):
1057            lines.append((reset() + separator).join(horizontal))
1058
1059        self.height = max(widget.height for widget in self)
1060        return lines
1061
1062    def debug(self) -> str:
1063        """Return identifiable information"""
1064
1065        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        for widget in self._widgets:
544            align, offset = self._get_aligners(widget, (borders[0], borders[2]))
545
546            self._update_width(widget)
547
548            widget.pos = (
549                self.pos[0] + offset,
550                self.pos[1] + len(lines) + (1 if has_top_bottom[0] else 0),
551            )
552
553            widget_lines: list[str] = []
554            for line in widget.get_lines():
555                if len(lines) + len(widget_lines) >= self.height - sum(has_top_bottom):
556                    if overflow is Overflow.HIDE:
557                        break
558
559                    if overflow == Overflow.AUTO:
560                        overflow = Overflow.SCROLL
561
562                widget_lines.append(align(line))
563
564            lines.extend(widget_lines)
565
566        if overflow == Overflow.SCROLL:
567            self._max_scroll = len(lines) - self.height + sum(has_top_bottom)
568            height = self.height - sum(has_top_bottom)
569
570            self._scroll_offset = max(0, min(self._scroll_offset, len(lines) - height))
571            lines = lines[self._scroll_offset : self._scroll_offset + height]
572
573        elif overflow == Overflow.RESIZE:
574            self.height = len(lines) + sum(has_top_bottom)
575
576        vertical_offset, lines = self._apply_vertalign(
577            lines, self.height - len(lines) - sum(has_top_bottom), align("")
578        )
579
580        for widget in self._widgets:
581            widget.pos = (widget.pos[0], widget.pos[1] + vertical_offset)
582
583            if widget.is_selectable:
584                # This buffer will be out of position, so we must clear it.
585                widget.positioned_line_buffer = []
586                widget.get_lines()
587
588            self.positioned_line_buffer.extend(widget.positioned_line_buffer)
589
590            widget.positioned_line_buffer = []
591
592        if has_top_bottom[0]:
593            lines.insert(0, _get_border(corners[0], borders[1], corners[1]))
594
595        if has_top_bottom[1]:
596            lines.append(_get_border(corners[3], borders[3], corners[2]))
597
598        self.height = len(lines)
599        return lines
600
601    def set_widgets(self, new: list[Widget]) -> None:
602        """Sets new list in place of self._widgets.
603
604        Args:
605            new: The new widget list.
606        """
607
608        self._widgets = []
609        for widget in new:
610            self._add_widget(widget)
611
612    def serialize(self) -> dict[str, Any]:
613        """Serializes this Container, adding in serializations of all widgets.
614
615        See `pytermgui.widgets.base.Widget.serialize` for more info.
616
617        Returns:
618            The dictionary containing all serialized data.
619        """
620
621        out = super().serialize()
622        out["_widgets"] = []
623
624        for widget in self._widgets:
625            out["_widgets"].append(widget.serialize())
626
627        return out
628
629    def pop(self, index: int = -1) -> Widget:
630        """Pops widget from self._widgets.
631
632        Analogous to self._widgets.pop(index).
633
634        Args:
635            index: The index to operate on.
636
637        Returns:
638            The widget that was popped off the list.
639        """
640
641        return self._widgets.pop(index)
642
643    def remove(self, other: Widget) -> None:
644        """Remove widget from self._widgets
645
646        Analogous to self._widgets.remove(other).
647
648        Args:
649            widget: The widget to remove.
650        """
651
652        return self._widgets.remove(other)
653
654    def set_recursive_depth(self, value: int) -> None:
655        """Set depth for this Container and all its children.
656
657        All inner widgets will receive value+1 as their new depth.
658
659        Args:
660            value: The new depth to use as the base depth.
661        """
662
663        self.depth = value
664        for widget in self._widgets:
665            if isinstance(widget, Container):
666                widget.set_recursive_depth(value + 1)
667            else:
668                widget.depth = value
669
670    def select(self, index: int | None = None) -> None:
671        """Selects inner subwidget.
672
673        Args:
674            index: The index to select.
675
676        Raises:
677            IndexError: The index provided was beyond len(self.selectables).
678        """
679
680        # Unselect all sub-elements
681        for other in self._widgets:
682            if other.selectables_length > 0:
683                other.select(None)
684
685        if index is not None:
686            index = max(0, min(index, len(self.selectables) - 1))
687            widget, inner_index = self.selectables[index]
688            widget.select(inner_index)
689
690        self.selected_index = index
691
692    def center(
693        self, where: CenteringPolicy | None = None, store: bool = True
694    ) -> Container:
695        """Centers this object to the given axis.
696
697        Args:
698            where: A CenteringPolicy describing the place to center to
699            store: When set, this centering will be reapplied during every
700                print, as well as when calling this method with no arguments.
701
702        Returns:
703            This Container.
704        """
705
706        # Refresh in case changes happened
707        self.get_lines()
708
709        if where is None:
710            # See `enums.py` for explanation about this ignore.
711            where = CenteringPolicy.get_default()  # type: ignore
712
713        centerx = centery = where is CenteringPolicy.ALL
714        centerx |= where is CenteringPolicy.HORIZONTAL
715        centery |= where is CenteringPolicy.VERTICAL
716
717        pos = list(self.pos)
718        if centerx:
719            pos[0] = (self.terminal.width - self.width + 2) // 2
720
721        if centery:
722            pos[1] = (self.terminal.height - self.height + 2) // 2
723
724        self.pos = (pos[0], pos[1])
725
726        if store:
727            self.centered_axis = where
728
729        self._prev_screen = self.terminal.size
730
731        return self
732
733    def handle_mouse(self, event: MouseEvent) -> bool:
734        """Handles mouse events.
735
736        This, like all mouse handlers should, calls super()'s implementation first,
737        to allow usage of `on_{event}`-type callbacks. After that, it tries to find
738        a target widget within itself to handle the event.
739
740        Each handler will return a boolean. This boolean is then used to figure out
741        whether the targeted widget should be "sticky", i.e. a slider. Returning
742        True will set that widget as the current mouse target, and all mouse events will
743        be sent to it as long as it returns True.
744
745        Args:
746            event: The event to handle.
747
748        Returns:
749            Whether the parent of this widget should treat it as one to "stick" events
750            to, e.g. to keep sending mouse events to it. One can "unstick" a widget by
751            returning False in the handler.
752        """
753
754        def _handle_scrolling() -> bool:
755            """Scrolls the container."""
756
757            if self.overflow != Overflow.SCROLL:
758                return False
759
760            if event.action is MouseAction.SCROLL_UP:
761                return self.scroll(-1)
762
763            if event.action is MouseAction.SCROLL_DOWN:
764                return self.scroll(1)
765
766            return False
767
768        if super().handle_mouse(event):
769            return True
770
771        if event.action is MouseAction.RELEASE and self._mouse_target is not None:
772            return self._mouse_target.handle_mouse(event)
773
774        if (
775            self._mouse_target is not None
776            and (
777                event.action.value.endswith("drag")
778                or event.action.value.startswith("scroll")
779            )
780            and self._mouse_target.handle_mouse(event)
781        ):
782            return True
783
784        release = MouseEvent(MouseAction.RELEASE, event.position)
785
786        selectables_index = 0
787        event.position = (event.position[0], event.position[1] + self._scroll_offset)
788
789        handled = False
790        for widget in self._widgets:
791            if (
792                widget.pos[1] - self.pos[1] - self._scroll_offset
793                > self.content_dimensions[1]
794            ):
795                break
796
797            if widget.contains(event.position):
798                handled = widget.handle_mouse(event)
799                selectables_index += widget.selected_index or 0
800
801                # TODO: This really should be customizable somehow.
802                if event.action is MouseAction.LEFT_CLICK:
803                    if handled and selectables_index < len(self.selectables):
804                        self.select(selectables_index)
805
806                if self._mouse_target is not None and self._mouse_target is not widget:
807                    self._mouse_target.handle_mouse(release)
808
809                self._mouse_target = widget
810
811                break
812
813            if widget.is_selectable:
814                selectables_index += widget.selectables_length
815
816        handled = handled or _handle_scrolling()
817
818        return handled
819
820    def execute_binding(self, key: Any, ignore_any: bool = False) -> bool:
821        """Executes a binding on self, and then on self._widgets.
822
823        If a widget.execute_binding call returns True this function will too. Note
824        that on success the function returns immediately; no further widgets are
825        checked.
826
827        Args:
828            key: The binding key.
829            ignore_any: If set, `keys.ANY_KEY` bindings will not be executed.
830
831        Returns:
832            True if any widget returned True, False otherwise.
833        """
834
835        if super().execute_binding(key, ignore_any=ignore_any):
836            return True
837
838        selectables_index = 0
839        for widget in self._widgets:
840            if widget.execute_binding(key):
841                selectables_index += widget.selected_index or 0
842                self.select(selectables_index)
843                return True
844
845            if widget.is_selectable:
846                selectables_index += widget.selectables_length
847
848        return False
849
850    def handle_key(  # pylint: disable=too-many-return-statements, too-many-branches
851        self, key: str
852    ) -> bool:
853        """Handles a keypress, returns its success.
854
855        Args:
856            key: A key str.
857
858        Returns:
859            A boolean showing whether the key was handled.
860        """
861
862        def _is_nav(key: str) -> bool:
863            """Determine if a key is in the navigation sets"""
864
865            return key in self.keys["next"] | self.keys["previous"]
866
867        if self.selected is not None and self.selected.handle_key(key):
868            return True
869
870        scroll_actions = {
871            **{key: 1 for key in self.keys["scroll_down"]},
872            **{key: -1 for key in self.keys["scroll_up"]},
873        }
874
875        if key in self.keys["scroll_down"] | self.keys["scroll_up"]:
876            for widget in self._widgets:
877                if isinstance(widget, Container) and self.selected in widget:
878                    widget.handle_key(key)
879
880            self.scroll(scroll_actions[key])
881            return True
882
883        # Only use navigation when there is more than one selectable
884        if self.selectables_length >= 1 and _is_nav(key):
885            if self.selected_index is None:
886                self.select(0)
887                return True
888
889            handled = False
890
891            assert isinstance(self.selected_index, int)
892
893            if key in self.keys["previous"]:
894                # No more selectables left, user wants to exit Container
895                # upwards.
896                if self.selected_index == 0:
897                    return False
898
899                self.select(self.selected_index - 1)
900                handled = True
901
902            elif key in self.keys["next"]:
903                # Stop selection at last element, return as unhandled
904                new = self.selected_index + 1
905                if new == len(self.selectables):
906                    return False
907
908                self.select(new)
909                handled = True
910
911            if handled:
912                return True
913
914        if key == keys.ENTER:
915            if self.selected_index is None and self.selectables_length > 0:
916                self.select(0)
917
918            if self.selected is not None:
919                self.selected.handle_key(key)
920                return True
921
922        for widget in self._widgets:
923            if widget.execute_binding(key):
924                return True
925
926        return False
927
928    def wipe(self) -> None:
929        """Wipes the characters occupied by the object"""
930
931        with cursor_at(self.pos) as print_here:
932            for line in self.get_lines():
933                print_here(real_length(line) * " ")
934
935    def print(self) -> None:
936        """Prints this Container.
937
938        If the screen size has changed since last `print` call, the object
939        will be centered based on its `centered_axis`.
940        """
941
942        if not self.terminal.size == self._prev_screen:
943            clear()
944            self.center(self.centered_axis)
945
946        self._prev_screen = self.terminal.size
947
948        if self.allow_fullscreen:
949            self.pos = self.terminal.origin
950
951        with cursor_at(self.pos) as print_here:
952            for line in self.get_lines():
953                print_here(line)
954
955        self._has_printed = True
956
957    def debug(self) -> str:
958        """Returns a string with identifiable information on this widget.
959
960        Returns:
961            A str in the form of a class construction. This string is in a form that
962            __could have been__ used to create this Container.
963        """
964
965        return (
966            f"{type(self).__name__}(width={self.width}, height={self.height}"
967            + (f", id={self.id}" if self.id is not None else "")
968            + ")"
969        )

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': {'\x1b[A', '\x10', 'k'}, 'scroll_down': {'\x1b[1;2B', 'J'}, '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        for widget in self._widgets:
544            align, offset = self._get_aligners(widget, (borders[0], borders[2]))
545
546            self._update_width(widget)
547
548            widget.pos = (
549                self.pos[0] + offset,
550                self.pos[1] + len(lines) + (1 if has_top_bottom[0] else 0),
551            )
552
553            widget_lines: list[str] = []
554            for line in widget.get_lines():
555                if len(lines) + len(widget_lines) >= self.height - sum(has_top_bottom):
556                    if overflow is Overflow.HIDE:
557                        break
558
559                    if overflow == Overflow.AUTO:
560                        overflow = Overflow.SCROLL
561
562                widget_lines.append(align(line))
563
564            lines.extend(widget_lines)
565
566        if overflow == Overflow.SCROLL:
567            self._max_scroll = len(lines) - self.height + sum(has_top_bottom)
568            height = self.height - sum(has_top_bottom)
569
570            self._scroll_offset = max(0, min(self._scroll_offset, len(lines) - height))
571            lines = lines[self._scroll_offset : self._scroll_offset + height]
572
573        elif overflow == Overflow.RESIZE:
574            self.height = len(lines) + sum(has_top_bottom)
575
576        vertical_offset, lines = self._apply_vertalign(
577            lines, self.height - len(lines) - sum(has_top_bottom), align("")
578        )
579
580        for widget in self._widgets:
581            widget.pos = (widget.pos[0], widget.pos[1] + vertical_offset)
582
583            if widget.is_selectable:
584                # This buffer will be out of position, so we must clear it.
585                widget.positioned_line_buffer = []
586                widget.get_lines()
587
588            self.positioned_line_buffer.extend(widget.positioned_line_buffer)
589
590            widget.positioned_line_buffer = []
591
592        if has_top_bottom[0]:
593            lines.insert(0, _get_border(corners[0], borders[1], corners[1]))
594
595        if has_top_bottom[1]:
596            lines.append(_get_border(corners[3], borders[3], corners[2]))
597
598        self.height = len(lines)
599        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:
601    def set_widgets(self, new: list[Widget]) -> None:
602        """Sets new list in place of self._widgets.
603
604        Args:
605            new: The new widget list.
606        """
607
608        self._widgets = []
609        for widget in new:
610            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]:
612    def serialize(self) -> dict[str, Any]:
613        """Serializes this Container, adding in serializations of all widgets.
614
615        See `pytermgui.widgets.base.Widget.serialize` for more info.
616
617        Returns:
618            The dictionary containing all serialized data.
619        """
620
621        out = super().serialize()
622        out["_widgets"] = []
623
624        for widget in self._widgets:
625            out["_widgets"].append(widget.serialize())
626
627        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:
629    def pop(self, index: int = -1) -> Widget:
630        """Pops widget from self._widgets.
631
632        Analogous to self._widgets.pop(index).
633
634        Args:
635            index: The index to operate on.
636
637        Returns:
638            The widget that was popped off the list.
639        """
640
641        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:
643    def remove(self, other: Widget) -> None:
644        """Remove widget from self._widgets
645
646        Analogous to self._widgets.remove(other).
647
648        Args:
649            widget: The widget to remove.
650        """
651
652        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:
654    def set_recursive_depth(self, value: int) -> None:
655        """Set depth for this Container and all its children.
656
657        All inner widgets will receive value+1 as their new depth.
658
659        Args:
660            value: The new depth to use as the base depth.
661        """
662
663        self.depth = value
664        for widget in self._widgets:
665            if isinstance(widget, Container):
666                widget.set_recursive_depth(value + 1)
667            else:
668                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:
670    def select(self, index: int | None = None) -> None:
671        """Selects inner subwidget.
672
673        Args:
674            index: The index to select.
675
676        Raises:
677            IndexError: The index provided was beyond len(self.selectables).
678        """
679
680        # Unselect all sub-elements
681        for other in self._widgets:
682            if other.selectables_length > 0:
683                other.select(None)
684
685        if index is not None:
686            index = max(0, min(index, len(self.selectables) - 1))
687            widget, inner_index = self.selectables[index]
688            widget.select(inner_index)
689
690        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:
692    def center(
693        self, where: CenteringPolicy | None = None, store: bool = True
694    ) -> Container:
695        """Centers this object to the given axis.
696
697        Args:
698            where: A CenteringPolicy describing the place to center to
699            store: When set, this centering will be reapplied during every
700                print, as well as when calling this method with no arguments.
701
702        Returns:
703            This Container.
704        """
705
706        # Refresh in case changes happened
707        self.get_lines()
708
709        if where is None:
710            # See `enums.py` for explanation about this ignore.
711            where = CenteringPolicy.get_default()  # type: ignore
712
713        centerx = centery = where is CenteringPolicy.ALL
714        centerx |= where is CenteringPolicy.HORIZONTAL
715        centery |= where is CenteringPolicy.VERTICAL
716
717        pos = list(self.pos)
718        if centerx:
719            pos[0] = (self.terminal.width - self.width + 2) // 2
720
721        if centery:
722            pos[1] = (self.terminal.height - self.height + 2) // 2
723
724        self.pos = (pos[0], pos[1])
725
726        if store:
727            self.centered_axis = where
728
729        self._prev_screen = self.terminal.size
730
731        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:
733    def handle_mouse(self, event: MouseEvent) -> bool:
734        """Handles mouse events.
735
736        This, like all mouse handlers should, calls super()'s implementation first,
737        to allow usage of `on_{event}`-type callbacks. After that, it tries to find
738        a target widget within itself to handle the event.
739
740        Each handler will return a boolean. This boolean is then used to figure out
741        whether the targeted widget should be "sticky", i.e. a slider. Returning
742        True will set that widget as the current mouse target, and all mouse events will
743        be sent to it as long as it returns True.
744
745        Args:
746            event: The event to handle.
747
748        Returns:
749            Whether the parent of this widget should treat it as one to "stick" events
750            to, e.g. to keep sending mouse events to it. One can "unstick" a widget by
751            returning False in the handler.
752        """
753
754        def _handle_scrolling() -> bool:
755            """Scrolls the container."""
756
757            if self.overflow != Overflow.SCROLL:
758                return False
759
760            if event.action is MouseAction.SCROLL_UP:
761                return self.scroll(-1)
762
763            if event.action is MouseAction.SCROLL_DOWN:
764                return self.scroll(1)
765
766            return False
767
768        if super().handle_mouse(event):
769            return True
770
771        if event.action is MouseAction.RELEASE and self._mouse_target is not None:
772            return self._mouse_target.handle_mouse(event)
773
774        if (
775            self._mouse_target is not None
776            and (
777                event.action.value.endswith("drag")
778                or event.action.value.startswith("scroll")
779            )
780            and self._mouse_target.handle_mouse(event)
781        ):
782            return True
783
784        release = MouseEvent(MouseAction.RELEASE, event.position)
785
786        selectables_index = 0
787        event.position = (event.position[0], event.position[1] + self._scroll_offset)
788
789        handled = False
790        for widget in self._widgets:
791            if (
792                widget.pos[1] - self.pos[1] - self._scroll_offset
793                > self.content_dimensions[1]
794            ):
795                break
796
797            if widget.contains(event.position):
798                handled = widget.handle_mouse(event)
799                selectables_index += widget.selected_index or 0
800
801                # TODO: This really should be customizable somehow.
802                if event.action is MouseAction.LEFT_CLICK:
803                    if handled and selectables_index < len(self.selectables):
804                        self.select(selectables_index)
805
806                if self._mouse_target is not None and self._mouse_target is not widget:
807                    self._mouse_target.handle_mouse(release)
808
809                self._mouse_target = widget
810
811                break
812
813            if widget.is_selectable:
814                selectables_index += widget.selectables_length
815
816        handled = handled or _handle_scrolling()
817
818        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: Any, ignore_any: bool = False) -> bool:
820    def execute_binding(self, key: Any, ignore_any: bool = False) -> bool:
821        """Executes a binding on self, and then on self._widgets.
822
823        If a widget.execute_binding call returns True this function will too. Note
824        that on success the function returns immediately; no further widgets are
825        checked.
826
827        Args:
828            key: The binding key.
829            ignore_any: If set, `keys.ANY_KEY` bindings will not be executed.
830
831        Returns:
832            True if any widget returned True, False otherwise.
833        """
834
835        if super().execute_binding(key, ignore_any=ignore_any):
836            return True
837
838        selectables_index = 0
839        for widget in self._widgets:
840            if widget.execute_binding(key):
841                selectables_index += widget.selected_index or 0
842                self.select(selectables_index)
843                return True
844
845            if widget.is_selectable:
846                selectables_index += widget.selectables_length
847
848        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.
  • ignore_any: If set, keys.ANY_KEY bindings will not be executed.
Returns

True if any widget returned True, False otherwise.

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

Wipes the characters occupied by the object

def print(self) -> None:
935    def print(self) -> None:
936        """Prints this Container.
937
938        If the screen size has changed since last `print` call, the object
939        will be centered based on its `centered_axis`.
940        """
941
942        if not self.terminal.size == self._prev_screen:
943            clear()
944            self.center(self.centered_axis)
945
946        self._prev_screen = self.terminal.size
947
948        if self.allow_fullscreen:
949            self.pos = self.terminal.origin
950
951        with cursor_at(self.pos) as print_here:
952            for line in self.get_lines():
953                print_here(line)
954
955        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:
957    def debug(self) -> str:
958        """Returns a string with identifiable information on this widget.
959
960        Returns:
961            A str in the form of a class construction. This string is in a form that
962            __could have been__ used to create this Container.
963        """
964
965        return (
966            f"{type(self).__name__}(width={self.width}, height={self.height}"
967            + (f", id={self.id}" if self.id is not None else "")
968            + ")"
969        )

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

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

Join all widgets horizontally.

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

Return identifiable information