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:
parent
a53ef1f299
commit
562de27c30
12 changed files with 1460 additions and 125 deletions
28
CLAUDE.md
28
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`.
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ swift_library(
|
|||
"//submodules/TranslateUI:TranslateUI",
|
||||
"//submodules/Tuples:Tuples",
|
||||
"//third-party/SwiftMath:SwiftMath",
|
||||
"//submodules/ComponentFlow:ComponentFlow",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
550
submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift
Normal file
550
submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift
Normal 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)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
16
submodules/TelegramUI/Components/StreamingTextReveal/BUILD
Normal file
16
submodules/TelegramUI/Components/StreamingTextReveal/BUILD
Normal 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",
|
||||
],
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue