pytermgui.window_manager.manager
The WindowManager class, whos job it is to move, control and update windows,
while letting Compositor
draw them.
1"""The WindowManager class, whos job it is to move, control and update windows, 2while letting `Compositor` draw them.""" 3 4from __future__ import annotations 5 6from enum import Enum 7from enum import auto as _auto 8from typing import Any, Iterator, Type 9 10from ..animations import Animation, AttrAnimation, FloatAnimation, animator 11from ..ansi_interface import MouseAction, MouseEvent 12from ..colors import str_to_color 13from ..context_managers import MouseTranslator, alt_buffer, mouse_handler 14from ..enums import Overflow 15from ..input import getch 16from ..regex import real_length 17from ..terminal import terminal 18from ..widgets import Container, Widget 19from ..widgets.base import BoundCallback 20from .compositor import Compositor 21from .layouts import Layout 22from .window import Window 23 24 25def _center_during_animation(animation: AttrAnimation) -> None: 26 """Centers a window, when applicable, while animating.""" 27 28 window = animation.target 29 assert isinstance(window, Window), window 30 31 if window.centered_axis is not None: 32 window.center() 33 34 35class Edge(Enum): 36 """Enum for window edges.""" 37 38 LEFT = _auto() 39 TOP = _auto() 40 RIGHT = _auto() 41 BOTTOM = _auto() 42 43 44class WindowManager(Widget): # pylint: disable=too-many-instance-attributes 45 """The manager of windows. 46 47 This class can be used, or even subclassed in order to create full-screen applications, 48 using the `pytermgui.window_manager.window.Window` class and the general Widget API. 49 """ 50 51 is_bindable = True 52 53 focusing_actions = (MouseAction.LEFT_CLICK, MouseAction.RIGHT_CLICK) 54 """These mouse actions will focus the window they are acted upon.""" 55 56 autorun = True 57 58 def __init__( 59 self, 60 *, 61 layout_type: Type[Layout] = Layout, 62 framerate: int = 60, 63 autorun: bool | None = None, 64 ) -> None: 65 """Initialize the manager.""" 66 67 super().__init__() 68 69 self._is_running = False 70 self._windows: list[Window] = [] 71 self._bindings: dict[str | Type[MouseEvent], tuple[BoundCallback, str]] = {} 72 73 self.focused: Window | None = None 74 75 if autorun is not None: 76 self.autorun = autorun 77 78 self.layout = layout_type() 79 self.compositor = Compositor(self._windows, framerate=framerate) 80 self.mouse_translator: MouseTranslator | None = None 81 82 self._mouse_target: Window | None = None 83 self._drag_offsets: tuple[int, int] = (0, 0) 84 self._drag_target: tuple[Window, Edge] | None = None 85 86 # This isn't quite implemented at the moment. 87 self.restrict_within_bounds = True 88 89 terminal.subscribe(terminal.RESIZE, self.on_resize) 90 91 def __iadd__(self, other: object) -> WindowManager: 92 """Adds a window to the manager.""" 93 94 if not isinstance(other, Window): 95 raise ValueError("You may only add windows to a WindowManager.") 96 97 return self.add(other) 98 99 def __isub__(self, other: object) -> WindowManager: 100 """Removes a window from the manager.""" 101 102 if not isinstance(other, Window): 103 raise ValueError("You may only add windows to a WindowManager.") 104 105 return self.remove(other) 106 107 def __enter__(self) -> WindowManager: 108 """Starts context manager.""" 109 110 return self 111 112 def __exit__(self, _: Any, exception: Exception, __: Any) -> bool: 113 """Ends context manager.""" 114 115 # Run the manager if it hasnt been run before. 116 if self.autorun and exception is None and self.mouse_translator is None: 117 self.run() 118 119 if exception is not None: 120 self.stop() 121 raise exception 122 123 return True 124 125 def __iter__(self) -> Iterator[Window]: 126 """Iterates this manager's windows.""" 127 128 return iter(self._windows) 129 130 def _run_input_loop(self) -> None: 131 """The main input loop of the WindowManager.""" 132 133 while self._is_running: 134 key = getch(interrupts=False) 135 136 if key == chr(3): 137 self.stop() 138 break 139 140 if self.handle_key(key): 141 continue 142 143 self.process_mouse(key) 144 145 def get_lines(self) -> list[str]: 146 """Gets the empty list.""" 147 148 # TODO: Allow using WindowManager as a widget. 149 150 return [] 151 152 def clear_cache(self, window: Window) -> None: 153 """Clears the compositor's cache related to the given window.""" 154 155 self.compositor.clear_cache(window) 156 157 def on_resize(self, size: tuple[int, int]) -> None: 158 """Correctly updates window positions & prints when terminal gets resized. 159 160 Args: 161 size: The new terminal size. 162 """ 163 164 width, height = size 165 166 for window in self._windows: 167 newx = max(0, min(window.pos[0], width - window.width)) 168 newy = max(0, min(window.pos[1], height - window.height + 1)) 169 170 window.pos = (newx, newy) 171 172 self.layout.apply() 173 self.compositor.redraw() 174 175 def run(self, mouse_events: list[str] | None = None) -> None: 176 """Starts the WindowManager. 177 178 Args: 179 mouse_events: A list of mouse event types to listen to. See 180 `pytermgui.ansi_interface.report_mouse` for more information. 181 Defaults to `["press_hold", "hover"]`. 182 183 Returns: 184 The WindowManager's compositor instance. 185 """ 186 187 self._is_running = True 188 189 if mouse_events is None: 190 mouse_events = ["press_hold", "hover"] 191 192 with alt_buffer(cursor=False, echo=False): 193 with mouse_handler(mouse_events, "decimal_xterm") as translate: 194 self.mouse_translator = translate 195 self.compositor.run() 196 197 self._run_input_loop() 198 199 def stop(self) -> None: 200 """Stops the WindowManager and its compositor.""" 201 202 self.compositor.stop() 203 self._is_running = False 204 205 def add( 206 self, window: Window, assign: str | bool = True, animate: bool = True 207 ) -> WindowManager: 208 """Adds a window to the manager. 209 210 Args: 211 window: The window to add. 212 assign: The name of the slot the new window should be assigned to, or a 213 boolean. If it is given a str, it is treated as the name of a slot. When 214 given True, the next non-filled slot will be assigned, and when given 215 False no assignment will be done. 216 animate: If set, an animation will be played on the window once it's added. 217 """ 218 219 self._windows.insert(0, window) 220 window.manager = self 221 222 if assign: 223 if isinstance(assign, str): 224 getattr(self.layout, assign).content = window 225 226 elif len(self._windows) <= len(self.layout.slots): 227 self.layout.assign(window, index=len(self._windows) - 1) 228 229 self.layout.apply() 230 231 # New windows take focus-precedence over already 232 # existing ones, even if they are modal. 233 self.focus(window) 234 235 if not animate: 236 return self 237 238 if window.height > 1: 239 animator.animate_attr( 240 target=window, 241 attr="height", 242 start=0, 243 end=window.height, 244 duration=300, 245 on_step=_center_during_animation, 246 ) 247 248 return self 249 250 def remove( 251 self, 252 window: Window, 253 autostop: bool = True, 254 animate: bool = True, 255 ) -> WindowManager: 256 """Removes a window from the manager. 257 258 Args: 259 window: The window to remove. 260 autostop: If set, the manager will be stopped if the length of its windows 261 hits 0. 262 """ 263 264 def _on_finish(_: AttrAnimation | None) -> bool: 265 self._windows.remove(window) 266 267 if autostop and len(self._windows) == 0: 268 self.stop() 269 else: 270 self.focus(self._windows[0]) 271 272 return True 273 274 if not animate: 275 _on_finish(None) 276 return self 277 278 animator.animate_attr( 279 target=window, 280 attr="height", 281 end=0, 282 duration=300, 283 on_step=_center_during_animation, 284 on_finish=_on_finish, 285 ) 286 287 return self 288 289 def focus(self, window: Window | None) -> None: 290 """Focuses a window by moving it to the first index in _windows.""" 291 292 if self.focused is not None: 293 self.focused.blur() 294 295 self.focused = window 296 297 if window is not None: 298 self._windows.remove(window) 299 self._windows.insert(0, window) 300 301 window.focus() 302 303 def focus_next(self) -> Window | None: 304 """Focuses the next window in focus order, looping to first at the end.""" 305 306 if self.focused is None: 307 self.focus(self._windows[0]) 308 return self.focused 309 310 index = self._windows.index(self.focused) 311 if index == len(self._windows) - 1: 312 index = 0 313 314 window = self._windows[index] 315 traversed = 0 316 while window.is_persistent or window is self.focused: 317 if index >= len(self._windows): 318 index = 0 319 320 window = self._windows[index] 321 322 index += 1 323 traversed += 1 324 if traversed >= len(self._windows): 325 return self.focused 326 327 self.focus(self._windows[index]) 328 329 return self.focused 330 331 def handle_key(self, key: str) -> bool: 332 """Processes a keypress. 333 334 Args: 335 key: The key to handle. 336 337 Returns: 338 True if the given key could be processed, False otherwise. 339 """ 340 341 # Apply WindowManager bindings 342 if self.execute_binding(key): 343 return True 344 345 # Apply focused window binding, or send to InputField 346 if self.focused is not None: 347 if self.focused.execute_binding(key): 348 return True 349 350 if self.focused.handle_key(key): 351 return True 352 353 return False 354 355 # I prefer having the _click, _drag and _release helpers within this function, for 356 # easier readability. 357 def process_mouse(self, key: str) -> None: # pylint: disable=too-many-statements 358 """Processes (potential) mouse input. 359 360 Args: 361 key: Input to handle. 362 """ 363 364 window: Window 365 366 def _clamp_pos(pos: tuple[int, int], index: int) -> int: 367 """Clamp a value using index to address x/y & width/height""" 368 369 offset = self._drag_offsets[index] 370 371 # TODO: This -2 is a very magical number. Not good. 372 maximum = terminal.size[index] - ((window.width, window.height)[index] - 2) 373 374 start_margin_index = abs(index - 1) 375 376 if self.restrict_within_bounds: 377 return max( 378 index + terminal.margins[start_margin_index], 379 min( 380 pos[index] - offset, 381 maximum 382 - terminal.margins[start_margin_index + 2] 383 - terminal.origin[index], 384 ), 385 ) 386 387 return pos[index] - offset 388 389 def _click(pos: tuple[int, int], window: Window) -> bool: 390 """Process clicking a window.""" 391 392 left, top, right, bottom = window.rect 393 borders = window.chars.get("border", [" "] * 4) 394 395 if real_length(borders[1]) > 0 and pos[1] == top and left <= pos[0] < right: 396 self._drag_target = (window, Edge.TOP) 397 398 elif ( 399 real_length(borders[3]) > 0 400 and pos[1] == bottom - 1 401 and left <= pos[0] < right 402 ): 403 self._drag_target = (window, Edge.BOTTOM) 404 405 elif ( 406 real_length(borders[0]) > 0 407 and pos[0] == left 408 and top <= pos[1] < bottom 409 ): 410 self._drag_target = (window, Edge.LEFT) 411 412 elif ( 413 real_length(borders[2]) > 0 414 and pos[0] == right - 1 415 and top <= pos[1] < bottom 416 ): 417 self._drag_target = (window, Edge.RIGHT) 418 419 else: 420 return False 421 422 self._drag_offsets = ( 423 pos[0] - window.pos[0], 424 pos[1] - window.pos[1], 425 ) 426 427 return True 428 429 def _drag(pos: tuple[int, int], window: Window) -> bool: 430 """Process dragging a window""" 431 432 if self._drag_target is None: 433 return False 434 435 target_window, edge = self._drag_target 436 handled = False 437 438 if window is not target_window: 439 return False 440 441 left, top, right, bottom = window.rect 442 443 if not window.is_static and edge is Edge.TOP: 444 window.pos = ( 445 _clamp_pos(pos, 0), 446 _clamp_pos(pos, 1), 447 ) 448 449 handled = True 450 451 # TODO: Why are all these arbitrary offsets needed? 452 elif not window.is_noresize: 453 if edge is Edge.RIGHT: 454 window.rect = (left, top, pos[0] + 1, bottom) 455 handled = True 456 457 elif edge is Edge.LEFT: 458 window.rect = (pos[0], top, right, bottom) 459 handled = True 460 461 elif edge is Edge.BOTTOM: 462 window.rect = (left, top, right, pos[1] + 1) 463 handled = True 464 465 if handled: 466 window.is_dirty = True 467 self.compositor.set_redraw() 468 469 return handled 470 471 def _release(_: tuple[int, int], __: Window) -> bool: 472 """Process release of key""" 473 474 self._drag_target = None 475 476 # This return False so Window can handle the mouse action as well, 477 # as not much is done in this callback. 478 return False 479 480 handlers = { 481 MouseAction.LEFT_CLICK: _click, 482 MouseAction.LEFT_DRAG: _drag, 483 MouseAction.RELEASE: _release, 484 } 485 486 translate = self.mouse_translator 487 event_list = None if translate is None else translate(key) 488 489 if event_list is None: 490 return 491 492 for event in event_list: 493 # Ignore null-events 494 if event is None: 495 continue 496 497 for window in self._windows: 498 contains = window.contains(event.position) 499 500 if event.action in self.focusing_actions: 501 self.focus(window) 502 503 if event.action in handlers and handlers[event.action]( 504 event.position, window 505 ): 506 break 507 508 if contains: 509 if self._mouse_target is not None: 510 self._mouse_target.handle_mouse( 511 MouseEvent(MouseAction.RELEASE, event.position) 512 ) 513 514 self._mouse_target = window 515 window.handle_mouse(event) 516 break 517 518 if window.is_modal: 519 break 520 521 # Unset drag_target if no windows received the input 522 else: 523 self._drag_target = None 524 if self._mouse_target is not None: 525 self._mouse_target.handle_mouse( 526 MouseEvent(MouseAction.RELEASE, event.position) 527 ) 528 529 self._mouse_target = None 530 531 def screenshot(self, title: str, filename: str = "screenshot.svg") -> None: 532 """Takes a screenshot of the current state. 533 534 See `pytermgui.exporters.to_svg` for more information. 535 536 Args: 537 filename: The name of the file. 538 """ 539 540 self.compositor.capture(title=title, filename=filename) 541 542 def show_positions(self) -> None: 543 """Shows the positions of each Window's widgets.""" 544 545 def _show_positions(widget, color_base: int = 60) -> None: 546 """Show positions of widget.""" 547 548 if isinstance(widget, Container): 549 for i, subwidget in enumerate(widget): 550 _show_positions(subwidget, color_base + i) 551 552 return 553 554 if not widget.is_selectable: 555 return 556 557 debug = widget.debug() 558 color = str_to_color(f"@{color_base}") 559 buff = color(" ", reset=False) 560 561 for i in range(min(widget.width, real_length(debug)) - 1): 562 buff += debug[i] 563 564 self.terminal.write(buff, pos=widget.pos) 565 566 for widget in self._windows: 567 _show_positions(widget) 568 self.terminal.flush() 569 570 getch() 571 572 def alert(self, *items, center: bool = True, **attributes) -> Window: 573 """Creates a modal popup of the given elements and attributes. 574 575 Args: 576 *items: All widget-convertable objects passed as children of the new window. 577 center: If set, `pytermgui.window_manager.window.center` is called on the window. 578 **attributes: kwargs passed as the new window's attributes. 579 """ 580 581 window = Window(*items, is_modal=True, **attributes) 582 583 if center: 584 window.center() 585 586 self.add(window, assign=False) 587 588 return window 589 590 def toast( 591 self, 592 *items, 593 offset: int = 0, 594 duration: int = 300, 595 delay: int = 1000, 596 **attributes, 597 ) -> Window: 598 """Creates a Material UI-inspired toast window of the given elements and attributes. 599 600 Args: 601 *items: All widget-convertable objects passed as children of the new window. 602 delay: The amount of time before the window will start animating out. 603 **attributes: kwargs passed as the new window's attributes. 604 """ 605 606 # pylint: disable=no-value-for-parameter 607 608 toast = Window(*items, is_noblur=True, **attributes) 609 610 target_height = toast.height 611 toast.overflow = Overflow.HIDE 612 613 def _finish(_: Animation) -> None: 614 self.remove(toast, animate=False) 615 616 def _progressively_show(anim: Animation, invert: bool = False) -> bool: 617 height = int(anim.state * target_height) 618 619 toast.center() 620 621 if invert: 622 toast.height = target_height - 1 - height 623 toast.pos = ( 624 toast.pos[0], 625 self.terminal.height - toast.height + 1 - offset, 626 ) 627 return False 628 629 toast.height = height 630 toast.pos = (toast.pos[0], self.terminal.height - toast.height + 1 - offset) 631 632 return False 633 634 def _animate_toast_out(_: Animation) -> None: 635 animator.schedule( 636 FloatAnimation( 637 delay, 638 on_finish=lambda *_: animator.schedule( 639 FloatAnimation( 640 duration, 641 on_step=lambda anim: _progressively_show(anim, invert=True), 642 on_finish=_finish, 643 ) 644 ), 645 ) 646 ) 647 648 leadup = FloatAnimation( 649 duration, on_step=_progressively_show, on_finish=_animate_toast_out 650 ) 651 652 # pylint: enable=no-value-for-parameter 653 654 self.add(toast.center(), animate=False, assign=False) 655 self.focus(toast) 656 animator.schedule(leadup) 657 658 return toast
36class Edge(Enum): 37 """Enum for window edges.""" 38 39 LEFT = _auto() 40 TOP = _auto() 41 RIGHT = _auto() 42 BOTTOM = _auto()
Enum for window edges.
Inherited Members
- enum.Enum
- name
- value
45class WindowManager(Widget): # pylint: disable=too-many-instance-attributes 46 """The manager of windows. 47 48 This class can be used, or even subclassed in order to create full-screen applications, 49 using the `pytermgui.window_manager.window.Window` class and the general Widget API. 50 """ 51 52 is_bindable = True 53 54 focusing_actions = (MouseAction.LEFT_CLICK, MouseAction.RIGHT_CLICK) 55 """These mouse actions will focus the window they are acted upon.""" 56 57 autorun = True 58 59 def __init__( 60 self, 61 *, 62 layout_type: Type[Layout] = Layout, 63 framerate: int = 60, 64 autorun: bool | None = None, 65 ) -> None: 66 """Initialize the manager.""" 67 68 super().__init__() 69 70 self._is_running = False 71 self._windows: list[Window] = [] 72 self._bindings: dict[str | Type[MouseEvent], tuple[BoundCallback, str]] = {} 73 74 self.focused: Window | None = None 75 76 if autorun is not None: 77 self.autorun = autorun 78 79 self.layout = layout_type() 80 self.compositor = Compositor(self._windows, framerate=framerate) 81 self.mouse_translator: MouseTranslator | None = None 82 83 self._mouse_target: Window | None = None 84 self._drag_offsets: tuple[int, int] = (0, 0) 85 self._drag_target: tuple[Window, Edge] | None = None 86 87 # This isn't quite implemented at the moment. 88 self.restrict_within_bounds = True 89 90 terminal.subscribe(terminal.RESIZE, self.on_resize) 91 92 def __iadd__(self, other: object) -> WindowManager: 93 """Adds a window to the manager.""" 94 95 if not isinstance(other, Window): 96 raise ValueError("You may only add windows to a WindowManager.") 97 98 return self.add(other) 99 100 def __isub__(self, other: object) -> WindowManager: 101 """Removes a window from the manager.""" 102 103 if not isinstance(other, Window): 104 raise ValueError("You may only add windows to a WindowManager.") 105 106 return self.remove(other) 107 108 def __enter__(self) -> WindowManager: 109 """Starts context manager.""" 110 111 return self 112 113 def __exit__(self, _: Any, exception: Exception, __: Any) -> bool: 114 """Ends context manager.""" 115 116 # Run the manager if it hasnt been run before. 117 if self.autorun and exception is None and self.mouse_translator is None: 118 self.run() 119 120 if exception is not None: 121 self.stop() 122 raise exception 123 124 return True 125 126 def __iter__(self) -> Iterator[Window]: 127 """Iterates this manager's windows.""" 128 129 return iter(self._windows) 130 131 def _run_input_loop(self) -> None: 132 """The main input loop of the WindowManager.""" 133 134 while self._is_running: 135 key = getch(interrupts=False) 136 137 if key == chr(3): 138 self.stop() 139 break 140 141 if self.handle_key(key): 142 continue 143 144 self.process_mouse(key) 145 146 def get_lines(self) -> list[str]: 147 """Gets the empty list.""" 148 149 # TODO: Allow using WindowManager as a widget. 150 151 return [] 152 153 def clear_cache(self, window: Window) -> None: 154 """Clears the compositor's cache related to the given window.""" 155 156 self.compositor.clear_cache(window) 157 158 def on_resize(self, size: tuple[int, int]) -> None: 159 """Correctly updates window positions & prints when terminal gets resized. 160 161 Args: 162 size: The new terminal size. 163 """ 164 165 width, height = size 166 167 for window in self._windows: 168 newx = max(0, min(window.pos[0], width - window.width)) 169 newy = max(0, min(window.pos[1], height - window.height + 1)) 170 171 window.pos = (newx, newy) 172 173 self.layout.apply() 174 self.compositor.redraw() 175 176 def run(self, mouse_events: list[str] | None = None) -> None: 177 """Starts the WindowManager. 178 179 Args: 180 mouse_events: A list of mouse event types to listen to. See 181 `pytermgui.ansi_interface.report_mouse` for more information. 182 Defaults to `["press_hold", "hover"]`. 183 184 Returns: 185 The WindowManager's compositor instance. 186 """ 187 188 self._is_running = True 189 190 if mouse_events is None: 191 mouse_events = ["press_hold", "hover"] 192 193 with alt_buffer(cursor=False, echo=False): 194 with mouse_handler(mouse_events, "decimal_xterm") as translate: 195 self.mouse_translator = translate 196 self.compositor.run() 197 198 self._run_input_loop() 199 200 def stop(self) -> None: 201 """Stops the WindowManager and its compositor.""" 202 203 self.compositor.stop() 204 self._is_running = False 205 206 def add( 207 self, window: Window, assign: str | bool = True, animate: bool = True 208 ) -> WindowManager: 209 """Adds a window to the manager. 210 211 Args: 212 window: The window to add. 213 assign: The name of the slot the new window should be assigned to, or a 214 boolean. If it is given a str, it is treated as the name of a slot. When 215 given True, the next non-filled slot will be assigned, and when given 216 False no assignment will be done. 217 animate: If set, an animation will be played on the window once it's added. 218 """ 219 220 self._windows.insert(0, window) 221 window.manager = self 222 223 if assign: 224 if isinstance(assign, str): 225 getattr(self.layout, assign).content = window 226 227 elif len(self._windows) <= len(self.layout.slots): 228 self.layout.assign(window, index=len(self._windows) - 1) 229 230 self.layout.apply() 231 232 # New windows take focus-precedence over already 233 # existing ones, even if they are modal. 234 self.focus(window) 235 236 if not animate: 237 return self 238 239 if window.height > 1: 240 animator.animate_attr( 241 target=window, 242 attr="height", 243 start=0, 244 end=window.height, 245 duration=300, 246 on_step=_center_during_animation, 247 ) 248 249 return self 250 251 def remove( 252 self, 253 window: Window, 254 autostop: bool = True, 255 animate: bool = True, 256 ) -> WindowManager: 257 """Removes a window from the manager. 258 259 Args: 260 window: The window to remove. 261 autostop: If set, the manager will be stopped if the length of its windows 262 hits 0. 263 """ 264 265 def _on_finish(_: AttrAnimation | None) -> bool: 266 self._windows.remove(window) 267 268 if autostop and len(self._windows) == 0: 269 self.stop() 270 else: 271 self.focus(self._windows[0]) 272 273 return True 274 275 if not animate: 276 _on_finish(None) 277 return self 278 279 animator.animate_attr( 280 target=window, 281 attr="height", 282 end=0, 283 duration=300, 284 on_step=_center_during_animation, 285 on_finish=_on_finish, 286 ) 287 288 return self 289 290 def focus(self, window: Window | None) -> None: 291 """Focuses a window by moving it to the first index in _windows.""" 292 293 if self.focused is not None: 294 self.focused.blur() 295 296 self.focused = window 297 298 if window is not None: 299 self._windows.remove(window) 300 self._windows.insert(0, window) 301 302 window.focus() 303 304 def focus_next(self) -> Window | None: 305 """Focuses the next window in focus order, looping to first at the end.""" 306 307 if self.focused is None: 308 self.focus(self._windows[0]) 309 return self.focused 310 311 index = self._windows.index(self.focused) 312 if index == len(self._windows) - 1: 313 index = 0 314 315 window = self._windows[index] 316 traversed = 0 317 while window.is_persistent or window is self.focused: 318 if index >= len(self._windows): 319 index = 0 320 321 window = self._windows[index] 322 323 index += 1 324 traversed += 1 325 if traversed >= len(self._windows): 326 return self.focused 327 328 self.focus(self._windows[index]) 329 330 return self.focused 331 332 def handle_key(self, key: str) -> bool: 333 """Processes a keypress. 334 335 Args: 336 key: The key to handle. 337 338 Returns: 339 True if the given key could be processed, False otherwise. 340 """ 341 342 # Apply WindowManager bindings 343 if self.execute_binding(key): 344 return True 345 346 # Apply focused window binding, or send to InputField 347 if self.focused is not None: 348 if self.focused.execute_binding(key): 349 return True 350 351 if self.focused.handle_key(key): 352 return True 353 354 return False 355 356 # I prefer having the _click, _drag and _release helpers within this function, for 357 # easier readability. 358 def process_mouse(self, key: str) -> None: # pylint: disable=too-many-statements 359 """Processes (potential) mouse input. 360 361 Args: 362 key: Input to handle. 363 """ 364 365 window: Window 366 367 def _clamp_pos(pos: tuple[int, int], index: int) -> int: 368 """Clamp a value using index to address x/y & width/height""" 369 370 offset = self._drag_offsets[index] 371 372 # TODO: This -2 is a very magical number. Not good. 373 maximum = terminal.size[index] - ((window.width, window.height)[index] - 2) 374 375 start_margin_index = abs(index - 1) 376 377 if self.restrict_within_bounds: 378 return max( 379 index + terminal.margins[start_margin_index], 380 min( 381 pos[index] - offset, 382 maximum 383 - terminal.margins[start_margin_index + 2] 384 - terminal.origin[index], 385 ), 386 ) 387 388 return pos[index] - offset 389 390 def _click(pos: tuple[int, int], window: Window) -> bool: 391 """Process clicking a window.""" 392 393 left, top, right, bottom = window.rect 394 borders = window.chars.get("border", [" "] * 4) 395 396 if real_length(borders[1]) > 0 and pos[1] == top and left <= pos[0] < right: 397 self._drag_target = (window, Edge.TOP) 398 399 elif ( 400 real_length(borders[3]) > 0 401 and pos[1] == bottom - 1 402 and left <= pos[0] < right 403 ): 404 self._drag_target = (window, Edge.BOTTOM) 405 406 elif ( 407 real_length(borders[0]) > 0 408 and pos[0] == left 409 and top <= pos[1] < bottom 410 ): 411 self._drag_target = (window, Edge.LEFT) 412 413 elif ( 414 real_length(borders[2]) > 0 415 and pos[0] == right - 1 416 and top <= pos[1] < bottom 417 ): 418 self._drag_target = (window, Edge.RIGHT) 419 420 else: 421 return False 422 423 self._drag_offsets = ( 424 pos[0] - window.pos[0], 425 pos[1] - window.pos[1], 426 ) 427 428 return True 429 430 def _drag(pos: tuple[int, int], window: Window) -> bool: 431 """Process dragging a window""" 432 433 if self._drag_target is None: 434 return False 435 436 target_window, edge = self._drag_target 437 handled = False 438 439 if window is not target_window: 440 return False 441 442 left, top, right, bottom = window.rect 443 444 if not window.is_static and edge is Edge.TOP: 445 window.pos = ( 446 _clamp_pos(pos, 0), 447 _clamp_pos(pos, 1), 448 ) 449 450 handled = True 451 452 # TODO: Why are all these arbitrary offsets needed? 453 elif not window.is_noresize: 454 if edge is Edge.RIGHT: 455 window.rect = (left, top, pos[0] + 1, bottom) 456 handled = True 457 458 elif edge is Edge.LEFT: 459 window.rect = (pos[0], top, right, bottom) 460 handled = True 461 462 elif edge is Edge.BOTTOM: 463 window.rect = (left, top, right, pos[1] + 1) 464 handled = True 465 466 if handled: 467 window.is_dirty = True 468 self.compositor.set_redraw() 469 470 return handled 471 472 def _release(_: tuple[int, int], __: Window) -> bool: 473 """Process release of key""" 474 475 self._drag_target = None 476 477 # This return False so Window can handle the mouse action as well, 478 # as not much is done in this callback. 479 return False 480 481 handlers = { 482 MouseAction.LEFT_CLICK: _click, 483 MouseAction.LEFT_DRAG: _drag, 484 MouseAction.RELEASE: _release, 485 } 486 487 translate = self.mouse_translator 488 event_list = None if translate is None else translate(key) 489 490 if event_list is None: 491 return 492 493 for event in event_list: 494 # Ignore null-events 495 if event is None: 496 continue 497 498 for window in self._windows: 499 contains = window.contains(event.position) 500 501 if event.action in self.focusing_actions: 502 self.focus(window) 503 504 if event.action in handlers and handlers[event.action]( 505 event.position, window 506 ): 507 break 508 509 if contains: 510 if self._mouse_target is not None: 511 self._mouse_target.handle_mouse( 512 MouseEvent(MouseAction.RELEASE, event.position) 513 ) 514 515 self._mouse_target = window 516 window.handle_mouse(event) 517 break 518 519 if window.is_modal: 520 break 521 522 # Unset drag_target if no windows received the input 523 else: 524 self._drag_target = None 525 if self._mouse_target is not None: 526 self._mouse_target.handle_mouse( 527 MouseEvent(MouseAction.RELEASE, event.position) 528 ) 529 530 self._mouse_target = None 531 532 def screenshot(self, title: str, filename: str = "screenshot.svg") -> None: 533 """Takes a screenshot of the current state. 534 535 See `pytermgui.exporters.to_svg` for more information. 536 537 Args: 538 filename: The name of the file. 539 """ 540 541 self.compositor.capture(title=title, filename=filename) 542 543 def show_positions(self) -> None: 544 """Shows the positions of each Window's widgets.""" 545 546 def _show_positions(widget, color_base: int = 60) -> None: 547 """Show positions of widget.""" 548 549 if isinstance(widget, Container): 550 for i, subwidget in enumerate(widget): 551 _show_positions(subwidget, color_base + i) 552 553 return 554 555 if not widget.is_selectable: 556 return 557 558 debug = widget.debug() 559 color = str_to_color(f"@{color_base}") 560 buff = color(" ", reset=False) 561 562 for i in range(min(widget.width, real_length(debug)) - 1): 563 buff += debug[i] 564 565 self.terminal.write(buff, pos=widget.pos) 566 567 for widget in self._windows: 568 _show_positions(widget) 569 self.terminal.flush() 570 571 getch() 572 573 def alert(self, *items, center: bool = True, **attributes) -> Window: 574 """Creates a modal popup of the given elements and attributes. 575 576 Args: 577 *items: All widget-convertable objects passed as children of the new window. 578 center: If set, `pytermgui.window_manager.window.center` is called on the window. 579 **attributes: kwargs passed as the new window's attributes. 580 """ 581 582 window = Window(*items, is_modal=True, **attributes) 583 584 if center: 585 window.center() 586 587 self.add(window, assign=False) 588 589 return window 590 591 def toast( 592 self, 593 *items, 594 offset: int = 0, 595 duration: int = 300, 596 delay: int = 1000, 597 **attributes, 598 ) -> Window: 599 """Creates a Material UI-inspired toast window of the given elements and attributes. 600 601 Args: 602 *items: All widget-convertable objects passed as children of the new window. 603 delay: The amount of time before the window will start animating out. 604 **attributes: kwargs passed as the new window's attributes. 605 """ 606 607 # pylint: disable=no-value-for-parameter 608 609 toast = Window(*items, is_noblur=True, **attributes) 610 611 target_height = toast.height 612 toast.overflow = Overflow.HIDE 613 614 def _finish(_: Animation) -> None: 615 self.remove(toast, animate=False) 616 617 def _progressively_show(anim: Animation, invert: bool = False) -> bool: 618 height = int(anim.state * target_height) 619 620 toast.center() 621 622 if invert: 623 toast.height = target_height - 1 - height 624 toast.pos = ( 625 toast.pos[0], 626 self.terminal.height - toast.height + 1 - offset, 627 ) 628 return False 629 630 toast.height = height 631 toast.pos = (toast.pos[0], self.terminal.height - toast.height + 1 - offset) 632 633 return False 634 635 def _animate_toast_out(_: Animation) -> None: 636 animator.schedule( 637 FloatAnimation( 638 delay, 639 on_finish=lambda *_: animator.schedule( 640 FloatAnimation( 641 duration, 642 on_step=lambda anim: _progressively_show(anim, invert=True), 643 on_finish=_finish, 644 ) 645 ), 646 ) 647 ) 648 649 leadup = FloatAnimation( 650 duration, on_step=_progressively_show, on_finish=_animate_toast_out 651 ) 652 653 # pylint: enable=no-value-for-parameter 654 655 self.add(toast.center(), animate=False, assign=False) 656 self.focus(toast) 657 animator.schedule(leadup) 658 659 return toast
The manager of windows.
This class can be used, or even subclassed in order to create full-screen applications,
using the pytermgui.window_manager.window.Window
class and the general Widget API.
59 def __init__( 60 self, 61 *, 62 layout_type: Type[Layout] = Layout, 63 framerate: int = 60, 64 autorun: bool | None = None, 65 ) -> None: 66 """Initialize the manager.""" 67 68 super().__init__() 69 70 self._is_running = False 71 self._windows: list[Window] = [] 72 self._bindings: dict[str | Type[MouseEvent], tuple[BoundCallback, str]] = {} 73 74 self.focused: Window | None = None 75 76 if autorun is not None: 77 self.autorun = autorun 78 79 self.layout = layout_type() 80 self.compositor = Compositor(self._windows, framerate=framerate) 81 self.mouse_translator: MouseTranslator | None = None 82 83 self._mouse_target: Window | None = None 84 self._drag_offsets: tuple[int, int] = (0, 0) 85 self._drag_target: tuple[Window, Edge] | None = None 86 87 # This isn't quite implemented at the moment. 88 self.restrict_within_bounds = True 89 90 terminal.subscribe(terminal.RESIZE, self.on_resize)
Initialize the manager.
These mouse actions will focus the window they are acted upon.
146 def get_lines(self) -> list[str]: 147 """Gets the empty list.""" 148 149 # TODO: Allow using WindowManager as a widget. 150 151 return []
Gets the empty list.
153 def clear_cache(self, window: Window) -> None: 154 """Clears the compositor's cache related to the given window.""" 155 156 self.compositor.clear_cache(window)
Clears the compositor's cache related to the given window.
158 def on_resize(self, size: tuple[int, int]) -> None: 159 """Correctly updates window positions & prints when terminal gets resized. 160 161 Args: 162 size: The new terminal size. 163 """ 164 165 width, height = size 166 167 for window in self._windows: 168 newx = max(0, min(window.pos[0], width - window.width)) 169 newy = max(0, min(window.pos[1], height - window.height + 1)) 170 171 window.pos = (newx, newy) 172 173 self.layout.apply() 174 self.compositor.redraw()
Correctly updates window positions & prints when terminal gets resized.
Args
- size: The new terminal size.
176 def run(self, mouse_events: list[str] | None = None) -> None: 177 """Starts the WindowManager. 178 179 Args: 180 mouse_events: A list of mouse event types to listen to. See 181 `pytermgui.ansi_interface.report_mouse` for more information. 182 Defaults to `["press_hold", "hover"]`. 183 184 Returns: 185 The WindowManager's compositor instance. 186 """ 187 188 self._is_running = True 189 190 if mouse_events is None: 191 mouse_events = ["press_hold", "hover"] 192 193 with alt_buffer(cursor=False, echo=False): 194 with mouse_handler(mouse_events, "decimal_xterm") as translate: 195 self.mouse_translator = translate 196 self.compositor.run() 197 198 self._run_input_loop()
Starts the WindowManager.
Args
- mouse_events: A list of mouse event types to listen to. See
pytermgui.ansi_interface.report_mouse
for more information. Defaults to["press_hold", "hover"]
.
Returns
The WindowManager's compositor instance.
200 def stop(self) -> None: 201 """Stops the WindowManager and its compositor.""" 202 203 self.compositor.stop() 204 self._is_running = False
Stops the WindowManager and its compositor.
206 def add( 207 self, window: Window, assign: str | bool = True, animate: bool = True 208 ) -> WindowManager: 209 """Adds a window to the manager. 210 211 Args: 212 window: The window to add. 213 assign: The name of the slot the new window should be assigned to, or a 214 boolean. If it is given a str, it is treated as the name of a slot. When 215 given True, the next non-filled slot will be assigned, and when given 216 False no assignment will be done. 217 animate: If set, an animation will be played on the window once it's added. 218 """ 219 220 self._windows.insert(0, window) 221 window.manager = self 222 223 if assign: 224 if isinstance(assign, str): 225 getattr(self.layout, assign).content = window 226 227 elif len(self._windows) <= len(self.layout.slots): 228 self.layout.assign(window, index=len(self._windows) - 1) 229 230 self.layout.apply() 231 232 # New windows take focus-precedence over already 233 # existing ones, even if they are modal. 234 self.focus(window) 235 236 if not animate: 237 return self 238 239 if window.height > 1: 240 animator.animate_attr( 241 target=window, 242 attr="height", 243 start=0, 244 end=window.height, 245 duration=300, 246 on_step=_center_during_animation, 247 ) 248 249 return self
Adds a window to the manager.
Args
- window: The window to add.
- assign: The name of the slot the new window should be assigned to, or a boolean. If it is given a str, it is treated as the name of a slot. When given True, the next non-filled slot will be assigned, and when given False no assignment will be done.
- animate: If set, an animation will be played on the window once it's added.
251 def remove( 252 self, 253 window: Window, 254 autostop: bool = True, 255 animate: bool = True, 256 ) -> WindowManager: 257 """Removes a window from the manager. 258 259 Args: 260 window: The window to remove. 261 autostop: If set, the manager will be stopped if the length of its windows 262 hits 0. 263 """ 264 265 def _on_finish(_: AttrAnimation | None) -> bool: 266 self._windows.remove(window) 267 268 if autostop and len(self._windows) == 0: 269 self.stop() 270 else: 271 self.focus(self._windows[0]) 272 273 return True 274 275 if not animate: 276 _on_finish(None) 277 return self 278 279 animator.animate_attr( 280 target=window, 281 attr="height", 282 end=0, 283 duration=300, 284 on_step=_center_during_animation, 285 on_finish=_on_finish, 286 ) 287 288 return self
Removes a window from the manager.
Args
- window: The window to remove.
- autostop: If set, the manager will be stopped if the length of its windows hits 0.
290 def focus(self, window: Window | None) -> None: 291 """Focuses a window by moving it to the first index in _windows.""" 292 293 if self.focused is not None: 294 self.focused.blur() 295 296 self.focused = window 297 298 if window is not None: 299 self._windows.remove(window) 300 self._windows.insert(0, window) 301 302 window.focus()
Focuses a window by moving it to the first index in _windows.
304 def focus_next(self) -> Window | None: 305 """Focuses the next window in focus order, looping to first at the end.""" 306 307 if self.focused is None: 308 self.focus(self._windows[0]) 309 return self.focused 310 311 index = self._windows.index(self.focused) 312 if index == len(self._windows) - 1: 313 index = 0 314 315 window = self._windows[index] 316 traversed = 0 317 while window.is_persistent or window is self.focused: 318 if index >= len(self._windows): 319 index = 0 320 321 window = self._windows[index] 322 323 index += 1 324 traversed += 1 325 if traversed >= len(self._windows): 326 return self.focused 327 328 self.focus(self._windows[index]) 329 330 return self.focused
Focuses the next window in focus order, looping to first at the end.
332 def handle_key(self, key: str) -> bool: 333 """Processes a keypress. 334 335 Args: 336 key: The key to handle. 337 338 Returns: 339 True if the given key could be processed, False otherwise. 340 """ 341 342 # Apply WindowManager bindings 343 if self.execute_binding(key): 344 return True 345 346 # Apply focused window binding, or send to InputField 347 if self.focused is not None: 348 if self.focused.execute_binding(key): 349 return True 350 351 if self.focused.handle_key(key): 352 return True 353 354 return False
Processes a keypress.
Args
- key: The key to handle.
Returns
True if the given key could be processed, False otherwise.
358 def process_mouse(self, key: str) -> None: # pylint: disable=too-many-statements 359 """Processes (potential) mouse input. 360 361 Args: 362 key: Input to handle. 363 """ 364 365 window: Window 366 367 def _clamp_pos(pos: tuple[int, int], index: int) -> int: 368 """Clamp a value using index to address x/y & width/height""" 369 370 offset = self._drag_offsets[index] 371 372 # TODO: This -2 is a very magical number. Not good. 373 maximum = terminal.size[index] - ((window.width, window.height)[index] - 2) 374 375 start_margin_index = abs(index - 1) 376 377 if self.restrict_within_bounds: 378 return max( 379 index + terminal.margins[start_margin_index], 380 min( 381 pos[index] - offset, 382 maximum 383 - terminal.margins[start_margin_index + 2] 384 - terminal.origin[index], 385 ), 386 ) 387 388 return pos[index] - offset 389 390 def _click(pos: tuple[int, int], window: Window) -> bool: 391 """Process clicking a window.""" 392 393 left, top, right, bottom = window.rect 394 borders = window.chars.get("border", [" "] * 4) 395 396 if real_length(borders[1]) > 0 and pos[1] == top and left <= pos[0] < right: 397 self._drag_target = (window, Edge.TOP) 398 399 elif ( 400 real_length(borders[3]) > 0 401 and pos[1] == bottom - 1 402 and left <= pos[0] < right 403 ): 404 self._drag_target = (window, Edge.BOTTOM) 405 406 elif ( 407 real_length(borders[0]) > 0 408 and pos[0] == left 409 and top <= pos[1] < bottom 410 ): 411 self._drag_target = (window, Edge.LEFT) 412 413 elif ( 414 real_length(borders[2]) > 0 415 and pos[0] == right - 1 416 and top <= pos[1] < bottom 417 ): 418 self._drag_target = (window, Edge.RIGHT) 419 420 else: 421 return False 422 423 self._drag_offsets = ( 424 pos[0] - window.pos[0], 425 pos[1] - window.pos[1], 426 ) 427 428 return True 429 430 def _drag(pos: tuple[int, int], window: Window) -> bool: 431 """Process dragging a window""" 432 433 if self._drag_target is None: 434 return False 435 436 target_window, edge = self._drag_target 437 handled = False 438 439 if window is not target_window: 440 return False 441 442 left, top, right, bottom = window.rect 443 444 if not window.is_static and edge is Edge.TOP: 445 window.pos = ( 446 _clamp_pos(pos, 0), 447 _clamp_pos(pos, 1), 448 ) 449 450 handled = True 451 452 # TODO: Why are all these arbitrary offsets needed? 453 elif not window.is_noresize: 454 if edge is Edge.RIGHT: 455 window.rect = (left, top, pos[0] + 1, bottom) 456 handled = True 457 458 elif edge is Edge.LEFT: 459 window.rect = (pos[0], top, right, bottom) 460 handled = True 461 462 elif edge is Edge.BOTTOM: 463 window.rect = (left, top, right, pos[1] + 1) 464 handled = True 465 466 if handled: 467 window.is_dirty = True 468 self.compositor.set_redraw() 469 470 return handled 471 472 def _release(_: tuple[int, int], __: Window) -> bool: 473 """Process release of key""" 474 475 self._drag_target = None 476 477 # This return False so Window can handle the mouse action as well, 478 # as not much is done in this callback. 479 return False 480 481 handlers = { 482 MouseAction.LEFT_CLICK: _click, 483 MouseAction.LEFT_DRAG: _drag, 484 MouseAction.RELEASE: _release, 485 } 486 487 translate = self.mouse_translator 488 event_list = None if translate is None else translate(key) 489 490 if event_list is None: 491 return 492 493 for event in event_list: 494 # Ignore null-events 495 if event is None: 496 continue 497 498 for window in self._windows: 499 contains = window.contains(event.position) 500 501 if event.action in self.focusing_actions: 502 self.focus(window) 503 504 if event.action in handlers and handlers[event.action]( 505 event.position, window 506 ): 507 break 508 509 if contains: 510 if self._mouse_target is not None: 511 self._mouse_target.handle_mouse( 512 MouseEvent(MouseAction.RELEASE, event.position) 513 ) 514 515 self._mouse_target = window 516 window.handle_mouse(event) 517 break 518 519 if window.is_modal: 520 break 521 522 # Unset drag_target if no windows received the input 523 else: 524 self._drag_target = None 525 if self._mouse_target is not None: 526 self._mouse_target.handle_mouse( 527 MouseEvent(MouseAction.RELEASE, event.position) 528 ) 529 530 self._mouse_target = None
Processes (potential) mouse input.
Args
- key: Input to handle.
532 def screenshot(self, title: str, filename: str = "screenshot.svg") -> None: 533 """Takes a screenshot of the current state. 534 535 See `pytermgui.exporters.to_svg` for more information. 536 537 Args: 538 filename: The name of the file. 539 """ 540 541 self.compositor.capture(title=title, filename=filename)
Takes a screenshot of the current state.
See pytermgui.exporters.to_svg
for more information.
Args
- filename: The name of the file.
543 def show_positions(self) -> None: 544 """Shows the positions of each Window's widgets.""" 545 546 def _show_positions(widget, color_base: int = 60) -> None: 547 """Show positions of widget.""" 548 549 if isinstance(widget, Container): 550 for i, subwidget in enumerate(widget): 551 _show_positions(subwidget, color_base + i) 552 553 return 554 555 if not widget.is_selectable: 556 return 557 558 debug = widget.debug() 559 color = str_to_color(f"@{color_base}") 560 buff = color(" ", reset=False) 561 562 for i in range(min(widget.width, real_length(debug)) - 1): 563 buff += debug[i] 564 565 self.terminal.write(buff, pos=widget.pos) 566 567 for widget in self._windows: 568 _show_positions(widget) 569 self.terminal.flush() 570 571 getch()
Shows the positions of each Window's widgets.
573 def alert(self, *items, center: bool = True, **attributes) -> Window: 574 """Creates a modal popup of the given elements and attributes. 575 576 Args: 577 *items: All widget-convertable objects passed as children of the new window. 578 center: If set, `pytermgui.window_manager.window.center` is called on the window. 579 **attributes: kwargs passed as the new window's attributes. 580 """ 581 582 window = Window(*items, is_modal=True, **attributes) 583 584 if center: 585 window.center() 586 587 self.add(window, assign=False) 588 589 return window
Creates a modal popup of the given elements and attributes.
Args
- *items: All widget-convertable objects passed as children of the new window.
- center: If set,
pytermgui.window_manager.window.center
is called on the window. - **attributes: kwargs passed as the new window's attributes.
591 def toast( 592 self, 593 *items, 594 offset: int = 0, 595 duration: int = 300, 596 delay: int = 1000, 597 **attributes, 598 ) -> Window: 599 """Creates a Material UI-inspired toast window of the given elements and attributes. 600 601 Args: 602 *items: All widget-convertable objects passed as children of the new window. 603 delay: The amount of time before the window will start animating out. 604 **attributes: kwargs passed as the new window's attributes. 605 """ 606 607 # pylint: disable=no-value-for-parameter 608 609 toast = Window(*items, is_noblur=True, **attributes) 610 611 target_height = toast.height 612 toast.overflow = Overflow.HIDE 613 614 def _finish(_: Animation) -> None: 615 self.remove(toast, animate=False) 616 617 def _progressively_show(anim: Animation, invert: bool = False) -> bool: 618 height = int(anim.state * target_height) 619 620 toast.center() 621 622 if invert: 623 toast.height = target_height - 1 - height 624 toast.pos = ( 625 toast.pos[0], 626 self.terminal.height - toast.height + 1 - offset, 627 ) 628 return False 629 630 toast.height = height 631 toast.pos = (toast.pos[0], self.terminal.height - toast.height + 1 - offset) 632 633 return False 634 635 def _animate_toast_out(_: Animation) -> None: 636 animator.schedule( 637 FloatAnimation( 638 delay, 639 on_finish=lambda *_: animator.schedule( 640 FloatAnimation( 641 duration, 642 on_step=lambda anim: _progressively_show(anim, invert=True), 643 on_finish=_finish, 644 ) 645 ), 646 ) 647 ) 648 649 leadup = FloatAnimation( 650 duration, on_step=_progressively_show, on_finish=_animate_toast_out 651 ) 652 653 # pylint: enable=no-value-for-parameter 654 655 self.add(toast.center(), animate=False, assign=False) 656 self.focus(toast) 657 animator.schedule(leadup) 658 659 return toast
Creates a Material UI-inspired toast window of the given elements and attributes.
Args
- *items: All widget-convertable objects passed as children of the new window.
- delay: The amount of time before the window will start animating out.
- **attributes: kwargs passed as the new window's attributes.
Inherited Members
- pytermgui.widgets.base.Widget
- set_style
- set_char
- styles
- chars
- keys
- serialized
- size_policy
- parent_align
- from_data
- bindings
- id
- selectables_length
- selectables
- is_selectable
- static_width
- relative_width
- terminal
- get_change
- contains
- handle_mouse
- serialize
- copy
- bind
- unbind
- execute_binding
- select
- debug