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

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._drag_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': {'j', '\x0e', '\x1b[B'}, 'previous': {'\x10', 'k', '\x1b[A'}, 'scroll_down': {'\x1b[1;2B', 'J'}, 'scroll_up': {'\x1b[1;2A', 'K'}}

Groups of keys that are used in handle_key

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

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

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

Gets the length of left and right borders combined.

Returns

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

content_dimensions: tuple[int, int]

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

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

Gets all selectable widgets and their inner indices.

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

Returns

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

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

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

Gets the length of the selectables list.

Returns

An integer equal to the length of self.selectables.

Returns the currently selected object

Returns

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

Returns current box setting

Returns

The currently set box instance.

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

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

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

Adds other without running get_lines.

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

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

Gets all lines by spacing out inner widgets.

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

Returns

A list of all lines that represent this Container.

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

Sets new list in place of self._widgets.

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

Serializes this Container, adding in serializations of all widgets.

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

Returns

The dictionary containing all serialized data.

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

Pops widget from self._widgets.

Analogous to self._widgets.pop(index).

Args
  • index: The index to operate on.
Returns

The widget that was popped off the list.

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

Remove widget from self._widgets

Analogous to self._widgets.remove(other).

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

Set depth for this Container and all its children.

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

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

Selects inner subwidget.

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

Centers this object to the given axis.

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

This Container.

def handle_mouse(self, event: pytermgui.ansi_interface.MouseEvent) -> bool:
732    def handle_mouse(self, event: MouseEvent) -> bool:
733        """Applies a mouse event on all children.
734
735        Args:
736            event: The event to handle
737
738        Returns:
739            A boolean showing whether the event was handled.
740        """
741
742        if event.action is MouseAction.RELEASE:
743            # Force RELEASE event to be sent
744            if self._drag_target is not None:
745                self._drag_target.handle_mouse(
746                    MouseEvent(MouseAction.RELEASE, event.position)
747                )
748
749            self._drag_target = None
750
751        if self._drag_target is not None:
752            return self._drag_target.handle_mouse(event)
753
754        selectables_index = 0
755        scrolled_pos = list(event.position)
756        scrolled_pos[1] += self._scroll_offset
757        event.position = (scrolled_pos[0], scrolled_pos[1])
758
759        handled = False
760        for widget in self._widgets:
761            if (
762                widget.pos[1] - self.pos[1] - self._scroll_offset
763                > self.content_dimensions[1]
764            ):
765                break
766
767            if widget.contains(event.position):
768                handled = widget.handle_mouse(event)
769                # This avoids too many branches from pylint.
770                selectables_index += widget.selected_index or 0
771
772                if event.action is MouseAction.LEFT_CLICK:
773                    self._drag_target = widget
774
775                    if handled and selectables_index < len(self.selectables):
776                        self.select(selectables_index)
777
778                break
779
780            if widget.is_selectable:
781                selectables_index += widget.selectables_length
782
783        if not handled and self.overflow == Overflow.SCROLL:
784            if event.action is MouseAction.SCROLL_UP:
785                return self.scroll(-1)
786
787            if event.action is MouseAction.SCROLL_DOWN:
788                return self.scroll(1)
789
790        return handled

Applies a mouse event on all children.

Args
  • event: The event to handle
Returns

A boolean showing whether the event was handled.

def execute_binding(self, key: str) -> bool:
792    def execute_binding(self, key: str) -> bool:
793        """Executes a binding on self, and then on self._widgets.
794
795        If a widget.execute_binding call returns True this function will too. Note
796        that on success the function returns immediately; no further widgets are
797        checked.
798
799        Args:
800            key: The binding key.
801
802        Returns:
803            True if any widget returned True, False otherwise.
804        """
805
806        if super().execute_binding(key):
807            return True
808
809        selectables_index = 0
810        for widget in self._widgets:
811            if widget.execute_binding(key):
812                selectables_index += widget.selected_index or 0
813                self.select(selectables_index)
814                return True
815
816            if widget.is_selectable:
817                selectables_index += widget.selectables_length
818
819        return False

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

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

Args
  • key: The binding key.
Returns

True if any widget returned True, False otherwise.

def handle_key(self, key: str) -> bool:
821    def handle_key(  # pylint: disable=too-many-return-statements, too-many-branches
822        self, key: str
823    ) -> bool:
824        """Handles a keypress, returns its success.
825
826        Args:
827            key: A key str.
828
829        Returns:
830            A boolean showing whether the key was handled.
831        """
832
833        def _is_nav(key: str) -> bool:
834            """Determine if a key is in the navigation sets"""
835
836            return key in self.keys["next"] | self.keys["previous"]
837
838        if self.selected is not None and self.selected.handle_key(key):
839            return True
840
841        scroll_actions = {
842            **{key: 1 for key in self.keys["scroll_down"]},
843            **{key: -1 for key in self.keys["scroll_up"]},
844        }
845
846        if key in self.keys["scroll_down"] | self.keys["scroll_up"]:
847            for widget in self._widgets:
848                if isinstance(widget, Container) and self.selected in widget:
849                    widget.handle_key(key)
850
851            self.scroll(scroll_actions[key])
852            return True
853
854        # Only use navigation when there is more than one selectable
855        if self.selectables_length >= 1 and _is_nav(key):
856            if self.selected_index is None:
857                self.select(0)
858                return True
859
860            handled = False
861
862            assert isinstance(self.selected_index, int)
863
864            if key in self.keys["previous"]:
865                # No more selectables left, user wants to exit Container
866                # upwards.
867                if self.selected_index == 0:
868                    return False
869
870                self.select(self.selected_index - 1)
871                handled = True
872
873            elif key in self.keys["next"]:
874                # Stop selection at last element, return as unhandled
875                new = self.selected_index + 1
876                if new == len(self.selectables):
877                    return False
878
879                self.select(new)
880                handled = True
881
882            if handled:
883                return True
884
885        if key == keys.ENTER:
886            if self.selected_index is None and self.selectables_length > 0:
887                self.select(0)
888
889            if self.selected is not None:
890                self.selected.handle_key(key)
891                return True
892
893        for widget in self._widgets:
894            if widget.execute_binding(key):
895                return True
896
897        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:
899    def wipe(self) -> None:
900        """Wipes the characters occupied by the object"""
901
902        with cursor_at(self.pos) as print_here:
903            for line in self.get_lines():
904                print_here(real_length(line) * " ")

Wipes the characters occupied by the object

def print(self) -> None:
906    def print(self) -> None:
907        """Prints this Container.
908
909        If the screen size has changed since last `print` call, the object
910        will be centered based on its `centered_axis`.
911        """
912
913        if not self.terminal.size == self._prev_screen:
914            clear()
915            self.center(self.centered_axis)
916
917        self._prev_screen = self.terminal.size
918
919        if self.allow_fullscreen:
920            self.pos = self.terminal.origin
921
922        with cursor_at(self.pos) as print_here:
923            for line in self.get_lines():
924                print_here(line)
925
926        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:
928    def debug(self) -> str:
929        """Returns a string with identifiable information on this widget.
930
931        Returns:
932            A str in the form of a class construction. This string is in a form that
933            __could have been__ used to create this Container.
934        """
935
936        return (
937            f"{type(self).__name__}(width={self.width}, height={self.height}"
938            + (f", id={self.id}" if self.id is not None else "")
939            + ")"
940        )

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):
 943class Splitter(Container):
 944    """A widget that displays other widgets, stacked horizontally."""
 945
 946    styles = w_styles.StyleManager(separator=w_styles.MARKUP, fill=w_styles.BACKGROUND)
 947
 948    chars: dict[str, list[str] | str] = {"separator": " | "}
 949    keys = {
 950        "previous": {keys.LEFT, "h", keys.CTRL_B},
 951        "next": {keys.RIGHT, "l", keys.CTRL_F},
 952    }
 953
 954    parent_align = HorizontalAlignment.RIGHT
 955
 956    def _align(
 957        self, alignment: HorizontalAlignment, target_width: int, line: str
 958    ) -> tuple[int, str]:
 959        """Align a line
 960
 961        r/wordavalanches"""
 962
 963        available = target_width - real_length(line)
 964        fill_style = self._get_style("fill")
 965
 966        char = fill_style(" ")
 967        line = fill_style(line)
 968
 969        if alignment == HorizontalAlignment.CENTER:
 970            padding, offset = divmod(available, 2)
 971            return padding, padding * char + line + (padding + offset) * char
 972
 973        if alignment == HorizontalAlignment.RIGHT:
 974            return available, available * char + line
 975
 976        return 0, line + available * char
 977
 978    @property
 979    def content_dimensions(self) -> tuple[int, int]:
 980        """Returns the available area for widgets."""
 981
 982        return self.height, self.width
 983
 984    def get_lines(self) -> list[str]:
 985        """Join all widgets horizontally."""
 986
 987        # An error will be raised if `separator` is not the correct type (str).
 988        separator = self._get_style("separator")(self._get_char("separator"))  # type: ignore
 989        separator_length = real_length(separator)
 990
 991        target_width, error = divmod(
 992            self.width - (len(self._widgets) - 1) * separator_length, len(self._widgets)
 993        )
 994
 995        vertical_lines = []
 996        total_offset = 0
 997
 998        for widget in self._widgets:
 999            inner = []
1000
1001            if widget.size_policy is SizePolicy.STATIC:
1002                target_width += target_width - widget.width
1003                width = widget.width
1004            else:
1005                widget.width = target_width + error
1006                width = widget.width
1007                error = 0
1008
1009            aligned: str | None = None
1010            for line in widget.get_lines():
1011                # See `enums.py` for information about this ignore
1012                padding, aligned = self._align(
1013                    cast(HorizontalAlignment, widget.parent_align), width, line
1014                )
1015                inner.append(aligned)
1016
1017            widget.pos = (
1018                self.pos[0] + padding + total_offset,
1019                self.pos[1] + (1 if type(widget).__name__ == "Container" else 0),
1020            )
1021
1022            if aligned is not None:
1023                total_offset += real_length(inner[-1]) + separator_length
1024
1025            vertical_lines.append(inner)
1026
1027        lines = []
1028        for horizontal in zip_longest(*vertical_lines, fillvalue=" " * target_width):
1029            lines.append((reset() + separator).join(horizontal))
1030
1031        return lines
1032
1033    def debug(self) -> str:
1034        """Return identifiable information"""
1035
1036        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': {'h', '\x02', '\x1b[D'}, 'next': {'\x06', 'l', '\x1b[C'}}

Groups of keys that are used in handle_key

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

Returns the available area for widgets.

def get_lines(self) -> list[str]:
 984    def get_lines(self) -> list[str]:
 985        """Join all widgets horizontally."""
 986
 987        # An error will be raised if `separator` is not the correct type (str).
 988        separator = self._get_style("separator")(self._get_char("separator"))  # type: ignore
 989        separator_length = real_length(separator)
 990
 991        target_width, error = divmod(
 992            self.width - (len(self._widgets) - 1) * separator_length, len(self._widgets)
 993        )
 994
 995        vertical_lines = []
 996        total_offset = 0
 997
 998        for widget in self._widgets:
 999            inner = []
1000
1001            if widget.size_policy is SizePolicy.STATIC:
1002                target_width += target_width - widget.width
1003                width = widget.width
1004            else:
1005                widget.width = target_width + error
1006                width = widget.width
1007                error = 0
1008
1009            aligned: str | None = None
1010            for line in widget.get_lines():
1011                # See `enums.py` for information about this ignore
1012                padding, aligned = self._align(
1013                    cast(HorizontalAlignment, widget.parent_align), width, line
1014                )
1015                inner.append(aligned)
1016
1017            widget.pos = (
1018                self.pos[0] + padding + total_offset,
1019                self.pos[1] + (1 if type(widget).__name__ == "Container" else 0),
1020            )
1021
1022            if aligned is not None:
1023                total_offset += real_length(inner[-1]) + separator_length
1024
1025            vertical_lines.append(inner)
1026
1027        lines = []
1028        for horizontal in zip_longest(*vertical_lines, fillvalue=" " * target_width):
1029            lines.append((reset() + separator).join(horizontal))
1030
1031        return lines

Join all widgets horizontally.

def debug(self) -> str:
1033    def debug(self) -> str:
1034        """Return identifiable information"""
1035
1036        return super().debug().replace("Container", "Splitter", 1)

Return identifiable information