Extracted Parsing logic into helper class

This commit is contained in:
davotoula
2025-09-14 13:17:19 +02:00
parent 74cc9535ac
commit 43f8053b30
2 changed files with 307 additions and 215 deletions

View File

@@ -0,0 +1,272 @@
/**
* 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.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.MutableState
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.ImageSegment
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.ParagraphState
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.Segment
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
data class RenderContext(
val state: RichTextViewerState,
val backgroundColor: MutableState<Color>,
val quotesLeft: Int,
val callbackUri: String?,
val accountViewModel: AccountViewModel,
val nav: INav,
)
data class ParagraphImageAnalysis(
val imageCount: Int,
val isImageOnly: Boolean,
val hasMultipleImages: Boolean,
)
class ParagraphParser {
fun analyzeParagraphImages(paragraph: ParagraphState): ParagraphImageAnalysis {
var imageCount = 0
var hasNonWhitespaceNonImageContent = false
paragraph.words.forEach { word ->
when (word) {
is ImageSegment, is Base64Segment -> imageCount++
is RegularTextSegment -> {
if (word.segmentText.isNotBlank()) {
hasNonWhitespaceNonImageContent = true
}
}
else -> hasNonWhitespaceNonImageContent = true // Links, emojis, etc.
}
}
val isImageOnly = imageCount > 0 && !hasNonWhitespaceNonImageContent
val hasMultipleImages = imageCount > 1
return ParagraphImageAnalysis(
imageCount = imageCount,
isImageOnly = isImageOnly,
hasMultipleImages = hasMultipleImages,
)
}
fun collectConsecutiveImageParagraphs(
paragraphs: ImmutableList<ParagraphState>,
startIndex: Int,
): Pair<List<ParagraphState>, Int> {
val imageParagraphs = mutableListOf<ParagraphState>()
var j = startIndex
while (j < paragraphs.size) {
val currentParagraph = paragraphs[j]
val words = currentParagraph.words
// Fast path for empty check
if (words.isEmpty()) {
j++
continue
}
// Check for single whitespace word
if (words.size == 1) {
val firstWord = words.first()
if (firstWord is RegularTextSegment && firstWord.segmentText.isBlank()) {
j++
continue
}
}
// Check if it's an image-only paragraph using unified analysis
val analysis = analyzeParagraphImages(currentParagraph)
if (analysis.isImageOnly) {
imageParagraphs.add(currentParagraph)
j++
} else {
break
}
}
return imageParagraphs to j
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun processParagraph(
paragraphs: ImmutableList<ParagraphState>,
paragraphIndex: Int,
spaceWidth: Dp,
context: RenderContext,
renderSingleParagraph: @Composable (ParagraphState, ImmutableList<Segment>, Dp, RenderContext) -> Unit,
renderImageGallery: @Composable (ImmutableList<Segment>, RenderContext) -> Unit,
): Int {
val paragraph = paragraphs[paragraphIndex]
if (paragraph.words.isEmpty()) {
// Empty paragraph - render normally with FlowRow (will render nothing)
renderSingleParagraph(paragraph, paragraph.words.toImmutableList(), spaceWidth, context)
return paragraphIndex + 1
}
val analysis = analyzeParagraphImages(paragraph)
if (analysis.isImageOnly) {
// Collect consecutive image-only paragraphs for gallery
val (imageParagraphs, endIndex) = collectConsecutiveImageParagraphs(paragraphs, paragraphIndex)
val allImageWords = imageParagraphs.flatMap { it.words }.toImmutableList()
if (allImageWords.size > 1) {
// Multiple images - render as gallery (no FlowRow wrapper needed)
renderImageGallery(allImageWords, context)
} else {
// Single image - render with FlowRow wrapper
renderSingleParagraph(paragraph, paragraph.words.toImmutableList(), spaceWidth, context)
}
return endIndex // Return next index to process
} else if (analysis.hasMultipleImages) {
// Mixed paragraph with multiple images - use renderImageGallery for smart grouping
renderImageGallery(paragraph.words.toImmutableList(), context)
return paragraphIndex + 1
} else {
// Regular paragraph (no images or single image) - render normally with FlowRow
renderSingleParagraph(paragraph, paragraph.words.toImmutableList(), spaceWidth, context)
return paragraphIndex + 1
}
}
@Composable
fun ProcessWordsWithImageGrouping(
words: ImmutableList<Segment>,
context: RenderContext,
renderSingleWord: @Composable (Segment, RenderContext) -> Unit,
renderGallery: @Composable (ImmutableList<MediaUrlImage>, AccountViewModel) -> Unit,
) {
var i = 0
val n = words.size
while (i < n) {
val word = words[i]
if (word is ImageSegment || word is Base64Segment) {
// Collect consecutive image/whitespace segments without extra list allocations
val imageSegments = mutableListOf<Segment>()
var j = i
while (j < n) {
val seg = words[j]
when {
seg is ImageSegment || seg is Base64Segment -> imageSegments.add(seg)
seg is RegularTextSegment && seg.segmentText.isBlank() -> { /* skip whitespace */ }
else -> break
}
j++
}
if (imageSegments.size > 1) {
val imageContents =
imageSegments
.mapNotNull { segment ->
val imageUrl = segment.segmentText
context.state.imagesForPager[imageUrl] as? MediaUrlImage
}.toImmutableList()
if (imageContents.isNotEmpty()) {
renderGallery(imageContents, context.accountViewModel)
}
} else {
renderSingleWord(imageSegments.firstOrNull() ?: word, context)
}
i = j // jump past processed run
} else {
renderSingleWord(word, context)
i++
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderSingleParagraphWithFlowRow(
paragraph: ParagraphState,
words: ImmutableList<Segment>,
spaceWidth: Dp,
context: RenderContext,
renderWord: @Composable (Segment, RenderContext) -> Unit,
) {
CompositionLocalProvider(
LocalLayoutDirection provides
if (paragraph.isRTL) {
LayoutDirection.Rtl
} else {
LayoutDirection.Ltr
},
LocalTextStyle provides LocalTextStyle.current,
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(spaceWidth),
) {
words.forEach { word ->
renderWord(word, context)
}
}
}
}
@Composable
fun ProcessAllParagraphs(
paragraphs: ImmutableList<ParagraphState>,
spaceWidth: Dp,
context: RenderContext,
renderSingleParagraph: @Composable (ParagraphState, ImmutableList<Segment>, Dp, RenderContext) -> Unit,
renderImageGallery: @Composable (ImmutableList<Segment>, RenderContext) -> Unit,
) {
var i = 0
while (i < paragraphs.size) {
i =
processParagraph(
paragraphs = paragraphs,
paragraphIndex = i,
spaceWidth = spaceWidth,
context = context,
renderSingleParagraph = renderSingleParagraph,
renderImageGallery = renderImageGallery,
)
}
}
}

