Add streaming text reveal animation - #81
Open
danglingP0inter wants to merge 6 commits into
Open
Conversation
Adds a placeholder demo page with rich StructuredText content and a Start Animation CTA, as groundwork for a future iOS 18 TextRenderer-based reveal animation similar to ChatGPT/Claude's streaming text. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a reusable ChatGPT/Claude-style reveal animation for text streamed incrementally (e.g. from a language model), plus a StreamingMarkdownBuffer utility that withholds incomplete Markdown constructs from the tail until they resolve, so raw syntax never flashes on screen. - RevealAnimation + .textual.textStreaming(_:)/.revealAnimation(_:) public API, following the existing EmojiProperties/environment-value convention - RevealTextRenderer: subtle per-glyph-cluster opacity+blur fade, driven by a checkpoint-based reveal clock (RevealState/RevealClock) instead of SwiftUI's withAnimation, so pacing stays perfectly uniform under bursty delivery and is bounded to never lag more than a fraction of a second behind what has actually arrived (and fast-finishes the moment streaming ends) — the animation is a smoothing layer, never a source of latency - BlockContent/OrderedList/UnorderedList narrow streaming to only the last, actively-growing block/item at each nesting level, so settled content pays zero cost and stays fully selectable - Wires the TextualDemo "Reveal Text Animation" screen to simulate real bursty server delivery, with auto-scroll following the growing content Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fadeWindow: 9 → 80 glyphs. clusterSize stays at 6 so the wider window still gets a smooth per-glyph gradient rather than a chunkier step; widening clusterSize alongside it would have reduced resolution across the window instead of improving flow. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
RevealTextRenderer.progress now clamps a glyph cluster's local fade window to the content's total length, so revealedGlyphCount (capped at its own target once caught up) can always reach progress 1. Without this, content shorter than fadeWindow — or the trailing cluster of any content — could get stuck below full opacity indefinitely. This was the real cause of a table-cell "ghosting" bug: since every cell inherited textStreamingEnabled uniformly from Table (no per-cell narrowing), and cell content is typically short, cells sat at low opacity until the table was superseded by the next block. Table.swift now mirrors the same last-block narrowing pattern already used in BlockContent/OrderedList/UnorderedList, so only the bottom-right cell is ever active — combined with the renderer fix, tables now animate correctly with no ghosting. Also adds a sample comparison table to the demo content to exercise this path, and 9 new tests covering the exact regression plus streaming-table smoke tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reverts a local, machine-specific Xcode package reference (XCLocalSwiftPackageReference pointing at the sibling ../../../textual checkout) that shouldn't be part of shared history — it was picked up incidentally from local development. Kept as an uncommitted local working-tree change so demo builds still work on this machine. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
RevealTextRenderer previously clamped a glyph's local fade window to the content's total length so short content could still reach full opacity. But the total grows as streaming continues, and that clamp's denominator grew retroactively for clusters whose position never moved — an already-fully-revealed glyph could silently drop back below full opacity the instant more content arrived, visible as text flashing from settled back to faded. Moves that concern out of the renderer, which is now a pure function of (clusterStart, revealedGlyphCount, fadeWindow) only — never of a mutable total, so progress can't regress. RevealState instead pads the reveal clock's ceiling (target + fadeWindow) past the raw target, so trailing glyphs of any content still naturally reach full opacity via the plain, monotonic formula. Adds 5 regression tests, including one that replays the exact bug scenario end-to-end through the real renderer formula. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a reusable, ChatGPT/Claude-style text reveal animation for content streamed incrementally into Textual (e.g. an LLM response arriving over the network in bursts), plus a companion utility that prevents raw Markdown syntax from flashing on screen while it streams. Also wires the
TextualDemo"Reveal Text Animation" screen to a simulated bursty-delivery source so the whole pipeline can be exercised end-to-end.Motivation
Apps built on Textual commonly stream Markdown into
StructuredText/InlineTextas it arrives from a language model. Today, updatingmarkdown:on every chunk has two problems:AttributedString(markdown:)(which Textual's built-in parser wraps) isn't streaming-aware — an unmatched delimiter (**,`,[, an unclosed code fence) renders as literal text until it closes, so raw syntax characters visibly flash and then vanish the instant the construct completes.This PR addresses both.
Scope
In scope:
StreamingMarkdownBuffer— withholds incomplete Markdown constructs from streamed text until they resolveRevealAnimationconfig +.textual.textStreaming(_:)/.textual.revealAnimation(_:)modifiersRevealTextRenderer— subtle per-glyph-cluster opacity + blur fade (iOS 18 / macOS 15TextRenderer, already the package minimum)Explicitly out of scope / deferred:
StreamingMarkdownBufferis a lightweight heuristic scanner, not a complete parser (see Known Limitations)Public API
Usage:
Implementation details
1.
StreamingMarkdownBuffer— preventing raw syntax flashesMaintains a
safeIndexinto the accumulated source. On eachappend, it incrementally rescans forward from that index only (never re-scanning already-confirmed text as the document grows), tracking:**/__(strong) and*/_(emphasis) — with a flanking-whitespace check, so2 * 3isn't mistaken for an emphasis opener[text](with no closing)yet stays withheld;[text]with no(following is recognized as plain brackets, not a pending linkA
pendingTimeout(default 2s) safety valve releases a stuck construct anyway, so a genuine non-formatting*(e.g. multiplication) isn't hidden forever. This is a deliberately lightweight heuristic scan, not a full incremental CommonMark parser — documented as such in the doc comment.2.
RevealTextRenderer— the fade itselfWalks every line/run/slice of a
Text.Layoutas one continuous glyph-index sequence — there's no special-casing at line boundaries, so the fade continues seamlessly across a wrapped line "for free." For each glyph, based on its position relative torevealedGlyphCount:smoothstep-easedopacityand a taperingblurNo translation/slide anywhere. Attachment placeholders (inline images/emoji) render as a single run slice, so they're already revealed as one atomic unit with no extra handling.
3.
RevealState/RevealClock— pacing decoupled from burst size and timingRather than driving the fade through SwiftUI's
withAnimation/Animatableinterpolation,TextFragmentmaintains an explicit checkpoint — a glyph count, the date it was set, and the rate to advance from it — and computes the currently-displayed value as a pure function of elapsed wall-clock time. ATimelineViewonly mounts (and only ticks) while there's backlog to drain, unmounting the instant the reveal catches up — no continuous per-frame cost once settled.Two rules in
RevealClock.effectiveRatekeep the reveal from ever adding latency:maxBacklogDuration(0.6s) behind what's actually arrived — one oversized burst, or a source sustaining a token rate faster thanglyphsPerSecond— the rate temporarily speeds up to bring it back within budget, rather than trailing indefinitely.streamEndCatchUpDuration(0.15s) instead of continuing at the nominal pace.Net effect: the reveal is a smoothing/pacing layer, never a gate — it can never make the complete response take meaningfully longer to fully appear than the underlying data took to arrive.
RevealDiff.classifycompares old/new content on each update (common-prefix based) to distinguish a pure append from the rare case where a reparse alters already-revealed text — defense in depth beyond the Markdown buffer. It never animates a "re-hide"; it snaps forward instantly to cover the new safe prefix instead.4. Block-level integration — only the active leaf pays any cost
BlockContent,OrderedList, andUnorderedListeach narrow thetextStreamingEnabledenvironment value totrueonly for the last block/item at their level,falsefor every other sibling. This composes correctly through nesting (list-in-list, quote-containing-list) because each level re-reads its own inherited value and reapplies the same last-index check — no shared/global state needed. A companion.animation(revealAnimation.heightAnimation, value:)scoped to just the active block lets its container grow smoothly as new lines stream in, instead of the default snap.5. Selection interop
Textual's selection (
TextSelectionInteraction) aggregates theText.LayoutKeypreference from every descendantTextat theStructuredText/InlineTextlevel, not per-fragment — so a simple environment read at that ancestor can't scope suppression to one fragment. Instead, an actively-revealingTextFragmentclears its ownText.LayoutKeycontribution via.transformPreference, reusing the exact techniqueOverflow.swiftalready applies to keep horizontally-scrolled regions out of the shared selection collection. Every other, already-settled fragment stays selectable throughout.6. Demo
TextualDemo's "Reveal Text Animation" screen now:**bold**), exercisingStreamingMarkdownBufferStreamingMarkdownBufferinto the real public.textual.textStreaming(_:)/.textual.revealAnimation(_:)API — no shortcutsScrollViewReader+ an invisible bottom anchorUntitled.mov
Testing
25 new tests under
Tests/TextualTests/Streaming/, all passing:StreamingMarkdownBufferTests— mid-construct chunk boundaries for every delimiter type, the timeout safety valve,flush()RevealDiffTests/RevealClockTests/RevealStateTests— pure-function coverage of the diffing algorithm, pacing/backlog-cap/fast-finish math, and the checkpoint state machine (including "never un-reveal" and "burst mid-reveal checkpoints from current position" cases)RevealAnimationTests— config defaultsRevealTextRendererTests/BlockStreamingNarrowingTests—ImageRenderer-based smoke tests (macOS) confirming the renderer and nested list/quote streaming compose without crashing across various progress values and configurationsRest of the existing suite is green.
CodeTokenizerTestsfails identically onmainin this environment (a pre-existing Prism resource-bundle loading issue, unrelated to this change) — verified before and after.Manual verification
Built and ran in the iOS Simulator (iPhone 17, iOS 26). Confirmed: no raw Markdown delimiters ever flash; reveal pace stays smooth and visually independent of how choppy the simulated bursts are; blur reads as subtle rather than distracting; block height grows smoothly as lines stream in; auto-scroll follows the content; selection is suppressed only on the actively-revealing fragment while earlier content stays selectable; an artificially large burst catches up within the backlog budget instead of lagging further behind.
Known limitations
StreamingMarkdownBufferis a heuristic scanner, not a full CommonMark incremental parser — escaped delimiters (\*) aren't specially recognized, and it doesn't track every edge case a true parser would (documented in the type's doc comment).