Files
booster d6dc384f0e Range and image builders support in ellipsis
commit_hash:f70efdbaf7bf8044c7eb8fd1ee207ba7695f5bc0
2026-06-03 17:33:05 +03:00

510 lines
16 KiB
Swift

import CoreFoundation
import Foundation
import LayoutKit
import VGSL
extension DivText: DivBlockModeling {
public func makeBlock(context: DivBlockModelingContext) throws -> Block {
let context = modifiedContextParentPath(context)
let expressionResolver = context.expressionResolver
let lazyText = Lazy(getter: { [unowned self] in
resolveText(expressionResolver) ?? ""
})
return try applyBaseProperties(
to: { try makeBaseBlock(context: context, text: lazyText) },
context: context,
actionsHolder: self,
customAccessibilityParams: CustomAccessibilityParams(
defaultTraits: .staticText
) { [unowned self] in
accessibility?.resolveDescription(expressionResolver) ?? lazyText.value
}
)
}
private func makeBaseBlock(
context: DivBlockModelingContext,
text: Lazy<String>
) throws -> Block {
let expressionResolver = context.expressionResolver
let fontParams = resolveFontParams(expressionResolver)
var typo = Typo(font: context.font(fontParams))
let alignment = resolveTextAlignmentHorizontal(expressionResolver)
.makeTextAlignment(uiLayoutDirection: context.layoutDirection)
if alignment != .left {
typo = typo.with(alignment: alignment)
}
let kern = CGFloat(resolveLetterSpacing(expressionResolver))
if !kern.isApproximatelyEqualTo(0) {
typo = typo.kerned(kern)
}
let isFocused = context.blockStateStorage.isFocused(path: context.path)
let resolvedColor = if isFocused,
let focusedTextColor = resolveFocusedTextColor(expressionResolver) {
focusedTextColor
} else {
resolveTextColor(expressionResolver)
}
if resolvedColor != .black {
typo = typo.with(color: resolvedColor)
}
if let lineHeight = resolveLineHeight(expressionResolver) {
typo = typo.with(height: CGFloat(lineHeight))
}
switch resolveStrike(expressionResolver) {
case .none: break
case .single: typo = typo.struckThrough(.single)
}
switch resolveUnderline(expressionResolver) {
case .none: break
case .single: typo = typo.underlined(.single)
}
if let textShadow = textShadow?.resolve(expressionResolver) {
typo = typo.shaded(textShadow.typoShadow)
}
let truncateMode = resolveTruncate(expressionResolver)
let lineBreakMode: LineBreakMode? = switch truncateMode {
case .none:
.byClipping
case .start:
.byTruncatingHead
case .end:
nil
case .middle:
.byTruncatingMiddle
}
if let lineBreakMode {
typo = typo.with(lineBreakMode: lineBreakMode)
}
let resolvedRanges = makeRanges(ranges, rangeBuilder: rangeBuilder, context: context)
let makeAttributedStringWithTypo: (Typo) -> NSAttributedString = {
self.makeAttributedString(
text: text.value as CFString,
typo: $0,
ranges: resolvedRanges,
actions: nil,
fontParams: fontParams
)
}
let attributedString = makeAttributedStringWithTypo(typo)
let gradientModel: TextBlock.GradientModel? = if let gradient = resolveGradient(context) {
TextBlock.GradientModel(
gradient: gradient,
rangedTextWithColor: makeAttributedStringWithTypo(typo.with(color: .clear))
)
} else {
nil
}
let images = makeInlineImages(
images: makeImages(self.images, imageBuilder: imageBuilder, context: context),
text: attributedString
)
let truncationToken = ellipsis.map {
makeAttributedString(
text: ($0.resolveText(expressionResolver) ?? "") as CFString,
typo: typo,
ranges: makeRanges($0.ranges, rangeBuilder: $0.rangeBuilder, context: context),
actions: $0.actions?.uiActions(context: context),
fontParams: fontParams
)
}
let truncationImages = makeInlineImages(
images: makeImages(ellipsis?.images, imageBuilder: ellipsis?.imageBuilder, context: context),
text: truncationToken
)
if let id {
context.blockStateStorage.setState(
id: id,
cardId: context.cardId,
state: TextBlockViewState(text: attributedString.string)
)
}
let additionalTextInsets = additionalTextInsets(ranges: resolvedRanges)
return TextBlock(
widthTrait: resolveContentWidthTrait(context),
heightTrait: resolveContentHeightTrait(context),
text: attributedString,
gradientModel: gradientModel,
verticalAlignment: resolveTextAlignmentVertical(expressionResolver).alignment,
maxIntrinsicNumberOfLines: resolveMaxLines(expressionResolver) ?? .max,
minNumberOfHiddenLines: resolveMinHiddenLines(expressionResolver) ?? 0,
images: images,
accessibilityElement: nil,
truncationToken: truncationToken,
truncationImages: truncationImages,
additionalTextInsets: additionalTextInsets,
canSelect: resolveSelectable(expressionResolver),
tightenWidth: resolveTightenWidth(expressionResolver),
autoEllipsize: resolveAutoEllipsize(expressionResolver)
?? context.flagsInfo.defaultTextAutoEllipsize,
path: context.path,
isFocused: isFocused
)
}
private func makeAttributedString(
text: CFString,
typo: Typo,
ranges: [(Range, DivBlockModelingContext)],
actions: [UserInterfaceAction]?,
fontParams: FontParams
) -> NSAttributedString {
let length = CFStringGetLength(text)
let attributedString = CFAttributedStringCreateMutable(kCFAllocatorDefault, length)!
CFAttributedStringBeginEditing(attributedString)
CFAttributedStringReplaceString(attributedString, CFRange(), text)
let fullRange = CFRange(location: 0, length: length)
typo.apply(to: attributedString, at: fullRange)
attributedString.apply(actions: actions, mask: nil, at: fullRange)
for (range, rangeContext) in ranges {
apply(range, to: attributedString, context: rangeContext, fontParams: fontParams)
}
CFAttributedStringEndEditing(attributedString)
return attributedString
}
private func makeInlineImages(
images: [(Image, DivBlockModelingContext)],
text: NSAttributedString?
) -> [TextBlock.InlineImage] {
guard let text else {
return []
}
let textLength = CFAttributedStringGetLength(text)
return images.compactMap { image, imageContext in
image.makeImage(
context: imageContext,
textLength: textLength
)
}
}
private func apply(
_ range: DivText.Range,
to string: CFMutableAttributedString,
context: DivBlockModelingContext,
fontParams: FontParams
) {
let expressionResolver = context.expressionResolver
let stringLength = CFAttributedStringGetLength(string)
let start = range.resolveStart(expressionResolver)
let end = range.resolveEnd(expressionResolver) ?? stringLength
guard end > start, start < stringLength else {
return
}
let actualEnd = min(end, stringLength)
let cfRange = CFRange(location: start, length: actualEnd - start)
let mask = range.makeMask(range: cfRange, resolver: expressionResolver)
let rangeVariationSettings = range.resolveFontVariationSettings(expressionResolver)?
.mapValues { $0 as? NSNumber }.filteringNilValues()
let rangeFontParams = FontParams(
family: range.resolveFontFamily(expressionResolver) ?? fontParams.family,
weight: range.resolveFontWeightValue(expressionResolver)
?? range.resolveFontWeight(expressionResolver)?.toInt()
?? fontParams.weight,
size: range.resolveFontSize(expressionResolver) ?? fontParams.size,
unit: range.resolveFontSizeUnit(expressionResolver),
featureSettings: range.resolveFontFeatureSettings(expressionResolver)
?? fontParams.featureSettings,
variationSettings: rangeVariationSettings ?? fontParams.variationSettings
)
let fontTypo = rangeFontParams == fontParams
? nil
: Typo(font: context.font(rangeFontParams))
let colorTypo = mask?.isEnabled == true
? Typo(color: .black.withAlphaComponent(0))
: range.resolveTextColor(expressionResolver).map { Typo(color: $0) }
let heightTypo = range.resolveLineHeight(expressionResolver)
.map { Typo(height: CGFloat($0)) }
let spacingTypo = range.resolveLetterSpacing(expressionResolver)
.map { Typo(kern: CGFloat($0)) }
let strikethroughTypo = range.resolveStrike(expressionResolver)
.map { Typo(strikethrough: $0.underlineStyle) }
let underlineTypo = range.resolveUnderline(expressionResolver)
.map { Typo(underline: $0.underlineStyle) }
let baselineOffset = range.resolveBaselineOffset(expressionResolver)
let baselineOffsetTypo = Typo(
baselineOffset: baselineOffset.isApproximatelyEqualTo(0) ? nil : baselineOffset
)
let blockShadow = range.textShadow?.resolve(expressionResolver).typoShadow
let shadowTypo = blockShadow.map { Typo(shadow: $0) }
let typos = [
fontTypo,
colorTypo,
heightTypo,
spacingTypo,
strikethroughTypo,
underlineTypo,
baselineOffsetTypo,
shadowTypo,
].compactMap { $0 }
let actions = range.actions?.uiActions(context: context)
if typos.isEmpty, actions == nil, range.background == nil, range.border == nil,
range.mask == nil {
return
}
typos.forEach { $0.apply(to: string, at: cfRange) }
string.apply(actions: actions, mask: mask, at: cfRange)
[
range.makeBackground(context: context, range: cfRange),
range.makeBorder(range: cfRange, resolver: expressionResolver),
range.makeVerticalAlignment(range: cfRange, resolver: expressionResolver),
].forEach {
$0?.apply(to: string, at: cfRange)
}
}
private func resolveGradient(_ context: DivBlockModelingContext) -> Gradient? {
guard let textGradient else {
return nil
}
switch textGradient {
case let .divLinearGradient(gradient):
return gradient.makeBlockLinearGradient(context).map { .linear($0) }
case let .divRadialGradient(gradient):
return gradient.makeBlockRadialGradient(context).map { .radial($0) }
}
}
private func additionalTextInsets(
ranges: [(Range, DivBlockModelingContext)]
) -> EdgeInsets {
var additionalInsets: EdgeInsets = .zero
for (range, rangeContext) in ranges {
if case let .divCloudBackground(background) = range.background {
let backgroundInsets = background.paddings?.resolve(rangeContext) ?? .zero
additionalInsets = EdgeInsets(
top: max(additionalInsets.top, backgroundInsets.top),
left: max(additionalInsets.left, backgroundInsets.left),
bottom: max(additionalInsets.bottom, backgroundInsets.bottom),
right: max(additionalInsets.right, backgroundInsets.right)
)
}
}
return additionalInsets
}
}
extension DivText: FontParamsProvider {}
extension CFMutableAttributedString {
fileprivate func apply(
actions: [UserInterfaceAction]?,
mask: TextMask?,
at range: CFRange
) {
RunWithBoundsAttribute(
actions: actions ?? [],
mask: mask
).apply(to: self, at: range)
}
}
extension DivAlignmentHorizontal {
fileprivate func makeTextAlignment(
uiLayoutDirection: UserInterfaceLayoutDirection =
.system
) -> TextAlignment {
switch self {
case .start:
uiLayoutDirection == .leftToRight ? .left : .right
case .end:
uiLayoutDirection == .leftToRight ? .right : .left
case .left:
.left
case .center:
.center
case .right:
.right
}
}
}
extension URLQueryItem {
init(name: String, value: DivCardID?) {
self.init(name: name, value: value?.rawValue)
}
}
extension DivText.Image {
fileprivate func makeImage(
context: DivBlockModelingContext,
textLength: Int
) -> TextBlock.InlineImage? {
let expressionResolver = context.expressionResolver
let start = resolveStart(expressionResolver) ?? 0
guard start <= textLength else {
return nil
}
let indexingDirection = resolveIndexingDirection(expressionResolver)
let location = switch indexingDirection {
case .normal:
start
case .reversed:
textLength - start
}
return TextBlock.InlineImage(
size: CGSize(
width: CGFloat(width.resolveValue(expressionResolver) ?? 0),
height: CGFloat(height.resolveValue(expressionResolver) ?? 0)
),
holder: context.imageHolderFactory.make(resolveUrl(expressionResolver)),
location: location,
tintColor: resolveTintColor(expressionResolver),
tintMode: resolveTintMode(expressionResolver).tintMode,
verticalAlignment: resolveAlignmentVertical(expressionResolver).inlineImageAlignment
)
}
}
extension DivTextAlignmentVertical {
fileprivate var inlineImageAlignment: TextBlock.InlineImage.VerticalAlignment {
switch self {
case .top: .top
case .center: .center
case .bottom: .bottom
case .baseline: .baseline
}
}
}
extension DivText.Range {
fileprivate func makeBorder(
range: CFRange,
resolver: ExpressionResolver
) -> BorderAttribute? {
guard let border else { return nil }
let color = border.stroke?.resolveColor(resolver)
let width = border.stroke?.resolveWidth(resolver)
let cornerRadius = border.resolveCornerRadius(resolver)
return BorderAttribute(
color: color?.cgColor,
width: width.flatMap(CGFloat.init),
cornerRadius:
cornerRadius.flatMap(CGFloat.init),
range: range
)
}
fileprivate func makeVerticalAlignment(
range: CFRange,
resolver: ExpressionResolver
) -> RangeVerticalAlignmentAttribute? {
switch resolveAlignmentVertical(resolver) {
case .top:
.init(verticalAlignment: .top, range: range)
case .center:
.init(verticalAlignment: .center, range: range)
case .bottom:
.init(verticalAlignment: .bottom, range: range)
case .baseline:
.init(verticalAlignment: .baseline, range: range)
case .none:
nil
}
}
fileprivate func makeBackground(
context: DivBlockModelingContext,
range: CFRange
) -> StringAttribute? {
guard let background else { return nil }
let resolver = context.expressionResolver
switch background {
case let .divSolidBackground(solid):
let color = solid.resolveColor(resolver) ?? .clear
return BackgroundAttribute(color: color.cgColor, range: range)
case let .divCloudBackground(cloud):
guard let color = cloud.resolveColor(resolver),
let cornerRadius = cloud.resolveCornerRadius(resolver)
else { return nil }
let insets = cloud.paddings?.resolve(context)
return CloudBackgroundAttribute(
color: color,
cornerRadius: CGFloat(cornerRadius),
range: range.location..<(range.location + range.length),
insets: insets
)
}
}
fileprivate func makeMask(
range: CFRange,
resolver: ExpressionResolver
) -> TextMask? {
guard let mask else { return nil }
switch mask {
case let .divTextRangeMaskSolid(solid):
guard let color = solid.resolveColor(resolver) else {
return nil
}
return TextMask(
color: color.cgColor,
range: range,
isEnabled: solid.resolveIsEnabled(resolver)
)
case let .divTextRangeMaskParticles(particles):
guard let color = particles.resolveColor(resolver) else {
return nil
}
let isAnimated = particles.resolveIsAnimated(resolver)
let density = particles.resolveDensity(resolver)
let particleSize = CGFloat(particles.particleSize.resolveValue(resolver) ?? 1)
return TextMask(
color: color.cgColor,
range: range,
isAnimated: isAnimated,
particleSize: particleSize,
density: density,
isEnabled: particles.resolveIsEnabled(resolver)
)
}
}
}
extension DivLineStyle {
fileprivate var underlineStyle: UnderlineStyle {
switch self {
case .none: []
case .single: .single
}
}
}
extension BlockShadow {
fileprivate var typoShadow: Shadow {
Shadow(
offset: CGSize(width: offset.x, height: -offset.y),
blurRadius: blurRadius,
color: color.withAlphaComponent(CGFloat(opacity))
)
}
}