pytermgui.window_manager.compositor

The Compositor class, which is used by the WindowManager to draw onto the terminal.

  1"""The Compositor class, which is used by the WindowManager to draw onto the terminal."""
  2
  3# pylint: disable=too-many-instance-attributes
  4
  5from __future__ import annotations
  6
  7import time
  8from threading import Thread
  9from typing import Iterator, List, Tuple
 10
 11from ..animations import animator
 12from ..enums import WidgetChange
 13from ..terminal import Terminal, get_terminal
 14from ..widgets import Widget
 15from .window import Window
 16
 17PositionedLineList = List[Tuple[Tuple[int, int], str]]
 18
 19
 20class Compositor:
 21    """The class used to draw `pytermgui.window_managers.manager.WindowManager` state.
 22
 23    This class handles turning a list of windows into a drawable buffer (composite),
 24    and then drawing it onto the screen.
 25
 26    Calling its `run` method will start the drawing thread, which will draw the current
 27    window states onto the screen. This routine targets `framerate`, though will likely
 28    not match it perfectly.
 29    """
 30
 31    def __init__(self, windows: list[Window], framerate: int) -> None:
 32        """Initializes the Compositor.
 33
 34        Args:
 35            windows: A list of the windows to be drawn.
 36        """
 37
 38        self._windows = windows
 39        self._is_running = False
 40
 41        self._previous: PositionedLineList = []
 42        self._frametime = 0.0
 43        self._should_redraw: bool = True
 44        self._cache: dict[int, list[str]] = {}
 45
 46        self.fps = 0
 47        self.framerate = framerate
 48
 49    @property
 50    def terminal(self) -> Terminal:
 51        """Returns the current global terminal."""
 52
 53        return get_terminal()
 54
 55    def _draw_loop(self) -> None:
 56        """A loop that draws at regular intervals."""
 57
 58        framecount = 0
 59        last_frame = fps_start_time = time.perf_counter()
 60
 61        while self._is_running:
 62            elapsed = time.perf_counter() - last_frame
 63
 64            if elapsed < self._frametime:
 65                time.sleep(self._frametime - elapsed)
 66                continue
 67
 68            animator.step(elapsed)
 69
 70            last_frame = time.perf_counter()
 71            self.draw()
 72
 73            framecount += 1
 74
 75            if last_frame - fps_start_time >= 1:
 76                self.fps = framecount
 77                fps_start_time = last_frame
 78                framecount = 0
 79
 80    # NOTE: This is not needed at the moment, but might be at some point soon.
 81    # def _get_lines(self, window: Window) -> list[str]:
 82    #     """Gets lines from the window, caching when possible.
 83
 84    #     This also applies the blurred style of the window, if it has no focus.
 85    #     """
 86
 87    #     if window.allow_fullscreen:
 88    #         window.pos = self.terminal.origin
 89    #         window.width = self.terminal.width
 90    #         window.height = self.terminal.height
 91
 92    #     return window.get_lines()
 93
 94    #     if window.has_focus or window.is_noblur:
 95    #         return window.get_lines()
 96
 97    #     _id = id(window)
 98    #     if not window.is_dirty and _id in self._cache:
 99    #         return self._cache[_id]
