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