pytermgui.window_manager.manager

The WindowManager class, whos job it is to move, control and update windows, while letting Compositor draw them.

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

The manager of windows.

This class can be used, or even subclassed in order to create full-screen applications, using the pytermgui.window_manager.window.Window class and the general Widget API.

WindowManager( *, layout_type: Type[pytermgui.window_manager.layouts.Layout] = pytermgui.window_manager.layouts.Layout'>, framerate: int = 60, autorun: bool | None = None)
59    def __init__(
60        self,
61        *,
62        layout_type: Type[Layout] = Layout,
63        framerate: int = 60,
64        autorun: bool | None = None,
65    ) -> None:
66        """Initialize the manager."""
67
68        super().__init__()
69
70        self._is_running = False
71        self._windows: list[Window] = []
72        self._bindings: dict[str | Type[MouseEvent], tuple[BoundCallback, str]] = {}
73
74        self.focused: Window | None = None
75
76        if autorun is not None:
77            self.autorun = autorun
78
79        self.layout = layout_type()
80        self.compositor = Compositor(self._windows, framerate=framerate)
81        self.mouse_translator: MouseTranslator | None = None
82
83        self._mouse_target: Window | None = None
84        self._drag_offsets: tuple[int, int] = (0, 0)
85        self._drag_target: tuple[Window, Edge] | None = None
86
87        # This isn't quite implemented at the moment.
88        self.restrict_within_bounds = True
89
90        terminal.subscribe(terminal.RESIZE, self.on_resize)

Initialize the manager.

is_bindable = True

Allow binding support

focusing_actions = (<MouseAction.LEFT_CLICK: 'left_click'>, <MouseAction.RIGHT_CLICK: 'right_click'>)

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

autorun = True
def get_lines(self) -> list[str]:
146    def get_lines(self) -> list[str]:
147        """Gets the empty list."""
148
149        # TODO: Allow using WindowManager as a widget.
150
151        return []

Gets the empty list.

def clear_cache(self, window: pytermgui.window_manager.window.Window) -> None:
153    def clear_cache(self, window: Window) -> None:
154        """Clears the compositor's cache related to the given window."""
155
156        self.compositor.clear_cache(window)

Clears the compositor's cache related to the given window.

def on_resize(self, size: tuple[int, int]) -> None:
158    def on_resize(self, size: tuple[int, int]) -> None:
159        """Correctly updates window positions & prints when terminal gets resized.
160
161        Args:
162            size: The new terminal size.
163        """
164
165        width, height = size
166
167        for window in self._windows:
168            newx = max(0, min(window.pos[0], width - window.width))
169            newy = max(0, min(window.pos[1], height - window.height + 1))
170
171            window.pos = (newx, newy)
172
173        self.layout.apply()
174        self.compositor.redraw()

Correctly updates window positions & prints when terminal gets resized.

Args
  • size: The new terminal size.
def run(self, mouse_events: list[str] | None = None) -> None:
176    def run(self, mouse_events: list[str] | None = None) -> None:
177        """Starts the WindowManager.
178
179        Args:
180            mouse_events: A list of mouse event types to listen to. See
181                `pytermgui.ansi_interface.report_mouse` for more information.
182                Defaults to `["press_hold", "hover"]`.
183
184        Returns:
185            The WindowManager's compositor instance.
186        """
187
188        self._is_running = True
189
190        if mouse_events is None:
191            mouse_events = ["press_hold", "hover"]
192
193        with alt_buffer(cursor=False, echo=False):
194            with mouse_handler(mouse_events, "decimal_xterm") as translate:
195                self.mouse_translator = translate
196                self.compositor.run()
197
198                self._run_input_loop()

Starts the WindowManager.

Args
Returns

The WindowManager's compositor instance.

def stop(self) -> None:
200    def stop(self) -> None:
201        """Stops the WindowManager and its compositor."""
202
203        self.compositor.stop()
204        self._is_running = False

Stops the WindowManager and its compositor.

def add( self, window: pytermgui.window_manager.window.Window, assign: str | bool = True, animate: bool = True) -> pytermgui.window_manager.manager.WindowManager:
206    def add(
207        self, window: Window, assign: str | bool = True, animate: bool = True
208    ) -> WindowManager:
209        """Adds a window to the manager.
210
211        Args:
212            window: The window to add.
213            assign: The name of the slot the new window should be assigned to, or a
214                boolean. If it is given a str, it is treated as the name of a slot. When
215                given True, the next non-filled slot will be assigned, and when given
216                False no assignment will be done.
217            animate: If set, an animation will be played on the window once it's added.
218        """
219
220        self._windows.insert(0, window)
221        window.manager = self
222
223        if assign:
224            if isinstance(assign, str):
225                getattr(self.layout, assign).content = window
226
227            elif len(self._windows) <= len(self.layout.slots):
228                self.layout.assign(window, index=len(self._windows) - 1)
229
230            self.layout.apply()
231
232        # New windows take focus-precedence over already
233        # existing ones, even if they are modal.
234        self.focus(window)
235
236        if not animate:
237            return self
238
239        if window.height > 1:
240            animator.animate_attr(
241                target=window,
242                attr="height",
243                start=0,
244                end=window.height,
245                duration=300,
246                on_step=_center_during_animation,
247            )
248
249        return self

Adds a window to the manager.

Args
  • window: The window to add.
  • assign: The name of the slot the new window should be assigned to, or a boolean. If it is given a str, it is treated as the name of a slot. When given True, the next non-filled slot will be assigned, and when given False no assignment will be done.
  • animate: If set, an animation will be played on the window once it's added.
def remove( self, window: pytermgui.window_manager.window.Window, autostop: bool = True, animate: bool = True) -> pytermgui.window_manager.manager.WindowManager:
251    def remove(
252        self,
253        window: Window,
254        autostop: bool = True,
255        animate: bool = True,
256    ) -> WindowManager:
257        """Removes a window from the manager.
258
259        Args:
260            window: The window to remove.
261            autostop: If set, the manager will be stopped if the length of its windows
262                hits 0.
263        """
264
265        def _on_finish(_: AttrAnimation | None) -> bool:
266            self._windows.remove(window)
267
268            if autostop and len(self._windows) == 0:
269                self.stop()
270            else:
271                self.focus(self._windows[0])
272
273            return True
274
275        if not animate:
276            _on_finish(None)
277            return self
278
279        animator.animate_attr(
280            target=window,
281            attr="height",
282            end=0,
283            duration=300,
284            on_step=_center_during_animation,
285            on_finish=_on_finish,
286        )
287
288        return self

Removes a window from the manager.

Args
  • window: The window to remove.
  • autostop: If set, the manager will be stopped if the length of its windows hits 0.
def focus(self, window: pytermgui.window_manager.window.Window | None) -> None:
290    def focus(self, window: Window | None) -> None:
291        """Focuses a window by moving it to the first index in _windows."""
292
293        if self.focused is not None:
294            self.focused.blur()
295
296        self.focused = window
297
298        if window is not None:
299            self._windows.remove(window)
300            self._windows.insert(0, window)
301
302            window.focus()

Focuses a window by moving it to the first index in _windows.

def focus_next(self) -> pytermgui.window_manager.window.Window | None:
304    def focus_next(self) -> Window | None:
305        """Focuses the next window in focus order, looping to first at the end."""
306
307        if self.focused is None:
308            self.focus(self._windows[0])
309            return self.focused
310
311        index = self._windows.index(self.focused)
312        if index == len(self._windows) - 1:
313            index = 0
314
315        window = self._windows[index]
316        traversed = 0
317        while window.is_persistent or window is self.focused:
318            if index >= len(self._windows):
319                index = 0
320
321            window = self._windows[index]
322
323            index += 1
324            traversed += 1
325            if traversed >= len(self._windows):
326                return self.focused
327
328        self.focus(self._windows[index])
329
330        return self.focused

Focuses the next window in focus order, looping to first at the end.

def handle_key(self, key: str) -> bool:
332    def handle_key(self, key: str) -> bool:
333        """Processes a keypress.
334
335        Args:
336            key: The key to handle.
337
338        Returns:
339            True if the given key could be processed, False otherwise.
340        """
341
342        # Apply WindowManager bindings
343        if self.execute_binding(key):
344            return True
345
346        # Apply focused window binding, or send to InputField
347        if self.focused is not None:
348            if self.focused.execute_binding(key):
349                return True
350
351            if self.focused.handle_key(key):
352                return True
353
354        return False

Processes a keypress.

Args
  • key: The key to handle.
Returns

True if the given key could be processed, False otherwise.

def process_mouse(self, key: str) -> None:
358    def process_mouse(self, key: str) -> None:  # pylint: disable=too-many-statements
359        """Processes (potential) mouse input.
360
361        Args:
362            key: Input to handle.
363        """
364
365        window: Window
366
367        def _clamp_pos(pos: tuple[int, int], index: int) -> int:
368            """Clamp a value using index to address x/y & width/height"""
369
370            offset = self._drag_offsets[index]
371
372            # TODO: This -2 is a very magical number. Not good.
373            maximum = terminal.size[index] - ((window.width, window.height)[index] - 2)
374
375            start_margin_index = abs(index - 1)
376
377            if self.restrict_within_bounds:
378                return max(
379                    index + terminal.margins[start_margin_index],
380                    min(
381                        pos[index] - offset,
382                        maximum
383                        - terminal.margins[start_margin_index + 2]
384                        - terminal.origin[index],
385                    ),
386                )
387
388            return pos[index] - offset
389
390        def _click(pos: tuple[int, int], window: Window) -> bool:
391            """Process clicking a window."""
392
393            left, top, right, bottom = window.rect
394            borders = window.chars.get("border", [" "] * 4)
395
396            if real_length(borders[1]) > 0 and pos[1] == top and left <= pos[0] < right:
397                self._drag_target = (window, Edge.TOP)
398
399            elif (
400                real_length(borders[3]) > 0
401                and pos[1] == bottom - 1
402                and left <= pos[0] < right
403            ):
404                self._drag_target = (window, Edge.BOTTOM)
405
406            elif (
407                real_length(borders[0]) > 0
408                and pos[0] == left
409                and top <= pos[1] < bottom
410            ):
411                self._drag_target = (window, Edge.LEFT)
412
413            elif (
414                real_length(borders[2]) > 0
415                and pos[0] == right - 1
416                and top <= pos[1] < bottom
417            ):
418                self._drag_target = (window, Edge.RIGHT)
419
420            else:
421                return False
422
423            self._drag_offsets = (
424                pos[0] - window.pos[0],
425                pos[1] - window.pos[1],
426            )
427
428            return True
429
430        def _drag(pos: tuple[int, int], window: Window) -> bool:
431            """Process dragging a window"""
432
433            if self._drag_target is None:
434                return False
435
436            target_window, edge = self._drag_target
437            handled = False
438
439            if window is not target_window:
440                return False
441
442            left, top, right, bottom = window.rect
443
444            if not window.is_static and edge is Edge.TOP:
445                window.pos = (
446                    _clamp_pos(pos, 0),
447                    _clamp_pos(pos, 1),
448                )
449
450                handled = True
451
452            # TODO: Why are all these arbitrary offsets needed?
453            elif not window.is_noresize:
454                if edge is Edge.RIGHT:
455                    window.rect = (left, top, pos[0] + 1, bottom)
456                    handled = True
457
458                elif edge is Edge.LEFT:
459                    window.rect = (pos[0], top, right, bottom)
460                    handled = True
461
462                elif edge is Edge.BOTTOM:
463                    window.rect = (left, top, right, pos[1] + 1)
464                    handled = True
465
466            if handled:
467                window.is_dirty = True
468                self.compositor.set_redraw()
469
470            return handled
471
472        def _release(_: tuple[int, int], __: Window) -> bool:
473            """Process release of key"""
474
475            self._drag_target = None
476
477            # This return False so Window can handle the mouse action as well,
478            # as not much is done in this callback.
479            return False
480
481        handlers = {
482            MouseAction.LEFT_CLICK: _click,
483            MouseAction.LEFT_DRAG: _drag,
484            MouseAction.RELEASE: _release,
485        }
486
487        translate = self.mouse_translator
488        event_list = None if translate is None else translate(key)
489
490        if event_list is None:
491            return
492
493        for event in event_list:
494            # Ignore null-events
495            if event is None:
496                continue
497
498            for window in self._windows:
499                contains = window.contains(event.position)
500
501                if event.action in self.focusing_actions:
502                    self.focus(window)
503
504                if event.action in handlers and handlers[event.action](
505                    event.position, window
506                ):
507                    break
508
509                if contains:
510                    if self._mouse_target is not None:
511                        self._mouse_target.handle_mouse(
512                            MouseEvent(MouseAction.RELEASE, event.position)
513                        )
514
515                    self._mouse_target = window
516                    window.handle_mouse(event)
517                    break
518
519                if window.is_modal:
520                    break
521
522            # Unset drag_target if no windows received the input
523            else:
524                self._drag_target = None
525                if self._mouse_target is not None:
526                    self._mouse_target.handle_mouse(
527                        MouseEvent(MouseAction.RELEASE, event.position)
528                    )
529
530                self._mouse_target = None

Processes (potential) mouse input.

Args
  • key: Input to handle.
def screenshot(self, title: str, filename: str = 'screenshot.svg') -> None:
532    def screenshot(self, title: str, filename: str = "screenshot.svg") -> None:
533        """Takes a screenshot of the current state.
534
535        See `pytermgui.exporters.to_svg` for more information.
536
537        Args:
538            filename: The name of the file.
539        """
540
541        self.compositor.capture(title=title, filename=filename)

Takes a screenshot of the current state.

See pytermgui.exporters.to_svg for more information.

Args
  • filename: The name of the file.
def show_positions(self) -> None:
543    def show_positions(self) -> None:
544        """Shows the positions of each Window's widgets."""
545
546        def _show_positions(widget, color_base: int = 60) -> None:
547            """Show positions of widget."""
548
549            if isinstance(widget, Container):
550                for i, subwidget in enumerate(widget):
551                    _show_positions(subwidget, color_base + i)
552
553                return
554
555            if not widget.is_selectable:
556                return
557
558            debug = widget.debug()
559            color = str_to_color(f"@{color_base}")
560            buff = color(" ", reset=False)
561
562            for i in range(min(widget.width, real_length(debug)) - 1):
563                buff += debug[i]
564
565            self.terminal.write(buff, pos=widget.pos)
566
567        for widget in self._windows:
568            _show_positions(widget)
569        self.terminal.flush()
570
571        getch()

Shows the positions of each Window's widgets.

def alert( self, *items, center: bool = True, **attributes) -> pytermgui.window_manager.window.Window:
573    def alert(self, *items, center: bool = True, **attributes) -> Window:
574        """Creates a modal popup of the given elements and attributes.
575
576        Args:
577            *items: All widget-convertable objects passed as children of the new window.
578            center: If set, `pytermgui.window_manager.window.center` is called on the window.
579            **attributes: kwargs passed as the new window's attributes.
580        """
581
582        window = Window(*items, is_modal=True, **attributes)
583
584        if center:
585            window.center()
586
587        self.add(window, assign=False)
588
589        return window

Creates a modal popup of the given elements and attributes.

Args
  • *items: All widget-convertable objects passed as children of the new window.
  • center: If set, pytermgui.window_manager.window.center is called on the window.
  • **attributes: kwargs passed as the new window's attributes.
def toast( self, *items, offset: int = 0, duration: int = 300, delay: int = 1000, **attributes) -> pytermgui.window_manager.window.Window:
591    def toast(
592        self,
593        *items,
594        offset: int = 0,
595        duration: int = 300,
596        delay: int = 1000,
597        **attributes,
598    ) -> Window:
599        """Creates a Material UI-inspired toast window of the given elements and attributes.
600
601        Args:
602            *items: All widget-convertable objects passed as children of the new window.
603            delay: The amount of time before the window will start animating out.
604            **attributes: kwargs passed as the new window's attributes.
605        """
606
607        # pylint: disable=no-value-for-parameter
608
609        toast = Window(*items, is_noblur=True, **attributes)
610
611        target_height = toast.height
612        toast.overflow = Overflow.HIDE
613
614        def _finish(_: Animation) -> None:
615            self.remove(toast, animate=False)
616
617        def _progressively_show(anim: Animation, invert: bool = False) -> bool:
618            height = int(anim.state * target_height)
619
620            toast.center()
621
622            if invert:
623                toast.height = target_height - 1 - height
624                toast.pos = (
625                    toast.pos[0],
626                    self.terminal.height - toast.height + 1 - offset,
627                )
628                return False
629
630            toast.height = height
631            toast.pos = (toast.pos[0], self.terminal.height - toast.height + 1 - offset)
632
633            return False
634
635        def _animate_toast_out(_: Animation) -> None:
636            animator.schedule(
637                FloatAnimation(
638                    delay,
639                    on_finish=lambda *_: animator.schedule(
640                        FloatAnimation(
641                            duration,
642                            on_step=lambda anim: _progressively_show(anim, invert=True),
643                            on_finish=_finish,
644                        )
645                    ),
646                )
647            )
648
649        leadup = FloatAnimation(
650            duration, on_step=_progressively_show, on_finish=_animate_toast_out
651        )
652
653        # pylint: enable=no-value-for-parameter
654
655        self.add(toast.center(), animate=False, assign=False)
656        self.focus(toast)
657        animator.schedule(leadup)
658
659        return toast

Creates a Material UI-inspired toast window of the given elements and attributes.

Args
  • *items: All widget-convertable objects passed as children of the new window.
  • delay: The amount of time before the window will start animating out.
  • **attributes: kwargs passed as the new window's attributes.