100
101    #     lines: list[str] = []
102    #     for line in window.get_lines():
103    #         if not window.has_focus:
104    #             line = tim.parse("[239]" + strip_ansi(line).replace("[", r"\["))
105
106    #         lines.append(line)
107
108    #     self._cache[_id] = lines
109    #     return lines
110
111    @staticmethod
112    def _iter_positioned(
113        widget: Widget, until: int | None = None
114    ) -> Iterator[tuple[tuple[int, int], str]]:
115        """Iterates through (pos, line) tuples from widget.get_lines()."""
116
117        # get_lines = widget.get_lines
118        # if isinstance(widget, Window):
119        #     get_lines = lambda *_: self._get_lines(widget)  # type: ignore
120
121        if until is None:
122            until = widget.height
123
124        for i, line in enumerate(widget.get_lines()[:until]):
125            if i >= until:
126                break
127
128            pos = (widget.pos[0], widget.pos[1] + i)
129
130            yield (pos, line)
131
132        for item in widget.positioned_line_buffer.copy():
133            yield item
134
135            widget.positioned_line_buffer.remove(item)
136
137    @property
138    def framerate(self) -> int:
139        """The framerate the draw loop runs at.
140
141        Note:
142            This will likely not be matched very accurately, mostly undershooting
143            the given target.
144        """
145
146        return self._framerate
147
148    @framerate.setter
149    def framerate(self, new: int) -> None:
150        """Updates the framerate."""
151
152        self._frametime = 1 / new
153        self._framerate = new
154
155    def clear_cache(self, window: Window) -> None:
156        """Clears the compositor's cache related to the given window."""
157
158        if id(window) in self._cache:
159            del self._cache[id(window)]
160
161    def run(self) -> None:
162        """Runs the compositor draw loop as a thread."""
163
164        self._is_running = True
165        Thread(name="CompositorDrawLoop", target=self._draw_loop, daemon=True).start()
166
167    def stop(self) -> None:
168        """Stops the compositor."""
169
170        self._is_running = False
171
172    def composite(self) -> PositionedLineList:
173        """Creates a composited buffer from the assigned windows.
174
175        Note that this is currently not used."""
176
177        lines = []
178        windows = self._windows
179
180        # Don't unnecessarily print under full screen windows
181        if any(window.allow_fullscreen for window in self._windows):
182            for window in reversed(self._windows):
183                if window.allow_fullscreen:
184                    windows = [window]
185                    break
186
187        size_changes = {WidgetChange.WIDTH, WidgetChange.HEIGHT, WidgetChange.SIZE}
188        for window in reversed(windows):
189            if not window.has_focus:
190                continue
191
192            change = window.get_change()
193
194            if change is None:
195                continue
196
197            if window.is_dirty or change in size_changes:
198                for pos, line in self._iter_positioned(window):
199                    lines.append((pos, line))
200
201                window.is_dirty = False
202                continue
203
204            if change is not None:
205                remaining = window.content_dimensions[1]
206
207                for widget in window.dirty_widgets:
208                    for pos, line in self._iter_positioned(widget, until=remaining):
209                        lines.append((pos, line))
210
211                    remaining -= widget.height
212
213                window.dirty_widgets = []
214                continue
215
216            if window.allow_fullscreen:
217                break
218
219        return lines
220
221    def set_redraw(self) -> None:
222        """Flags compositor for full redraw.
223
224        Note:
225            At the moment the compositor will always redraw the entire screen.
226        """
227
228        self._should_redraw = True
229
230    def draw(self, force: bool = False) -> None:
231        """Writes composited screen to the terminal.
232
233        At the moment this uses full-screen rewrites. There is a compositing
234        implementation in `composite`, but it is currently not performant enough to use.
235
236        Args:
237            force: When set, new composited lines will not be checked against the
238                previous ones, and everything will be redrawn.
239        """
240
241        # if self._should_redraw or force:
242        lines: PositionedLineList = []
243
244        for window in reversed(self._windows):
245            lines.extend(self._iter_positioned(window))
246
247        self._should_redraw = False
248
249        # else:
250        # lines = self.composite()
251
252        if not force and self._previous == lines:
253            return
254
255        buffer = "".join(f"\x1b[{pos[1]};{pos[0]}H{line}" for pos, line in lines)
256
257        self.terminal.clear_stream()
258        self.terminal.write(buffer)
259        self.terminal.flush()
260
261        self._previous = lines
262
263    def redraw(self) -> None:
264        """Force-redraws the buffer."""
265
266        self.draw(force=True)
267
268    def capture(self, title: str, filename: str | None = None) -> None:
269        """Captures the most-recently drawn buffer as `filename`.
270
271        See `pytermgui.exporters.to_svg` for more information.
272        """
273
274        with self.terminal.record() as recording:
275            self.redraw()
276
277        recording.save_svg(title=title, filename=filename)
class Compositor:
 21class Compositor:
 22    """The class used to draw `pytermgui.window_managers.manager.WindowManager` state.
 23
 24    This class handles turning a list of windows into a drawable buffer (composite),
 25    and then drawing it onto the screen.
 26
 27    Calling its `run` method will start the drawing thread, which will draw the current
 28    window states onto the screen. This routine targets `framerate`, though will likely
 29    not match it perfectly.
 30    """
 31
 32    def __init__(self, windows: list[Window], framerate: int) -> None:
 33        """Initializes the Compositor.
 34
 35        Args:
 36            windows: A list of the windows to be drawn.
 37        """
 38
 39        self._windows = windows
 40        self._is_running = False
 41
 42        self._previous: PositionedLineList = []
 43        self._frametime = 0.0
 44        self._should_redraw: bool = True
 45        self._cache: dict[int, list[str]] = {}
 46
 47        self.fps = 0
 48        self.framerate = framerate
 49
 50    @property
 51    def terminal(self) -> Terminal:
 52        """Returns the current global terminal."""
 53
 54        return get_terminal()
 55
 56    def _draw_loop(self) -> None:
 57        """A loop that draws at regular intervals."""
 58
 59        framecount = 0
 60        last_frame = fps_start_time = time.perf_counter()
 61
 62        while self._is_running:
 63            elapsed = time.perf_counter() - last_frame
 64
 65            if elapsed < self._frametime:
 66                time.sleep(self._frametime - elapsed)
 67                continue
 68
 69            animator.step(elapsed)
 70
 71            last_frame = time.perf_counter()
 72            self.draw()
 73
 74            framecount += 1
 75
 76            if last_frame - fps_start_time >= 1:
 77                self.fps = framecount
 78                fps_start_time = last_frame
 79                framecount = 0
 80
 81    # NOTE: This is not needed at the moment, but might be at some point soon.
 82    # def _get_lines(self, window: Window) -> list[str]:
 83    #     """Gets lines from the window, caching when possible.
 84
 85    #     This also applies the blurred style of the window, if it has no focus.
 86    #     """
 87
 88    #     if window.allow_fullscreen:
 89    #         window.pos = self.terminal.origin
 90    #         window.width = self.terminal.width
 91    #         window.height = self.terminal.height
 92
 93    #     return window.get_lines()
 94
 95    #     if window.has_focus or window.is_noblur:
 96    #         return window.get_lines()
 97
 98    #     _id = id(window)
 99    #     if not window.is_dirty and _id in self._cache:
100    #         return self._cache[_id]
101
102    #     lines: list[str] = []
103    #     for line in window.get_lines():
104    #         if not window.has_focus:
105    #             line = tim.parse("[239]" + strip_ansi(line).replace("[", r"\["))
106
107    #         lines.append(line)
108
109    #     self._cache[_id] = lines
110    #     return lines
111
112    @staticmethod
113    def _iter_positioned(
114        widget: Widget, until: int | None = None
115    ) -> Iterator[tuple[tuple[int, int], str]]:
116        """Iterates through (pos, line) tuples from widget.get_lines()."""
117
118        # get_lines = widget.get_lines
119        # if isinstance(widget, Window):
120        #     get_lines = lambda *_: self._get_lines(widget)  # type: ignore
121
122        if until is None:
123            until = widget.height
124
125        for i, line in enumerate(widget.get_lines()[:until]):
126            if i >= until:
127                break
128
129            pos = (widget.pos[0], widget.pos[1] + i)
130
131            yield (pos, line)
132
133        for item in widget.positioned_line_buffer.copy():
134            yield item
135
136            widget.positioned_line_buffer.remove(item)
137
138    @property
139    def framerate(self) -> int:
140        """The framerate the draw loop runs at.
141
142        Note:
143            This will likely not be matched very accurately, mostly undershooting
144            the given target.
145        """
146
147        return self._framerate
148
149    @framerate.setter
150    def framerate(self, new: int) -> None:
151        """Updates the framerate."""
152
153        self._frametime = 1 / new
154        self._framerate = new
155
156    def clear_cache(self, window: Window) -> None:
157        """Clears the compositor's cache related to the given window."""
158
159        if id(window) in self._cache:
160            del self._cache[id(window)]
161
162    def run(self) -> None:
163        """Runs the compositor draw loop as a thread."""
164
165        self._is_running = True
166        Thread(name="CompositorDrawLoop", target=self._draw_loop, daemon=True).start()
167
168    def stop(self) -> None:
169        """Stops the compositor."""
170
171        self._is_running = False
172
173    def composite(self) -> PositionedLineList:
174        """Creates a composited buffer from the assigned windows.
175
176        Note that this is currently not used."""
177
178        lines = []
179        windows = self._windows
180
181        # Don't unnecessarily print under full screen windows
182        if any(window.allow_fullscreen for window in self._windows):
183            for window in reversed(self._windows):
184                if window.allow_fullscreen:
185                    windows = [window]
186                    break
187
188        size_changes = {WidgetChange.WIDTH, WidgetChange.HEIGHT, WidgetChange.SIZE}
189        for window in reversed(windows):
190            if not window.has_focus:
191                continue
192
193            change = window.get_change()
194
195            if change is None:
196                continue
197
198            if window.is_dirty or change in size_changes:
199                for pos, line in self._iter_positioned(window):
200                    lines.append((pos, line))
201
202                window.is_dirty = False
203                continue
204
205            if change is not None:
206                remaining = window.content_dimensions[1]
207
208                for widget in window.dirty_widgets:
209                    for pos, line in self._iter_positioned(widget, until=remaining):
210                        lines.append((pos, line))
211
212                    remaining -= widget.height
213
214                window.dirty_widgets = []
215                continue
216
217            if window.allow_fullscreen:
218                break
219
220        return lines
221
222    def set_redraw(self) -> None:
223        """Flags compositor for full redraw.
224
225        Note:
226            At the moment the compositor will always redraw the entire screen.
227        """
228
229        self._should_redraw = True
230
231    def draw(self, force: bool = False) -> None:
232        """Writes composited screen to the terminal.
233
234        At the moment this uses full-screen rewrites. There is a compositing
235        implementation in `composite`, but it is currently not performant enough to use.
236
237        Args:
238            force: When set, new composited lines will not be checked against the
239                previous ones, and everything will be redrawn.
240        """
241
242        # if self._should_redraw or force:
243        lines: PositionedLineList = []
244
245        for window in reversed(self._windows):
246            lines.extend(self._iter_positioned(window))
247
248        self._should_redraw = False
249
250        # else:
251        # lines = self.composite()
252
253        if not force and self._previous == lines:
254            return
255
256        buffer = "".join(f"\x1b[{pos[1]};{pos[0]}H{line}" for pos, line in lines)
257
258        self.terminal.clear_stream()
259        self.terminal.write(buffer)
260        self.terminal.flush()
261
262        self._previous = lines
263
264    def redraw(self) -> None:
265        """Force-redraws the buffer."""
266
267        self.draw(force=True)
268
269    def capture(self, title: str, filename: str | None = None) -> None:
270        """Captures the most-recently drawn buffer as `filename`.
271
272        See `pytermgui.exporters.to_svg` for more information.
273        """
274
275        with self.terminal.record() as recording:
276            self.redraw()
277
278        recording.save_svg(title=title, filename=filename)

