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