mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
InstantPage V2 table: rounded corners and corner-cell fill rounding
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
79bcc7bc34
commit
3d9fbdec3c
3 changed files with 60 additions and 27 deletions
|
|
@ -96,16 +96,18 @@ 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
|
||||
## InstantPage V2 table — flush frame, inset borders, rounded corners
|
||||
|
||||
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).
|
||||
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. The grid card also has a **10pt rounded outer border**. Specs: [`…-table-inset-design.md`](docs/superpowers/specs/2026-05-30-instantpage-v2-table-inset-design.md), [`…-table-corner-radius-design.md`](docs/superpowers/specs/2026-05-30-instantpage-v2-table-corner-radius-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`.
|
||||
- **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, inner border lines, and the title stay x=0-relative inside `contentView`, so the single shift carries them all; the rounded outer border is `contentView.layer`'s own border (see below), which wraps the shifted layer automatically.
|
||||
- **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.
|
||||
- **The 10pt rounded outer border is `contentView.layer`'s own border, NOT sublayers.** `v2TableCornerRadius = 10.0` (`InstantPageV2Layout.swift`). The renderer sets `contentView.layer.cornerRadius`/`borderColor`/`borderWidth = bordered ? v2TableBorderWidth : 0.0` in BOTH `init` and `update` (the four straight outer-edge rect layers were removed; `lineLayers` now holds only inner grid lines). **Border-only — deliberately no `masksToBounds`:** `cornerRadius` rounds the layer's border without clipping contents (filled corner cells round their own fills separately — see next bullet), and there is **zero interaction with the streaming reveal mask** (`contentView.layer.mask`, set only during AI streaming) — the border reveals row-by-row with the rows and is part of the masked layer. The rounded card belongs to the grid (scrolls with it). For a non-empty-title table (never produced by markdown/AI), the border wraps title+grid since `contentView` includes the title region — an accepted, approved nuance.
|
||||
- **Filled corner cells round their own fills to match the border.** A header/striped cell's background is a stripe `CALayer`; `tableStripeCornerMask(cellFrame:gridWidth:gridHeight:effectiveBorderWidth:)` detects which grid corners the cell's (grid-local) frame touches — `firstCol/firstRow` via `frame.min{X,Y} <= effectiveBorderWidth/2 + 0.5`, `lastCol/lastRow` via `frame.max{X,Y} >= grid{Width,Height} - …` (gridWidth = `item.contentSize.width`, gridHeight = `item.contentSize.height - gridOffsetY`) — and rounds only those corners: `stripe.cornerRadius = max(0, v2TableCornerRadius - effectiveBorderWidth)` (the `-borderWidth` leaves an even border ring; borderless → full radius) + `stripe.maskedCorners`, in BOTH `init` and `update`. A `CALayer`'s `backgroundColor` honors `cornerRadius`+`maskedCorners` with no `masksToBounds`. A full-width (colspan) header rounds both top corners; a one-row filled table rounds all four; bottom corners round only when the last row is filled. The empty-mask branch resets `cornerRadius = 0` **and** `maskedCorners = []` so reused stripes (persist across streaming chunks) don't keep stale rounding. Detection is grid-local, so it's independent of the `contentInset` shift / horizontal scroll.
|
||||
|
||||
## Inline custom emoji (RichText.textCustomEmoji)
|
||||
|
||||
|
|
|
|||
|
|
@ -1916,6 +1916,22 @@ final class InstantPageV2ThinkingView: UIView, InstantPageItemView {
|
|||
|
||||
// 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 }
|
||||
|
|
@ -1970,6 +1986,8 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
|
|||
|
||||
// Grid origin: shifted down by title height when present.
|
||||
let gridOffsetY = item.titleFrame?.height ?? 0.0
|
||||
let effectiveBorderWidth = item.bordered ? v2TableBorderWidth : 0.0
|
||||
let gridHeight = item.contentSize.height - gridOffsetY
|
||||
|
||||
// Cell backgrounds and sub-layouts.
|
||||
for cell in item.cells {
|
||||
|
|
@ -1977,6 +1995,16 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
|
|||
let stripe = CALayer()
|
||||
stripe.backgroundColor = bg.cgColor
|
||||
stripe.frame = cell.frame.offsetBy(dx: 0.0, dy: gridOffsetY)
|
||||
// Round the stripe at any grid corner it touches, so a filled corner cell
|
||||
// follows the rounded outer border instead of poking past it.
|
||||
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
|
||||
}
|
||||
self.contentView.layer.insertSublayer(stripe, at: 0)
|
||||
self.stripeLayers.append(stripe)
|
||||
}
|
||||
|
|
@ -1990,7 +2018,14 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
|
|||
}
|
||||
}
|
||||
|
||||
// Border lines.
|
||||
// Rounded outer border. `cornerRadius` rounds the layer's border WITHOUT `masksToBounds`,
|
||||
// so table contents (cell fills, text) stay unclipped — only the border visual is rounded.
|
||||
// Replaces the four straight outer-edge layers the old code appended to `lineLayers`.
|
||||
self.contentView.layer.cornerRadius = v2TableCornerRadius
|
||||
self.contentView.layer.borderColor = item.borderColor.cgColor
|
||||
self.contentView.layer.borderWidth = item.bordered ? v2TableBorderWidth : 0.0
|
||||
|
||||
// Inner grid lines (between cells).
|
||||
if item.bordered {
|
||||
for r in item.horizontalLines + item.verticalLines {
|
||||
let line = CALayer()
|
||||
|
|
@ -1999,27 +2034,6 @@ final class InstantPageV2TableView: UIView, InstantPageItemView {
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2058,21 +2072,37 @@ 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.
|
||||
// Inner line layers — refresh color in place. (`lineLayers` now holds only inner grid
|
||||
// lines; the outer border is the contentView layer's own rounded border, refreshed below.)
|
||||
for line in self.lineLayers {
|
||||
line.backgroundColor = item.borderColor.cgColor
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1049,6 +1049,7 @@ private struct V2TableRow {
|
|||
|
||||
let v2TableCellInsets = UIEdgeInsets(top: 14.0, left: 12.0, bottom: 14.0, right: 12.0)
|
||||
let v2TableBorderWidth: CGFloat = 1.0
|
||||
let v2TableCornerRadius: CGFloat = 10.0
|
||||
|
||||
private func layoutTable(
|
||||
title: RichText,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue