InstantPage V2 thinking block: text-item sizing, overhang, inline emoji/images

Make the thinking block render consistently with a normal text item:

- Size the `.thinking` item frame text-width at the page inset
  (`(horizontalInset, 0, textWidth, height)`), identical to a `.text`
  item, instead of a full-bleed `(0, 0, boundingWidth, height)` box, by
  laying the text out flush and moving the inset onto the block frame.
  The shimmer (sized to `item.frame.size`) now hugs the text.

- Size the shimmer + its gradient mask to the clipping-inset-expanded
  frame (shifted `-inset`) so the mask no longer crops glyph ascenders,
  descenders and the last line's underline, mirroring how a `.text`
  view's frame is inset-expanded.

- Visit the thinking block's shimmer-wrapped inner text view in
  updateInlineEmoji / updateInlineImages so its inline custom emoji and
  images get layers created, positioned and revealed. They were skipped
  because the page only iterated top-level InstantPageV2TextView items.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-05-31 15:49:33 +02:00
parent 1120b9084e
commit 6408175725
2 changed files with 46 additions and 12 deletions

View file

@ -307,7 +307,18 @@ public final class InstantPageV2View: UIView {
var validIds: [InlineStickerItemLayer.Key] = []
for view in self.itemViews {
guard let textView = view as? InstantPageV2TextView else { continue }
// Top-level `.text` items host their emoji directly. The thinking block hosts emoji on
// its shimmer-wrapped inner text view, which the page never sees as a top-level item
// so without this it is skipped and the emoji never get layers (invisible). Nested V2
// sub-layouts (details bodies, table cells) instead run their own updateInlineEmoji.
let textView: InstantPageV2TextView
if let topLevelTextView = view as? InstantPageV2TextView {
textView = topLevelTextView
} else if let thinkingView = view as? InstantPageV2ThinkingView {
textView = thinkingView.textView
} else {
continue
}
let textItem = textView.item.textItem
let boundsWidth = textItem.frame.size.width
for line in textItem.lines {
@ -390,7 +401,17 @@ public final class InstantPageV2View: UIView {
var validKeys: Set<InlineImageKey> = []
for view in self.itemViews {
guard let textView = view as? InstantPageV2TextView else { continue }
// Same nesting as updateInlineEmoji: top-level `.text` items host their inline images
// directly; the thinking block hosts them on its shimmer-wrapped inner text view, which
// the page never sees as a top-level item. Nested V2 sub-layouts run their own pass.
let textView: InstantPageV2TextView
if let topLevelTextView = view as? InstantPageV2TextView {
textView = topLevelTextView
} else if let thinkingView = view as? InstantPageV2ThinkingView {
textView = thinkingView.textView
} else {
continue
}
let textItem = textView.item.textItem
let boundsWidth = textItem.frame.size.width
for line in textItem.lines {
@ -1859,7 +1880,7 @@ final class InstantPageV2ThinkingView: UIView, InstantPageItemView {
var itemFrame: CGRect { return self.item.frame }
private let shimmerView: ShimmeringMaskView
private let textView: InstantPageV2TextView
let textView: InstantPageV2TextView // exposed so the parent V2 view can host its inline emoji
init(item: InstantPageV2ThinkingItem, theme: InstantPageTheme) {
self.item = item
@ -1877,15 +1898,22 @@ final class InstantPageV2ThinkingView: UIView, InstantPageItemView {
@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`).
/// Parent positions self at the item frame (the bare line box). The shimmer and its gradient
/// mask are sized to the text view's clipping-inset-EXPANDED frame and shifted to
/// `(-inset, -inset)`, so the mask doesn't crop the glyph overhang the inset reserves (tall
/// ascenders, descenders, the last line's underline) the symptom of sizing the mask to the
/// bare line box. The inner text view fills the shimmer; its `+inset` render translate lands the
/// glyphs back at self's origin, so the text position is unchanged. Mirrors how a `.text` view's
/// frame is inset-expanded (`actualFrame` / `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)
let inset = v2TextViewClippingInset
let expandedSize = CGSize(width: self.item.frame.size.width + inset * 2.0,
height: self.item.frame.size.height + inset * 2.0)
self.shimmerView.frame = CGRect(x: -inset, y: -inset, width: expandedSize.width, height: expandedSize.height)
self.textView.frame = CGRect(origin: .zero, size: expandedSize)
self.shimmerView.update(
size: self.item.frame.size,
containerWidth: self.item.frame.size.width,
size: expandedSize,
containerWidth: expandedSize.width,
offsetX: 0.0,
gradientWidth: 200.0,
transition: .immediate

View file

@ -2004,16 +2004,22 @@ private func layoutThinking(
setupStyleStack(styleStack, theme: context.theme, attributes: dimmedAttributes)
let attributedString = attributedStringForRichText(text, styleStack: styleStack)
// Mirror a normal `.text` item's sizing: lay the text out flush (offset .zero) and put the page
// inset onto the BLOCK frame, so the `.thinking` item's frame == a `.text` item's frame
// (`(horizontalInset, 0, textWidth, height)`) instead of a full-bleed `(0, 0, boundingWidth, )`
// box. The shimmer (sized to `item.frame.size`) then hugs the text rather than the whole page
// width; the rendered text stays at the same place (`horizontalInset`) since the block carries
// the inset.
let (textItem, _, textSize) = layoutTextItem(
attributedString,
boundingWidth: boundingWidth - horizontalInset * 2.0,
offset: CGPoint(x: horizontalInset, y: 0.0),
offset: CGPoint(x: 0.0, 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)
let blockFrame = CGRect(x: horizontalInset, y: 0.0, width: textSize.width, height: textSize.height)
return [.thinking(InstantPageV2ThinkingItem(frame: blockFrame, textItem: textItem))]
}