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

A widget that displays other widgets, stacked vertically.

Container(*widgets: Any, **attrs: Any)
60    def __init__(self, *widgets: Any, **attrs: Any) -> None:
61        """Initialize Container data"""
62
63        super().__init__(**attrs)
64
65        # TODO: This is just a band-aid.
66        if not any("width" in attr for attr in attrs):
67            self.width = 40
68
69        self._widgets: list[Widget] = []
70        self.dirty_widgets: list[Widget] = []
71        self.centered_axis: CenteringPolicy | None = None
72
73        self._prev_screen: tuple[int, int] = (0, 0)
74        self._has_printed = False
75
76        for widget in widgets:
77            self._add_widget(widget)
78
79        if "box" in attrs:
80            self.box = attrs["box"]
81
82        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': {'\x1b[B', 'j', '\x0e'}, 'previous': {'\x1b[A', 'k', '\x10'}, 'scroll_down': {'\x1b[1;2B', 'J'}, 'scroll_up': {'K', '\x1b[1;2A'}}

Groups of keys that are used in handle_key

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

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

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

Gets the length of left and right borders combined.

Returns

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

content_dimensions: tuple[int, int]

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

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

Gets all selectable widgets and their inner indices.

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

Returns

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

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

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

Gets the length of the selectables list.

Returns

An integer equal to the length of self.selectables.

Returns the currently selected object

Returns

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

Returns current box setting

Returns

The currently set box instance.

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

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

def lazy_add(self, other: object) -> None:
493    def lazy_add(self, other: object) -> None:
494        """Adds `other` without running get_lines.
495
496        This is analogous to `self._add_widget(other, run_get_lines=False).
497
498        Args:
499            other: The object to add.
500        """
501
502        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]:
504    def get_lines(self) -> list[str]:
505        """Gets all lines by spacing out inner widgets.
506
507        This method reflects & applies both width settings, as well as
508        the `parent_align` field.
509
510        Returns:
511            A list of all lines that represent this Container.
512        """
513
514        def _get_border(left: str, char: str, right: str) -> str:
515            """Gets a top or bottom border.
516
517            Args:
518                left: Left corner character.
519                char: Border character filling between left & right.
520                right: Right corner character.
521
522            Returns:
523                The border line.
524            """
525
526            offset = real_length(strip_markup(left + right))
527            return (
528                self.styles.corner(left)
529                + self.styles.border(char * (self.width - offset))
530                + self.styles.corner(right)
531            )
532
533        lines: list[str] = []
534
535        borders = self._get_char("border")
536        corners = self._get_char("corner")
537
538        has_top_bottom = (real_length(borders[1]) > 0, real_length(borders[3]) > 0)
539
540        align, offset = self._get_aligners(self, (borders[0], borders[2]))
541
542        overflow = self.overflow
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        if overflow == Overflow.SCROLL:
568            self._max_scroll = len(lines) - self.height + sum(has_top_bottom)
569            height = self.height - sum(has_top_bottom)
570
571            self._scroll_offset = max(0, min(self._scroll_offset, len(lines) - height))
572            lines = lines[self._scroll_offset : self._scroll_offset + height]
573
574        elif overflow == Overflow.RESIZE:
575            self.height = len(lines) + sum(has_top_bottom)
576
577        vertical_offset, lines = self._apply_vertalign(
578            lines, self.height - len(lines) - sum(has_top_bottom), align("")
579        )
580
581        for widget in self._widgets:
582            widget.pos = (widget.pos[0], widget.pos[1] + vertical_offset)
583
584            if widget.is_selectable:
585                widget.get_lines()
586
587        if has_top_bottom[0]:
588            lines.insert(0, _get_border(corners[0], borders[1], corners[1]))
589
590        if has_top_bottom[1]:
591            lines.append(_get_border(corners[3], borders[3], corners[2]))
592
593        self.height = len(lines)
594        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:
596    def set_widgets(self, new: list[Widget]) -> None:
597        """Sets new list in place of self._widgets.
598
599        Args:
600            new: The new widget list.
601        """
602
603        self._widgets = []
604        for widget in new:
605            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]:
607    def serialize(self) -> dict[str, Any]:
608        """Serializes this Container, adding in serializations of all widgets.
609
610        See `pytermgui.widgets.base.Widget.serialize` for more info.
611
612        Returns:
613            The dictionary containing all serialized data.
614        """
615
616        out = super().serialize()
617        out["_widgets"] = []
618
619        for widget in self._widgets:
620            out["_widgets"].append(widget.serialize())
621
622        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:
624    def pop(self, index: int = -1) -> Widget:
625        """Pops widget from self._widgets.
626
627        Analogous to self._widgets.pop(index).
628
629        Args:
630            index: The index to operate on.
631
632        Returns:
633            The widget that was popped off the list.
634        """
635
636        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:
638    def remove(self, other: Widget) -> None:
639        """Remove widget from self._widgets
640
641        Analogous to self._widgets.remove(other).
642
643        Args:
644            widget: The widget to remove.
645        """
646
647        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:
649    def set_recursive_depth(self, value: int) -> None:
650        """Set depth for this Container and all its children.
651
652        All inner widgets will receive value+1 as their new depth.
653
654        Args:
655            value: The new depth to use as the base depth.
656        """
657
658        self.depth = value
659        for widget in self._widgets:
660            if isinstance(widget, Container):
661                widget.set_recursive_depth(value + 1)
662            else:
663                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:
665    def select(self, index: int | None = None) -> None:
666        """Selects inner subwidget.
667
668        Args:
669            index: The index to select.
670
671        Raises:
672            IndexError: The index provided was beyond len(self.selectables).
673        """
674
675        # Unselect all sub-elements
676        for other in self._widgets:
677            if other.selectables_length > 0:
678                other.select(None)
679
680        if index is not None:
681            index = max(0, min(index, len(self.selectables) - 1))
682            widget, inner_index = self.selectables[index]
683            widget.select(inner_index)
684
685        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:
687    def center(
688        self, where: CenteringPolicy | None = None, store: bool = True
689    ) -> Container:
690        """Centers this object to the given axis.
691
692        Args:
693            where: A CenteringPolicy describing the place to center to
694            store: When set, this centering will be reapplied during every
695                print, as well as when calling this method with no arguments.
696
697        Returns:
698            This Container.
699        """
700
701        # Refresh in case changes happened
702        self.get_lines()
703
704        if where is None:
705            # See `enums.py` for explanation about this ignore.
706            where = CenteringPolicy.get_default()  # type: ignore
707
708        centerx = centery = where is CenteringPolicy.ALL
709        centerx |= where is CenteringPolicy.HORIZONTAL
710        centery |= where is CenteringPolicy.VERTICAL
711
712        pos = list(self.pos)
713        if centerx:
714            pos[0] = (self.terminal.width - self.width + 2) // 2
715
716        if centery:
717            pos[1] = (self.terminal.height - self.height + 2) // 2
718
719        self.pos = (pos[0], pos[1])
720
721        if store:
722            self.centered_axis = where
723
724        self._prev_screen = self.terminal.size
725
726        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:
728    def handle_mouse(self, event: MouseEvent) -> bool:
729        """Applies a mouse event on all children.
730
731        Args:
732            event: The event to handle
733
734        Returns:
735            A boolean showing whether the event was handled.
736        """
737
738        if event.action is MouseAction.RELEASE:
739            # Force RELEASE event to be sent
740            if self._drag_target is not None:
741                self._drag_target.handle_mouse(
742                    MouseEvent(MouseAction.RELEASE, event.position)
743                )
744
745            self._drag_target = None
746
747        if self._drag_target is not None:
748            return self._drag_target.handle_mouse(event)
749
750        selectables_index = 0
751        scrolled_pos = list(event.position)
752        scrolled_pos[1] += self._scroll_offset
753        event.position = (scrolled_pos[0], scrolled_pos[1])
754
755        handled = False
756        for widget in self._widgets:
757            if (
758                widget.pos[1] - self.pos[1] - self._scroll_offset
759                > self.content_dimensions[1]
760            ):
761                break
762
763            if widget.contains(event.position):
764                handled = widget.handle_mouse(event)
765                # This avoids too many branches from pylint.
766                selectables_index += widget.selected_index or 0
767
768                if event.action is MouseAction.LEFT_CLICK:
769                    self._drag_target = widget
770
771                    if handled and selectables_index < len(self.selectables):
772                        self.select(selectables_index)
773
774                break
775
776            if widget.is_selectable:
777                selectables_index += widget.selectables_length
778
779        if not handled and self.overflow == Overflow.SCROLL:
780            if event.action is MouseAction.SCROLL_UP:
781                return self.scroll(-1)
782
783            if event.action is MouseAction.SCROLL_DOWN:
784                return self.scroll(1)
785
786        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:
788    def execute_binding(self, key: str) -> bool:
789        """Executes a binding on self, and then on self._widgets.
790
791        If a widget.execute_binding call returns True this function will too. Note
792        that on success the function returns immediately; no further widgets are
793        checked.
794
795        Args:
796            key: The binding key.
797
798        Returns:
799            True if any widget returned True, False otherwise.
800        """
801
802        if super().execute_binding(key):
803            return True
804
805        selectables_index = 0
806        for widget in self._widgets:
807            if widget.execute_binding(key):
808                selectables_index += widget.selected_index or 0
809                self.select(selectables_index)
810                return True
811
812            if widget.is_selectable:
813                selectables_index += widget.selectables_length
814
815        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:
817    def handle_key(  # pylint: disable=too-many-return-statements, too-many-branches
818        self, key: str
819    ) -> bool:
820        """Handles a keypress, returns its success.
821
822        Args:
823            key: A key str.
824
825        Returns:
826            A boolean showing whether the key was handled.
827        """
828
829        def _is_nav(key: str) -> bool:
830            """Determine if a key is in the navigation sets"""
831
832            return key in self.keys["next"] | self.keys["previous"]
833
834        if self.selected is not None and self.selected.handle_key(key):
835            return True
836
837        scroll_actions = {
838            **{key: 1 for key in self.keys["scroll_down"]},
839            **{key: -1 for key in self.keys["scroll_up"]},
840        }
841
842        if key in self.keys["scroll_down"] | self.keys["scroll_up"]:
843            for widget in self._widgets:
844                if isinstance(widget, Container) and self.selected in widget:
845                    widget.handle_key(key)
846
847            self.scroll(scroll_actions[key])
848            return True
849
850        # Only use navigation when there is more than one selectable
851        if self.selectables_length >= 1 and _is_nav(key):
852            if self.selected_index is None:
853                self.select(0)
854                return True
855
856            handled = False
857
858            assert isinstance(self.selected_index, int)
859
860            if key in self.keys["previous"]:
861                # No more selectables left, user wants to exit Container
862                # upwards.
863                if self.selected_index == 0:
864                    return False
865
866                self.select(self.selected_index - 1)
867                handled = True
868
869            elif key in self.keys["next"]:
870                # Stop selection at last element, return as unhandled
871                new = self.selected_index + 1
872                if new == len(self.selectables):
873                    return False
874
875                self.select(new)
876                handled = True
877
878            if handled:
879                return True
880
881        if key == keys.ENTER:
882            if self.selected_index is None and self.selectables_length > 0:
883                self.select(0)
884
885            if self.selected is not None:
886                self.selected.handle_key(key)
887                return True
888
889        for widget in self._widgets:
890            if widget.execute_binding(key):
891                return True
892
893        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:
895    def wipe(self) -> None:
896        """Wipes the characters occupied by the object"""
897
898        with cursor_at(self.pos) as print_here:
899            for line in self.get_lines():
900                print_here(real_length(line) * " ")

Wipes the characters occupied by the object

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

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

Groups of keys that are used in handle_key

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

Returns the available area for widgets.

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

Join all widgets horizontally.

def debug(self) -> str:
1029    def debug(self) -> str:
1030        """Return identifiable information"""
1031
1032        return super().debug().replace("Container", "Splitter", 1)

Return identifiable information