WinterGram/submodules/InstantPageUI/Sources/InstantPageV2RevealCost.swift
isaac ab68a1af82 InstantPage V2: render collage & slideshow blocks
Port V1's collage and slideshow InstantPage blocks into the V2 renderer
(previously grey-box placeholders).

- Collage: layoutCollage computes the mosaic (MosaicLayout, the grouped-message
  engine) over inner image/video sizes and flattens it into existing top-level
  .mediaImage/.mediaVideo items + a caption, so gallery / reveal-cost / registry
  / hidden-media all handle the cells with no collage-specific code. Right-edge
  cells bleed 4pt for the bubble's rounded clip.
- Slideshow: a new .slideshow laid-out item + InstantPageV2SlideshowView, an
  eager paged carousel (UIScrollView + PageControlNode) of InstantPageImageNode
  pages, wired through frame/offsetBy/collectMedias/stableId/reuse/makeItemView
  and the reveal-cost non-text list.
- Gallery transitions generalized onto InstantPageItemView via
  instantPageTransitionNode(for:)/instantPageUpdateHiddenMedia(_:) (default
  nil/no-op; explicit per-class witnesses on the 4 static media views, the
  slideshow forwards to its live pages) so the multi-media slideshow can
  participate alongside single-media views.

Docs: document both in docs/instantpage-richtext.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:50:40 +02:00

570 lines
26 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 thinking(start: 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 .thinking(start):
// Revealed (and contributes its full height) once the cursor reaches its index position.
// A top thinking block (start == 0) is revealed from the first frame.
if revealedCount < start { 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 .thinking:
// Zero cost: do NOT advance the cursor. This is the linchpin answer-content cursor
// positions are identical whether or not thinking blocks are present, so adding/
// removing a thinking block never jumps the answer's reveal position.
entries.append(.thinking(start: cursor))
case .formula, .mediaImage, .mediaVideo, .mediaMap, .mediaCoverImage, .mediaPlaceholder, .slideshow,
.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)
}
self.updateEmojiReveal(animated: animated)
self.updateImageReveal(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)
}
}
self.updateEmojiReveal(animated: animated)
self.updateImageReveal(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
case let .thinking(start):
// Whole-block 0.12s alpha fade-in at the index position; inner text is drawn fully
// (never char-reveal-masked) the shimmer is the only ongoing animation.
let visible = revealedCount >= start
applyVisibility(view: view, visible: visible, animated: animated)
}
}
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)
}