Skip to content

Add streaming text reveal animation - #81

Open
danglingP0inter wants to merge 6 commits into
gonzalezreal:mainfrom
danglingP0inter:feat/text-reveal-animation
Open

Add streaming text reveal animation#81
danglingP0inter wants to merge 6 commits into
gonzalezreal:mainfrom
danglingP0inter:feat/text-reveal-animation

Conversation

@danglingP0inter

@danglingP0inter danglingP0inter commented Aug 1, 2026

Copy link
Copy Markdown

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/InlineText as it arrives from a language model. Today, updating markdown: on every chunk has two problems:

  1. Foundation's 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.
  2. New content just pops onto screen with each update; there's no way to reveal it smoothly, and naively animating it risks making the response feel like it's arriving slower than it actually is.

This PR addresses both.

Scope

In scope:

  • StreamingMarkdownBuffer — withholds incomplete Markdown constructs from streamed text until they resolve
  • RevealAnimation config + .textual.textStreaming(_:) / .textual.revealAnimation(_:) modifiers
  • RevealTextRenderer — subtle per-glyph-cluster opacity + blur fade (iOS 18 / macOS 15 TextRenderer, already the package minimum)
  • Block-level integration — only the last, actively-growing block/list item animates or costs anything; everything else is immediately full-opacity, zero-overhead, and fully selectable
  • A companion height animation so a block/item's container grows smoothly as new lines stream in
  • Selection suppression scoped to just the actively-revealing fragment
  • Demo wiring: bursty-delivery simulation + auto-scroll to follow the growing content

Explicitly out of scope / deferred:

  • Full CommonMark-compliant incremental parsing — StreamingMarkdownBuffer is a lightweight heuristic scanner, not a complete parser (see Known Limitations)

Public API

public struct StreamingMarkdownBuffer: Sendable {
  public init(pendingTimeout: TimeInterval = 2.0)
  public mutating func append(_ chunk: String) -> String
  public mutating func flush() -> String
}

public struct RevealAnimation: Sendable, Hashable {
  public var glyphsPerSecond: Double                 // default 45
  public var clusterSize: Int                        // default 6   (4...8)
  public var fadeWindow: Double                       // default 9
  public var blurRadius: CGFloat                      // default 1
  public var heightAnimation: Animation?               // default .easeOut(duration: 0.25)
  public var maxBacklogDuration: TimeInterval          // default 0.6
  public var streamEndCatchUpDuration: TimeInterval    // default 0.15
  public var isEnabled: Bool                           // default true

  public static let `default`: RevealAnimation
  public static let disabled: RevealAnimation
}

extension TextualNamespace where Base: View {
  public func textStreaming(_ isStreaming: Bool) -> some View
  public func revealAnimation(_ revealAnimation: RevealAnimation) -> some View
}

Usage:

var buffer = StreamingMarkdownBuffer()
// ...each time a chunk arrives from your network layer:
visibleMarkdown = buffer.append(chunk)
// ...once the stream ends:
visibleMarkdown = buffer.flush()

StructuredText(markdown: visibleMarkdown)
  .textual.textStreaming(isStillStreaming)
  .textual.revealAnimation(.default)

Implementation details

1. StreamingMarkdownBuffer — preventing raw syntax flashes

Maintains a safeIndex into the accumulated source. On each append, it incrementally rescans forward from that index only (never re-scanning already-confirmed text as the document grows), tracking:

  • Delimiter parity for **/__ (strong) and */_ (emphasis) — with a flanking-whitespace check, so 2 * 3 isn't mistaken for an emphasis opener
  • Inline code spans (backtick runs, matched by run length)
  • Fenced code blocks — an unclosed fence withholds its entire body, since showing raw fenced content as a plain paragraph and then having it snap into a monospace code block is a bigger visual jump than just holding it back
  • Link/image syntax — an in-progress [text]( with no closing ) yet stays withheld; [text] with no ( following is recognized as plain brackets, not a pending link

A 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 itself

Walks every line/run/slice of a Text.Layout as 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 to revealedGlyphCount:

  • Not yet revealed → skipped entirely (no draw call)
  • Fully settled → drawn normally, no filter/opacity overhead
  • Mid fade-window → drawn with smoothstep-eased opacity and a tapering blur

No 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 timing

Rather than driving the fade through SwiftUI's withAnimation/Animatable interpolation, TextFragment maintains 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. A TimelineView only 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.effectiveRate keep the reveal from ever adding latency:

  • Backlog cap: if the visible reveal would fall more than maxBacklogDuration (0.6s) behind what's actually arrived — one oversized burst, or a source sustaining a token rate faster than glyphsPerSecond — the rate temporarily speeds up to bring it back within budget, rather than trailing indefinitely.
  • Stream-end fast-finish: the instant the app signals streaming has ended, any remaining backlog resolves within 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.classify compares 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, and UnorderedList each narrow the textStreamingEnabled environment value to true only for the last block/item at their level, false for 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 the Text.LayoutKey preference from every descendant Text at the StructuredText/InlineText level, not per-fragment — so a simple environment read at that ancestor can't scope suppression to one fragment. Instead, an actively-revealing TextFragment clears its own Text.LayoutKey contribution via .transformPreference, reusing the exact technique Overflow.swift already 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:

  • Chunks its markdown into randomly-sized (6–20 char), deliberately unaligned pieces released on a jittered delay — simulating bursty server delivery realistically enough that chunk boundaries frequently land mid-construct (e.g. inside **bold**), exercising StreamingMarkdownBuffer
  • Feeds those chunks through a StreamingMarkdownBuffer into the real public .textual.textStreaming(_:) / .textual.revealAnimation(_:) API — no shortcuts
  • Auto-scrolls to follow the growing content via ScrollViewReader + an invisible bottom anchor
Untitled.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 defaults
  • RevealTextRendererTests / BlockStreamingNarrowingTestsImageRenderer-based smoke tests (macOS) confirming the renderer and nested list/quote streaming compose without crashing across various progress values and configurations

Rest of the existing suite is green. CodeTokenizerTests fails identically on main in 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

  • StreamingMarkdownBuffer is 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).
  • Reduce Motion is respected (the renderer is skipped outright, avoiding its blur-filter cost entirely), but hasn't been exercised against other assistive technologies beyond that.

danglingP0inter and others added 6 commits August 1, 2026 11:30
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>
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.

1 participant