Skip to content

Commit 241c381

Browse files
committed
feat(Text): render Text + Text concatenation as a Compose AnnotatedString
Add `_ConcatenatedText`, which folds an ordered list of styled runs into a single `AnnotatedString` (per-run color / font size / weight / italic / monospaced / underline / strikethrough applied as a `SpanStyle`), and a primitive-friendly `Text(bridgedRuns:colors:fontSizes:fontWeights:flags:)` initializer that SkipSwiftUI uses to bridge captured run styling across the boundary. Each run's base text is bridged so its localized string is resolved at render time. The public `static func + (Text, Text)` remains unimplemented in SkipUI itself: SkipUI's `Text` applies styling as environment modifiers on a wrapped view, which cannot be read back out to build per-run spans. SkipFuse drives this renderer by capturing styling as data and calling the bridge.
1 parent 32a5656 commit 241c381

1 file changed

Lines changed: 142 additions & 0 deletions

File tree

Sources/SkipUI/SkipUI/Text/Text.swift

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,17 @@ public struct Text: View, Renderable, Equatable {
9494
modifiedView = textView
9595
}
9696

97+
// SKIP @bridge
98+
public init(bridgedRuns runs: [any View], colors: [any View], fontSizes: [Double], fontWeights: [Int], flags: [Int]) {
99+
#if SKIP
100+
textView = _Text(verbatim: "")
101+
modifiedView = _ConcatenatedText(runs: runs, colors: colors, fontSizes: fontSizes, fontWeights: fontWeights, flags: flags)
102+
#else
103+
textView = _Text(verbatim: "")
104+
modifiedView = textView
105+
#endif
106+
}
107+
97108
init(textView: _Text, modifiedView: any View) {
98109
self.textView = textView
99110
// Don't copy view
@@ -597,6 +608,137 @@ struct _Text: View, Renderable, Equatable {
597608
#endif
598609
}
599610

611+
#if SKIP
612+
/// Renders a concatenation of styled text runs (the result of `Text + Text`) as a single Compose
613+
/// `AnnotatedString`, applying each run's captured styling as a `SpanStyle`.
614+
///
615+
/// SkipFuse constructs this via `Text.init(bridgedRuns:colors:fontSizes:fontWeights:flags:)`: the
616+
/// styling modifiers (`.foregroundColor`, `.font`, `.bold`, …) are captured as *data* on the Swift
617+
/// side — where they are applied — and bridged across as primitive per-run descriptors, because a
618+
/// styled `Text` applies its style as an environment modifier that cannot be read back out at render
619+
/// time to build per-run spans.
620+
struct _ConcatenatedText: View, Renderable {
621+
let runs: [any View] // each run's base text (a `Text`), used only for its localized string
622+
let colors: [any View] // each run's foreground color (a `Color`); honored only when the hasColor flag is set
623+
let fontSizes: [Double] // each run's point size; honored only when the hasFontSize flag is set
624+
let fontWeights: [Int] // each run's Compose font weight (100...900); honored only when the hasFontWeight flag is set
625+
let flags: [Int] // per-run style bitmask (see the constants below)
626+
627+
// Per-run style bitmask. Kept in sync with SkipSwiftUI's Text run capture.
628+
static let hasColorFlag = 1
629+
static let hasFontSizeFlag = 2
630+
static let hasFontWeightFlag = 4
631+
static let italicFlag = 8
632+
static let monospacedFlag = 16
633+
static let underlineFlag = 32
634+
static let strikethroughFlag = 64
635+
636+
// SKIP INSERT: @OptIn(ExperimentalTextApi::class)
637+
@Composable override func Render(context: ComposeContext) {
638+
let textEnvironment = EnvironmentValues.shared._textEnvironment
639+
let textDecoration = textEnvironment.textDecoration
640+
let textAlign = EnvironmentValues.shared.multilineTextAlignment.asTextAlign()
641+
let maxLines = max(1, EnvironmentValues.shared.lineLimit ?? Int.MAX_VALUE)
642+
let truncationMode = EnvironmentValues.shared.truncationMode
643+
let hasLineLimit = (EnvironmentValues.shared.lineLimit != nil)
644+
let reservesSpace = EnvironmentValues.shared._lineLimitReservesSpace ?? false
645+
let minLines = reservesSpace ? maxLines : 1
646+
let redaction = EnvironmentValues.shared.redactionReasons
647+
let styleInfo = Text.styleInfo(textEnvironment: textEnvironment, redaction: redaction, context: context)
648+
let animatable = styleInfo.style.asAnimatable(context: context)
649+
650+
// Resolve each run's localized string and foreground color here, in the @Composable scope:
651+
// `localizedTextString()` and `asComposeColor()` are @Composable and cannot be called from
652+
// inside the (non-composable) `buildAnnotatedString` builder lambda below.
653+
var runStrings: [String] = []
654+
var runColors: [androidx.compose.ui.graphics.Color?] = []
655+
for i in 0..<runs.count {
656+
if let text = runs[i] as? Text {
657+
runStrings.append(text.localizedTextString())
658+
} else {
659+
runStrings.append("")
660+
}
661+
let flag = i < flags.count ? flags[i] : 0
662+
if (flag & _ConcatenatedText.hasColorFlag) != 0, let color = colors[i] as? Color {
663+
runColors.append(color.asComposeColor())
664+
} else {
665+
runColors.append(nil)
666+
}
667+
}
668+
669+
let annotatedText = buildAnnotatedString {
670+
appendRuns(to: self, strings: runStrings, resolvedColors: runColors)
671+
}
672+
673+
var modifier = Modifier.flexibleWidth(max: Float.flexibleUnknownNonExpanding).then(context.modifier)
674+
if EnvironmentValues.shared._layoutAxis == .horizontal {
675+
modifier = modifier.applyHStackTextBaselineAlignment(EnvironmentValues.shared._horizontalStackVerticalAlignmentKey)
676+
}
677+
var options = Material3TextOptions(annotatedText: annotatedText, modifier: modifier, color: styleInfo.color ?? androidx.compose.ui.graphics.Color.Unspecified, maxLines: maxLines, minLines: minLines, style: animatable.value, textDecoration: textDecoration, textAlign: textAlign, onTextLayout: { _ in })
678+
if let tracking = textEnvironment.tracking {
679+
options = options.copy(letterSpacing: tracking.sp)
680+
}
681+
if let lineSpacing = textEnvironment.lineSpacing {
682+
let lineHeightEm = 1.0 + (lineSpacing / 16.0)
683+
options = options.copy(lineHeight: lineHeightEm.em)
684+
}
685+
if hasLineLimit {
686+
options = options.copy(
687+
overflow: {
688+
let singleLine = (maxLines == 1) || options.softWrap == false
689+
switch truncationMode {
690+
case .tail:
691+
return TextOverflow.Ellipsis
692+
case .head:
693+
return singleLine ? TextOverflow.StartEllipsis : TextOverflow.Ellipsis
694+
case .middle:
695+
return singleLine ? TextOverflow.MiddleEllipsis : TextOverflow.Ellipsis
696+
default:
697+
return TextOverflow.Clip
698+
}
699+
}()
700+
)
701+
}
702+
if let updateOptions = EnvironmentValues.shared._material3Text {
703+
options = updateOptions(options)
704+
}
705+
if let annotatedText = options.annotatedText {
706+
androidx.compose.material3.Text(text: annotatedText, modifier: options.modifier, color: options.color, autoSize: options.autoSize, fontSize: options.fontSize, fontStyle: options.fontStyle, fontWeight: options.fontWeight, fontFamily: options.fontFamily, letterSpacing: options.letterSpacing, textDecoration: options.textDecoration, textAlign: options.textAlign, lineHeight: options.lineHeight, overflow: options.overflow, softWrap: options.softWrap, maxLines: options.maxLines, minLines: options.minLines, onTextLayout: options.onTextLayout ?? { _ in }, style: options.style)
707+
}
708+
}
709+
710+
/// Append the styled runs to `builder`. Non-composable: the strings and colors are pre-resolved
711+
/// in `Render`. Mirrors the markdown builder's `append(markdown:to:)` helper pattern; `self` at
712+
/// the call site (`buildAnnotatedString { appendRuns(to: self, …) }`) is the builder receiver.
713+
private func appendRuns(to builder: AnnotatedString.Builder, strings: [String], resolvedColors: [androidx.compose.ui.graphics.Color?]) {
714+
for i in 0..<strings.count {
715+
let flag = i < flags.count ? flags[i] : 0
716+
let underline = (flag & _ConcatenatedText.underlineFlag) != 0
717+
let strikethrough = (flag & _ConcatenatedText.strikethroughFlag) != 0
718+
var decoration: TextDecoration? = nil
719+
if underline, strikethrough {
720+
decoration = TextDecoration.Underline + TextDecoration.LineThrough
721+
} else if underline {
722+
decoration = TextDecoration.Underline
723+
} else if strikethrough {
724+
decoration = TextDecoration.LineThrough
725+
}
726+
let span = SpanStyle(
727+
color: resolvedColors[i] ?? androidx.compose.ui.graphics.Color.Unspecified,
728+
fontSize: (flag & _ConcatenatedText.hasFontSizeFlag) != 0 ? fontSizes[i].sp : TextUnit.Unspecified,
729+
fontWeight: (flag & _ConcatenatedText.hasFontWeightFlag) != 0 ? FontWeight(fontWeights[i]) : nil,
730+
fontStyle: (flag & _ConcatenatedText.italicFlag) != 0 ? FontStyle.Italic : nil,
731+
fontFamily: (flag & _ConcatenatedText.monospacedFlag) != 0 ? FontFamily.Monospace : nil,
732+
textDecoration: decoration
733+
)
734+
builder.pushStyle(span)
735+
builder.append(strings[i])
736+
builder.pop()
737+
}
738+
}
739+
}
740+
#endif
741+
600742
public enum TextAlignment : Int, Hashable, CaseIterable {
601743
case leading = 0 // For bridging
602744
case center = 1 // For bridging

0 commit comments

Comments
 (0)