diff --git a/CLAUDE.md b/CLAUDE.md index 90f9b358ef..ba830e558a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/submodules/InstantPageUI/BUILD b/submodules/InstantPageUI/BUILD index 91e9d5a2bf..2f104544d2 100644 --- a/submodules/InstantPageUI/BUILD +++ b/submodules/InstantPageUI/BUILD @@ -30,6 +30,7 @@ swift_library( "//submodules/TranslateUI:TranslateUI", "//submodules/Tuples:Tuples", "//third-party/SwiftMath:SwiftMath", + "//submodules/ComponentFlow:ComponentFlow", ], visibility = [ "//visibility:public", diff --git a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift index f217aa4640..b3b4f10435 100644 --- a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift +++ b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift @@ -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] = [] diff --git a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift index 10c25b0b16..ab3c41c048 100644 --- a/submodules/InstantPageUI/Sources/InstantPageTextItem.swift +++ b/submodules/InstantPageUI/Sources/InstantPageTextItem.swift @@ -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 } } diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index 76a4454aa7..9ec07047d2 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -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) diff --git a/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift b/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift new file mode 100644 index 0000000000..0341079c94 --- /dev/null +++ b/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift @@ -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) +} diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD index 54ebf1c150..bcc4287fb3 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/BUILD @@ -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", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index d92c022f8b..ed5b41c56a 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -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) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/BUILD b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/BUILD index f283b07c37..be9cffaee8 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/BUILD +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/BUILD @@ -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", diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift index 8ccaff03c6..dd78c19c41 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageTextBubbleContentNode/Sources/ChatMessageTextBubbleContentNode.swift @@ -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? diff --git a/submodules/TelegramUI/Components/StreamingTextReveal/BUILD b/submodules/TelegramUI/Components/StreamingTextReveal/BUILD new file mode 100644 index 0000000000..6318cdd2e7 --- /dev/null +++ b/submodules/TelegramUI/Components/StreamingTextReveal/BUILD @@ -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", + ], +) diff --git a/submodules/TelegramUI/Components/StreamingTextReveal/Sources/TextRevealController.swift b/submodules/TelegramUI/Components/StreamingTextReveal/Sources/TextRevealController.swift new file mode 100644 index 0000000000..c950385977 --- /dev/null +++ b/submodules/TelegramUI/Components/StreamingTextReveal/Sources/TextRevealController.swift @@ -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) + } +}