The class used to draw pytermgui.window_managers.manager.WindowManager state.

This class handles turning a list of windows into a drawable buffer (composite), and then drawing it onto the screen.

Calling its run method will start the drawing thread, which will draw the current window states onto the screen. This routine targets framerate, though will likely not match it perfectly.

Compositor( windows: list[pytermgui.window_manager.window.Window], framerate: int)
32    def __init__(self, windows: list[Window], framerate: int) -> None:
33        """Initializes the Compositor.
34
35        Args:
36            windows: A list of the windows to be drawn.
37        """
38
39        self._windows = windows
40        self._is_running = False
41
42        self._previous: PositionedLineList = []
43        self._frametime = 0.0
44        self._should_redraw: bool = True
45        self._cache: dict[int, list[str]] = {}
46
47        self.fps = 0
48        self.framerate = framerate

Initializes the Compositor.

Args
  • windows: A list of the windows to be drawn.
framerate: int

The framerate the draw loop runs at.

Note

This will likely not be matched very accurately, mostly undershooting the given target.

Returns the current global terminal.

def clear_cache(self, window: pytermgui.window_manager.window.Window) -> None:
156    def clear_cache(self, window: Window) -> None:
157        """Clears the compositor's cache related to the given window."""
158
159        if id(window) in self._cache:
160            del self._cache[id(window)]

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

def run(self) -> None:
162    def run(self) -> None:
163        """Runs the compositor draw loop as a thread."""
164
165        self._is_running = True
166        Thread(name="CompositorDrawLoop", target=self._draw_loop, daemon=True).start()

Runs the compositor draw loop as a thread.

def stop(self) -> None:
168    def stop(self) -> None:
169        """Stops the compositor."""
170
171        self._is_running = False

Stops the compositor.

def composite(self) -> List[Tuple[Tuple[int, int], str]]:
173    def composite(self) -> PositionedLineList:
174        """Creates a composited buffer from the assigned windows.
175
176        Note that this is currently not used."""
177
178        lines = []
179        windows = self._windows
180
181        # Don't unnecessarily print under full screen windows
182        if any(window.allow_fullscreen for window in self._windows):
183            for window in reversed(self._windows):
184                if window.allow_fullscreen:
185                    windows = [window]
186                    break
187
188        size_changes = {WidgetChange.WIDTH, WidgetChange.HEIGHT, WidgetChange.SIZE}
189        for window in reversed(windows):
190            if not window.has_focus:
191                continue
192
193            change = window.get_change()
194
195            if change is None:
196                continue
197
198            if window.is_dirty or change in size_changes:
199                for pos, line in self._iter_positioned(window):
200                    lines.append((pos, line))
201
202                window.is_dirty = False
203                continue
204
205            if change is not None:
206                remaining = window.content_dimensions[1]
207
208                for widget in window.dirty_widgets:
209                    for pos, line in self._iter_positioned(widget, until=remaining):
210                        lines.append((pos, line))
211
212                    remaining -= widget.height
213
214                window.dirty_widgets = []
215                continue
216
217            if window.allow_fullscreen:
218                break
219
220        return lines

Creates a composited buffer from the assigned windows.

Note that this is currently not used.

def set_redraw(self) -> None:
222    def set_redraw(self) -> None:
223        """Flags compositor for full redraw.
224
225        Note:
226            At the moment the compositor will always redraw the entire screen.
227        """
228
229        self._should_redraw = True

Flags compositor for full redraw.

Note

At the moment the compositor will always redraw the entire screen.

def draw(self, force: bool = False) -> None:
231    def draw(self, force: bool = False) -> None:
232        """Writes composited screen to the terminal.
233
234        At the moment this uses full-screen rewrites. There is a compositing
235        implementation in `composite`, but it is currently not performant enough to use.
236
237        Args:
238            force: When set, new composited lines will not be checked against the
239                previous ones, and everything will be redrawn.
240        """
241
242        # if self._should_redraw or force:
243        lines: PositionedLineList = []
244
245        for window in reversed(self._windows):
246            lines.extend(self._iter_positioned(window))
247
248        self._should_redraw = False
249
250        # else:
251        # lines = self.composite()
252
253        if not force and self._previous == lines:
254            return
255
256        buffer = "".join(f"\x1b[{pos[1]};{pos[0]}H{line}" for pos, line in lines)
257
258        self.terminal.clear_stream()
259        self.terminal.write(buffer)
260        self.terminal.flush()
261
262        self._previous = lines

Writes composited screen to the terminal.

At the moment this uses full-screen rewrites. There is a compositing implementation in composite, but it is currently not performant enough to use.

Args
  • force: When set, new composited lines will not be checked against the previous ones, and everything will be redrawn.
def redraw(self) -> None:
264    def redraw(self) -> None:
265        """Force-redraws the buffer."""
266
267        self.draw(force=True)

Force-redraws the buffer.

def capture(self, title: str, filename: str | None = None) -> None:
269    def capture(self, title: str, filename: str | None = None) -> None:
270        """Captures the most-recently drawn buffer as `filename`.
271
272        See `pytermgui.exporters.to_svg` for more information.
273        """
274
275        with self.terminal.record() as recording:
276            self.redraw()
277
278        recording.save_svg(title=title, filename=filename)

Captures the most-recently drawn buffer as filename.

See pytermgui.exporters.to_svg for more information.