diff --git a/CLAUDE.md b/CLAUDE.md index 872fbc9cee..427f6dd665 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,17 @@ The `ChatMessageDateAndStatusNode` mirrors TextBubble's placement, adapted to th - **Bubble height leaves ~6pt below the date.** One unified formula for all cases: `boundingSize.height = max(boundingSize.height, statusBottomEdge + 6.0)`, where `statusBottomEdge = statusAnchorY + max(1, statusHeight)`. The `statusAnchorY` in the measure (`continue`) closure must mirror the `statusFrameY` in the apply closure exactly, or the date will be clipped/misplaced. (`streamingHeaderOffset` is `0.0` — there is no header offset to add.) 6pt matches TextBubble's bottom bubble inset. - **`hasDraft` adds the same 6pt at the streaming site.** The status max() above is gated by `!hasDraft`, so during streaming (status hidden, alpha=0) it can't supply the bubble's bottom inset. A separate `boundingSize.height += 6.0` inside `if hasDraft` in the SizeBlock closure does it instead — same 6pt, so the streaming bubble's bottom breathing room matches its post-stream height and there's no 6pt grow-pop when the status node fades in at finalize. The `hadDraft && !hasDraft` finalize pass doesn't need it because `!hasDraft` re-enables the status max(). If you ever refactor the `+6.0` constant out of the status max() into a `bottomInset` (TextBubble's pattern), kill this separate term at the same time — they're two ends of the same invariant. +## InstantPage V2 table — flush frame, inset borders + +A V2 `.table` block's item frame is **full-width / flush** with the bubble interior (so a horizontally-scrollable wide table's scroll container bleeds edge-to-edge), but the actual grid **borders start at the body-text side inset** — matching the V1 renderer. Spec: [`docs/superpowers/specs/2026-05-30-instantpage-v2-table-inset-design.md`](docs/superpowers/specs/2026-05-30-instantpage-v2-table-inset-design.md). + +### Non-obvious invariants + +- **`InstantPageV2TableItem.contentInset` (= page `horizontalInset`) is the linchpin.** `layoutTable` (`InstantPageV2Layout.swift`) sizes columns against `contentBoundingWidth = boundingWidth − horizontalInset·2` (so a fitting table aligns with body text on both sides) and stores `contentInset` on the item; the item `frame.width` is the flush `boundingWidth`, and `contentSize.width` stays the **bare grid width** (`totalWidth`, no inset). +- **The renderer (`InstantPageV2TableView`) realizes the inset as a view shift, not baked coordinates.** In `init` AND `update` it shifts the grid `contentView` to `x: contentInset`, sets `scrollView.contentSize.width = contentSize.width + contentInset` (**left margin only**), and `scrollView.clipsToBounds = true`. Cells, border lines, the outer border, and the title stay x=0-relative inside `contentView`, so the single shift carries them all; the outer border keeps using the bare `contentSize.width`. +- **Scrollable tables clip to the full width with no inset on the clip.** The inset lives inside the scroll content as a left margin only: a fitting table (`grid + inset ≤ boundingWidth`) doesn't scroll and shows both-side inset; an overflowing table rests with its left border at the inset and scrolls its right border flush to the full-width edge (no trailing gap). +- **Manual cell-coordinate helpers MUST add `contentInset`.** Because the shift is a real `contentView` frame change, UIKit `hitTest` and `self.convert(_:to:)` paths (`propagateVisibilityRect`, the row-reveal mask) handle it automatically — but the *manual* coordinate helpers `findTextItem` / `collectSelectableTextItems` (the live tap / URL / text-selection path) compute cell/title positions arithmetically and must add `table.contentInset` to the x-offset, or in-cell hit-testing is off by the inset. (These helpers still do **not** account for the table's live horizontal `scrollView.contentOffset` — a pre-existing limitation, so in-cell hit-testing is only correct at scroll offset 0.) The dead-but-symmetric `lastTextLineFrame(in:)` table branch has the same omission but has no callers. + ## Inline custom emoji (RichText.textCustomEmoji) `RichText.textCustomEmoji(fileId:alt:)` renders an inline **animated** custom emoji inside rich-data bubbles. Covers API parsing, Postbox + FlatBuffers serialization, and display in the InstantPage V2 renderer; the emoji participates in the streaming reveal above. diff --git a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift index f869a8c3b6..be65b8cb8b 100644 --- a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift +++ b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift @@ -1942,15 +1942,20 @@ final class InstantPageV2TableView: UIView, InstantPageItemView { self.backgroundColor = .clear self.scrollView.frame = self.bounds - self.scrollView.contentSize = item.contentSize + // Scrollable tables clip to the full width with no inset on the clip; the inset lives inside + // the scroll content width as a left margin only (see contentSize below). + self.scrollView.clipsToBounds = true + self.scrollView.contentSize = CGSize(width: item.contentSize.width + item.contentInset, height: item.contentSize.height) self.scrollView.alwaysBounceHorizontal = false self.scrollView.alwaysBounceVertical = false - self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width > item.frame.width + self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width + item.contentInset > item.frame.width self.scrollView.showsVerticalScrollIndicator = false self.scrollView.disablesInteractiveTransitionGestureRecognizer = true self.addSubview(self.scrollView) - self.contentView.frame = CGRect(origin: .zero, size: item.contentSize) + // Grid container shifted right by the inset so the borders start at the body-text inset. + // Its size stays the bare grid (item.contentSize); grid coords inside remain x=0-relative. + self.contentView.frame = CGRect(x: item.contentInset, y: 0.0, width: item.contentSize.width, height: item.contentSize.height) self.scrollView.addSubview(self.contentView) // Title sub-layout (above the grid, inside the scroll view's content). @@ -2025,9 +2030,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, height: item.contentSize.height) + self.scrollView.showsHorizontalScrollIndicator = item.contentSize.width + item.contentInset > 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 @@ -2145,7 +2150,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, @@ -2155,7 +2160,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)) { @@ -2208,7 +2213,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) @@ -2216,7 +2221,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) diff --git a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift index c07ac62819..5b8c00a413 100644 --- a/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift +++ b/submodules/InstantPageUI/Sources/InstantPageV2Layout.swift @@ -320,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 // left inset (= page horizontalInset) baked into the scroll content width by the renderer public let cells: [InstantPageV2TableCell] public let horizontalLines: [CGRect] public let verticalLines: [CGRect] @@ -1067,8 +1068,12 @@ private func layoutTable( setupStyleStack(styleStack, theme: context.theme, category: .paragraph, 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 @@ -1153,7 +1158,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] = [:] @@ -1224,7 +1229,7 @@ private func layoutTable( distributedWidth -= growth finalColumnWidths[i] = width } - totalWidth = boundingWidth + totalWidth = contentBoundingWidth } else { totalWidth += borderWidth } @@ -1530,10 +1535,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 left inset on top. 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, @@ -1545,6 +1551,7 @@ private func layoutTable( titleSubLayout: titleSubLayout, titleFrame: titleFrame, contentSize: contentSize, + contentInset: horizontalInset, cells: finalizedCells, horizontalLines: horizontalLines, verticalLines: verticalLines,