refactor(markdown): decompose MarkdownText UI and styling

Extract MarkdownInlineStyler, MarkdownInlineText, MarkdownTable,
and MarkdownMediaResolver into ui/chat/markdown package.
This commit is contained in:
Hy4ri committed 2026-09-11 20:02:26 +03:00
1 parent f4904653d1
commit 5f79bdee54
5 files changed
+595 -498

No files matched your search

@@ -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<String, InlineTextContent>()
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~, <kbd>keys</kbd>, [^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++
}
}
// <kbd>key</kbd>
src.startsWith("<kbd>", i) -> {
val end = src.indexOf("</kbd>", 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
}
@@ -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<>'"\)\],;]+(?<![.\),;])""",
RegexOption.IGNORE_CASE,
)
fun parseInline(
text: String,
textColor: Color,
searchQuery: String,
isCurrentMatch: Boolean,
linkColor: Color,
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
}
},
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++
}
}
// <kbd>key</kbd>
src.startsWith("<kbd>", i) -> {
val end = src.indexOf("</kbd>", 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
}
@@ -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<String, InlineTextContent>()
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,
)
}
}
@@ -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
}
}
@@ -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
}