Skip to content

Commit c0d4557

Browse files
Merge pull request #50 from openai/bjd/link-icons
Add leading and trailing icon link decorations
2 parents 1048d0d + 07609fc commit c0d4557

File tree

5 files changed

+322
-7
lines changed

5 files changed

+322
-7
lines changed

android-sample/src/main/java/com/zachklipp/richtext/sample/MarkdownSample.kt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@ import androidx.compose.foundation.verticalScroll
1414
import androidx.compose.material3.Card
1515
import androidx.compose.material3.CardDefaults
1616
import androidx.compose.material3.Checkbox
17+
import androidx.compose.material3.LocalContentColor
1718
import androidx.compose.material3.Surface
1819
import androidx.compose.material3.Text
1920
import androidx.compose.material3.darkColorScheme
2021
import androidx.compose.material3.lightColorScheme
22+
import androidx.compose.material.icons.Icons
23+
import androidx.compose.material.icons.filled.ArrowForward
2124
import androidx.compose.runtime.Composable
2225
import androidx.compose.runtime.CompositionLocalProvider
2326
import androidx.compose.runtime.LaunchedEffect
@@ -33,6 +36,7 @@ import androidx.compose.ui.tooling.preview.Preview
3336
import androidx.compose.ui.unit.LayoutDirection
3437
import androidx.compose.ui.unit.dp
3538
import androidx.compose.ui.unit.sp
39+
import androidx.compose.ui.graphics.vector.rememberVectorPainter
3640
import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions
3741
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
3842
import com.halilibo.richtext.markdown.AstBlockNodeComposer
@@ -47,6 +51,8 @@ import com.halilibo.richtext.ui.RichTextScope
4751
import com.halilibo.richtext.ui.RichTextStyle
4852
import com.halilibo.richtext.ui.material3.RichText
4953
import com.halilibo.richtext.ui.resolveDefaults
54+
import com.halilibo.richtext.ui.string.InlineIconSpec
55+
import com.halilibo.richtext.ui.string.LinkInlineContent
5056
import com.halilibo.richtext.ui.string.MarkdownAnimationState
5157
import com.halilibo.richtext.ui.string.LinkDecoration
5258
import com.halilibo.richtext.ui.string.RichTextDecorations
@@ -128,12 +134,21 @@ import com.halilibo.richtext.ui.string.UnderlineStyle
128134
val astNode = remember(parser) {
129135
parser.parse(sampleMarkdown)
130136
}
131-
val richTextDecorations = remember {
137+
val arrowPainter = rememberVectorPainter(Icons.Default.ArrowForward)
138+
val linkTint = LocalContentColor.current
139+
val richTextDecorations = remember(arrowPainter, linkTint) {
132140
RichTextDecorations(
133141
linkDecorations = listOf(
134142
LinkDecoration(
135143
matcher = { destination, _ -> destination.contains("dotted") },
136144
underlineStyle = UnderlineStyle.Dotted(),
145+
inlineContent = LinkInlineContent(
146+
trailing = InlineIconSpec.Painter(
147+
painter = arrowPainter,
148+
tint = linkTint,
149+
contentDescription = null,
150+
),
151+
),
137152
),
138153
LinkDecoration(
139154
matcher = { destination, _ -> destination.contains("dashed") },

richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/LinkDecorations.kt

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package com.halilibo.richtext.ui.string
22

3+
import androidx.compose.ui.text.TextLinkStyles
4+
import androidx.compose.runtime.Composable
5+
import androidx.compose.ui.graphics.Color
6+
import androidx.compose.ui.text.PlaceholderVerticalAlign
37
import androidx.compose.ui.unit.Dp
8+
import androidx.compose.ui.unit.DpSize
49
import androidx.compose.ui.unit.dp
5-
import androidx.compose.ui.text.TextLinkStyles
610

711
/**
812
* Defines how specific links should be decorated based on their destination.
@@ -11,6 +15,7 @@ public data class LinkDecoration(
1115
val matcher: (destination: String, text: String) -> Boolean,
1216
val underlineStyle: UnderlineStyle = UnderlineStyle.Solid,
1317
val linkStyleOverride: ((TextLinkStyles?) -> TextLinkStyles)? = null,
18+
val inlineContent: LinkInlineContent? = null,
1419
)
1520

1621
/**
@@ -20,6 +25,53 @@ public data class RichTextDecorations(
2025
val linkDecorations: List<LinkDecoration> = emptyList(),
2126
)
2227

28+
public data class LinkInlineContent(
29+
val leading: InlineIconSpec? = null,
30+
val trailing: InlineIconSpec? = null,
31+
val spacing: Dp = 2.dp,
32+
val includeInHitTarget: Boolean = true,
33+
)
34+
35+
public sealed class InlineIconSpec(
36+
public val size: DpSize,
37+
public val placeholderVerticalAlign: PlaceholderVerticalAlign,
38+
) {
39+
public data class Painter(
40+
val painter: androidx.compose.ui.graphics.painter.Painter,
41+
val tint: Color? = null,
42+
val contentDescription: String? = null,
43+
val iconSize: DpSize = DefaultSize,
44+
val iconPlaceholderVerticalAlign: PlaceholderVerticalAlign = DefaultPlaceholderVerticalAlign,
45+
) : InlineIconSpec(
46+
size = iconSize,
47+
placeholderVerticalAlign = iconPlaceholderVerticalAlign,
48+
)
49+
50+
public data class Composable(
51+
val content: LinkComposableContent,
52+
val iconSize: DpSize = DefaultSize,
53+
val iconPlaceholderVerticalAlign: PlaceholderVerticalAlign = DefaultPlaceholderVerticalAlign,
54+
) : InlineIconSpec(
55+
size = iconSize,
56+
placeholderVerticalAlign = iconPlaceholderVerticalAlign,
57+
)
58+
59+
public companion object {
60+
public val DefaultSize: DpSize = DpSize(24.dp, 24.dp)
61+
public val DefaultPlaceholderVerticalAlign: PlaceholderVerticalAlign =
62+
PlaceholderVerticalAlign.Center
63+
}
64+
}
65+
66+
public data class LinkContext(
67+
val destination: String,
68+
val text: String,
69+
)
70+
71+
public fun interface LinkComposableContent {
72+
@Composable public fun Render(context: LinkContext)
73+
}
74+
2375
/**
2476
* The underline style to use for a matched link.
2577
*/
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package com.halilibo.richtext.ui.string
2+
3+
import androidx.compose.foundation.Image
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.padding
6+
import androidx.compose.foundation.layout.size
7+
import androidx.compose.foundation.text.appendInlineContent
8+
import androidx.compose.ui.Modifier
9+
import androidx.compose.ui.graphics.ColorFilter
10+
import androidx.compose.ui.text.AnnotatedString
11+
import androidx.compose.ui.text.LinkAnnotation
12+
import androidx.compose.ui.unit.Dp
13+
import androidx.compose.ui.unit.DpSize
14+
import androidx.compose.ui.unit.IntSize
15+
import androidx.compose.ui.unit.dp
16+
import kotlin.math.roundToInt
17+
18+
internal data class DecoratedTextResult(
19+
val annotatedString: AnnotatedString,
20+
val inlineContents: Map<String, InlineContent>,
21+
val decoratedLinkRanges: List<DecoratedLinkRange>,
22+
)
23+
24+
internal fun ResolvedLinkDecorationRange.hasInlineContent(): Boolean {
25+
val inlineContent = inlineContent ?: return false
26+
return inlineContent.leading != null || inlineContent.trailing != null
27+
}
28+
29+
internal fun decorateAnnotatedStringWithLinkIcons(
30+
annotated: AnnotatedString,
31+
baseInlineContents: Map<String, InlineContent>,
32+
linkDecorations: List<ResolvedLinkDecorationRange>,
33+
): DecoratedTextResult {
34+
if (linkDecorations.isEmpty()) {
35+
return DecoratedTextResult(
36+
annotatedString = annotated,
37+
inlineContents = baseInlineContents,
38+
decoratedLinkRanges = emptyList(),
39+
)
40+
}
41+
42+
val builder = AnnotatedString.Builder()
43+
val inlineContents = LinkedHashMap<String, InlineContent>(baseInlineContents.size)
44+
inlineContents.putAll(baseInlineContents)
45+
val decoratedLinkRanges = mutableListOf<DecoratedLinkRange>()
46+
val sortedDecorations = linkDecorations.sortedBy { it.start }
47+
var cursor = 0
48+
49+
sortedDecorations.forEachIndexed { index, decoration ->
50+
val start = decoration.start
51+
val end = decoration.end
52+
if (cursor < start) {
53+
builder.append(annotated, cursor, start)
54+
}
55+
if (start >= end) {
56+
cursor = maxOf(cursor, end)
57+
return@forEachIndexed
58+
}
59+
60+
val inlineContent = decoration.inlineContent
61+
val linkAnnotation = annotated.getLinkAnnotations(start, end)
62+
.firstOrNull()
63+
?.item as? LinkAnnotation.Url
64+
if (inlineContent != null) {
65+
inlineContent.leading?.let { spec ->
66+
val id = "link_inline_${index}_leading"
67+
builder.appendInlineContent(id, REPLACEMENT_CHAR)
68+
inlineContents[id] = buildInlineContent(
69+
spec = spec,
70+
context = LinkContext(decoration.destination, decoration.text),
71+
spacing = inlineContent.spacing,
72+
isLeading = true,
73+
)
74+
if (inlineContent.includeInHitTarget && linkAnnotation != null) {
75+
val iconStart = builder.length - 1
76+
builder.addLink(linkAnnotation, iconStart, builder.length)
77+
}
78+
}
79+
}
80+
81+
val textStart = builder.length
82+
builder.append(annotated, start, end)
83+
val textEnd = builder.length
84+
85+
if (inlineContent != null) {
86+
inlineContent.trailing?.let { spec ->
87+
val id = "link_inline_${index}_trailing"
88+
builder.appendInlineContent(id, REPLACEMENT_CHAR)
89+
inlineContents[id] = buildInlineContent(
90+
spec = spec,
91+
context = LinkContext(decoration.destination, decoration.text),
92+
spacing = inlineContent.spacing,
93+
isLeading = false,
94+
)
95+
if (inlineContent.includeInHitTarget && linkAnnotation != null) {
96+
val iconStart = builder.length - 1
97+
builder.addLink(linkAnnotation, iconStart, builder.length)
98+
}
99+
}
100+
}
101+
102+
if (decoration.underlineStyle !is UnderlineStyle.Solid) {
103+
decoratedLinkRanges += DecoratedLinkRange(
104+
start = textStart,
105+
end = textEnd,
106+
destination = decoration.destination,
107+
underlineStyle = decoration.underlineStyle,
108+
linkStyleOverride = decoration.linkStyleOverride,
109+
)
110+
}
111+
112+
cursor = end
113+
}
114+
115+
if (cursor < annotated.length) {
116+
builder.append(annotated, cursor, annotated.length)
117+
}
118+
119+
return DecoratedTextResult(
120+
annotatedString = builder.toAnnotatedString(),
121+
inlineContents = inlineContents,
122+
decoratedLinkRanges = decoratedLinkRanges,
123+
)
124+
}
125+
126+
private fun buildInlineContent(
127+
spec: InlineIconSpec,
128+
context: LinkContext,
129+
spacing: Dp,
130+
isLeading: Boolean,
131+
): InlineContent {
132+
val spacingWidth = if (spacing.value > 0f) spacing else 0.dp
133+
val placeholderSize = if (spacingWidth.value > 0f) {
134+
DpSize(
135+
width = spec.size.width + spacingWidth,
136+
height = spec.size.height,
137+
)
138+
} else {
139+
spec.size
140+
}
141+
142+
val paddingModifier = when {
143+
spacingWidth.value <= 0f -> Modifier
144+
isLeading -> Modifier.padding(end = spacingWidth)
145+
else -> Modifier.padding(start = spacingWidth)
146+
}
147+
148+
val contentModifier = paddingModifier
149+
.then(Modifier.size(spec.size))
150+
151+
return InlineContent(
152+
initialSize = {
153+
IntSize(
154+
placeholderSize.width.toPx().roundToInt(),
155+
placeholderSize.height.toPx().roundToInt(),
156+
)
157+
},
158+
placeholderVerticalAlign = spec.placeholderVerticalAlign,
159+
) {
160+
when (spec) {
161+
is InlineIconSpec.Painter -> {
162+
Image(
163+
painter = spec.painter,
164+
contentDescription = spec.contentDescription,
165+
colorFilter = spec.tint?.let { ColorFilter.tint(it) },
166+
modifier = contentModifier,
167+
)
168+
}
169+
is InlineIconSpec.Composable -> {
170+
Box(modifier = contentModifier) {
171+
spec.content.Render(context)
172+
}
173+
}
174+
}
175+
}
176+
}

richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/RichTextString.kt

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,33 @@ public class RichTextString internal constructor(
251251
.toList()
252252
}
253253

254+
internal fun resolveLinkDecorations(
255+
decorations: RichTextDecorations,
256+
): List<ResolvedLinkDecorationRange> {
257+
if (decorations.linkDecorations.isEmpty()) return emptyList()
258+
259+
return taggedString.getStringAnnotations(FormatAnnotationScope, 0, taggedString.length)
260+
.asSequence()
261+
.mapNotNull { range ->
262+
val format = Format.findTag(range.item, formatObjects)
263+
as? Format.Link
264+
?: return@mapNotNull null
265+
val linkText = taggedString.text.substring(range.start, range.end)
266+
val decoration = decorations.findLinkDecoration(format.destination, linkText)
267+
?: return@mapNotNull null
268+
ResolvedLinkDecorationRange(
269+
start = range.start,
270+
end = range.end,
271+
destination = format.destination,
272+
text = linkText,
273+
underlineStyle = decoration.underlineStyle,
274+
linkStyleOverride = decoration.linkStyleOverride,
275+
inlineContent = decoration.inlineContent,
276+
)
277+
}
278+
.toList()
279+
}
280+
254281
override fun equals(other: Any?): Boolean {
255282
if (this === other) return true
256283
if (other !is RichTextString) return false
@@ -506,6 +533,16 @@ internal data class DecoratedLinkRange(
506533
val linkStyleOverride: ((TextLinkStyles?) -> TextLinkStyles)?,
507534
)
508535

536+
internal data class ResolvedLinkDecorationRange(
537+
val start: Int,
538+
val end: Int,
539+
val destination: String,
540+
val text: String,
541+
val underlineStyle: UnderlineStyle,
542+
val linkStyleOverride: ((TextLinkStyles?) -> TextLinkStyles)?,
543+
val inlineContent: LinkInlineContent?,
544+
)
545+
509546
private fun RichTextDecorations.findLinkDecoration(
510547
destination: String,
511548
text: String,

0 commit comments

Comments
 (0)