Files
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

469 lines
26 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 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)
}