diff --git a/android-sample/src/main/java/com/zachklipp/richtext/sample/Demo.kt b/android-sample/src/main/java/com/zachklipp/richtext/sample/Demo.kt index a1607541..976551d9 100644 --- a/android-sample/src/main/java/com/zachklipp/richtext/sample/Demo.kt +++ b/android-sample/src/main/java/com/zachklipp/richtext/sample/Demo.kt @@ -13,6 +13,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.halilibo.richtext.ui.BlockQuote @@ -32,6 +34,13 @@ import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.Table import com.halilibo.richtext.ui.WithStyle import com.halilibo.richtext.ui.material3.RichText +import com.halilibo.richtext.ui.string.LinkDecoration +import com.halilibo.richtext.ui.string.RichTextDecorations +import com.halilibo.richtext.ui.string.RichTextString +import com.halilibo.richtext.ui.string.Text as RichTextText +import com.halilibo.richtext.ui.string.UnderlineStyle +import com.halilibo.richtext.ui.string.richTextString +import com.halilibo.richtext.ui.string.withFormat @Preview(widthDp = 300, heightDp = 1000) @Composable fun RichTextDemoOnWhite() { @@ -61,6 +70,36 @@ import com.halilibo.richtext.ui.material3.RichText Text("Simple paragraph.") Text("Paragraph with\nmultiple lines.") Text("Paragraph with really long line that should be getting wrapped.") + val bodyTextColor = LocalContentColor.current + val dottedLinkDecorations = RichTextDecorations( + linkDecorations = listOf( + LinkDecoration( + matcher = { destination, _ -> destination.contains("dotted") }, + underlineStyle = UnderlineStyle.Dotted(), + linkStyleOverride = { base -> + TextLinkStyles( + style = (base?.style ?: SpanStyle()).copy(color = bodyTextColor), + focusedStyle = base?.focusedStyle?.copy(color = bodyTextColor), + hoveredStyle = base?.hoveredStyle?.copy(color = bodyTextColor), + pressedStyle = base?.pressedStyle?.copy(color = bodyTextColor), + ) + }, + ), + ), + ) + val dottedUnderlineText = richTextString { + append("Dotted underline with wrapping: ") + withFormat(RichTextString.Format.Link("https://example.com/dotted")) { + append( + "This is a long link that should wrap across multiple lines to show the " + + "dotted underline.", + ) + } + } + RichTextText( + text = dottedUnderlineText, + decorations = dottedLinkDecorations, + ) TextPreview() Heading(0, "Lists") diff --git a/android-sample/src/main/java/com/zachklipp/richtext/sample/MarkdownSample.kt b/android-sample/src/main/java/com/zachklipp/richtext/sample/MarkdownSample.kt index f2859888..a0bdbea8 100644 --- a/android-sample/src/main/java/com/zachklipp/richtext/sample/MarkdownSample.kt +++ b/android-sample/src/main/java/com/zachklipp/richtext/sample/MarkdownSample.kt @@ -48,7 +48,10 @@ import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.material3.RichText import com.halilibo.richtext.ui.resolveDefaults import com.halilibo.richtext.ui.string.MarkdownAnimationState +import com.halilibo.richtext.ui.string.LinkDecoration +import com.halilibo.richtext.ui.string.RichTextDecorations import com.halilibo.richtext.ui.string.RichTextRenderOptions +import com.halilibo.richtext.ui.string.UnderlineStyle @Preview @Composable private fun MarkdownSamplePreview() { @@ -125,13 +128,31 @@ import com.halilibo.richtext.ui.string.RichTextRenderOptions val astNode = remember(parser) { parser.parse(sampleMarkdown) } + val richTextDecorations = remember { + RichTextDecorations( + linkDecorations = listOf( + LinkDecoration( + matcher = { destination, _ -> destination.contains("dotted") }, + underlineStyle = UnderlineStyle.Dotted(), + ), + LinkDecoration( + matcher = { destination, _ -> destination.contains("dashed") }, + underlineStyle = UnderlineStyle.Dashed(), + ), + ), + ) + } ProvideToastUriHandler(context) { RichText( style = richTextStyle, modifier = Modifier.padding(8.dp), ) { - BasicMarkdown(astNode, astBlockNodeComposer = HeadingAstBlockNodeComposer) + BasicMarkdown( + astNode = astNode, + richTextDecorations = richTextDecorations, + astBlockNodeComposer = HeadingAstBlockNodeComposer, + ) } } } @@ -150,6 +171,7 @@ val HeadingAstBlockNodeComposer = object : AstBlockNodeComposer { contentOverride: ContentOverride?, inlineContentOverride: InlineContentOverride?, richTextRenderOptions: RichTextRenderOptions, + richTextDecorations: RichTextDecorations, markdownAnimationState: MarkdownAnimationState, visitChildren: @Composable (AstNode) -> Unit ) { @@ -186,6 +208,11 @@ private val sampleMarkdown = """ # Demo Based on [this cheatsheet][cheatsheet] + ## Link underline styles + - [Dotted underline example](https://example.com/dotted) + - [Dashed underline example](https://example.com/dashed) + - [Default underline example](https://example.com/solid) + --- ## Headers diff --git a/richtext-commonmark/src/commonMain/kotlin/com/halilibo/richtext/commonmark/Markdown.kt b/richtext-commonmark/src/commonMain/kotlin/com/halilibo/richtext/commonmark/Markdown.kt index 7fc711e2..7561d07b 100644 --- a/richtext-commonmark/src/commonMain/kotlin/com/halilibo/richtext/commonmark/Markdown.kt +++ b/richtext-commonmark/src/commonMain/kotlin/com/halilibo/richtext/commonmark/Markdown.kt @@ -12,7 +12,7 @@ import com.halilibo.richtext.markdown.ContentOverride import com.halilibo.richtext.markdown.InlineContentOverride import com.halilibo.richtext.markdown.node.AstNode import com.halilibo.richtext.ui.RichTextScope -import com.halilibo.richtext.ui.string.MarkdownAnimationState +import com.halilibo.richtext.ui.string.RichTextDecorations import com.halilibo.richtext.ui.string.RichTextRenderOptions import org.commonmark.node.Node @@ -29,6 +29,7 @@ public fun RichTextScope.Markdown( content: String, markdownParseOptions: CommonMarkdownParseOptions = CommonMarkdownParseOptions.Default, richtextRenderOptions: RichTextRenderOptions = RichTextRenderOptions.Default, + richTextDecorations: RichTextDecorations = RichTextDecorations(), contentOverride: ContentOverride? = null, inlineContentOverride: InlineContentOverride? = null, astBlockNodeComposer: AstBlockNodeComposer? = null @@ -51,6 +52,7 @@ public fun RichTextScope.Markdown( contentOverride = contentOverride, inlineContentOverride = inlineContentOverride, richTextRenderOptions = richtextRenderOptions, + richTextDecorations = richTextDecorations, astBlockNodeComposer = astBlockNodeComposer, ) } @@ -66,6 +68,7 @@ public fun RichTextScope.Markdown( public fun RichTextScope.Markdown( content: Node, richtextRenderOptions: RichTextRenderOptions = RichTextRenderOptions.Default, + richTextDecorations: RichTextDecorations = RichTextDecorations(), contentOverride: ContentOverride? = null, inlineContentOverride: InlineContentOverride? = null, astBlockNodeComposer: AstBlockNodeComposer? = null @@ -76,6 +79,7 @@ public fun RichTextScope.Markdown( contentOverride, inlineContentOverride, richtextRenderOptions, + richTextDecorations, astBlockNodeComposer, ) } diff --git a/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/BasicMarkdown.kt b/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/BasicMarkdown.kt index 2cc068e7..d9e19ba9 100644 --- a/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/BasicMarkdown.kt +++ b/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/BasicMarkdown.kt @@ -37,6 +37,7 @@ import com.halilibo.richtext.ui.ListType.Unordered import com.halilibo.richtext.ui.RichTextScope import com.halilibo.richtext.ui.string.InlineContent import com.halilibo.richtext.ui.string.MarkdownAnimationState +import com.halilibo.richtext.ui.string.RichTextDecorations import com.halilibo.richtext.ui.string.RichTextRenderOptions import com.halilibo.richtext.ui.string.RichTextRenderOptions.Companion import com.halilibo.richtext.ui.string.RichTextString @@ -80,6 +81,7 @@ public fun RichTextScope.BasicMarkdown( contentOverride: ContentOverride? = null, inlineContentOverride: InlineContentOverride? = null, richTextRenderOptions: RichTextRenderOptions = RichTextRenderOptions.Default, + richTextDecorations: RichTextDecorations = RichTextDecorations(), astBlockNodeComposer: AstBlockNodeComposer? = null, ) { RecursiveRenderMarkdownAst( @@ -87,6 +89,7 @@ public fun RichTextScope.BasicMarkdown( contentOverride = contentOverride, inlineContentOverride = inlineContentOverride, richTextRenderOptions = richTextRenderOptions, + richTextDecorations = richTextDecorations, markdownAnimationState = remember { MarkdownAnimationState() }, astNodeComposer = astBlockNodeComposer, ) @@ -116,6 +119,7 @@ public interface AstBlockNodeComposer { contentOverride: ContentOverride?, inlineContentOverride: InlineContentOverride?, richTextRenderOptions: RichTextRenderOptions, + richTextDecorations: RichTextDecorations, markdownAnimationState: MarkdownAnimationState, visitChildren: @Composable (AstNode) -> Unit ) @@ -152,6 +156,7 @@ internal fun RichTextScope.RecursiveRenderMarkdownAst( contentOverride: ContentOverride?, inlineContentOverride: InlineContentOverride?, richTextRenderOptions: RichTextRenderOptions, + richTextDecorations: RichTextDecorations, markdownAnimationState: MarkdownAnimationState, astNodeComposer: AstBlockNodeComposer? ) { @@ -163,6 +168,7 @@ internal fun RichTextScope.RecursiveRenderMarkdownAst( contentOverride, inlineContentOverride, richTextRenderOptions, + richTextDecorations, markdownAnimationState, astNodeComposer, ) @@ -180,6 +186,7 @@ internal fun RichTextScope.RecursiveRenderMarkdownAst( contentOverride, inlineContentOverride, richTextRenderOptions, + richTextDecorations, markdownAnimationState, ) { renderChildren( @@ -187,6 +194,7 @@ internal fun RichTextScope.RecursiveRenderMarkdownAst( contentOverride, inlineContentOverride = inlineContentOverride, richTextRenderOptions = richTextRenderOptions, + richTextDecorations = richTextDecorations, markdownAnimationState = markdownAnimationState, astNodeComposer = astNodeComposer ) @@ -199,6 +207,7 @@ internal fun RichTextScope.RecursiveRenderMarkdownAst( contentOverride, inlineContentOverride = inlineContentOverride, richTextRenderOptions = richTextRenderOptions, + richTextDecorations = richTextDecorations, markdownAnimationState = markdownAnimationState, visitChildren = { renderChildren( @@ -206,6 +215,7 @@ internal fun RichTextScope.RecursiveRenderMarkdownAst( contentOverride, inlineContentOverride = inlineContentOverride, richTextRenderOptions = richTextRenderOptions, + richTextDecorations = richTextDecorations, markdownAnimationState = markdownAnimationState, astNodeComposer = astNodeComposer ) @@ -224,6 +234,7 @@ private val DefaultAstNodeComposer = object : AstBlockNodeComposer { contentOverride: ContentOverride?, inlineContentOverride: InlineContentOverride?, richTextRenderOptions: RichTextRenderOptions, + richTextDecorations: RichTextDecorations, markdownAnimationState: MarkdownAnimationState, visitChildren: @Composable (AstNode) -> Unit ) { @@ -284,6 +295,7 @@ private val DefaultAstNodeComposer = object : AstBlockNodeComposer { astNode, inlineContentOverride, richTextRenderOptions, + richTextDecorations, markdownAnimationState, modifier = Modifier.semantics { heading() }, ) @@ -324,12 +336,19 @@ private val DefaultAstNodeComposer = object : AstBlockNodeComposer { astNode, inlineContentOverride, richTextRenderOptions, + richTextDecorations, markdownAnimationState, ) } is AstTableRoot -> { - RenderTable(astNode, inlineContentOverride, richTextRenderOptions, markdownAnimationState) + RenderTable( + astNode, + inlineContentOverride, + richTextRenderOptions, + richTextDecorations, + markdownAnimationState, + ) } // This should almost never happen. All the possible text // nodes must be under either Heading, Paragraph or CustomNode @@ -371,6 +390,7 @@ internal fun RichTextScope.renderChildren( contentOverride: ContentOverride?, inlineContentOverride: InlineContentOverride?, richTextRenderOptions: RichTextRenderOptions, + richTextDecorations: RichTextDecorations, markdownAnimationState: MarkdownAnimationState, astNodeComposer: AstBlockNodeComposer? ) { @@ -380,6 +400,7 @@ internal fun RichTextScope.renderChildren( contentOverride = contentOverride, inlineContentOverride = inlineContentOverride, richTextRenderOptions = richTextRenderOptions, + richTextDecorations = richTextDecorations, markdownAnimationState = markdownAnimationState, astNodeComposer = astNodeComposer, ) diff --git a/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/MarkdownRichText.kt b/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/MarkdownRichText.kt index 98dd77cc..129cdc19 100644 --- a/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/MarkdownRichText.kt +++ b/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/MarkdownRichText.kt @@ -30,6 +30,7 @@ import com.halilibo.richtext.ui.FormattedList import com.halilibo.richtext.ui.RichTextScope import com.halilibo.richtext.ui.string.InlineContent import com.halilibo.richtext.ui.string.MarkdownAnimationState +import com.halilibo.richtext.ui.string.RichTextDecorations import com.halilibo.richtext.ui.string.RichTextRenderOptions import com.halilibo.richtext.ui.string.RichTextString import com.halilibo.richtext.ui.string.Text @@ -63,6 +64,7 @@ internal fun RichTextScope.MarkdownRichText( astNode: AstNode, inlineContentOverride: InlineContentOverride?, richTextRenderOptions: RichTextRenderOptions, + richTextDecorations: RichTextDecorations, markdownAnimationState: MarkdownAnimationState, modifier: Modifier = Modifier, ) { @@ -77,6 +79,7 @@ internal fun RichTextScope.MarkdownRichText( isLeafText = astNode.isLastInTree(), renderOptions = richTextRenderOptions, sharedAnimationState = markdownAnimationState, + decorations = richTextDecorations, ) } diff --git a/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/RenderTable.kt b/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/RenderTable.kt index 751a4974..8452771a 100644 --- a/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/RenderTable.kt +++ b/richtext-markdown/src/commonMain/kotlin/com/halilibo/richtext/markdown/RenderTable.kt @@ -1,7 +1,6 @@ package com.halilibo.richtext.markdown import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import com.halilibo.richtext.markdown.node.AstNode import com.halilibo.richtext.markdown.node.AstTableBody import com.halilibo.richtext.markdown.node.AstTableCell @@ -10,6 +9,7 @@ import com.halilibo.richtext.markdown.node.AstTableRow import com.halilibo.richtext.ui.RichTextScope import com.halilibo.richtext.ui.Table import com.halilibo.richtext.ui.string.MarkdownAnimationState +import com.halilibo.richtext.ui.string.RichTextDecorations import com.halilibo.richtext.ui.string.RichTextRenderOptions @Composable @@ -17,6 +17,7 @@ internal fun RichTextScope.RenderTable( node: AstNode, inlineContentOverride: InlineContentOverride?, richtextRenderOptions: RichTextRenderOptions, + richTextDecorations: RichTextDecorations, markdownAnimationState: MarkdownAnimationState, ) { Table( @@ -34,6 +35,7 @@ internal fun RichTextScope.RenderTable( tableCell, inlineContentOverride, richtextRenderOptions, + richTextDecorations, markdownAnimationState, ) } @@ -52,11 +54,12 @@ internal fun RichTextScope.RenderTable( tableCell, inlineContentOverride, richtextRenderOptions, + richTextDecorations, markdownAnimationState, ) } } } - } + } } } diff --git a/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/LinkDecorations.kt b/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/LinkDecorations.kt new file mode 100644 index 00000000..18275b42 --- /dev/null +++ b/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/LinkDecorations.kt @@ -0,0 +1,41 @@ +package com.halilibo.richtext.ui.string + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.TextLinkStyles + +/** + * Defines how specific links should be decorated based on their destination. + */ +public data class LinkDecoration( + val matcher: (destination: String, text: String) -> Boolean, + val underlineStyle: UnderlineStyle = UnderlineStyle.Solid, + val linkStyleOverride: ((TextLinkStyles?) -> TextLinkStyles)? = null, +) + +/** + * Collection of decorations to apply when rendering a [RichTextString]. + */ +public data class RichTextDecorations( + val linkDecorations: List = emptyList(), +) + +/** + * The underline style to use for a matched link. + */ +public sealed class UnderlineStyle { + public object Solid : UnderlineStyle() + + public data class Dotted( + val strokeWidth: Dp = 1.dp, + val gap: Dp = 2.dp, + val offset: Dp = 0.dp, + ) : UnderlineStyle() + + public data class Dashed( + val dash: Dp = 6.dp, + val gap: Dp = 4.dp, + val strokeWidth: Dp = 1.dp, + val offset: Dp = 1.dp, + ) : UnderlineStyle() +} diff --git a/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/RichTextString.kt b/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/RichTextString.kt index c4280304..51537f5d 100644 --- a/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/RichTextString.kt +++ b/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/RichTextString.kt @@ -172,7 +172,8 @@ public class RichTextString internal constructor( internal fun toAnnotatedString( style: RichTextStringStyle, - contentColor: Color + contentColor: Color, + decorations: RichTextDecorations = RichTextDecorations(), ): AnnotatedString = buildAnnotatedString { append(taggedString) @@ -182,14 +183,33 @@ public class RichTextString internal constructor( // And apply their actual SpanStyles to the string. tags.forEach { range -> val format = Format.findTag(range.item, formatObjects) ?: return@forEach - format.getAnnotation(style, contentColor) - ?.let { annotation -> - if (annotation is SpanStyle) { - addStyle(annotation, range.start, range.end) - } else if (annotation is LinkAnnotation.Url) { - addLink(annotation, range.start, range.end) + if (format is Format.Link) { + val linkText = taggedString.text.substring(range.start, range.end) + val decoration = decorations.findLinkDecoration(format.destination, linkText) + val linkStyle = decoration?.linkStyleOverride + ?.invoke(style.linkStyle) + ?: style.linkStyle + val resolvedLinkStyle = when (decoration?.underlineStyle) { + null, + UnderlineStyle.Solid -> linkStyle + else -> linkStyle.withoutUnderline() } - } + val linkAnnotation = LinkAnnotation.Url( + url = format.destination, + styles = resolvedLinkStyle, + linkInteractionListener = format.linkInteractionListener, + ) + addLink(linkAnnotation, range.start, range.end) + } else { + format.getAnnotation(style, contentColor) + ?.let { annotation -> + if (annotation is SpanStyle) { + addStyle(annotation, range.start, range.end) + } else if (annotation is LinkAnnotation.Url) { + addLink(annotation, range.start, range.end) + } + } + } } } @@ -205,6 +225,32 @@ public class RichTextString internal constructor( } .toMap() + internal fun decoratedLinkRanges( + decorations: RichTextDecorations, + ): List { + if (decorations.linkDecorations.isEmpty()) return emptyList() + + return taggedString.getStringAnnotations(FormatAnnotationScope, 0, taggedString.length) + .asSequence() + .mapNotNull { range -> + val format = Format.findTag(range.item, formatObjects) + as? Format.Link + ?: return@mapNotNull null + val linkText = taggedString.text.substring(range.start, range.end) + val decoration = decorations.findLinkDecoration(format.destination, linkText) + ?: return@mapNotNull null + if (decoration.underlineStyle is UnderlineStyle.Solid) return@mapNotNull null + DecoratedLinkRange( + start = range.start, + end = range.end, + destination = format.destination, + underlineStyle = decoration.underlineStyle, + linkStyleOverride = decoration.linkStyleOverride, + ) + } + .toList() + } + override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is RichTextString) return false @@ -305,7 +351,7 @@ public class RichTextString internal constructor( public class Link( public val destination: String, - public val linkInteractionListener: LinkInteractionListener? = null + public val linkInteractionListener: LinkInteractionListener? = null, ) : Format() { public override fun getAnnotation( richTextStyle: RichTextStringStyle, @@ -451,3 +497,26 @@ private fun TextLinkStyles.merge(other: TextLinkStyles?): TextLinkStyles { ) } } + +internal data class DecoratedLinkRange( + val start: Int, + val end: Int, + val destination: String, + val underlineStyle: UnderlineStyle, + val linkStyleOverride: ((TextLinkStyles?) -> TextLinkStyles)?, +) + +private fun RichTextDecorations.findLinkDecoration( + destination: String, + text: String, +): LinkDecoration? = linkDecorations.firstOrNull { it.matcher(destination, text) } + +private fun TextLinkStyles?.withoutUnderline(): TextLinkStyles? { + if (this == null) return null + return TextLinkStyles( + style = style?.copy(textDecoration = null), + focusedStyle = focusedStyle?.copy(textDecoration = null), + hoveredStyle = hoveredStyle?.copy(textDecoration = null), + pressedStyle = pressedStyle?.copy(textDecoration = null), + ) +} diff --git a/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/Text.kt b/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/Text.kt index b0bd5dc4..091e5dd6 100644 --- a/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/Text.kt +++ b/richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/Text.kt @@ -16,12 +16,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.LinearGradientShader +import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.Shader import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.layout.layout import androidx.compose.ui.text.AnnotatedString @@ -30,6 +33,8 @@ import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach import com.halilibo.richtext.ui.RichTextScope import com.halilibo.richtext.ui.Text import com.halilibo.richtext.ui.currentContentColor @@ -40,6 +45,7 @@ import com.halilibo.richtext.ui.util.segmentIntoPhrases import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.math.pow +import kotlin.math.roundToInt import kotlin.time.Duration.Companion.milliseconds /** @@ -56,16 +62,54 @@ public fun RichTextScope.Text( isLeafText: Boolean = true, renderOptions: RichTextRenderOptions = RichTextRenderOptions(), sharedAnimationState: MarkdownAnimationState = remember { MarkdownAnimationState() }, + decorations: RichTextDecorations = RichTextDecorations(), overflow: TextOverflow = TextOverflow.Clip, - maxLines: Int = Int.MAX_VALUE + maxLines: Int = Int.MAX_VALUE, ) { val style = currentRichTextStyle.stringStyle val contentColor = currentContentColor - val annotated = remember(text, style, contentColor) { - val resolvedStyle = (style ?: RichTextStringStyle.Default).resolveDefaults() - text.toAnnotatedString(resolvedStyle, contentColor) + val resolvedStyle = remember(style) { + (style ?: RichTextStringStyle.Default).resolveDefaults() + } + val annotated = remember(text, resolvedStyle, contentColor, decorations) { + text.toAnnotatedString(resolvedStyle, contentColor, decorations) } val inlineContents = remember(text) { text.getInlineContents() } + val decoratedLinkRanges = remember(text, decorations) { + text.decoratedLinkRanges(decorations) + } + var textLayoutResult by remember { mutableStateOf(null) } + val underlineSpecs = remember(decoratedLinkRanges, resolvedStyle, contentColor) { + decoratedLinkRanges.mapNotNull { range -> + val linkStyle = range.linkStyleOverride + ?.invoke(resolvedStyle.linkStyle) + ?: resolvedStyle.linkStyle + val underlineColor = linkStyle?.style?.color + ?.takeIf { it.isSpecified } + ?: contentColor + UnderlineSpec( + range = range, + color = underlineColor, + ) + } + } + val underlineModifier = if (underlineSpecs.isNotEmpty()) { + Modifier.drawWithContent { + drawContent() + val layoutResult = textLayoutResult ?: return@drawWithContent + underlineSpecs.fastForEach { spec -> + drawUnderline( + layoutResult = layoutResult, + start = spec.range.start, + end = spec.range.end, + underlineStyle = spec.range.underlineStyle, + color = spec.color, + ) + } + } + } else { + Modifier + } val animatedText = if (renderOptions.animate && inlineContents.isEmpty()) { rememberAnimatedText( @@ -82,10 +126,14 @@ public fun RichTextScope.Text( if (inlineContents.isEmpty()) { Text( text = animatedText, - onTextLayout = onTextLayout, + onTextLayout = { layoutResult -> + textLayoutResult = layoutResult + onTextLayout(layoutResult) + }, softWrap = softWrap, overflow = overflow, - maxLines = maxLines + maxLines = maxLines, + modifier = modifier.then(underlineModifier), ) } else { val inlineTextConstraints = remember { mutableStateOf(Constraints()) } @@ -96,12 +144,15 @@ public fun RichTextScope.Text( Text( text = animatedText, - onTextLayout = onTextLayout, + onTextLayout = { layoutResult -> + textLayoutResult = layoutResult + onTextLayout(layoutResult) + }, inlineContent = inlineTextContents, softWrap = softWrap, overflow = overflow, maxLines = maxLines, - modifier = modifier.layout { measurable, constraints -> + modifier = modifier.then(underlineModifier).layout { measurable, constraints -> // Prepares the custom constraints InlineTextContents before they get measured. inlineTextConstraints.value = constraints.copy(minWidth = 0, minHeight = 0) val placeable = measurable.measure(constraints) @@ -113,6 +164,81 @@ public fun RichTextScope.Text( } } +private data class UnderlineSpec( + val range: DecoratedLinkRange, + val color: Color, +) + +private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawUnderline( + layoutResult: TextLayoutResult, + start: Int, + end: Int, + underlineStyle: UnderlineStyle, + color: Color, +) { + val textLength = layoutResult.layoutInput.text.text.length + val clampedStart = start.coerceIn(0, textLength) + val clampedEnd = end.coerceIn(0, textLength) + if (clampedStart >= clampedEnd) return + + val strokeWidthPx: Float + val offsetPx: Float + val pathEffect: PathEffect? + val cap: StrokeCap + + with(this) { + when (underlineStyle) { + is UnderlineStyle.Solid -> { + strokeWidthPx = 1.dp.toPx() + offsetPx = 0.dp.toPx() + pathEffect = null + cap = StrokeCap.Butt + } + is UnderlineStyle.Dotted -> { + strokeWidthPx = underlineStyle.strokeWidth.toPx() + offsetPx = underlineStyle.offset.toPx() + val gapPx = underlineStyle.gap.toPx() + val dotPx = strokeWidthPx.coerceAtLeast(1f) + pathEffect = PathEffect.dashPathEffect(floatArrayOf(dotPx, gapPx), 0f) + cap = StrokeCap.Round + } + is UnderlineStyle.Dashed -> { + strokeWidthPx = underlineStyle.strokeWidth.toPx() + offsetPx = underlineStyle.offset.toPx() + val dashPx = underlineStyle.dash.toPx() + val gapPx = underlineStyle.gap.toPx() + pathEffect = PathEffect.dashPathEffect(floatArrayOf(dashPx, gapPx), 0f) + cap = StrokeCap.Butt + } + } + } + + val startLine = layoutResult.getLineForOffset(clampedStart) + val endLine = layoutResult.getLineForOffset(clampedEnd - 1) + for (line in startLine..endLine) { + val lineStart = layoutResult.getLineStart(line) + val lineEnd = layoutResult.getLineEnd(line, visibleEnd = true) + val segmentStart = maxOf(clampedStart, lineStart) + val segmentEnd = minOf(clampedEnd, lineEnd) + if (segmentEnd <= segmentStart) continue + + val startBox = layoutResult.getBoundingBox(segmentStart) + val endBox = layoutResult.getBoundingBox(segmentEnd - 1) + val y = (maxOf(startBox.bottom, endBox.bottom) + offsetPx).roundToInt().toFloat() + val xStart = startBox.left.roundToInt().toFloat() + val xEnd = endBox.right.roundToInt().toFloat() + + drawLine( + color = color, + start = Offset(xStart, y), + end = Offset(xEnd, y), + strokeWidth = strokeWidthPx, + cap = cap, + pathEffect = pathEffect, + ) + } +} + @Stable public class MarkdownAnimationState {