Files
ghostgram/submodules/InstantPageUI/Sources/InstantPageV2ButtonRowLayout.swift
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:57:09 +01:00

243 lines
12 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import UIKit
import TelegramCore
import RichTextButtonIcons
/// Geometry for a block-level `pageBlockButtonRow`.
///
/// Split out of `InstantPageV2Layout`'s `.buttonRow` arm because the packing is this block's real
/// complexity and `layoutBlock` is already a ~600-line switch. The arm keeps the typography — it owns
/// the file-private style-stack helpers — and hands the measured label strings down here.
/// Fixed rather than label-derived: this is a touch target. The pill centres its label, so the inner
/// vertical inset is (height − labelBox) / 2 — raising the height by 4 adds 2pt top and bottom.
let instantPageBlockButtonHeight: CGFloat = 40.0
/// Used both between pills within a row and between rows.
let instantPageBlockButtonSpacing: CGFloat = 6.0
/// The schema's own cap.
let instantPageBlockButtonsPerRow: Int = 8
/// Per-side horizontal padding for a row pill. A LINK-styled button is chrome-less — no fill — so it
/// takes no inner padding either and its label sits flush, reading as a link rather than a pill. The
/// inline path reaches the same result differently, by rendering plain link text instead of a pill.
func instantPageBlockButtonPadding(for button: InstantPageButton) -> CGFloat {
return button.isLink ? 0.0 : instantPageBlockButtonHorizontalPadding
}
/// The padding to retry at when the label did not fit at the comfortable value. `min` rather than the
/// constant, so a link button (0) can never be given MORE room by the fallback than it started with.
private func instantPageBlockButtonMinimumPadding(for button: InstantPageButton) -> CGFloat {
return min(instantPageBlockButtonPadding(for: button), instantPageBlockButtonMinimumHorizontalPadding)
}
/// Room a badge-bearing pill must keep clear on EACH side beyond `padding`, so that a centred label
/// cannot run under the top-right type badge. `instantPageInlineButtonAttachment` already subtracts
/// `padding` per side from the cap it is handed, so only the difference is taken off on top of it —
/// making the total clearance `max(padding, reserve)`, not their sum. Buttons without a badge reserve
/// nothing.
///
/// At the comfortable padding (19) this is 0: the padding alone already exceeds the badge's reserve
/// (18). It arms only in the tight fallback, where the padding drops below the reserve and the badge
/// becomes the binding constraint.
private func instantPageBlockButtonExtraSideInset(for button: InstantPageButton, padding: CGFloat) -> CGFloat {
guard richTextButtonIconName(for: button.action) != nil else {
return 0.0
}
return max(0.0, richTextBlockButtonIconReserve - padding)
}
/// Measures one row pill against the width its frame will occupy, choosing its padding: comfortable
/// when the label fits there, tight when it does not.
///
/// **A pill's inner padding is a preference, not a constraint.** Once a label has to be cut, the
/// padding is holding room that the label needs more, so it gives it back — per button, so a row's
/// short labels are untouched. The two-try shape is deliberate over solving for the padding directly:
/// truncation is decided by the typesetter's cluster break, not by a width formula we could invert.
///
/// Returns the per-side `extraSideInset` the chosen padding implies alongside the attachment; the
/// hugging path adds it to the pill's frame width (the justified path's frame is the column, fixed).
private func instantPageBlockButtonMeasure(
button: InstantPageButton,
labelString: NSAttributedString,
pillWidth: CGFloat
) -> (attachment: InstantPageInlineButtonAttachment, extraSideInset: CGFloat) {
func measure(padding: CGFloat) -> (attachment: InstantPageInlineButtonAttachment, extraSideInset: CGFloat) {
let extra = instantPageBlockButtonExtraSideInset(for: button, padding: padding)
let attachment = instantPageInlineButtonAttachment(
button: button,
labelString: labelString,
maxWidth: max(0.0, pillWidth - extra * 2.0),
horizontalPadding: padding,
iconPlacement: .blockBadge
)
return (attachment, extra)
}
let comfortable = instantPageBlockButtonPadding(for: button)
let minimum = instantPageBlockButtonMinimumPadding(for: button)
let result = measure(padding: comfortable)
guard result.attachment.isTruncated, minimum < comfortable else {
return result
}
return measure(padding: minimum)
}
/// Where a row's content starts within the available width, given its leftover space.
///
/// One rule, applied to every mode: **a row lays out in the page's reading direction**. `align_left`
/// therefore means *leading* — the right edge on an RTL page — and `align_right` means trailing.
/// `center` is unaffected by direction, and `justify` has no slack to distribute.
private func instantPageBlockButtonRowSlackOffset(alignment: InstantPageButtonRowAlignment, slack: CGFloat, rtl: Bool) -> CGFloat {
switch alignment {
case .justify:
return 0.0
case .left:
return rtl ? slack : 0.0
case .center:
return slack / 2.0
case .right:
return rtl ? 0.0 : slack
}
}
/// Lays out one `pageBlockButtonRow`. Entries come back in **model order** (the view maps entry *i*
/// onto pill *i* and reuses pills positionally); reading direction lives in the frames.
///
/// Unlike .audio/media, which are flush at the full `boundingWidth`, a button row is chrome and
/// respects the page's horizontal inset.
func instantPageV2LayoutButtonRow(
labelledButtons: [(button: InstantPageButton, labelString: NSAttributedString)],
alignment: InstantPageButtonRowAlignment,
boundingWidth: CGFloat,
horizontalInset: CGFloat,
rtl: Bool,
metrics: InstantPageMetrics
) -> (entries: [(attachment: InstantPageInlineButtonAttachment, frame: CGRect)], totalHeight: CGFloat) {
let availableWidth = boundingWidth - horizontalInset * 2.0
guard !labelledButtons.isEmpty, availableWidth > 0.0 else {
return ([], 0.0)
}
switch alignment {
case .justify:
return instantPageV2LayoutJustifiedButtonRow(
labelledButtons: labelledButtons,
availableWidth: availableWidth,
horizontalInset: horizontalInset,
rtl: rtl,
metrics: metrics
)
case .left, .center, .right:
return instantPageV2LayoutHuggingButtonRow(
labelledButtons: labelledButtons,
alignment: alignment,
availableWidth: availableWidth,
horizontalInset: horizontalInset,
rtl: rtl,
metrics: metrics
)
}
}
/// Equal columns filling the width, wrapping at a fixed 8 per row — the behaviour every row had
/// before the alignment bits were honoured, unchanged apart from the reading-direction ordering.
private func instantPageV2LayoutJustifiedButtonRow(
labelledButtons: [(button: InstantPageButton, labelString: NSAttributedString)],
availableWidth: CGFloat,
horizontalInset: CGFloat,
rtl: Bool,
metrics: InstantPageMetrics
) -> (entries: [(attachment: InstantPageInlineButtonAttachment, frame: CGRect)], totalHeight: CGFloat) {
var entries: [(attachment: InstantPageInlineButtonAttachment, frame: CGRect)] = []
var y: CGFloat = 0.0
var index = 0
while index < labelledButtons.count {
let rowButtons = Array(labelledButtons[index ..< min(index + instantPageBlockButtonsPerRow, labelledButtons.count)])
let totalSpacing = metrics.blockButtonSpacing * CGFloat(max(0, rowButtons.count - 1))
let buttonWidth = max(0.0, (availableWidth - totalSpacing) / CGFloat(rowButtons.count))
for (position, entry) in rowButtons.enumerated() {
// Cap the label at the column it will be stretched to. The pill centres its label, so the
// clearance is kept on BOTH sides — otherwise a long label, centred, would run under the
// top-right type badge.
//
// This path used to take the reserve off the column AND let the attachment subtract the
// padding again, clearing `padding + reserve` (37pt per side) where only `max` of the two
// is occupied. In a 3-button row that phantom inset cost the label ~36pt of its ~60pt of
// ink, so short labels ellipsised for no reason.
let (attachment, _) = instantPageBlockButtonMeasure(button: entry.button, labelString: entry.labelString, pillWidth: buttonWidth)
let column = rtl ? (rowButtons.count - 1 - position) : position
let x = horizontalInset + CGFloat(column) * (buttonWidth + metrics.blockButtonSpacing)
entries.append((attachment, CGRect(x: x, y: y, width: buttonWidth, height: metrics.blockButtonHeight)))
}
y += metrics.blockButtonHeight + metrics.blockButtonSpacing
index += instantPageBlockButtonsPerRow
}
return (entries, max(0.0, y - metrics.blockButtonSpacing))
}
/// Left / centre / right: every pill hugs its label, rows fill greedily, and each row is placed at its
/// own origin (a short last row re-aligns on itself).
private func instantPageV2LayoutHuggingButtonRow(
labelledButtons: [(button: InstantPageButton, labelString: NSAttributedString)],
alignment: InstantPageButtonRowAlignment,
availableWidth: CGFloat,
horizontalInset: CGFloat,
rtl: Bool,
metrics: InstantPageMetrics
) -> (entries: [(attachment: InstantPageInlineButtonAttachment, frame: CGRect)], totalHeight: CGFloat) {
// Pass 1 — measure each pill at its natural width, capped so that even the longest label fits
// `availableWidth` on its own (the attachment builder ellipsises past the cap, having first
// retried at the tight padding). Pass 2 depends on that: a row can then never overflow, and an
// over-long single label truncates instead.
let measured: [(attachment: InstantPageInlineButtonAttachment, width: CGFloat)] = labelledButtons.map { entry in
let (attachment, extra) = instantPageBlockButtonMeasure(
button: entry.button, labelString: entry.labelString, pillWidth: availableWidth)
return (attachment, min(availableWidth, attachment.size.width + extra * 2.0))
}
// Pass 2a — greedy packing, still capped at the schema's 8 per row.
var rows: [[Int]] = []
var currentRow: [Int] = []
var currentWidth: CGFloat = 0.0
for index in 0 ..< measured.count {
let width = measured[index].width
if !currentRow.isEmpty {
let projected = currentWidth + metrics.blockButtonSpacing + width
if projected > availableWidth || currentRow.count >= instantPageBlockButtonsPerRow {
rows.append(currentRow)
currentRow = []
currentWidth = 0.0
}
}
currentWidth = currentRow.isEmpty ? width : currentWidth + metrics.blockButtonSpacing + width
currentRow.append(index)
}
if !currentRow.isEmpty {
rows.append(currentRow)
}
// Pass 2b — place each row at its own origin.
var frames = [CGRect](repeating: .zero, count: measured.count)
var y: CGFloat = 0.0
for row in rows {
let rowWidth = row.reduce(0.0) { $0 + measured[$1].width } + metrics.blockButtonSpacing * CGFloat(max(0, row.count - 1))
let slack = max(0.0, availableWidth - rowWidth)
var x = horizontalInset + instantPageBlockButtonRowSlackOffset(alignment: alignment, slack: slack, rtl: rtl)
// The first button sits at the reading start, so on an RTL page the pills run right-to-left.
let visualOrder = rtl ? Array(row.reversed()) : row
for index in visualOrder {
frames[index] = CGRect(x: x, y: y, width: measured[index].width, height: metrics.blockButtonHeight)
x += measured[index].width + metrics.blockButtonSpacing
}
y += metrics.blockButtonHeight + metrics.blockButtonSpacing
}
// Indexed rather than `enumerated().map { (offset, item) in … }`: Swift rejects destructuring a
// tuple in a closure parameter list.
let entries = (0 ..< measured.count).map { index in
return (attachment: measured[index].attachment, frame: frames[index])
}
return (entries, max(0.0, y - metrics.blockButtonSpacing))
}