Skip to content

Commit 0a92dbd

Browse files
isaacclaude
andcommitted
InstantPage V2: fix inline emoji/image/formula x-offset on RTL lines
Inline attachments anchored their left edge at CTLineGetOffsetForStringIndex(line, range.location), which is the glyph's LEFT edge for LTR runs but its RIGHT edge for RTL runs (string index increases leftward). On an RTL line (e.g. an Arabic thinking block) this shoved emoji/images/formulas ~one advance (~24pt) too far right while the CoreText-drawn text stayed correct. Add v2LeadingOffsetForRange(_:range:), which returns min(offset(start), offset(end)) with directional-boundary secondary-offset handling — the true leading edge in both directions. Mirrors Display.TextNode.addEmbeddedItem and the strikethrough/underline/spoiler decorations already in this file (which used the min/abs form; the inline attachments had regressed to a single offset). Applied at all 5 sites: the emoji/image/formula display frames and the emoji/image characterRect (reveal mask). Widths unchanged; only x corrected. LTR is byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 52db806 commit 0a92dbd

2 files changed

Lines changed: 33 additions & 5 deletions

File tree

‎docs/instantpage-richtext.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ Specs: [`2026-06-02-instantpage-v2-audio-design.md`](docs/superpowers/specs/2026
164164
- **`ChatTextInputTextCustomEmojiAttribute` is reused end-to-end** (display layer ⇄ layout model). The attribute is written to the placeholder in `attributedStringForRichText` and read back by the V2 line-breaker under the SAME key (`ChatTextInputAttributes.customEmoji`); `InlineStickerItemLayer.init` consumes it directly and resolves the file lazily from `fileId`.
165165
- **Emoji participates in the streaming reveal.** Its placeholder char's `characterRect` is overwritten to a full cell (width = `itemSize`), so the width-based cost map charges it like other content. `updateEmojiReveal` pops the layer in (alpha 0→1 + scale) when `charIndexInItem < currentRevealCharacterCount`; unrevealed → opacity 0.
166166
- **Inline emoji/images are CENTERED on the font line box, NOT baseline-aligned, and do NOT inflate the line.** The line-breaker keeps `lineAscent = fontLineHeight` (only formulas grow it) and places each attachment at `baselineY − fontLineHeight/2 − size/2`, so it bleeds symmetrically about the line box instead of doubling the line height and shoving the text baseline down (the prior `lineAscent = emoji.size` behavior was a regression from V1 `layoutTextItemWithString`, which centers via `(fontLineHeight − imageHeight)/2`). Custom emoji are sized to ≈ the line box (`size = font.ascender − font.descender + 4·pointSize/17`) so they fit the true-font-height item box (see "InstantPage V2 text item height") with minimal bleed. Mirrors the chat `InteractiveTextComponent`. The cell's `characterRect` is centered the same way (`y = fontLineHeight/2 − size/2`) so the reveal mask (`renderer: y = minY + lineAscent − rect.maxY`) tracks it; a tall attachment grows `extraDescent` so the next line isn't overlapped. Three things must stay in lockstep: the display frame, the `characterRect`, and `extraDescent`.
167+
- **Inline-attachment x must be the LEADING edge, computed RTL-safely via `v2LeadingOffsetForRange` (`InstantPageV2Layout.swift`).** An attachment's left edge is `min(CTLineGetOffsetForStringIndex(start), CTLineGetOffsetForStringIndex(end))` — NOT the bare start-index offset. `CTLineGetOffsetForStringIndex` at the start index returns the glyph's LEFT edge in LTR but its RIGHT edge in RTL (string index increases leftward), so the old single-offset form (`…, range.location, nil`) shoved emoji/images/formulas ~one advance (≈ the attachment width) too far right on RTL lines — e.g. an emoji in an Arabic thinking-block line, while the CoreText-drawn text stayed correct. The helper mirrors `Display.TextNode`'s `addEmbeddedItem` (incl. directional-boundary secondary-offset handling) and the strikethrough/underline/marked/spoiler decorations in this same file, which already used the `min`/`abs` form. For pure-LTR lines it returns exactly the start-index offset, so LTR is byte-identical. Applies to all 5 attachment sites: the emoji/image/formula display frames AND the emoji/image `characterRect` (reveal mask). The widths stay the fixed `size`/`rendered.size` values (the run-delegate advance), only the x is corrected.
167168
- **Layers sit ABOVE the reveal mask.** They attach to `InstantPageV2TextView.emojiContainerView` (a sibling above `renderContainer`), NOT inside it — so the reveal mask wipes glyphs while emoji pop in independently. Adding a CTRunDelegate-glyph to the mask would clip-wipe them instead.
168169
- **Layers are owned by `InstantPageV2View`, not the text view.** Keyed by `InlineStickerItemLayer.Key(id: fileId, index: occurrence)`. The pageView is now REUSED across `stableVersion` bumps (see streaming section), so the inline-emoji dict PERSISTS across chunks; `updateInlineEmoji` prunes stale keys (emoji whose blocks have been removed) and creates/repositions layers for new or unchanged emoji each update pass.
169170
- **`visibilityRect` gates looping; `nil` means "not visible".** The bubble's `visibility` override pushes a full-width sub-rect to the root `pageView.visibilityRect`, re-pushed in the apply closure after `pageView.frame` is set. `propagateVisibilityRect` converts the rect into each nested V2View's coordinate space (`self.convert(_:to:)`) for details bodies / table cells+title, fanning out via each child's `didSet`.

‎submodules/InstantPageUI/Sources/InstantPageV2Layout.swift‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2966,6 +2966,33 @@ func v2FrameForLine(_ line: InstantPageTextLine, boundingWidth: CGFloat, alignme
29662966
return lineFrame
29672967
}
29682968

2969+
// Returns the leading-edge x offset (line-origin-relative) for an inline-attachment's string
2970+
// `range`, correct for both LTR and RTL runs. `CTLineGetOffsetForStringIndex` at the start index
2971+
// gives the glyph's LEFT edge in LTR text, but its RIGHT edge in RTL text (increasing string index
2972+
// moves leftward) — so using the start-index offset alone as the left edge shoves an RTL attachment
2973+
// ~one advance too far right. Taking the min of the start- and end-index offsets yields the true
2974+
// leading (left) edge in both directions. Mirrors `Display.TextNode`'s `addEmbeddedItem`, including
2975+
// the directional-boundary secondary-offset handling. For a pure-LTR line this returns exactly the
2976+
// start-index offset (primary == secondary, and start-offset < end-offset), so LTR layout is
2977+
// byte-identical to the previous single-offset behavior.
2978+
private func v2LeadingOffsetForRange(_ line: CTLine, range: NSRange) -> CGFloat {
2979+
var secondaryStartOffset: CGFloat = 0.0
2980+
let rawStartOffset = CTLineGetOffsetForStringIndex(line, range.location, &secondaryStartOffset)
2981+
var startOffset = rawStartOffset
2982+
if !rawStartOffset.isEqual(to: secondaryStartOffset) {
2983+
startOffset = secondaryStartOffset
2984+
}
2985+
2986+
var secondaryEndOffset: CGFloat = 0.0
2987+
let rawEndOffset = CTLineGetOffsetForStringIndex(line, range.location + range.length, &secondaryEndOffset)
2988+
var endOffset = rawEndOffset
2989+
if !rawEndOffset.isEqual(to: secondaryEndOffset) {
2990+
endOffset = secondaryEndOffset
2991+
}
2992+
2993+
return min(startOffset, endOffset)
2994+
}
2995+
29692996
private func v2LocalAttachmentBoundsForRange(_ range: NSRange, imageItems: [InstantPageTextImageItem], formulaItems: [InstantPageTextFormulaRun]) -> CGRect? {
29702997
var result: CGRect?
29712998

@@ -3144,14 +3171,14 @@ func layoutTextItem(
31443171
string.enumerateAttributes(in: runRange, options: []) { attributes, range, _ in
31453172
if let id = attributes[NSAttributedString.Key.init(rawValue: InstantPageMediaIdAttribute)] as? Int64, let dimensions = attributes[NSAttributedString.Key.init(rawValue: InstantPageMediaDimensionsAttribute)] as? PixelDimensions {
31463173
let imageSize = dimensions.cgSize.fitted(CGSize(width: boundingWidth, height: boundingWidth))
3147-
let xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil)
3174+
let xOffset = v2LeadingOffsetForRange(line, range: range)
31483175
pendingImages.append(PendingV2ImageAttachment(xOffset: xOffset, range: range, id: id, size: imageSize))
31493176
} else if let attachment = attributes[NSAttributedString.Key(rawValue: InstantPageFormulaAttribute)] as? InstantPageMathAttachment {
3150-
let xOffset = CTLineGetOffsetForStringIndex(line, range.location, nil)
3177+
let xOffset = v2LeadingOffsetForRange(line, range: range)
31513178
let baselineOffset = (attributes[NSAttributedString.Key.baselineOffset] as? CGFloat) ?? 0.0
31523179
pendingFormulas.append(PendingV2FormulaAttachment(xOffset: xOffset, range: range, attachment: attachment, baselineOffset: baselineOffset))
31533180
} else if let emoji = attributes[ChatTextInputAttributes.customEmoji] as? ChatTextInputTextCustomEmojiAttribute {
3154-
let xOffset = CTLineGetOffsetForStringIndex(line, range.location, nil)
3181+
let xOffset = v2LeadingOffsetForRange(line, range: range)
31553182
let font = (attributes[NSAttributedString.Key.font] as? UIFont) ?? UIFont.systemFont(ofSize: 17.0)
31563183
// Size the inline emoji to the font's line height (A + D = the true
31573184
// line-box height) plus a 4pt bump at the 17pt body font (scaled
@@ -3363,7 +3390,7 @@ func layoutTextItem(
33633390
for emoji in pendingEmoji {
33643391
let localIndex = emoji.range.location - lineRange.location
33653392
if localIndex >= 0 && localIndex < rects.count {
3366-
let x = CTLineGetOffsetForStringIndex(line, emoji.range.location, nil)
3393+
let x = v2LeadingOffsetForRange(line, range: emoji.range)
33673394
// characterRects are baseline-relative (positive-up). The emoji cell is now
33683395
// centered on the font line box (see frame loop), so in baseline-relative
33693396
// coords it spans [fontLineHeight/2 − size/2, fontLineHeight/2 + size/2].
@@ -3375,7 +3402,7 @@ func layoutTextItem(
33753402
for image in pendingImages {
33763403
let localIndex = image.range.location - lineRange.location
33773404
if localIndex >= 0 && localIndex < rects.count {
3378-
let x = CTLineGetOffsetForStringIndex(line, image.range.location, nil)
3405+
let x = v2LeadingOffsetForRange(line, range: image.range)
33793406
// Image cell is centered on the font line box (see frame loop). Baseline-relative
33803407
// cell spans [fontLineHeight/2 − height/2, fontLineHeight/2 + height/2]; the full
33813408
// width feeds the reveal cost map so the streaming cursor is charged the image's

0 commit comments

Comments
 (0)