InstantPage V2: proper RTL layout for rich messages

When a rich message's page-level rtl flag is set, mirror every V2 block
ornament and content column onto the trailing (right) edge, gated solely on
the explicit flag (no content auto-detection, no send/wire change, V2 only):

- Two centralized leading/trailing geometry helpers (instantPageV2ContentColumnX
  / instantPageV2LeadingEdgeX) as the single source of truth for gutter side.
- Lists: content column mirrors to match the marker (8pt trailing gap).
- Blockquotes: bar + text gutter on the trailing edge, single- and multi-block
  (multi-block child band rigid-translated; caption uses its own single-inset
  delta).
- <details>: chevron + title indented from the right (rtl flag on the item).
- Uniform trailing alignment for paragraph/heading/simple-text.
- Keep the full bounding width for non-leading alignment so display-time
  alignment lands at the true edge: .right (RTL text) was collapsing short
  content to the leading edge under fitToWidth (the list-gap), and .center
  (pull quotes) was failing to center body + author. Only .natural shrinks.

LTR output is unchanged (.right/.center are only produced under RTL contexts /
pull quotes). Builds clean; runtime-verified on an rtl message with lists,
blockquotes, pull quotes, and inline formatting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-06-04 15:01:59 +02:00
parent a731f8bdc0
commit 9dada484aa
2 changed files with 54 additions and 13 deletions

View file

