pytermgui.widgets.base
The basic building blocks making up the Widget system.
1""" 2The basic building blocks making up the Widget system. 3""" 4 5# The classes defined here need more than 7 instance attributes, 6# and there is no cyclic import during runtime. 7# pylint: disable=too-many-instance-attributes, cyclic-import 8 9from __future__ import annotations 10 11from copy import deepcopy 12from inspect import signature 13from typing import Any, Callable, Generator, Iterator, Optional, Type, Union 14 15from ..ansi_interface import MouseAction, MouseEvent, reset 16from ..enums import HorizontalAlignment, SizePolicy, WidgetChange 17from ..fancy_repr import FancyYield 18from ..helpers import break_line 19from ..input import keys 20from ..markup import get_markup 21from ..regex import real_length 22from ..terminal import Terminal, get_terminal 23from . import styles as w_styles 24 25__all__ = ["Widget", "Label"] 26 27BoundCallback = Callable[..., Any] 28WidgetType = Union["Widget", Type["Widget"]] 29 30 31def _set_obj_or_cls_style( 32 obj_or_cls: Type[Widget] | Widget, key: str, value: w_styles.StyleType 33) -> Type[Widget] | Widget: 34 """Sets a style for an object or class 35 36 Args: 37 obj_or_cls: The Widget instance or type to update. 38 key: The style key. 39 value: The new style. 40 41 Returns: 42 Type[Widget] | Widget: The updated class. 43 44 Raises: 45 See `pytermgui.widgets.styles.StyleManager`. 46 """ 47 48 obj_or_cls.styles[key] = value 49 50 return obj_or_cls 51 52 53def _set_obj_or_cls_char( 54 obj_or_cls: Type[Widget] | Widget, key: str, value: w_styles.CharType 55) -> Type[Widget] | Widget: 56 """Sets a char for an object or class 57 58 Args: 59 obj_or_cls: The Widget instance or type to update. 60 key: The char key. 61 value: The new char. 62 63 Returns: 64 Type[Widget] | Widget: The updated class. 65 66 Raises: 67 KeyError: The char key provided is invalid. 68 """ 69 70 if not key in obj_or_cls.chars.keys(): 71 raise KeyError(f"Char {key} is not valid for {obj_or_cls}!") 72 73 obj_or_cls.chars[key] = value 74 75 return obj_or_cls 76 77 78class Widget: # pylint: disable=too-many-public-methods 79 """The base of the Widget system""" 80 81 set_style = classmethod(_set_obj_or_cls_style) 82 set_char = classmethod(_set_obj_or_cls_char) 83 84 styles = w_styles.StyleManager() 85 """Default styles for this class""" 86 87 chars: dict[str, w_styles.CharType] = {} 88 """Default characters for this class""" 89 90 keys: dict[str, set[str]] = {} 91 """Groups of keys that are used in `handle_key`""" 92 93 serialized: list[str] = [ 94 "id", 95 "pos", 96 "depth", 97 "width", 98 "height", 99 "selected_index", 100 "selectables_length", 101 ] 102 """Fields of widget that shall be serialized by `pytermgui.serializer.Serializer`""" 103 104 # This class is loaded after this module, 105 # and thus mypy doesn't see its existence. 106 _id_manager: Optional["_IDManager"] = None # type: ignore 107 108 is_bindable = False 109 """Allow binding support""" 110 111 size_policy = SizePolicy.get_default() 112 """`pytermgui.enums.SizePolicy` to set widget's width according to""" 113 114 parent_align = HorizontalAlignment.get_default() 115 """`pytermgui.enums.HorizontalAlignment` to align widget by""" 116 117 from_data: Callable[..., Widget | list[Widget] | None] 118 119 # We cannot import boxes here due to cyclic imports. 120 box: Any 121 122 def __init__(self, **attrs: Any) -> None: 123 """Initialize object""" 124 125 self.set_style = lambda key, value: _set_obj_or_cls_style(self, key, value) 126 self.set_char = lambda key, value: _set_obj_or_cls_char(self, key, value) 127 128 self.width = 1 129 self.height = 1 130 self.pos = self.terminal.origin 131 132 self.depth = 0 133 134 self.styles = type(self).styles.branch(self) 135 self.chars = type(self).chars.copy() 136 137 self.parent: Widget | None = None 138 self.selected_index: int | None = None 139 140 self._selectables_length = 0 141 self._id: Optional[str] = None 142 self._serialized_fields = type(self).serialized 143 self._bindings: dict[str | Type[MouseEvent], tuple[BoundCallback, str]] = {} 144 self._relative_width: float | None = None 145 self._previous_state: tuple[tuple[int, int], list[str]] | None = None 146 147 self.positioned_line_buffer: list[tuple[tuple[int, int], str]] = [] 148 149 for attr, value in attrs.items(): 150 setattr(self, attr, value) 151 152 def __repr__(self) -> str: 153 """Return repr string of this widget. 154 155 Returns: 156 Whatever this widget's `debug` method gives. 157 """ 158 159 return self.debug() 160 161 def __fancy_repr__(self) -> Generator[FancyYield, None, None]: 162 """Yields the repr of this object, then a preview of it.""" 163 164 yield self.debug() 165 yield "\n\n" 166 yield { 167 "text": "\n".join((line + reset() for line in self.get_lines())), 168 "highlight": False, 169 } 170 171 def __iter__(self) -> Iterator[Widget]: 172 """Return self for iteration""" 173 174 yield self 175 176 @property 177 def bindings(self) -> dict[str | Type[MouseEvent], tuple[BoundCallback, str]]: 178 """Gets a copy of the bindings internal dictionary. 179 180 Returns: 181 A copy of the internal bindings dictionary, such as: 182 183 ``` 184 { 185 "*": (star_callback, "This is a callback activated when '*' is pressed.") 186 } 187 ``` 188 """ 189 190 return self._bindings.copy() 191 192 @property 193 def id(self) -> Optional[str]: # pylint: disable=invalid-name 194 """Gets this widget's id property 195 196 Returns: 197 The id string if one is present, None otherwise. 198 """ 199 200 return self._id 201 202 @id.setter 203 def id(self, value: str) -> None: # pylint: disable=invalid-name 204 """Registers a widget to the Widget._id_manager. 205 206 If this widget already had an id, the old value is deregistered 207 before the new one is assigned. 208 209 Args: 210 value: The new id this widget will be registered as. 211 """ 212 213 if self._id == value: 214 return 215 216 manager = Widget._id_manager 217 assert manager is not None 218 219 old = manager.get_id(self) 220 if old is not None: 221 manager.deregister(old) 222 223 self._id = value 224 manager.register(self) 225 226 @property 227 def selectables_length(self) -> int: 228 """Gets how many selectables this widget contains. 229 230 Returns: 231 An integer describing the amount of selectables in this widget. 232 """ 233 234 return self._selectables_length 235 236 @property 237 def selectables(self) -> list[tuple[Widget, int]]: 238 """Gets a list of all selectables within this widget 239 240 Returns: 241 A list of tuples. In the default implementation this will be 242 a list of one tuple, containing a reference to `self`, as well 243 as the lowest index, 0. 244 """ 245 246 return [(self, 0)] 247 248 @property 249 def is_selectable(self) -> bool: 250 """Determines whether this widget has any selectables. 251 252 Returns: 253 A boolean, representing `self.selectables_length != 0`. 254 """ 255 256 return self.selectables_length != 0 257 258 @property 259 def static_width(self) -> int: 260 """Allows for a shorter way of setting a width, and SizePolicy.STATIC. 261 262 Args: 263 value: The new width integer. 264 265 Returns: 266 None, as this is setter only. 267 """ 268 269 return None # type: ignore 270 271 @static_width.setter 272 def static_width(self, value: int) -> None: 273 """See the static_width getter.""" 274 275 self.width = value 276 self.size_policy = SizePolicy.STATIC 277 278 @property 279 def relative_width(self) -> float | None: 280 """Sets this widget's relative width, and changes size_policy to RELATIVE. 281 282 The value is clamped to 1.0. 283 284 If a Container holds a width of 30, and it has a subwidget with a relative 285 width of 0.5, it will be resized to 15. 286 287 Args: 288 value: The multiplier to apply to the parent's width. 289 290 Returns: 291 The current relative_width. 292 """ 293 294 return self._relative_width 295 296 @relative_width.setter 297 def relative_width(self, value: float) -> None: 298 """See the relative_width getter.""" 299 300 self.size_policy = SizePolicy.RELATIVE 301 self._relative_width = min(1.0, value) 302 303 @property 304 def terminal(self) -> Terminal: 305 """Returns the current global terminal instance.""" 306 307 return get_terminal() 308 309 def get_change(self) -> WidgetChange | None: 310 """Determines whether widget lines changed since the last call to this function.""" 311 312 lines = self.get_lines() 313 314 if self._previous_state is None: 315 self._previous_state = (self.width, self.height), lines 316 return WidgetChange.LINES 317 318 lines = self.get_lines() 319 (old_width, old_height), old_lines = self._previous_state 320 321 self._previous_state = (self.width, self.height), lines 322 323 if old_width != self.width and old_height != self.height: 324 return WidgetChange.SIZE 325 326 if old_width != self.width: 327 return WidgetChange.WIDTH 328 329 if old_height != self.height: 330 return WidgetChange.HEIGHT 331 332 if old_lines != lines: 333 return WidgetChange.LINES 334 335 return None 336 337 def contains(self, pos: tuple[int, int]) -> bool: 338 """Determines whether widget contains `pos`. 339 340 Args: 341 pos: Position to compare. 342 343 Returns: 344 Boolean describing whether the position is inside 345 this widget. 346 """ 347 348 rect = self.pos, ( 349 self.pos[0] + self.width, 350 self.pos[1] + self.height, 351 ) 352 353 (left, top), (right, bottom) = rect 354 355 return left <= pos[0] < right and top <= pos[1] < bottom 356 357 def handle_mouse(self, event: MouseEvent) -> bool: 358 """Tries to call the most specific mouse handler function available. 359 360 This function looks for a set of mouse action handlers. Each handler follows 361 the format 362 363 on_{event_name} 364 365 For example, the handler triggered on MouseAction.LEFT_CLICK would be 366 `on_left_click`. If no handler is found nothing is done. 367 368 You can also define more general handlers, for example to group left & right 369 clicks you can use `on_click`, and to catch both up and down scroll you can use 370 `on_scroll`. General handlers are only used if they are the most specific ones, 371 i.e. there is no "specific" handler. 372 373 Args: 374 event: The event to handle. 375 376 Returns: 377 Whether the parent of this widget should treat it as one to "stick" events 378 to, e.g. to keep sending mouse events to it. One can "unstick" a widget by 379 returning False in the handler. 380 """ 381 382 def _get_names(action: MouseAction) -> tuple[str, ...]: 383 if action.value in ["hover", "release"]: 384 return (action.value,) 385 386 parts = action.value.split("_") 387 388 # left click & right click 389 if parts[0] in ["left", "right"]: 390 return (action.value, parts[1]) 391 392 # scroll up & down 393 return (action.value, parts[0]) 394 395 possible_names = _get_names(event.action) 396 for name in possible_names: 397 if hasattr(self, f"on_{name}"): 398 handle = getattr(self, f"on_{name}") 399 400 return handle(event) 401 402 return False 403 404 def handle_key(self, key: str) -> bool: 405 """Handles a mouse event, returning its success. 406 407 Args: 408 key: String representation of input string. 409 The `pytermgui.input.keys` object can be 410 used to retrieve special keys. 411 412 Returns: 413 A boolean describing whether the key was handled. 414 """ 415 416 return False and hasattr(self, key) 417 418 def serialize(self) -> dict[str, Any]: 419 """Serializes a widget. 420 421 The fields looked at are defined `Widget.serialized`. Note that 422 this method is not very commonly used at the moment, so it might 423 not have full functionality in non-nuclear widgets. 424 425 Returns: 426 Dictionary of widget attributes. The dictionary will always 427 have a `type` field. Any styles are converted into markup 428 strings during serialization, so they can be loaded again in 429 their original form. 430 431 Example return: 432 ``` 433 { 434 "type": "Label", 435 "value": "[210 bold]I am a title", 436 "parent_align": 0, 437 ... 438 } 439 ``` 440 """ 441 442 fields = self._serialized_fields 443 444 out: dict[str, Any] = {"type": type(self).__name__} 445 for key in fields: 446 # Detect styled values 447 if key.startswith("*"): 448 style = True 449 key = key[1:] 450 else: 451 style = False 452 453 value = getattr(self, key) 454 455 # Convert styled value into markup 456 if style: 457 style_call = self._get_style(key) 458 if isinstance(value, list): 459 out[key] = [get_markup(style_call(char)) for char in value] 460 else: 461 out[key] = get_markup(style_call(value)) 462 463 continue 464 465 out[key] = value 466 467 # The chars need to be handled separately 468 out["chars"] = {} 469 for key, value in self.chars.items(): 470 style_call = self._get_style(key) 471 472 if isinstance(value, list): 473 out["chars"][key] = [get_markup(style_call(char)) for char in value] 474 else: 475 out["chars"][key] = get_markup(style_call(value)) 476 477 return out 478 479 def copy(self) -> Widget: 480 """Creates a deep copy of this widget""" 481 482 return deepcopy(self) 483 484 def _get_style(self, key: str) -> w_styles.DepthlessStyleType: 485 """Gets style call from its key. 486 487 This is analogous to using `self.styles.{key}` 488 489 Args: 490 key: A key into the widget's style manager. 491 492 Returns: 493 A `pytermgui.styles.StyleCall` object containing the referenced 494 style. StyleCall objects should only be used internally inside a 495 widget. 496 497 Raises: 498 KeyError: Style key is invalid. 499 """ 500 501 return self.styles[key] 502 503 def _get_char(self, key: str) -> w_styles.CharType: 504 """Gets character from its key. 505 506 Args: 507 key: A key into the widget's chars dictionary. 508 509 Returns: 510 Either a `list[str]` or a simple `str`, depending on the character. 511 512 Raises: 513 KeyError: Style key is invalid. 514 """ 515 516 chars = self.chars[key] 517 if isinstance(chars, str): 518 return chars 519 520 return chars.copy() 521 522 def get_lines(self) -> list[str]: 523 """Gets lines representing this widget. 524 525 These lines have to be equal to the widget in length. All 526 widgets must provide this method. Make sure to keep it performant, 527 as it will be called very often, often multiple times per WindowManager frame. 528 529 Any longer actions should be done outside of this method, and only their 530 result should be looked up here. 531 532 Returns: 533 Nothing by default. 534 535 Raises: 536 NotImplementedError: As this method is required for **all** widgets, not 537 having it defined will raise NotImplementedError. 538 """ 539 540 raise NotImplementedError(f"get_lines() is not defined for type {type(self)}.") 541 542 def bind( 543 self, key: str, action: BoundCallback, description: Optional[str] = None 544 ) -> None: 545 """Binds an action to a keypress. 546 547 This function is only called by implementations above this layer. To use this 548 functionality use `pytermgui.window_manager.WindowManager`, or write your own 549 custom layer. 550 551 Special keys: 552 - keys.ANY_KEY: Any and all keypresses execute this binding. 553 - keys.MouseAction: Any and all mouse inputs execute this binding. 554 555 Args: 556 key: The key that the action will be bound to. 557 action: The action executed when the key is pressed. 558 description: An optional description for this binding. It is not really 559 used anywhere, but you can provide a helper menu and display them. 560 561 Raises: 562 TypeError: This widget is not bindable, i.e. widget.is_bindable == False. 563 """ 564 565 if not self.is_bindable: 566 raise TypeError(f"Widget of type {type(self)} does not accept bindings.") 567 568 if description is None: 569 description = f"Binding of {key} to {action}" 570 571 self._bindings[key] = (action, description) 572 573 def unbind(self, key: str) -> None: 574 """Unbinds the given key.""" 575 576 del self._bindings[key] 577 578 def execute_binding(self, key: Any, ignore_any: bool = False) -> bool: 579 """Executes a binding belonging to key, when present. 580 581 Use this method inside custom widget `handle_keys` methods, or to run a callback 582 without its corresponding key having been pressed. 583 584 Args: 585 key: Usually a string, indexing into the `_bindings` dictionary. These are the 586 same strings as defined in `Widget.bind`. 587 ignore_any: If set, `keys.ANY_KEY` bindings will not be executed. 588 589 Returns: 590 True if the binding was found, False otherwise. Bindings will always be 591 executed if they are found. 592 """ 593 594 # Execute special binding 595 if not ignore_any and keys.ANY_KEY in self._bindings: 596 method, _ = self._bindings[keys.ANY_KEY] 597 method(self, key) 598 599 if key in self._bindings: 600 method, _ = self._bindings[key] 601 method(self, key) 602 603 return True 604 605 return False 606 607 def select(self, index: int | None = None) -> None: 608 """Selects a part of this Widget. 609 610 Args: 611 index: The index to select. 612 613 Raises: 614 TypeError: This widget has no selectables, i.e. widget.is_selectable == False. 615 """ 616 617 if not self.is_selectable: 618 raise TypeError(f"Object of type {type(self)} has no selectables.") 619 620 if index is not None: 621 index = min(max(0, index), self.selectables_length - 1) 622 self.selected_index = index 623 624 def print(self) -> None: 625 """Prints this widget""" 626 627 for line in self.get_lines(): 628 print(line) 629 630 def debug(self) -> str: 631 """Returns identifiable information about this widget. 632 633 This method is used to easily differentiate between widgets. By default, all widget's 634 __repr__ method is an alias to this. The signature of each widget is used to generate 635 the return value. 636 637 Returns: 638 A string almost exactly matching the line of code that could have defined the widget. 639 640 Example return: 641 642 ``` 643 Container(Label(value="This is a label", padding=0), 644 Button(label="This is a button", padding=0), **attrs) 645 ``` 646 647 """ 648 649 constructor = "(" 650 for name in signature(getattr(self, "__init__")).parameters: 651 current = "" 652 if name == "attrs": 653 current += "**attrs" 654 continue 655 656 if len(constructor) > 1: 657 current += ", " 658 659 current += name 660 661 attr = getattr(self, name, None) 662 if attr is None: 663 continue 664 665 current += "=" 666 667 if isinstance(attr, str): 668 current += f'"{attr}"' 669 else: 670 current += str(attr) 671 672 constructor += current 673 674 constructor += ")" 675 676 return type(self).__name__ + constructor 677 678 679class Label(Widget): 680 """A Widget to display a string 681 682 By default, this widget uses `pytermgui.widgets.styles.MARKUP`. This 683 allows it to house markup text that is parsed before display, such as: 684 685 ```python3 686 import pytermgui as ptg 687 688 with ptg.alt_buffer(): 689 root = ptg.Container( 690 ptg.Label("[italic 141 bold]This is some [green]fancy [white inverse]text!") 691 ) 692 root.print() 693 ptg.getch() 694 ``` 695 696 <p style="text-align: center"> 697 <img 698 src="https://github.com/bczsalba/pytermgui/blob/master/assets/docs/widgets/label.png?raw=true" 699 width=100%> 700 </p> 701 """ 702 703 serialized = Widget.serialized + ["*value", "align", "padding"] 704 styles = w_styles.StyleManager(value=w_styles.MARKUP) 705 706 def __init__( 707 self, 708 value: str = "", 709 style: str | w_styles.StyleValue = "", 710 padding: int = 0, 711 non_first_padding: int = 0, 712 **attrs: Any, 713 ) -> None: 714 """Initializes a Label. 715 716 Args: 717 value: The value of this string. Using the default value style 718 (`pytermgui.widgets.styles.MARKUP`), 719 style: A pre-set value for self.styles.value. 720 padding: The number of space (" ") characters to prepend to every line after 721 line breaking. 722 non_first_padding: The number of space characters to prepend to every 723 non-first line of `get_lines`. This is applied on top of `padding`. 724 """ 725 726 super().__init__(**attrs) 727 728 self.value = value 729 self.padding = padding 730 self.non_first_padding = non_first_padding 731 self.width = real_length(value) + self.padding 732 733 if style != "": 734 self.styles.value = style 735 736 def get_lines(self) -> list[str]: 737 """Get lines representing this Label, breaking lines as necessary""" 738 739 lines = [] 740 limit = self.width - self.padding 741 broken = break_line( 742 self.styles.value(self.value), 743 limit=limit, 744 non_first_limit=limit - self.non_first_padding, 745 ) 746 747 for i, line in enumerate(broken): 748 if i == 0: 749 lines.append(self.padding * " " + line) 750 continue 751 752 lines.append(self.padding * " " + self.non_first_padding * " " + line) 753 754 return lines or [""] 755 756 757class ScrollableWidget(Widget): 758 """A widget with some scrolling helper methods. 759 760 This is not an implementation of the scrolling behaviour itself, just the 761 user-facing API for it. 762 763 It provides a `_scroll_offset` attribute, which is an integer describing the current 764 scroll state offset from the top, as well as some methods to modify the state.""" 765 766 def __init__(self, **attrs: Any) -> None: 767 """Initializes the scrollable widget.""" 768 769 super().__init__(**attrs) 770 771 self._max_scroll = 0 772 self._scroll_offset = 0 773 774 def scroll(self, offset: int) -> bool: 775 """Scrolls to given offset, returns the new scroll_offset. 776 777 Args: 778 offset: The amount to scroll by. Positive offsets scroll down, 779 negative up. 780 781 Returns: 782 True if the scroll offset changed, False otherwise. 783 """ 784 785 base = self._scroll_offset 786 787 self._scroll_offset = min( 788 max(0, self._scroll_offset + offset), self._max_scroll 789 ) 790 791 return base != self._scroll_offset 792 793 def scroll_end(self, end: int) -> int: 794 """Scrolls to either top or bottom end of this object. 795 796 Args: 797 end: The offset to scroll to. 0 goes to the very top, -1 to the 798 very bottom. 799 800 Returns: 801 True if the scroll offset changed, False otherwise. 802 """ 803 804 base = self._scroll_offset 805 806 if end == 0: 807 self._scroll_offset = 0 808 809 elif end == -1: 810 self._scroll_offset = self._max_scroll 811 812 return base != self._scroll_offset 813 814 def get_lines(self) -> list[str]: 815 ...
79class Widget: # pylint: disable=too-many-public-methods 80 """The base of the Widget system""" 81 82 set_style = classmethod(_set_obj_or_cls_style) 83 set_char = classmethod(_set_obj_or_cls_char) 84 85 styles = w_styles.StyleManager() 86 """Default styles for this class""" 87 88 chars: dict[str, w_styles.CharType] = {} 89 """Default characters for this class""" 90 91 keys: dict[str, set[str]] = {} 92 """Groups of keys that are used in `handle_key`""" 93 94 serialized: list[str] = [ 95 "id", 96 "pos", 97 "depth", 98 "width", 99 "height", 100 "selected_index", 101 "selectables_length", 102 ] 103 """Fields of widget that shall be serialized by `pytermgui.serializer.Serializer`""" 104 105 # This class is loaded after this module, 106 # and thus mypy doesn't see its existence. 107 _id_manager: Optional["_IDManager"] = None # type: ignore 108 109 is_bindable = False 110 """Allow binding support""" 111 112 size_policy = SizePolicy.get_default() 113 """`pytermgui.enums.SizePolicy` to set widget's width according to""" 114 115 parent_align = HorizontalAlignment.get_default() 116 """`pytermgui.enums.HorizontalAlignment` to align widget by""" 117 118 from_data: Callable[..., Widget | list[Widget] | None] 119 120 # We cannot import boxes here due to cyclic imports. 121 box: Any 122 123 def __init__(self, **attrs: Any) -> None: 124 """Initialize object""" 125 126 self.set_style = lambda key, value: _set_obj_or_cls_style(self, key, value) 127 self.set_char = lambda key, value: _set_obj_or_cls_char(self, key, value) 128 129 self.width = 1 130 self.height = 1 131 self.pos = self.terminal.origin 132 133 self.depth = 0 134 135 self.styles = type(self).styles.branch(self) 136 self.chars = type(self).chars.copy() 137 138 self.parent: Widget | None = None 139 self.selected_index: int | None = None 140 141 self._selectables_length = 0 142 self._id: Optional[str] = None 143 self._serialized_fields = type(self).serialized 144 self._bindings: dict[str | Type[MouseEvent], tuple[BoundCallback, str]] = {} 145 self._relative_width: float | None = None 146 self._previous_state: tuple[tuple[int, int], list[str]] | None = None 147 148 self.positioned_line_buffer: list[tuple[tuple[int, int], str]] = [] 149 150 for attr, value in attrs.items(): 151 setattr(self, attr, value) 152 153 def __repr__(self) -> str: 154 """Return repr string of this widget. 155 156 Returns: 157 Whatever this widget's `debug` method gives. 158 """ 159 160 return self.debug() 161 162 def __fancy_repr__(self) -> Generator[FancyYield, None, None]: 163 """Yields the repr of this object, then a preview of it.""" 164 165 yield self.debug() 166 yield "\n\n" 167 yield { 168 "text": "\n".join((line + reset() for line in self.get_lines())), 169 "highlight": False, 170 } 171 172 def __iter__(self) -> Iterator[Widget]: 173 """Return self for iteration""" 174 175 yield self 176 177 @property 178 def bindings(self) -> dict[str | Type[MouseEvent], tuple[BoundCallback, str]]: 179 """Gets a copy of the bindings internal dictionary. 180 181 Returns: 182 A copy of the internal bindings dictionary, such as: 183 184 ``` 185 { 186 "*": (star_callback, "This is a callback activated when '*' is pressed.") 187 } 188 ``` 189 """ 190 191 return self._bindings.copy() 192 193 @property 194 def id(self) -> Optional[str]: # pylint: disable=invalid-name 195 """Gets this widget's id property 196 197 Returns: 198 The id string if one is present, None otherwise. 199 """ 200 201 return self._id 202 203 @id.setter 204 def id(self, value: str) -> None: # pylint: disable=invalid-name 205 """Registers a widget to the Widget._id_manager. 206 207 If this widget already had an id, the old value is deregistered 208 before the new one is assigned. 209 210 Args: 211 value: The new id this widget will be registered as. 212 """ 213 214 if self._id == value: 215 return 216 217 manager = Widget._id_manager 218 assert manager is not None 219 220 old = manager.get_id(self) 221 if old is not None: 222 manager.deregister(old) 223 224 self._id = value 225 manager.register(self) 226 227 @property 228 def selectables_length(self) -> int: 229 """Gets how many selectables this widget contains. 230 231 Returns: 232 An integer describing the amount of selectables in this widget. 233 """ 234 235 return self._selectables_length 236 237 @property 238 def selectables(self) -> list[tuple[Widget, int]]: 239 """Gets a list of all selectables within this widget 240 241 Returns: 242 A list of tuples. In the default implementation this will be 243 a list of one tuple, containing a reference to `self`, as well 244 as the lowest index, 0. 245 """ 246 247 return [(self, 0)] 248 249 @property 250 def is_selectable(self) -> bool: 251 """Determines whether this widget has any selectables. 252 253 Returns: 254 A boolean, representing `self.selectables_length != 0`. 255 """ 256 257 return self.selectables_length != 0 258 259 @property 260 def static_width(self) -> int: 261 """Allows for a shorter way of setting a width, and SizePolicy.STATIC. 262 263 Args: 264 value: The new width integer. 265 266 Returns: 267 None, as this is setter only. 268 """ 269 270 return None # type: ignore 271 272 @static_width.setter 273 def static_width(self, value: int) -> None: 274 """See the static_width getter.""" 275 276 self.width = value 277 self.size_policy = SizePolicy.STATIC 278 279 @property 280 def relative_width(self) -> float | None: 281 """Sets this widget's relative width, and changes size_policy to RELATIVE. 282 283 The value is clamped to 1.0. 284 285 If a Container holds a width of 30, and it has a subwidget with a relative 286 width of 0.5, it will be resized to 15. 287 288 Args: 289 value: The multiplier to apply to the parent's width. 290 291 Returns: 292 The current relative_width. 293 """ 294 295 return self._relative_width 296 297 @relative_width.setter 298 def relative_width(self, value: float) -> None: 299 """See the relative_width getter.""" 300 301 self.size_policy = SizePolicy.RELATIVE 302 self._relative_width = min(1.0, value) 303 304 @property 305 def terminal(self) -> Terminal: 306 """Returns the current global terminal instance.""" 307 308 return get_terminal() 309 310 def get_change(self) -> WidgetChange | None: 311 """Determines whether widget lines changed since the last call to this function.""" 312 313 lines = self.get_lines() 314 315 if self._previous_state is None: 316 self._previous_state = (self.width, self.height), lines 317 return WidgetChange.LINES 318 319 lines = self.get_lines() 320 (old_width, old_height), old_lines = self._previous_state 321 322 self._previous_state = (self.width, self.height), lines 323 324 if old_width != self.width and old_height != self.height: 325 return WidgetChange.SIZE 326 327 if old_width != self.width: 328 return WidgetChange.WIDTH 329 330 if old_height != self.height: 331 return WidgetChange.HEIGHT 332 333 if old_lines != lines: 334 return WidgetChange.LINES 335 336 return None 337 338 def contains(self, pos: tuple[int, int]) -> bool: 339 """Determines whether widget contains `pos`. 340 341 Args: 342 pos: Position to compare. 343 344 Returns: 345 Boolean describing whether the position is inside 346 this widget. 347 """ 348 349 rect = self.pos, ( 350 self.pos[0] + self.width, 351 self.pos[1] + self.height, 352 ) 353 354 (left, top), (right, bottom) = rect 355 356 return left <= pos[0] < right and top <= pos[1] < bottom 357 358 def handle_mouse(self, event: MouseEvent) -> bool: 359 """Tries to call the most specific mouse handler function available. 360 361 This function looks for a set of mouse action handlers. Each handler follows 362 the format 363 364 on_{event_name} 365 366 For example, the handler triggered on MouseAction.LEFT_CLICK would be 367 `on_left_click`. If no handler is found nothing is done. 368 369 You can also define more general handlers, for example to group left & right 370 clicks you can use `on_click`, and to catch both up and down scroll you can use 371 `on_scroll`. General handlers are only used if they are the most specific ones, 372 i.e. there is no "specific" handler. 373 374 Args: 375 event: The event to handle. 376 377 Returns: 378 Whether the parent of this widget should treat it as one to "stick" events 379 to, e.g. to keep sending mouse events to it. One can "unstick" a widget by 380 returning False in the handler. 381 """ 382 383 def _get_names(action: MouseAction) -> tuple[str, ...]: 384 if action.value in ["hover", "release"]: 385 return (action.value,) 386 387 parts = action.value.split("_") 388 389 # left click & right click 390 if parts[0] in ["left", "right"]: 391 return (action.value, parts[1]) 392 393 # scroll up & down 394 return (action.value, parts[0]) 395 396 possible_names = _get_names(event.action) 397 for name in possible_names: 398 if hasattr(self, f"on_{name}"): 399 handle = getattr(self, f"on_{name}") 400 401 return handle(event) 402 403 return False 404 405 def handle_key(self, key: str) -> bool: 406 """Handles a mouse event, returning its success. 407 408 Args: 409 key: String representation of input string. 410 The `pytermgui.input.keys` object can be 411 used to retrieve special keys. 412 413 Returns: 414 A boolean describing whether the key was handled. 415 """ 416 417 return False and hasattr(self, key) 418 419 def serialize(self) -> dict[str, Any]: 420 """Serializes a widget. 421 422 The fields looked at are defined `Widget.serialized`. Note that 423 this method is not very commonly used at the moment, so it might 424 not have full functionality in non-nuclear widgets. 425 426 Returns: 427 Dictionary of widget attributes. The dictionary will always 428 have a `type` field. Any styles are converted into markup 429 strings during serialization, so they can be loaded again in 430 their original form. 431 432 Example return: 433 ``` 434 { 435 "type": "Label", 436 "value": "[210 bold]I am a title", 437 "parent_align": 0, 438 ... 439 } 440 ``` 441 """ 442 443 fields = self._serialized_fields 444 445 out: dict[str, Any] = {"type": type(self).__name__} 446 for key in fields: 447 # Detect styled values 448 if key.startswith("*"): 449 style = True 450 key = key[1:] 451 else: 452 style = False 453 454 value = getattr(self, key) 455 456 # Convert styled value into markup 457 if style: 458 style_call = self._get_style(key) 459 if isinstance(value, list): 460 out[key] = [get_markup(style_call(char)) for char in value] 461 else: 462 out[key] = get_markup(style_call(value)) 463 464 continue 465 466 out[key] = value 467 468 # The chars need to be handled separately 469 out["chars"] = {} 470 for key, value in self.chars.items(): 471 style_call = self._get_style(key) 472 473 if isinstance(value, list): 474 out["chars"][key] = [get_markup(style_call(char)) for char in value] 475 else: 476 out["chars"][key] = get_markup(style_call(value)) 477 478 return out 479 480 def copy(self) -> Widget: 481 """Creates a deep copy of this widget""" 482 483 return deepcopy(self) 484 485 def _get_style(self, key: str) -> w_styles.DepthlessStyleType: 486 """Gets style call from its key. 487 488 This is analogous to using `self.styles.{key}` 489 490 Args: 491 key: A key into the widget's style manager. 492 493 Returns: 494 A `pytermgui.styles.StyleCall` object containing the referenced 495 style. StyleCall objects should only be used internally inside a 496 widget. 497 498 Raises: 499 KeyError: Style key is invalid. 500 """ 501 502 return self.styles[key] 503 504 def _get_char(self, key: str) -> w_styles.CharType: 505 """Gets character from its key. 506 507 Args: 508 key: A key into the widget's chars dictionary. 509 510 Returns: 511 Either a `list[str]` or a simple `str`, depending on the character. 512 513 Raises: 514 KeyError: Style key is invalid. 515 """ 516 517 chars = self.chars[key] 518 if isinstance(chars, str): 519 return chars 520 521 return chars.copy() 522 523 def get_lines(self) -> list[str]: 524 """Gets lines representing this widget. 525 526 These lines have to be equal to the widget in length. All 527 widgets must provide this method. Make sure to keep it performant, 528 as it will be called very often, often multiple times per WindowManager frame. 529 530 Any longer actions should be done outside of this method, and only their 531 result should be looked up here. 532 533 Returns: 534 Nothing by default. 535 536 Raises: 537 NotImplementedError: As this method is required for **all** widgets, not 538 having it defined will raise NotImplementedError. 539 """ 540 541 raise NotImplementedError(f"get_lines() is not defined for type {type(self)}.") 542 543 def bind( 544 self, key: str, action: BoundCallback, description: Optional[str] = None 545 ) -> None: 546 """Binds an action to a keypress. 547 548 This function is only called by implementations above this layer. To use this 549 functionality use `pytermgui.window_manager.WindowManager`, or write your own 550 custom layer. 551 552 Special keys: 553 - keys.ANY_KEY: Any and all keypresses execute this binding. 554 - keys.MouseAction: Any and all mouse inputs execute this binding. 555 556 Args: 557 key: The key that the action will be bound to. 558 action: The action executed when the key is pressed. 559 description: An optional description for this binding. It is not really 560 used anywhere, but you can provide a helper menu and display them. 561 562 Raises: 563 TypeError: This widget is not bindable, i.e. widget.is_bindable == False. 564 """ 565 566 if not self.is_bindable: 567 raise TypeError(f"Widget of type {type(self)} does not accept bindings.") 568 569 if description is None: 570 description = f"Binding of {key} to {action}" 571 572 self._bindings[key] = (action, description) 573 574 def unbind(self, key: str) -> None: 575 """Unbinds the given key.""" 576 577 del self._bindings[key] 578 579 def execute_binding(self, key: Any, ignore_any: bool = False) -> bool: 580 """Executes a binding belonging to key, when present. 581 582 Use this method inside custom widget `handle_keys` methods, or to run a callback 583 without its corresponding key having been pressed. 584 585 Args: 586 key: Usually a string, indexing into the `_bindings` dictionary. These are the 587 same strings as defined in `Widget.bind`. 588 ignore_any: If set, `keys.ANY_KEY` bindings will not be executed. 589 590 Returns: 591 True if the binding was found, False otherwise. Bindings will always be 592 executed if they are found. 593 """ 594 595 # Execute special binding 596 if not ignore_any and keys.ANY_KEY in self._bindings: 597 method, _ = self._bindings[keys.ANY_KEY] 598 method(self, key) 599 600 if key in self._bindings: 601 method, _ = self._bindings[key] 602 method(self, key) 603 604 return True 605 606 return False 607 608 def select(self, index: int | None = None) -> None: 609 """Selects a part of this Widget. 610 611 Args: 612 index: The index to select. 613 614 Raises: 615 TypeError: This widget has no selectables, i.e. widget.is_selectable == False. 616 """ 617 618 if not self.is_selectable: 619 raise TypeError(f"Object of type {type(self)} has no selectables.") 620 621 if index is not None: 622 index = min(max(0, index), self.selectables_length - 1) 623 self.selected_index = index 624 625 def print(self) -> None: 626 """Prints this widget""" 627 628 for line in self.get_lines(): 629 print(line) 630 631 def debug(self) -> str: 632 """Returns identifiable information about this widget. 633 634 This method is used to easily differentiate between widgets. By default, all widget's 635 __repr__ method is an alias to this. The signature of each widget is used to generate 636 the return value. 637 638 Returns: 639 A string almost exactly matching the line of code that could have defined the widget. 640 641 Example return: 642 643 ``` 644 Container(Label(value="This is a label", padding=0), 645 Button(label="This is a button", padding=0), **attrs) 646 ``` 647 648 """ 649 650 constructor = "(" 651 for name in signature(getattr(self, "__init__")).parameters: 652 current = "" 653 if name == "attrs": 654 current += "**attrs" 655 continue 656 657 if len(constructor) > 1: 658 current += ", " 659 660 current += name 661 662 attr = getattr(self, name, None) 663 if attr is None: 664 continue 665 666 current += "=" 667 668 if isinstance(attr, str): 669 current += f'"{attr}"' 670 else: 671 current += str(attr) 672 673 constructor += current 674 675 constructor += ")" 676 677 return type(self).__name__ + constructor
The base of the Widget system
123 def __init__(self, **attrs: Any) -> None: 124 """Initialize object""" 125 126 self.set_style = lambda key, value: _set_obj_or_cls_style(self, key, value) 127 self.set_char = lambda key, value: _set_obj_or_cls_char(self, key, value) 128 129 self.width = 1 130 self.height = 1 131 self.pos = self.terminal.origin 132 133 self.depth = 0 134 135 self.styles = type(self).styles.branch(self) 136 self.chars = type(self).chars.copy() 137 138 self.parent: Widget | None = None 139 self.selected_index: int | None = None 140 141 self._selectables_length = 0 142 self._id: Optional[str] = None 143 self._serialized_fields = type(self).serialized 144 self._bindings: dict[str | Type[MouseEvent], tuple[BoundCallback, str]] = {} 145 self._relative_width: float | None = None 146 self._previous_state: tuple[tuple[int, int], list[str]] | None = None 147 148 self.positioned_line_buffer: list[tuple[tuple[int, int], str]] = [] 149 150 for attr, value in attrs.items(): 151 setattr(self, attr, value)
Initialize object
32def _set_obj_or_cls_style( 33 obj_or_cls: Type[Widget] | Widget, key: str, value: w_styles.StyleType 34) -> Type[Widget] | Widget: 35 """Sets a style for an object or class 36 37 Args: 38 obj_or_cls: The Widget instance or type to update. 39 key: The style key. 40 value: The new style. 41 42 Returns: 43 Type[Widget] | Widget: The updated class. 44 45 Raises: 46 See `pytermgui.widgets.styles.StyleManager`. 47 """ 48 49 obj_or_cls.styles[key] = value 50 51 return obj_or_cls
Sets a style for an object or class
Args
- obj_or_cls: The Widget instance or type to update.
- key: The style key.
- value: The new style.
Returns
Type[Widget] | Widget: The updated class.
Raises
54def _set_obj_or_cls_char( 55 obj_or_cls: Type[Widget] | Widget, key: str, value: w_styles.CharType 56) -> Type[Widget] | Widget: 57 """Sets a char for an object or class 58 59 Args: 60 obj_or_cls: The Widget instance or type to update. 61 key: The char key. 62 value: The new char. 63 64 Returns: 65 Type[Widget] | Widget: The updated class. 66 67 Raises: 68 KeyError: The char key provided is invalid. 69 """ 70 71 if not key in obj_or_cls.chars.keys(): 72 raise KeyError(f"Char {key} is not valid for {obj_or_cls}!") 73 74 obj_or_cls.chars[key] = value 75 76 return obj_or_cls
Sets a char for an object or class
Args
- obj_or_cls: The Widget instance or type to update.
- key: The char key.
- value: The new char.
Returns
Type[Widget] | Widget: The updated class.
Raises
- KeyError: The char key provided is invalid.
Fields of widget that shall be serialized by pytermgui.serializer.Serializer
pytermgui.enums.HorizontalAlignment
to align widget by
49def auto(data: Any, **widget_args: Any) -> Optional[Widget | list[Splitter]]: 50 """Creates a widget from specific data structures. 51 52 This conversion includes various widget classes, as well as some shorthands for 53 more complex objects. This method is called implicitly whenever a non-widget is 54 attempted to be added to a Widget. 55 56 57 Args: 58 data: The structure to convert. See below for formats. 59 **widget_args: Arguments passed straight to the widget constructor. 60 61 Returns: 62 The widget or list of widgets created, or None if the passed structure could 63 not be converted. 64 65 <br> 66 <details style="text-align: left"> 67 <summary style="all: revert; cursor: pointer">Data structures:</summary> 68 69 `pytermgui.widgets.base.Label`: 70 71 * Created from `str` 72 * Syntax example: `"Label value"` 73 74 `pytermgui.widgets.extra.Splitter`: 75 76 * Created from `tuple[Any]` 77 * Syntax example: `(YourWidget(), "auto_syntax", ...)` 78 79 `pytermgui.widgets.extra.Splitter` prompt: 80 81 * Created from `dict[Any, Any]` 82 * Syntax example: `{YourWidget(): "auto_syntax"}` 83 84 `pytermgui.widgets.buttons.Button`: 85 86 * Created from `list[str, pytermgui.widgets.buttons.MouseCallback]` 87 * Syntax example: `["Button label", lambda target, caller: ...]` 88 89 `pytermgui.widgets.buttons.Checkbox`: 90 91 * Created from `list[bool, Callable[[bool], Any]]` 92 * Syntax example: `[True, lambda checked: ...]` 93 94 `pytermgui.widgets.buttons.Toggle`: 95 96 * Created from `list[tuple[str, str], Callable[[str], Any]]` 97 * Syntax example: `[("On", "Off"), lambda new_value: ...]` 98 </details> 99 100 Example: 101 102 ```python3 103 from pytermgui import Container 104 form = ( 105 Container(id="form") 106 + "[157 bold]This is a title" 107 + "" 108 + {"[72 italic]Label1": "[210]Button1"} 109 + {"[72 italic]Label2": "[210]Button2"} 110 + {"[72 italic]Label3": "[210]Button3"} 111 + "" 112 + ["Submit", lambda _, button, your_submit_handler(button.parent)] 113 ) 114 ``` 115 """ 116 # In my opinion, returning immediately after construction is much more readable. 117 # pylint: disable=too-many-return-statements 118 119 # Nothing to do. 120 if isinstance(data, Widget): 121 # Set all **widget_args 122 for key, value in widget_args.items(): 123 setattr(data, key, value) 124 125 return data 126 127 # Label 128 if isinstance(data, str): 129 return Label(data, **widget_args) 130 131 # Splitter 132 if isinstance(data, tuple): 133 return Splitter(*data, **widget_args) 134 135 # buttons 136 if isinstance(data, list): 137 label = data[0] 138 onclick = None 139 if len(data) > 1: 140 onclick = data[1] 141 142 # Checkbox 143 if isinstance(label, bool): 144 return Checkbox(onclick, checked=label, **widget_args) 145 146 # Toggle 147 if isinstance(label, tuple): 148 assert len(label) == 2 149 return Toggle(label, onclick, **widget_args) 150 151 return Button(label, onclick, **widget_args) 152 153 # prompt splitter 154 if isinstance(data, dict): 155 rows: list[Splitter] = [] 156 157 for key, value in data.items(): 158 left = auto(key, parent_align=HorizontalAlignment.LEFT) 159 right = auto(value, parent_align=HorizontalAlignment.RIGHT) 160 161 rows.append(Splitter(left, right, **widget_args)) 162 163 if len(rows) == 1: 164 return rows[0] 165 166 return rows 167 168 return None
Creates a widget from specific data structures.
This conversion includes various widget classes, as well as some shorthands for more complex objects. This method is called implicitly whenever a non-widget is attempted to be added to a Widget.
Args
- data: The structure to convert. See below for formats.
- **widget_args: Arguments passed straight to the widget constructor.
Returns
The widget or list of widgets created, or None if the passed structure could not be converted.
Data structures:
- Created from
str
- Syntax example:
"Label value"
pytermgui.widgets.extra.Splitter
:
- Created from
tuple[Any]
- Syntax example:
(YourWidget(), "auto_syntax", ...)
pytermgui.widgets.extra.Splitter
prompt:
- Created from
dict[Any, Any]
- Syntax example:
{YourWidget(): "auto_syntax"}
pytermgui.widgets.buttons.Button
:
- Created from
list[str, pytermgui.widgets.buttons.MouseCallback]
- Syntax example:
["Button label", lambda target, caller: ...]
pytermgui.widgets.buttons.Checkbox
:
- Created from
list[bool, Callable[[bool], Any]]
- Syntax example:
[True, lambda checked: ...]
pytermgui.widgets.buttons.Toggle
:
- Created from
list[tuple[str, str], Callable[[str], Any]]
- Syntax example:
[("On", "Off"), lambda new_value: ...]
Example:
from pytermgui import Container
form = (
Container(id="form")
+ "[157 bold]This is a title"
+ ""
+ {"[72 italic]Label1": "[210]Button1"}
+ {"[72 italic]Label2": "[210]Button2"}
+ {"[72 italic]Label3": "[210]Button3"}
+ ""
+ ["Submit", lambda _, button, your_submit_handler(button.parent)]
)
Gets a copy of the bindings internal dictionary.
Returns
A copy of the internal bindings dictionary, such as:
{ "*": (star_callback, "This is a callback activated when '*' is pressed.") }
Gets this widget's id property
Returns
The id string if one is present, None otherwise.
Gets how many selectables this widget contains.
Returns
An integer describing the amount of selectables in this widget.
Gets a list of all selectables within this widget
Returns
A list of tuples. In the default implementation this will be a list of one tuple, containing a reference to
self
, as well as the lowest index, 0.
Determines whether this widget has any selectables.
Returns
A boolean, representing
self.selectables_length != 0
.
Allows for a shorter way of setting a width, and SizePolicy.STATIC.
Args
- value: The new width integer.
Returns
None, as this is setter only.
Sets this widget's relative width, and changes size_policy to RELATIVE.
The value is clamped to 1.0.
If a Container holds a width of 30, and it has a subwidget with a relative width of 0.5, it will be resized to 15.
Args
- value: The multiplier to apply to the parent's width.
Returns
The current relative_width.
310 def get_change(self) -> WidgetChange | None: 311 """Determines whether widget lines changed since the last call to this function.""" 312 313 lines = self.get_lines() 314 315 if self._previous_state is None: 316 self._previous_state = (self.width, self.height), lines 317 return WidgetChange.LINES 318 319 lines = self.get_lines() 320 (old_width, old_height), old_lines = self._previous_state 321 322 self._previous_state = (self.width, self.height), lines 323 324 if old_width != self.width and old_height != self.height: 325 return WidgetChange.SIZE 326 327 if old_width != self.width: 328 return WidgetChange.WIDTH 329 330 if old_height != self.height: 331 return WidgetChange.HEIGHT 332 333 if old_lines != lines: 334 return WidgetChange.LINES 335 336 return None
Determines whether widget lines changed since the last call to this function.
338 def contains(self, pos: tuple[int, int]) -> bool: 339 """Determines whether widget contains `pos`. 340 341 Args: 342 pos: Position to compare. 343 344 Returns: 345 Boolean describing whether the position is inside 346 this widget. 347 """ 348 349 rect = self.pos, ( 350 self.pos[0] + self.width, 351 self.pos[1] + self.height, 352 ) 353 354 (left, top), (right, bottom) = rect 355 356 return left <= pos[0] < right and top <= pos[1] < bottom
Determines whether widget contains pos
.
Args
- pos: Position to compare.
Returns
Boolean describing whether the position is inside this widget.
358 def handle_mouse(self, event: MouseEvent) -> bool: 359 """Tries to call the most specific mouse handler function available. 360 361 This function looks for a set of mouse action handlers. Each handler follows 362 the format 363 364 on_{event_name} 365 366 For example, the handler triggered on MouseAction.LEFT_CLICK would be 367 `on_left_click`. If no handler is found nothing is done. 368 369 You can also define more general handlers, for example to group left & right 370 clicks you can use `on_click`, and to catch both up and down scroll you can use 371 `on_scroll`. General handlers are only used if they are the most specific ones, 372 i.e. there is no "specific" handler. 373 374 Args: 375 event: The event to handle. 376 377 Returns: 378 Whether the parent of this widget should treat it as one to "stick" events 379 to, e.g. to keep sending mouse events to it. One can "unstick" a widget by 380 returning False in the handler. 381 """ 382 383 def _get_names(action: MouseAction) -> tuple[str, ...]: 384 if action.value in ["hover", "release"]: 385 return (action.value,) 386 387 parts = action.value.split("_") 388 389 # left click & right click 390 if parts[0] in ["left", "right"]: 391 return (action.value, parts[1]) 392 393 # scroll up & down 394 return (action.value, parts[0]) 395 396 possible_names = _get_names(event.action) 397 for name in possible_names: 398 if hasattr(self, f"on_{name}"): 399 handle = getattr(self, f"on_{name}") 400 401 return handle(event) 402 403 return False
Tries to call the most specific mouse handler function available.
This function looks for a set of mouse action handlers. Each handler follows the format
on_{event_name}
For example, the handler triggered on MouseAction.LEFT_CLICK would be
on_left_click
. If no handler is found nothing is done.
You can also define more general handlers, for example to group left & right
clicks you can use on_click
, and to catch both up and down scroll you can use
on_scroll
. General handlers are only used if they are the most specific ones,
i.e. there is no "specific" handler.
Args
- event: The event to handle.
Returns
Whether the parent of this widget should treat it as one to "stick" events to, e.g. to keep sending mouse events to it. One can "unstick" a widget by returning False in the handler.
405 def handle_key(self, key: str) -> bool: 406 """Handles a mouse event, returning its success. 407 408 Args: 409 key: String representation of input string. 410 The `pytermgui.input.keys` object can be 411 used to retrieve special keys. 412 413 Returns: 414 A boolean describing whether the key was handled. 415 """ 416 417 return False and hasattr(self, key)
Handles a mouse event, returning its success.
Args
- key: String representation of input string.
The
pytermgui.input.keys
object can be used to retrieve special keys.
Returns
A boolean describing whether the key was handled.
419 def serialize(self) -> dict[str, Any]: 420 """Serializes a widget. 421 422 The fields looked at are defined `Widget.serialized`. Note that 423 this method is not very commonly used at the moment, so it might 424 not have full functionality in non-nuclear widgets. 425 426 Returns: 427 Dictionary of widget attributes. The dictionary will always 428 have a `type` field. Any styles are converted into markup 429 strings during serialization, so they can be loaded again in 430 their original form. 431 432 Example return: 433 ``` 434 { 435 "type": "Label", 436 "value": "[210 bold]I am a title", 437 "parent_align": 0, 438 ... 439 } 440 ``` 441 """ 442 443 fields = self._serialized_fields 444 445 out: dict[str, Any] = {"type": type(self).__name__} 446 for key in fields: 447 # Detect styled values 448 if key.startswith("*"): 449 style = True 450 key = key[1:] 451 else: 452 style = False 453 454 value = getattr(self, key) 455 456 # Convert styled value into markup 457 if style: 458 style_call = self._get_style(key) 459 if isinstance(value, list): 460 out[key] = [get_markup(style_call(char)) for char in value] 461 else: 462 out[key] = get_markup(style_call(value)) 463 464 continue 465 466 out[key] = value 467 468 # The chars need to be handled separately 469 out["chars"] = {} 470 for key, value in self.chars.items(): 471 style_call = self._get_style(key) 472 473 if isinstance(value, list): 474 out["chars"][key] = [get_markup(style_call(char)) for char in value] 475 else: 476 out["chars"][key] = get_markup(style_call(value)) 477 478 return out
Serializes a widget.
The fields looked at are defined Widget.serialized
. Note that
this method is not very commonly used at the moment, so it might
not have full functionality in non-nuclear widgets.
Returns
Dictionary of widget attributes. The dictionary will always have a
type
field. Any styles are converted into markup strings during serialization, so they can be loaded again in their original form.Example return:
{ "type": "Label", "value": "[210 bold]I am a title", "parent_align": 0, ... }
480 def copy(self) -> Widget: 481 """Creates a deep copy of this widget""" 482 483 return deepcopy(self)
Creates a deep copy of this widget
523 def get_lines(self) -> list[str]: 524 """Gets lines representing this widget. 525 526 These lines have to be equal to the widget in length. All 527 widgets must provide this method. Make sure to keep it performant, 528 as it will be called very often, often multiple times per WindowManager frame. 529 530 Any longer actions should be done outside of this method, and only their 531 result should be looked up here. 532 533 Returns: 534 Nothing by default. 535 536 Raises: 537 NotImplementedError: As this method is required for **all** widgets, not 538 having it defined will raise NotImplementedError. 539 """ 540 541 raise NotImplementedError(f"get_lines() is not defined for type {type(self)}.")
Gets lines representing this widget.
These lines have to be equal to the widget in length. All widgets must provide this method. Make sure to keep it performant, as it will be called very often, often multiple times per WindowManager frame.
Any longer actions should be done outside of this method, and only their result should be looked up here.
Returns
Nothing by default.
Raises
- NotImplementedError: As this method is required for all widgets, not having it defined will raise NotImplementedError.
543 def bind( 544 self, key: str, action: BoundCallback, description: Optional[str] = None 545 ) -> None: 546 """Binds an action to a keypress. 547 548 This function is only called by implementations above this layer. To use this 549 functionality use `pytermgui.window_manager.WindowManager`, or write your own 550 custom layer. 551 552 Special keys: 553 - keys.ANY_KEY: Any and all keypresses execute this binding. 554 - keys.MouseAction: Any and all mouse inputs execute this binding. 555 556 Args: 557 key: The key that the action will be bound to. 558 action: The action executed when the key is pressed. 559 description: An optional description for this binding. It is not really 560 used anywhere, but you can provide a helper menu and display them. 561 562 Raises: 563 TypeError: This widget is not bindable, i.e. widget.is_bindable == False. 564 """ 565 566 if not self.is_bindable: 567 raise TypeError(f"Widget of type {type(self)} does not accept bindings.") 568 569 if description is None: 570 description = f"Binding of {key} to {action}" 571 572 self._bindings[key] = (action, description)
Binds an action to a keypress.
This function is only called by implementations above this layer. To use this
functionality use pytermgui.window_manager.WindowManager
, or write your own
custom layer.
Special keys:
- keys.ANY_KEY: Any and all keypresses execute this binding.
- keys.MouseAction: Any and all mouse inputs execute this binding.
Args
- key: The key that the action will be bound to.
- action: The action executed when the key is pressed.
- description: An optional description for this binding. It is not really used anywhere, but you can provide a helper menu and display them.
Raises
- TypeError: This widget is not bindable, i.e. widget.is_bindable == False.
574 def unbind(self, key: str) -> None: 575 """Unbinds the given key.""" 576 577 del self._bindings[key]
Unbinds the given key.
579 def execute_binding(self, key: Any, ignore_any: bool = False) -> bool: 580 """Executes a binding belonging to key, when present. 581 582 Use this method inside custom widget `handle_keys` methods, or to run a callback 583 without its corresponding key having been pressed. 584 585 Args: 586 key: Usually a string, indexing into the `_bindings` dictionary. These are the 587 same strings as defined in `Widget.bind`. 588 ignore_any: If set, `keys.ANY_KEY` bindings will not be executed. 589 590 Returns: 591 True if the binding was found, False otherwise. Bindings will always be 592 executed if they are found. 593 """ 594 595 # Execute special binding 596 if not ignore_any and keys.ANY_KEY in self._bindings: 597 method, _ = self._bindings[keys.ANY_KEY] 598 method(self, key) 599 600 if key in self._bindings: 601 method, _ = self._bindings[key] 602 method(self, key) 603 604 return True 605 606 return False
Executes a binding belonging to key, when present.
Use this method inside custom widget handle_keys
methods, or to run a callback
without its corresponding key having been pressed.
Args
- key: Usually a string, indexing into the
_bindings
dictionary. These are the same strings as defined inWidget.bind
. - ignore_any: If set,
keys.ANY_KEY
bindings will not be executed.
Returns
True if the binding was found, False otherwise. Bindings will always be executed if they are found.
608 def select(self, index: int | None = None) -> None: 609 """Selects a part of this Widget. 610 611 Args: 612 index: The index to select. 613 614 Raises: 615 TypeError: This widget has no selectables, i.e. widget.is_selectable == False. 616 """ 617 618 if not self.is_selectable: 619 raise TypeError(f"Object of type {type(self)} has no selectables.") 620 621 if index is not None: 622 index = min(max(0, index), self.selectables_length - 1) 623 self.selected_index = index
Selects a part of this Widget.
Args
- index: The index to select.
Raises
- TypeError: This widget has no selectables, i.e. widget.is_selectable == False.
625 def print(self) -> None: 626 """Prints this widget""" 627 628 for line in self.get_lines(): 629 print(line)
Prints this widget
631 def debug(self) -> str: 632 """Returns identifiable information about this widget. 633 634 This method is used to easily differentiate between widgets. By default, all widget's 635 __repr__ method is an alias to this. The signature of each widget is used to generate 636 the return value. 637 638 Returns: 639 A string almost exactly matching the line of code that could have defined the widget. 640 641 Example return: 642 643 ``` 644 Container(Label(value="This is a label", padding=0), 645 Button(label="This is a button", padding=0), **attrs) 646 ``` 647 648 """ 649 650 constructor = "(" 651 for name in signature(getattr(self, "__init__")).parameters: 652 current = "" 653 if name == "attrs": 654 current += "**attrs" 655 continue 656 657 if len(constructor) > 1: 658 current += ", " 659 660 current += name 661 662 attr = getattr(self, name, None) 663 if attr is None: 664 continue 665 666 current += "=" 667 668 if isinstance(attr, str): 669 current += f'"{attr}"' 670 else: 671 current += str(attr) 672 673 constructor += current 674 675 constructor += ")" 676 677 return type(self).__name__ + constructor
Returns identifiable information about this widget.
This method is used to easily differentiate between widgets. By default, all widget's __repr__ method is an alias to this. The signature of each widget is used to generate the return value.
Returns
A string almost exactly matching the line of code that could have defined the widget.
Example return:
Container(Label(value="This is a label", padding=0), Button(label="This is a button", padding=0), **attrs)
680class Label(Widget): 681 """A Widget to display a string 682 683 By default, this widget uses `pytermgui.widgets.styles.MARKUP`. This 684 allows it to house markup text that is parsed before display, such as: 685 686 ```python3 687 import pytermgui as ptg 688 689 with ptg.alt_buffer(): 690 root = ptg.Container( 691 ptg.Label("[italic 141 bold]This is some [green]fancy [white inverse]text!") 692 ) 693 root.print() 694 ptg.getch() 695 ``` 696 697 <p style="text-align: center"> 698 <img 699 src="https://github.com/bczsalba/pytermgui/blob/master/assets/docs/widgets/label.png?raw=true" 700 width=100%> 701 </p> 702 """ 703 704 serialized = Widget.serialized + ["*value", "align", "padding"] 705 styles = w_styles.StyleManager(value=w_styles.MARKUP) 706 707 def __init__( 708 self, 709 value: str = "", 710 style: str | w_styles.StyleValue = "", 711 padding: int = 0, 712 non_first_padding: int = 0, 713 **attrs: Any, 714 ) -> None: 715 """Initializes a Label. 716 717 Args: 718 value: The value of this string. Using the default value style 719 (`pytermgui.widgets.styles.MARKUP`), 720 style: A pre-set value for self.styles.value. 721 padding: The number of space (" ") characters to prepend to every line after 722 line breaking. 723 non_first_padding: The number of space characters to prepend to every 724 non-first line of `get_lines`. This is applied on top of `padding`. 725 """ 726 727 super().__init__(**attrs) 728 729 self.value = value 730 self.padding = padding 731 self.non_first_padding = non_first_padding 732 self.width = real_length(value) + self.padding 733 734 if style != "": 735 self.styles.value = style 736 737 def get_lines(self) -> list[str]: 738 """Get lines representing this Label, breaking lines as necessary""" 739 740 lines = [] 741 limit = self.width - self.padding 742 broken = break_line( 743 self.styles.value(self.value), 744 limit=limit, 745 non_first_limit=limit - self.non_first_padding, 746 ) 747 748 for i, line in enumerate(broken): 749 if i == 0: 750 lines.append(self.padding * " " + line) 751 continue 752 753 lines.append(self.padding * " " + self.non_first_padding * " " + line) 754 755 return lines or [""]
A Widget to display a string
By default, this widget uses pytermgui.widgets.styles.MARKUP
. This
allows it to house markup text that is parsed before display, such as:
import pytermgui as ptg
with ptg.alt_buffer():
root = ptg.Container(
ptg.Label("[italic 141 bold]This is some [green]fancy [white inverse]text!")
)
root.print()
ptg.getch()
707 def __init__( 708 self, 709 value: str = "", 710 style: str | w_styles.StyleValue = "", 711 padding: int = 0, 712 non_first_padding: int = 0, 713 **attrs: Any, 714 ) -> None: 715 """Initializes a Label. 716 717 Args: 718 value: The value of this string. Using the default value style 719 (`pytermgui.widgets.styles.MARKUP`), 720 style: A pre-set value for self.styles.value. 721 padding: The number of space (" ") characters to prepend to every line after 722 line breaking. 723 non_first_padding: The number of space characters to prepend to every 724 non-first line of `get_lines`. This is applied on top of `padding`. 725 """ 726 727 super().__init__(**attrs) 728 729 self.value = value 730 self.padding = padding 731 self.non_first_padding = non_first_padding 732 self.width = real_length(value) + self.padding 733 734 if style != "": 735 self.styles.value = style
Initializes a Label.
Args
- value: The value of this string. Using the default value style
(
pytermgui.widgets.styles.MARKUP
), - style: A pre-set value for self.styles.value.
- padding: The number of space (" ") characters to prepend to every line after line breaking.
- non_first_padding: The number of space characters to prepend to every
non-first line of
get_lines
. This is applied on top ofpadding
.
Fields of widget that shall be serialized by pytermgui.serializer.Serializer
737 def get_lines(self) -> list[str]: 738 """Get lines representing this Label, breaking lines as necessary""" 739 740 lines = [] 741 limit = self.width - self.padding 742 broken = break_line( 743 self.styles.value(self.value), 744 limit=limit, 745 non_first_limit=limit - self.non_first_padding, 746 ) 747 748 for i, line in enumerate(broken): 749 if i == 0: 750 lines.append(self.padding * " " + line) 751 continue 752 753 lines.append(self.padding * " " + self.non_first_padding * " " + line) 754 755 return lines or [""]
Get lines representing this Label, breaking lines as necessary