View File

@@ -73,8 +73,6 @@ import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
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.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.ParagraphState
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
@@ -114,15 +112,6 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
data class RenderContext(
val state: RichTextViewerState,
val backgroundColor: MutableState<Color>,
val quotesLeft: Int,
val callbackUri: String?,
val accountViewModel: AccountViewModel,
val nav: INav,
)
fun isMarkdown(content: String): Boolean =
content.startsWith("> ") ||
content.startsWith("# ") ||
@@ -333,161 +322,27 @@ fun RenderRegularWithGallery(
)
val spaceWidth = measureSpaceWidth(LocalTextStyle.current)
val paragraphParser = remember { ParagraphParser() }
Column {
// Process each paragraph uniformly
var i = 0
while (i < state.paragraphs.size) {
i =
renderParagraphWithFlowRow(
paragraphs = state.paragraphs,
paragraphIndex = i,
spaceWidth = spaceWidth,
context = context,
paragraphParser.ProcessAllParagraphs(
paragraphs = state.paragraphs,
spaceWidth = spaceWidth,
context = context,
renderSingleParagraph = { paragraph, words, width, ctx ->
paragraphParser.RenderSingleParagraphWithFlowRow(
paragraph = paragraph,
words = words,
spaceWidth = width,
context = ctx,
renderWord = { word, renderContext -> RenderWordWithPreview(word, renderContext) },
)
}
}
}
@Composable
private fun renderParagraphWithFlowRow(
paragraphs: ImmutableList<ParagraphState>,
paragraphIndex: Int,
spaceWidth: Dp,
context: RenderContext,
): Int {
val paragraph = paragraphs[paragraphIndex]
if (paragraph.words.isEmpty()) {
// Empty paragraph - render normally with FlowRow (will render nothing)
RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context)
return paragraphIndex + 1
}
val analysis = analyzeParagraphImages(paragraph)
if (analysis.isImageOnly) {
// Collect consecutive image-only paragraphs for gallery
val (imageParagraphs, endIndex) = collectConsecutiveImageParagraphs(paragraphs, paragraphIndex)
val allImageWords = imageParagraphs.flatMap { it.words }.toImmutableList()
if (allImageWords.size > 1) {
// Multiple images - render as gallery (no FlowRow wrapper needed)
RenderWordsWithImageGallery(allImageWords, context)
} else {
// Single image - render with FlowRow wrapper
RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context)
}
return endIndex // Return next index to process
} else if (analysis.hasMultipleImages) {
// Mixed paragraph with multiple images - use RenderWordsWithImageGallery for smart grouping
RenderWordsWithImageGallery(paragraph.words.toImmutableList(), context)
return paragraphIndex + 1
} else {
// Regular paragraph (no images or single image) - render normally with FlowRow
RenderSingleParagraphWithFlowRow(paragraph, paragraph.words.toImmutableList(), spaceWidth, context)
return paragraphIndex + 1
}
}
@Composable
private fun RenderSingleParagraphWithFlowRow(
paragraph: ParagraphState,
words: ImmutableList<Segment>,
spaceWidth: Dp,
context: RenderContext,
) {
CompositionLocalProvider(
LocalLayoutDirection provides
if (paragraph.isRTL) {
LayoutDirection.Rtl
} else {
LayoutDirection.Ltr
},
LocalTextStyle provides LocalTextStyle.current,
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(spaceWidth),
) {
words.forEach { word ->
RenderWordWithPreview(word, context)
}
}
renderImageGallery = { words, ctx -> RenderWordsWithImageGallery(words, ctx) },
)
}
}
data class ParagraphImageAnalysis(
val imageCount: Int,
val isImageOnly: Boolean,
val hasMultipleImages: Boolean,
)
private fun analyzeParagraphImages(paragraph: ParagraphState): ParagraphImageAnalysis {
var imageCount = 0
var hasNonWhitespaceNonImageContent = false
paragraph.words.forEach { word ->
when (word) {
is ImageSegment, is Base64Segment -> imageCount++
is RegularTextSegment -> {
if (word.segmentText.isNotBlank()) {
hasNonWhitespaceNonImageContent = true
}
}
else -> hasNonWhitespaceNonImageContent = true // Links, emojis, etc.
}
}
val isImageOnly = imageCount > 0 && !hasNonWhitespaceNonImageContent
val hasMultipleImages = imageCount > 1
return ParagraphImageAnalysis(
imageCount = imageCount,
isImageOnly = isImageOnly,
hasMultipleImages = hasMultipleImages,
)
}
private fun collectConsecutiveImageParagraphs(
paragraphs: ImmutableList<ParagraphState>,
startIndex: Int,
): Pair<List<ParagraphState>, Int> {
val imageParagraphs = mutableListOf<ParagraphState>()
var j = startIndex
while (j < paragraphs.size) {
val currentParagraph = paragraphs[j]
val words = currentParagraph.words
// Fast path for empty check
if (words.isEmpty()) {
j++
continue
}
// Check for single whitespace word
if (words.size == 1) {
val firstWord = words.first()
if (firstWord is RegularTextSegment && firstWord.segmentText.isBlank()) {
j++
continue
}
}
// Check if it's an image-only paragraph using unified analysis
val analysis = analyzeParagraphImages(currentParagraph)
if (analysis.isImageOnly) {
imageParagraphs.add(currentParagraph)
j++
} else {
break
}
}
return imageParagraphs to j
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderRegular(
@@ -585,52 +440,20 @@ private fun RenderWordsWithImageGallery(
words: ImmutableList<Segment>,
context: RenderContext,
) {
var i = 0
val n = words.size
val paragraphParser = remember { ParagraphParser() }
while (i < n) {
val word = words[i]
if (word is ImageSegment || word is Base64Segment) {
// Collect consecutive image/whitespace segments without extra list allocations
val imageSegments = mutableListOf<Segment>()
var j = i
while (j < n) {
val seg = words[j]
when {
seg is ImageSegment || seg is Base64Segment -> imageSegments.add(seg)
seg is RegularTextSegment && seg.segmentText.isBlank() -> { /* skip whitespace */ }
else -> break
}
j++
}
if (imageSegments.size > 1) {
val imageContents =
imageSegments
.mapNotNull { segment ->
val imageUrl = segment.segmentText
context.state.imagesForPager[imageUrl] as? MediaUrlImage
}.toImmutableList()
if (imageContents.isNotEmpty()) {
ImageGallery(
images = imageContents,
accountViewModel = context.accountViewModel,
roundedCorner = true,
)
}
} else {
RenderWordWithPreview(imageSegments.firstOrNull() ?: word, context)
}
i = j // jump past processed run
} else {
RenderWordWithPreview(word, context)
i++
}
}
paragraphParser.ProcessWordsWithImageGrouping(
words = words,
context = context,
renderSingleWord = { word, ctx -> RenderWordWithPreview(word, ctx) },
renderGallery = { imageContents, accountViewModel ->
ImageGallery(
images = imageContents,
accountViewModel = accountViewModel,
roundedCorner = true,
)
},
)
}
@Composable
@@ -832,20 +655,17 @@ fun CoreSecretMessage(
}
} else if (localSecretContent.paragraphs.size > 1) {
val spaceWidth = measureSpaceWidth(LocalTextStyle.current)
val paragraphParser = remember { ParagraphParser() }
Column(CashuCardBorders) {
localSecretContent.paragraphs.forEach { paragraph ->
FlowRow(
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start),
horizontalArrangement = Arrangement.spacedBy(spaceWidth),
) {
paragraph.words.forEach { word ->
RenderWordWithPreview(
word,
context,
)
}
}
paragraphParser.RenderSingleParagraphWithFlowRow(
paragraph = paragraph,
words = paragraph.words.toImmutableList(),
spaceWidth = spaceWidth,
context = context,
renderWord = { word, ctx -> RenderWordWithPreview(word, ctx) },
)
}
}
}