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)
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.
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.
The framerate the draw loop runs at.
Note
This will likely not be matched very accurately, mostly undershooting the given target.
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.
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.
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.
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.
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.
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.