From fec8883a81d1cf8bc26569fd4731a19f4c35c56c Mon Sep 17 00:00:00 2001 From: Dan Fabulich Date: Sun, 17 May 2026 17:55:25 -0700 Subject: [PATCH] Render real attributed strings; support `+` operator on `Text` --- .../SkipUI/Text/AttributedStringCompose.swift | 124 ++++++++++++++ .../SkipUI/Text/AttributedStringSkipUI.swift | 90 ++++++++++ Sources/SkipUI/SkipUI/Text/Text.swift | 160 +++++++++++++++++- 3 files changed, 370 insertions(+), 4 deletions(-) create mode 100644 Sources/SkipUI/SkipUI/Text/AttributedStringCompose.swift create mode 100644 Sources/SkipUI/SkipUI/Text/AttributedStringSkipUI.swift diff --git a/Sources/SkipUI/SkipUI/Text/AttributedStringCompose.swift b/Sources/SkipUI/SkipUI/Text/AttributedStringCompose.swift new file mode 100644 index 00000000..1d81b45e --- /dev/null +++ b/Sources/SkipUI/SkipUI/Text/AttributedStringCompose.swift @@ -0,0 +1,124 @@ +// Copyright 2023–2026 Skip +// SPDX-License-Identifier: MPL-2.0 +#if SKIP +import Foundation +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.UrlAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration + +enum AttributedStringCompose { + // SKIP INSERT: @OptIn(ExperimentalTextApi::class) + @Composable static func toAnnotatedString( + _ attrStr: AttributedString, + baseStyle: androidx.compose.ui.text.TextStyle, + baseColor: androidx.compose.ui.graphics.Color?, + linkColor: androidx.compose.ui.graphics.Color, + textDecoration: TextDecoration?, + isUppercased: Bool, + isLowercased: Bool, + isRedacted: Bool + ) -> AnnotatedString { + if attrStr.characters.isEmpty { + return AnnotatedString("") + } + return buildAnnotatedString { + for run in attrStr.runs { + var text = attrStr.substring(in: run.utf16Range) + if isUppercased { + text = text.uppercased() + } else if isLowercased { + text = text.lowercased() + } + let spanStyle = composeSpanStyle( + run: run, + baseStyle: baseStyle, + baseColor: baseColor, + linkColor: linkColor, + textDecoration: textDecoration, + isRedacted: isRedacted + ) + let link = run.attributes.link + if let url = link { + if isRedacted { + pushStyle(spanStyle.copy(background: linkColor)) + } else { + pushStyle(spanStyle) + } + pushUrlAnnotation(UrlAnnotation(url.absoluteString)) + append(text) + pop() + pop() + } else { + pushStyle(spanStyle) + append(text) + pop() + } + } + } + } + + @Composable private static func composeSpanStyle( + run: AttributedString.Run, + baseStyle: androidx.compose.ui.text.TextStyle, + baseColor: androidx.compose.ui.graphics.Color?, + linkColor: androidx.compose.ui.graphics.Color, + textDecoration: TextDecoration?, + isRedacted: Bool + ) -> SpanStyle { + var style = baseStyle + if let font = run.attributes.font { + style = style.merge(font.asComposeTextStyle()) + } + var spanStyle = style.toSpanStyle() + if let color = run.attributes.foregroundColor?.colorImpl() { + spanStyle = spanStyle.copy(color: color) + } else if let baseColor, baseColor != androidx.compose.ui.graphics.Color.Unspecified { + spanStyle = spanStyle.copy(color: baseColor) + } + if let background = run.attributes.backgroundColor?.colorImpl() { + spanStyle = spanStyle.copy(background: background) + } + var decorations: TextDecoration? = textDecoration + if run.attributes.markdownBold { + spanStyle = spanStyle.copy(fontWeight: FontWeight.Bold) + } + if run.attributes.markdownItalic { + spanStyle = spanStyle.copy(fontStyle: FontStyle.Italic) + } + if run.attributes.markdownCode { + spanStyle = spanStyle.copy(fontFamily: FontFamily.Monospace) + } + if run.attributes.markdownStrikethrough { + decorations = combineDecoration(decorations, TextDecoration.LineThrough) + } + if run.attributes.underlineStyle != nil { + decorations = combineDecoration(decorations, TextDecoration.Underline) + } + if run.attributes.strikethroughStyle != nil { + decorations = combineDecoration(decorations, TextDecoration.LineThrough) + } + if let decorations { + spanStyle = spanStyle.copy(textDecoration: decorations) + } + if run.attributes.link != nil && !isRedacted { + spanStyle = spanStyle.copy(color: linkColor) + } + return spanStyle + } + + private static func combineDecoration(_ existing: TextDecoration?, _ addition: TextDecoration) -> TextDecoration { + if let existing { + return existing + addition + } + return addition + } +} + +#endif diff --git a/Sources/SkipUI/SkipUI/Text/AttributedStringSkipUI.swift b/Sources/SkipUI/SkipUI/Text/AttributedStringSkipUI.swift new file mode 100644 index 00000000..2d5d1809 --- /dev/null +++ b/Sources/SkipUI/SkipUI/Text/AttributedStringSkipUI.swift @@ -0,0 +1,90 @@ +// Copyright 2023–2026 Skip +// SPDX-License-Identifier: MPL-2.0 +#if SKIP +import Foundation + +/// SkipUI font attribute for attributed strings. +public enum SkipUIFontAttribute : AttributedStringKey { + public typealias Value = Font + public static let name = "Font" +} + +/// SkipUI foreground color attribute for attributed strings. +public enum SkipUIForegroundColorAttribute : AttributedStringKey { + public typealias Value = Color + public static let name = "ForegroundColor" +} + +/// SkipUI background color attribute for attributed strings. +public enum SkipUIBackgroundColorAttribute : AttributedStringKey { + public typealias Value = Color + public static let name = "BackgroundColor" +} + +/// SkipUI underline style attribute for attributed strings. +public enum SkipUIUnderlineStyleAttribute : AttributedStringKey { + public typealias Value = Text.LineStyle? + public static let name = "UnderlineStyle" +} + +/// SkipUI strikethrough style attribute for attributed strings. +public enum SkipUIStrikethroughStyleAttribute : AttributedStringKey { + public typealias Value = Text.LineStyle? + public static let name = "StrikethroughStyle" +} + +extension AttributeContainer { + public var font: Font? { + get { value(key: SkipUIFontAttribute.name) as? Font } + set { setValue(newValue, key: SkipUIFontAttribute.name) } + } + + public var foregroundColor: Color? { + get { value(key: SkipUIForegroundColorAttribute.name) as? Color } + set { setValue(newValue, key: SkipUIForegroundColorAttribute.name) } + } + + public var backgroundColor: Color? { + get { value(key: SkipUIBackgroundColorAttribute.name) as? Color } + set { setValue(newValue, key: SkipUIBackgroundColorAttribute.name) } + } + + public var underlineStyle: Text.LineStyle? { + get { value(key: SkipUIUnderlineStyleAttribute.name) as? Text.LineStyle } + set { setValue(newValue, key: SkipUIUnderlineStyleAttribute.name) } + } + + public var strikethroughStyle: Text.LineStyle? { + get { value(key: SkipUIStrikethroughStyleAttribute.name) as? Text.LineStyle } + set { setValue(newValue, key: SkipUIStrikethroughStyleAttribute.name) } + } +} + +extension AttributedString { + public var font: Font? { + get { attributeValue(key: SkipUIFontAttribute.name) as? Font } + mutating set { setAttributeValue(newValue, key: SkipUIFontAttribute.name) } + } + + public var foregroundColor: Color? { + get { attributeValue(key: SkipUIForegroundColorAttribute.name) as? Color } + mutating set { setAttributeValue(newValue, key: SkipUIForegroundColorAttribute.name) } + } + + public var backgroundColor: Color? { + get { attributeValue(key: SkipUIBackgroundColorAttribute.name) as? Color } + mutating set { setAttributeValue(newValue, key: SkipUIBackgroundColorAttribute.name) } + } + + public var underlineStyle: Text.LineStyle? { + get { attributeValue(key: SkipUIUnderlineStyleAttribute.name) as? Text.LineStyle } + mutating set { setAttributeValue(newValue, key: SkipUIUnderlineStyleAttribute.name) } + } + + public var strikethroughStyle: Text.LineStyle? { + get { attributeValue(key: SkipUIStrikethroughStyleAttribute.name) as? Text.LineStyle } + mutating set { setAttributeValue(newValue, key: SkipUIStrikethroughStyleAttribute.name) } + } +} + +#endif diff --git a/Sources/SkipUI/SkipUI/Text/Text.swift b/Sources/SkipUI/SkipUI/Text/Text.swift index afc18e42..9b97b6e5 100644 --- a/Sources/SkipUI/SkipUI/Text/Text.swift +++ b/Sources/SkipUI/SkipUI/Text/Text.swift @@ -49,7 +49,7 @@ import struct CoreGraphics.CGFloat // SKIP @bridge public struct Text: View, Renderable, Equatable { - private let textView: _Text + internal let textView: _Text private let modifiedView: any View // SKIP @bridge @@ -345,9 +345,18 @@ public struct Text: View, Renderable, Equatable { public static let writingDirectionBased = AlignmentStrategy() public static let `default` = Text.AlignmentStrategy() } + + #if SKIP + // SKIP DECLARE: operator fun plus(other: Text): Text + public func plus(other: Text) -> Text { + let combined = _Text.concatenating(textView, other.textView) + return Text(textView: combined, modifiedView: combined) + } + #endif } struct _Text: View, Renderable, Equatable { + let parts: [_Text]? let verbatim: String? let attributedString: AttributedString? let key: LocalizedStringKey? @@ -355,7 +364,8 @@ struct _Text: View, Renderable, Equatable { let locale: Locale? let bundle: Bundle? - init(verbatim: String? = nil, attributedString: AttributedString? = nil, key: LocalizedStringKey? = nil, tableName: String? = nil, locale: Locale? = nil, bundle: Bundle? = nil) { + init(verbatim: String? = nil, attributedString: AttributedString? = nil, key: LocalizedStringKey? = nil, tableName: String? = nil, locale: Locale? = nil, bundle: Bundle? = nil, parts: [_Text]? = nil) { + self.parts = parts self.verbatim = verbatim self.attributedString = attributedString self.key = key @@ -364,6 +374,21 @@ struct _Text: View, Renderable, Equatable { self.bundle = bundle } + static func concatenating(_ lhs: _Text, _ rhs: _Text) -> _Text { + var combined: [_Text] = [] + if let parts = lhs.parts { + combined.append(contentsOf: parts) + } else { + combined.append(lhs) + } + if let parts = rhs.parts { + combined.append(contentsOf: parts) + } else { + combined.append(rhs) + } + return _Text(parts: combined) + } + #if SKIP @Composable func localizedTextString() -> String { let (locfmt, _, interpolations) = localizedTextInfo() @@ -375,8 +400,15 @@ struct _Text: View, Renderable, Equatable { } @Composable private func localizedTextInfo() -> (String, MarkdownNode?, kotlin.collections.List?) { + if let parts { + var text = "" + for part in parts { + text += part.localizedTextString() + } + return (text, nil, nil) + } if let verbatim { return (verbatim, nil, nil) } - if let attributedString { return (attributedString.string, attributedString.markdownNode, nil) } + if let attributedString { return (attributedString.string, nil, nil) } guard let key else { return ("", nil, nil) } // localize and Kotlin-ize the format string. the string is cached by the bundle, and we @@ -408,7 +440,95 @@ struct _Text: View, Renderable, Equatable { modifier = modifier.applyHStackTextBaselineAlignment(EnvironmentValues.shared._horizontalStackVerticalAlignmentKey) } var options: Material3TextOptions - if let locnode { + if let parts { + let layoutResult = remember { mutableStateOf(nil) } + let isPlaceholder = redaction.contains(RedactionReasons.placeholder) + var linkColor = EnvironmentValues.shared._tint?.colorImpl() ?? Color.accentColor.colorImpl() + if isPlaceholder { + linkColor = linkColor.copy(alpha: linkColor.alpha * Float(Color.placeholderOpacity)) + } + let annotatedText = composeConcatenatedParts(parts: parts, baseStyle: animatable.value, baseColor: styleInfo.color, linkColor: linkColor, textDecoration: textDecoration, isUppercased: styleInfo.isUppercased, isLowercased: styleInfo.isLowercased, isRedacted: isPlaceholder) + let links = annotatedText.getUrlAnnotations(start: 0, end: annotatedText.length) + if !links.isEmpty() { + let currentText = rememberUpdatedState(annotatedText) + let currentHandler = rememberUpdatedState(EnvironmentValues.shared.openURL) + let currentIsEnabled = rememberUpdatedState(EnvironmentValues.shared.isEnabled) + modifier = modifier.pointerInput(true) { + let slop = viewConfiguration.touchSlop + let timeout = viewConfiguration.longPressTimeoutMillis + awaitEachGesture { + let downEvent = awaitPointerEvent(pass: PointerEventPass.Initial) + guard let down = downEvent.changes.firstOrNull({ $0.pressed }) else { return } + let start = down.position + let upPosition: Offset? = withTimeoutOrNull(timeout) { + while true { + let event = awaitPointerEvent(pass: PointerEventPass.Initial) + guard let change = event.changes.firstOrNull() else { return nil } + if abs(change.position.x - start.x) > slop || abs(change.position.y - start.y) > slop { + return nil + } + if !change.pressed { + return change.position + } + } + return nil + } + guard let upPosition else { return } + if currentIsEnabled.value, + let offset = layoutResult.value?.getOffsetForPosition(upPosition), + let urlString = currentText.value.getUrlAnnotations(offset, offset).firstOrNull()?.item.url, + let url = URL(string: urlString) { + currentHandler.value.invoke(url) + } + } + } + } + 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: { layoutResult.value = $0 }) + } else if let attributedString { + let layoutResult = remember { mutableStateOf(nil) } + let isPlaceholder = redaction.contains(RedactionReasons.placeholder) + var linkColor = EnvironmentValues.shared._tint?.colorImpl() ?? Color.accentColor.colorImpl() + if isPlaceholder { + linkColor = linkColor.copy(alpha: linkColor.alpha * Float(Color.placeholderOpacity)) + } + let annotatedText = AttributedStringCompose.toAnnotatedString(attributedString, baseStyle: animatable.value, baseColor: styleInfo.color, linkColor: linkColor, textDecoration: textDecoration, isUppercased: styleInfo.isUppercased, isLowercased: styleInfo.isLowercased, isRedacted: isPlaceholder) + let links = annotatedText.getUrlAnnotations(start: 0, end: annotatedText.length) + if !links.isEmpty() { + let currentText = rememberUpdatedState(annotatedText) + let currentHandler = rememberUpdatedState(EnvironmentValues.shared.openURL) + let currentIsEnabled = rememberUpdatedState(EnvironmentValues.shared.isEnabled) + modifier = modifier.pointerInput(true) { + let slop = viewConfiguration.touchSlop + let timeout = viewConfiguration.longPressTimeoutMillis + awaitEachGesture { + let downEvent = awaitPointerEvent(pass: PointerEventPass.Initial) + guard let down = downEvent.changes.firstOrNull({ $0.pressed }) else { return } + let start = down.position + let upPosition: Offset? = withTimeoutOrNull(timeout) { + while true { + let event = awaitPointerEvent(pass: PointerEventPass.Initial) + guard let change = event.changes.firstOrNull() else { return nil } + if abs(change.position.x - start.x) > slop || abs(change.position.y - start.y) > slop { + return nil + } + if !change.pressed { + return change.position + } + } + return nil + } + guard let upPosition else { return } + if currentIsEnabled.value, + let offset = layoutResult.value?.getOffsetForPosition(upPosition), + let urlString = currentText.value.getUrlAnnotations(offset, offset).firstOrNull()?.item.url, + let url = URL(string: urlString) { + currentHandler.value.invoke(url) + } + } + } + } + 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: { layoutResult.value = $0 }) + } else if let locnode { let layoutResult = remember { mutableStateOf(nil) } let isPlaceholder = redaction.contains(RedactionReasons.placeholder) var linkColor = EnvironmentValues.shared._tint?.colorImpl() ?? Color.accentColor.colorImpl() @@ -522,6 +642,38 @@ struct _Text: View, Renderable, Equatable { } } + // SKIP INSERT: @OptIn(ExperimentalTextApi::class) + @Composable private func composeConcatenatedParts(parts: [_Text], baseStyle: androidx.compose.ui.text.TextStyle, baseColor: androidx.compose.ui.graphics.Color?, linkColor: androidx.compose.ui.graphics.Color, textDecoration: TextDecoration?, isUppercased: Bool, isLowercased: Bool, isRedacted: Bool) -> AnnotatedString { + return buildAnnotatedString { + for part in parts { + append(part.composeAnnotatedText(baseStyle: baseStyle, baseColor: baseColor, linkColor: linkColor, textDecoration: textDecoration, isUppercased: isUppercased, isLowercased: isLowercased, isRedacted: isRedacted)) + } + } + } + + // SKIP INSERT: @OptIn(ExperimentalTextApi::class) + @Composable private func composeAnnotatedText(baseStyle: androidx.compose.ui.text.TextStyle, baseColor: androidx.compose.ui.graphics.Color?, linkColor: androidx.compose.ui.graphics.Color, textDecoration: TextDecoration?, isUppercased: Bool, isLowercased: Bool, isRedacted: Bool) -> AnnotatedString { + if let attributedString { + return AttributedStringCompose.toAnnotatedString(attributedString, baseStyle: baseStyle, baseColor: baseColor, linkColor: linkColor, textDecoration: textDecoration, isUppercased: isUppercased, isLowercased: isLowercased, isRedacted: isRedacted) + } + let (locfmt, locnode, interpolations) = localizedTextInfo() + if let locnode { + return annotatedString(markdown: locnode, interpolations: interpolations, linkColor: linkColor, isUppercased: isUppercased, isLowercased: isLowercased, isRedacted: isRedacted) + } + var text: String + if let interpolations { + text = locfmt.format(*interpolations.toTypedArray()) + } else { + text = locfmt + } + if isUppercased { + text = text.uppercased() + } else if isLowercased { + text = text.lowercased() + } + return AnnotatedString(text) + } + private func annotatedString(markdown: MarkdownNode, interpolations: kotlin.collections.List?, linkColor: androidx.compose.ui.graphics.Color, isUppercased: Bool, isLowercased: Bool, isRedacted: Bool) -> AnnotatedString { return buildAnnotatedString { append(markdown: markdown, to: self, interpolations: interpolations, linkColor: linkColor, isUppercased: isUppercased, isLowercased: isLowercased, isRedacted: isRedacted)