From b32c6df17d36634ca88378d3458d08db0f8d9bbf Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 12 Nov 2026 22:18:15 -0500 Subject: [PATCH] Targeted animation repaint: invalidate only the effect's region MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit repaint_lines() cleared the entire styles cache and marked every line dirty on each animation tick. A 1-character cursor blink re-rendered 40+ lines of the CursorContainer viewport. The name wave on 10 small sidebar labels flushed 10 caches 20 times per second. FramePresentation.redraw() now calls _effect_region() to get the region the running effects actually paint. _effect_region() returns None by default (full invalidation, fine for 1-2 line widgets). CursorContainer overrides it to return just the cursor's visible rows — 1-2 lines instead of the full viewport height. Co-Authored-By: Claude Opus 4.6 (1M context) Claude-Session: https://claude.ai/code/session_01XbFMSnLEc51gw21bLFFRK1 --- src/toad/frame_presentation.py | 18 ++++++++++++++++-- src/toad/widgets/conversation.py | 7 +++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/toad/frame_presentation.py b/src/toad/frame_presentation.py index 541be9c76..3e8bbd805 100644 --- a/src/toad/frame_presentation.py +++ b/src/toad/frame_presentation.py @@ -12,6 +12,7 @@ from agent_comms.nominal.declared_family import DeclaredFamily from textual._paint_state import PaintState +from textual.geometry import Region from textual.app import ScreenStackError, UnknownModeError from textual import events from textual.strip import Strip @@ -537,8 +538,21 @@ def frame_moved(self) -> None: def redraw(self) -> None: """A frame paints over the lines (paint_effects in render_line); the content they - show is unchanged, so it is not rendered again.""" - self.repaint_lines() + show is unchanged, so it is not rendered again. Invalidate only the lines the + running effects actually paint; a one-row throbber or a two-row sidebar name + should not flush the styles cache of a 200-line conversation widget.""" + if not self.effects: + return + region = self._effect_region() + if region is not None: + self._invalidate_lines(region) + self._repaint_required = True + else: + self.repaint_lines() + + def _effect_region(self) -> Region | None: + """The region the running effects paint, or None for the whole widget.""" + return None class EffectInContent: diff --git a/src/toad/widgets/conversation.py b/src/toad/widgets/conversation.py index 47029deec..857bb699e 100644 --- a/src/toad/widgets/conversation.py +++ b/src/toad/widgets/conversation.py @@ -375,6 +375,13 @@ def on_mount(self) -> None: def animation_visible(self) -> bool: return super().animation_visible and self.query_ancestor(Window).has_focus + def _effect_region(self) -> Region | None: + cursor = self.query_ancestor(Conversation).navigation.cursor + visible = cursor.visible_region + if visible.height == 0: + return Region(0, 0, 1, 0) + return Region(0, visible.y - self.region.y, 1, visible.height) + def render_lines(self, crop: Region) -> list[Strip]: colours = self.app.colours strips = [colours.paint_mix(self.RAIL, Strip([Segment("▌")], cell_length=1))] * crop.height