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
class Edge(enum.Enum):
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.

LEFT = <Edge.LEFT: 1>
TOP = <Edge.TOP: 2>
RIGHT = <Edge.RIGHT: 3>
BOTTOM = <Edge.BOTTOM: 4>
Inherited Members
enum.Enum
name
value
class WindowManager(pytermgui.widgets.base.Widget):
 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.

WindowManager( *, autorun: bool = True, layout_type: Type[pytermgui.window_manager.layouts.Layout] = <class 'pytermgui.window_manager.layouts.Layout'>, framerate: int = 60)
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.

is_bindable = True

Allow binding support

focusing_actions = (<MouseAction.LEFT_CLICK: 1>, <MouseAction.RIGHT_CLICK: 3>)

These mouse actions will focus the window they are acted upon.

def get_lines(self) -> list[str]:
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.

def clear_cache(self, window: pytermgui.window_manager.window.Window) -> None:
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.

def on_resize(self, size: tuple[int, int]) -> None:
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.
def run(self, mouse_events: list[str] | None = None) -> None:
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
Returns

The WindowManager's compositor instance.

def stop(self) -> None:
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.

def add( self, window: pytermgui.window_manager.window.Window, assign: str | bool = True, animate: bool = True) -> pytermgui.window_manager.manager.WindowManager:
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.
def remove( self, window: pytermgui.window_manager.window.Window, autostop: bool = True, animate: bool = True) -> pytermgui.window_manager.manager.WindowManager:
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.
def focus(self, window: pytermgui.window_manager.window.Window | None) -> None:
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.

def focus_next(self) -> pytermgui.window_manager.window.Window | None:
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.

def handle_key(self, key: str) -> bool:
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.

def process_mouse(self, key: str) -> None:
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.
def screenshot(self, title: str, filename: str = 'screenshot.svg') -> None:
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.
def show_positions(self) -> None:
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.

def alert( self, *items, center: bool = True, **attributes) -> pytermgui.window_manager.window.Window:
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.
def toast( self, *items, offset: int = 0, duration: int = 300, delay: int = 1000, **attributes) -> pytermgui.window_manager.window.Window:
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.