pytermgui.widgets.containers

The module containing all of the layout-related widgets.

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

A widget that displays other widgets, stacked vertically.

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

Initialize Container data

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

Default styles for this class

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

Default characters for this class

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

Groups of keys that are used in handle_key

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

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

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

Gets the length of left and right borders combined.

Returns

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

#   content_dimensions: tuple[int, int]

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

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

Gets all selectable widgets and their inner indices.

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

Returns

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

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

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

Gets the length of the selectables list.

Returns

An integer equal to the length of self.selectables.

Returns the currently selected object

Returns

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

Returns current box setting

Returns

The currently set box instance.

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

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

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

Wipes the characters occupied by the object

#   def print(self) -> None:
View Source
890    def print(self) -> None:
891        """Prints this Container.
892
893        If the screen size has changed since last `print` call, the object
894        will be centered based on its `centered_axis`.
895        """
896
897        if not self.terminal.size == self._prev_screen:
898            clear()
899            self.center(self.centered_axis)
900
901        self._prev_screen = self.terminal.size
902
903        if self.allow_fullscreen:
904            self.pos = self.terminal.origin
905
906        with cursor_at(self.pos) as print_here:
907            for line in self.get_lines():
908                print_here(line)
909
910        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:
View Source
912    def debug(self) -> str:
913        """Returns a string with identifiable information on this widget.
914
915        Returns:
916            A str in the form of a class construction. This string is in a form that
917            __could have been__ used to create this Container.
918        """
919
920        return (
921            f"{type(self).__name__}(width={self.width}, height={self.height}"
922            + (f", id={self.id}" if self.id is not None else "")
923            + ")"
924        )

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

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]:
View Source
 968    def get_lines(self) -> list[str]:
 969        """Join all widgets horizontally."""
 970
 971        # An error will be raised if `separator` is not the correct type (str).
 972        separator = self._get_style("separator")(self._get_char("separator"))  # type: ignore
 973        separator_length = real_length(separator)
 974
 975        target_width, error = divmod(
 976            self.width - (len(self._widgets) - 1) * separator_length, len(self._widgets)
 977        )
 978
 979        vertical_lines = []
 980        total_offset = 0
 981
 982        for widget in self._widgets:
 983            inner = []
 984
 985            if widget.size_policy is SizePolicy.STATIC:
 986                target_width += target_width - widget.width
 987                width = widget.width
 988            else:
 989                widget.width = target_width + error
 990                width = widget.width
 991                error = 0
 992
 993            aligned: str | None = None
 994            for line in widget.get_lines():
 995                # See `enums.py` for information about this ignore
 996                padding, aligned = self._align(
 997                    cast(HorizontalAlignment, widget.parent_align), width, line
 998                )
 999                inner.append(aligned)
1000
1001            widget.pos = (
1002                self.pos[0] + padding + total_offset,
1003                self.pos[1] + (1 if type(widget).__name__ == "Container" else 0),
1004            )
1005
1006            if aligned is not None:
1007                total_offset += real_length(inner[-1]) + separator_length
1008
1009            vertical_lines.append(inner)
1010
1011        lines = []
1012        for horizontal in zip_longest(*vertical_lines, fillvalue=" " * target_width):
1013            lines.append((reset() + separator).join(horizontal))
1014
1015        return lines

Join all widgets horizontally.

#   def debug(self) -> str:
View Source
1017    def debug(self) -> str:
1018        """Return identifiable information"""
1019
1020        return super().debug().replace("Container", "Splitter", 1)

Return identifiable information