Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios
This commit is contained in:
commit
e2de53ea17
21 changed files with 738 additions and 746 deletions
|
|
@ -37,6 +37,7 @@ swift_library(
|
|||
"//submodules/Tuples:Tuples",
|
||||
"//third-party/SwiftMath:SwiftMath",
|
||||
"//submodules/ComponentFlow:ComponentFlow",
|
||||
"//submodules/TelegramUI/Components/ShimmeringMask:ShimmeringMask",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
|
|
|
|||
|
|
@ -461,7 +461,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation:
|
|||
let subBlock = blocks[i]
|
||||
let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: listItems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == blocks.count - 1, fitToWidth: fitToWidth)
|
||||
|
||||
let spacing: CGFloat = previousBlock != nil && subLayout.contentSize.height > 0.0 ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: fitToWidth) : 0.0
|
||||
let spacing: CGFloat = previousBlock != nil && subLayout.contentSize.height > 0.0 ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: fitToWidth, kind: .topLevel) : 0.0
|
||||
let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: horizontalInset + indexSpacing + maxIndexWidth, y: contentSize.height + spacing))
|
||||
if previousBlock == nil {
|
||||
originY += spacing
|
||||
|
|
@ -778,7 +778,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation:
|
|||
let subBlock = blocks[i]
|
||||
let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth - horizontalInset * 2.0 - lineInset, horizontalInset: 0.0, safeInset: 0.0, isCover: false, previousItems: items, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == blocks.count - 1, fitToWidth: fitToWidth)
|
||||
|
||||
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: false)
|
||||
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: false, kind: .topLevel)
|
||||
let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: horizontalInset + lineInset, y: contentSize.height + spacing))
|
||||
items.append(contentsOf: blockItems)
|
||||
contentSize.height += subLayout.contentSize.height + spacing
|
||||
|
|
@ -962,7 +962,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation:
|
|||
let subBlock = blocks[i]
|
||||
let subLayout = layoutInstantPageBlock(webpage: webpage, userLocation: userLocation, rtl: rtl, block: subBlock, boundingWidth: boundingWidth, horizontalInset: horizontalInset, safeInset: safeInset, isCover: false, previousItems: subitems, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &subDetailsIndex, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == blocks.count - 1, fitToWidth: fitToWidth)
|
||||
|
||||
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: false)
|
||||
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: false, kind: .topLevel)
|
||||
let blockItems = subLayout.flattenedItemsWithOrigin(CGPoint(x: 0.0, y: contentSize.height + spacing))
|
||||
subitems.append(contentsOf: blockItems)
|
||||
contentSize.height += subLayout.contentSize.height + spacing
|
||||
|
|
@ -970,7 +970,7 @@ public func layoutInstantPageBlock(webpage: TelegramMediaWebpage, userLocation:
|
|||
}
|
||||
|
||||
if !blocks.isEmpty {
|
||||
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: false)
|
||||
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: false, kind: .topLevel)
|
||||
contentSize.height += closingSpacing
|
||||
}
|
||||
|
||||
|
|
@ -1102,7 +1102,7 @@ public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, instant
|
|||
for i in 0 ..< pageBlocks.count {
|
||||
let block = pageBlocks[i]
|
||||
let blockLayout = layoutInstantPageBlock(webpage: webPage, userLocation: userLocation, rtl: rtl, block: block, boundingWidth: boundingWidth, horizontalInset: sideInset + safeInset, safeInset: safeInset, isCover: false, previousItems: items, fillToSize: nil, media: media, mediaIndexCounter: &mediaIndexCounter, embedIndexCounter: &embedIndexCounter, detailsIndexCounter: &detailsIndexCounter, theme: theme, strings: strings, dateTimeFormat: dateTimeFormat, webEmbedHeights: webEmbedHeights, cachedMessageSyntaxHighlight: cachedMessageSyntaxHighlight, excludeCaptions: false, isLast: i == pageBlocks.count - 1, fitToWidth: fitToWidth)
|
||||
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: block, fitToWidth: fitToWidth)
|
||||
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: block, fitToWidth: fitToWidth, kind: .topLevel)
|
||||
let blockItems = blockLayout.flattenedItemsWithOrigin(CGPoint(x: 0.0, y: contentSize.height + spacing))
|
||||
items.append(contentsOf: blockItems)
|
||||
if CGFloat(0.0).isLess(than: blockLayout.contentSize.height) {
|
||||
|
|
@ -1111,7 +1111,7 @@ public func instantPageLayoutForWebPage(_ webPage: TelegramMediaWebpage, instant
|
|||
}
|
||||
}
|
||||
|
||||
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: fitToWidth)
|
||||
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: fitToWidth, kind: .topLevel)
|
||||
contentSize.height += closingSpacing
|
||||
|
||||
if webPage.webpageId.id != 0 && addFeedback {
|
||||
|
|
|
|||
|
|
@ -2,19 +2,30 @@ import Foundation
|
|||
import UIKit
|
||||
import TelegramCore
|
||||
|
||||
func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fitToWidth: Bool) -> CGFloat {
|
||||
enum BlockSequenceKind {
|
||||
case topLevel
|
||||
case detail
|
||||
case cell
|
||||
case list
|
||||
}
|
||||
|
||||
func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fitToWidth: Bool, kind: BlockSequenceKind) -> CGFloat {
|
||||
if let upper, let lower {
|
||||
switch (upper, lower) {
|
||||
case (_, .cover), (_, .channelBanner), (.details, .details), (.relatedArticles, _), (_, .anchor):
|
||||
return 0.0
|
||||
case (.divider, _), (_, .divider):
|
||||
if fitToWidth {
|
||||
return 10.0
|
||||
return 20.0
|
||||
} else {
|
||||
return 25.0
|
||||
}
|
||||
case (_, .blockQuote), (.blockQuote, _), (_, .pullQuote), (.pullQuote, _):
|
||||
return 27.0
|
||||
if fitToWidth {
|
||||
return 11.0
|
||||
} else {
|
||||
return 27.0
|
||||
}
|
||||
case (.kicker, .title), (.cover, .title):
|
||||
return 16.0
|
||||
case (_, .title):
|
||||
|
|
@ -27,12 +38,22 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
|
|||
return 34.0
|
||||
case (.header, .paragraph), (.subheader, .paragraph), (.heading, .paragraph):
|
||||
if fitToWidth {
|
||||
return 10.0
|
||||
return 14.0
|
||||
} else {
|
||||
return 25.0
|
||||
}
|
||||
case (.list, .paragraph):
|
||||
return 31.0
|
||||
if fitToWidth {
|
||||
return 14.0
|
||||
} else {
|
||||
return 31.0
|
||||
}
|
||||
case (.paragraph, .list):
|
||||
if fitToWidth {
|
||||
return 14.0
|
||||
} else {
|
||||
return 31.0
|
||||
}
|
||||
case (.preformatted, .paragraph):
|
||||
return 19.0
|
||||
case (.formula, .paragraph):
|
||||
|
|
@ -92,24 +113,45 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
|
|||
}
|
||||
} else if let lower {
|
||||
switch lower {
|
||||
case .cover, .channelBanner, .details, .anchor, .table:
|
||||
case .cover, .channelBanner, .details, .anchor:
|
||||
return 0.0
|
||||
default:
|
||||
if fitToWidth {
|
||||
return 10.0
|
||||
switch kind {
|
||||
case .topLevel:
|
||||
switch lower {
|
||||
case .heading:
|
||||
return 13.0
|
||||
default:
|
||||
return 10.0
|
||||
}
|
||||
case .cell:
|
||||
return 0.0
|
||||
case .detail, .list:
|
||||
return 4.0
|
||||
}
|
||||
} else {
|
||||
return 25.0
|
||||
}
|
||||
}
|
||||
} else if let upper {
|
||||
switch kind {
|
||||
case .topLevel:
|
||||
if case .relatedArticles = upper {
|
||||
return 0.0
|
||||
} else {
|
||||
if fitToWidth {
|
||||
return 5.0
|
||||
} else {
|
||||
return 25.0
|
||||
}
|
||||
}
|
||||
case .detail, .list:
|
||||
return 16.0
|
||||
case .cell:
|
||||
return 0.0
|
||||
}
|
||||
} else {
|
||||
if let upper, case .relatedArticles = upper {
|
||||
return 0.0
|
||||
} else {
|
||||
if fitToWidth {
|
||||
return 5.0
|
||||
} else {
|
||||
return 25.0
|
||||
}
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import EmojiTextAttachmentView
|
|||
import AnimationCache
|
||||
import MultiAnimationRenderer
|
||||
import InvisibleInkDustNode
|
||||
import ShimmeringMask
|
||||
|
||||
// MARK: - Stable item identity (for view reuse on re-layouts)
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ import InvisibleInkDustNode
|
|||
public enum InstantPageV2StableItemId: Hashable {
|
||||
case media(Int) // media.index (4 media cases share this namespace)
|
||||
case details(Int) // details.index
|
||||
case thinking(Int) // thinking-block sequence index (own namespace)
|
||||
case positional(InstantPageV2ItemKind, Int) // (caseTag, items-array position)
|
||||
}
|
||||
|
||||
|
|
@ -48,7 +50,7 @@ public enum InstantPageV2ItemKind: Hashable {
|
|||
/// `InstantPageV2View()` constructor usable.
|
||||
public final class InstantPageV2RenderContext {
|
||||
public let context: AccountContext
|
||||
public let webpage: TelegramMediaWebpage
|
||||
public private(set) var webpage: TelegramMediaWebpage
|
||||
public let sourceLocation: InstantPageSourceLocation
|
||||
public let imageReference: (TelegramMediaImage) -> ImageMediaReference
|
||||
public let fileReference: (TelegramMediaFile) -> FileMediaReference
|
||||
|
|
@ -78,6 +80,15 @@ public final class InstantPageV2RenderContext {
|
|||
self.openUrl = openUrl
|
||||
self.baseNavigationController = baseNavigationController
|
||||
}
|
||||
|
||||
/// Update the content-bearing fields for a later chunk of the SAME message. Enables the
|
||||
/// streaming bubble to reuse one V2View across `stableVersion` bumps instead of rebuilding.
|
||||
/// Only `webpage` changes across chunks; the `imageReference`/`fileReference` closures keep
|
||||
/// their construction-time `MessageReference` snapshot, which is acceptable because the message
|
||||
/// id is stable across chunks (media resolves by id) and streamed AI content carries no media.
|
||||
public func updateContent(webpage: TelegramMediaWebpage) {
|
||||
self.webpage = webpage
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Inline image view data
|
||||
|
|
@ -212,8 +223,21 @@ public final class InstantPageV2View: UIView {
|
|||
var newStableIds: [InstantPageV2StableItemId] = []
|
||||
var reusedIds: Set<InstantPageV2StableItemId> = []
|
||||
|
||||
for (position, item) in layout.items.enumerated() {
|
||||
let id = InstantPageV2View.stableId(for: item, atPosition: position)
|
||||
// Two independent position counters so thinking-block churn never renumbers content
|
||||
// blocks' stable ids (requirement: adding/removing a thinking block must not affect other
|
||||
// blocks). Content items are numbered ignoring thinking items; thinking items get their
|
||||
// own .thinking(index) namespace.
|
||||
var contentPosition = 0
|
||||
var thinkingPosition = 0
|
||||
for item in layout.items {
|
||||
let id: InstantPageV2StableItemId
|
||||
if case .thinking = item {
|
||||
id = InstantPageV2View.stableId(for: item, atPosition: thinkingPosition)
|
||||
thinkingPosition += 1
|
||||
} else {
|
||||
id = InstantPageV2View.stableId(for: item, atPosition: contentPosition)
|
||||
contentPosition += 1
|
||||
}
|
||||
|
||||
if let existing = oldViewsById[id], let reusedView = self.reuse(existingView: existing, for: item, theme: theme, animation: animation) {
|
||||
let newFrame = InstantPageV2View.actualFrame(forItem: item) // parent positions child
|
||||
|
|
@ -261,10 +285,10 @@ public final class InstantPageV2View: UIView {
|
|||
let enableSpoilerAnimations = self.renderContext.map { $0.context.sharedContext.energyUsageSettings.fullTranslucency } ?? true
|
||||
for view in self.itemViews {
|
||||
if let textView = view as? InstantPageV2TextView {
|
||||
// Both fresh (makeItemView) and reused text views now build their dust through the
|
||||
// single init→update→updateSpoiler path, so we only push the external animation
|
||||
// setting here; its didSet rebuilds the dust if the value actually changed.
|
||||
textView.enableSpoilerAnimations = enableSpoilerAnimations
|
||||
// makeItemView builds fresh text views via init only (no update(item:theme:)), so
|
||||
// build their dust here; updateSpoiler is idempotent (no-op when there are no spoilers).
|
||||
textView.updateSpoiler(animated: false)
|
||||
}
|
||||
}
|
||||
// Force the current reveal state (true OR false) onto every text view every layout, so a
|
||||
|
|
@ -630,6 +654,10 @@ public final class InstantPageV2View: UIView {
|
|||
guard let v = existingView as? InstantPageV2MediaCoverImageView, let rc = self.renderContext else { return nil }
|
||||
v.update(item: media, theme: theme, renderContext: rc)
|
||||
return v
|
||||
case let .thinking(thinking):
|
||||
guard let v = existingView as? InstantPageV2ThinkingView else { return nil }
|
||||
v.update(item: thinking, theme: theme)
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -650,6 +678,7 @@ public final class InstantPageV2View: UIView {
|
|||
case .table: return .positional(.table, position)
|
||||
case .anchor: return .positional(.anchor, position)
|
||||
case .formula: return .positional(.formula, position)
|
||||
case .thinking: return .thinking(position)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -699,19 +728,19 @@ public final class InstantPageV2View: UIView {
|
|||
private func makeItemView(for item: InstantPageV2LaidOutItem, theme: InstantPageTheme) -> InstantPageItemView? {
|
||||
switch item {
|
||||
case let .text(text):
|
||||
return InstantPageV2TextView(item: text)
|
||||
return InstantPageV2TextView(item: text, theme: theme)
|
||||
case let .divider(divider):
|
||||
return InstantPageV2DividerView(item: divider)
|
||||
return InstantPageV2DividerView(item: divider, theme: theme)
|
||||
case let .anchor(anchor):
|
||||
return InstantPageV2AnchorView(item: anchor)
|
||||
return InstantPageV2AnchorView(item: anchor, theme: theme)
|
||||
case let .listMarker(marker):
|
||||
return InstantPageV2ListMarkerView(item: marker)
|
||||
return InstantPageV2ListMarkerView(item: marker, theme: theme)
|
||||
case let .codeBlock(block):
|
||||
return InstantPageV2CodeBlockView(item: block)
|
||||
return InstantPageV2CodeBlockView(item: block, theme: theme)
|
||||
case let .blockQuoteBar(bar):
|
||||
return InstantPageV2BlockQuoteBarView(item: bar)
|
||||
return InstantPageV2BlockQuoteBarView(item: bar, theme: theme)
|
||||
case let .shape(shape):
|
||||
return InstantPageV2ShapeView(item: shape)
|
||||
return InstantPageV2ShapeView(item: shape, theme: theme)
|
||||
case let .mediaPlaceholder(media):
|
||||
return InstantPageV2MediaPlaceholderView(item: media, theme: theme)
|
||||
case let .details(details):
|
||||
|
|
@ -747,7 +776,9 @@ public final class InstantPageV2View: UIView {
|
|||
return InstantPageV2MediaPlaceholderView(item: placeholderFallback(for: media), theme: theme)
|
||||
}
|
||||
case let .formula(formula):
|
||||
return InstantPageV2FormulaView(item: formula)
|
||||
return InstantPageV2FormulaView(item: formula, theme: theme)
|
||||
case let .thinking(thinking):
|
||||
return InstantPageV2ThinkingView(item: thinking, theme: theme)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -849,33 +880,25 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
|
|||
private var revealLineMaskLayers: [SimpleLayer] = []
|
||||
private var animatingSnippetLayers: [SnippetLayer] = []
|
||||
|
||||
init(item: InstantPageV2TextItem) {
|
||||
init(item: InstantPageV2TextItem, theme: InstantPageTheme) {
|
||||
self.item = item
|
||||
self.renderContainer = UIView()
|
||||
self.renderView = TextRenderView(item: item)
|
||||
super.init(frame: item.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset))
|
||||
// Structural wiring only (one-time); all frames/content live in update(item:theme:).
|
||||
self.backgroundColor = .clear
|
||||
self.isOpaque = false
|
||||
|
||||
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)
|
||||
|
||||
self.imageContainerView.frame = self.bounds
|
||||
self.imageContainerView.isUserInteractionEnabled = false
|
||||
self.addSubview(self.imageContainerView)
|
||||
|
||||
self.emojiContainerView.frame = self.bounds
|
||||
self.emojiContainerView.isUserInteractionEnabled = false
|
||||
self.addSubview(self.emojiContainerView)
|
||||
|
||||
self.spoilerContainerView.frame = self.bounds
|
||||
self.spoilerContainerView.isUserInteractionEnabled = false
|
||||
self.addSubview(self.spoilerContainerView)
|
||||
self.update(item: item, theme: theme)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
|
@ -886,11 +909,18 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
|
|||
func update(item: InstantPageV2TextItem, theme: InstantPageTheme) {
|
||||
let _ = theme
|
||||
self.item = item
|
||||
// Lay every container out from the item's own (clipping-inset-expanded) frame rather than
|
||||
// self.bounds, so the single path is correct regardless of when the parent assigns our
|
||||
// frame — and so a reused text view that changed size (e.g. AI streaming) re-frames its
|
||||
// renderContainer/renderView too, which the old update path skipped.
|
||||
let containerBounds = CGRect(origin: .zero, size: item.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset).size)
|
||||
self.renderContainer.frame = containerBounds
|
||||
self.renderView.frame = containerBounds
|
||||
self.renderView.item = item
|
||||
self.renderView.setNeedsDisplay()
|
||||
self.imageContainerView.frame = self.bounds
|
||||
self.emojiContainerView.frame = self.bounds
|
||||
self.spoilerContainerView.frame = self.bounds
|
||||
self.imageContainerView.frame = containerBounds
|
||||
self.emojiContainerView.frame = containerBounds
|
||||
self.spoilerContainerView.frame = containerBounds
|
||||
self.renderView.displayContentsUnderSpoilers = self.displayContentsUnderSpoilers
|
||||
self.updateSpoiler(animated: false)
|
||||
}
|
||||
|
|
@ -1421,10 +1451,10 @@ final class InstantPageV2DividerView: UIView, InstantPageItemView {
|
|||
private(set) var item: InstantPageV2DividerItem
|
||||
var itemFrame: CGRect { return self.item.frame }
|
||||
|
||||
init(item: InstantPageV2DividerItem) {
|
||||
init(item: InstantPageV2DividerItem, theme: InstantPageTheme) {
|
||||
self.item = item
|
||||
super.init(frame: item.frame)
|
||||
self.backgroundColor = item.color
|
||||
self.update(item: item, theme: theme)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
|
@ -1443,10 +1473,11 @@ final class InstantPageV2AnchorView: UIView, InstantPageItemView {
|
|||
private(set) var item: InstantPageV2AnchorItem
|
||||
var itemFrame: CGRect { return self.item.frame }
|
||||
|
||||
init(item: InstantPageV2AnchorItem) {
|
||||
init(item: InstantPageV2AnchorItem, theme: InstantPageTheme) {
|
||||
self.item = item
|
||||
super.init(frame: item.frame)
|
||||
self.isHidden = true
|
||||
self.isHidden = true // structural: zero-height, never renders
|
||||
self.update(item: item, theme: theme)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
|
@ -1464,12 +1495,12 @@ final class InstantPageV2ListMarkerView: UIView, InstantPageItemView {
|
|||
private(set) var item: InstantPageV2ListMarkerItem
|
||||
var itemFrame: CGRect { return self.item.frame }
|
||||
|
||||
init(item: InstantPageV2ListMarkerItem) {
|
||||
init(item: InstantPageV2ListMarkerItem, theme: InstantPageTheme) {
|
||||
self.item = item
|
||||
super.init(frame: item.frame)
|
||||
self.backgroundColor = .clear
|
||||
self.isOpaque = false
|
||||
self.rebuildContents()
|
||||
self.backgroundColor = .clear // structural
|
||||
self.isOpaque = false // structural
|
||||
self.update(item: item, theme: theme)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
|
@ -1538,11 +1569,10 @@ final class InstantPageV2BlockQuoteBarView: UIView, InstantPageItemView {
|
|||
private(set) var item: InstantPageV2BarItem
|
||||
var itemFrame: CGRect { return self.item.frame }
|
||||
|
||||
init(item: InstantPageV2BarItem) {
|
||||
init(item: InstantPageV2BarItem, theme: InstantPageTheme) {
|
||||
self.item = item
|
||||
super.init(frame: item.frame)
|
||||
self.backgroundColor = item.color
|
||||
self.layer.cornerRadius = item.cornerRadius
|
||||
self.update(item: item, theme: theme)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
|
@ -1562,10 +1592,10 @@ final class InstantPageV2ShapeView: UIView, InstantPageItemView {
|
|||
private(set) var item: InstantPageV2ShapeItem
|
||||
var itemFrame: CGRect { return self.item.frame }
|
||||
|
||||
init(item: InstantPageV2ShapeItem) {
|
||||
init(item: InstantPageV2ShapeItem, theme: InstantPageTheme) {
|
||||
self.item = item
|
||||
super.init(frame: item.frame)
|
||||
self.applyKind()
|
||||
self.update(item: item, theme: theme)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
|
@ -1598,9 +1628,7 @@ final class InstantPageV2MediaPlaceholderView: UIView, InstantPageItemView {
|
|||
init(item: InstantPageV2MediaPlaceholderItem, theme: InstantPageTheme) {
|
||||
self.item = item
|
||||
super.init(frame: item.frame)
|
||||
self.backgroundColor = theme.imageTintColor?.withAlphaComponent(0.2) ?? UIColor(white: 0.85, alpha: 1.0)
|
||||
self.layer.cornerRadius = item.cornerRadius
|
||||
self.clipsToBounds = item.cornerRadius > 0.0
|
||||
self.update(item: item, theme: theme)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
|
@ -1648,67 +1676,36 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
|
|||
frame: item.titleTextItem.frame,
|
||||
textItem: item.titleTextItem
|
||||
)
|
||||
self.titleTextView = InstantPageV2TextView(item: titleV2Item)
|
||||
self.titleTextView = InstantPageV2TextView(item: titleV2Item, theme: theme)
|
||||
self.titleTextView.isUserInteractionEnabled = false
|
||||
|
||||
self.chevronView = UIImageView()
|
||||
// Single downward chevron; the expanded state is a 180° rotation (animatable) rather than
|
||||
// an instant chevron.up/chevron.down image swap. A template image + tintColor renders the
|
||||
// SF Symbol in the message's primary text color — baking the color into a CALayer's cgImage
|
||||
// contents drops the tint and renders black. (SF Symbol is iOS 13+.)
|
||||
self.chevronView.image = UIImage(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate)
|
||||
self.chevronView.tintColor = theme.textCategories.paragraph.color
|
||||
self.chevronView.image = UIImage(bundleImageName: "Item List/ExpandingItemVerticalRegularArrow")?.withRenderingMode(.alwaysTemplate)
|
||||
self.chevronView.contentMode = .scaleAspectFit
|
||||
// Decorative: let taps fall through to titleHitView (which carries the toggle gesture).
|
||||
self.chevronView.isUserInteractionEnabled = false
|
||||
|
||||
self.separator = UIView()
|
||||
self.separator.backgroundColor = item.separatorColor
|
||||
self.separator.isUserInteractionEnabled = false
|
||||
|
||||
self.titleHitView = UIView(frame: item.titleFrame)
|
||||
self.titleHitView = UIView()
|
||||
self.titleHitView.backgroundColor = .clear
|
||||
|
||||
super.init(frame: item.frame)
|
||||
self.backgroundColor = .clear
|
||||
self.clipsToBounds = true
|
||||
self.backgroundColor = .clear // structural
|
||||
self.clipsToBounds = true // structural — the parent's frame-height animation clips the body
|
||||
|
||||
self.addSubview(self.titleTextView)
|
||||
self.addSubview(self.chevronView)
|
||||
self.addSubview(self.separator)
|
||||
|
||||
let chevronSize = CGSize(width: 18.0, height: 18.0)
|
||||
// bounds + center (not frame) so the rotation transform pivots around the center and the
|
||||
// frame stays well-defined while a non-identity transform is applied.
|
||||
self.chevronView.bounds = CGRect(origin: .zero, size: chevronSize)
|
||||
self.chevronView.center = CGPoint(
|
||||
x: item.titleFrame.maxX - chevronSize.width / 2.0 - 12.0,
|
||||
y: item.titleFrame.midY
|
||||
)
|
||||
self.chevronView.layer.transform = item.isExpanded ? InstantPageV2DetailsView.expandedChevronTransform : CATransform3DIdentity
|
||||
|
||||
// V1 (InstantPageDetailsNode.swift:138): separator sits at titleHeight - UIScreenPixel.
|
||||
self.separator.frame = CGRect(
|
||||
x: 0.0,
|
||||
y: item.titleFrame.maxY - 0.5,
|
||||
width: item.frame.width,
|
||||
height: 0.5
|
||||
)
|
||||
|
||||
if item.isExpanded, let innerLayout = item.innerLayout {
|
||||
let body = InstantPageV2View(renderContext: renderContext)
|
||||
body.update(layout: innerLayout, theme: theme, animation: .None)
|
||||
body.frame = CGRect(
|
||||
origin: CGPoint(x: 0.0, y: item.titleFrame.maxY),
|
||||
size: innerLayout.contentSize
|
||||
)
|
||||
self.addSubview(body)
|
||||
self.bodyView = body
|
||||
}
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(self.titleTapped))
|
||||
self.insertSubview(self.titleHitView, at: 0)
|
||||
self.titleHitView.addGestureRecognizer(tap)
|
||||
|
||||
// All content (title, chevron tint/position, separator, titleHit frame, body) flows through
|
||||
// update — its expanded branch lazily creates the body, so init no longer builds it itself.
|
||||
self.update(item: item, theme: theme, renderContext: renderContext, animation: .None)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
|
@ -1727,20 +1724,12 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
|
|||
)
|
||||
self.titleTextView.update(item: titleV2Item, theme: theme)
|
||||
|
||||
self.chevronView.tintColor = theme.textCategories.paragraph.color
|
||||
self.chevronView.tintColor = theme.secondaryControlColor
|
||||
let chevronSize = CGSize(width: 18.0, height: 18.0)
|
||||
self.chevronView.bounds = CGRect(origin: .zero, size: chevronSize)
|
||||
self.chevronView.center = CGPoint(
|
||||
x: item.titleFrame.maxX - chevronSize.width / 2.0 - 12.0,
|
||||
y: item.titleFrame.midY
|
||||
)
|
||||
|
||||
self.separator.backgroundColor = item.separatorColor
|
||||
self.separator.frame = CGRect(
|
||||
x: 0.0,
|
||||
y: item.titleFrame.maxY - 0.5,
|
||||
width: item.frame.width,
|
||||
height: 0.5
|
||||
x: item.sideInset + chevronSize.width / 2.0,
|
||||
y: item.titleFrame.midY + 1.0
|
||||
)
|
||||
|
||||
self.titleHitView.frame = item.titleFrame
|
||||
|
|
@ -1749,6 +1738,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
|
|||
// view's own frame height (clipsToBounds = true), not by the body itself — see
|
||||
// InstantPageV2View.update. The body's internal layout is forwarded `animation` so a
|
||||
// *nested* details block inside the body can also animate its own toggle.
|
||||
let blockHeight: CGFloat
|
||||
if item.isExpanded {
|
||||
if let innerLayout = item.innerLayout {
|
||||
let body: InstantPageV2View
|
||||
|
|
@ -1767,6 +1757,9 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
|
|||
origin: CGPoint(x: 0.0, y: item.titleFrame.maxY),
|
||||
size: innerLayout.contentSize
|
||||
)
|
||||
blockHeight = body.frame.maxY
|
||||
} else {
|
||||
blockHeight = item.titleFrame.maxY
|
||||
}
|
||||
} else {
|
||||
if let existingBody = self.bodyView {
|
||||
|
|
@ -1780,7 +1773,16 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
|
|||
self.bodyView = nil
|
||||
}
|
||||
}
|
||||
blockHeight = item.titleFrame.maxY
|
||||
}
|
||||
|
||||
self.separator.backgroundColor = item.separatorColor
|
||||
animation.animator.updateFrame(layer: self.separator.layer, frame: CGRect(
|
||||
x: 8.0,
|
||||
y: blockHeight - UIScreenPixel,
|
||||
width: item.frame.width - 8.0 * 2.0,
|
||||
height: UIScreenPixel
|
||||
), completion: nil)
|
||||
|
||||
// Chevron rotation. The body teardown on collapse is NOT tied to this completion — see
|
||||
// finalizePendingCollapse(), which the parent calls from the frame-shrink (clip) animation.
|
||||
|
|
@ -1810,25 +1812,23 @@ final class InstantPageV2CodeBlockView: UIView, InstantPageItemView {
|
|||
private let backgroundLayer: CALayer
|
||||
let textView: InstantPageV2TextView
|
||||
|
||||
init(item: InstantPageV2CodeBlockItem) {
|
||||
init(item: InstantPageV2CodeBlockItem, theme: InstantPageTheme) {
|
||||
self.item = item
|
||||
|
||||
self.backgroundLayer = CALayer()
|
||||
self.backgroundLayer.backgroundColor = item.backgroundColor.cgColor
|
||||
self.backgroundLayer.cornerRadius = item.cornerRadius
|
||||
self.backgroundLayer.frame = CGRect(origin: .zero, size: item.frame.size)
|
||||
|
||||
// item.textItem.frame is already in code-block content-area coords (x=17, y=backgroundInset).
|
||||
let innerV2TextItem = InstantPageV2TextItem(
|
||||
frame: item.textItem.frame,
|
||||
textItem: item.textItem
|
||||
)
|
||||
self.textView = InstantPageV2TextView(item: innerV2TextItem)
|
||||
self.textView = InstantPageV2TextView(item: innerV2TextItem, theme: theme)
|
||||
|
||||
super.init(frame: item.frame)
|
||||
self.backgroundColor = .clear
|
||||
self.layer.addSublayer(self.backgroundLayer)
|
||||
self.addSubview(self.textView)
|
||||
self.backgroundColor = .clear // structural
|
||||
self.layer.addSublayer(self.backgroundLayer) // structural
|
||||
self.addSubview(self.textView) // structural
|
||||
self.update(item: item, theme: theme)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
|
@ -1848,8 +1848,76 @@ final class InstantPageV2CodeBlockView: UIView, InstantPageItemView {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Thinking view (dimmed shimmering reasoning block)
|
||||
|
||||
/// A top-level thinking block: dimmed text drawn fully, masked by a continuously-running
|
||||
/// `ShimmeringMaskView`. Reveal is whole-block alpha (driven from the cost map), NOT char-by-char,
|
||||
/// and the block contributes zero reveal cost. Structure mirrors `InstantPageV2CodeBlockView`
|
||||
/// (container hosting an inner `InstantPageV2TextView`).
|
||||
final class InstantPageV2ThinkingView: UIView, InstantPageItemView {
|
||||
private(set) var item: InstantPageV2ThinkingItem
|
||||
var itemFrame: CGRect { return self.item.frame }
|
||||
|
||||
private let shimmerView: ShimmeringMaskView
|
||||
private let textView: InstantPageV2TextView
|
||||
|
||||
init(item: InstantPageV2ThinkingItem, theme: InstantPageTheme) {
|
||||
self.item = item
|
||||
self.shimmerView = ShimmeringMaskView(peakAlpha: 0.3, duration: 1.0)
|
||||
let innerV2TextItem = InstantPageV2TextItem(frame: item.textItem.frame, textItem: item.textItem)
|
||||
self.textView = InstantPageV2TextView(item: innerV2TextItem, theme: theme)
|
||||
|
||||
super.init(frame: item.frame)
|
||||
self.backgroundColor = .clear // structural
|
||||
self.addSubview(self.shimmerView) // structural
|
||||
self.shimmerView.contentView.addSubview(self.textView) // structural
|
||||
self.update(item: item, theme: theme)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
/// Parent positions children (see CLAUDE.md "View frame ownership"): the shimmer covers the
|
||||
/// whole block; the inner text view sits at its block-local typographic frame (expanded by the
|
||||
/// text view's clipping inset, matching `InstantPageV2TextView.init`).
|
||||
private func layoutContents() {
|
||||
self.shimmerView.frame = CGRect(origin: .zero, size: self.item.frame.size)
|
||||
self.textView.frame = self.item.textItem.frame.insetBy(dx: -v2TextViewClippingInset, dy: -v2TextViewClippingInset)
|
||||
self.shimmerView.update(
|
||||
size: self.item.frame.size,
|
||||
containerWidth: self.item.frame.size.width,
|
||||
offsetX: 0.0,
|
||||
gradientWidth: 200.0,
|
||||
transition: .immediate
|
||||
)
|
||||
}
|
||||
|
||||
func update(item: InstantPageV2ThinkingItem, theme: InstantPageTheme) {
|
||||
self.item = item
|
||||
let innerV2TextItem = InstantPageV2TextItem(frame: item.textItem.frame, textItem: item.textItem)
|
||||
self.textView.update(item: innerV2TextItem, theme: theme)
|
||||
self.layoutContents()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Table view
|
||||
|
||||
/// The set of grid corners a cell occupies, so a filled corner cell's stripe can be rounded to
|
||||
/// follow the table's rounded outer border. `cellFrame` is table-grid-local (pre-`gridOffsetY`).
|
||||
private func tableStripeCornerMask(cellFrame: CGRect, gridWidth: CGFloat, gridHeight: CGFloat, effectiveBorderWidth: CGFloat) -> CACornerMask {
|
||||
let edge = effectiveBorderWidth / 2.0 + 0.5
|
||||
let firstCol = cellFrame.minX <= edge
|
||||
let firstRow = cellFrame.minY <= edge
|
||||
let lastCol = cellFrame.maxX >= gridWidth - edge
|
||||
let lastRow = cellFrame.maxY >= gridHeight - edge
|
||||
var mask: CACornerMask = []
|
||||
if firstRow && firstCol { mask.insert(.layerMinXMinYCorner) }
|
||||
if firstRow && lastCol { mask.insert(.layerMaxXMinYCorner) }
|
||||
if lastRow && firstCol { mask.insert(.layerMinXMaxYCorner) }
|
||||
if lastRow && lastCol { mask.insert(.layerMaxXMaxYCorner) }
|
||||
return mask
|
||||
}
|
||||
|
||||
final class InstantPageV2TableView: UIView, InstantPageItemView {
|
||||
private(set) var item: InstantPageV2TableItem
|
||||
var itemFrame: CGRect { return self.item.frame }
|
||||
|
|
@ -1875,81 +1943,51 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
|
|||
super.init(frame: item.frame)
|
||||
self.backgroundColor = .clear
|
||||
|
||||
self.scrollView.frame = self.bounds
|
||||
self.scrollView.contentSize = item.contentSize
|
||||
// Structural, one-time scroll-view configuration. Frames / contentSize / indicator
|
||||
// visibility all depend on the item and are (re)applied by update(item:theme:).
|
||||
// Scrollable tables clip to the full width with no inset on the clip; the inset lives inside
|
||||
// the scroll content width as a margin on BOTH sides (`contentInset * 2.0`, mirroring V1's
|
||||
// `InstantPageScrollableNode`), so a scrolled-to-the-end table keeps a symmetric trailing
|
||||
// inset instead of jamming its right border flush against the screen edge.
|
||||
self.scrollView.clipsToBounds = true
|
||||
self.scrollView.alwaysBounceHorizontal = false
|
||||
self.scrollView.alwaysBounceVertical = false
|
||||
self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width > item.frame.width
|
||||
self.scrollView.showsVerticalScrollIndicator = false
|
||||
self.scrollView.disablesInteractiveTransitionGestureRecognizer = true
|
||||
self.addSubview(self.scrollView)
|
||||
|
||||
self.contentView.frame = CGRect(origin: .zero, size: item.contentSize)
|
||||
self.scrollView.addSubview(self.contentView)
|
||||
|
||||
// Title sub-layout (above the grid, inside the scroll view's content).
|
||||
if let titleLayout = item.titleSubLayout, let titleFrame = item.titleFrame {
|
||||
// Build the (content-less) child structure sized to the construction-time item; update fills
|
||||
// every frame / colour / sub-layout below. Insertion order matches the original interleaved
|
||||
// build so the layer/subview z-order is unchanged (stripes at the bottom, then the title and
|
||||
// cell sub-views, then the inner grid lines). Cell-count changes on later reuse are not
|
||||
// reconciled here (pre-existing limitation) — update's index-guarded loops refresh in place.
|
||||
if item.titleSubLayout != nil {
|
||||
let v = InstantPageV2View(renderContext: renderContext)
|
||||
v.update(layout: titleLayout, theme: theme, animation: .None)
|
||||
v.frame = CGRect(x: v2TableCellInsets.left, y: titleFrame.minY + v2TableCellInsets.top,
|
||||
width: titleLayout.contentSize.width, height: titleLayout.contentSize.height)
|
||||
self.contentView.addSubview(v)
|
||||
self.titleSubView = v
|
||||
}
|
||||
|
||||
// Grid origin: shifted down by title height when present.
|
||||
let gridOffsetY = item.titleFrame?.height ?? 0.0
|
||||
|
||||
// Cell backgrounds and sub-layouts.
|
||||
for cell in item.cells {
|
||||
if let bg = cell.backgroundColor {
|
||||
if cell.backgroundColor != nil {
|
||||
let stripe = CALayer()
|
||||
stripe.backgroundColor = bg.cgColor
|
||||
stripe.frame = cell.frame.offsetBy(dx: 0.0, dy: gridOffsetY)
|
||||
self.contentView.layer.insertSublayer(stripe, at: 0)
|
||||
self.stripeLayers.append(stripe)
|
||||
}
|
||||
if let subLayout = cell.subLayout {
|
||||
if cell.subLayout != nil {
|
||||
let v = InstantPageV2View(renderContext: renderContext)
|
||||
v.update(layout: subLayout, theme: theme, animation: .None)
|
||||
// The sub-layout items are already offset by cell insets inside the cell frame.
|
||||
v.frame = cell.frame.offsetBy(dx: 0.0, dy: gridOffsetY)
|
||||
self.contentView.addSubview(v)
|
||||
self.cellSubViews.append(v)
|
||||
}
|
||||
}
|
||||
|
||||
// Border lines.
|
||||
if item.bordered {
|
||||
for r in item.horizontalLines + item.verticalLines {
|
||||
for _ in item.horizontalLines + item.verticalLines {
|
||||
let line = CALayer()
|
||||
line.backgroundColor = item.borderColor.cgColor
|
||||
line.frame = r.offsetBy(dx: 0.0, dy: gridOffsetY)
|
||||
self.contentView.layer.addSublayer(line)
|
||||
self.lineLayers.append(line)
|
||||
}
|
||||
// Outer border rect (four edges).
|
||||
let outerW = v2TableBorderWidth
|
||||
let outerRect = CGRect(
|
||||
x: outerW / 2.0,
|
||||
y: gridOffsetY + outerW / 2.0,
|
||||
width: item.contentSize.width - outerW,
|
||||
height: item.contentSize.height - outerW
|
||||
)
|
||||
let outerEdges: [CGRect] = [
|
||||
CGRect(x: outerRect.minX, y: outerRect.minY, width: outerRect.width, height: outerW),
|
||||
CGRect(x: outerRect.minX, y: outerRect.maxY - outerW, width: outerRect.width, height: outerW),
|
||||
CGRect(x: outerRect.minX, y: outerRect.minY, width: outerW, height: outerRect.height),
|
||||
CGRect(x: outerRect.maxX - outerW, y: outerRect.minY, width: outerW, height: outerRect.height)
|
||||
]
|
||||
for edge in outerEdges {
|
||||
let line = CALayer()
|
||||
line.backgroundColor = item.borderColor.cgColor
|
||||
line.frame = edge
|
||||
self.contentView.layer.addSublayer(line)
|
||||
self.lineLayers.append(line)
|
||||
}
|
||||
}
|
||||
|
||||
self.update(item: item, theme: theme)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
|
@ -1959,9 +1997,9 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
|
|||
self.item = item
|
||||
|
||||
self.scrollView.frame = CGRect(origin: .zero, size: item.frame.size)
|
||||
self.scrollView.contentSize = item.contentSize
|
||||
self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width > item.frame.width
|
||||
self.contentView.frame = CGRect(origin: .zero, size: item.contentSize)
|
||||
self.scrollView.contentSize = CGSize(width: item.contentSize.width + item.contentInset * 2.0, height: item.contentSize.height)
|
||||
self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width + item.contentInset * 2.0 > item.frame.width
|
||||
self.contentView.frame = CGRect(x: item.contentInset, y: 0.0, width: item.contentSize.width, height: item.contentSize.height)
|
||||
|
||||
// Forward updates to nested V2 sub-layouts (title + each cell). Recursive update
|
||||
// propagation. Cell-count or title-presence changes fall back to rebuild via the
|
||||
|
|
@ -1987,21 +2025,42 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
|
|||
}
|
||||
}
|
||||
|
||||
// Stripe layers (cell backgrounds) — update color + frame in original order.
|
||||
// Stripe layers (cell backgrounds) — update color + frame + corner rounding in original order.
|
||||
let effectiveBorderWidth = item.bordered ? v2TableBorderWidth : 0.0
|
||||
let gridHeight = item.contentSize.height - gridOffsetY
|
||||
var stripeIndex = 0
|
||||
for cell in item.cells {
|
||||
if let bg = cell.backgroundColor, stripeIndex < self.stripeLayers.count {
|
||||
let stripe = self.stripeLayers[stripeIndex]
|
||||
stripe.backgroundColor = bg.cgColor
|
||||
stripe.frame = cell.frame.offsetBy(dx: 0.0, dy: gridOffsetY)
|
||||
let cornerMask = tableStripeCornerMask(cellFrame: cell.frame, gridWidth: item.contentSize.width, gridHeight: gridHeight, effectiveBorderWidth: effectiveBorderWidth)
|
||||
if cornerMask.isEmpty {
|
||||
stripe.cornerRadius = 0.0
|
||||
stripe.maskedCorners = []
|
||||
} else {
|
||||
stripe.cornerRadius = max(0.0, v2TableCornerRadius - effectiveBorderWidth)
|
||||
stripe.maskedCorners = cornerMask
|
||||
}
|
||||
stripeIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Line layers (borders) — update color in place; frames recomputed in original order.
|
||||
for line in self.lineLayers {
|
||||
// Inner line layers — refresh colour AND frame in place. (`lineLayers` holds only inner grid
|
||||
// lines; the outer border is the contentView layer's own rounded border, refreshed below.)
|
||||
// Frames are set here (not in init) so reuse with a different grid re-positions the lines.
|
||||
let lineRects = item.horizontalLines + item.verticalLines
|
||||
for (i, line) in self.lineLayers.enumerated() {
|
||||
line.backgroundColor = item.borderColor.cgColor
|
||||
if i < lineRects.count {
|
||||
line.frame = lineRects[i].offsetBy(dx: 0.0, dy: gridOffsetY)
|
||||
}
|
||||
}
|
||||
|
||||
// Rounded outer border — refresh radius/color/width (theme or `bordered` flag may change).
|
||||
self.contentView.layer.cornerRadius = v2TableCornerRadius
|
||||
self.contentView.layer.borderColor = item.borderColor.cgColor
|
||||
self.contentView.layer.borderWidth = item.bordered ? v2TableBorderWidth : 0.0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2079,7 +2138,7 @@ private func findTextItem(
|
|||
}
|
||||
case let .table(table):
|
||||
for cell in table.cells {
|
||||
let cellAbs = cell.frame.offsetBy(dx: f.minX, dy: f.minY)
|
||||
let cellAbs = cell.frame.offsetBy(dx: f.minX + table.contentInset, dy: f.minY)
|
||||
if !cellAbs.contains(point) { continue }
|
||||
if let sub = cell.subLayout {
|
||||
if let hit = findTextItem(in: sub, point: point,
|
||||
|
|
@ -2089,7 +2148,7 @@ private func findTextItem(
|
|||
}
|
||||
}
|
||||
if let titleLayout = table.titleSubLayout, let titleFrame = table.titleFrame {
|
||||
let titleAbs = titleFrame.offsetBy(dx: f.minX, dy: f.minY)
|
||||
let titleAbs = titleFrame.offsetBy(dx: f.minX + table.contentInset, dy: f.minY)
|
||||
if titleAbs.contains(point) {
|
||||
if let hit = findTextItem(in: titleLayout, point: point,
|
||||
accumulatedOffset: CGPoint(x: titleAbs.minX, y: titleAbs.minY)) {
|
||||
|
|
@ -2142,7 +2201,7 @@ private func collectSelectableTextItems(
|
|||
case let .table(table):
|
||||
if let titleLayout = table.titleSubLayout, let titleFrame = table.titleFrame {
|
||||
let titleOffset = CGPoint(
|
||||
x: accumulatedOffset.x + table.frame.minX + titleFrame.minX,
|
||||
x: accumulatedOffset.x + table.frame.minX + table.contentInset + titleFrame.minX,
|
||||
y: accumulatedOffset.y + table.frame.minY + titleFrame.minY
|
||||
)
|
||||
collectSelectableTextItems(in: titleLayout, accumulatedOffset: titleOffset, into: &result)
|
||||
|
|
@ -2150,7 +2209,7 @@ private func collectSelectableTextItems(
|
|||
for cell in table.cells {
|
||||
if let sub = cell.subLayout {
|
||||
let cellOffset = CGPoint(
|
||||
x: accumulatedOffset.x + table.frame.minX + cell.frame.minX,
|
||||
x: accumulatedOffset.x + table.frame.minX + table.contentInset + cell.frame.minX,
|
||||
y: accumulatedOffset.y + table.frame.minY + cell.frame.minY
|
||||
)
|
||||
collectSelectableTextItems(in: sub, accumulatedOffset: cellOffset, into: &result)
|
||||
|
|
@ -2175,12 +2234,12 @@ final class InstantPageV2FormulaView: UIView, InstantPageItemView {
|
|||
private(set) var item: InstantPageV2FormulaItem
|
||||
var itemFrame: CGRect { return self.item.frame }
|
||||
|
||||
init(item: InstantPageV2FormulaItem) {
|
||||
init(item: InstantPageV2FormulaItem, theme: InstantPageTheme) {
|
||||
self.item = item
|
||||
super.init(frame: item.frame)
|
||||
self.backgroundColor = .clear
|
||||
self.isOpaque = false
|
||||
self.buildContents()
|
||||
self.backgroundColor = .clear // structural
|
||||
self.isOpaque = false // structural
|
||||
self.update(item: item, theme: theme)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
|
@ -2190,7 +2249,8 @@ final class InstantPageV2FormulaView: UIView, InstantPageItemView {
|
|||
let _ = theme
|
||||
self.item = item
|
||||
|
||||
// Image content and scroll/non-scroll shape may change with width; rebuild.
|
||||
// Image content and scroll/non-scroll shape may change with width; rebuild. On the first
|
||||
// call (from init) there is nothing to tear down, so this collapses to a plain build.
|
||||
for sub in self.subviews { sub.removeFromSuperview() }
|
||||
if let sublayers = self.layer.sublayers {
|
||||
for layer in sublayers { layer.removeFromSuperlayer() }
|
||||
|
|
|
|||
|
|
@ -131,12 +131,12 @@ public final class InstantPageTheme {
|
|||
public let tableBorderColor: UIColor
|
||||
public let tableHeaderColor: UIColor
|
||||
public let controlColor: UIColor
|
||||
|
||||
public let imageTintColor: UIColor?
|
||||
|
||||
public let overlayPanelColor: UIColor
|
||||
public let separatorColor: UIColor
|
||||
public let secondaryControlColor: UIColor
|
||||
|
||||
public init(type: InstantPageThemeType, pageBackgroundColor: UIColor, textCategories: InstantPageTextCategories, serif: Bool, codeBlockBackgroundColor: UIColor, linkColor: UIColor, textHighlightColor: UIColor, linkHighlightColor: UIColor, markerColor: UIColor, panelBackgroundColor: UIColor, panelHighlightedBackgroundColor: UIColor, panelPrimaryColor: UIColor, panelSecondaryColor: UIColor, panelAccentColor: UIColor, tableBorderColor: UIColor, tableHeaderColor: UIColor, controlColor: UIColor, imageTintColor: UIColor?, overlayPanelColor: UIColor) {
|
||||
public init(type: InstantPageThemeType, pageBackgroundColor: UIColor, textCategories: InstantPageTextCategories, serif: Bool, codeBlockBackgroundColor: UIColor, linkColor: UIColor, textHighlightColor: UIColor, linkHighlightColor: UIColor, markerColor: UIColor, panelBackgroundColor: UIColor, panelHighlightedBackgroundColor: UIColor, panelPrimaryColor: UIColor, panelSecondaryColor: UIColor, panelAccentColor: UIColor, tableBorderColor: UIColor, tableHeaderColor: UIColor, controlColor: UIColor, imageTintColor: UIColor?, overlayPanelColor: UIColor, separatorColor: UIColor, secondaryControlColor: UIColor) {
|
||||
self.type = type
|
||||
self.pageBackgroundColor = pageBackgroundColor
|
||||
self.textCategories = textCategories
|
||||
|
|
@ -156,10 +156,12 @@ public final class InstantPageTheme {
|
|||
self.controlColor = controlColor
|
||||
self.imageTintColor = imageTintColor
|
||||
self.overlayPanelColor = overlayPanelColor
|
||||
self.separatorColor = separatorColor
|
||||
self.secondaryControlColor = secondaryControlColor
|
||||
}
|
||||
|
||||
public func withUpdatedFontStyles(sizeMultiplier: CGFloat, lineSpacingFactor: CGFloat, forceSerif: Bool) -> InstantPageTheme {
|
||||
return InstantPageTheme(type: type, pageBackgroundColor: pageBackgroundColor, textCategories: self.textCategories.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), serif: forceSerif, codeBlockBackgroundColor: codeBlockBackgroundColor, linkColor: linkColor, textHighlightColor: textHighlightColor, linkHighlightColor: linkHighlightColor, markerColor: markerColor, panelBackgroundColor: panelBackgroundColor, panelHighlightedBackgroundColor: panelHighlightedBackgroundColor, panelPrimaryColor: panelPrimaryColor, panelSecondaryColor: panelSecondaryColor, panelAccentColor: panelAccentColor, tableBorderColor: tableBorderColor, tableHeaderColor: tableHeaderColor, controlColor: controlColor, imageTintColor: imageTintColor, overlayPanelColor: overlayPanelColor)
|
||||
return InstantPageTheme(type: type, pageBackgroundColor: pageBackgroundColor, textCategories: self.textCategories.withUpdatedFontStyles(sizeMultiplier: sizeMultiplier, lineSpacingFactor: lineSpacingFactor, forceSerif: forceSerif), serif: forceSerif, codeBlockBackgroundColor: codeBlockBackgroundColor, linkColor: linkColor, textHighlightColor: textHighlightColor, linkHighlightColor: linkHighlightColor, markerColor: markerColor, panelBackgroundColor: panelBackgroundColor, panelHighlightedBackgroundColor: panelHighlightedBackgroundColor, panelPrimaryColor: panelPrimaryColor, panelSecondaryColor: panelSecondaryColor, panelAccentColor: panelAccentColor, tableBorderColor: tableBorderColor, tableHeaderColor: tableHeaderColor, controlColor: controlColor, imageTintColor: imageTintColor, overlayPanelColor: overlayPanelColor, separatorColor: separatorColor, secondaryControlColor: secondaryControlColor)
|
||||
}
|
||||
|
||||
func headingTextAttributes(level: Int32, link: Bool) -> InstantPageTextAttributes {
|
||||
|
|
@ -191,7 +193,7 @@ public final class InstantPageTheme {
|
|||
baseSize = 13.0
|
||||
}
|
||||
|
||||
let sizeMultiplier = subheaderAttributes.font.size / 19.0
|
||||
let sizeMultiplier = subheaderAttributes.font.size / 18.0
|
||||
let attributes = InstantPageTextAttributes(
|
||||
font: InstantPageFont(style: .serif, size: floor(baseSize * sizeMultiplier), lineSpacingFactor: subheaderAttributes.font.lineSpacingFactor),
|
||||
color: subheaderAttributes.color,
|
||||
|
|
@ -229,7 +231,9 @@ private let lightTheme = InstantPageTheme(
|
|||
tableHeaderColor: UIColor(rgb: 0xf4f4f4),
|
||||
controlColor: UIColor(rgb: 0xc7c7cd),
|
||||
imageTintColor: nil,
|
||||
overlayPanelColor: .white
|
||||
overlayPanelColor: .white,
|
||||
separatorColor: UIColor(rgb: 0xe2e2e2),
|
||||
secondaryControlColor: .black
|
||||
)
|
||||
|
||||
private let sepiaTheme = InstantPageTheme(
|
||||
|
|
@ -260,7 +264,9 @@ private let sepiaTheme = InstantPageTheme(
|
|||
tableHeaderColor: UIColor(rgb: 0xf0e7d4),
|
||||
controlColor: UIColor(rgb: 0xddd1b8),
|
||||
imageTintColor: nil,
|
||||
overlayPanelColor: UIColor(rgb: 0xf8f1e2)
|
||||
overlayPanelColor: UIColor(rgb: 0xf8f1e2),
|
||||
separatorColor: UIColor(rgb: 0xe2e2e2),
|
||||
secondaryControlColor: .black
|
||||
)
|
||||
|
||||
private let grayTheme = InstantPageTheme(
|
||||
|
|
@ -291,7 +297,9 @@ private let grayTheme = InstantPageTheme(
|
|||
tableHeaderColor: UIColor(rgb: 0x555556),
|
||||
controlColor: UIColor(rgb: 0x484848),
|
||||
imageTintColor: UIColor(rgb: 0xcecece),
|
||||
overlayPanelColor: UIColor(rgb: 0x5a5a5c)
|
||||
overlayPanelColor: UIColor(rgb: 0x5a5a5c),
|
||||
separatorColor: UIColor(rgb: 0x484848),
|
||||
secondaryControlColor: .black
|
||||
)
|
||||
|
||||
private let darkTheme = InstantPageTheme(
|
||||
|
|
@ -322,7 +330,9 @@ private let darkTheme = InstantPageTheme(
|
|||
tableHeaderColor: UIColor(rgb: 0x131313),
|
||||
controlColor: UIColor(rgb: 0x303030),
|
||||
imageTintColor: UIColor(rgb: 0xb0b0b0),
|
||||
overlayPanelColor: UIColor(rgb: 0x232323)
|
||||
overlayPanelColor: UIColor(rgb: 0x232323),
|
||||
separatorColor: UIColor(rgb: 0x303030),
|
||||
secondaryControlColor: UIColor(rgb: 0xb0b0b0)
|
||||
)
|
||||
|
||||
private func fontSizeMultiplierForVariant(_ variant: InstantPagePresentationFontSize) -> CGFloat {
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ public enum InstantPageV2LaidOutItem {
|
|||
case mediaMap(InstantPageV2MediaMapItem)
|
||||
case mediaCoverImage(InstantPageV2MediaCoverImageItem)
|
||||
case formula(InstantPageV2FormulaItem)
|
||||
case thinking(InstantPageV2ThinkingItem)
|
||||
|
||||
public var frame: CGRect {
|
||||
switch self {
|
||||
|
|
@ -103,6 +104,7 @@ public enum InstantPageV2LaidOutItem {
|
|||
case let .mediaMap(item): return item.frame
|
||||
case let .mediaCoverImage(item): return item.frame
|
||||
case let .formula(item): return item.frame
|
||||
case let .thinking(item): return item.frame
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -126,6 +128,7 @@ public enum InstantPageV2LaidOutItem {
|
|||
case var .mediaMap(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaMap(item)
|
||||
case var .mediaCoverImage(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .mediaCoverImage(item)
|
||||
case var .formula(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .formula(item)
|
||||
case var .thinking(item): item.frame = item.frame.offsetBy(dx: delta.x, dy: delta.y); return .thinking(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -143,6 +146,13 @@ public struct InstantPageV2CodeBlockItem {
|
|||
public let inset: UIEdgeInsets
|
||||
}
|
||||
|
||||
public struct InstantPageV2ThinkingItem {
|
||||
public var frame: CGRect
|
||||
/// The dimmed thinking text, laid out in block-local coordinates. Drawn fully (never
|
||||
/// char-reveal-masked); the shimmer + whole-block fade are the only animations.
|
||||
public let textItem: InstantPageTextItem
|
||||
}
|
||||
|
||||
public struct InstantPageV2DividerItem {
|
||||
public var frame: CGRect
|
||||
public let color: UIColor
|
||||
|
|
@ -283,6 +293,7 @@ public struct InstantPageV2MediaPlaceholderItem {
|
|||
public struct InstantPageV2DetailsItem {
|
||||
public var frame: CGRect
|
||||
public let index: Int
|
||||
public let sideInset: CGFloat
|
||||
public let titleTextItem: InstantPageTextItem
|
||||
public let titleFrame: CGRect // local to this item's frame
|
||||
public let separatorColor: UIColor
|
||||
|
|
@ -309,6 +320,7 @@ public struct InstantPageV2TableItem {
|
|||
public let titleSubLayout: InstantPageV2Layout?
|
||||
public let titleFrame: CGRect?
|
||||
public let contentSize: CGSize // grid intrinsic size; may exceed frame.width → scroll
|
||||
public let contentInset: CGFloat // page horizontalInset; the renderer shifts the grid right by it and pads the scroll content by it on BOTH sides
|
||||
public let cells: [InstantPageV2TableCell]
|
||||
public let horizontalLines: [CGRect]
|
||||
public let verticalLines: [CGRect]
|
||||
|
|
@ -370,6 +382,7 @@ public func layoutInstantPageV2(
|
|||
instantPage.blocks,
|
||||
boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset,
|
||||
kind: .topLevel,
|
||||
context: &context
|
||||
)
|
||||
}
|
||||
|
|
@ -481,6 +494,7 @@ private func layoutBlockSequence(
|
|||
_ blocks: [InstantPageBlock],
|
||||
boundingWidth: CGFloat,
|
||||
horizontalInset: CGFloat,
|
||||
kind: BlockSequenceKind,
|
||||
context: inout LayoutContext
|
||||
) -> InstantPageV2Layout {
|
||||
var items: [InstantPageV2LaidOutItem] = []
|
||||
|
|
@ -489,11 +503,12 @@ private func layoutBlockSequence(
|
|||
var previousBlock: InstantPageBlock?
|
||||
|
||||
for (i, block) in blocks.enumerated() {
|
||||
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: block, fitToWidth: context.fitToWidth)
|
||||
let spacing = spacingBetweenBlocks(upper: previousBlock, lower: block, fitToWidth: context.fitToWidth, kind: kind)
|
||||
let localItems = layoutBlock(
|
||||
block,
|
||||
boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset,
|
||||
kind: kind,
|
||||
isCover: false,
|
||||
previousItems: items,
|
||||
isLast: i == blocks.count - 1,
|
||||
|
|
@ -521,7 +536,7 @@ private func layoutBlockSequence(
|
|||
}
|
||||
}
|
||||
|
||||
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: context.fitToWidth)
|
||||
let closingSpacing = spacingBetweenBlocks(upper: previousBlock, lower: nil, fitToWidth: context.fitToWidth, kind: kind)
|
||||
contentHeight += closingSpacing
|
||||
|
||||
var contentSize = CGSize(width: boundingWidth, height: contentHeight)
|
||||
|
|
@ -583,6 +598,7 @@ private func layoutBlock(
|
|||
_ block: InstantPageBlock,
|
||||
boundingWidth: CGFloat,
|
||||
horizontalInset: CGFloat,
|
||||
kind: BlockSequenceKind,
|
||||
isCover: Bool,
|
||||
previousItems: [InstantPageV2LaidOutItem],
|
||||
isLast: Bool,
|
||||
|
|
@ -591,7 +607,7 @@ private func layoutBlock(
|
|||
let _ = isLast // reserved for Tasks 7–9
|
||||
switch block {
|
||||
case let .cover(inner):
|
||||
return layoutBlock(inner, boundingWidth: boundingWidth, horizontalInset: horizontalInset,
|
||||
return layoutBlock(inner, boundingWidth: boundingWidth, horizontalInset: horizontalInset, kind: kind,
|
||||
isCover: true, previousItems: previousItems, isLast: isLast, context: &context)
|
||||
case let .title(text):
|
||||
let titleItems = layoutSimpleText(text, category: .header, boundingWidth: boundingWidth,
|
||||
|
|
@ -617,7 +633,7 @@ private func layoutBlock(
|
|||
return layoutSimpleText(text, category: .caption, boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset, context: &context)
|
||||
case let .paragraph(text):
|
||||
return layoutParagraph(text, boundingWidth: boundingWidth, horizontalInset: horizontalInset,
|
||||
return layoutParagraph(text, boundingWidth: boundingWidth, horizontalInset: horizontalInset, kind: kind,
|
||||
previousItems: previousItems, context: &context)
|
||||
case let .authorDate(author, date):
|
||||
return layoutAuthorDate(author: author, date: date, boundingWidth: boundingWidth,
|
||||
|
|
@ -629,7 +645,7 @@ private func layoutBlock(
|
|||
|
||||
case let .list(items, ordered):
|
||||
return layoutList(items, ordered: ordered, boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset, context: &context)
|
||||
horizontalInset: horizontalInset, kind: kind, context: &context)
|
||||
|
||||
case let .preformatted(text, language):
|
||||
return layoutCodeBlock(text, language: language, boundingWidth: boundingWidth,
|
||||
|
|
@ -637,7 +653,7 @@ private func layoutBlock(
|
|||
|
||||
case let .blockQuote(blocks, caption):
|
||||
return layoutBlockQuote(blocks: blocks, caption: caption,
|
||||
boundingWidth: boundingWidth, horizontalInset: horizontalInset,
|
||||
boundingWidth: boundingWidth, horizontalInset: horizontalInset, kind: kind,
|
||||
isLast: isLast, context: &context)
|
||||
case let .pullQuote(text, caption):
|
||||
return layoutQuoteText(text: text, caption: caption, isPull: true,
|
||||
|
|
@ -875,7 +891,7 @@ private func layoutBlock(
|
|||
case let .formula(latex):
|
||||
return layoutFormulaBlock(latex: latex,
|
||||
boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset,
|
||||
horizontalInset: horizontalInset, kind: kind,
|
||||
context: &context)
|
||||
|
||||
case let .details(title, blocks, expanded):
|
||||
|
|
@ -888,9 +904,9 @@ private func layoutBlock(
|
|||
boundingWidth: boundingWidth, horizontalInset: horizontalInset,
|
||||
context: &context)
|
||||
|
||||
// Block kinds filled in by later tasks:
|
||||
case .thinking:
|
||||
return []
|
||||
case let .thinking(text):
|
||||
return layoutThinking(text, boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset, context: &context)
|
||||
case .unsupported:
|
||||
return []
|
||||
}
|
||||
|
|
@ -907,6 +923,7 @@ private func layoutFormulaBlock(
|
|||
latex: String,
|
||||
boundingWidth: CGFloat,
|
||||
horizontalInset: CGFloat,
|
||||
kind: BlockSequenceKind,
|
||||
context: inout LayoutContext
|
||||
) -> [InstantPageV2LaidOutItem] {
|
||||
// Style stack matches V1's per-block formula (paragraph category, not header).
|
||||
|
|
@ -926,6 +943,7 @@ private func layoutFormulaBlock(
|
|||
return layoutParagraph(.plain(latex),
|
||||
boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset,
|
||||
kind: kind,
|
||||
previousItems: [],
|
||||
context: &context)
|
||||
}
|
||||
|
|
@ -983,16 +1001,19 @@ private func layoutDetails(
|
|||
let (titleTextItem, _, _) = layoutTextItem(
|
||||
attributedStringForRichText(title, styleStack: titleStyleStack),
|
||||
boundingWidth: boundingWidth - horizontalInset * 2.0 - 32.0, // reserve right edge for chevron
|
||||
offset: CGPoint(x: horizontalInset, y: 12.0),
|
||||
offset: CGPoint(x: 0.0, y: 0.0),
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
guard let titleTextItem = titleTextItem else { return [] }
|
||||
|
||||
let titleHeight = max(44.0, titleTextItem.frame.height + 26.0)
|
||||
titleTextItem.frame.origin.x = horizontalInset + 23.0
|
||||
titleTextItem.frame.origin.y = floorToScreenPixels((titleHeight - titleTextItem.frame.height) * 0.5)
|
||||
|
||||
let isExpanded = context.expandedDetails[index] ?? defaultExpanded
|
||||
|
||||
// V1 uses max(44.0, titleSize.height + 26.0); matched here.
|
||||
let titleHeight = max(44.0, titleTextItem.frame.height + 26.0)
|
||||
let titleFrame = CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: titleHeight)
|
||||
|
||||
var innerLayout: InstantPageV2Layout?
|
||||
|
|
@ -1002,6 +1023,7 @@ private func layoutDetails(
|
|||
blocks,
|
||||
boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset,
|
||||
kind: .detail,
|
||||
context: &context
|
||||
)
|
||||
innerLayout = layout
|
||||
|
|
@ -1011,9 +1033,10 @@ private func layoutDetails(
|
|||
let item = InstantPageV2DetailsItem(
|
||||
frame: CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: totalHeight),
|
||||
index: index,
|
||||
sideInset: horizontalInset,
|
||||
titleTextItem: titleTextItem,
|
||||
titleFrame: titleFrame,
|
||||
separatorColor: context.theme.controlColor.withMultipliedAlpha(0.25),
|
||||
separatorColor: context.theme.separatorColor,
|
||||
isExpanded: isExpanded,
|
||||
innerLayout: innerLayout,
|
||||
defaultExpanded: defaultExpanded
|
||||
|
|
@ -1028,8 +1051,13 @@ private struct V2TableRow {
|
|||
var maxColumnWidths: [Int: CGFloat]
|
||||
}
|
||||
|
||||
let v2TableCellInsets = UIEdgeInsets(top: 14.0, left: 12.0, bottom: 14.0, right: 12.0)
|
||||
let v2TableBorderWidth: CGFloat = 1.0
|
||||
let v2TableCellInsets: UIEdgeInsets = {
|
||||
return UIEdgeInsets(top: 15.0, left: 13.0, bottom: 15.0, right: 13.0)
|
||||
}()
|
||||
let v2TableBorderWidth: CGFloat = {
|
||||
return UIScreenPixel * 2.0
|
||||
}()
|
||||
let v2TableCornerRadius: CGFloat = 10.0
|
||||
|
||||
private func layoutTable(
|
||||
title: RichText,
|
||||
|
|
@ -1046,11 +1074,15 @@ private func layoutTable(
|
|||
|
||||
// Style stack shared across all cell text measurements.
|
||||
let styleStack = InstantPageTextStyleStack()
|
||||
setupStyleStack(styleStack, theme: context.theme, category: .paragraph, link: false)
|
||||
setupStyleStack(styleStack, theme: context.theme, category: .table, link: false)
|
||||
|
||||
let borderWidth = bordered ? v2TableBorderWidth : 0.0
|
||||
// Size columns against the inset content width (mirrors V1's `boundingWidth - horizontalInset*2`),
|
||||
// so a fitting table aligns with body text on both sides. The item frame stays full-width (flush)
|
||||
// and the renderer bakes the inset back in as a left margin on the scroll content.
|
||||
let contentBoundingWidth = boundingWidth - horizontalInset * 2.0
|
||||
let totalCellPadding = v2TableCellInsets.left + v2TableCellInsets.right
|
||||
let cellWidthLimit = boundingWidth - totalCellPadding
|
||||
let cellWidthLimit = contentBoundingWidth - totalCellPadding
|
||||
|
||||
var tableRows: [V2TableRow] = []
|
||||
var columnCount: Int = 0
|
||||
|
|
@ -1080,10 +1112,14 @@ private func layoutTable(
|
|||
var minCellWidth: CGFloat = 1.0
|
||||
var maxCellWidth: CGFloat = 1.0
|
||||
if let text = cell.text {
|
||||
let attrStr = attributedStringForRichText(text, styleStack: styleStack)
|
||||
// Mirror V1 (`InstantPageTableItem.layoutTableItem`): `attributedStringForRichText`'s
|
||||
// boundingWidth sizes inline attachments to `cellWidthLimit - totalCellPadding`, while
|
||||
// the line-break budget passed to `layoutTextItem` is the full `cellWidthLimit`. (V1
|
||||
// subtracts `totalCellPadding` only on the attribute-string arg, not the layout arg.)
|
||||
let attrStr = attributedStringForRichText(text, styleStack: styleStack, boundingWidth: cellWidthLimit - totalCellPadding)
|
||||
if let shortestItem = layoutTextItem(
|
||||
attrStr,
|
||||
boundingWidth: cellWidthLimit - totalCellPadding,
|
||||
boundingWidth: cellWidthLimit,
|
||||
offset: CGPoint(),
|
||||
minimizeWidth: true,
|
||||
fitToWidth: context.fitToWidth,
|
||||
|
|
@ -1093,7 +1129,7 @@ private func layoutTable(
|
|||
}
|
||||
if let longestItem = layoutTextItem(
|
||||
attrStr,
|
||||
boundingWidth: cellWidthLimit - totalCellPadding,
|
||||
boundingWidth: cellWidthLimit,
|
||||
offset: CGPoint(),
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
|
|
@ -1135,7 +1171,7 @@ private func layoutTable(
|
|||
}
|
||||
|
||||
// Aggregate column min/max across all rows.
|
||||
let maxContentWidth = boundingWidth - borderWidth
|
||||
let maxContentWidth = contentBoundingWidth - borderWidth
|
||||
var availableWidth = maxContentWidth
|
||||
var minColumnWidths: [Int: CGFloat] = [:]
|
||||
var maxColumnWidths: [Int: CGFloat] = [:]
|
||||
|
|
@ -1206,7 +1242,7 @@ private func layoutTable(
|
|||
distributedWidth -= growth
|
||||
finalColumnWidths[i] = width
|
||||
}
|
||||
totalWidth = boundingWidth
|
||||
totalWidth = contentBoundingWidth
|
||||
} else {
|
||||
totalWidth += borderWidth
|
||||
}
|
||||
|
|
@ -1279,6 +1315,7 @@ private func layoutTable(
|
|||
[.paragraph(cellText)],
|
||||
boundingWidth: cellContentWidth,
|
||||
horizontalInset: 0.0,
|
||||
kind: .cell,
|
||||
context: &context
|
||||
)
|
||||
stampMarkdownContext(cellLayout.items, kind: .tableCell(row: i, column: k, isHeader: cell.header))
|
||||
|
|
@ -1503,6 +1540,7 @@ private func layoutTable(
|
|||
[.paragraph(title)],
|
||||
boundingWidth: totalWidth - v2TableCellInsets.left * 2.0,
|
||||
horizontalInset: 0.0,
|
||||
kind: .cell,
|
||||
context: &context
|
||||
)
|
||||
titleSubLayout = titleLayout
|
||||
|
|
@ -1510,10 +1548,11 @@ private func layoutTable(
|
|||
titleFrame = CGRect(x: 0.0, y: 0.0, width: totalWidth, height: titleHeight)
|
||||
}
|
||||
|
||||
// The table item frame spans the full boundingWidth slot in the bubble;
|
||||
// contentSize.width is the intrinsic grid width (may exceed frame.width → horizontal scroll).
|
||||
// The table item frame spans the full visible bubble interior (`boundingWidth`); the scroll
|
||||
// viewport equals what is actually visible. contentSize.width is the intrinsic grid width
|
||||
// (may exceed frame.width → horizontal scroll); the renderer adds the inset on both sides.
|
||||
let tableFrame = CGRect(x: 0.0, y: 0.0,
|
||||
width: boundingWidth + horizontalInset * 2.0,
|
||||
width: boundingWidth,
|
||||
height: totalHeight + (titleFrame?.height ?? 0.0))
|
||||
let contentSize = CGSize(
|
||||
width: totalWidth,
|
||||
|
|
@ -1525,6 +1564,7 @@ private func layoutTable(
|
|||
titleSubLayout: titleSubLayout,
|
||||
titleFrame: titleFrame,
|
||||
contentSize: contentSize,
|
||||
contentInset: horizontalInset,
|
||||
cells: finalizedCells,
|
||||
horizontalLines: horizontalLines,
|
||||
verticalLines: verticalLines,
|
||||
|
|
@ -1765,13 +1805,14 @@ private func layoutParagraph(
|
|||
_ text: RichText,
|
||||
boundingWidth: CGFloat,
|
||||
horizontalInset: CGFloat,
|
||||
kind: BlockSequenceKind,
|
||||
previousItems: [InstantPageV2LaidOutItem],
|
||||
context: inout LayoutContext
|
||||
) -> [InstantPageV2LaidOutItem] {
|
||||
let _ = previousItems
|
||||
|
||||
let styleStack = InstantPageTextStyleStack()
|
||||
setupStyleStack(styleStack, theme: context.theme, category: .paragraph, link: false)
|
||||
setupStyleStack(styleStack, theme: context.theme, category: kind == .cell ? .table : .paragraph, link: false)
|
||||
let attributedString = attributedStringForRichText(text, styleStack: styleStack)
|
||||
|
||||
let (_, items, _) = layoutTextItem(
|
||||
|
|
@ -1944,6 +1985,38 @@ private func layoutCodeBlock(
|
|||
))]
|
||||
}
|
||||
|
||||
private func layoutThinking(
|
||||
_ text: RichText,
|
||||
boundingWidth: CGFloat,
|
||||
horizontalInset: CGFloat,
|
||||
context: inout LayoutContext
|
||||
) -> [InstantPageV2LaidOutItem] {
|
||||
// Dimmed/secondary base color: the paragraph body color at reduced alpha. RichText keeps
|
||||
// its own bold/italic/link/inline-emoji formatting on top of this base (mirrors the old
|
||||
// hardcoded "Thinking…" header, which used the message theme's dimmed description color).
|
||||
let base = context.theme.textCategories.paragraph
|
||||
let dimmedAttributes = InstantPageTextAttributes(
|
||||
font: base.font,
|
||||
color: base.color.withAlphaComponent(0.55),
|
||||
underline: false
|
||||
)
|
||||
let styleStack = InstantPageTextStyleStack()
|
||||
setupStyleStack(styleStack, theme: context.theme, attributes: dimmedAttributes)
|
||||
let attributedString = attributedStringForRichText(text, styleStack: styleStack)
|
||||
|
||||
let (textItem, _, textSize) = layoutTextItem(
|
||||
attributedString,
|
||||
boundingWidth: boundingWidth - horizontalInset * 2.0,
|
||||
offset: CGPoint(x: horizontalInset, y: 0.0),
|
||||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
guard let textItem = textItem else { return [] }
|
||||
|
||||
let blockFrame = CGRect(x: 0.0, y: 0.0, width: boundingWidth, height: textSize.height)
|
||||
return [.thinking(InstantPageV2ThinkingItem(frame: blockFrame, textItem: textItem))]
|
||||
}
|
||||
|
||||
// MARK: - Block quote / pull quote layout (ported from V1 InstantPageLayout.swift lines 517–586)
|
||||
|
||||
private func layoutBlockQuote(
|
||||
|
|
@ -1951,6 +2024,7 @@ private func layoutBlockQuote(
|
|||
caption: RichText,
|
||||
boundingWidth: CGFloat,
|
||||
horizontalInset: CGFloat,
|
||||
kind: BlockSequenceKind,
|
||||
isLast: Bool,
|
||||
context: inout LayoutContext
|
||||
) -> [InstantPageV2LaidOutItem] {
|
||||
|
|
@ -1962,7 +2036,7 @@ private func layoutBlockQuote(
|
|||
}
|
||||
|
||||
let verticalInset: CGFloat = 4.0
|
||||
let lineInset: CGFloat = 20.0
|
||||
let lineInset: CGFloat = context.fitToWidth ? 12.0 : 20.0
|
||||
let barWidth: CGFloat = 3.0
|
||||
|
||||
let innerBoundingWidth = boundingWidth - horizontalInset * 2.0 - lineInset
|
||||
|
|
@ -1981,6 +2055,7 @@ private func layoutBlockQuote(
|
|||
child,
|
||||
boundingWidth: innerBoundingWidth,
|
||||
horizontalInset: innerHorizontalInset,
|
||||
kind: kind,
|
||||
isCover: false,
|
||||
previousItems: result,
|
||||
isLast: i == blocks.count - 1 && isLast,
|
||||
|
|
@ -2041,7 +2116,7 @@ private func layoutQuoteText(
|
|||
// V1 line 518/553: verticalInset = 4.0 for both variants.
|
||||
let verticalInset: CGFloat = 4.0
|
||||
// V1 line 518: lineInset = 20.0 (blockQuote only; pullQuote uses full width).
|
||||
let lineInset: CGFloat = isPull ? 0.0 : 20.0
|
||||
let lineInset: CGFloat = isPull ? 0.0 : (context.fitToWidth ? 12.0 : 20.0)
|
||||
|
||||
var result: [InstantPageV2LaidOutItem] = []
|
||||
var contentHeight: CGFloat = verticalInset // V1 line 520/554: starts at verticalInset
|
||||
|
|
@ -2147,6 +2222,7 @@ private func layoutList(
|
|||
ordered: Bool,
|
||||
boundingWidth: CGFloat,
|
||||
horizontalInset: CGFloat,
|
||||
kind: BlockSequenceKind,
|
||||
context: inout LayoutContext
|
||||
) -> [InstantPageV2LaidOutItem] {
|
||||
// Determine marker characteristics.
|
||||
|
|
@ -2323,13 +2399,14 @@ private func layoutList(
|
|||
subBlock,
|
||||
boundingWidth: boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth,
|
||||
horizontalInset: 0.0,
|
||||
kind: kind,
|
||||
isCover: false,
|
||||
previousItems: result,
|
||||
isLast: j == blocks.count - 1,
|
||||
context: &context
|
||||
)
|
||||
let subLocalMaxY: CGFloat = subItems.map { $0.frame.maxY }.max() ?? 0.0
|
||||
let spacing: CGFloat = (previousBlock != nil && subLocalMaxY > 0.0) ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: context.fitToWidth) : 0.0
|
||||
let spacing: CGFloat = (previousBlock != nil && subLocalMaxY > 0.0) ? spacingBetweenBlocks(upper: previousBlock, lower: subBlock, fitToWidth: context.fitToWidth, kind: .list) : 0.0
|
||||
let offsetX = horizontalInset + indexSpacing + maxIndexWidth
|
||||
let offsetY = contentHeight + spacing
|
||||
let translatedItems = subItems.map { $0.offsetBy(CGPoint(x: offsetX, y: offsetY)) }
|
||||
|
|
|
|||
|
|
@ -137,11 +137,10 @@ final class InstantPageV2MediaImageView: UIView, InstantPageItemView {
|
|||
)
|
||||
|
||||
super.init(frame: item.frame)
|
||||
self.backgroundColor = .clear
|
||||
self.layer.cornerRadius = item.cornerRadius
|
||||
self.clipsToBounds = item.cornerRadius > 0.0
|
||||
self.addSubview(self.wrappedNode.view)
|
||||
wrapperRef.view = self
|
||||
self.backgroundColor = .clear // structural
|
||||
self.addSubview(self.wrappedNode.view) // structural
|
||||
wrapperRef.view = self // structural: back-reference for the openMedia closure
|
||||
self.update(item: item, theme: theme, renderContext: renderContext)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
|
@ -193,11 +192,10 @@ final class InstantPageV2MediaVideoView: UIView, InstantPageItemView {
|
|||
)
|
||||
|
||||
super.init(frame: item.frame)
|
||||
self.backgroundColor = .clear
|
||||
self.layer.cornerRadius = item.cornerRadius
|
||||
self.clipsToBounds = item.cornerRadius > 0.0
|
||||
self.addSubview(self.wrappedNode.view)
|
||||
wrapperRef.view = self
|
||||
self.backgroundColor = .clear // structural
|
||||
self.addSubview(self.wrappedNode.view) // structural
|
||||
wrapperRef.view = self // structural: back-reference for the openMedia closure
|
||||
self.update(item: item, theme: theme, renderContext: renderContext)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
|
@ -249,11 +247,10 @@ final class InstantPageV2MediaMapView: UIView, InstantPageItemView {
|
|||
)
|
||||
|
||||
super.init(frame: item.frame)
|
||||
self.backgroundColor = .clear
|
||||
self.layer.cornerRadius = item.cornerRadius
|
||||
self.clipsToBounds = item.cornerRadius > 0.0
|
||||
self.addSubview(self.wrappedNode.view)
|
||||
wrapperRef.view = self
|
||||
self.backgroundColor = .clear // structural
|
||||
self.addSubview(self.wrappedNode.view) // structural
|
||||
wrapperRef.view = self // structural: back-reference for the openMedia closure
|
||||
self.update(item: item, theme: theme, renderContext: renderContext)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
|
@ -305,11 +302,10 @@ final class InstantPageV2MediaCoverImageView: UIView, InstantPageItemView {
|
|||
)
|
||||
|
||||
super.init(frame: item.frame)
|
||||
self.backgroundColor = .clear
|
||||
self.layer.cornerRadius = item.cornerRadius
|
||||
self.clipsToBounds = item.cornerRadius > 0.0
|
||||
self.addSubview(self.wrappedNode.view)
|
||||
wrapperRef.view = self
|
||||
self.backgroundColor = .clear // structural
|
||||
self.addSubview(self.wrappedNode.view) // structural
|
||||
wrapperRef.view = self // structural: back-reference for the openMedia closure
|
||||
self.update(item: item, theme: theme, renderContext: renderContext)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ 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?)
|
||||
|
|
@ -168,6 +169,11 @@ private func revealedExtent(entry: InstantPageV2RevealCostMap.Entry, item: Insta
|
|||
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
|
||||
|
|
@ -323,6 +329,11 @@ private func computeEntries(items: [InstantPageV2LaidOutItem], cursor: inout Int
|
|||
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,
|
||||
.divider, .listMarker, .blockQuoteBar, .shape, .anchor:
|
||||
let start = cursor
|
||||
|
|
@ -419,6 +430,11 @@ private func applyRevealEntry(view: InstantPageItemView, entry: InstantPageV2Rev
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1654,14 +1654,14 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
|
|||
var allowFullWidth = false
|
||||
let chatLocationPeerId: PeerId = item.chatLocation.peerId ?? item.content.firstMessage.id.peerId
|
||||
|
||||
var isInlinePage = false
|
||||
/*let isInlinePage = false
|
||||
for attribute in item.message.attributes {
|
||||
if attribute is RichTextMessageAttribute {
|
||||
allowFullWidth = true
|
||||
isInlinePage = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
do {
|
||||
let peerId = chatLocationPeerId
|
||||
|
|
@ -1931,9 +1931,9 @@ public class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewI
|
|||
needsShareButton = false
|
||||
}
|
||||
|
||||
if isInlinePage {
|
||||
/*if isInlinePage {
|
||||
needsShareButton = false
|
||||
}
|
||||
}*/
|
||||
|
||||
var tmpWidth: CGFloat
|
||||
if allowFullWidth {
|
||||
|
|
|
|||
|
|
@ -24,10 +24,7 @@ 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",
|
||||
|
|
|
|||
|
|
@ -15,9 +15,6 @@ import TelegramUIPreferences
|
|||
import TextLoadingEffect
|
||||
import TextSelectionNode
|
||||
import StreamingTextReveal
|
||||
import ShimmeringMask
|
||||
import InteractiveTextComponent
|
||||
import TextNodeWithEntities
|
||||
|
||||
public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode {
|
||||
public final class ContainerNode: ASDisplayNode {
|
||||
|
|
@ -49,9 +46,6 @@ 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?
|
||||
|
|
@ -70,10 +64,10 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
|||
}
|
||||
|
||||
// Pushes the current `visibility` sub-rect into `pageView.visibilityRect`, translated into the
|
||||
// page view's coordinate space (the page view sits at `streamingHeaderOffset` inside the bubble).
|
||||
// Re-invoked from the apply closure after `pageView.frame` is set, because that offset shifts
|
||||
// across streamed chunks without a `visibility` change, which would otherwise leave the
|
||||
// animation-gating rect stale.
|
||||
// page view's coordinate space (the page view sits at the top of the bubble; no header offset).
|
||||
// Re-invoked from the apply closure after `pageView.frame` is set, because the pageView's
|
||||
// y-origin and size can change across streamed chunks (content growth) without a `visibility`
|
||||
// change, which would otherwise leave the animation-gating rect stale.
|
||||
private func updatePageViewVisibilityRect() {
|
||||
guard let pageView = self.pageView else {
|
||||
return
|
||||
|
|
@ -99,14 +93,22 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
|||
self.addSubnode(self.containerNode)
|
||||
}
|
||||
|
||||
/// Builds (or reuses) the V2View. The render context is constructor-fixed on V2View, so
|
||||
/// when the bubble is recycled with a different webpage we must rebuild the V2View.
|
||||
/// Builds (or reuses) the V2View. Same-message stableVersion bumps (streamed AI chunks) reuse
|
||||
/// the existing view, updating only the webpage content in place. The view is rebuilt only when
|
||||
/// the bubble is recycled with a different message/webpage (different message id).
|
||||
private func ensurePageView(item: ChatMessageBubbleContentItem, webpage: TelegramMediaWebpage) -> InstantPageV2View {
|
||||
let key = (id: item.message.id, stableVersion: item.message.stableVersion)
|
||||
if let existing = self.pageView,
|
||||
let current = self.pageViewMessageKey,
|
||||
current.id == key.id,
|
||||
current.stableVersion == key.stableVersion {
|
||||
if let existing = self.pageView, let current = self.pageViewMessageKey, current.id == key.id {
|
||||
if current.stableVersion == key.stableVersion {
|
||||
return existing
|
||||
}
|
||||
// Same message, new chunk: reuse the view. Update only the content-bearing webpage on
|
||||
// the existing render context; the subsequent pageView.update(layout:) call diffs item
|
||||
// views by stable id (content blocks keep their ids, so their views and in-flight
|
||||
// reveal state persist; only added/removed blocks change). This replaces the old
|
||||
// wholesale rebuild and eliminates the per-chunk full-text-then-mask flash.
|
||||
existing.renderContext?.updateContent(webpage: webpage)
|
||||
self.pageViewMessageKey = key
|
||||
return existing
|
||||
}
|
||||
self.pageView?.removeFromSuperview()
|
||||
|
|
@ -174,7 +176,6 @@ 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)
|
||||
|
|
@ -188,25 +189,6 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
|||
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)
|
||||
|
|
@ -219,7 +201,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
|||
// self-x 0 (containerNode at 1, pageView at -1 inside it), so the page's text
|
||||
// left edge in the status node's coordinate space is exactly this value. Used as
|
||||
// the status node's left edge + side inset, mirroring TextBubble's bubbleInsets.
|
||||
let pageHorizontalInset: CGFloat = 10.0
|
||||
let pageHorizontalInset: CGFloat = 11.0
|
||||
|
||||
let isDark = item.presentationData.theme.theme.overallDarkAppearance
|
||||
let isIncoming = item.message.effectivelyIncoming(item.context.account.peerId)
|
||||
|
|
@ -296,8 +278,8 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
|||
|
||||
let textCategories = InstantPageTextCategories(
|
||||
kicker: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
|
||||
header: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 24.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
|
||||
subheader: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 19.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
|
||||
header: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 19.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
|
||||
subheader: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
|
||||
paragraph: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 17.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor),
|
||||
caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor),
|
||||
credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor),
|
||||
|
|
@ -323,7 +305,9 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
|||
tableHeaderColor: isDark || !isIncoming ? messageTheme.accentControlColor.withMultipliedAlpha(0.1) : UIColor(white: 0.0, alpha: 0.05),
|
||||
controlColor: messageTheme.accentControlColor,
|
||||
imageTintColor: nil,
|
||||
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13)
|
||||
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13),
|
||||
separatorColor: isIncoming ? UIColor(white: 0.0, alpha: 0.25): messageTheme.accentControlColor.withMultipliedAlpha(0.25),
|
||||
secondaryControlColor: messageTheme.secondaryTextColor
|
||||
)
|
||||
|
||||
var hasDraft = false
|
||||
|
|
@ -401,70 +385,10 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
|||
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
|
||||
}
|
||||
// The hardcoded "Thinking…" header was removed in favor of server-sent
|
||||
// InstantPageBlock.thinking blocks (rendered inside the pageView). There is no
|
||||
// header strip anymore, so the page content starts at the top of the bubble.
|
||||
let streamingHeaderOffset: CGFloat = 0.0
|
||||
|
||||
if hasDraft {
|
||||
// The bubble's bottom inset is supplied by the `statusBottomEdge + 6.0`
|
||||
|
|
@ -762,64 +686,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
|||
self.currentRevealCostMap = nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 2. Drive the reveal controller.
|
||||
let previousAnimateGlyphCount: Int? = (hasDraft || hadDraft) ? (self.textRevealController?.currentGlyphCount ?? 0) : nil
|
||||
if previousAnimateGlyphCount != nil || self.textRevealController != nil || hasDraft || hadDraft {
|
||||
if hasDraft {
|
||||
|
|
|
|||
|
|
@ -698,7 +698,7 @@ public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode {
|
|||
))
|
||||
|
||||
var streamingTextLayoutAndApply: (layout: InteractiveTextNodeLayout, apply: (InteractiveTextNodeWithEntities.Arguments) -> InteractiveTextNodeWithEntities)?
|
||||
if hasDraft || hadDraft {
|
||||
if !"".isEmpty && (hasDraft || hadDraft) {
|
||||
//TODO:localize
|
||||
streamingTextLayoutAndApply = streamingStatusTextLayout(InteractiveTextNodeLayoutArguments(
|
||||
attributedString: NSAttributedString(string: "Thinking...", font: textFont, textColor: messageTheme.fileDescriptionColor),
|
||||
|
|
|
|||
|
|
@ -119,7 +119,9 @@ final class ChatSendMessageRichTextPreview: ChatSendMessageContextScreenRichText
|
|||
tableHeaderColor: messageTheme.accentControlColor.withMultipliedAlpha(0.1),
|
||||
controlColor: messageTheme.accentControlColor,
|
||||
imageTintColor: nil,
|
||||
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13)
|
||||
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13),
|
||||
separatorColor: messageTheme.accentControlColor.withMultipliedAlpha(0.25),
|
||||
secondaryControlColor: messageTheme.secondaryTextColor
|
||||
)
|
||||
|
||||
let layout = layoutInstantPageV2(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue