From 0441a5d838090da1f8551b4d79b6e1a306a6ef09 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 27 Mar 2022 17:50:26 +0100 Subject: [PATCH] dirty regions --- examples/basic.css | 4 +++ src/textual/_compositor.py | 8 ++--- src/textual/message_pump.py | 2 +- src/textual/reactive.py | 6 ++-- src/textual/screen.py | 24 ++++++++----- src/textual/widget.py | 67 ++++++++++++++++--------------------- 6 files changed, 54 insertions(+), 57 deletions(-) diff --git a/examples/basic.css b/examples/basic.css index 95b25e453..ed125641c 100644 --- a/examples/basic.css +++ b/examples/basic.css @@ -8,6 +8,10 @@ App > Screen { text: on $primary; } +Widget:hover { + outline: solid green; +} + #sidebar { text: #09312e on #3caea3; dock: side; diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 7f7918c51..0141465c8 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -325,7 +325,7 @@ class Compositor: return Style.null() if widget not in self.regions: return Style.null() - lines = widget._get_lines() + lines = widget.get_render_lines() x -= region.x y -= region.y if y > len(lines): @@ -421,9 +421,9 @@ class Compositor: # log(widget, region) continue if region in clip: - yield region, clip, widget._get_lines() + yield region, clip, widget.get_render_lines() elif overlaps(clip, region): - lines = widget._get_lines() + lines = widget.get_render_lines() new_x, new_y, new_width, new_height = intersection(region, clip) delta_x = new_x - region.x delta_y = new_y - region.y @@ -538,14 +538,12 @@ class Compositor: """ if widget not in self.regions: return None - region, clip = self.regions[widget] if not region: return None update_region = region.intersection(clip) if not update_region: return None - widget.clear_render_cache() update_lines = self.render(crop=update_region).lines update = LayoutUpdate(update_lines, update_region) return update diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 7b1235e32..e79509f03 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -220,7 +220,7 @@ class MessagePump: if not self._closed: event = events.Idle(self) for method in self._get_dispatch_methods("on_idle", event): - await method(event) + await invoke(method, event) log("CLOSED", self) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 5978beaaf..27bfdaedb 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -77,10 +77,8 @@ class Reactive(Generic[ReactiveType]): setattr(obj, self.internal_name, value) self.check_watchers(obj, name, current_value) - if self.layout: - obj.refresh(layout=True) - elif self.repaint: - obj.refresh() + if self.layout or self.repaint: + obj.refresh(repaint=self.repaint, layout=self.layout) @classmethod def check_watchers(cls, obj: Reactable, name: str, old_value: Any) -> None: diff --git a/src/textual/screen.py b/src/textual/screen.py index 3e6a26b73..7ec2dfd25 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -27,6 +27,7 @@ class Screen(Widget): def __init__(self, name: str | None = None, id: str | None = None) -> None: super().__init__(name=name, id=id) self._compositor = Compositor() + self._dirty_widgets: list[Widget] = [] @property def is_transparent(self) -> bool: @@ -81,11 +82,21 @@ class Screen(Widget): """ return self._compositor.get_widget_region(widget) - async def refresh_layout(self) -> None: + def on_idle(self, event: events.Idle) -> None: + # Check for any widgets marked as 'dirty' (needs a repaint) + if self._dirty_widgets: + for widget in self._dirty_widgets: + # Repaint widgets + display_update = self._compositor.update_widget(self.console, widget) + if display_update is not None: + self.app.display(display_update) + # Reset dirty list + self._dirty_widgets.clear() + async def refresh_layout(self) -> None: + """Refresh the layout (can change size and positions of widgets).""" if not self.size: return - try: hidden, shown, resized = self._compositor.reflow(self, self.size) @@ -100,7 +111,7 @@ class Screen(Widget): for ( widget, - region, + _region, unclipped_region, virtual_size, container_size, @@ -112,20 +123,15 @@ class Screen(Widget): self, unclipped_region.size, virtual_size, container_size ) ) - except Exception: self.app.panic() - self.app.refresh() async def handle_update(self, message: messages.Update) -> None: message.stop() widget = message.widget assert isinstance(widget, Widget) - - display_update = self._compositor.update_widget(self.console, widget) - if display_update is not None: - self.app.display(display_update) + self._dirty_widgets.append(widget) async def handle_layout(self, message: messages.Layout) -> None: message.stop() diff --git a/src/textual/widget.py b/src/textual/widget.py index 9face8279..1f05e37db 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -81,26 +81,27 @@ class Widget(DOMNode): self._size = Size(0, 0) self._virtual_size = Size(0, 0) self._container_size = Size(0, 0) - self._repaint_required = False self._layout_required = False self._animate: BoundAnimator | None = None self._reactive_watches: dict[str, Callable] = {} self._mouse_over: bool = False - self.render_cache: RenderCache | None = None self.highlight_style: Style | None = None self._vertical_scrollbar: ScrollBar | None = None self._horizontal_scrollbar: ScrollBar | None = None + self._render_cache = RenderCache(Size(0, 0), []) + self._dirty_regions: list[Region] = [] + super().__init__(name=name, id=id, classes=classes) self.add_children(*children) has_focus = Reactive(False) mouse_over = Reactive(False) - scroll_x = Reactive(0.0) - scroll_y = Reactive(0.0) - scroll_target_x = Reactive(0.0) - scroll_target_y = Reactive(0.0) + scroll_x = Reactive(0.0, repaint=False) + scroll_y = Reactive(0.0, repaint=False) + scroll_target_x = Reactive(0.0, repaint=False) + scroll_target_y = Reactive(0.0, repaint=False) show_vertical_scrollbar = Reactive(False, layout=True) show_horizontal_scrollbar = Reactive(False, layout=True) @@ -208,6 +209,11 @@ class Widget(DOMNode): enabled = self.show_vertical_scrollbar, self.show_horizontal_scrollbar return enabled + def set_dirty(self) -> None: + """Set the Widget as 'dirty' (requiring re-render).""" + self._dirty_regions.clear() + self._dirty_regions.append(self.size.region) + def scroll_to( self, x: float | None = None, @@ -469,7 +475,7 @@ class Widget(DOMNode): self.app.update_styles() def on_style_change(self) -> None: - self.clear_render_cache() + self.set_dirty() def size_updated( self, size: Size, virtual_size: Size, container_size: Size @@ -494,36 +500,26 @@ class Widget(DOMNode): self.refresh() self.call_later(self._refresh_scrollbars) - def render_lines(self) -> None: + def _render_lines(self) -> None: width, height = self.size renderable = self.render_styled() options = self.console.options.update_dimensions(width, height) lines = self.console.render_lines(renderable, options) - self.render_cache = RenderCache(self.size, lines) + self._render_cache = RenderCache(self.size, lines) + self._dirty_regions.clear() - def _get_lines(self) -> Lines: + def get_render_lines(self) -> Lines: """Get segment lines to render the widget.""" - if self.render_cache is None: - self.render_lines() - assert self.render_cache is not None - lines = self.render_cache.lines + if self._dirty_regions: + self._render_lines() + lines = self._render_cache.lines return lines - def clear_render_cache(self) -> None: - self.render_cache = None - - def check_repaint(self) -> bool: - """Check if a repaint has been requested.""" - return self._repaint_required - def check_layout(self) -> bool: """Check if a layout has been requested.""" return self._layout_required - def reset_check_repaint(self) -> None: - self._repaint_required = False - def reset_check_layout(self) -> None: self._layout_required = False @@ -549,11 +545,9 @@ class Widget(DOMNode): layout (bool, optional): Also layout widgets in the view. Defaults to False. """ if layout: - self.clear_render_cache() self._layout_required = True - elif repaint: - self.clear_render_cache() - self._repaint_required = True + if repaint: + self.set_dirty() self.check_idle() def render(self) -> RenderableType: @@ -566,7 +560,6 @@ class Widget(DOMNode): # Default displays a pretty repr in the center of the screen label = self.css_identifier_styled - return Align.center(label, vertical="middle") async def action(self, action: str, *params) -> None: @@ -579,24 +572,22 @@ class Widget(DOMNode): self.log(self, f"IS NOT RUNNING, {message!r} not sent") return await super().post_message(message) - async def on_idle(self, event: events.Idle) -> None: + def on_idle(self, event: events.Idle) -> None: """Called when there are no more events on the queue. Args: event (events.Idle): Idle event. """ - # Check if the styles have chained + # Check if the styles have changed repaint, layout = self.styles.check_refresh() + if self._dirty_regions: + repaint = True if layout or self.check_layout(): - # self.render_cache = None - self.reset_check_repaint() self.reset_check_layout() - await self.screen.post_message(messages.Layout(self)) - elif repaint or self.check_repaint(): - # self.render_cache = None - self.reset_check_repaint() - await self.emit(messages.Update(self, self)) + self.screen.post_message_no_wait(messages.Layout(self)) + elif repaint: + self.emit_no_wait(messages.Update(self, self)) async def focus(self) -> None: """Give input focus to this widget."""