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._drag_target: Widget | None = None 81 82 @property 83 def sidelength(self) -> int: 84 """Gets the length of left and right borders combined. 85 86 Returns: 87 An integer equal to the `pytermgui.helpers.real_length` of the concatenation of 88 the left and right borders of this widget, both with their respective styles 89 applied. 90 """ 91 92 return self.width - self.content_dimensions[0] 93 94 @property 95 def content_dimensions(self) -> tuple[int, int]: 96 """Gets the size (width, height) of the available content area.""" 97 98 if not "border" in self.chars: 99 return self.width, self.height 100 101 chars = self._get_char("border") 102 103 assert isinstance(chars, list) 104 105 left, top, right, bottom = chars 106 107 return ( 108 self.width - real_length(self.styles.border(left + right)), 109 self.height - sum(1 if real_length(char) else 0 for char in [top, bottom]), 110 ) 111 112 @property 113 def selectables(self) -> list[tuple[Widget, int]]: 114 """Gets all selectable widgets and their inner indices. 115 116 This is used in order to have a constant reference to all selectable indices within this 117 widget. 118 119 Returns: 120 A list of tuples containing a widget and an integer each. For each widget that is 121 withing this one, it is added to this list as many times as it has selectables. Each 122 of the integers correspond to a selectable_index within the widget. 123 124 For example, a Container with a Button, InputField and an inner Container containing 125 3 selectables might return something like this: 126 127 ``` 128 [ 129 (Button(...), 0), 130 (InputField(...), 0), 131 (Container(...), 0), 132 (Container(...), 1), 133 (Container(...), 2), 134 ] 135 ``` 136 """ 137 138 _selectables: list[tuple[Widget, int]] = [] 139 for widget in self._widgets: 140 if not widget.is_selectable: 141 continue 142 143 for i, (inner, _) in enumerate(widget.selectables): 144 _selectables.append((inner, i)) 145 146 return _selectables 147 148 @property 149 def selectables_length(self) -> int: 150 """Gets the length of the selectables list. 151 152 Returns: 153 An integer equal to the length of `self.selectables`. 154 """ 155 156 return len(self.selectables) 157 158 @property 159 def selected(self) -> Widget | None: 160 """Returns the currently selected object 161 162 Returns: 163 The currently selected widget if selected_index is not None, 164 otherwise None. 165 """ 166 167 # TODO: Add deeper selection 168 169 if self.selected_index is None: 170 return None 171 172 if self.selected_index >= len(self.selectables): 173 return None 174 175 return self.selectables[self.selected_index][0] 176 177 @property 178 def box(self) -> boxes.Box: 179 """Returns current box setting 180 181 Returns: 182 The currently set box instance. 183 """ 184 185 return self._box 186 187 @box.setter 188 def box(self, new: str | boxes.Box) -> None: 189 """Applies a new box. 190 191 Args: 192 new: Either a `pytermgui.boxes.Box` instance or a string 193 analogous to one of the default box names. 194 """ 195 196 if isinstance(new, str): 197 from_module = vars(boxes).get(new) 198 if from_module is None: 199 raise ValueError(f"Unknown box type {new}.") 200 201 new = from_module 202 203 assert isinstance(new, boxes.Box) 204 self._box = new 205 new.set_chars_of(self) 206 207 def get_change(self) -> WidgetChange | None: 208 """Determines whether widget lines changed since the last call to this function.""" 209 210 change = super().get_change() 211 212 if change is None: 213 return None 214 215 for widget in self._widgets: 216 if widget.get_change() is not None: 217 self.dirty_widgets.append(widget) 218 219 return change 220 221 def __iadd__(self, other: object) -> Container: 222 """Adds a new widget, then returns self. 223 224 Args: 225 other: Any widget instance, or data structure that can be turned 226 into a widget by `Widget.from_data`. 227 228 Returns: 229 A reference to self. 230 """ 231 232 self._add_widget(other) 233 return self 234 235 def __add__(self, other: object) -> Container: 236 """Adds a new widget, then returns self. 237 238 This method is analogous to `Container.__iadd__`. 239 240 Args: 241 other: Any widget instance, or data structure that can be turned 242 into a widget by `Widget.from_data`. 243 244 Returns: 245 A reference to self. 246 """ 247 248 self.__iadd__(other) 249 return self 250 251 def __iter__(self) -> Iterator[Widget]: 252 """Gets an iterator of self._widgets. 253 254 Yields: 255 The next widget. 256 """ 257 258 for widget in self._widgets: 259 yield widget 260 261 def __len__(self) -> int: 262 """Gets the length of the widgets list. 263 264 Returns: 265 An integer describing len(self._widgets). 266 """ 267 268 return len(self._widgets) 269 270 def __getitem__(self, sli: int | slice) -> Widget | list[Widget]: 271 """Gets an item from self._widgets. 272 273 Args: 274 sli: Slice of the list. 275 276 Returns: 277 The slice in the list. 278 """ 279 280 return self._widgets[sli] 281 282 def __setitem__(self, index: int, value: Any) -> None: 283 """Sets an item in self._widgets. 284 285 Args: 286 index: The index to be set. 287 value: The new widget at this index. 288 """ 289 290 self._widgets[index] = value 291 292 def __contains__(self, other: object) -> bool: 293 """Determines if self._widgets contains other widget. 294 295 Args: 296 other: Any widget-like. 297 298 Returns: 299 A boolean describing whether `other` is in `self.widgets` 300 """ 301 302 if other in self._widgets: 303 return True 304 305 for widget in self._widgets: 306 if isinstance(widget, Container) and other in widget: 307 return True 308 309 return False 310 311 def _add_widget(self, other: object, run_get_lines: bool = True) -> Widget: 312 """Adds other to this widget. 313 314 Args: 315 other: Any widget-like object. 316 run_get_lines: Boolean controlling whether the self.get_lines is ran. 317 318 Returns: 319 The added widget. This is useful when data conversion took place in this 320 function, e.g. a string was converted to a Label. 321 """ 322 323 if not isinstance(other, Widget): 324 to_widget = Widget.from_data(other) 325 if to_widget is None: 326 raise ValueError( 327 f"Could not convert {other} of type {type(other)} to a Widget!" 328 ) 329 330 other = to_widget 331 332 # This is safe to do, as it would've raised an exception above already 333 assert isinstance(other, Widget) 334 335 self._widgets.append(other) 336 if isinstance(other, Container): 337 other.set_recursive_depth(self.depth + 2) 338 else: 339 other.depth = self.depth + 1 340 341 other.get_lines() 342 other.parent = self 343 344 if run_get_lines: 345 self.get_lines() 346 347 return other 348 349 def _get_aligners( 350 self, widget: Widget, borders: tuple[str, str] 351 ) -> tuple[Callable[[str], str], int]: 352 """Gets an aligning method and position offset. 353 354 Args: 355 widget: The widget to align. 356 borders: The left and right borders to put the widget within. 357 358 Returns: 359 A tuple of a method that, when called with a line, will return that line 360 centered using the passed in widget's parent_align and width, as well as 361 the horizontal offset resulting from the widget being aligned. 362 """ 363 364 left, right = self.styles.border(borders[0]), self.styles.border(borders[1]) 365 char = " " 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 """Applies a mouse event on all children. 733 734 Args: 735 event: The event to handle 736 737 Returns: 738 A boolean showing whether the event was handled. 739 """ 740 741 if event.action is MouseAction.RELEASE: 742 # Force RELEASE event to be sent 743 if self._drag_target is not None: 744 self._drag_target.handle_mouse( 745 MouseEvent(MouseAction.RELEASE, event.position) 746 ) 747 748 self._drag_target = None 749 750 if self._drag_target is not None: 751 return self._drag_target.handle_mouse(event) 752 753 selectables_index = 0 754 scrolled_pos = list(event.position) 755 scrolled_pos[1] += self._scroll_offset 756 event.position = (scrolled_pos[0], scrolled_pos[1]) 757 758 handled = False 759 for widget in self._widgets: 760 if ( 761 widget.pos[1] - self.pos[1] - self._scroll_offset 762 > self.content_dimensions[1] 763 ): 764 break 765 766 if widget.contains(event.position): 767 handled = widget.handle_mouse(event) 768 # This avoids too many branches from pylint. 769 selectables_index += widget.selected_index or 0 770 771 if event.action is MouseAction.LEFT_CLICK: 772 self._drag_target = widget 773 774 if handled and selectables_index < len(self.selectables): 775 self.select(selectables_index) 776 777 break 778 779 if widget.is_selectable: 780 selectables_index += widget.selectables_length 781 782 if not handled and self.overflow == Overflow.SCROLL: 783 if event.action is MouseAction.SCROLL_UP: 784 return self.scroll(-1) 785 786 if event.action is MouseAction.SCROLL_DOWN: 787 return self.scroll(1) 788 789 return handled 790 791 def execute_binding(self, key: str) -> bool: 792 """Executes a binding on self, and then on self._widgets. 793 794 If a widget.execute_binding call returns True this function will too. Note 795 that on success the function returns immediately; no further widgets are 796 checked. 797 798 Args: 799 key: The binding key. 800 801 Returns: 802 True if any widget returned True, False otherwise. 803 """ 804 805 if super().execute_binding(key): 806 return True 807 808 selectables_index = 0 809 for widget in self._widgets: 810 if widget.execute_binding(key): 811 selectables_index += widget.selected_index or 0 812 self.select(selectables_index) 813 return True 814 815 if widget.is_selectable: 816 selectables_index += widget.selectables_length 817 818 return False 819 820 def handle_key( # pylint: disable=too-many-return-statements, too-many-branches 821 self, key: str 822 ) -> bool: 823 """Handles a keypress, returns its success. 824 825 Args: 826 key: A key str. 827 828 Returns: 829 A boolean showing whether the key was handled. 830 """ 831 832 def _is_nav(key: str) -> bool: 833 """Determine if a key is in the navigation sets""" 834 835 return key in self.keys["next"] | self.keys["previous"] 836 837 if self.selected is not None and self.selected.handle_key(key): 838 return True 839 840 scroll_actions = { 841 **{key: 1 for key in self.keys["scroll_down"]}, 842 **{key: -1 for key in self.keys["scroll_up"]}, 843 } 844 845 if key in self.keys["scroll_down"] | self.keys["scroll_up"]: 846 for widget in self._widgets: 847 if isinstance(widget, Container) and self.selected in widget: 848 widget.handle_key(key) 849 850 self.scroll(scroll_actions[key]) 851 return True 852 853 # Only use navigation when there is more than one selectable 854 if self.selectables_length >= 1 and _is_nav(key): 855 if self.selected_index is None: 856 self.select(0) 857 return True 858 859 handled = False 860 861 assert isinstance(self.selected_index, int) 862 863 if key in self.keys["previous"]: 864 # No more selectables left, user wants to exit Container 865 # upwards. 866 if self.selected_index == 0: 867 return False 868 869 self.select(self.selected_index - 1) 870 handled = True 871 872 elif key in self.keys["next"]: 873 # Stop selection at last element, return as unhandled 874 new = self.selected_index + 1 875 if new == len(self.selectables): 876 return False 877 878 self.select(new) 879 handled = True 880 881 if handled: 882 return True 883 884 if key == keys.ENTER: 885 if self.selected_index is None and self.selectables_length > 0: 886 self.select(0) 887 888 if self.selected is not None: 889 self.selected.handle_key(key) 890 return True 891 892 for widget in self._widgets: 893 if widget.execute_binding(key): 894 return True 895 896 return False 897 898 def wipe(self) -> None: 899 """Wipes the characters occupied by the object""" 900 901 with cursor_at(self.pos) as print_here: 902 for line in self.get_lines(): 903 print_here(real_length(line) * " ") 904 905 def print(self) -> None: 906 """Prints this Container. 907 908 If the screen size has changed since last `print` call, the object 909 will be centered based on its `centered_axis`. 910 """ 911 912 if not self.terminal.size == self._prev_screen: 913 clear() 914 self.center(self.centered_axis) 915 916 self._prev_screen = self.terminal.size 917 918 if self.allow_fullscreen: 919 self.pos = self.terminal.origin 920 921 with cursor_at(self.pos) as print_here: 922 for line in self.get_lines(): 923 print_here(line) 924 925 self._has_printed = True 926 927 def debug(self) -> str: 928 """Returns a string with identifiable information on this widget. 929 930 Returns: 931 A str in the form of a class construction. This string is in a form that 932 __could have been__ used to create this Container. 933 """ 934 935 return ( 936 f"{type(self).__name__}(width={self.width}, height={self.height}" 937 + (f", id={self.id}" if self.id is not None else "") 938 + ")" 939 ) 940 941 942class Splitter(Container): 943 """A widget that displays other widgets, stacked horizontally.""" 944 945 styles = w_styles.StyleManager(separator=w_styles.MARKUP, fill=w_styles.BACKGROUND) 946 947 chars: dict[str, list[str] | str] = {"separator": " | "} 948 keys = { 949 "previous": {keys.LEFT, "h", keys.CTRL_B}, 950 "next": {keys.RIGHT, "l", keys.CTRL_F}, 951 } 952 953 parent_align = HorizontalAlignment.RIGHT 954 955 def _align( 956 self, alignment: HorizontalAlignment, target_width: int, line: str 957 ) -> tuple[int, str]: 958 """Align a line 959 960 r/wordavalanches""" 961 962 available = target_width - real_length(line) 963 fill_style = self._get_style("fill") 964 965 char = fill_style(" ") 966 line = fill_style(line) 967 968 if alignment == HorizontalAlignment.CENTER: 969 padding, offset = divmod(available, 2) 970 return padding, padding * char + line + (padding + offset) * char 971 972 if alignment == HorizontalAlignment.RIGHT: 973 return available, available * char + line 974 975 return 0, line + available * char 976 977 @property 978 def content_dimensions(self) -> tuple[int, int]: 979 """Returns the available area for widgets.""" 980 981 return self.height, self.width 982 983 def get_lines(self) -> list[str]: 984 """Join all widgets horizontally.""" 985 986 # An error will be raised if `separator` is not the correct type (str). 987 separator = self._get_style("separator")(self._get_char("separator")) # type: ignore 988 separator_length = real_length(separator) 989 990 target_width, error = divmod( 991 self.width - (len(self._widgets) - 1) * separator_length, len(self._widgets) 992 ) 993 994 vertical_lines = [] 995 total_offset = 0 996 997 for widget in self._widgets: 998 inner = [] 999 1000 if widget.size_policy is SizePolicy.STATIC: 1001 target_width += target_width - widget.width 1002 width = widget.width 1003 else: 1004 widget.width = target_width + error 1005 width = widget.width 1006 error = 0 1007 1008 aligned: str | None = None 1009 for line in widget.get_lines(): 1010 # See `enums.py` for information about this ignore 1011 padding, aligned = self._align( 1012 cast(HorizontalAlignment, widget.parent_align), width, line 1013 ) 1014 inner.append(aligned) 1015 1016 widget.pos = ( 1017 self.pos[0] + padding + total_offset, 1018 self.pos[1] + (1 if type(widget).__name__ == "Container" else 0), 1019 ) 1020 1021 if aligned is not None: 1022 total_offset += real_length(inner[-1]) + separator_length 1023 1024 vertical_lines.append(inner) 1025 1026 lines = [] 1027 for horizontal in zip_longest(*vertical_lines, fillvalue=" " * target_width): 1028 lines.append((reset() + separator).join(horizontal)) 1029 1030 return lines 1031 1032 def debug(self) -> str: 1033 """Return identifiable information""" 1034 1035 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._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 + 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 """Applies a mouse event on all children. 734 735 Args: 736 event: The event to handle 737 738 Returns: 739 A boolean showing whether the event was handled. 740 """ 741 742 if event.action is MouseAction.RELEASE: 743 # Force RELEASE event to be sent 744 if self._drag_target is not None: 745 self._drag_target.handle_mouse( 746 MouseEvent(MouseAction.RELEASE, event.position) 747 ) 748 749 self._drag_target = None 750 751 if self._drag_target is not None: 752 return self._drag_target.handle_mouse(event) 753 754 selectables_index = 0 755 scrolled_pos = list(event.position) 756 scrolled_pos[1] += self._scroll_offset 757 event.position = (scrolled_pos[0], scrolled_pos[1]) 758 759 handled = False 760 for widget in self._widgets: 761 if ( 762 widget.pos[1] - self.pos[1] - self._scroll_offset 763 > self.content_dimensions[1] 764 ): 765 break 766 767 if widget.contains(event.position): 768 handled = widget.handle_mouse(event) 769 # This avoids too many branches from pylint. 770 selectables_index += widget.selected_index or 0 771 772 if event.action is MouseAction.LEFT_CLICK: 773 self._drag_target = widget 774 775 if handled and selectables_index < len(self.selectables): 776 self.select(selectables_index) 777 778 break 779 780 if widget.is_selectable: 781 selectables_index += widget.selectables_length 782 783 if not handled and self.overflow == Overflow.SCROLL: 784 if event.action is MouseAction.SCROLL_UP: 785 return self.scroll(-1) 786 787 if event.action is MouseAction.SCROLL_DOWN: 788 return self.scroll(1) 789 790 return handled 791 792 def execute_binding(self, key: str) -> bool: 793 """Executes a binding on self, and then on self._widgets. 794 795 If a widget.execute_binding call returns True this function will too. Note 796 that on success the function returns immediately; no further widgets are 797 checked. 798 799 Args: 800 key: The binding key. 801 802 Returns: 803 True if any widget returned True, False otherwise. 804 """ 805 806 if super().execute_binding(key): 807 return True 808 809 selectables_index = 0 810 for widget in self._widgets: 811 if widget.execute_binding(key): 812 selectables_index += widget.selected_index or 0 813 self.select(selectables_index) 814 return True 815 816 if widget.is_selectable: 817 selectables_index += widget.selectables_length 818 819 return False 820 821 def handle_key( # pylint: disable=too-many-return-statements, too-many-branches 822 self, key: str 823 ) -> bool: 824 """Handles a keypress, returns its success. 825 826 Args: 827 key: A key str. 828 829 Returns: 830 A boolean showing whether the key was handled. 831 """ 832 833 def _is_nav(key: str) -> bool: 834 """Determine if a key is in the navigation sets""" 835 836 return key in self.keys["next"] | self.keys["previous"] 837 838 if self.selected is not None and self.selected.handle_key(key): 839 return True 840 841 scroll_actions = { 842 **{key: 1 for key in self.keys["scroll_down"]}, 843 **{key: -1 for key in self.keys["scroll_up"]}, 844 } 845 846 if key in self.keys["scroll_down"] | self.keys["scroll_up"]: 847 for widget in self._widgets: 848 if isinstance(widget, Container) and self.selected in widget: 849 widget.handle_key(key) 850 851 self.scroll(scroll_actions[key]) 852 return True 853 854 # Only use navigation when there is more than one selectable 855 if self.selectables_length >= 1 and _is_nav(key): 856 if self.selected_index is None: 857 self.select(0) 858 return True 859 860 handled = False 861 862 assert isinstance(self.selected_index, int) 863 864 if key in self.keys["previous"]: 865 # No more selectables left, user wants to exit Container 866 # upwards. 867 if self.selected_index == 0: 868 return False 869 870 self.select(self.selected_index - 1) 871 handled = True 872 873 elif key in self.keys["next"]: 874 # Stop selection at last element, return as unhandled 875 new = self.selected_index + 1 876 if new == len(self.selectables): 877 return False 878 879 self.select(new) 880 handled = True 881 882 if handled: 883 return True 884 885 if key == keys.ENTER: 886 if self.selected_index is None and self.selectables_length > 0: 887 self.select(0) 888 889 if self.selected is not None: 890 self.selected.handle_key(key) 891 return True 892 893 for widget in self._widgets: 894 if widget.execute_binding(key): 895 return True 896 897 return False 898 899 def wipe(self) -> None: 900 """Wipes the characters occupied by the object""" 901 902 with cursor_at(self.pos) as print_here: 903 for line in self.get_lines(): 904 print_here(real_length(line) * " ") 905 906 def print(self) -> None: 907 """Prints this Container. 908 909 If the screen size has changed since last `print` call, the object 910 will be centered based on its `centered_axis`. 911 """ 912 913 if not self.terminal.size == self._prev_screen: 914 clear() 915 self.center(self.centered_axis) 916 917 self._prev_screen = self.terminal.size 918 919 if self.allow_fullscreen: 920 self.pos = self.terminal.origin 921 922 with cursor_at(self.pos) as print_here: 923 for line in self.get_lines(): 924 print_here(line) 925 926 self._has_printed = True 927 928 def debug(self) -> str: 929 """Returns a string with identifiable information on this widget. 930 931 Returns: 932 A str in the form of a class construction. This string is in a form that 933 __could have been__ used to create this Container. 934 """ 935 936 return ( 937 f"{type(self).__name__}(width={self.width}, height={self.height}" 938 + (f", id={self.id}" if self.id is not None else "") 939 + ")" 940 )
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._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.
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 """Applies a mouse event on all children. 734 735 Args: 736 event: The event to handle 737 738 Returns: 739 A boolean showing whether the event was handled. 740 """ 741 742 if event.action is MouseAction.RELEASE: 743 # Force RELEASE event to be sent 744 if self._drag_target is not None: 745 self._drag_target.handle_mouse( 746 MouseEvent(MouseAction.RELEASE, event.position) 747 ) 748 749 self._drag_target = None 750 751 if self._drag_target is not None: 752 return self._drag_target.handle_mouse(event) 753 754 selectables_index = 0 755 scrolled_pos = list(event.position) 756 scrolled_pos[1] += self._scroll_offset 757 event.position = (scrolled_pos[0], scrolled_pos[1]) 758 759 handled = False 760 for widget in self._widgets: 761 if ( 762 widget.pos[1] - self.pos[1] - self._scroll_offset 763 > self.content_dimensions[1] 764 ): 765 break 766 767 if widget.contains(event.position): 768 handled = widget.handle_mouse(event) 769 # This avoids too many branches from pylint. 770 selectables_index += widget.selected_index or 0 771 772 if event.action is MouseAction.LEFT_CLICK: 773 self._drag_target = widget 774 775 if handled and selectables_index < len(self.selectables): 776 self.select(selectables_index) 777 778 break 779 780 if widget.is_selectable: 781 selectables_index += widget.selectables_length 782 783 if not handled and self.overflow == Overflow.SCROLL: 784 if event.action is MouseAction.SCROLL_UP: 785 return self.scroll(-1) 786 787 if event.action is MouseAction.SCROLL_DOWN: 788 return self.scroll(1) 789 790 return handled
Applies a mouse event on all children.
Args
- event: The event to handle
Returns
A boolean showing whether the event was handled.
792 def execute_binding(self, key: str) -> bool: 793 """Executes a binding on self, and then on self._widgets. 794 795 If a widget.execute_binding call returns True this function will too. Note 796 that on success the function returns immediately; no further widgets are 797 checked. 798 799 Args: 800 key: The binding key. 801 802 Returns: 803 True if any widget returned True, False otherwise. 804 """ 805 806 if super().execute_binding(key): 807 return True 808 809 selectables_index = 0 810 for widget in self._widgets: 811 if widget.execute_binding(key): 812 selectables_index += widget.selected_index or 0 813 self.select(selectables_index) 814 return True 815 816 if widget.is_selectable: 817 selectables_index += widget.selectables_length 818 819 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.
821 def handle_key( # pylint: disable=too-many-return-statements, too-many-branches 822 self, key: str 823 ) -> bool: 824 """Handles a keypress, returns its success. 825 826 Args: 827 key: A key str. 828 829 Returns: 830 A boolean showing whether the key was handled. 831 """ 832 833 def _is_nav(key: str) -> bool: 834 """Determine if a key is in the navigation sets""" 835 836 return key in self.keys["next"] | self.keys["previous"] 837 838 if self.selected is not None and self.selected.handle_key(key): 839 return True 840 841 scroll_actions = { 842 **{key: 1 for key in self.keys["scroll_down"]}, 843 **{key: -1 for key in self.keys["scroll_up"]}, 844 } 845 846 if key in self.keys["scroll_down"] | self.keys["scroll_up"]: 847 for widget in self._widgets: 848 if isinstance(widget, Container) and self.selected in widget: 849 widget.handle_key(key) 850 851 self.scroll(scroll_actions[key]) 852 return True 853 854 # Only use navigation when there is more than one selectable 855 if self.selectables_length >= 1 and _is_nav(key): 856 if self.selected_index is None: 857 self.select(0) 858 return True 859 860 handled = False 861 862 assert isinstance(self.selected_index, int) 863 864 if key in self.keys["previous"]: 865 # No more selectables left, user wants to exit Container 866 # upwards. 867 if self.selected_index == 0: 868 return False 869 870 self.select(self.selected_index - 1) 871 handled = True 872 873 elif key in self.keys["next"]: 874 # Stop selection at last element, return as unhandled 875 new = self.selected_index + 1 876 if new == len(self.selectables): 877 return False 878 879 self.select(new) 880 handled = True 881 882 if handled: 883 return True 884 885 if key == keys.ENTER: 886 if self.selected_index is None and self.selectables_length > 0: 887 self.select(0) 888 889 if self.selected is not None: 890 self.selected.handle_key(key) 891 return True 892 893 for widget in self._widgets: 894 if widget.execute_binding(key): 895 return True 896 897 return False
Handles a keypress, returns its success.
Args
- key: A key str.
Returns
A boolean showing whether the key was handled.
899 def wipe(self) -> None: 900 """Wipes the characters occupied by the object""" 901 902 with cursor_at(self.pos) as print_here: 903 for line in self.get_lines(): 904 print_here(real_length(line) * " ")
Wipes the characters occupied by the object
906 def print(self) -> None: 907 """Prints this Container. 908 909 If the screen size has changed since last `print` call, the object 910 will be centered based on its `centered_axis`. 911 """ 912 913 if not self.terminal.size == self._prev_screen: 914 clear() 915 self.center(self.centered_axis) 916 917 self._prev_screen = self.terminal.size 918 919 if self.allow_fullscreen: 920 self.pos = self.terminal.origin 921 922 with cursor_at(self.pos) as print_here: 923 for line in self.get_lines(): 924 print_here(line) 925 926 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
.
928 def debug(self) -> str: 929 """Returns a string with identifiable information on this widget. 930 931 Returns: 932 A str in the form of a class construction. This string is in a form that 933 __could have been__ used to create this Container. 934 """ 935 936 return ( 937 f"{type(self).__name__}(width={self.width}, height={self.height}" 938 + (f", id={self.id}" if self.id is not None else "") 939 + ")" 940 )
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.
943class Splitter(Container): 944 """A widget that displays other widgets, stacked horizontally.""" 945 946 styles = w_styles.StyleManager(separator=w_styles.MARKUP, fill=w_styles.BACKGROUND) 947 948 chars: dict[str, list[str] | str] = {"separator": " | "} 949 keys = { 950 "previous": {keys.LEFT, "h", keys.CTRL_B}, 951 "next": {keys.RIGHT, "l", keys.CTRL_F}, 952 } 953 954 parent_align = HorizontalAlignment.RIGHT 955 956 def _align( 957 self, alignment: HorizontalAlignment, target_width: int, line: str 958 ) -> tuple[int, str]: 959 """Align a line 960 961 r/wordavalanches""" 962 963 available = target_width - real_length(line) 964 fill_style = self._get_style("fill") 965 966 char = fill_style(" ") 967 line = fill_style(line) 968 969 if alignment == HorizontalAlignment.CENTER: 970 padding, offset = divmod(available, 2) 971 return padding, padding * char + line + (padding + offset) * char 972 973 if alignment == HorizontalAlignment.RIGHT: 974 return available, available * char + line 975 976 return 0, line + available * char 977 978 @property 979 def content_dimensions(self) -> tuple[int, int]: 980 """Returns the available area for widgets.""" 981 982 return self.height, self.width 983 984 def get_lines(self) -> list[str]: 985 """Join all widgets horizontally.""" 986 987 # An error will be raised if `separator` is not the correct type (str). 988 separator = self._get_style("separator")(self._get_char("separator")) # type: ignore 989 separator_length = real_length(separator) 990 991 target_width, error = divmod( 992 self.width - (len(self._widgets) - 1) * separator_length, len(self._widgets) 993 ) 994 995 vertical_lines = [] 996 total_offset = 0 997 998 for widget in self._widgets: 999 inner = [] 1000 1001 if widget.size_policy is SizePolicy.STATIC: 1002 target_width += target_width - widget.width 1003 width = widget.width 1004 else: 1005 widget.width = target_width + error 1006 width = widget.width 1007 error = 0 1008 1009 aligned: str | None = None 1010 for line in widget.get_lines(): 1011 # See `enums.py` for information about this ignore 1012 padding, aligned = self._align( 1013 cast(HorizontalAlignment, widget.parent_align), width, line 1014 ) 1015 inner.append(aligned) 1016 1017 widget.pos = ( 1018 self.pos[0] + padding + total_offset, 1019 self.pos[1] + (1 if type(widget).__name__ == "Container" else 0), 1020 ) 1021 1022 if aligned is not None: 1023 total_offset += real_length(inner[-1]) + separator_length 1024 1025 vertical_lines.append(inner) 1026 1027 lines = [] 1028 for horizontal in zip_longest(*vertical_lines, fillvalue=" " * target_width): 1029 lines.append((reset() + separator).join(horizontal)) 1030 1031 return lines 1032 1033 def debug(self) -> str: 1034 """Return identifiable information""" 1035 1036 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
984 def get_lines(self) -> list[str]: 985 """Join all widgets horizontally.""" 986 987 # An error will be raised if `separator` is not the correct type (str). 988 separator = self._get_style("separator")(self._get_char("separator")) # type: ignore 989 separator_length = real_length(separator) 990 991 target_width, error = divmod( 992 self.width - (len(self._widgets) - 1) * separator_length, len(self._widgets) 993 ) 994 995 vertical_lines = [] 996 total_offset = 0 997 998 for widget in self._widgets: 999 inner = [] 1000 1001 if widget.size_policy is SizePolicy.STATIC: 1002 target_width += target_width - widget.width 1003 width = widget.width 1004 else: 1005 widget.width = target_width + error 1006 width = widget.width 1007 error = 0 1008 1009 aligned: str | None = None 1010 for line in widget.get_lines(): 1011 # See `enums.py` for information about this ignore 1012 padding, aligned = self._align( 1013 cast(HorizontalAlignment, widget.parent_align), width, line 1014 ) 1015 inner.append(aligned) 1016 1017 widget.pos = ( 1018 self.pos[0] + padding + total_offset, 1019 self.pos[1] + (1 if type(widget).__name__ == "Container" else 0), 1020 ) 1021 1022 if aligned is not None: 1023 total_offset += real_length(inner[-1]) + separator_length 1024 1025 vertical_lines.append(inner) 1026 1027 lines = [] 1028 for horizontal in zip_longest(*vertical_lines, fillvalue=" " * target_width): 1029 lines.append((reset() + separator).join(horizontal)) 1030 1031 return lines
Join all widgets horizontally.
1033 def debug(self) -> str: 1034 """Return identifiable information""" 1035 1036 return super().debug().replace("Container", "Splitter", 1)
Return identifiable information