Skip to content

Targeted animation repaint: invalidate only the effect's region - #1756

Merged
trissim merged 1 commit into
mainfrom
perf/targeted-animation-repaint
Nov 13, 2026
Merged

trissim merged 1 commit into
mainfrom
perf/targeted-animation-repaint

Conversation

@trissim

@trissim trissim commented Nov 13, 2026

Copy link
Copy Markdown

Summary

  • FramePresentation.redraw() invalidated the entire widget's styles cache on every animation tick
  • A 1-character cursor blink re-rendered 40+ lines of the CursorContainer viewport
  • redraw() now calls _effect_region() to get the actual painted region, invalidating only those lines
  • CursorContainer overrides _effect_region() to return the cursor's visible rows (1-2 lines, not the full viewport)
  • Small widgets (sidebar labels, throbbers) return None and use full invalidation, which is already cheap at 1-2 lines

Why it was expensive

repaint_lines() → _invalidate_lines() (no regions) → _styles_cache.clear() → marks EVERY Y coordinate dirty → compositor re-runs render_line() for each dirty line → paint_effects() resolves and blends colours per segment per line

Test plan

  • Import check passes
  • Install and measure Toad main CPU (target: <10%)

🤖 Generated with Claude Code

https://claude.ai/code/session_01XbFMSnLEc51gw21bLFFRK1

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbFMSnLEc51gw21bLFFRK1
Copilot AI balanced review requested due to automatic review settings November 13, 2026 03:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@trissim
trissim merged commit b04adb8 into main Nov 13, 2026
1 of 2 checks passed
@github-actions

Copy link
Copy Markdown

debt-src at b32c6df blocks on 10 lines:

grew NoneAbsence:src/toad/frame_presentation.py +2
grew NoneAbsence:src/toad/widgets/conversation.py +1
grew TypeErrors:src/toad/frame_presentation.py +1
grew TypeErrors:src/toad/widgets/conversation.py +3
found NoneAbsence:src/toad/frame_presentation.py at src/toad/frame_presentation.py:553 src/toad/frame_presentation.py:547 src/toad/frame_presentation.py:135
found NoneAbsence:src/toad/widgets/conversation.py at src/toad/widgets/conversation.py:2055 src/toad/widgets/conversation.py:2018 src/toad/widgets/conversation.py:1918 src/toad/widgets/conversation.py:1888 src/toad/widgets/conversation.py:1880 src/toad/widgets/conversation.py:1842 src/toad/widgets/conversation.py:1699 src/toad/widgets/conversation.py:1698 src/toad/widgets/conversation.py:1677 src/toad/widgets/conversation.py:1675 src/toad/widgets/conversation.py:1669 src/toad/widgets/conversation.py:1668 src/toad/widgets/conversation.py:1595 src/toad/widgets/conversation.py:1571 src/toad/widgets/conversation.py:1571 src/toad/widgets/conversation.py:1538 src/toad/widgets/conversation.py:1527 src/toad/widgets/conversation.py:1492 src/toad/widgets/conversation.py:1473 src/toad/widgets/conversation.py:1471 src/toad/widgets/conversation.py:1457 src/toad/widgets/conversation.py:1312 src/toad/widgets/conversation.py:1308 src/toad/widgets/conversation.py:1309 src/toad/widgets/conversation.py:1227 src/toad/widgets/conversation.py:1179 src/toad/widgets/conversation.py:1136 src/toad/widgets/conversation.py:897 src/toad/widgets/conversation.py:881 src/toad/widgets/conversation.py:869 src/toad/widgets/conversation.py:817 src/toad/widgets/conversation.py:781 src/toad/widgets/conversation.py:763 src/toad/widgets/conversation.py:755 src/toad/widgets/conversation.py:752 src/toad/widgets/conversation.py:738 src/toad/widgets/conversation.py:737 src/toad/widgets/conversation.py:730 src/toad/widgets/conversation.py:729 src/toad/widgets/conversation.py:728 src/toad/widgets/conversation.py:727 src/toad/widgets/conversation.py:705 src/toad/widgets/conversation.py:704 src/toad/widgets/conversation.py:703 src/toad/widgets/conversation.py:702 src/toad/widgets/conversation.py:677 src/toad/widgets/conversation.py:662 src/toad/widgets/conversation.py:530 src/toad/widgets/conversation.py:378
found TypeErrors:src/toad/frame_presentation.py at src/toad/frame_presentation.py:548 ("AnimationEffects[FrameT]" has no attribute "_invalidate_lines"  [attr-defined])
found TypeErrors:src/toad/widgets/conversation.py at src/toad/widgets/conversation.py:381 ("Unplaced" has no attribute "height"  [attr-defined]) src/toad/widgets/conversation.py:383 ("Unplaced" has no attribute "y"  [attr-defined]) src/toad/widgets/conversation.py:383 ("Unplaced" has no attribute "height"  [attr-defined])
introduced NoneAbsence:src/toad/frame_presentation.py at lines [547, 553]
introduced NoneAbsence:src/toad/widgets/conversation.py at lines [378]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants