InstantPage V2: AI streaming animation for rich-data bubbles

Spec, plan, and full implementation of the AI-message streaming
animation in ChatMessageRichDataBubbleContentNode. Extracts
TextRevealController into a shared StreamingTextReveal submodule;
precomputes per-character rects in V2 layout (RTL-safe, glyph-ink
bounds); splits InstantPageV2TextView into render container + render
view with a reveal mask layer; implements mask + snippet pop-in
reveal; adds a per-text-view reveal cost map + applyReveal extension;
switches reveal cost to a width-based unit; sizes / clips the bubble
to the revealed prefix during streaming; aligns the Thinking… header
with TextBubble; floors table cell reveal cost at cell frame width;
includes layout closing pad in revealedContentSize; documents the
non-obvious invariants in CLAUDE.md.
This commit is contained in:
isaac 2026-05-20 00:34:07 +08:00
parent a53ef1f299
commit 562de27c30
12 changed files with 1460 additions and 125 deletions

View file

@ -46,6 +46,34 @@ This matters in two places specifically:
Rare exceptions: top-level view-controller views integrating with the system's first-responder/inset model. If you find yourself wanting `self.frame = …` from inside a child view, refactor so the parent positions it instead.
## AI streaming animation (rich-text bubbles)
`ChatMessageRichDataBubbleContentNode` shows a "Thinking…" shimmer header and progressively reveals InstantPage V2 content while `TypingDraftMessageAttribute` is on the message. Mirrors the older animation in `ChatMessageTextBubbleContentNode`, adapted to the heterogeneous V2 layout.
Spec: [`docs/superpowers/specs/2026-05-19-richdata-streaming-animation-design.md`](docs/superpowers/specs/2026-05-19-richdata-streaming-animation-design.md). Plan: [`docs/superpowers/plans/2026-05-19-richdata-streaming-animation.md`](docs/superpowers/plans/2026-05-19-richdata-streaming-animation.md).
### Where things live
| File | Responsibility |
|---|---|
| `submodules/TelegramUI/Components/StreamingTextReveal/Sources/TextRevealController.swift` | Pacing controller, shared by both bubbles. EWMA inter-arrival → velocity-smoothed cursor. |
| `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` (`InstantPageV2TextView`) | Drawing split: private `TextRenderView` does `draw(_)` inside a `renderContainer` whose layer carries a `revealMaskLayer`; new chars spawn cropped `SnippetLayer` siblings of the render container that animate in (blur + alpha + scale + position) and are absorbed into the mask on completion. Ported from `InteractiveTextComponent`. |
| `submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift` | `InstantPageV2RevealCostMap` + `InstantPageV2View.applyReveal(revealedCount:costMap:animated:)`. Bridges the global width-based cursor to per-text-view char counts (via `charCountForWidthBudget`) and per-item visibility / table-row pop-in. |
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `InstantPageTextLine.characterRects` (line-local CT coords, baseline-relative positive-up) populated when `computeRevealCharacterRects: true` is passed to `layoutInstantPageV2(...)`. Uses `CTFontGetBoundingRectsForGlyphs` for actual glyph ink, not advance widths. |
| `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/...` | Streaming detection (`TypingDraftMessageAttribute`), "Thinking…" header layout, display-link wiring, container sizing. |
### Non-obvious invariants
- **Cost unit is points of width, not characters.** Each item's cost = its width in points along the reading direction. Text contributes sum of glyph ink widths; non-text items contribute `frame.width`. Table cells are floored at `cell.frame.width` so narrow- or empty-cell tables don't race through the cursor. Reveal pace becomes "points per second" — uniform across content types.
- **Mask uses per-glyph ink bounds, unioned per line.** Each revealed glyph's mask rect comes from `CTFontGetBoundingRectsForGlyphs` (not advance widths) so italics, accents, descenders are covered exactly. Per line, glyphs are unioned into one mask rect; consecutive fully-revealed lines union further — fully-revealed prefix is always one `CALayer`.
- **`containerNode` does ALL the clipping.** During streaming, containerNode is sized to `streamingHeaderOffset + revealedItemsMaxY` (no closing pad). The bubble itself is taller (`revealedContentSize.height + 2`) — the strip below containerNode is empty bubble background. pageView keeps its full `pageLayout.contentSize`; anything past containerNode's bottom is clipped at containerNode (`clipsToBounds = true` set in init). Do NOT shorten the pageView or set `pageView.clipsToBounds`.
- **The pageView is rebuilt on every `stableVersion` bump.** V2View's render context is constructor-fixed, so each AI chunk creates a brand-new `InstantPageV2View`. The reveal cursor survives on `TextRevealController` (owned by the bubble). The seed call `applyReveal(revealedCount: previousAnimateGlyphCount, …, animated: false)` after `ensurePageView` re-applies state to the fresh V2View so there's no flash of full-text-then-mask.
- **Layout cache key includes `message.stableVersion`.** Each AI chunk bumps stableVersion; without this the cached layout would shadow newly-arrived content.
- **`TypingDraftMessageAttribute` is the streaming gate.** Same trigger TextBubble uses. The InstantPage's `isComplete` flag is informational only.
- **Width-based cost → char count bridge.** Mask APIs (`updateRevealCharacterCount`) still take character counts. `applyRevealEntry` calls `charCountForWidthBudget(textItem:widthBudget:)` to translate the width-based local cursor into the per-text-view character count.
- **`Thinking…` header positioning matches TextBubble.** `streamingTextFrame.origin = (bubbleInsets.left - textInsets.left, topInset - textInsets.top)`. `streamingHeaderOffset` = visible bottom + 1pt spacing = where pageView's `frame.origin.y` and statusFrame y-shift attach. Bubble minimum width includes `visible_thinking_width + bubbleInsets.left + bubbleInsets.right + 2`.
- **Display-link tick re-layouts on extent change.** Tick reads `revealedContentSize` at the new cursor; if the height differs from the previous cursor, calls `requestFullUpdate`. So the bubble grows in flight when the cursor crosses a line/item boundary, not just between chunks. Tick passes `animated: true` to `applyReveal` to fire the snippet pop-in.
## Postbox → TelegramEngine refactor (in progress)
A gradual migration is underway to eliminate direct `import Postbox` from consumer submodules in favor of `TelegramEngine`.

View file

@ -30,6 +30,7 @@ swift_library(
"//submodules/TranslateUI:TranslateUI",
"//submodules/Tuples:Tuples",
"//third-party/SwiftMath:SwiftMath",
"//submodules/ComponentFlow:ComponentFlow",
],
visibility = [
"//visibility:public",

View file

@ -7,6 +7,7 @@ import TelegramPresentationData
import TelegramUIPreferences
import AccountContext
import GalleryUI
import ComponentFlow
// MARK: - Stable item identity (for view reuse on re-layouts)
@ -82,7 +83,7 @@ public final class InstantPageV2View: UIView {
/// Invoked when a details title is tapped. Bubble routes to its expand-state mutation + requestUpdate.
public var detailsTapped: ((_ index: Int) -> Void)?
private var itemViews: [InstantPageItemView] = []
var itemViews: [InstantPageItemView] = []
private var itemViewStableIds: [InstantPageV2StableItemId] = []
public let renderContext: InstantPageV2RenderContext?
@ -448,12 +449,31 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2TextItem
var itemFrame: CGRect { return self.item.frame }
private let renderContainer: UIView
private let renderView: TextRenderView
// Reveal mask state populated in Task 5.
private var maxCharacterDrawCount: Int?
private var previousMaxCharacterDrawCount: Int = 0
private var revealMaskLayer: SimpleLayer?
private var revealLineMaskLayers: [SimpleLayer] = []
private var animatingSnippetLayers: [SnippetLayer] = []
init(item: InstantPageV2TextItem) {
self.item = item
self.renderContainer = UIView()
self.renderView = TextRenderView(item: item)
super.init(frame: item.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset))
self.backgroundColor = .clear
self.isOpaque = false
self.contentMode = .redraw
self.renderContainer.frame = self.bounds
self.renderContainer.backgroundColor = .clear
self.renderContainer.isOpaque = false
self.addSubview(self.renderContainer)
self.renderView.frame = self.bounds
self.renderContainer.addSubview(self.renderView)
}
@available(*, unavailable)
@ -464,7 +484,324 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
func update(item: InstantPageV2TextItem, theme: InstantPageTheme) {
let _ = theme
self.item = item
self.setNeedsDisplay()
self.renderView.item = item
self.renderView.setNeedsDisplay()
}
func updateRevealCharacterCount(value: Int?, animated: Bool) {
if self.maxCharacterDrawCount == value {
return
}
self.maxCharacterDrawCount = value
self.updateRevealMask(animateNewSegments: animated)
}
private struct RevealLineInfo {
let lineFrame: CGRect
let lineHeight: CGFloat
let revealedWidth: CGFloat
let isFull: Bool
let isRTL: Bool
}
private func computeRevealedLines(characterLimit: Int) -> [RevealLineInfo] {
var result: [RevealLineInfo] = []
var remainingCharacters = characterLimit
let textItem = self.item.textItem
let boundsWidth = textItem.frame.size.width
for line in textItem.lines {
let lineFrame = v2FrameForLine(line, boundingWidth: boundsWidth, alignment: textItem.alignment)
// Translate from textItem-local to renderView-local coords (renderView's draw(_) translates by the inset).
let renderLocalLineFrame = lineFrame.offsetBy(dx: v2TextViewClippingInset, dy: v2TextViewClippingInset)
let lineHeight = lineFrame.size.height
guard let characterRects = line.characterRects else {
let revealedWidth: CGFloat = remainingCharacters > 0 ? renderLocalLineFrame.width : 0.0
result.append(RevealLineInfo(lineFrame: renderLocalLineFrame, lineHeight: lineHeight, revealedWidth: revealedWidth, isFull: remainingCharacters > 0, isRTL: line.isRTL))
if remainingCharacters > 0 {
remainingCharacters -= line.range.length
}
continue
}
if remainingCharacters <= 0 {
result.append(RevealLineInfo(lineFrame: renderLocalLineFrame, lineHeight: lineHeight, revealedWidth: 0.0, isFull: false, isRTL: line.isRTL))
continue
}
let revealCount = min(characterRects.count, remainingCharacters)
var revealedWidth: CGFloat = 0.0
if line.isRTL {
var minX: CGFloat = .greatestFiniteMagnitude
for j in 0 ..< revealCount {
let rect = characterRects[j]
if !rect.isEmpty {
minX = min(minX, rect.minX)
}
}
if minX != .greatestFiniteMagnitude {
revealedWidth = ceil(renderLocalLineFrame.width - minX)
}
} else {
for j in 0 ..< revealCount {
let rect = characterRects[j]
if !rect.isEmpty {
revealedWidth = max(revealedWidth, rect.maxX)
}
}
revealedWidth = ceil(revealedWidth)
}
remainingCharacters -= characterRects.count
let isFull = remainingCharacters >= 0
result.append(RevealLineInfo(lineFrame: renderLocalLineFrame, lineHeight: lineHeight, revealedWidth: revealedWidth, isFull: isFull, isRTL: line.isRTL))
}
return result
}
private func updateRevealMask(animateNewSegments: Bool) {
let textItem = self.item.textItem
let lines = textItem.lines
let boundsWidth = textItem.frame.size.width
let layerSize = self.renderContainer.bounds.size
let effectiveCharacterDrawCount: Int
if let maxCharacterDrawCount = self.maxCharacterDrawCount {
effectiveCharacterDrawCount = maxCharacterDrawCount
} else {
if self.previousMaxCharacterDrawCount > 0 || !self.animatingSnippetLayers.isEmpty {
var totalCharCount = 0
for line in lines {
if let characterRects = line.characterRects {
totalCharCount += characterRects.count
} else {
totalCharCount += line.range.length
}
}
effectiveCharacterDrawCount = totalCharCount
} else {
if let _ = self.revealMaskLayer {
self.renderContainer.layer.mask = nil
self.revealMaskLayer = nil
self.revealLineMaskLayers.removeAll()
}
self.previousMaxCharacterDrawCount = 0
return
}
}
let revealMaskLayer: SimpleLayer
if let existing = self.revealMaskLayer {
revealMaskLayer = existing
} else {
revealMaskLayer = SimpleLayer()
revealMaskLayer.backgroundColor = UIColor.clear.cgColor
self.revealMaskLayer = revealMaskLayer
self.renderContainer.layer.mask = revealMaskLayer
}
revealMaskLayer.frame = CGRect(origin: .zero, size: layerSize)
let currentLineInfos = self.computeRevealedLines(characterLimit: effectiveCharacterDrawCount)
// Snippet spawn pass animate newly-revealed characters.
if self.previousMaxCharacterDrawCount < effectiveCharacterDrawCount,
let contents = self.renderView.layer.contents,
animateNewSegments {
let containerOrigin = self.renderContainer.frame.origin
var previousRemaining = self.previousMaxCharacterDrawCount
var currentRemaining = effectiveCharacterDrawCount
var globalCharIndex = 0
for i in 0 ..< lines.count {
let line = lines[i]
let lineInfo = currentLineInfos[i]
guard let characterRects = line.characterRects else { continue }
let lineCharCount = characterRects.count
let prevCount = min(max(0, previousRemaining), lineCharCount)
let curCount = min(max(0, currentRemaining), lineCharCount)
previousRemaining -= lineCharCount
currentRemaining -= lineCharCount
if curCount <= prevCount {
globalCharIndex += lineCharCount
continue
}
for j in prevCount ..< curCount {
let charRect = characterRects[j]
if charRect.isEmpty { continue }
let snippetRect = CGRect(
x: lineInfo.lineFrame.minX + charRect.origin.x,
y: lineInfo.lineFrame.minY,
width: charRect.width,
height: lineInfo.lineHeight
)
if snippetRect.width < 0.5 { continue }
let contentsRect = CGRect(
x: snippetRect.minX / layerSize.width,
y: snippetRect.minY / layerSize.height,
width: snippetRect.width / layerSize.width,
height: snippetRect.height / layerSize.height
)
let snippetLayer = SnippetLayer(characterIndex: globalCharIndex + j)
snippetLayer.contents = contents
snippetLayer.contentsRect = contentsRect
snippetLayer.contentsScale = self.renderView.layer.contentsScale
snippetLayer.contentsGravity = self.renderView.layer.contentsGravity
snippetLayer.frame = snippetRect.offsetBy(dx: containerOrigin.x, dy: containerOrigin.y)
self.layer.addSublayer(snippetLayer)
self.animatingSnippetLayers.append(snippetLayer)
ComponentTransition(animation: .curve(duration: 0.22, curve: .easeInOut)).animateBlur(layer: snippetLayer, fromRadius: 2.0, toRadius: 0.0)
snippetLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
snippetLayer.animatePosition(from: CGPoint(x: 0.0, y: 6.0), to: .zero, duration: 0.2, additive: true)
snippetLayer.animateScale(from: 0.5, to: 1.0, duration: 0.2, completion: { [weak self, weak snippetLayer] _ in
guard let self, let snippetLayer else { return }
snippetLayer.removeFromSuperlayer()
self.animatingSnippetLayers.removeAll(where: { $0 === snippetLayer })
self.updateRevealMask(animateNewSegments: false)
})
}
globalCharIndex += lineCharCount
}
}
// Mask rebuild when snippets are in flight, clamp to the lowest animating one
// (so the mask never exposes a char a snippet is still flying for). With no animations
// in flight, snap directly to the current target `previousMaxCharacterDrawCount`
// would lag by one call (it's updated at the end of this function) and is 0 on a fresh
// view, which would hide every char until the next tick.
let maskCharacterLimit: Int
if let lowestAnimating = self.animatingSnippetLayers.min(by: { $0.characterIndex < $1.characterIndex })?.characterIndex {
maskCharacterLimit = lowestAnimating
} else {
maskCharacterLimit = effectiveCharacterDrawCount
}
// Build mask rects from each revealed glyph's ink bbox, unioned per line.
// Per-character rects are stored in line-local CT coords (y positive-up,
// baseline-relative; rect.minY is negative for descenders); convert to
// renderContainer-local UIKit coords as:
// x = renderLocalLineFrame.minX + rect.minX
// y = renderLocalLineFrame.minY + lineAscent - rect.maxY
// where `lineAscent = lineFrame.size.height` (top of line frame baseline).
//
// Per-glyph rect captures descenders, italic overhang, accents exactly. Per
// line we accumulate the union of revealed glyphs into one mask rect (one
// CALayer sublayer per line), and consecutive fully-revealed lines collapse
// further into a single rect so a fully-revealed prefix is always one
// sublayer regardless of line count.
//
// Lines without per-character data (computeRevealCharacterRects == false on
// a non-streaming layout) fall back to a line-spanning rect, treated as a
// full line for merging.
var maskRects: [CGRect] = []
var pendingFullPrefix: CGRect? = nil
var remainingChars = maskCharacterLimit
for line in lines {
if remainingChars <= 0 {
break
}
let lineFrame = v2FrameForLine(line, boundingWidth: boundsWidth, alignment: textItem.alignment)
let renderLocalLineFrame = lineFrame.offsetBy(dx: v2TextViewClippingInset, dy: v2TextViewClippingInset)
let lineAscent = lineFrame.size.height
let lineUnion: CGRect?
let isFullLine: Bool
if let characterRects = line.characterRects {
let revealCount = min(characterRects.count, remainingChars)
isFullLine = revealCount >= characterRects.count
var union: CGRect? = nil
for j in 0 ..< revealCount {
let rect = characterRects[j]
if rect.isEmpty {
continue
}
let glyphRect = CGRect(
x: renderLocalLineFrame.minX + rect.minX,
y: renderLocalLineFrame.minY + lineAscent - rect.maxY,
width: rect.width,
height: rect.height
)
union = union?.union(glyphRect) ?? glyphRect
}
lineUnion = union
remainingChars -= characterRects.count
} else {
// No per-character data expose the whole line.
lineUnion = line.range.length > 0 ? renderLocalLineFrame : nil
isFullLine = remainingChars >= line.range.length
remainingChars -= line.range.length
}
guard let lineUnion else {
continue
}
if isFullLine {
pendingFullPrefix = pendingFullPrefix?.union(lineUnion) ?? lineUnion
} else {
if let pending = pendingFullPrefix {
maskRects.append(pending)
pendingFullPrefix = nil
}
maskRects.append(lineUnion)
}
}
if let pending = pendingFullPrefix {
maskRects.append(pending)
}
while self.revealLineMaskLayers.count < maskRects.count {
let childLayer = SimpleLayer()
childLayer.backgroundColor = UIColor.white.cgColor
revealMaskLayer.addSublayer(childLayer)
self.revealLineMaskLayers.append(childLayer)
}
while self.revealLineMaskLayers.count > maskRects.count {
let removed = self.revealLineMaskLayers.removeLast()
removed.removeFromSuperlayer()
}
for i in 0 ..< maskRects.count {
self.revealLineMaskLayers[i].frame = maskRects[i]
}
self.previousMaxCharacterDrawCount = effectiveCharacterDrawCount
if self.maxCharacterDrawCount == nil && self.animatingSnippetLayers.isEmpty {
self.renderContainer.layer.mask = nil
self.revealMaskLayer = nil
self.revealLineMaskLayers.removeAll()
}
}
}
private final class TextRenderView: UIView {
var item: InstantPageV2TextItem
init(item: InstantPageV2TextItem) {
self.item = item
super.init(frame: .zero)
self.backgroundColor = .clear
self.isOpaque = false
self.contentMode = .redraw
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
@ -549,6 +886,32 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
}
}
/// Private snippet layer for the streaming reveal pop-in animation.
/// Each instance represents one character cropped from the renderView's backing texture,
/// animating in (blur + alpha + position + scale) before the mask absorbs its rect.
private final class SnippetLayer: SimpleLayer {
let characterIndex: Int
init(characterIndex: Int) {
self.characterIndex = characterIndex
super.init()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override init(layer: Any) {
if let other = layer as? SnippetLayer {
self.characterIndex = other.characterIndex
} else {
self.characterIndex = 0
}
super.init(layer: layer)
}
}
// MARK: - Divider view
final class InstantPageV2DividerView: UIView, InstantPageItemView {
@ -755,10 +1118,10 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
private(set) var item: InstantPageV2DetailsItem
var itemFrame: CGRect { return self.item.frame }
private let titleTextView: InstantPageV2TextView
let titleTextView: InstantPageV2TextView
private let chevronLayer: CALayer
private let separator: UIView
private var bodyView: InstantPageV2View?
var bodyView: InstantPageV2View?
private let titleHitView: UIView
var onTitleTapped: ((Int) -> Void)?
@ -906,7 +1269,7 @@ final class InstantPageV2CodeBlockView: UIView, InstantPageItemView {
var itemFrame: CGRect { return self.item.frame }
private let backgroundLayer: CALayer
private let textView: InstantPageV2TextView
let textView: InstantPageV2TextView
init(item: InstantPageV2CodeBlockItem) {
self.item = item
@ -953,9 +1316,9 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
var itemFrame: CGRect { return self.item.frame }
private let scrollView: UIScrollView
private let contentView: UIView
private var titleSubView: InstantPageV2View?
private var cellSubViews: [InstantPageV2View] = []
let contentView: UIView
var titleSubView: InstantPageV2View?
var cellSubViews: [InstantPageV2View] = []
private var stripeLayers: [CALayer] = []
private var lineLayers: [CALayer] = []

View file

@ -80,8 +80,9 @@ public final class InstantPageTextLine {
let formulaItems: [InstantPageTextFormulaRun]
public let anchorItems: [InstantPageTextAnchorItem]
let isRTL: Bool
public let characterRects: [CGRect]? // line-local, one rect per character in `range`; nil = not computed
init(line: CTLine, range: NSRange, frame: CGRect, strikethroughItems: [InstantPageTextStrikethroughItem], underlineItems: [InstantPageTextUnderlineItem], markedItems: [InstantPageTextMarkedItem], imageItems: [InstantPageTextImageItem], formulaItems: [InstantPageTextFormulaRun], anchorItems: [InstantPageTextAnchorItem], isRTL: Bool) {
init(line: CTLine, range: NSRange, frame: CGRect, strikethroughItems: [InstantPageTextStrikethroughItem], underlineItems: [InstantPageTextUnderlineItem], markedItems: [InstantPageTextMarkedItem], imageItems: [InstantPageTextImageItem], formulaItems: [InstantPageTextFormulaRun], anchorItems: [InstantPageTextAnchorItem], isRTL: Bool, characterRects: [CGRect]? = nil) {
self.line = line
self.range = range
self.frame = frame
@ -92,6 +93,7 @@ public final class InstantPageTextLine {
self.formulaItems = formulaItems
self.anchorItems = anchorItems
self.isRTL = isRTL
self.characterRects = characterRects
}
}

View file

@ -298,7 +298,7 @@ public struct InstantPageV2AnchorItem {
public let name: String
}
// MARK: - Public entry points (stubs; filled in later tasks)
// MARK: - Public entry points
public func layoutInstantPageV2(
webpage: TelegramMediaWebpage,
@ -311,7 +311,8 @@ public func layoutInstantPageV2(
dateTimeFormat: PresentationDateTimeFormat,
cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight?,
expandedDetails: [Int: Bool],
fitToWidth: Bool
fitToWidth: Bool,
computeRevealCharacterRects: Bool = false
) -> InstantPageV2Layout {
guard case let .Loaded(loadedContent) = webpage.content else {
return InstantPageV2Layout(contentSize: .zero, items: [], detailsIndices: [])
@ -335,6 +336,7 @@ public func layoutInstantPageV2(
cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight,
rtl: instantPage.rtl,
fitToWidth: fitToWidth,
computeRevealCharacterRects: computeRevealCharacterRects,
mediaIndexCounter: 0,
detailsIndexCounter: 0,
expandedDetails: expandedDetails
@ -404,6 +406,7 @@ private struct LayoutContext {
let cachedMessageSyntaxHighlight: CachedMessageSyntaxHighlight?
let rtl: Bool
let fitToWidth: Bool
let computeRevealCharacterRects: Bool
var mediaIndexCounter: Int = 0
var detailsIndexCounter: Int = 0
@ -879,7 +882,8 @@ private func layoutDetails(
offset: CGPoint(x: horizontalInset, y: 12.0),
media: context.media,
webpage: context.webpage,
fitToWidth: context.fitToWidth
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
guard let titleTextItem = titleTextItem else { return [] }
@ -982,7 +986,8 @@ private func layoutTable(
media: context.media,
webpage: context.webpage,
minimizeWidth: true,
fitToWidth: context.fitToWidth
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
).0 {
minCellWidth = shortestItem.effectiveWidth() + totalCellPadding
}
@ -992,7 +997,8 @@ private func layoutTable(
offset: CGPoint(),
media: context.media,
webpage: context.webpage,
fitToWidth: context.fitToWidth
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
).0 {
maxCellWidth = max(minCellWidth, longestItem.effectiveWidth() + totalCellPadding)
}
@ -1464,7 +1470,8 @@ private func layoutCaptionAndCredit(
offset: CGPoint(x: horizontalInset, y: y),
media: context.media,
webpage: context.webpage,
fitToWidth: context.fitToWidth
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
totalHeight += captionSize.height
y += captionSize.height
@ -1492,7 +1499,8 @@ private func layoutCaptionAndCredit(
offset: CGPoint(x: horizontalInset, y: y),
media: context.media,
webpage: context.webpage,
fitToWidth: context.fitToWidth
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
totalHeight += creditSize.height
items.append(contentsOf: creditItems)
@ -1633,7 +1641,8 @@ private func layoutSimpleText(
offset: CGPoint(x: horizontalInset, y: 0.0),
media: context.media,
webpage: context.webpage,
fitToWidth: context.fitToWidth
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
return items
}
@ -1654,7 +1663,8 @@ private func layoutHeading(
offset: CGPoint(x: horizontalInset, y: 0.0),
media: context.media,
webpage: context.webpage,
fitToWidth: context.fitToWidth
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
return items
}
@ -1678,7 +1688,8 @@ private func layoutParagraph(
offset: CGPoint(x: horizontalInset, y: 0.0),
media: context.media,
webpage: context.webpage,
fitToWidth: context.fitToWidth
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
return items
}
@ -1749,7 +1760,8 @@ private func layoutAuthorDate(
offset: CGPoint(x: horizontalInset, y: 0.0),
media: context.media,
webpage: context.webpage,
fitToWidth: context.fitToWidth
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
return items
}
@ -1813,7 +1825,8 @@ private func layoutCodeBlock(
media: context.media,
webpage: context.webpage,
fitToWidth: context.fitToWidth,
opaqueBackground: true
opaqueBackground: true,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
guard let textItem = textItem else { return [] }
@ -1895,7 +1908,8 @@ private func layoutBlockQuote(
offset: CGPoint(x: textX, y: contentHeight),
media: context.media,
webpage: context.webpage,
fitToWidth: context.fitToWidth
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
result.append(contentsOf: bodyItems)
contentHeight += bodySize.height // V1 line 530/567
@ -1917,7 +1931,8 @@ private func layoutBlockQuote(
offset: CGPoint(x: textX, y: contentHeight),
media: context.media,
webpage: context.webpage,
fitToWidth: context.fitToWidth
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
result.append(contentsOf: captionItems)
contentHeight += captionSize.height // V1 lines 542/582
@ -2031,7 +2046,8 @@ private func layoutList(
attrStr,
boundingWidth: boundingWidth - horizontalInset * 2.0,
offset: .zero,
fitToWidth: context.fitToWidth
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
let w: CGFloat
if let textItem = textItem, let firstLine = textItem.lines.first {
@ -2083,7 +2099,8 @@ private func layoutList(
offset: CGPoint(x: textX, y: contentHeight),
media: context.media,
webpage: context.webpage,
fitToWidth: context.fitToWidth
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
// Compute marker vertical position: align to mid of first text line.
@ -2437,7 +2454,8 @@ func layoutTextItem(
minimizeWidth: Bool = false,
fitToWidth: Bool = false,
maxNumberOfLines: Int = 0,
opaqueBackground: Bool = false
opaqueBackground: Bool = false,
computeRevealCharacterRects: Bool = false
) -> (InstantPageTextItem?, [InstantPageV2LaidOutItem], CGSize) {
if string.length == 0 {
return (nil, [], CGSize())
@ -2669,7 +2687,63 @@ func layoutTextItem(
}
}
}
let textLine = InstantPageTextLine(line: line, range: lineRange, frame: CGRect(x: workingLineOrigin.x, y: workingLineOrigin.y, width: lineWidth, height: height), strikethroughItems: strikethroughItems, underlineItems: underlineItems, markedItems: markedItems, imageItems: lineImageItems, formulaItems: lineFormulaItems, anchorItems: anchorItems, isRTL: isRTL)
// Per-character rects use each glyph's actual ink bounds via
// CTFontGetBoundingRectsForGlyphs caret-position advance-width
// math (CTLineGetOffsetForStringIndex) is too tight for italics,
// accented marks, and any glyph with side bearings, which causes
// the reveal mask to visibly clip the glyph edges. Mirrors
// InteractiveTextComponent.computeCharacterRectsForLine.
//
// For ligatures (one glyph for multiple chars), only the first
// char's slot is populated; the rest stay CGRect.zero and the
// consumer's `rect.isEmpty` guard skips them.
let lineCharacterRects: [CGRect]?
if computeRevealCharacterRects {
var rects = [CGRect](repeating: CGRect.zero, count: lineRange.length)
let glyphRuns = CTLineGetGlyphRuns(line) as NSArray
for run in glyphRuns {
let run = run as! CTRun
let glyphCount = CTRunGetGlyphCount(run)
if glyphCount == 0 {
continue
}
var glyphs = [CGGlyph](repeating: 0, count: glyphCount)
CTRunGetGlyphs(run, CFRangeMake(0, glyphCount), &glyphs)
var positions = [CGPoint](repeating: CGPoint.zero, count: glyphCount)
CTRunGetPositions(run, CFRangeMake(0, glyphCount), &positions)
var stringIndices = [CFIndex](repeating: 0, count: glyphCount)
CTRunGetStringIndices(run, CFRangeMake(0, glyphCount), &stringIndices)
let attributes = CTRunGetAttributes(run) as NSDictionary
guard let font = attributes[kCTFontAttributeName] as! CTFont? else {
continue
}
var boundingRects = [CGRect](repeating: CGRect.zero, count: glyphCount)
CTFontGetBoundingRectsForGlyphs(font, .default, &glyphs, &boundingRects, glyphCount)
for i in 0 ..< glyphCount {
let charIndex = stringIndices[i] - lineRange.location
if charIndex >= 0 && charIndex < lineRange.length {
let pos = positions[i]
let bbox = boundingRects[i]
rects[charIndex] = CGRect(
x: pos.x + bbox.origin.x,
y: pos.y + bbox.origin.y,
width: bbox.width,
height: bbox.height
)
}
}
}
lineCharacterRects = rects
} else {
lineCharacterRects = nil
}
let textLine = InstantPageTextLine(line: line, range: lineRange, frame: CGRect(x: workingLineOrigin.x, y: workingLineOrigin.y, width: lineWidth, height: height), strikethroughItems: strikethroughItems, underlineItems: underlineItems, markedItems: markedItems, imageItems: lineImageItems, formulaItems: lineFormulaItems, anchorItems: anchorItems, isRTL: isRTL, characterRects: lineCharacterRects)
lines.append(textLine)
imageItems.append(contentsOf: lineImageItems)

View file

@ -0,0 +1,550 @@
import Foundation
import UIKit
import QuartzCore
/// Opaque per-layout reveal-cost description. Public surface is just `total` (the running
/// length used to drive `TextRevealController`); internals are coupled to `applyReveal`'s
/// view-tree walk (same module, same file).
public struct InstantPageV2RevealCostMap {
public let total: Int
fileprivate let topLevelEntries: [Entry]
}
extension InstantPageV2RevealCostMap {
fileprivate enum Entry {
case text(start: Int, end: Int)
case nonText(start: Int, end: Int)
case details(start: Int, end: Int, body: InstantPageV2RevealCostMap?)
case codeBlock(start: Int, end: Int)
case table(start: Int, end: Int, rows: [TableRow], title: InstantPageV2RevealCostMap?)
}
fileprivate struct TableRow {
let startCount: Int
let cells: [InstantPageV2RevealCostMap?]
}
}
// Reveal cost is in width units (points along the reading direction). The unit is uniform
// across all item kinds, so the streaming reveal pace ("points per second") is visually
// consistent wide tables and media take proportionally longer than narrow inline text.
//
// For text items, the cost is the sum of glyph ink widths across all lines (the total ink
// extent in reading direction). For non-text items, the cost is `item.frame.width`. Zero-width
// items (e.g. anchors) contribute 0 and are revealed instantly when the cursor reaches them.
private func textInkWidth(_ textItem: InstantPageTextItem) -> Int {
var total: CGFloat = 0.0
for line in textItem.lines {
if let chars = line.characterRects {
for r in chars where !r.isEmpty {
total += r.width
}
} else {
total += line.frame.width
}
}
return max(0, Int(total.rounded()))
}
private func itemWidthCost(_ item: InstantPageV2LaidOutItem) -> Int {
return max(0, Int(item.frame.width.rounded()))
}
/// Convert a width-budget (cost in points) into a character count for a text item.
/// Walks the item's lines and per-glyph rects accumulating widths until the budget runs out;
/// returns the character index of the first glyph not yet fully covered. Used to bridge the
/// width-based cost map to the per-character mask API on `InstantPageV2TextView`.
private func charCountForWidthBudget(textItem: InstantPageTextItem, widthBudget: Int) -> Int {
var remaining = CGFloat(widthBudget)
var count = 0
for line in textItem.lines {
if remaining <= 0 { break }
guard let chars = line.characterRects else {
let lineWidth = line.frame.width
if remaining >= lineWidth {
count += line.range.length
remaining -= lineWidth
} else {
let frac = remaining / max(lineWidth, 0.001)
count += Int((CGFloat(line.range.length) * frac).rounded(.down))
remaining = 0
}
continue
}
var lineDone = false
for r in chars {
let w = max(0, r.width)
if remaining >= w {
remaining -= w
count += 1
} else {
lineDone = true
break
}
}
if lineDone {
break
}
}
return count
}
public extension InstantPageV2Layout {
func computeRevealCostMap() -> InstantPageV2RevealCostMap {
var cursor = 0
let entries = computeEntries(items: self.items, cursor: &cursor)
return InstantPageV2RevealCostMap(total: cursor, topLevelEntries: entries)
}
}
public extension InstantPageV2RevealCostMap {
/// Size that the layout would occupy if only the items revealed up to `revealedCount`
/// were visible. Width is the layout's full content width (we don't shrink horizontally,
/// since text wraps to the layout-decided width and shrinking would cause visual jitter).
/// Height is the bottom y of the revealed prefix:
///
/// * Fully-revealed items contribute their full frame.maxY.
/// * Partially-revealed text items contribute the bottom y of their last revealed line.
/// * Partially-revealed tables contribute up to the last revealed row's bottom y.
/// * Non-text items (formula, media, etc.) contribute only once revealedCount >= entry.endCount.
/// * `details` / `codeBlock`: the whole container appears as soon as revealedCount > entry.start
/// (background and chrome pop in atomically, then inner text reveals char-by-char).
///
/// Used by the rich-data bubble to size itself to the revealed prefix during AI streaming,
/// mirroring TextBubble's `clippedGlyphCountLayout = textLayout.layoutForCharacterCount(...)`.
func revealedContentSize(revealedCount: Int, layout: InstantPageV2Layout) -> CGSize {
let bounds = computeRevealedBounds(items: layout.items, entries: self.topLevelEntries, revealedCount: revealedCount)
if bounds.maxY <= 0.0 {
return CGSize(width: layout.contentSize.width, height: 0.0)
}
// The full layout reserves a closing spacing after the last top-level block (see
// `closingSpacing` in layoutBlockSequence). Mirror that so a partially-revealed
// bubble has the same bottom padding as a fully-revealed one otherwise the last
// revealed line sits flush against the bubble's bottom edge.
let lastItemMaxY = layout.items.map { $0.frame.maxY }.max() ?? 0.0
let closingPad = max(0.0, layout.contentSize.height - lastItemMaxY)
return CGSize(width: layout.contentSize.width, height: bounds.maxY + closingPad)
}
/// Returns the maxY of revealed items in `layout` coords (no closing pad). Use this to
/// size the InstantPageV2View itself so its content never overflows past the revealed
/// extent the bubble's closing pad sits in containerNode space *outside* the pageView,
/// not inside it (where unrevealed items would otherwise draw).
func revealedItemsMaxY(revealedCount: Int, layout: InstantPageV2Layout) -> CGFloat {
let bounds = computeRevealedBounds(items: layout.items, entries: self.topLevelEntries, revealedCount: revealedCount)
return max(0.0, bounds.maxY)
}
}
private func computeRevealedBounds(items: [InstantPageV2LaidOutItem], entries: [InstantPageV2RevealCostMap.Entry], revealedCount: Int) -> CGRect {
var bounds: CGRect = .zero
var initialized = false
let n = min(items.count, entries.count)
for i in 0 ..< n {
guard let extent = revealedExtent(entry: entries[i], item: items[i], revealedCount: revealedCount) else {
continue
}
if initialized {
bounds = bounds.union(extent)
} else {
bounds = extent
initialized = true
}
}
return bounds
}
private func revealedExtent(entry: InstantPageV2RevealCostMap.Entry, item: InstantPageV2LaidOutItem, revealedCount: Int) -> CGRect? {
switch entry {
case let .text(start, end):
if revealedCount <= start { return nil }
if revealedCount >= end { return item.frame }
if case let .text(textItem) = item {
return revealedTextExtent(textItem: textItem.textItem, itemFrame: item.frame, localRevealedCount: revealedCount - start)
}
return item.frame
case let .nonText(start, end):
let _ = start
if revealedCount < end { return nil }
return item.frame
case let .codeBlock(start, _):
if revealedCount <= start { return nil }
// Block backdrop appears atomically once revealing reaches the block; inner text
// is char-revealed inside. Bubble height grows by the full block height in one
// step rather than mid-block.
return item.frame
case let .details(start, _, body):
if revealedCount <= start { return nil }
if case let .details(detailsItem) = item {
// Title region appears as soon as the details block is reached.
var bounds = CGRect(
x: detailsItem.frame.minX,
y: detailsItem.frame.minY,
width: detailsItem.frame.width,
height: detailsItem.titleFrame.maxY
)
if let body, let innerLayout = detailsItem.innerLayout, detailsItem.isExpanded {
let bodyBounds = computeRevealedBounds(items: innerLayout.items, entries: body.topLevelEntries, revealedCount: revealedCount)
if !bodyBounds.isEmpty {
let bodyAbs = bodyBounds.offsetBy(dx: detailsItem.frame.minX, dy: detailsItem.frame.minY + detailsItem.titleFrame.maxY)
bounds = bounds.union(bodyAbs)
}
}
return bounds
}
return item.frame
case let .table(start, end, rows, _):
if revealedCount <= start { return nil }
if revealedCount >= end { return item.frame }
if case let .table(tableItem) = item {
let gridOffsetY = tableItem.titleFrame?.height ?? 0.0
var lastRevealedRowIndex: Int? = nil
for (rowIdx, row) in rows.enumerated() {
if revealedCount >= row.startCount {
lastRevealedRowIndex = rowIdx
} else {
break
}
}
let groupedByY = Dictionary(grouping: tableItem.cells, by: { $0.frame.minY })
let sortedRowYs = groupedByY.keys.sorted()
let heightInTable: CGFloat
if let idx = lastRevealedRowIndex, idx < sortedRowYs.count {
let rowY = sortedRowYs[idx]
let cellsInRow = groupedByY[rowY] ?? []
let rowMaxY = cellsInRow.map { $0.frame.maxY }.max() ?? 0.0
heightInTable = gridOffsetY + rowMaxY
} else {
heightInTable = gridOffsetY
}
return CGRect(
x: tableItem.frame.minX,
y: tableItem.frame.minY,
width: tableItem.frame.width,
height: heightInTable
)
}
return item.frame
}
}
/// Returns the y-extent (= bottom of the last revealed line) of a text item given a
/// width-based local cursor. Walks lines summing per-line ink widths; the last line whose
/// preceding-line-widths-sum is < cursor is the last revealed line.
private func revealedTextExtent(textItem: InstantPageTextItem, itemFrame: CGRect, localRevealedCount: Int) -> CGRect {
var remaining = CGFloat(localRevealedCount)
var lastRevealedLineMaxY: CGFloat = 0.0
for line in textItem.lines {
if remaining <= 0 { break }
lastRevealedLineMaxY = max(lastRevealedLineMaxY, line.frame.maxY)
let lineWidth: CGFloat
if let chars = line.characterRects {
var sum: CGFloat = 0
for r in chars where !r.isEmpty {
sum += r.width
}
lineWidth = sum
} else {
lineWidth = line.frame.width
}
remaining -= lineWidth
}
return CGRect(x: itemFrame.minX, y: itemFrame.minY, width: itemFrame.width, height: lastRevealedLineMaxY)
}
private func computeEntries(items: [InstantPageV2LaidOutItem], cursor: inout Int) -> [InstantPageV2RevealCostMap.Entry] {
var entries: [InstantPageV2RevealCostMap.Entry] = []
for item in items {
switch item {
case let .text(text):
let start = cursor
cursor += textInkWidth(text.textItem)
entries.append(.text(start: start, end: cursor))
case let .codeBlock(block):
let start = cursor
cursor += textInkWidth(block.textItem)
entries.append(.codeBlock(start: start, end: cursor))
case let .details(details):
let start = cursor
cursor += textInkWidth(details.titleTextItem)
var body: InstantPageV2RevealCostMap? = nil
if details.isExpanded, let inner = details.innerLayout {
var innerCursor = cursor
let innerEntries = computeEntries(items: inner.items, cursor: &innerCursor)
let innerTotal = innerCursor - cursor
cursor = innerCursor
body = InstantPageV2RevealCostMap(total: innerTotal, topLevelEntries: innerEntries)
}
entries.append(.details(start: start, end: cursor, body: body))
case let .table(table):
let start = cursor
var titleMap: InstantPageV2RevealCostMap? = nil
if let titleLayout = table.titleSubLayout {
var titleCursor = cursor
let titleEntries = computeEntries(items: titleLayout.items, cursor: &titleCursor)
let titleTotal = titleCursor - cursor
cursor = titleCursor
titleMap = InstantPageV2RevealCostMap(total: titleTotal, topLevelEntries: titleEntries)
}
// Group cells by frame.minY (rows in top-to-bottom order); within each row, left-to-right.
let groupedByY = Dictionary(grouping: table.cells, by: { $0.frame.minY })
let sortedRowYs = groupedByY.keys.sorted()
var rows: [InstantPageV2RevealCostMap.TableRow] = []
for rowY in sortedRowYs {
let cellsInRow = (groupedByY[rowY] ?? []).sorted(by: { $0.frame.minX < $1.frame.minX })
let rowStart = cursor
var cellMaps: [InstantPageV2RevealCostMap?] = []
for cell in cellsInRow {
// Each cell consumes at least its frame.width worth of cursor advance,
// even if its inner text ink width is smaller (or it has no subLayout
// at all). Without this floor, narrow- or empty-cell tables ran through
// the cursor much faster than their visual width warrants a 3-column
// table of "1"/"2"/"3" costs ~30pt while occupying ~200pt visually.
// Text inside a cell still char-reveals against its own ink widths; the
// "extra" cost (cell width inner ink) is filler time during which the
// cell's text is fully revealed and the cursor is moving through padding
// before reaching the next cell.
let cellWidthFloor = max(0, Int(cell.frame.width.rounded()))
if let subLayout = cell.subLayout {
var cellCursor = cursor
let cellEntries = computeEntries(items: subLayout.items, cursor: &cellCursor)
let cellInnerCost = cellCursor - cursor
cursor += max(cellInnerCost, cellWidthFloor)
cellMaps.append(InstantPageV2RevealCostMap(total: cellInnerCost, topLevelEntries: cellEntries))
} else {
cellMaps.append(nil)
cursor += cellWidthFloor
}
}
rows.append(InstantPageV2RevealCostMap.TableRow(startCount: rowStart, cells: cellMaps))
}
entries.append(.table(start: start, end: cursor, rows: rows, title: titleMap))
case .formula, .mediaImage, .mediaVideo, .mediaMap, .mediaCoverImage, .mediaPlaceholder,
.divider, .listMarker, .blockQuoteBar, .shape, .anchor:
let start = cursor
cursor += itemWidthCost(item)
entries.append(.nonText(start: start, end: cursor))
}
}
return entries
}
public extension InstantPageV2View {
/// Push reveal state into the V2 view tree.
///
/// - `revealedCount == nil` (and `costMap == nil`): clear all reveal state, removing
/// masks and showing every item fully.
/// - Otherwise: walk `self.itemViews` alongside the cost map's `topLevelEntries`, updating
/// text views' per-character reveal, non-text views' visibility, table row masks, and
/// recursing into nested V2 views (details body, table cells, table title).
///
/// `animated` controls non-text visibility cross-fades and table-row-mask growth; per-text-view
/// glyph counts are written directly (smoothness comes from the display-link tick frequency).
func applyReveal(revealedCount: Int?, costMap: InstantPageV2RevealCostMap?, animated: Bool) {
guard let costMap, let revealedCount else {
// Clear path
for view in self.itemViews {
clearRevealOn(view: view, animated: animated)
}
return
}
// Walk views + entries in lockstep. The orderings of itemViews and topLevelEntries
// match because both are produced by walking layout.items in array order.
let entries = costMap.topLevelEntries
let entryCount = min(entries.count, self.itemViews.count)
for i in 0 ..< entryCount {
let view = self.itemViews[i]
let entry = entries[i]
applyRevealEntry(view: view, entry: entry, revealedCount: revealedCount, animated: animated)
}
// If counts mismatch (shouldn't in a clean apply right after update(layout:)), clear extras.
if self.itemViews.count > entryCount {
for i in entryCount ..< self.itemViews.count {
clearRevealOn(view: self.itemViews[i], animated: animated)
}
}
}
}
private func applyRevealEntry(view: InstantPageItemView, entry: InstantPageV2RevealCostMap.Entry,
revealedCount: Int, animated: Bool) {
switch entry {
case let .text(start, end):
if let textView = view as? InstantPageV2TextView {
let localWidth = max(0, min(revealedCount - start, end - start))
let charCount = (localWidth >= (end - start))
? textView.item.textItem.attributedString.length
: charCountForWidthBudget(textItem: textView.item.textItem, widthBudget: localWidth)
textView.updateRevealCharacterCount(value: charCount, animated: animated)
}
case let .codeBlock(start, end):
if let codeView = view as? InstantPageV2CodeBlockView {
let localWidth = max(0, min(revealedCount - start, end - start))
let charCount = (localWidth >= (end - start))
? codeView.textView.item.textItem.attributedString.length
: charCountForWidthBudget(textItem: codeView.textView.item.textItem, widthBudget: localWidth)
codeView.textView.updateRevealCharacterCount(value: charCount, animated: animated)
let visible = revealedCount >= start
applyVisibility(view: codeView, visible: visible, animated: animated)
}
case let .details(start, end, body):
if let detailsView = view as? InstantPageV2DetailsView {
let titleTextItem = detailsView.titleTextView.item.textItem
let titleWidth = textInkWidth(titleTextItem)
let titleLocal = max(0, min(revealedCount - start, titleWidth))
let charCount = (titleLocal >= titleWidth)
? titleTextItem.attributedString.length
: charCountForWidthBudget(textItem: titleTextItem, widthBudget: titleLocal)
detailsView.titleTextView.updateRevealCharacterCount(value: charCount, animated: animated)
if let body, let bodyView = detailsView.bodyView {
bodyView.applyReveal(revealedCount: revealedCount, costMap: body, animated: animated)
}
let _ = end
}
case let .table(start, end, rows, title):
if let tableView = view as? InstantPageV2TableView {
applyTableReveal(tableView: tableView, start: start, end: end, rows: rows, title: title,
revealedCount: revealedCount, animated: animated)
}
case let .nonText(start, end):
let visible = revealedCount >= end
applyVisibility(view: view, visible: visible, animated: animated)
let _ = start
}
}
private func applyVisibility(view: UIView, visible: Bool, animated: Bool) {
let targetAlpha: CGFloat = visible ? 1.0 : 0.0
if !animated {
view.alpha = targetAlpha
view.isHidden = !visible
return
}
if visible {
if view.alpha == 1.0 && !view.isHidden { return }
view.isHidden = false
let from = view.alpha
view.alpha = 1.0
let anim = CABasicAnimation(keyPath: "opacity")
anim.fromValue = from
anim.toValue = 1.0
anim.duration = 0.12
view.layer.add(anim, forKey: "opacity")
} else {
if view.alpha == 0.0 || view.isHidden { return }
let from = view.alpha
view.alpha = 0.0
let anim = CABasicAnimation(keyPath: "opacity")
anim.fromValue = from
anim.toValue = 0.0
anim.duration = 0.12
view.layer.add(anim, forKey: "opacity")
// Don't set isHidden so the animation can run; UIKit treats alpha==0 + hitTest as non-blocking.
}
}
private func applyTableReveal(tableView: InstantPageV2TableView, start: Int, end: Int,
rows: [InstantPageV2RevealCostMap.TableRow],
title: InstantPageV2RevealCostMap?,
revealedCount: Int, animated: Bool) {
let _ = start
let _ = end
// Title sub-view, if present, recurses with the title's own sub-map.
if let title, let titleSub = tableView.titleSubView {
titleSub.applyReveal(revealedCount: revealedCount, costMap: title, animated: animated)
}
// Determine which row index the cursor is currently in (or past).
// A row is "revealed" once revealedCount >= rowStartCount.
var lastRevealedRowIndex: Int? = nil
for (idx, row) in rows.enumerated() {
if revealedCount >= row.startCount {
lastRevealedRowIndex = idx
} else {
break
}
}
// Recurse into each cell sub-view (each non-nil cell map corresponds to a cellSubView,
// in the order they were registered when InstantPageV2TableView was constructed:
// cell-sub-views are added in iteration order of `item.cells` for cells with subLayout).
var cellSubViewIndex = 0
for row in rows {
for cellMap in row.cells {
if let cellMap, cellSubViewIndex < tableView.cellSubViews.count {
let cellSubView = tableView.cellSubViews[cellSubViewIndex]
cellSubView.applyReveal(revealedCount: revealedCount, costMap: cellMap, animated: animated)
cellSubViewIndex += 1
}
}
}
// Apply the row-mask on contentView. The mask exposes rows [0 ... lastRevealedRowIndex].
// Compute the y-bound by taking the max maxY across cells in those rows.
let gridOffsetY = tableView.item.titleFrame?.height ?? 0.0
let maskHeight: CGFloat
if let lastRevealedRowIndex {
// Find the maxY among cells in rows [0 ... lastRevealedRowIndex]. Use the actual table item's
// cells (the layout-time data, not the cost-map's logical row groupings).
let groupedByY = Dictionary(grouping: tableView.item.cells, by: { $0.frame.minY })
let sortedRowYs = groupedByY.keys.sorted()
let safeIndex = min(lastRevealedRowIndex, sortedRowYs.count - 1)
let rowY = sortedRowYs[safeIndex]
let cellsInRow = groupedByY[rowY] ?? []
let rowMaxY = cellsInRow.map { $0.frame.maxY }.max() ?? 0.0
maskHeight = gridOffsetY + rowMaxY
} else {
// No rows revealed yet but the title (if any) is still visible above the grid.
maskHeight = (title != nil) ? gridOffsetY : 0.0
}
let maskFrame = CGRect(x: 0.0, y: 0.0, width: tableView.contentView.bounds.width, height: maskHeight)
let maskLayer: CALayer
if let existing = tableView.contentView.layer.mask {
maskLayer = existing
} else {
let new = CALayer()
new.backgroundColor = UIColor.white.cgColor
tableView.contentView.layer.mask = new
maskLayer = new
}
if animated && maskLayer.frame != maskFrame {
let anim = CABasicAnimation(keyPath: "bounds")
anim.fromValue = maskLayer.bounds
anim.toValue = CGRect(origin: .zero, size: maskFrame.size)
anim.duration = 0.12
maskLayer.add(anim, forKey: "bounds")
}
maskLayer.frame = maskFrame
}
private func clearRevealOn(view: InstantPageItemView, animated: Bool) {
if let textView = view as? InstantPageV2TextView {
textView.updateRevealCharacterCount(value: nil, animated: animated)
}
if let codeView = view as? InstantPageV2CodeBlockView {
codeView.textView.updateRevealCharacterCount(value: nil, animated: animated)
applyVisibility(view: codeView, visible: true, animated: animated)
}
if let detailsView = view as? InstantPageV2DetailsView {
detailsView.titleTextView.updateRevealCharacterCount(value: nil, animated: animated)
detailsView.bodyView?.applyReveal(revealedCount: nil, costMap: nil, animated: animated)
}
if let tableView = view as? InstantPageV2TableView {
tableView.titleSubView?.applyReveal(revealedCount: nil, costMap: nil, animated: animated)
for cell in tableView.cellSubViews {
cell.applyReveal(revealedCount: nil, costMap: nil, animated: animated)
}
tableView.contentView.layer.mask = nil
}
// Non-text item: ensure visible.
applyVisibility(view: view, visible: true, animated: animated)
}

View file

@ -23,6 +23,10 @@ swift_library(
"//submodules/TelegramUI/Components/TextLoadingEffect",
"//submodules/TelegramUIPreferences",
"//submodules/TextSelectionNode",
"//submodules/TelegramUI/Components/ShimmeringMask:ShimmeringMask",
"//submodules/TelegramUI/Components/InteractiveTextComponent:InteractiveTextComponent",
"//submodules/TelegramUI/Components/StreamingTextReveal:StreamingTextReveal",
"//submodules/TelegramUI/Components/TextNodeWithEntities:TextNodeWithEntities",
],
visibility = [
"//visibility:public",

View file

@ -13,6 +13,10 @@ import InstantPageUI
import TelegramUIPreferences
import TextLoadingEffect
import TextSelectionNode
import StreamingTextReveal
import ShimmeringMask
import InteractiveTextComponent
import TextNodeWithEntities
public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode {
public final class ContainerNode: ASDisplayNode {
@ -28,9 +32,13 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
// messages, so we key cache invalidation on the message itself. When the bubble is recycled
// with a different message we must discard pageView (render context is constructor-fixed).
private var pageViewMessageKey: (id: EngineMessage.Id, stableVersion: UInt32)?
// messageStableVersion is in the cache key because the synthesized instantPage content
// mutates between streamed AI message chunks (each chunk bumps stableVersion); without
// this, the cached layout would shadow newly-arrived content during streaming.
private var currentPageLayout: (boundingWidth: CGFloat,
presentationThemeIdentity: ObjectIdentifier,
expandedDetails: [Int: Bool],
messageStableVersion: UInt32,
layout: InstantPageV2Layout)?
private var currentExpandedDetails: [Int: Bool] = [:]
private var linkProgressDisposable: Disposable?
@ -40,6 +48,17 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
private var textSelectionAdapter: InstantPageMultiTextAdapter?
private var textSelectionNode: TextSelectionNode?
private var streamingStatusTextNode: InteractiveTextNodeWithEntities?
private var streamingStatusShimmerView: ShimmeringMaskView?
private var textRevealController: TextRevealController?
private var textRevealLink: SharedDisplayLinkDriver.Link?
private var currentRevealCostMap: InstantPageV2RevealCostMap?
// Cursor value pushed into pageView.applyReveal on the prior tick. The display-link tick
// compares the revealed prefix's height at this cursor vs the new cursor to decide when
// to request a full bubble re-layout (so the bubble grows with the reveal).
private var lastAppliedRevealedCount: Int = 0
required public init() {
self.containerNode = ContainerNode()
self.containerNode.clipsToBounds = true
@ -123,14 +142,40 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
}
override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) {
let previousItem = self.item
let streamingStatusTextLayout = InteractiveTextNodeWithEntities.asyncLayout(self.streamingStatusTextNode)
let currentPageLayout = self.currentPageLayout
let currentExpandedDetails = self.currentExpandedDetails
let statusLayout = ChatMessageDateAndStatusNode.asyncLayout(self.statusNode)
// Captured at main-thread, top of asyncLayoutContent. Mirrors TextBubble's
// `currentMaxGlyphCount` (TextBubble:313). The bubble's bounding size is sized
// to this revealed prefix during streaming, so it grows with the reveal rather
// than being final-sized from the first chunk.
let currentMaxGlyphCount: Int? = self.textRevealController?.currentGlyphCount
return { [weak self] item, _, _, _, _, _ in
return { [weak self] item, layoutConstants, _, _, _, _ in
let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: false, headerSpacing: 0.0, hidesBackground: .never, forceFullCorners: false, forceAlignment: .none)
return (contentProperties, nil, CGFloat.greatestFiniteMagnitude, { constrainedSize, position in
// topInset matches TextBubble's logic at lines 234-249 gives the "Thinking"
// header the same vertical alignment as TextBubble's status header does inside
// its bubble.
var topInset: CGFloat = 0.0
if case let .linear(top, _) = position {
switch top {
case .None:
topInset = layoutConstants.text.bubbleInsets.top
case let .Neighbour(_, topType, _):
switch topType {
case .text:
topInset = layoutConstants.text.bubbleInsets.top - 2.0
case .header, .footer, .media, .reactions:
topInset = layoutConstants.text.bubbleInsets.top
}
default:
topInset = layoutConstants.text.bubbleInsets.top
}
}
let suggestedBoundingWidth: CGFloat = constrainedSize.width
var boundingSize = CGSize(width: suggestedBoundingWidth, height: 0.0)
@ -244,6 +289,15 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13)
)
var hasDraft = false
if item.message.attributes.contains(where: { $0 is TypingDraftMessageAttribute }) {
hasDraft = true
}
var hadDraft = false
if let previousItem, previousItem.message.attributes.contains(where: { $0 is TypingDraftMessageAttribute }) {
hadDraft = true
}
if let attribute = item.message.richText {
let webpage = TelegramMediaWebpage(webpageId: EngineMedia.Id(namespace: 0, id: 0), content: .Loaded(TelegramMediaWebpageLoadedContent(
url: "",
@ -269,10 +323,12 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
pageWebpage = webpage
let presentationThemeIdentity = ObjectIdentifier(item.presentationData.theme.theme)
let currentMessageStableVersion = item.message.stableVersion
if let current = currentPageLayout,
current.boundingWidth == suggestedBoundingWidth,
current.presentationThemeIdentity == presentationThemeIdentity,
current.expandedDetails == currentExpandedDetails {
current.expandedDetails == currentExpandedDetails,
current.messageStableVersion == currentMessageStableVersion {
pageLayout = current.layout
} else {
pageLayout = layoutInstantPageV2(
@ -286,14 +342,91 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
dateTimeFormat: item.presentationData.dateTimeFormat,
cachedMessageSyntaxHighlight: nil,
expandedDetails: currentExpandedDetails,
fitToWidth: true
fitToWidth: true,
computeRevealCharacterRects: hasDraft || hadDraft
)
}
}
// Cost map computed here (not in apply) so we can size the bubble to the
// revealed prefix this layout pass. Mirrors TextBubble's clippedGlyphCountLayout.
let revealCostMap: InstantPageV2RevealCostMap? = (hasDraft || hadDraft) ? pageLayout?.computeRevealCostMap() : nil
let revealedGlyphCount: Int? = (hasDraft || hadDraft) ? (currentMaxGlyphCount ?? 0) : nil
if let pageLayout {
boundingSize.width = pageLayout.contentSize.width
boundingSize.height = pageLayout.contentSize.height + 2.0
let effectiveSize: CGSize
if let costMap = revealCostMap, let glyphCount = revealedGlyphCount {
effectiveSize = costMap.revealedContentSize(revealedCount: glyphCount, layout: pageLayout)
} else {
effectiveSize = pageLayout.contentSize
}
boundingSize.width = effectiveSize.width
boundingSize.height = effectiveSize.height + 2.0
}
let textFont = item.presentationData.messageFont
let textInsets = UIEdgeInsets(top: 2.0, left: 2.0, bottom: 5.0, right: 2.0)
let streamingTextSpacing: CGFloat = 1.0
let textConstrainedSize = CGSize(width: suggestedBoundingWidth - 4.0, height: .greatestFiniteMagnitude)
var streamingTextLayoutAndApply: (layout: InteractiveTextNodeLayout, apply: (InteractiveTextNodeWithEntities.Arguments) -> InteractiveTextNodeWithEntities)?
if hasDraft || hadDraft {
//TODO:localize
streamingTextLayoutAndApply = streamingStatusTextLayout(InteractiveTextNodeLayoutArguments(
attributedString: NSAttributedString(string: "Thinking...", font: textFont, textColor: messageTheme.fileDescriptionColor),
backgroundColor: nil,
maximumNumberOfLines: 1,
truncationType: .end,
constrainedSize: textConstrainedSize,
alignment: .natural,
cutout: nil,
insets: textInsets,
lineColor: messageTheme.accentControlColor,
customTruncationToken: nil,
computeCharacterRects: true
))
}
// Origin mirrors TextBubble:783 (bubbleInsets.left - textInsets.left,
// topInset - textInsets.top). The negative textInset offsets cancel the
// inset that's baked into the InteractiveTextNode layout, so the visible
// glyph origin aligns with (bubbleInsets.left, topInset).
var streamingTextFrame: CGRect?
if let streamingTextLayoutAndApply {
streamingTextFrame = CGRect(
origin: CGPoint(
x: layoutConstants.text.bubbleInsets.left - textInsets.left,
y: topInset - textInsets.top
),
size: streamingTextLayoutAndApply.layout.size
)
}
// Offset for the pageView (and status node y-shift) places the pageView
// right below the streaming header's *visible* bottom (= origin.y + height
// - inset.bottom, since the layout-baked inset.bottom isn't visible content)
// plus a 1pt spacing.
let streamingHeaderOffset: CGFloat
if let streamingTextFrame {
streamingHeaderOffset = streamingTextFrame.origin.y + streamingTextFrame.height - textInsets.bottom + streamingTextSpacing
} else {
streamingHeaderOffset = 0.0
}
if let streamingTextFrame {
// Mirrors TextBubble's suggestedBoundingWidth contribution at lines 886-893:
// visible_thinking_width + bubbleInsets.left + bubbleInsets.right
// where visible_thinking_width = streamingTextFrame.width - textInsets.left
// - textInsets.right. Adds 2pt for RichData's 1pt-per-side containerNode
// border that TextBubble doesn't have. Without this, an empty-pageLayout
// bubble was sized too narrow to fit the "Thinking" label.
let visibleThinkingWidth = streamingTextFrame.width - textInsets.left - textInsets.right
let thinkingMinBubbleWidth = visibleThinkingWidth + layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right + 2.0
boundingSize.width = max(boundingSize.width, thinkingMinBubbleWidth)
// Adds exactly the vertical space the streaming header consumes before the
// pageView starts (= where pageView's frame.origin.y will be set). Keeps
// the bubble's total height consistent with `containerHeight + closingPad + 2`
// computed in the apply closure.
boundingSize.height += streamingHeaderOffset
}
let message = item.message
@ -419,7 +552,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
))
}
if let statusSuggestedWidthAndContinue {
if let statusSuggestedWidthAndContinue, !hasDraft {
let statusLeftEdgeInBubble: CGFloat
if let lastTextLineFrame {
statusLeftEdgeInBubble = 1.0 + lastTextLineFrame.minX
@ -431,7 +564,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
return (boundingSize.width, { boundingWidth in
let statusSizeAndApply = statusSuggestedWidthAndContinue?.1(boundingWidth)
if let statusSizeAndApply {
if let statusSizeAndApply, !hasDraft {
boundingSize.height += statusSizeAndApply.0.height
}
@ -441,7 +574,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
}
self.item = item
self.containerNode.frame = CGRect(origin: CGPoint(x: 1.0, y: 1.0), size: CGSize(width: boundingWidth - 2.0, height: boundingSize.height - 2.0))
animation.animator.updateFrame(layer: self.containerNode.layer, frame: CGRect(origin: CGPoint(x: 1.0, y: 1.0), size: CGSize(width: boundingWidth - 2.0, height: boundingSize.height)), completion: nil)
if let statusSizeAndApply {
let statusFrameX: CGFloat
@ -456,7 +589,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
statusFrameX = 1.0
statusFrameY = 1.0
}
let statusFrame = CGRect(origin: CGPoint(x: statusFrameX, y: statusFrameY), size: statusSizeAndApply.0)
let statusFrame = CGRect(origin: CGPoint(x: statusFrameX, y: statusFrameY + streamingHeaderOffset), size: statusSizeAndApply.0)
let statusNode = statusSizeAndApply.1(self.statusNode == nil ? .None : animation)
if self.statusNode !== statusNode {
@ -504,12 +637,13 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
suggestedBoundingWidth,
ObjectIdentifier(item.presentationData.theme.theme),
self.currentExpandedDetails,
item.message.stableVersion,
pageLayout
)
let pageView = self.ensurePageView(item: item, webpage: pageWebpage)
pageView.update(layout: pageLayout, theme: pageTheme, animation: animation)
pageView.frame = CGRect(
origin: CGPoint(x: -1.0, y: 0.0),
origin: CGPoint(x: -1.0, y: streamingHeaderOffset),
size: pageLayout.contentSize
)
} else {
@ -521,12 +655,172 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
)
self.pageViewMessageKey = nil
}
// === Streaming state apply ===
// 1. Compute / cache the cost map.
// Reuse the cost map computed in the layout pass (the bubble's
// size depended on it) don't recompute.
self.currentRevealCostMap = revealCostMap
// 2. Update the "Thinking" header.
if let streamingTextFrame, let streamingTextLayoutAndApply {
var statusAnimation = animation
if self.streamingStatusTextNode == nil {
statusAnimation = .None
}
let streamingStatusTextNode = streamingTextLayoutAndApply.apply(InteractiveTextNodeWithEntities.Arguments(
context: item.context,
cache: item.controllerInteraction.presentationContext.animationCache,
renderer: item.controllerInteraction.presentationContext.animationRenderer,
placeholderColor: messageTheme.mediaPlaceholderColor,
attemptSynchronous: false,
textColor: messageTheme.primaryTextColor,
spoilerEffectColor: messageTheme.secondaryTextColor,
applyArguments: InteractiveTextNode.ApplyArguments(
animation: statusAnimation,
spoilerTextColor: messageTheme.primaryTextColor,
spoilerEffectColor: messageTheme.secondaryTextColor,
areContentAnimationsEnabled: item.context.sharedContext.energyUsageSettings.loopEmoji,
spoilerExpandRect: nil,
crossfadeContents: nil
)
))
let streamingStatusShimmerView: ShimmeringMaskView
if let current = self.streamingStatusShimmerView {
streamingStatusShimmerView = current
} else {
streamingStatusShimmerView = ShimmeringMaskView(peakAlpha: 0.3, duration: 1.0)
self.streamingStatusShimmerView = streamingStatusShimmerView
self.containerNode.view.addSubview(streamingStatusShimmerView)
}
if streamingStatusTextNode !== self.streamingStatusTextNode {
self.streamingStatusTextNode?.textNode.view.removeFromSuperview()
self.streamingStatusTextNode = streamingStatusTextNode
streamingStatusShimmerView.contentView.addSubview(streamingStatusTextNode.textNode.view)
}
statusAnimation.animator.updatePosition(layer: streamingStatusShimmerView.layer, position: streamingTextFrame.center, completion: nil)
statusAnimation.animator.updateBounds(layer: streamingStatusShimmerView.layer, bounds: CGRect(origin: .zero, size: streamingTextFrame.size), completion: nil)
statusAnimation.animator.updatePosition(layer: streamingStatusTextNode.textNode.layer, position: CGPoint(x: streamingTextFrame.size.width * 0.5, y: streamingTextFrame.size.height * 0.5), completion: nil)
statusAnimation.animator.updateBounds(layer: streamingStatusTextNode.textNode.layer, bounds: CGRect(origin: .zero, size: streamingTextFrame.size), completion: nil)
streamingStatusShimmerView.update(
size: streamingTextFrame.size,
containerWidth: streamingTextFrame.size.width,
offsetX: 0.0,
gradientWidth: 200.0,
transition: .immediate
)
} else if let streamingStatusShimmerView = self.streamingStatusShimmerView {
self.streamingStatusTextNode = nil
self.streamingStatusShimmerView = nil
animation.animator.updateAlpha(layer: streamingStatusShimmerView.layer, alpha: 0.0, completion: { [weak streamingStatusShimmerView] _ in
streamingStatusShimmerView?.removeFromSuperview()
})
}
// 3. Drive the reveal controller.
let previousAnimateGlyphCount: Int? = (hasDraft || hadDraft) ? (self.textRevealController?.currentGlyphCount ?? 0) : nil
if previousAnimateGlyphCount != nil || self.textRevealController != nil || hasDraft || hadDraft {
if hasDraft {
self.statusNode?.alpha = 0.0
}
// Seed the V2 view to the previous count so we don't flash full text at the start.
self.pageView?.applyReveal(revealedCount: previousAnimateGlyphCount ?? 0,
costMap: self.currentRevealCostMap,
animated: false)
self.lastAppliedRevealedCount = previousAnimateGlyphCount ?? 0
self.updateTextRevealAnimation(previousGlyphCount: previousAnimateGlyphCount ?? 0,
hasDraft: hasDraft,
hadDraft: hadDraft)
}
})
})
})
}
}
private func updateTextRevealAnimation(previousGlyphCount: Int, hasDraft: Bool, hadDraft: Bool) {
let toCount = self.currentRevealCostMap?.total ?? 0
let now = CACurrentMediaTime()
if hasDraft, let controller = self.textRevealController, controller.isFinalizing {
self.textRevealController = nil
self.textRevealLink = nil
}
if self.textRevealController == nil && (hasDraft || hadDraft) {
self.textRevealController = TextRevealController(initialRevealedCount: previousGlyphCount, initialLength: toCount, durationMultiplier: 10.0)
}
guard let controller = self.textRevealController else { return }
if hasDraft {
controller.observeUpdate(latestLength: toCount, at: now)
} else if hadDraft {
controller.finalize(finalLength: toCount)
}
if controller.isFinalizing && controller.revealedCount >= Double(controller.latestLength) {
self.textRevealController = nil
self.textRevealLink = nil
self.pageView?.applyReveal(revealedCount: nil, costMap: nil, animated: false)
self.lastAppliedRevealedCount = 0
return
}
guard toCount > 0 else { return }
if self.textRevealLink == nil {
self.textRevealLink = SharedDisplayLinkDriver.shared.add { [weak self] _ in
guard let self else { return }
guard let item = self.item else {
self.textRevealController = nil
self.textRevealLink = nil
return
}
guard let controller = self.textRevealController, let costMap = self.currentRevealCostMap else {
self.textRevealLink = nil
return
}
let now = CACurrentMediaTime()
let (revealedGlyphCount, isComplete) = controller.tick(now: now)
if isComplete {
self.textRevealController = nil
self.textRevealLink = nil
self.pageView?.applyReveal(revealedCount: nil, costMap: nil, animated: false)
self.lastAppliedRevealedCount = 0
if let statusNode = self.statusNode,
!item.message.attributes.contains(where: { $0 is TypingDraftMessageAttribute }) {
ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut).updateAlpha(node: statusNode, alpha: 1.0)
}
self.requestFullUpdate?(ControlledTransition(duration: 0.15, curve: .easeInOut, interactive: false))
} else {
// If the revealed prefix's bottom y would change at the new cursor (i.e.
// crossing a line/item boundary), trigger a full bubble re-layout so the
// bubble grows with the reveal. Mirrors TextBubble's
// `cachedLayout.sizeForCharacterCount(...)` check at lines 1209-1216.
var requestUpdate = false
if let pageLayout = self.currentPageLayout?.layout, self.lastAppliedRevealedCount != revealedGlyphCount {
let prevHeight = costMap.revealedContentSize(revealedCount: self.lastAppliedRevealedCount, layout: pageLayout).height
let newHeight = costMap.revealedContentSize(revealedCount: revealedGlyphCount, layout: pageLayout).height
if prevHeight != newHeight {
requestUpdate = true
}
}
self.pageView?.applyReveal(revealedCount: revealedGlyphCount, costMap: costMap, animated: true)
self.lastAppliedRevealedCount = revealedGlyphCount
if requestUpdate {
self.requestFullUpdate?(ControlledTransition(duration: 0.15, curve: .easeInOut, interactive: false))
}
}
}
}
}
override public func animateInsertion(_ currentTimestamp: Double, duration: Double) {
if let statusNode = self.statusNode, statusNode.alpha != 0.0 {
statusNode.layer.animateAlpha(from: 0.0, to: statusNode.alpha, duration: 0.2)

View file

@ -38,6 +38,7 @@ swift_library(
"//submodules/TelegramUI/Components/ChatControllerInteraction",
"//submodules/TelegramUI/Components/InteractiveTextComponent",
"//submodules/TelegramUI/Components/ShimmeringMask",
"//submodules/TelegramUI/Components/StreamingTextReveal:StreamingTextReveal",
],
visibility = [
"//visibility:public",

View file

@ -26,6 +26,7 @@ import TextLoadingEffect
import ChatControllerInteraction
import InteractiveTextComponent
import ShimmeringMask
import StreamingTextReveal
private final class CachedChatMessageText {
let text: String
@ -119,94 +120,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
private var displayContentsUnderSpoilers: (value: Bool, location: CGPoint?) = (false, nil)
private var isSummaryApplied = false
private final class TextRevealController {
private static let velocityTau: Double = 0.12
private static let gapEwmaAlpha: Double = 0.4
private static let initialGap: Double = 0.5
private static let stallFloor: Double = 0.10
private static let finalizeTime: Double = 0.3
private static let frameDtCap: Double = 0.05
private static let initialInputRate: Double = 40.0
private(set) var revealedCount: Double
private var velocity: Double = 0.0
private var avgInterArrival: Double = TextRevealController.initialGap
private var lastSampleTime: Double?
private var lastSampleLength: Int?
private var predictedNextArrivalTime: Double?
private var chunkCount: Int = 0
private(set) var latestLength: Int
private(set) var isFinalizing: Bool = false
private var lastFrameTime: Double?
init(initialRevealedCount: Int, initialLength: Int) {
self.revealedCount = Double(initialRevealedCount)
self.latestLength = initialLength
}
var currentGlyphCount: Int {
return Int(self.revealedCount)
}
func observeUpdate(latestLength: Int, at now: Double) {
if let lastLen = self.lastSampleLength {
if latestLength > lastLen {
if let lastTime = self.lastSampleTime {
let interArrival = max(now - lastTime, 0.001)
self.avgInterArrival = TextRevealController.gapEwmaAlpha * interArrival
+ (1.0 - TextRevealController.gapEwmaAlpha) * self.avgInterArrival
}
self.lastSampleTime = now
self.lastSampleLength = latestLength
self.predictedNextArrivalTime = now + self.avgInterArrival
self.chunkCount += 1
} else if latestLength < lastLen {
self.lastSampleLength = latestLength
}
} else {
self.lastSampleTime = now
self.lastSampleLength = latestLength
self.predictedNextArrivalTime = now + self.avgInterArrival
self.chunkCount += 1
}
self.latestLength = latestLength
if self.revealedCount > Double(latestLength) {
self.revealedCount = Double(latestLength)
}
}
func finalize(finalLength: Int) {
self.latestLength = finalLength
self.isFinalizing = true
if self.revealedCount > Double(finalLength) {
self.revealedCount = Double(finalLength)
}
}
func tick(now: Double) -> (revealedGlyphCount: Int, isComplete: Bool) {
let dt = min(now - (self.lastFrameTime ?? now), TextRevealController.frameDtCap)
let lag = max(0.0, Double(self.latestLength) - self.revealedCount)
let targetVelocity: Double
if self.isFinalizing {
targetVelocity = max(self.velocity, lag / TextRevealController.finalizeTime)
} else if self.chunkCount < 2 {
targetVelocity = lag > 0.0 ? TextRevealController.initialInputRate : 0.0
} else if let predNext = self.predictedNextArrivalTime {
let timeToNext = max(TextRevealController.stallFloor, predNext - now)
targetVelocity = lag / timeToNext
} else {
targetVelocity = lag > 0.0 ? TextRevealController.initialInputRate : 0.0
}
let smoothing = min(1.0, dt / TextRevealController.velocityTau)
self.velocity += (targetVelocity - self.velocity) * smoothing
self.revealedCount = min(Double(self.latestLength), self.revealedCount + self.velocity * dt)
self.lastFrameTime = now
let isComplete = self.isFinalizing && self.revealedCount >= Double(self.latestLength)
return (Int(self.revealedCount), isComplete)
}
}
private var textRevealLink: SharedDisplayLinkDriver.Link?
private var textRevealController: TextRevealController?

View file

@ -0,0 +1,16 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "StreamingTextReveal",
module_name = "StreamingTextReveal",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [],
visibility = [
"//visibility:public",
],
)

View file

@ -0,0 +1,88 @@
import Foundation
public final class TextRevealController {
private static let velocityTau: Double = 0.12
private static let gapEwmaAlpha: Double = 0.4
private static let initialGap: Double = 0.5
private static let stallFloor: Double = 0.10
private static let finalizeTime: Double = 0.3
private static let frameDtCap: Double = 0.05
private static let initialInputRate: Double = 40.0
public private(set) var revealedCount: Double
private var velocity: Double = 0.0
private var avgInterArrival: Double = TextRevealController.initialGap
private var lastSampleTime: Double?
private var lastSampleLength: Int?
private var predictedNextArrivalTime: Double?
private var chunkCount: Int = 0
public private(set) var latestLength: Int
public private(set) var isFinalizing: Bool = false
private var lastFrameTime: Double?
public init(initialRevealedCount: Int, initialLength: Int) {
self.revealedCount = Double(initialRevealedCount)
self.latestLength = initialLength
}
public var currentGlyphCount: Int {
return Int(self.revealedCount)
}
public func observeUpdate(latestLength: Int, at now: Double) {
if let lastLen = self.lastSampleLength {
if latestLength > lastLen {
if let lastTime = self.lastSampleTime {
let interArrival = max(now - lastTime, 0.001)
self.avgInterArrival = TextRevealController.gapEwmaAlpha * interArrival
+ (1.0 - TextRevealController.gapEwmaAlpha) * self.avgInterArrival
}
self.lastSampleTime = now
self.lastSampleLength = latestLength
self.predictedNextArrivalTime = now + self.avgInterArrival
self.chunkCount += 1
} else if latestLength < lastLen {
self.lastSampleLength = latestLength
}
} else {
self.lastSampleTime = now
self.lastSampleLength = latestLength
self.predictedNextArrivalTime = now + self.avgInterArrival
self.chunkCount += 1
}
self.latestLength = latestLength
if self.revealedCount > Double(latestLength) {
self.revealedCount = Double(latestLength)
}
}
public func finalize(finalLength: Int) {
self.latestLength = finalLength
self.isFinalizing = true
if self.revealedCount > Double(finalLength) {
self.revealedCount = Double(finalLength)
}
}
public func tick(now: Double) -> (revealedGlyphCount: Int, isComplete: Bool) {
let dt = min(now - (self.lastFrameTime ?? now), TextRevealController.frameDtCap)
let lag = max(0.0, Double(self.latestLength) - self.revealedCount)
let targetVelocity: Double
if self.isFinalizing {
targetVelocity = max(self.velocity, lag / TextRevealController.finalizeTime)
} else if self.chunkCount < 2 {
targetVelocity = lag > 0.0 ? TextRevealController.initialInputRate : 0.0
} else if let predNext = self.predictedNextArrivalTime {
let timeToNext = max(TextRevealController.stallFloor, predNext - now)
targetVelocity = lag / timeToNext
} else {
targetVelocity = lag > 0.0 ? TextRevealController.initialInputRate : 0.0
}
let smoothing = min(1.0, dt / TextRevealController.velocityTau)
self.velocity += (targetVelocity - self.velocity) * smoothing
self.revealedCount = min(Double(self.latestLength), self.revealedCount + self.velocity * dt)
self.lastFrameTime = now
let isComplete = self.isFinalizing && self.revealedCount >= Double(self.latestLength)
return (Int(self.revealedCount), isComplete)
}
}