From 690be62a8ded70d2484598ce5dec9e356d761ff8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 01:20:22 -0500 Subject: [PATCH] refresh_lines repaints a widget's lines without rendering its content again A full refresh() reaches _set_dirty(), which adds the whole widget to _dirty_regions, so render_line rebuilds the content: right for "the content changed", wrong for an animation frame that only tints the same lines in render_line. Toad's arrival fades did exactly that, rebuilding every Markdown block and row of a fading entry each frame (rich Style construction was 19% of the live main thread). refresh_lines() invalidates and repaints every line from the content already rendered; _invalidate_lines is the half _set_dirty already did, now owned once and inherited by both. Co-Authored-By: Claude Opus 5.5 --- src/textual/widget.py | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 0e8b0912fe..cd7d1b6fa7 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2546,16 +2546,19 @@ def _set_dirty(self, *regions: Region) -> None: self._styles_cache.set_dirty(*widget_regions) else: self._dirty_regions.clear() - self._repaint_regions.clear() - self._styles_cache.clear() - # Invalidation must not measure a lazily invalidated compositor. - # Mark the last committed local content bounds; the next geometry - # commit dirties its new bounds independently. - self._styles_cache.set_dirty(self._size.region.shrink(self.geometry.box_gutter).reset_offset) - outer_size = self.outer_size - self._dirty_regions.add(outer_size.region) - if outer_size: - self._repaint_regions.add(outer_size.region) + self._invalidate_lines() + self._dirty_regions.add(self.outer_size.region) + + def _invalidate_lines(self) -> None: + """Every line is painted again on the next frame, from whatever content is rendered.""" + self._repaint_regions.clear() + self._styles_cache.clear() + # Invalidation must not measure a lazily invalidated compositor. + # Mark the last committed local content bounds; the next geometry + # commit dirties its new bounds independently. + self._styles_cache.set_dirty(self._size.region.shrink(self.geometry.box_gutter).reset_offset) + if outer_size := self.outer_size: + self._repaint_regions.add(outer_size.region) def _exchange_repaint_regions(self) -> Collection[Region]: """Get a copy of the regions which need a repaint, and clear internal cache. @@ -4242,6 +4245,19 @@ def refresh( self.check_idle() return self + def refresh_lines(self) -> Self: + """Paint every line again from the content already rendered. + + For a change over the lines and not in them: an animation frame tinting the same + text. `refresh()` renders the content again; this keeps it and re-runs `render_line`. + """ + if not self._is_mounted: + return self.refresh() + self._invalidate_lines() + self._repaint_required = True + self.check_idle() + return self + def remove(self) -> AwaitRemove: """Remove the Widget from the DOM (effectively deleting it).