From 86377895ef158482e65885982906a2ca7d787b9a Mon Sep 17 00:00:00 2001 From: Ben Dodson Date: Tue, 20 Jan 2026 22:18:11 -0800 Subject: [PATCH 1/3] Dotted and dashed underline support for annotated links --- .../com/zachklipp/richtext/sample/Demo.kt | 28 ++++ .../richtext/sample/MarkdownSample.kt | 29 +++- .../halilibo/richtext/commonmark/Markdown.kt | 6 +- .../richtext/markdown/BasicMarkdown.kt | 23 ++- .../richtext/markdown/MarkdownRichText.kt | 3 + .../halilibo/richtext/markdown/RenderTable.kt | 7 +- .../richtext/ui/string/LinkDecorations.kt | 41 +++++ .../richtext/ui/string/RichTextString.kt | 84 +++++++++- .../com/halilibo/richtext/ui/string/Text.kt | 144 +++++++++++++++++- 9 files changed, 344 insertions(+), 21 deletions(-) create mode 100644 richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/LinkDecorations.kt 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..467106db 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 @@ -32,6 +32,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 +68,27 @@ 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 dottedLinkDecorations = RichTextDecorations( + linkDecorations = listOf( + LinkDecoration( + matcher = { destination -> destination.contains("dotted") }, + underlineStyle = UnderlineStyle.Dotted(), + ), + ), + ) + 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..c52d4230 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..88334bdd --- /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: (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..53641b9f 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,35 @@ 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) + when (format) { + is Format.Link -> { + val decoration = decorations.findLinkDecoration(format.destination) + 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 +227,31 @@ 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 decoration = decorations.findLinkDecoration(format.destination) + ?: 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 +352,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 +498,24 @@ 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): LinkDecoration? = + linkDecorations.firstOrNull { it.matcher(destination) } + +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..71ee9bb5 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,20 +16,25 @@ 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.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle 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 com.halilibo.richtext.ui.RichTextScope import com.halilibo.richtext.ui.Text import com.halilibo.richtext.ui.currentContentColor @@ -56,16 +61,56 @@ 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 density = LocalDensity.current + 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.forEach { spec -> + drawUnderline( + layoutResult = layoutResult, + start = spec.range.start, + end = spec.range.end, + underlineStyle = spec.range.underlineStyle, + color = spec.color, + density = density, + ) + } + } + } else { + Modifier + } val animatedText = if (renderOptions.animate && inlineContents.isEmpty()) { rememberAnimatedText( @@ -82,10 +127,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 +145,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 +165,82 @@ 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, + density: androidx.compose.ui.unit.Density, +) { + 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(density) { + 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 + val xStart = startBox.left + val xEnd = endBox.right + + drawLine( + color = color, + start = Offset(xStart, y), + end = Offset(xEnd, y), + strokeWidth = strokeWidthPx, + cap = cap, + pathEffect = pathEffect, + ) + } +} + @Stable public class MarkdownAnimationState { From c0a4a56fb20d91879dac8e23edc017173f4a65f4 Mon Sep 17 00:00:00 2001 From: Ben Dodson Date: Tue, 20 Jan 2026 22:33:26 -0800 Subject: [PATCH 2/3] style --- .../main/java/com/zachklipp/richtext/sample/Demo.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 467106db..017d076f 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 @@ -68,11 +70,20 @@ import com.halilibo.richtext.ui.string.withFormat 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), + ) + }, ), ), ) From 492637f9f25b81dec270be5bafc377a25a075e78 Mon Sep 17 00:00:00 2001 From: Ben Dodson Date: Wed, 21 Jan 2026 08:13:22 -0800 Subject: [PATCH 3/3] feedback --- .../com/zachklipp/richtext/sample/Demo.kt | 2 +- .../richtext/sample/MarkdownSample.kt | 4 +-- .../richtext/ui/string/LinkDecorations.kt | 2 +- .../richtext/ui/string/RichTextString.kt | 33 ++++++++++--------- .../com/halilibo/richtext/ui/string/Text.kt | 16 ++++----- 5 files changed, 28 insertions(+), 29 deletions(-) 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 017d076f..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 @@ -74,7 +74,7 @@ import com.halilibo.richtext.ui.string.withFormat val dottedLinkDecorations = RichTextDecorations( linkDecorations = listOf( LinkDecoration( - matcher = { destination -> destination.contains("dotted") }, + matcher = { destination, _ -> destination.contains("dotted") }, underlineStyle = UnderlineStyle.Dotted(), linkStyleOverride = { base -> TextLinkStyles( 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 c52d4230..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 @@ -132,11 +132,11 @@ import com.halilibo.richtext.ui.string.UnderlineStyle RichTextDecorations( linkDecorations = listOf( LinkDecoration( - matcher = { destination -> destination.contains("dotted") }, + matcher = { destination, _ -> destination.contains("dotted") }, underlineStyle = UnderlineStyle.Dotted(), ), LinkDecoration( - matcher = { destination -> destination.contains("dashed") }, + matcher = { destination, _ -> destination.contains("dashed") }, underlineStyle = UnderlineStyle.Dashed(), ), ), 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 index 88334bdd..18275b42 100644 --- 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 @@ -8,7 +8,7 @@ import androidx.compose.ui.text.TextLinkStyles * Defines how specific links should be decorated based on their destination. */ public data class LinkDecoration( - val matcher: (String) -> Boolean, + val matcher: (destination: String, text: String) -> Boolean, val underlineStyle: UnderlineStyle = UnderlineStyle.Solid, val linkStyleOverride: ((TextLinkStyles?) -> TextLinkStyles)? = null, ) 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 53641b9f..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 @@ -183,9 +183,9 @@ 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 - when (format) { - is Format.Link -> { - val decoration = decorations.findLinkDecoration(format.destination) + 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 @@ -200,17 +200,15 @@ public class RichTextString internal constructor( 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) - } + } 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) } - } + } } } } @@ -238,7 +236,8 @@ public class RichTextString internal constructor( val format = Format.findTag(range.item, formatObjects) as? Format.Link ?: return@mapNotNull null - val decoration = decorations.findLinkDecoration(format.destination) + 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( @@ -507,8 +506,10 @@ internal data class DecoratedLinkRange( val linkStyleOverride: ((TextLinkStyles?) -> TextLinkStyles)?, ) -private fun RichTextDecorations.findLinkDecoration(destination: String): LinkDecoration? = - linkDecorations.firstOrNull { it.matcher(destination) } +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 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 71ee9bb5..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 @@ -27,7 +27,6 @@ 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.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult @@ -35,6 +34,7 @@ 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 @@ -45,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 /** @@ -67,7 +68,6 @@ public fun RichTextScope.Text( ) { val style = currentRichTextStyle.stringStyle val contentColor = currentContentColor - val density = LocalDensity.current val resolvedStyle = remember(style) { (style ?: RichTextStringStyle.Default).resolveDefaults() } @@ -97,14 +97,13 @@ public fun RichTextScope.Text( Modifier.drawWithContent { drawContent() val layoutResult = textLayoutResult ?: return@drawWithContent - underlineSpecs.forEach { spec -> + underlineSpecs.fastForEach { spec -> drawUnderline( layoutResult = layoutResult, start = spec.range.start, end = spec.range.end, underlineStyle = spec.range.underlineStyle, color = spec.color, - density = density, ) } } @@ -176,7 +175,6 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawUnderline( end: Int, underlineStyle: UnderlineStyle, color: Color, - density: androidx.compose.ui.unit.Density, ) { val textLength = layoutResult.layoutInput.text.text.length val clampedStart = start.coerceIn(0, textLength) @@ -188,7 +186,7 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawUnderline( val pathEffect: PathEffect? val cap: StrokeCap - with(density) { + with(this) { when (underlineStyle) { is UnderlineStyle.Solid -> { strokeWidthPx = 1.dp.toPx() @@ -226,9 +224,9 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawUnderline( val startBox = layoutResult.getBoundingBox(segmentStart) val endBox = layoutResult.getBoundingBox(segmentEnd - 1) - val y = maxOf(startBox.bottom, endBox.bottom) + offsetPx - val xStart = startBox.left - val xEnd = endBox.right + 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,