From 5f79bdee54461d8dff7038fb149e744bf442a6a3 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 20:02:26 +0300 Subject: [PATCH] refactor(markdown): decompose MarkdownText UI and styling Extract MarkdownInlineStyler, MarkdownInlineText, MarkdownTable, and MarkdownMediaResolver into ui/chat/markdown package. --- .../m57/hermescontrol/ui/chat/MarkdownText.kt | 506 +----------------- .../ui/chat/markdown/MarkdownInlineStyler.kt | 342 ++++++++++++ .../ui/chat/markdown/MarkdownInlineText.kt | 113 ++++ .../ui/chat/markdown/MarkdownMediaResolver.kt | 29 + .../ui/chat/markdown/MarkdownTableView.kt | 103 ++++ 5 files changed, 595 insertions(+), 498 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownMediaResolver.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index ffb7b3a4..6a1feeb2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -63,6 +63,10 @@ import com.m57.hermescontrol.theme.SearchHighlightColors import com.m57.hermescontrol.theme.searchHighlightColors import com.m57.hermescontrol.ui.chat.markdown.FnNote import com.m57.hermescontrol.ui.chat.markdown.InlineMathSegment +import com.m57.hermescontrol.ui.chat.markdown.MarkdownInlineStyler +import com.m57.hermescontrol.ui.chat.markdown.MarkdownInlineText +import com.m57.hermescontrol.ui.chat.markdown.MarkdownMediaResolver +import com.m57.hermescontrol.ui.chat.markdown.MarkdownTable import com.m57.hermescontrol.ui.chat.markdown.MdBlock import com.m57.hermescontrol.ui.chat.markdown.TableAlign import com.m57.hermescontrol.ui.chat.markdown.parseBlocks @@ -347,7 +351,7 @@ fun MarkdownText( } is MdBlock.Video -> { - val resolvedUri = remember(block.uri) { resolveImageUrl(block.uri) } + val resolvedUri = remember(block.uri) { MarkdownMediaResolver.resolveImageUrl(block.uri) } var showVideoDialog by remember { mutableStateOf(false) } androidx.compose.foundation.layout.Box( modifier = @@ -369,7 +373,7 @@ fun MarkdownText( } is MdBlock.Image -> { - val model: Any = remember(block.uri) { resolveImageUrl(block.uri) } + val model: Any = remember(block.uri) { MarkdownMediaResolver.resolveImageUrl(block.uri) } val isGif = remember(block.uri) { block.uri.contains(".gif", ignoreCase = true) || @@ -509,179 +513,6 @@ fun MarkdownText( } } -@Composable -private fun MarkdownInlineText( - text: String, - textColor: Color, - latexMeasurer: LatexMeasurerState, - style: TextStyle, - searchQuery: String, - isCurrentMatch: Boolean, - linkColor: Color, - highlights: SearchHighlightColors, - modifier: Modifier = Modifier, -) { - val isRtl = remember(text) { BidiUtils.isRtlText(text) } - val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current - val resolvedStyle = - style.copy( - textDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr, - ) - val processedText = - remember(text, isRtl) { - if (isRtl) BidiUtils.anchorTrailingRtl(text) else text - } - - CompositionLocalProvider(LocalLayoutDirection provides direction) { - val segments = remember(processedText) { splitInlineMath(processedText) } - if (segments.none { it is InlineMathSegment.Math }) { - Text( - text = - remember(processedText, searchQuery, isCurrentMatch, textColor, linkColor, isRtl) { - parseInlineSource( - text = processedText, - textColor = textColor, - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - isRtl = isRtl, - ) - }, - color = textColor, - style = resolvedStyle, - modifier = modifier, - ) - return@CompositionLocalProvider - } - - val config = - LatexConfig( - fontSize = style.fontSize, - theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent), - accessibilityEnabled = true, - ) - val inlineContent = mutableMapOf() - segments.forEachIndexed { index, segment -> - if (segment is InlineMathSegment.Math) { - latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it } - } - } - val annotated = - buildAnnotatedString { - segments.forEachIndexed { index, segment -> - when (segment) { - is InlineMathSegment.Math -> { - val id = "latex-$index" - if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex) - } - - is InlineMathSegment.Text -> { - append( - parseInlineSource( - text = segment.value, - textColor = textColor, - searchQuery = searchQuery, - isCurrentMatch = isCurrentMatch, - linkColor = linkColor, - highlights = highlights, - isRtl = isRtl, - ), - ) - } - } - } - } - Text( - text = annotated, - inlineContent = inlineContent, - color = textColor, - style = resolvedStyle, - modifier = modifier, - ) - } -} - -@Composable -private fun MarkdownTable( - block: MdBlock.Table, - textColor: Color, -) { - val isRtl = - remember(block) { - block.header.any { BidiUtils.isRtlText(it) } || - block.rows.any { row -> row.any { BidiUtils.isRtlText(it) } } - } - val tableDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current - val headerBg = textColor.copy(alpha = 0.08f) - val alignments = block.alignments - CompositionLocalProvider(LocalLayoutDirection provides tableDirection) { - Column( - modifier = - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(vertical = 4.dp), - ) { - // Header row - Row(modifier = Modifier.background(headerBg)) { - block.header.forEachIndexed { idx, cell -> - val cellRtl = BidiUtils.isRtlText(cell) - Text( - text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell, - textAlign = tableTextAlign(alignments.getOrNull(idx)), - color = textColor, - style = - MaterialTheme.typography.bodySmall.copy( - fontWeight = FontWeight.Bold, - textDirection = bidiTextDirection(cellRtl), - ), - modifier = - Modifier - .width(TABLE_COL_WIDTH) - .padding(6.dp), - ) - } - } - HorizontalDivider(color = textColor.copy(alpha = 0.25f)) - // Body rows - block.rows.forEach { row -> - Row { - row.forEachIndexed { idx, cell -> - val cellRtl = BidiUtils.isRtlText(cell) - Text( - text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell, - textAlign = tableTextAlign(alignments.getOrNull(idx)), - color = textColor, - style = - MaterialTheme.typography.bodySmall.copy( - textDirection = bidiTextDirection(cellRtl), - ), - modifier = - Modifier - .width(TABLE_COL_WIDTH) - .padding(6.dp), - ) - } - } - HorizontalDivider(color = textColor.copy(alpha = 0.1f)) - } - } - } -} - -private fun tableTextAlign(align: TableAlign?): TextAlign? = - when (align) { - TableAlign.CENTER -> TextAlign.Center - TableAlign.RIGHT -> TextAlign.End - else -> null - } - -/** - * Inline Markdown -> AnnotatedString. Handles `code`, **bold**, *italic*, ***bold italic***, - * ~~strike~~, ==highlight==, ^sup^, ~sub~, keys, [^ref] footnotes, [text](url) and - * bare URLs, plus search-query highlighting. - */ internal fun parseInline( text: String, textColor: Color, @@ -691,14 +522,8 @@ internal fun parseInline( highlights: SearchHighlightColors, isRtl: Boolean = BidiUtils.isRtlText(text), ): AnnotatedString = - parseInlineSource( - text = - splitInlineMath(text).joinToString(separator = "") { segment -> - when (segment) { - is InlineMathSegment.Math -> segment.latex - is InlineMathSegment.Text -> segment.value - } - }, + MarkdownInlineStyler.parseInline( + text = text, textColor = textColor, searchQuery = searchQuery, isCurrentMatch = isCurrentMatch, @@ -706,318 +531,3 @@ internal fun parseInline( highlights = highlights, isRtl = isRtl, ) - -private fun parseInlineSource( - text: String, - textColor: Color, - searchQuery: String, - isCurrentMatch: Boolean, - linkColor: Color, - highlights: SearchHighlightColors, - isRtl: Boolean = BidiUtils.isRtlText(text), -): AnnotatedString { - val searchHighlightColor = - if (isCurrentMatch) { - highlights.currentSearchBackground to highlights.currentSearchForeground - } else { - highlights.searchBackground to highlights.searchForeground - } - - return buildAnnotatedString { - var i = 0 - val src = text - while (i < src.length) { - when { - // ***bold italic*** - src.startsWith("***", i) -> { - val end = src.indexOf("***", i + 3) - if (end != -1) { - val raw = src.substring(i + 3, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) { - append(toAppend) - } - i = end + 3 - } else { - append(src[i]) - i++ - } - } - - // **bold** - src.startsWith("**", i) -> { - val end = src.indexOf("**", i + 2) - if (end != -1) { - val raw = src.substring(i + 2, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(toAppend) - } - i = end + 2 - } else { - append(src[i]) - i++ - } - } - - // ~~strike~~ - src.startsWith("~~", i) -> { - val end = src.indexOf("~~", i + 2) - if (end != -1) { - val raw = src.substring(i + 2, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { - append(toAppend) - } - i = end + 2 - } else { - append(src[i]) - i++ - } - } - - // *italic* - src.startsWith("*", i) -> { - val end = src.indexOf('*', i + 1) - if (end != -1 && end > i + 1) { - val raw = src.substring(i + 1, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { - append(toAppend) - } - i = end + 1 - } else { - append(src[i]) - i++ - } - } - - // ==highlight== - src.startsWith("==", i) -> { - val end = src.indexOf("==", i + 2) - if (end != -1) { - val raw = src.substring(i + 2, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(background = highlights.markupBackground)) { - append(toAppend) - } - i = end + 2 - } else { - append(src[i]) - i++ - } - } - - // ^superscript^ - src.startsWith("^", i) -> { - val end = src.indexOf('^', i + 1) - if (end != -1 && end > i + 1) { - withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) { - append(src.substring(i + 1, end)) - } - i = end + 1 - } else { - append(src[i]) - i++ - } - } - - // ~subscript~ (single tilde; ~~ handled above) - src.startsWith("~", i) -> { - val end = src.indexOf('~', i + 1) - if (end != -1 && end > i + 1) { - withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) { - append(src.substring(i + 1, end)) - } - i = end + 1 - } else { - append(src[i]) - i++ - } - } - - // key - src.startsWith("", i) -> { - val end = src.indexOf("", i) - if (end != -1) { - val raw = src.substring(i + 5, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - background = textColor.copy(alpha = 0.12f), - ), - ) { - append(toAppend) - } - i = end + 6 - } else { - append(src[i]) - i++ - } - } - - // [^ref] footnote marker -> superscript - src.startsWith("[^", i) -> { - val close = src.indexOf(']', i) - if (close != -1) { - val id = src.substring(i + 2, close) - withStyle( - SpanStyle( - baselineShift = BaselineShift.Superscript, - color = linkColor, - fontWeight = FontWeight.Bold, - ), - ) { - append("[$id]") - } - i = close + 1 - } else { - append(src[i]) - i++ - } - } - - // [text](url) - src.startsWith("[", i) -> { - val close = src.indexOf(']', i) - if (close != -1 && close + 1 < src.length && src[close + 1] == '(') { - val urlEnd = src.indexOf(')', close + 2) - if (urlEnd != -1) { - val label = src.substring(i + 1, close) - val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label - val url = src.substring(close + 2, urlEnd) - pushLink(LinkAnnotation.Url(url)) - withStyle( - SpanStyle( - color = linkColor, - textDecoration = TextDecoration.Underline, - ), - ) { - append(labelToAppend) - } - pop() - i = urlEnd + 1 - } else { - append(src[i]) - i++ - } - } else { - append(src[i]) - i++ - } - } - - // `inline code` - src.startsWith("`", i) -> { - val end = src.indexOf('`', i + 1) - if (end != -1) { - val raw = src.substring(i + 1, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - background = textColor.copy(alpha = 0.08f), - ), - ) { - append(toAppend) - } - i = end + 1 - } else { - append(src[i]) - i++ - } - } - - // bare URL - URL_PATTERN.matchAt(src, i) != null -> { - val match = URL_PATTERN.matchAt(src, i)!! - val url = match.value - val urlToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(url) else url - pushLink(LinkAnnotation.Url(url)) - withStyle( - SpanStyle( - color = linkColor, - textDecoration = TextDecoration.Underline, - ), - ) { - append(urlToAppend) - } - pop() - i = match.range.last + 1 - } - - // Plain text / words: when inside RTL text, wrap sequences of Latin/LTR words in LTR isolate - // so an English word at the start/middle of an Arabic line doesn't flip the layout direction. - isRtl && Character.isLetterOrDigit(src.codePointAt(i)) && - Character.getDirectionality(src.codePointAt(i)) == Character.DIRECTIONALITY_LEFT_TO_RIGHT -> { - var end = i + Character.charCount(src.codePointAt(i)) - while (end < src.length) { - val cp = src.codePointAt(end) - val dir = Character.getDirectionality(cp) - if (dir == Character.DIRECTIONALITY_LEFT_TO_RIGHT || - dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER || - dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_SEPARATOR || - dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_TERMINATOR || - ( - dir == Character.DIRECTIONALITY_WHITESPACE && end + 1 < src.length && - Character.getDirectionality(src.codePointAt(end + 1)) == - Character.DIRECTIONALITY_LEFT_TO_RIGHT - ) - ) { - end += Character.charCount(cp) - } else { - break - } - } - val ltrSnippet = src.substring(i, end) - append(BidiUtils.wrapLtrIsolate(ltrSnippet)) - i = end - } - - // search highlight - searchQuery.isNotEmpty() && - src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> { - withStyle( - SpanStyle( - background = searchHighlightColor.first, - color = searchHighlightColor.second, - ), - ) { - append(src.substring(i, i + searchQuery.length)) - } - i += searchQuery.length - } - - else -> { - append(src[i]) - i++ - } - } - } - } -} - -private fun resolveImageUrl(uri: String): String { - val trimmed = uri.trim() - if (trimmed.isBlank()) return uri - if (trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.startsWith("data:image/")) { - return trimmed - } - val baseUrl = runCatching { AuthManager.getBaseUrl() }.getOrDefault("") - val token = runCatching { AuthManager.getToken() }.getOrNull().orEmpty() - val mediaUrl = GatewayFileClient.buildMediaUrl(baseUrl, token, trimmed) - if (mediaUrl != null) return mediaUrl - - if (baseUrl.isNotBlank() && (trimmed.startsWith("/api/") || trimmed.startsWith("api/"))) { - val cleanPath = if (trimmed.startsWith("/")) trimmed else "/$trimmed" - val sep = if (cleanPath.contains("?")) "&" else "?" - return if (token.isNotBlank() && !cleanPath.contains("token=")) { - "${baseUrl.trimEnd('/')}$cleanPath${sep}token=$token" - } else { - "${baseUrl.trimEnd('/')}$cleanPath" - } - } - return uri -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt new file mode 100644 index 00000000..2aed606e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt @@ -0,0 +1,342 @@ +package com.m57.hermescontrol.ui.chat.markdown + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.theme.SearchHighlightColors +import com.m57.hermescontrol.util.BidiUtils + +object MarkdownInlineStyler { + private val URL_PATTERN = + Regex( + """https?://[^\s<>'"\)\],;]+(? + when (segment) { + is InlineMathSegment.Math -> segment.latex + is InlineMathSegment.Text -> segment.value + } + }, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + isRtl = isRtl, + ) + + fun parseInlineSource( + text: String, + textColor: Color, + searchQuery: String, + isCurrentMatch: Boolean, + linkColor: Color, + highlights: SearchHighlightColors, + isRtl: Boolean = BidiUtils.isRtlText(text), + ): AnnotatedString { + val searchHighlightColor = + if (isCurrentMatch) { + highlights.currentSearchBackground to highlights.currentSearchForeground + } else { + highlights.searchBackground to highlights.searchForeground + } + + return buildAnnotatedString { + var i = 0 + val src = text + while (i < src.length) { + when { + // ***bold italic*** + src.startsWith("***", i) -> { + val end = src.indexOf("***", i + 3) + if (end != -1) { + val raw = src.substring(i + 3, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) { + append(toAppend) + } + i = end + 3 + } else { + append(src[i]) + i++ + } + } + + // **bold** + src.startsWith("**", i) -> { + val end = src.indexOf("**", i + 2) + if (end != -1) { + val raw = src.substring(i + 2, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(toAppend) + } + i = end + 2 + } else { + append(src[i]) + i++ + } + } + + // ~~strike~~ + src.startsWith("~~", i) -> { + val end = src.indexOf("~~", i + 2) + if (end != -1) { + val raw = src.substring(i + 2, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { + append(toAppend) + } + i = end + 2 + } else { + append(src[i]) + i++ + } + } + + // *italic* + src.startsWith("*", i) -> { + val end = src.indexOf('*', i + 1) + if (end != -1 && end > i + 1) { + val raw = src.substring(i + 1, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append(toAppend) + } + i = end + 1 + } else { + append(src[i]) + i++ + } + } + + // ==highlight== + src.startsWith("==", i) -> { + val end = src.indexOf("==", i + 2) + if (end != -1) { + val raw = src.substring(i + 2, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle(SpanStyle(background = highlights.markupBackground)) { + append(toAppend) + } + i = end + 2 + } else { + append(src[i]) + i++ + } + } + + // ^superscript^ + src.startsWith("^", i) -> { + val end = src.indexOf('^', i + 1) + if (end != -1 && end > i + 1) { + withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) { + append(src.substring(i + 1, end)) + } + i = end + 1 + } else { + append(src[i]) + i++ + } + } + + // ~subscript~ (single tilde; ~~ handled above) + src.startsWith("~", i) -> { + val end = src.indexOf('~', i + 1) + if (end != -1 && end > i + 1) { + withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) { + append(src.substring(i + 1, end)) + } + i = end + 1 + } else { + append(src[i]) + i++ + } + } + + // key + src.startsWith("", i) -> { + val end = src.indexOf("", i) + if (end != -1) { + val raw = src.substring(i + 5, end) + val toAppend = if (isRtr(isRtl)) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + background = textColor.copy(alpha = 0.12f), + ), + ) { + append(toAppend) + } + i = end + 6 + } else { + append(src[i]) + i++ + } + } + + // [^ref] footnote marker -> superscript + src.startsWith("[^", i) -> { + val close = src.indexOf(']', i) + if (close != -1) { + val id = src.substring(i + 2, close) + withStyle( + SpanStyle( + baselineShift = BaselineShift.Superscript, + color = linkColor, + fontWeight = FontWeight.Bold, + ), + ) { + append("[$id]") + } + i = close + 1 + } else { + append(src[i]) + i++ + } + } + + // [text](url) + src.startsWith("[", i) -> { + val close = src.indexOf(']', i) + if (close != -1 && close + 1 < src.length && src[close + 1] == '(') { + val urlEnd = src.indexOf(')', close + 2) + if (urlEnd != -1) { + val label = src.substring(i + 1, close) + val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label + val url = src.substring(close + 2, urlEnd) + pushLink(LinkAnnotation.Url(url)) + withStyle( + SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + ), + ) { + append(labelToAppend) + } + pop() + i = urlEnd + 1 + } else { + append(src[i]) + i++ + } + } else { + append(src[i]) + i++ + } + } + + // `inline code` + src.startsWith("`", i) -> { + val end = src.indexOf('`', i + 1) + if (end != -1) { + val raw = src.substring(i + 1, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + background = textColor.copy(alpha = 0.08f), + ), + ) { + append(toAppend) + } + i = end + 1 + } else { + append(src[i]) + i++ + } + } + + // bare URL + URL_PATTERN.matchAt(src, i) != null -> { + val match = URL_PATTERN.matchAt(src, i)!! + val url = match.value + val urlToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(url) else url + pushLink(LinkAnnotation.Url(url)) + withStyle( + SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + ), + ) { + append(urlToAppend) + } + pop() + i = match.range.last + 1 + } + + // Plain text / words in RTL + isRtl && Character.isLetterOrDigit(src.codePointAt(i)) && + Character.getDirectionality(src.codePointAt(i)) == Character.DIRECTIONALITY_LEFT_TO_RIGHT -> { + var end = i + Character.charCount(src.codePointAt(i)) + while (end < src.length) { + val cp = src.codePointAt(end) + val dir = Character.getDirectionality(cp) + if (dir == Character.DIRECTIONALITY_LEFT_TO_RIGHT || + dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER || + dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_SEPARATOR || + dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_TERMINATOR || + ( + dir == Character.DIRECTIONALITY_WHITESPACE && end + 1 < src.length && + Character.getDirectionality(src.codePointAt(end + 1)) == + Character.DIRECTIONALITY_LEFT_TO_RIGHT + ) + ) { + end += Character.charCount(cp) + } else { + break + } + } + val ltrSnippet = src.substring(i, end) + append(BidiUtils.wrapLtrIsolate(ltrSnippet)) + i = end + } + + // search highlight + searchQuery.isNotEmpty() && + src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> { + withStyle( + SpanStyle( + background = searchHighlightColor.first, + color = searchHighlightColor.second, + ), + ) { + append(src.substring(i, i + searchQuery.length)) + } + i += searchQuery.length + } + + else -> { + append(src[i]) + i++ + } + } + } + } + } + + private fun isRtr(isRtl: Boolean): Boolean = isRtl +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt new file mode 100644 index 00000000..167ca1e1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt @@ -0,0 +1,113 @@ +package com.m57.hermescontrol.ui.chat.markdown + +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.LayoutDirection +import com.hrm.latex.renderer.measure.LatexMeasurerState +import com.hrm.latex.renderer.model.LatexConfig +import com.hrm.latex.renderer.model.LatexTheme +import com.m57.hermescontrol.theme.SearchHighlightColors +import com.m57.hermescontrol.util.BidiUtils + +@Composable +fun MarkdownInlineText( + text: String, + textColor: Color, + latexMeasurer: LatexMeasurerState, + style: TextStyle, + searchQuery: String, + isCurrentMatch: Boolean, + linkColor: Color, + highlights: SearchHighlightColors, + modifier: Modifier = Modifier, +) { + val isRtl = remember(text) { BidiUtils.isRtlText(text) } + val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + val resolvedStyle = + style.copy( + textDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr, + ) + val processedText = + remember(text, isRtl) { + if (isRtl) BidiUtils.anchorTrailingRtl(text) else text + } + + CompositionLocalProvider(LocalLayoutDirection provides direction) { + val segments = remember(processedText) { splitInlineMath(processedText) } + if (segments.none { it is InlineMathSegment.Math }) { + Text( + text = + remember(processedText, searchQuery, isCurrentMatch, textColor, linkColor, isRtl) { + MarkdownInlineStyler.parseInlineSource( + text = processedText, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + isRtl = isRtl, + ) + }, + color = textColor, + style = resolvedStyle, + modifier = modifier, + ) + return@CompositionLocalProvider + } + + val config = + LatexConfig( + fontSize = style.fontSize, + theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent), + accessibilityEnabled = true, + ) + val inlineContent = mutableMapOf() + segments.forEachIndexed { index, segment -> + if (segment is InlineMathSegment.Math) { + latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it } + } + } + val annotated = + buildAnnotatedString { + segments.forEachIndexed { index, segment -> + when (segment) { + is InlineMathSegment.Math -> { + val id = "latex-$index" + if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex) + } + + is InlineMathSegment.Text -> { + append( + MarkdownInlineStyler.parseInlineSource( + text = segment.value, + textColor = textColor, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, + linkColor = linkColor, + highlights = highlights, + isRtl = isRtl, + ), + ) + } + } + } + } + Text( + text = annotated, + inlineContent = inlineContent, + color = textColor, + style = resolvedStyle, + modifier = modifier, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownMediaResolver.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownMediaResolver.kt new file mode 100644 index 00000000..13d8bda6 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownMediaResolver.kt @@ -0,0 +1,29 @@ +package com.m57.hermescontrol.ui.chat.markdown + +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.GatewayFileClient + +object MarkdownMediaResolver { + fun resolveImageUrl(uri: String): String { + val trimmed = uri.trim() + if (trimmed.isBlank()) return uri + if (trimmed.startsWith("http://") || trimmed.startsWith("https://") || trimmed.startsWith("data:image/")) { + return trimmed + } + val baseUrl = runCatching { AuthManager.getBaseUrl() }.getOrDefault("") + val token = runCatching { AuthManager.getToken() }.getOrNull().orEmpty() + val mediaUrl = GatewayFileClient.buildMediaUrl(baseUrl, token, trimmed) + if (mediaUrl != null) return mediaUrl + + if (baseUrl.isNotBlank() && (trimmed.startsWith("/api/") || trimmed.startsWith("api/"))) { + val cleanPath = if (trimmed.startsWith("/")) trimmed else "/$trimmed" + val sep = if (cleanPath.contains("?")) "&" else "?" + return if (token.isNotBlank() && !cleanPath.contains("token=")) { + "${baseUrl.trimEnd('/')}$cleanPath${sep}token=$token" + } else { + "${baseUrl.trimEnd('/')}$cleanPath" + } + } + return uri + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt new file mode 100644 index 00000000..9d6ef28c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt @@ -0,0 +1,103 @@ +package com.m57.hermescontrol.ui.chat.markdown + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.HorizontalDivider +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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.util.BidiUtils + +private val TABLE_COL_WIDTH = 140.dp + +@Composable +fun MarkdownTable( + block: MdBlock.Table, + textColor: Color, + modifier: Modifier = Modifier, +) { + val isRtl = + remember(block) { + block.header.any { BidiUtils.isRtlText(it) } || + block.rows.any { row -> row.any { BidiUtils.isRtlText(it) } } + } + val tableDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current + val headerBg = textColor.copy(alpha = 0.08f) + val alignments = block.alignments + CompositionLocalProvider(LocalLayoutDirection provides tableDirection) { + Column( + modifier = + modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(vertical = 4.dp), + ) { + // Header row + Row(modifier = Modifier.background(headerBg)) { + block.header.forEachIndexed { idx, cell -> + val cellRtl = BidiUtils.isRtlText(cell) + Text( + text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell, + textAlign = tableTextAlign(alignments.getOrNull(idx)), + color = textColor, + style = + MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Bold, + textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr, + ), + modifier = + Modifier + .width(TABLE_COL_WIDTH) + .padding(6.dp), + ) + } + } + HorizontalDivider(color = textColor.copy(alpha = 0.25f)) + // Body rows + block.rows.forEach { row -> + Row { + row.forEachIndexed { idx, cell -> + val cellRtl = BidiUtils.isRtlText(cell) + Text( + text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell, + textAlign = tableTextAlign(alignments.getOrNull(idx)), + color = textColor, + style = + MaterialTheme.typography.bodySmall.copy( + textDirection = if (cellRtl) TextDirection.Rtl else TextDirection.Ltr, + ), + modifier = + Modifier + .width(TABLE_COL_WIDTH) + .padding(6.dp), + ) + } + } + HorizontalDivider(color = textColor.copy(alpha = 0.1f)) + } + } + } +} + +private fun tableTextAlign(align: TableAlign?): TextAlign? = + when (align) { + TableAlign.CENTER -> TextAlign.Center + TableAlign.RIGHT -> TextAlign.End + else -> null + }