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