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