@ -1779,7 +1779,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView {
let chevronSize = CGSize(width: 18.0, height: 18.0)
self.chevronView.bounds = CGRect(origin: .zero, size: chevronSize)
self.chevronView.center = CGPoint(
x: item.sideInset + chevronSize.width / 2.0,
x: item.rtl ? (item.frame.width - item.sideInset - chevronSize.width / 2.0) : (item.sideInset + chevronSize.width / 2.0),
y: item.titleFrame.midY + 1.0
)

View file

@ -338,6 +338,7 @@ public struct InstantPageV2DetailsItem {
public let isExpanded: Bool
public let innerLayout: InstantPageV2Layout?
public let defaultExpanded: Bool // from the InstantPageBlock model
public let rtl: Bool // mirror chevron + title onto the trailing edge
}
public enum InstantPageV2TableVerticalAlignment {
@ -1103,7 +1104,9 @@ private func layoutDetails(
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.x = context.rtl
? (boundingWidth - horizontalInset - 23.0 - titleTextItem.frame.width)
: (horizontalInset + 23.0)
titleTextItem.frame.origin.y = floorToScreenPixels((titleHeight - titleTextItem.frame.height) * 0.5)
let isExpanded = context.expandedDetails[index] ?? defaultExpanded
@ -1134,7 +1137,8 @@ private func layoutDetails(
separatorColor: context.theme.separatorColor,
isExpanded: isExpanded,
innerLayout: innerLayout,
defaultExpanded: defaultExpanded
defaultExpanded: defaultExpanded,
rtl: context.rtl
)
return [.details(item)]
}
@ -2038,6 +2042,7 @@ private func layoutSimpleText(
let (_, items, _) = layoutTextItem(
attributedString,
boundingWidth: boundingWidth - horizontalInset * 2.0,
alignment: context.rtl ? .right : .natural,
offset: CGPoint(x: horizontalInset, y: 0.0),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
@ -2058,6 +2063,7 @@ private func layoutHeading(
let (_, items, _) = layoutTextItem(
attributedString,
boundingWidth: boundingWidth - horizontalInset * 2.0,
alignment: context.rtl ? .right : .natural,
offset: CGPoint(x: horizontalInset, y: 0.0),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
@ -2083,6 +2089,7 @@ private func layoutParagraph(
let (_, items, _) = layoutTextItem(
attributedString,
boundingWidth: boundingWidth - horizontalInset * 2.0,
alignment: context.rtl ? .right : .natural,
offset: CGPoint(x: horizontalInset, y: 0.0),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
@ -2306,6 +2313,10 @@ private func layoutBlockQuote(
let innerBoundingWidth = boundingWidth - horizontalInset * 2.0 - lineInset
let innerHorizontalInset = horizontalInset + lineInset
// RTL: rigid-translate the child band so its gutter (lineInset) lands on the trailing edge,
// faithfully mirroring the existing (intentionally preserved) LTR band. Width is preserved,
// so a single x-delta moves the whole band correctly.
let bandOffsetX: CGFloat = context.rtl ? (2.0 * horizontalInset + lineInset) : 0.0
var result: [InstantPageV2LaidOutItem] = []
var contentHeight: CGFloat = verticalInset
@ -2327,7 +2338,7 @@ private func layoutBlockQuote(
context: &context
)
let dy = contentHeight + spacing
let offsetItems = childItems.map { $0.offsetBy(CGPoint(x: 0.0, y: dy)) }
let offsetItems = childItems.map { $0.offsetBy(CGPoint(x: bandOffsetX, y: dy)) }
let childMaxY = offsetItems.map { $0.frame.maxY }.max() ?? dy
contentHeight = max(contentHeight, childMaxY)
result.append(contentsOf: offsetItems)
@ -2344,8 +2355,11 @@ private func layoutBlockQuote(
let (_, captionItems, captionSize) = layoutTextItem(
attributedCaption,
boundingWidth: innerBoundingWidth,
alignment: .natural,
offset: CGPoint(x: innerHorizontalInset, y: contentHeight),
alignment: context.rtl ? .right : .natural,
// The caption is single-inset (band [H+lineInset, B-H]), unlike the double-inset
// child band, so it needs its own RTL mirror delta of -lineInset ( [H, B-H-lineInset],
// tucked under the trailing bar) NOT the children's bandOffsetX.
offset: CGPoint(x: innerHorizontalInset + (context.rtl ? -lineInset : 0.0), y: contentHeight),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
)
@ -2357,7 +2371,7 @@ private func layoutBlockQuote(
// Vertical bar on the leading edge (matches the blockQuote branch of layoutQuoteText).
let bar = InstantPageV2BarItem(
frame: CGRect(x: horizontalInset, y: 0.0, width: barWidth, height: contentHeight),
frame: CGRect(x: instantPageV2LeadingEdgeX(boundingWidth: boundingWidth, horizontalInset: horizontalInset, elementWidth: barWidth, rtl: context.rtl), y: 0.0, width: barWidth, height: contentHeight),
color: context.theme.textCategories.paragraph.color,
cornerRadius: barWidth / 2.0
)
@ -2408,8 +2422,8 @@ private func layoutQuoteText(
// Body text (V1 line 528 / 562).
let textBoundingWidth = boundingWidth - horizontalInset * 2.0 - lineInset
let textX: CGFloat = horizontalInset + lineInset
let textAlignment: NSTextAlignment = isPull ? .center : .natural
let textX: CGFloat = instantPageV2ContentColumnX(horizontalInset: horizontalInset, gutter: lineInset, rtl: context.rtl)
let textAlignment: NSTextAlignment = isPull ? .center : (context.rtl ? .right : .natural)
let attributedBody = attributedStringForRichText(text, styleStack: styleStack, formatDate: context.formatDate)
let (_, bodyItems, bodySize) = layoutTextItem(
@ -2463,7 +2477,7 @@ private func layoutQuoteText(
// V1 shape: .roundLine (rounded caps) cornerRadius = barWidth / 2 = 1.5.
let barWidth: CGFloat = 3.0 // V1 line 547
let bar = InstantPageV2BarItem(
frame: CGRect(x: horizontalInset, y: 0.0, width: barWidth, height: contentHeight),
frame: CGRect(x: instantPageV2LeadingEdgeX(boundingWidth: boundingWidth, horizontalInset: horizontalInset, elementWidth: barWidth, rtl: context.rtl), y: 0.0, width: barWidth, height: contentHeight),
color: context.theme.textCategories.paragraph.color, // V1 line 547
cornerRadius: barWidth / 2.0 // V1 .roundLine half-width rounded caps
)
@ -2612,11 +2626,12 @@ private func layoutList(
let styleStack = InstantPageTextStyleStack()
setupStyleStack(styleStack, theme: context.theme, category: .paragraph, link: false)
let attrStr = attributedStringForRichText(text, styleStack: styleStack, formatDate: context.formatDate)
let textX = horizontalInset + indexSpacing + maxIndexWidth
let textX = instantPageV2ContentColumnX(horizontalInset: horizontalInset, gutter: indexSpacing + maxIndexWidth, rtl: context.rtl)
let textWidth = boundingWidth - horizontalInset * 2.0 - indexSpacing - maxIndexWidth
let (textItem, textLaidOutItems, textSize) = layoutTextItem(
attrStr,
boundingWidth: textWidth,
alignment: context.rtl ? .right : .natural,
offset: CGPoint(x: textX, y: contentHeight),
fitToWidth: context.fitToWidth,
computeRevealCharacterRects: context.computeRevealCharacterRects
@ -2672,7 +2687,7 @@ private func layoutList(
)
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, kind: .list) : 0.0
let offsetX = horizontalInset + indexSpacing + maxIndexWidth
let offsetX = instantPageV2ContentColumnX(horizontalInset: horizontalInset, gutter: indexSpacing + maxIndexWidth, rtl: context.rtl)
let offsetY = contentHeight + spacing
let translatedItems = subItems.map { $0.offsetBy(CGPoint(x: offsetX, y: offsetY)) }
@ -2779,6 +2794,26 @@ private func markerFrameFor(
return CGRect(x: x, y: floorToScreenPixels(lineMidY - size.height / 2.0), width: size.width, height: size.height)
}
/// Leading/trailing geometry helpers the single source of truth for "which side is the
/// block gutter on", gated on the page's explicit `rtl` flag. The `rtl == false` branch returns
/// the pre-existing literal so non-RTL pages are byte-identical.
/// X origin of a block's content column, given a leading gutter of width `gutter`
/// (the marker column, or the quote bar+inset band). Column width is unchanged either way.
/// LTR: content sits after the gutter horizontalInset + gutter
/// RTL: content sits at the inset; the gutter is mirrored onto the trailing edge horizontalInset
func instantPageV2ContentColumnX(horizontalInset: CGFloat, gutter: CGFloat, rtl: Bool) -> CGFloat {
return rtl ? horizontalInset : horizontalInset + gutter
}
/// X origin of a leading-edge element of width `elementWidth` (e.g. the quote bar), hugging the
/// trailing edge of the gutter band in RTL.
/// LTR: horizontalInset
/// RTL: boundingWidth - horizontalInset - elementWidth
func instantPageV2LeadingEdgeX(boundingWidth: CGFloat, horizontalInset: CGFloat, elementWidth: CGFloat, rtl: Bool) -> CGFloat {
return rtl ? (boundingWidth - horizontalInset - elementWidth) : horizontalInset
}
// MARK: - Style helpers (ported from V1 InstantPageLayout.swift lines 3288)
private func setupStyleStack(_ stack: InstantPageTextStyleStack, theme: InstantPageTheme, attributes: InstantPageTextAttributes) {
@ -3378,7 +3413,13 @@ func layoutTextItem(
}
var textWidth = boundingWidth
if fitToWidth {
// Shrinking the box to content width anchors it at the leading `offset.x`, which makes any
// non-leading display-time alignment a no-op (the block stays pinned to the leading edge and
// only redistributes internally). Only `.natural` is leading-anchored; `.right` (RTL text)
// and `.center` (pull quotes) must keep the full bounding width so `v2FrameForLine` lands
// each line at the true trailing / centered position. `.right`/`.center` reach here only via
// RTL text and pull quotes respectively, so plain LTR `.natural` body text is unaffected.
if fitToWidth && alignment == .natural {
textWidth = maxLineWidth
}
if (!imageItems.isEmpty || hasFormulaItems) && maxLineWidth > boundingWidth + 10.0 {