mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 14:17:19 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
243 lines
12 KiB
Swift
243 lines
12 KiB
Swift
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))
|
||
}
|