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