feat(commons): prototype shared rich-text rendering contract

Introduces commons/ui/richtext: one cross-platform RichTextViewer that
both the touch (Amethyst Android) and mouse-first (Desktop) front ends
can drive, to replace the two current forks (amethyst RichTextViewer +
DesktopRichTextViewer).

The shared core owns the universal parts (paragraph/RTL/word layout,
plain text, inline custom emoji, hashtags) and delegates the segments
whose *presentation and* call-to-action diverge by platform (media,
equation, quoted event, mention, payment, link preview, relay/invite,
secret message) to a RichTextSegmentRenderer strategy provided via
LocalRichTextSegmentRenderer -- the same CompositionLocal idiom the
codebase already uses for LocalInlineQuoteRenderer. Universal actions
(open url/email/phone, hashtag) go through a small RichTextInteractions
callback bag. A PlainTextSegmentRenderer default keeps the core usable
from previews/tests/headless callers.

Contract + skeleton only; compiles in :commons. No consumer wired yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
This commit is contained in:
Claude
2026-07-16 20:45:58 +00:00
parent a068eaf721
commit c8deb0f227
3 changed files with 623 additions and 0 deletions
@@ -0,0 +1,64 @@
# commons/ui/richtext — shared rich-text rendering contract
A **prototype** of one cross-platform rich-text renderer that Amethyst Android
(touch) and Amethyst Desktop (mouse-first) can both drive, replacing the two
current forks (`amethyst/ui/components/RichTextViewer.kt` ~1031 LOC and
`desktopApp/…/DesktopRichTextViewer.kt` ~745 LOC).
## What lives here
- **`RichTextViewer.kt`** — the shared skeleton. Owns everything identical on
every front end: paragraph splitting, RTL, FlowRow word layout, plain text,
inline custom emoji, and hashtags. Takes an already-parsed
`RichTextViewerState` (the parser is pure and already in `commons/richtext`);
it holds **no** account, nav, or cache handle.
- **`RichTextSegmentRenderer.kt`** — the seam. Two things the host provides:
- `RichTextSegmentRenderer` (via `LocalRichTextSegmentRenderer`) — one method
per **platform-divergent** segment (media, equation, quoted event, user
mention, payment, link preview, relay/invite chip, secret message). The
platform owns both the **visual** and the **call-to-action** for these.
- `RichTextInteractions` (via `LocalRichTextInteractions`) — plain callbacks
for segments whose **action is universal** and only the trigger styling
differs (open URL/email/phone, jump to hashtag).
## Why this split (and not desktop's callback-only bag)
The divergent segments differ in *two* ways at once between touch and mouse: the
**presentation** (a tap-to-zoom media pager vs. an inline image that opens in a
window) *and* the **call-to-action** (a bottom sheet vs. a popover; navigate on
tap vs. a hover-card). A callbacks-only contract assumes shared rendering + a
different click handler — which isn't true here — so the platform must own the
whole rendering of those segments. Hence a renderer strategy, not just callbacks.
This is the same idiom the codebase already uses for inline quotes
(`LocalInlineQuoteRenderer`), generalised to every divergent segment: a
CompositionLocal set at the shell, read deep in the recursive tree, re-providable
per subtree — no parameter threading through 55+ call sites.
**Feature parity, not presentation parity.** Desktop being simpler today is a
gap, not the design; it is expected to cover the same range over time, its own
mouse-first way. Every method has a plain-text default (`PlainTextSegmentRenderer`)
so an unimplemented kind degrades to readable text and the core stays usable from
previews, `commonTest`, and headless callers.
## How each front end plugs in (next steps — not in this prototype)
- **Android** (`amethyst`): implement `RichTextSegmentRenderer` by wrapping the
existing leaf composables (`ZoomableContentView`, `LoadUrlPreview`,
`CashuPreview`, `MayBeInvoicePreview`, `LatexEquation`, `BechLink`
`LocalInlineQuoteRenderer`, …), closing over `AccountViewModel`/`INav`. The
Android `RichTextViewer` becomes a thin wrapper that parses the content, then
provides the two CompositionLocals and calls this shared core — keeping its
current signature so no call site changes. The flavor-specific
`TranslatableRichTextViewer` stays native and feeds the final string in.
- **Desktop** (`desktopApp`): implement the strategy with `AsyncImage` +
window-open for media, `RenderMarkdown` for markdown, popovers for payments,
hover-cards for mentions — then delete `DesktopRichTextViewer`.
## Status
Compiles in `:commons` (`compileCommonMainKotlinMetadata`). No consumer is wired
yet — this is the contract + skeleton for review before the per-platform
implementations and the fork deletions land. Known follow-ups: share the custom
emoji **icon** table (hashtag icons) and a `CreateTextWithEmoji` equivalent;
unify the two `CachedRichTextParser` forks so callers don't each parse.
@@ -0,0 +1,264 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.richtext
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph
import com.vitorpamplona.amethyst.commons.richtext.MathSegment
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji
import com.vitorpamplona.amethyst.commons.richtext.Segment
/**
* The seam that lets one shared [RichTextViewer] serve every front end.
*
* ## Why a strategy and not a callback bag
*
* The parse → paragraph/word-layout → plain-text/emoji/hashtag rendering is
* *identical* on every platform, so the shared core owns it outright. But a
* handful of segment kinds — media, embedded notes, mentions, payments, link
* unfurls, LaTeX — differ between a **touch** front end (Amethyst Android) and a
* **mouse-first** one (Desktop). They differ in *two* ways at once:
*
* - **Presentation.** A phone shows an image in a full-bleed, tap-to-zoom pager;
* a desktop shows it inline with a hover affordance and opens it in a window.
* - **Call-to-action.** A phone opens a lightning invoice in a bottom sheet; a
* desktop opens a popover. A phone navigates on a mention tap; a desktop may
* show a hover-card first.
*
* Because *both* the visual and the interaction diverge (not just the click
* handler), a callback-only contract is not enough — the platform has to own the
* whole rendering of these segments. So each divergent kind is a method here, and
* the platform provides an implementation via [LocalRichTextSegmentRenderer].
*
* This is the same idiom the codebase already uses for inline quotes
* (`LocalInlineQuoteRenderer`), generalised to every platform-divergent segment:
* a [androidx.compose.runtime.CompositionLocal] set once at the app shell, read
* deep inside the recursive render tree, and re-providable per subtree (e.g. chat
* bubbles vs. the feed) without threading a parameter through every call site.
*
* ## Feature parity, not presentation parity
*
* A platform is expected to *cover the same range* of segments over time — the
* Desktop being simpler today is a gap, not the design. What it is **not**
* expected to do is render them the same way. Every method has a plain-text
* default (see [PlainTextSegmentRenderer]) so an unimplemented kind degrades to
* readable text rather than vanishing, which also keeps the core usable from
* tests, previews, and headless callers.
*
* Every method receives a [Modifier] the core already aligned for RTL; draw into
* it. `quotesLeft` is the remaining recursion budget for embedded content — a
* renderer that recurses back into [RichTextViewer] must decrement it.
*/
@Stable
interface RichTextSegmentRenderer {
/** A single image / video / pdf / base64 / blossom-uri media word. */
@Composable
fun Media(
segment: Segment,
state: RichTextViewerState,
modifier: Modifier,
)
/** A whole paragraph that is nothing but images — laid out as a grid/gallery. */
@Composable
fun Gallery(
paragraph: ImageGalleryParagraph,
state: RichTextViewerState,
modifier: Modifier,
)
/** An inline/display LaTeX equation (platform math renderer). */
@Composable
fun Equation(
segment: MathSegment,
modifier: Modifier,
)
/** A bare `nostr:` bech entity whose kind (user vs event) the renderer resolves. */
@Composable
fun NostrEntity(
bech: String,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier,
)
/** A `#[i]`-style event mention resolved to [eventHex]; renders the quoted note. */
@Composable
fun QuotedEvent(
eventHex: String,
addedChars: String?,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier,
)
/** A `#[i]`-style user mention resolved to [userHex]. */
@Composable
fun UserMention(
userHex: String,
addedChars: String?,
modifier: Modifier,
)
/** A payable token: lightning invoice, LNURL-withdraw, Cashu token, or Clink offer. */
@Composable
fun Payment(
segment: Segment,
modifier: Modifier,
)
/** An external link that may unfurl into a preview card (touch) or hover card (mouse). */
@Composable
fun LinkPreview(
url: String,
modifier: Modifier,
)
/** A relay URL, NIP-29 group invite, or Concord invite chip. */
@Composable
fun RelayLink(
segment: Segment,
modifier: Modifier,
)
/** A NIP-C0 secret-emoji span that expands into its own decoded rich-text message. */
@Composable
fun SecretMessage(
segment: SecretEmoji,
state: RichTextViewerState,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier,
)
}
/**
* Presentation-agnostic activations for the segments the shared core renders
* itself. The *action* is unambiguous on every platform (open a URL, dial a
* number, jump to a hashtag); only how the trigger looks/feels differs, which is
* a Modifier concern the core applies. Anything whose action itself diverges by
* platform (a mention that navigates vs. pops a hover-card) belongs in
* [RichTextSegmentRenderer], not here.
*/
@Immutable
data class RichTextInteractions(
val onOpenUrl: (url: String) -> Unit = {},
val onOpenEmail: (address: String) -> Unit = {},
val onOpenPhone: (number: String) -> Unit = {},
val onClickHashtag: (hashtag: String) -> Unit = {},
)
/**
* The default: render every divergent segment as its raw text. Safe for previews,
* `commonTest`, and headless callers; a real front end replaces it wholesale.
*/
object PlainTextSegmentRenderer : RichTextSegmentRenderer {
@Composable
override fun Media(
segment: Segment,
state: RichTextViewerState,
modifier: Modifier,
) = Text(segment.segmentText, modifier)
@Composable
override fun Gallery(
paragraph: ImageGalleryParagraph,
state: RichTextViewerState,
modifier: Modifier,
) = Text(paragraph.words.joinToString(" ") { it.segmentText }, modifier)
@Composable
override fun Equation(
segment: MathSegment,
modifier: Modifier,
) = Text(segment.segmentText, modifier)
@Composable
override fun NostrEntity(
bech: String,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier,
) = Text(bech, modifier)
@Composable
override fun QuotedEvent(
eventHex: String,
addedChars: String?,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier,
) = Text(addedChars?.let { "$eventHex$it" } ?: eventHex, modifier)
@Composable
override fun UserMention(
userHex: String,
addedChars: String?,
modifier: Modifier,
) = Text(addedChars?.let { "$userHex$it" } ?: userHex, modifier)
@Composable
override fun Payment(
segment: Segment,
modifier: Modifier,
) = Text(segment.segmentText, modifier)
@Composable
override fun LinkPreview(
url: String,
modifier: Modifier,
) = Text(url, modifier)
@Composable
override fun RelayLink(
segment: Segment,
modifier: Modifier,
) = Text(segment.segmentText, modifier)
@Composable
override fun SecretMessage(
segment: SecretEmoji,
state: RichTextViewerState,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier,
) = Text(segment.segmentText, modifier)
}
/**
* Deep-tree seam for platform-divergent segment rendering. Uses [compositionLocalOf]
* (not static) so a subtree can re-provide a variant — e.g. a compact renderer in a
* preview card — and only the readers under it recompose.
*/
val LocalRichTextSegmentRenderer =
compositionLocalOf<RichTextSegmentRenderer> { PlainTextSegmentRenderer }
/** Universal activations for core-rendered segments. Static: it changes at the shell, rarely below. */
val LocalRichTextInteractions =
staticCompositionLocalOf { RichTextInteractions() }
@@ -0,0 +1,295 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.richtext
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment
import com.vitorpamplona.amethyst.commons.richtext.ConcordInviteLinkSegment
import com.vitorpamplona.amethyst.commons.richtext.EmailSegment
import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment
import com.vitorpamplona.amethyst.commons.richtext.HashIndexEventSegment
import com.vitorpamplona.amethyst.commons.richtext.HashIndexUserSegment
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
import com.vitorpamplona.amethyst.commons.richtext.ImageGalleryParagraph
import com.vitorpamplona.amethyst.commons.richtext.ImageSegment
import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
import com.vitorpamplona.amethyst.commons.richtext.MathSegment
import com.vitorpamplona.amethyst.commons.richtext.NowhereLinkSegment
import com.vitorpamplona.amethyst.commons.richtext.ParagraphState
import com.vitorpamplona.amethyst.commons.richtext.PdfSegment
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.commons.richtext.RelayGroupLinkSegment
import com.vitorpamplona.amethyst.commons.richtext.RelayUrlSegment
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment
import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji
import com.vitorpamplona.amethyst.commons.richtext.Segment
import com.vitorpamplona.amethyst.commons.richtext.VideoSegment
import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment
import kotlinx.collections.immutable.ImmutableMap
/**
* Cross-platform rich-text renderer. Owns everything identical on every front
* end — paragraph splitting, RTL, word layout, plain text, custom emoji, and
* hashtags — and delegates every platform-divergent segment to the
* [LocalRichTextSegmentRenderer] and universal activations to
* [LocalRichTextInteractions]. See [RichTextSegmentRenderer] for the rationale.
*
* Callers pass an already-parsed [RichTextViewerState] (the parser is pure and
* lives in `commons/richtext`), so this composable takes no account, navigation,
* or cache handle of its own — those enter through the two CompositionLocals the
* host provides.
*/
@Composable
fun RichTextViewer(
state: RichTextViewerState,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier = Modifier,
) {
val renderer = LocalRichTextSegmentRenderer.current
val baseStyle = LocalTextStyle.current
val paragraphStyle = remember(baseStyle) { baseStyle.copy(lineHeight = 1.3.em) }
Column(modifier) {
state.paragraphs.forEach { paragraph ->
val align = if (paragraph.isRTL) Alignment.End else Alignment.Start
if (paragraph is ImageGalleryParagraph) {
renderer.Gallery(paragraph, state, Modifier.align(align))
} else {
CompositionLocalProvider(
LocalLayoutDirection provides if (paragraph.isRTL) LayoutDirection.Rtl else LayoutDirection.Ltr,
LocalTextStyle provides paragraphStyle,
) {
RenderParagraph(paragraph, state, canPreview, quotesLeft, Modifier.align(align))
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RenderParagraph(
paragraph: ParagraphState,
state: RichTextViewerState,
canPreview: Boolean,
quotesLeft: Int,
modifier: Modifier,
) {
val spaceWidth = measureSpaceWidth(LocalTextStyle.current)
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(spaceWidth),
itemVerticalAlignment = Alignment.CenterVertically,
) {
paragraph.words.forEach { word ->
RenderWord(word, state, canPreview, quotesLeft)
}
}
}
@Composable
private fun RenderWord(
word: Segment,
state: RichTextViewerState,
canPreview: Boolean,
quotesLeft: Int,
) {
val renderer = LocalRichTextSegmentRenderer.current
val actions = LocalRichTextInteractions.current
when (word) {
is RegularTextSegment -> Text(word.segmentText)
is EmojiSegment -> CustomEmojiText(word.segmentText, state.customEmoji, Modifier)
is HashTagSegment -> HashTagText(word) { actions.onClickHashtag(word.hashtag) }
is EmailSegment -> ClickableSpan(word.segmentText) { actions.onOpenEmail(word.segmentText) }
is PhoneSegment -> ClickableSpan(word.segmentText) { actions.onOpenPhone(word.segmentText) }
// Divergent media — presentation and CTA are platform-owned.
is ImageSegment, is VideoSegment, is PdfSegment, is Base64Segment, is BlossomUriSegment ->
renderer.Media(word, state, Modifier)
is MathSegment -> renderer.Equation(word, Modifier)
is LinkSegment ->
if (canPreview) {
renderer.LinkPreview(word.segmentText, Modifier)
} else {
ClickableSpan(word.segmentText) { actions.onOpenUrl(word.segmentText) }
}
is SchemelessUrlSegment ->
ClickableSpan(word.segmentText) { actions.onOpenUrl("https://${word.segmentText}") }
is NowhereLinkSegment ->
ClickableSpan(word.segmentText) { actions.onOpenUrl(word.segmentText) }
is RelayUrlSegment, is RelayGroupLinkSegment, is ConcordInviteLinkSegment ->
renderer.RelayLink(word, Modifier)
is InvoiceSegment, is WithdrawSegment, is CashuSegment, is ClinkOfferSegment ->
renderer.Payment(word, Modifier)
is HashIndexUserSegment -> renderer.UserMention(word.hex, word.extras, Modifier)
is HashIndexEventSegment -> renderer.QuotedEvent(word.hex, word.extras, canPreview, quotesLeft, Modifier)
is BechSegment -> renderer.NostrEntity(word.segmentText, canPreview, quotesLeft, Modifier)
is SecretEmoji -> renderer.SecretMessage(word, state, canPreview, quotesLeft, Modifier)
// Unknown/other segments fall back to their raw text.
else -> Text(word.segmentText)
}
}
@Composable
private fun ClickableSpan(
text: String,
onClick: () -> Unit,
) {
Text(
text = text,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = onClick),
)
}
/**
* A `#hashtag` chip in the theme's primary color. The icon variants Amethyst
* shows for known tags depend on an app-side icon table; until that table is
* shared this renders the text form on every platform.
*/
@Composable
private fun HashTagText(
segment: HashTagSegment,
onClick: () -> Unit,
) {
val text = remember(segment.segmentText) { "#${segment.hashtag}${segment.extras ?: ""}" }
Text(
text = text,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
modifier = Modifier.clickable(onClick = onClick),
)
}
/**
* Inline custom emoji: replaces each shortcode present in [emojis] with its image,
* leaving surrounding text intact. Universal to every front end, so it lives in
* the core rather than the platform seam.
*/
@Composable
private fun CustomEmojiText(
text: String,
emojis: ImmutableMap<String, String>,
modifier: Modifier,
) {
if (emojis.isEmpty()) {
Text(text, modifier)
return
}
val fontSize = LocalTextStyle.current.fontSize
val emojiSize = if (fontSize.isSpecified) fontSize else 16.sp
val inlineContent = HashMap<String, InlineTextContent>()
val annotated =
buildAnnotatedString {
var cursor = 0
while (cursor < text.length) {
var bestIdx = -1
var bestKey: String? = null
for (key in emojis.keys) {
val idx = text.indexOf(key, cursor)
if (idx >= 0 && (bestIdx == -1 || idx < bestIdx)) {
bestIdx = idx
bestKey = key
}
}
if (bestKey == null) {
append(text.substring(cursor))
break
}
if (bestIdx > cursor) append(text.substring(cursor, bestIdx))
val url = emojis.getValue(bestKey)
inlineContent[bestKey] =
InlineTextContent(
Placeholder(emojiSize, emojiSize, PlaceholderVerticalAlign.Center),
) {
AsyncImage(model = url, contentDescription = bestKey)
}
appendInlineContent(bestKey, bestKey)
cursor = bestIdx + bestKey.length
}
}
Text(text = annotated, inlineContent = inlineContent, modifier = modifier)
}
/** Width of a single space in [textStyle], used to space FlowRow words. */
@Composable
fun measureSpaceWidth(textStyle: TextStyle): Dp {
val fontFamilyResolver = LocalFontFamilyResolver.current
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
return remember(fontFamilyResolver, density, layoutDirection, textStyle) {
val widthPx =
TextMeasurer(fontFamilyResolver, density, layoutDirection, 1)
.measure(" ", textStyle)
.size
.width
with(density) { widthPx.toDp() }
}
}