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>
469 lines
26 KiB
Swift
469 lines
26 KiB
Swift
import Foundation
|
||
import UIKit
|
||
import CoreText
|
||
import TelegramCore
|
||
import TextFormat
|
||
import RichTextButtonIcons
|
||
|
||
/// A measured inline button. Mirrors `InstantPageMathAttachment` (`InstantPageMath.swift:18`): the
|
||
/// attribute payload carries both the model and the metrics, because the V2 line-breaker raises the
|
||
/// line's ascent/descent from the attachment itself and has no `styleStack` with which to re-measure.
|
||
public final class InstantPageInlineButtonAttachment: NSObject {
|
||
public let button: InstantPageButton
|
||
/// The label, already laid out with the surrounding style stack.
|
||
public let labelString: NSAttributedString
|
||
/// Full pill size — the label's ink box inflated by the padding below.
|
||
public let size: CGSize
|
||
public let ascent: CGFloat
|
||
public let descent: CGFloat
|
||
/// The per-side horizontal padding `size` was inflated by — inline pills and block-row pills use
|
||
/// different values. LOAD-BEARING: `instantPageButtonLabelOrigin` recovers the label's ink width as
|
||
/// `size.width − 2 × padding`, so reading a global here instead of the value that actually built
|
||
/// `size` would silently mis-centre the label (and the emoji squares derived from it).
|
||
public let horizontalPadding: CGFloat
|
||
/// True when `maxWidth` forced an ellipsis — i.e. the label did NOT fit at this padding. The row
|
||
/// layout re-measures such a pill at `instantPageBlockButtonMinimumHorizontalPadding` to win the
|
||
/// difference back as label room; see `instantPageBlockButtonMeasure`.
|
||
public let isTruncated: Bool
|
||
/// Trailing width inside `size` held for an inline type icon — `richTextInlineButtonIconReserve`
|
||
/// when the action has one, 0 otherwise (and always 0 for a block pill, whose badge is a corner
|
||
/// overlay rather than a width). LOAD-BEARING for the same reason as `horizontalPadding`: the
|
||
/// label's ink width is recovered as `size.width − 2 × padding − iconReserve`.
|
||
public let iconReserve: CGFloat
|
||
|
||
public init(button: InstantPageButton, labelString: NSAttributedString, size: CGSize, ascent: CGFloat, descent: CGFloat, horizontalPadding: CGFloat, isTruncated: Bool = false, iconReserve: CGFloat = 0.0) {
|
||
self.button = button
|
||
self.labelString = labelString
|
||
self.size = size
|
||
self.ascent = ascent
|
||
self.descent = descent
|
||
self.horizontalPadding = horizontalPadding
|
||
self.isTruncated = isTruncated
|
||
self.iconReserve = iconReserve
|
||
}
|
||
}
|
||
|
||
/// Where a pill draws its type icon, which differs by pill kind and is fixed at measurement time.
|
||
public enum InstantPageButtonIconPlacement {
|
||
/// Trailing the label, on the same optical line, with the pill widened to hold it. The only shape
|
||
/// that fits an inline pill: it is the label's ink box plus 2pt, far too short for a corner badge.
|
||
case inlineTrailing
|
||
/// A badge in the pill's top-right corner, overlaying the fill. A block-row pill is 40pt tall and
|
||
/// has the room; its clearance comes from the row layout's side inset, not from the pill's width.
|
||
case blockBadge
|
||
}
|
||
|
||
/// Padding between the label's ink and the pill edge. Starting values — tune at the visual pass.
|
||
/// For scale, `TextRenderView`'s `markedItems` highlight uses ±2pt horizontal with a 2.2x height
|
||
/// inflation; a button wants noticeably more horizontal room than a highlight.
|
||
public let instantPageInlineButtonHorizontalPadding: CGFloat = 7.0
|
||
public let instantPageInlineButtonVerticalPadding: CGFloat = 1.0
|
||
|
||
/// A block-row pill is a standalone touch target rather than a word inside a line, so it carries
|
||
/// noticeably more horizontal room than an inline `textButton`.
|
||
public let instantPageBlockButtonHorizontalPadding: CGFloat = 19.0
|
||
|
||
/// The padding a row pill falls back to when its label does NOT fit at the comfortable value above.
|
||
/// Comfort is worth less than legibility: an ellipsis loses words, whereas a tighter pill only looks
|
||
/// tighter. Applied per button, so a row's short labels keep the full padding.
|
||
///
|
||
/// Bounded below by the capsule rather than by taste: a 40pt pill has a 20pt corner radius, whose arc
|
||
/// intrudes ~2.6pt at the top and bottom of the label's box, so this leaves ~3.4pt of visible margin
|
||
/// at the label's tightest corner. Going much lower would have the label touch the arc before it
|
||
/// touches the nominal edge.
|
||
public let instantPageBlockButtonMinimumHorizontalPadding: CGFloat = 6.0
|
||
|
||
/// Extra gap between two *directly adjacent* pills — two `textButton`s with no rich text between
|
||
/// them. Without it they touch: each pill's width lives entirely on its placeholder's CTRunDelegate,
|
||
/// which reports exactly the pill width, so consecutive placeholders leave no advance between the
|
||
/// two fills. Only adjacency is padded; a pill next to ordinary text already gets that text's own
|
||
/// spaces.
|
||
public let instantPageInlineButtonAdjacentSpacing: CGFloat = 3.0
|
||
|
||
/// Button labels carry their own typography rather than inheriting the paragraph's — semibold in both
|
||
/// cases, one point smaller inline than in a block row. Fixed sizes, so they do not scale with the
|
||
/// Instant View font-size setting nor with the chat's Text Size (`contentScale`): a pill is a control
|
||
/// with its own typography, and a wider pill would move line breaks the editor has to mirror.
|
||
public let instantPageInlineButtonFontSize: CGFloat = 15.0
|
||
public let instantPageBlockButtonFontSize: CGFloat = 16.0
|
||
|
||
/// The side of an emoji square inside a button label: the font's own line box, `A − D`
|
||
/// (≈20.1pt at 17pt, against a body emoji's ≈24.3pt).
|
||
///
|
||
/// Deliberately NOT the body-text emoji size (`A − D + 4·pointSize/17`, `InstantPageTextItem.swift`).
|
||
/// It has to be smaller for two independent reasons, and both land on this same value:
|
||
///
|
||
/// - **A pill** is `clipsToBounds = true` — load-bearing, without it the capsule renders as a rect —
|
||
/// and its height is the label's ink box plus 2pt of padding, so the body size overflows and the
|
||
/// capsule shaves the emoji's top and bottom.
|
||
/// - **A link-styled button** is flowing text, and the chat bubble's paragraph
|
||
/// (`ChatMessageRichDataBubbleContentNode`: 17pt, `lineSpacingFactor` 0.9) has a line box of 12pt
|
||
/// and a line-to-line advance of just 22pt. A body-sized 24.3pt emoji is taller than the entire
|
||
/// row: it overhangs 6.15pt per side against 5pt of leading and visibly collides with the lines
|
||
/// above and below. `A − D` overhangs 4.05pt, clearing them.
|
||
///
|
||
/// So this is the largest standard size that fits a body row, which is why it is not tuned by eye.
|
||
/// The cost is that a button-label emoji reads at 84% of a neighbouring body emoji; sizing it to the
|
||
/// bare line box instead (`floor(A + D)`, 12pt) removes the overhang entirely but renders at 49%,
|
||
/// which reads as a shrunken glyph.
|
||
func instantPageButtonLabelEmojiSide(font: UIFont) -> CGFloat {
|
||
return font.ascender - font.descender
|
||
}
|
||
|
||
/// Rewrites every custom-emoji run delegate in a button label so the emoji fits the pill.
|
||
///
|
||
/// A custom emoji arrives as a ONE-character `" "` placeholder carrying a `CTRunDelegate` and a
|
||
/// `ChatTextInputAttributes.customEmoji` value, built by `attributedStringForRichText`'s
|
||
/// `.textCustomEmoji` arm for BODY TEXT. Two of that delegate's three numbers are wrong in a pill:
|
||
///
|
||
/// - **width** is the body-text emoji size, which overflows the capsule (see
|
||
/// `instantPageButtonLabelEmojiSide`).
|
||
/// - **descent** is `font.descender`, which is NEGATIVE. In body text nothing reads it — the V2 line
|
||
/// layout pins `lineDescent` to `fontDescentBelowBaseline` — but a pill measures its own height with
|
||
/// `CTLineGetTypographicBounds`. For a label that is ONLY an emoji no other run contributes a
|
||
/// positive descent, so the line's descent comes back negative and the pill collapses from ~19.7pt
|
||
/// to ~12.9pt.
|
||
///
|
||
/// Correcting them HERE, inside the single construction path and BEFORE truncation and measurement,
|
||
/// is what makes the reserved advance and the drawn square the same number by construction. The body
|
||
/// text arm is deliberately left alone so no existing page's metrics move.
|
||
///
|
||
/// Shared with the LINK-styled path (`attributedStringForLinkStyleButton`), which needs the same
|
||
/// sizing for a different reason — see `instantPageButtonLabelEmojiSide`. That path is the reason
|
||
/// `InstantPageEmojiSizeAttribute` is written as well as the delegate: a link button's label is laid
|
||
/// out by the V2 line layout, which derives the drawn square from the FONT and never reads the
|
||
/// delegate, so rewriting the delegate alone would shrink the advance and leave a body-sized emoji
|
||
/// drawn on top of it. The attribute is inert for a pill, which draws its own label.
|
||
func instantPageButtonLabelWithFittedEmoji(_ labelString: NSAttributedString) -> NSAttributedString {
|
||
struct RunStruct {
|
||
let ascent: CGFloat
|
||
let descent: CGFloat
|
||
let width: CGFloat
|
||
}
|
||
|
||
var emojiIndices: [Int] = []
|
||
// Character-by-character rather than `enumerateAttribute`: attribute enumeration coalesces
|
||
// adjacent runs carrying equal values, which would merge two side-by-side emoji into one range.
|
||
// Every placeholder is exactly one character, so indices are the natural unit.
|
||
for index in 0 ..< labelString.length {
|
||
if labelString.attribute(ChatTextInputAttributes.customEmoji, at: index, effectiveRange: nil) != nil {
|
||
emojiIndices.append(index)
|
||
}
|
||
}
|
||
guard !emojiIndices.isEmpty else {
|
||
return labelString
|
||
}
|
||
|
||
let result = labelString.mutableCopy() as! NSMutableAttributedString
|
||
for index in emojiIndices {
|
||
let font = (labelString.attribute(.font, at: index, effectiveRange: nil) as? UIFont) ?? UIFont.systemFont(ofSize: 17.0)
|
||
let side = instantPageButtonLabelEmojiSide(font: font)
|
||
let extentBuffer = UnsafeMutablePointer<RunStruct>.allocate(capacity: 1)
|
||
// Descent is negated: CTLine wants a positive distance below the baseline, whereas
|
||
// `font.descender` is negative.
|
||
extentBuffer.initialize(to: RunStruct(ascent: font.ascender, descent: -font.descender, width: side))
|
||
var callbacks = CTRunDelegateCallbacks(version: kCTRunDelegateVersion1, dealloc: { pointer in
|
||
pointer.assumingMemoryBound(to: RunStruct.self).deallocate()
|
||
}, getAscent: { pointer -> CGFloat in
|
||
return pointer.assumingMemoryBound(to: RunStruct.self).pointee.ascent
|
||
}, getDescent: { pointer -> CGFloat in
|
||
return pointer.assumingMemoryBound(to: RunStruct.self).pointee.descent
|
||
}, getWidth: { pointer -> CGFloat in
|
||
return pointer.assumingMemoryBound(to: RunStruct.self).pointee.width
|
||
})
|
||
let delegate = CTRunDelegateCreate(&callbacks, extentBuffer)
|
||
// Replaces the body-text delegate; the old one's buffer is freed by its own dealloc callback.
|
||
// The size attribute rides along for the link path — see this function's note.
|
||
result.addAttributes([
|
||
kCTRunDelegateAttributeName as NSAttributedString.Key: delegate as Any,
|
||
NSAttributedString.Key(rawValue: InstantPageEmojiSizeAttribute): side as NSNumber
|
||
], range: NSRange(location: index, length: 1))
|
||
}
|
||
return result
|
||
}
|
||
|
||
/// Truncates `labelString` with a tail ellipsis so its ink fits `availableWidth`. Returns the input
|
||
/// unchanged when it already fits.
|
||
///
|
||
/// `truncated` is reported rather than inferred from the returned string: a one-character label
|
||
/// replaced by a bare ellipsis keeps the same length, so a length comparison would miss exactly the
|
||
/// case that is cut hardest.
|
||
private func instantPageButtonTruncatedLabel(_ labelString: NSAttributedString, availableWidth: CGFloat) -> (label: NSAttributedString, truncated: Bool) {
|
||
guard labelString.length != 0, availableWidth > 0.0 else {
|
||
// A zero (or negative) budget cannot fit a non-empty label; an empty one has nothing to cut.
|
||
return (labelString, labelString.length != 0)
|
||
}
|
||
let fullWidth = CGFloat(CTLineGetTypographicBounds(CTLineCreateWithAttributedString(labelString), nil, nil, nil))
|
||
if fullWidth <= availableWidth {
|
||
return (labelString, false)
|
||
}
|
||
|
||
// Inherit the label's own attributes (font, weight) so the ellipsis matches the text it replaces.
|
||
let tailAttributes = labelString.attributes(at: labelString.length - 1, effectiveRange: nil)
|
||
let ellipsis = NSAttributedString(string: "\u{2026}", attributes: tailAttributes)
|
||
let ellipsisWidth = CGFloat(CTLineGetTypographicBounds(CTLineCreateWithAttributedString(ellipsis), nil, nil, nil))
|
||
|
||
// Not enough room for even one character plus the ellipsis: show the ellipsis alone.
|
||
let widthForText = availableWidth - ellipsisWidth
|
||
guard widthForText > 0.0 else {
|
||
return (ellipsis, true)
|
||
}
|
||
|
||
let typesetter = CTTypesetterCreateWithAttributedString(labelString)
|
||
let fittingCount = CTTypesetterSuggestClusterBreak(typesetter, 0, Double(widthForText))
|
||
guard fittingCount > 0 else {
|
||
return (ellipsis, true)
|
||
}
|
||
|
||
let result = NSMutableAttributedString(attributedString: labelString.attributedSubstring(from: NSRange(location: 0, length: min(fittingCount, labelString.length))))
|
||
result.append(ellipsis)
|
||
return (result, true)
|
||
}
|
||
|
||
/// Measures `labelString` and inflates it by the pill padding. The single construction path for both
|
||
/// inline `textButton`s and `pageBlockButtonRow` members, so `size`, `ascent` and `descent` mean the
|
||
/// same thing in both — the pill view relies on that when it centres the label.
|
||
///
|
||
/// `maxWidth` caps the whole pill. A pill wider than the line it sits on cannot be moved anywhere by
|
||
/// the line-breaker's re-break (that path requires the line to hold more than the pill), so the label
|
||
/// is truncated with an ellipsis instead of overflowing. Pass nil for no cap.
|
||
public func instantPageInlineButtonAttachment(button: InstantPageButton, labelString: NSAttributedString, maxWidth: CGFloat? = nil, horizontalPadding: CGFloat = instantPageInlineButtonHorizontalPadding, iconPlacement: InstantPageButtonIconPlacement = .inlineTrailing) -> InstantPageInlineButtonAttachment {
|
||
let hPad = horizontalPadding
|
||
let vPad = instantPageInlineButtonVerticalPadding
|
||
// Only an inline pill pays width for its icon. A block pill's badge overlays the fill, and the row
|
||
// layout keeps it clear of the label with a side inset instead — charging both would double it.
|
||
let iconReserve: CGFloat
|
||
switch iconPlacement {
|
||
case .inlineTrailing:
|
||
iconReserve = richTextButtonIconName(for: button.action) != nil ? richTextInlineButtonIconReserve : 0.0
|
||
case .blockBadge:
|
||
iconReserve = 0.0
|
||
}
|
||
|
||
// MUST run before truncation and before measurement: the ellipsis cut and the returned
|
||
// size/ascent/descent are all computed against the rewritten delegates.
|
||
var effectiveLabel = instantPageButtonLabelWithFittedEmoji(labelString)
|
||
var truncated = false
|
||
if let maxWidth {
|
||
// The reserve is real width, so it comes out of the label's room like the padding does —
|
||
// otherwise a capped pill overflows its line by exactly the icon.
|
||
(effectiveLabel, truncated) = instantPageButtonTruncatedLabel(effectiveLabel, availableWidth: max(0.0, maxWidth - hPad * 2.0 - iconReserve))
|
||
}
|
||
|
||
let line = CTLineCreateWithAttributedString(effectiveLabel)
|
||
var labelAscent: CGFloat = 0.0
|
||
var labelDescent: CGFloat = 0.0
|
||
let labelWidth = CGFloat(CTLineGetTypographicBounds(line, &labelAscent, &labelDescent, nil))
|
||
return InstantPageInlineButtonAttachment(
|
||
button: button,
|
||
labelString: effectiveLabel,
|
||
size: CGSize(width: labelWidth + hPad * 2.0 + iconReserve, height: labelAscent + labelDescent + vPad * 2.0),
|
||
ascent: labelAscent + vPad,
|
||
descent: labelDescent + vPad,
|
||
horizontalPadding: hPad,
|
||
isTruncated: truncated,
|
||
iconReserve: iconReserve
|
||
)
|
||
}
|
||
|
||
/// Where an inline pill draws its type icon, in pill-local coordinates. Meaningless for a pill whose
|
||
/// `iconReserve` is 0 — the caller decides whether there is an icon at all.
|
||
///
|
||
/// Derived from `instantPageButtonLabelOrigin` rather than from the pill's trailing edge, so the icon
|
||
/// travels with the label when a row column stretches the pill, exactly as the emoji squares do.
|
||
///
|
||
/// Vertically it centres on the label's CAP box — `capHeight` above the baseline — and NOT on the pill
|
||
/// box. The two differ by ~0.6pt at 15pt: the pill's box is asymmetric around the text because it also
|
||
/// holds the descender, so centring on it sits the icon visibly low against the letters it follows.
|
||
func instantPageInlineButtonIconFrame(attachment: InstantPageInlineButtonAttachment, pillSize: CGSize) -> CGRect {
|
||
let origin = instantPageButtonLabelOrigin(attachment: attachment, pillSize: pillSize)
|
||
let labelWidth = attachment.size.width - attachment.horizontalPadding * 2.0 - attachment.iconReserve
|
||
let font = instantPageButtonLabelFont(attachment.labelString)
|
||
return CGRect(
|
||
origin: CGPoint(
|
||
x: origin.x + labelWidth + richTextInlineButtonIconSpacing,
|
||
y: origin.y - font.capHeight / 2.0 - richTextButtonIconSize.height / 2.0
|
||
),
|
||
size: richTextButtonIconSize
|
||
)
|
||
}
|
||
|
||
/// The face a button label is drawn in, read off the label itself. An empty label — a button whose
|
||
/// text is blank, or one truncated to nothing — still needs a face to derive metrics from, and the
|
||
/// inline pill's own typography is the right default there.
|
||
private func instantPageButtonLabelFont(_ labelString: NSAttributedString) -> UIFont {
|
||
guard labelString.length != 0,
|
||
let font = labelString.attribute(.font, at: 0, effectiveRange: nil) as? UIFont else {
|
||
return UIFont.systemFont(ofSize: instantPageInlineButtonFontSize, weight: .semibold)
|
||
}
|
||
return font
|
||
}
|
||
|
||
/// True when `string`'s first (respectively last) character carries a pill. Asked of the *rendered*
|
||
/// attributed string rather than of the `RichText` tree, so it also holds for a button wrapped in
|
||
/// formatting — `bold(textButton(…))` — and for a button reached through nested `concat`s.
|
||
func instantPageStringStartsWithInlineButton(_ string: NSAttributedString) -> Bool {
|
||
guard string.length != 0 else {
|
||
return false
|
||
}
|
||
return string.attribute(NSAttributedString.Key(rawValue: InstantPageInlineButtonAttribute), at: 0, effectiveRange: nil) != nil
|
||
}
|
||
|
||
func instantPageStringEndsWithInlineButton(_ string: NSAttributedString) -> Bool {
|
||
guard string.length != 0 else {
|
||
return false
|
||
}
|
||
return string.attribute(NSAttributedString.Key(rawValue: InstantPageInlineButtonAttribute), at: string.length - 1, effectiveRange: nil) != nil
|
||
}
|
||
|
||
/// The gap run inserted between two adjacent pills. The width has to travel on a CTRunDelegate: a
|
||
/// plain space would advance by whatever the font says, and this is a tuned pixel amount.
|
||
///
|
||
/// Reports 0 ascent/descent — like the inline-image path and unlike the pill itself — so it can never
|
||
/// grow the line box. Carries no button attribute, so the V2 line-breaker skips it when collecting
|
||
/// pending pills.
|
||
func instantPageInlineButtonSpacerString(attributes: [NSAttributedString.Key: Any]) -> NSAttributedString {
|
||
struct RunStruct {
|
||
let ascent: CGFloat
|
||
let descent: CGFloat
|
||
let width: CGFloat
|
||
}
|
||
let extentBuffer = UnsafeMutablePointer<RunStruct>.allocate(capacity: 1)
|
||
extentBuffer.initialize(to: RunStruct(ascent: 0.0, descent: 0.0, width: instantPageInlineButtonAdjacentSpacing))
|
||
var callbacks = CTRunDelegateCallbacks(version: kCTRunDelegateVersion1, dealloc: { pointer in
|
||
pointer.assumingMemoryBound(to: RunStruct.self).deallocate()
|
||
}, getAscent: { (pointer) -> CGFloat in
|
||
return pointer.assumingMemoryBound(to: RunStruct.self).pointee.ascent
|
||
}, getDescent: { (pointer) -> CGFloat in
|
||
return pointer.assumingMemoryBound(to: RunStruct.self).pointee.descent
|
||
}, getWidth: { (pointer) -> CGFloat in
|
||
return pointer.assumingMemoryBound(to: RunStruct.self).pointee.width
|
||
})
|
||
let delegate = CTRunDelegateCreate(&callbacks, extentBuffer)
|
||
let result = NSMutableAttributedString(string: " ", attributes: attributes)
|
||
result.addAttribute(kCTRunDelegateAttributeName as NSAttributedString.Key, value: delegate as Any, range: NSMakeRange(0, result.length))
|
||
return result
|
||
}
|
||
|
||
/// Where a pill draws its label, in pill-local coordinates: `x` is the left edge of the label's ink,
|
||
/// `y` is its BASELINE.
|
||
///
|
||
/// Extracted verbatim from `InstantPageV2ButtonPillContentView.draw(_:)` — including the `- 0.33`
|
||
/// optical nudge — so that the emoji placement below and the actual drawing cannot drift apart.
|
||
/// A pure function of `(attachment, pillSize)`: `updateInlineEmoji()` runs before a freshly created
|
||
/// pill's `layoutSubviews`, so its live `bounds` are still zero at that point and must not be read.
|
||
func instantPageButtonLabelOrigin(attachment: InstantPageInlineButtonAttachment, pillSize: CGSize) -> CGPoint {
|
||
// Horizontally centre the label: for an inline pill this equals the padding, but a row pill's
|
||
// frame is stretched to an equal column width, so the label must centre within it.
|
||
//
|
||
// The icon's reserve is trailing room that belongs to the GROUP, not to the label: it is taken off
|
||
// the recovered ink width, and the group (label + gap + icon) is what centres. Centring the label
|
||
// alone would slide it right by half the reserve and drag the emoji squares with it.
|
||
let labelWidth = attachment.size.width - attachment.horizontalPadding * 2.0 - attachment.iconReserve
|
||
let x = max(attachment.horizontalPadding, (pillSize.width - labelWidth - attachment.iconReserve) / 2.0)
|
||
// Vertically: `attachment.ascent` is the baseline's distance from the pill top for an inline
|
||
// pill. A row pill has a fixed taller height, so centre the label's box instead.
|
||
let labelBoxHeight = attachment.ascent + attachment.descent
|
||
let y = (pillSize.height - labelBoxHeight) / 2.0 + attachment.ascent - 0.33
|
||
return CGPoint(x: x, y: y)
|
||
}
|
||
|
||
/// The squares a pill's custom emoji occupy, in pill-local coordinates.
|
||
///
|
||
/// Each square spans exactly the label font's ascender→descender box — precisely the extent
|
||
/// `instantPageButtonLabelWithFittedEmoji` reserved for it — so it always sits inside the pill's
|
||
/// padding and is never shaved by the capsule clip.
|
||
func instantPageButtonEmojiPlacements(
|
||
attachment: InstantPageInlineButtonAttachment,
|
||
pillSize: CGSize
|
||
) -> [(emoji: ChatTextInputTextCustomEmojiAttribute, frame: CGRect)] {
|
||
let labelString = attachment.labelString
|
||
var result: [(emoji: ChatTextInputTextCustomEmojiAttribute, frame: CGRect)] = []
|
||
guard labelString.length != 0 else {
|
||
return result
|
||
}
|
||
|
||
var line: CTLine?
|
||
let origin = instantPageButtonLabelOrigin(attachment: attachment, pillSize: pillSize)
|
||
|
||
// Character-by-character, matching the rewrite in `instantPageButtonLabelWithFittedEmoji`:
|
||
// `enumerateAttribute` would coalesce two adjacent emoji into a single range.
|
||
for index in 0 ..< labelString.length {
|
||
guard let emoji = labelString.attribute(ChatTextInputAttributes.customEmoji, at: index, effectiveRange: nil) as? ChatTextInputTextCustomEmojiAttribute else {
|
||
continue
|
||
}
|
||
// Built lazily: the overwhelming majority of button labels carry no emoji at all.
|
||
let resolvedLine: CTLine
|
||
if let line {
|
||
resolvedLine = line
|
||
} else {
|
||
resolvedLine = CTLineCreateWithAttributedString(labelString)
|
||
line = resolvedLine
|
||
}
|
||
|
||
let font = (labelString.attribute(.font, at: index, effectiveRange: nil) as? UIFont) ?? UIFont.systemFont(ofSize: 17.0)
|
||
let side = instantPageButtonLabelEmojiSide(font: font)
|
||
let xOffset = v2LeadingOffsetForRange(resolvedLine, range: NSRange(location: index, length: 1))
|
||
result.append((
|
||
emoji: emoji,
|
||
frame: CGRect(
|
||
x: origin.x + xOffset,
|
||
y: origin.y - font.ascender,
|
||
width: side,
|
||
height: side
|
||
)
|
||
))
|
||
}
|
||
return result
|
||
}
|
||
|
||
/// Fill and label colours for a button pill. Mirrors the semantics of
|
||
/// `ChatMessageActionButtonsNode.swift:468-472` (coloured background at reduced alpha) but sources
|
||
/// from `InstantPageTheme`, which is what the V2 renderer is handed. Alphas are tunable.
|
||
///
|
||
/// `isInline` distinguishes an inline `RichText.textButton` pill from a block-level
|
||
/// `pageBlockButtonRow` pill. Both currently resolve to the same colours — the parameter is the seam
|
||
/// for giving them different treatments (an inline pill sits inside a paragraph and may want a lighter
|
||
/// fill than a standalone row button).
|
||
/// `isLink` is the `richButtonStyle.link` bit. A link-styled ROW button is chrome-less: no fill, and the
|
||
/// page's ordinary link colour for the label — the same treatment the INLINE path gives it by rendering
|
||
/// plain link text instead of a pill (`attributedStringForLinkStyleButton`). It wins over the background
|
||
/// bits, matching the documented `link > bg_primary > bg_danger > bg_success` precedence.
|
||
public func instantPageButtonColors(
|
||
_ color: ReplyMarkupButton.Style.Color?,
|
||
theme: InstantPageTheme,
|
||
isInline: Bool,
|
||
isDisabled: Bool,
|
||
isLink: Bool = false
|
||
) -> (fill: UIColor, label: UIColor) {
|
||
if isLink {
|
||
let label = theme.linkColor
|
||
return (.clear, isDisabled ? label.withMultipliedAlpha(0.4) : label)
|
||
}
|
||
let fill: UIColor
|
||
let label: UIColor
|
||
switch color {
|
||
case .none:
|
||
if isInline {
|
||
fill = theme.panelBackgroundColor
|
||
label = theme.panelAccentColor
|
||
} else {
|
||
fill = theme.neutralButtonBackgroundColor
|
||
label = theme.neutralButtonForegroundColor
|
||
}
|
||
case .some(.primary):
|
||
fill = theme.checkboxFill
|
||
label = theme.checkboxForeground
|
||
case .some(.danger):
|
||
fill = theme.buttonDangerBackgroundColor
|
||
label = theme.buttonDangerForegroundColor
|
||
case .some(.success):
|
||
fill = theme.buttonSuccessBackgroundColor
|
||
label = theme.buttonSuccessForegroundColor
|
||
}
|
||
if isDisabled {
|
||
return (fill, label.withMultipliedAlpha(0.4))
|
||
}
|
||
return (fill, label)
|
||
}
|