diff --git a/src/toad/block_navigation.py b/src/toad/block_navigation.py index 9c229fd81..003ce22fe 100644 --- a/src/toad/block_navigation.py +++ b/src/toad/block_navigation.py @@ -360,7 +360,7 @@ def starts_agent_activity(self, boundary: AgentActivityBoundary) -> bool: def repaint(self) -> None: # The widgets inside the entry draw its rows. for widget in cast(Widget, self).walk_children(with_self=True): - widget.refresh() + widget.refresh_lines() def notification_targets(self): return () diff --git a/src/toad/frame_presentation.py b/src/toad/frame_presentation.py index 0287b6adf..d34ff9b9c 100644 --- a/src/toad/frame_presentation.py +++ b/src/toad/frame_presentation.py @@ -311,11 +311,11 @@ async def on_unmount(self) -> None: def on_hide(self) -> None: for effect in tuple(self.effects): self.effects.pop(effect).stop() - self.refresh() + self.repaint() def on_show(self) -> None: self.start_wanted() - self.refresh() + self.repaint() def paint_effects(self, strip: Strip, *frames: Mix) -> Strip: # Background-origin text rises from the widget's tone; accent flashes @@ -345,6 +345,16 @@ def animation_visible(self) -> bool: return False return screen is current and self in screen._compositor.visible_widgets + def repaint(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.refresh_lines() + + +class EffectInContent: + """A widget whose content itself reads the effect frame (in render or its strips), so each + frame renders the content again instead of repainting the same lines.""" + def repaint(self) -> None: self.refresh() diff --git a/src/toad/widgets/future_text.py b/src/toad/widgets/future_text.py index 14d1d5574..cd0c74c7e 100644 --- a/src/toad/widgets/future_text.py +++ b/src/toad/widgets/future_text.py @@ -11,11 +11,11 @@ from ui_theme.effects import Wipe, Band from ui_theme.scheme import AccentColour, PrimaryColour -from toad.frame_presentation import Animated +from toad.frame_presentation import Animated, EffectInContent from toad.native_style import AutoWidth, NoWrap, OneRow -class FutureText(AutoWidth, OneRow, NoWrap, Animated[Band], Static): +class FutureText(EffectInContent, AutoWidth, OneRow, NoWrap, Animated[Band], Static): """Reveal one text from elapsed time, then leave it still.""" native_theme = (NativeDeclaration.of(Styles.color, Variable(PrimaryColour.name)),) diff --git a/src/toad/widgets/message_divider.py b/src/toad/widgets/message_divider.py index 2dfe846f4..5419dd46f 100644 --- a/src/toad/widgets/message_divider.py +++ b/src/toad/widgets/message_divider.py @@ -17,7 +17,7 @@ from toad.block_content import BlockContent from toad.block_navigation import CategorizedBlock, ConversationBlock -from toad.frame_presentation import Animated +from toad.frame_presentation import Animated, EffectInContent from toad.line_blocks import Divider, Gap, LineBlock from toad.native_style import ( AutoHeight, @@ -121,7 +121,7 @@ def pin(self, pinned: PinnedHeader) -> None: self.display = bool(pinned.headers) -class MessageDivider(SpacedAbove, Animated[Mix], ConversationLineBlock): +class MessageDivider(EffectInContent, SpacedAbove, Animated[Mix], ConversationLineBlock): """The rule above a live message: the same `Divider` block saved history draws. A message that has just arrived fades in from the accent colour. diff --git a/src/toad/widgets/session_tabs.py b/src/toad/widgets/session_tabs.py index 0c4ea9079..68d2677cb 100644 --- a/src/toad/widgets/session_tabs.py +++ b/src/toad/widgets/session_tabs.py @@ -34,7 +34,7 @@ from toad.core import session_requests from toad.core.events import SessionChangedEvent from toad.core_event_carrier import CoreEventReceiver -from toad.frame_presentation import Animated +from toad.frame_presentation import Animated, EffectInContent from toad.session_tracker import OpenTab from toad.app_node import ToadNode from toad.widgets.selection import ChangingText, ViewLabel @@ -136,7 +136,7 @@ def close_tab(self) -> None: TAB_SLIDE = Transition(Amount, 0.0, 1.0, duration=0.18, frames_per_second=60) -class Underline(WidgetView, Animated[Amount]): +class Underline(EffectInContent, WidgetView, Animated[Amount]): """The track under the tab cells, marking the selected tab's cells. It is laid out under the cells themselves (TabCells), so it spans every diff --git a/src/toad/widgets/splash_text.py b/src/toad/widgets/splash_text.py index d15816d93..a485a630e 100644 --- a/src/toad/widgets/splash_text.py +++ b/src/toad/widgets/splash_text.py @@ -8,7 +8,7 @@ from ui_theme.scheme import ColourScheme from ui_theme.splash import loading_splash -from toad.frame_presentation import Animated +from toad.frame_presentation import Animated, EffectInContent from toad.block_navigation import ConversationBlock from toad.native_style import ( AutoHeight, @@ -20,7 +20,7 @@ ) -class SplashText(Animated[TextFrame], Static): +class SplashText(EffectInContent, Animated[TextFrame], Static): """Shows ``TEXT`` plainly until its effect's frames arrive, then plays them. The frames are built in a render worker, once per colour scheme. A diff --git a/src/toad/widgets/switch_toast.py b/src/toad/widgets/switch_toast.py index 13b84d550..1a2958cf5 100644 --- a/src/toad/widgets/switch_toast.py +++ b/src/toad/widgets/switch_toast.py @@ -196,7 +196,7 @@ def on_show(self) -> None: def repaint(self) -> None: """The clock's paint request: the toast leaves the layout once its swell ends.""" self.display = self.effect_phase(TAB_TOAST).in_motion - self.refresh() + self.refresh_lines() def render_line(self, y: int, paint: PaintState) -> Strip: colours = self.app.colours diff --git a/src/toad/widgets/throbber.py b/src/toad/widgets/throbber.py index fd0d67439..0e937230e 100644 --- a/src/toad/widgets/throbber.py +++ b/src/toad/widgets/throbber.py @@ -15,7 +15,7 @@ from ui_theme.effects import ACTIVITY_SWEEP, Ribbon from ui_theme.scheme import TurnBandColour -from toad.frame_presentation import Animated +from toad.frame_presentation import Animated, EffectInContent from toad.native_style import FullWidth, OneRow from toad.textual_colours import BlockInk, BottomInk @@ -45,7 +45,7 @@ def get_height(self, rules: TextFormat, width: int) -> int: return 1 -class Throbber(FullWidth, OneRow, Animated[Ribbon]): +class Throbber(EffectInContent, FullWidth, OneRow, Animated[Ribbon]): _paint_only_render = True tone = TurnBandColour # The row is always reserved; only its paint and animation change. diff --git a/tests/textual/test_fade_frames_keep_content.py b/tests/textual/test_fade_frames_keep_content.py new file mode 100644 index 000000000..fa9fa2725 --- /dev/null +++ b/tests/textual/test_fade_frames_keep_content.py @@ -0,0 +1,27 @@ +"""A fade paints over the lines an entry already rendered: each frame re-paints them, and the +content (Markdown blocks, rows, a new rich Style per segment) is rendered once, not per frame. +The live profile had rich Style construction at 19% of the main thread from exactly this.""" + +from textual.widget import Widget + +from toad.widgets.agent_response import AgentResponse + + +async def test_an_arrival_fade_renders_its_entry_once(workspace, monkeypatch): + rendered: list[Widget] = [] + render = Widget._render_content + + def counting(self, paint): + rendered.append(self) + return render(self, paint) + + monkeypatch.setattr(Widget, "_render_content", counting) + async with workspace.app(size=(120, 40)) as ui: + block = await ui.conversation.post(AgentResponse("")) + await block.append_fragment("Streamed agent text that fades in as it arrives") + await ui.step() + rendered.clear() + for _ in range(20): + await ui.pilot.pause(0.025) + + assert sum(isinstance(widget, AgentResponse) for widget in rendered) <= 1