Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/toad/block_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ()
Expand Down
14 changes: 12 additions & 2 deletions src/toad/frame_presentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
4 changes: 2 additions & 2 deletions src/toad/widgets/future_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)),)
Expand Down
4 changes: 2 additions & 2 deletions src/toad/widgets/message_divider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/toad/widgets/session_tabs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/toad/widgets/splash_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/toad/widgets/switch_toast.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/toad/widgets/throbber.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions tests/textual/test_fade_frames_keep_content.py
Original file line number Diff line number Diff line change
@@ -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
Loading