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