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