feat(chat): support dynamic BiDi Arabic input and isolated mixed text in Markdown (#1047)

This commit is contained in:
M57 authored and GitHub committed 2026-09-03 13:06:03 +03:00
1 parent fa17c95296
commit cd8c27e62b
8 files changed
+868 -401

No files matched your search

@@ -1226,9 +1226,7 @@ object HermesWsClient {
disconnectIfIdleInBackground()
}
else -> {
Unit
}
else -> {}
}
// tryEmit on a DROP_OLDEST flow only returns false when the
// buffer is full AND no subscriber is draining; with extraBuffer=512
@@ -42,6 +42,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -58,6 +59,7 @@ import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@@ -65,8 +67,10 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
@@ -75,6 +79,7 @@ import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.LightOnSurface
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.onColorFor
import com.m57.hermescontrol.util.BidiUtils
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.time.Instant
@@ -176,67 +181,79 @@ fun ChatBubble(
color = Color.Transparent,
tonalElevation = 0.dp,
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
SelectionContainer {
Text(
text = highlightedText,
color = userBubbleTextColor,
style = MaterialTheme.typography.bodyMedium,
)
}
// Render inline attachments
if (!message.attachments.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
message.attachments.forEach { attachment ->
InlineAttachment(
attachment = attachment,
textColor = userBubbleTextColor,
onOpen = { onOpenAttachment(it) },
onSave = { onSaveAttachment(it) },
savingPath = savingAttachmentPath,
openingPath = openingAttachmentPath,
canSave = canSaveAttachment,
onImageClick = onImageClick,
val isRtl = remember(message.content) { BidiUtils.isRtlText(message.content) }
val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
SelectionContainer {
Text(
text = highlightedText,
color = userBubbleTextColor,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection =
if (isRtl) {
TextDirection.ContentOrRtl
} else {
TextDirection.ContentOrLtr
},
),
)
Spacer(modifier = Modifier.height(4.dp))
}
}
if (!message.isStreaming) {
Row(
modifier =
Modifier
.align(Alignment.End)
.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(null, message.content)),
)
}
copied = true
},
modifier = Modifier.size(20.dp),
// Render inline attachments
if (!message.attachments.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
message.attachments.forEach { attachment ->
InlineAttachment(
attachment = attachment,
textColor = userBubbleTextColor,
onOpen = { onOpenAttachment(it) },
onSave = { onSaveAttachment(it) },
savingPath = savingAttachmentPath,
openingPath = openingAttachmentPath,
canSave = canSaveAttachment,
onImageClick = onImageClick,
)
Spacer(modifier = Modifier.height(4.dp))
}
}
if (!message.isStreaming) {
Row(
modifier =
Modifier
.align(Alignment.End)
.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = stringResource(R.string.content_desc_copy),
modifier = Modifier.size(12.dp),
tint = userBubbleTextColor.copy(alpha = 0.7f),
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(null, message.content)),
)
}
copied = true
},
modifier = Modifier.size(20.dp),
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = stringResource(R.string.content_desc_copy),
modifier = Modifier.size(12.dp),
tint = userBubbleTextColor.copy(alpha = 0.7f),
)
}
Spacer(modifier = Modifier.width(2.dp))
Text(
text =
formatTimestamp(
message.timestamp,
DateFormat.is24HourFormat(LocalContext.current),
),
color = userBubbleTextColor.copy(alpha = 0.6f),
style = MaterialTheme.typography.labelSmall,
)
}
Spacer(modifier = Modifier.width(2.dp))
Text(
text =
formatTimestamp(
message.timestamp,
DateFormat.is24HourFormat(LocalContext.current),
),
color = userBubbleTextColor.copy(alpha = 0.6f),
style = MaterialTheme.typography.labelSmall,
)
}
}
}
@@ -789,9 +789,7 @@ class ChatViewModel(
streamingController.flushPendingTokens()
}
else -> {
Unit
}
else -> {}
}
// First, let the reducer compute the new state and any effects
@@ -25,6 +25,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -33,6 +34,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
@@ -44,7 +46,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.hrm.latex.renderer.LatexAutoWrap
@@ -57,6 +61,7 @@ import com.m57.hermescontrol.data.remote.GatewayFileClient
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.SearchHighlightColors
import com.m57.hermescontrol.theme.searchHighlightColors
import com.m57.hermescontrol.util.BidiUtils
private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""")
private val TABLE_COL_WIDTH = 160.dp
@@ -66,6 +71,9 @@ private val BULLET_LINE_RE = Regex("""^(\s*)[-*+]\s+(.*)$""")
private val ORDERED_LINE_RE = Regex("""^(\s*)(\d+)\.\s+(.*)$""")
private val LIST_ITEM_LINE_RE = Regex("""^(\s*)(?:[-*+]\s+(?:\[([ xX])\]\s+)?|(\d+)\.\s+)(.*)$""")
private fun bidiTextDirection(isRtl: Boolean): TextDirection =
if (isRtl) TextDirection.ContentOrRtl else TextDirection.ContentOrLtr
/**
* Renders chat assistant text as Markdown — but ONLY once the message has finished streaming.
* While [isStreaming] is true we show the raw text to avoid flicker / re-parse churn, then swap
@@ -89,12 +97,19 @@ fun MarkdownText(
val statusColors = LocalHermesStatusColors.current
val highlights = searchHighlightColors(statusColors)
if (isStreaming) {
Text(
text = text,
color = textColor,
style = MaterialTheme.typography.bodyMedium,
modifier = modifier,
)
val isRtl = remember(text) { BidiUtils.isRtlText(text) }
val streamingDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides streamingDirection) {
Text(
text = if (isRtl) BidiUtils.anchorTrailingRtl(text) else text,
color = textColor,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
modifier = modifier,
)
}
return
}
@@ -143,154 +158,191 @@ fun MarkdownText(
5 -> 15.sp
else -> 14.sp
}
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium
.copy(fontSize = fontSize, fontWeight = FontWeight.Bold),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.padding(vertical = 2.dp),
)
}
is MdBlock.Bullet -> {
val indent = (block.level * 16).dp
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = indent)
.padding(vertical = 1.dp),
verticalAlignment = Alignment.Top,
) {
val bulletChar =
when (block.level % 3) {
0 -> "•"
1 -> "◦"
else -> "▪"
}
Text(
text = bulletChar,
color = textColor,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 6.dp),
)
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium,
style =
MaterialTheme.typography.bodyMedium
.copy(
fontSize = fontSize,
fontWeight = FontWeight.Bold,
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
modifier = Modifier.padding(vertical = 2.dp),
)
}
}
is MdBlock.Bullet -> {
val indent = (block.level * 16).dp
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = indent)
.padding(vertical = 1.dp),
verticalAlignment = Alignment.Top,
) {
val bulletChar =
when (block.level % 3) {
0 -> "•"
1 -> "◦"
else -> "▪"
}
Text(
text = bulletChar,
color = textColor,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 6.dp),
)
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
}
}
}
is MdBlock.Task -> {
val indent = (block.level * 16).dp
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = indent)
.padding(vertical = 1.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector =
if (block.checked) {
Icons.Outlined.CheckBox
} else {
Icons.Outlined.CheckBoxOutlineBlank
},
contentDescription = null,
tint =
if (block.checked) {
MaterialTheme.colorScheme.primary
} else {
textColor.copy(
alpha = 0.6f,
)
},
modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp),
)
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = indent)
.padding(vertical = 1.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector =
if (block.checked) {
Icons.Outlined.CheckBox
} else {
Icons.Outlined.CheckBoxOutlineBlank
},
contentDescription = null,
tint =
if (block.checked) {
MaterialTheme.colorScheme.primary
} else {
textColor.copy(
alpha = 0.6f,
)
},
modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp),
)
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
}
}
}
is MdBlock.Ordered -> {
val indent = (block.level * 16).dp
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = indent)
.padding(vertical = 1.dp),
verticalAlignment = Alignment.Top,
) {
Text(
text = "${block.index}.",
color = textColor,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 6.dp),
)
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = indent)
.padding(vertical = 1.dp),
verticalAlignment = Alignment.Top,
) {
Text(
text = "${block.index}.",
color = textColor,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 6.dp),
)
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
}
}
}
is MdBlock.Quote -> {
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min).padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.width(3.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(2.dp))
.background(textColor.copy(alpha = 0.35f)),
)
Spacer(modifier = Modifier.width(8.dp))
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min).padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.width(3.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(2.dp))
.background(textColor.copy(alpha = 0.35f)),
)
Spacer(modifier = Modifier.width(8.dp))
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium.copy(
fontStyle = FontStyle.Italic,
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
}
}
}
@@ -347,22 +399,41 @@ fun MarkdownText(
}
is MdBlock.DefList -> {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
block.items.forEach { item ->
Text(
text = item.term,
color = textColor,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold),
)
item.definitions.forEach { def ->
val isRtl =
remember(block.items) {
block.items.any { item ->
BidiUtils.isRtlText(item.term) ||
item.definitions.any { BidiUtils.isRtlText(it) }
}
}
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
block.items.forEach { item ->
val itemRtl = BidiUtils.isRtlText(item.term)
Text(
text = def,
text = if (itemRtl) BidiUtils.anchorTrailingRtl(item.term) else item.term,
color = textColor,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = 16.dp, bottom = 2.dp),
style =
MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Bold,
textDirection = bidiTextDirection(itemRtl),
),
)
item.definitions.forEach { def ->
val defRtl = BidiUtils.isRtlText(def)
Text(
text = if (defRtl) BidiUtils.anchorTrailingRtl(def) else def,
color = textColor,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(defRtl),
),
modifier = Modifier.padding(start = 16.dp, bottom = 2.dp),
)
}
Spacer(modifier = Modifier.height(2.dp))
}
Spacer(modifier = Modifier.height(2.dp))
}
}
}
@@ -384,40 +455,54 @@ fun MarkdownText(
modifier = Modifier.padding(bottom = 2.dp),
)
block.notes.forEach { note ->
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
Text(
text = "[${note.id}] ",
color = textColor,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
)
MarkdownInlineText(
text = note.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodySmall,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
val isRtl = remember(note.text) { BidiUtils.isRtlText(note.text) }
val noteDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides noteDirection) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
Text(
text = "[${note.id}] ",
color = textColor,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
)
MarkdownInlineText(
text = note.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodySmall.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
is MdBlock.Paragraph -> {
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
)
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
)
}
}
}
}
@@ -462,63 +547,85 @@ private fun MarkdownInlineText(
highlights: SearchHighlightColors,
modifier: Modifier = Modifier,
) {
val segments = remember(text) { splitInlineMath(text) }
if (segments.none { it is InlineMathSegment.Math }) {
Text(
text =
remember(text, searchQuery, isCurrentMatch, textColor, linkColor) {
parseInlineSource(text, textColor, searchQuery, isCurrentMatch, linkColor, highlights)
},
color = textColor,
style = style,
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.ContentOrRtl else TextDirection.ContentOrLtr,
)
return
}
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 processedText =
remember(text, isRtl) {
if (isRtl) BidiUtils.anchorTrailingRtl(text) else text
}
}
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(
segment.value,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
),
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 = style,
modifier = modifier,
)
Text(
text = annotated,
inlineContent = inlineContent,
color = textColor,
style = resolvedStyle,
modifier = modifier,
)
}
}
@Composable
@@ -526,40 +633,35 @@ 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
Column(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = 4.dp),
) {
// Header row
Row(modifier = Modifier.background(headerBg)) {
block.header.forEachIndexed { idx, cell ->
Text(
text = cell,
textAlign = tableTextAlign(alignments.getOrNull(idx)),
color = textColor,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
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 ->
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 = cell,
text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
textAlign = tableTextAlign(alignments.getOrNull(idx)),
color = textColor,
style = MaterialTheme.typography.bodySmall,
style =
MaterialTheme.typography.bodySmall.copy(
fontWeight = FontWeight.Bold,
textDirection = bidiTextDirection(cellRtl),
),
modifier =
Modifier
.width(TABLE_COL_WIDTH)
@@ -567,7 +669,29 @@ private fun MarkdownTable(
)
}
}
HorizontalDivider(color = textColor.copy(alpha = 0.1f))
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))
}
}
}
}
@@ -1116,6 +1240,7 @@ internal fun parseInline(
isCurrentMatch: Boolean,
linkColor: Color,
highlights: SearchHighlightColors,
isRtl: Boolean = BidiUtils.isRtlText(text),
): AnnotatedString =
parseInlineSource(
text =
@@ -1130,6 +1255,7 @@ internal fun parseInline(
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
isRtl = isRtl,
)
private fun parseInlineSource(
@@ -1139,6 +1265,7 @@ private fun parseInlineSource(
isCurrentMatch: Boolean,
linkColor: Color,
highlights: SearchHighlightColors,
isRtl: Boolean = BidiUtils.isRtlText(text),
): AnnotatedString {
val searchHighlightColor =
if (isCurrentMatch) {
@@ -1156,8 +1283,10 @@ private fun parseInlineSource(
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(src.substring(i + 3, end))
append(toAppend)
}
i = end + 3
} else {
@@ -1170,8 +1299,10 @@ private fun parseInlineSource(
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(src.substring(i + 2, end))
append(toAppend)
}
i = end + 2
} else {
@@ -1184,8 +1315,10 @@ private fun parseInlineSource(
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(src.substring(i + 2, end))
append(toAppend)
}
i = end + 2
} else {
@@ -1198,8 +1331,10 @@ private fun parseInlineSource(
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(src.substring(i + 1, end))
append(toAppend)
}
i = end + 1
} else {
@@ -1212,8 +1347,10 @@ private fun parseInlineSource(
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(src.substring(i + 2, end))
append(toAppend)
}
i = end + 2
} else {
@@ -1254,13 +1391,15 @@ private fun parseInlineSource(
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(src.substring(i + 5, end))
append(toAppend)
}
i = end + 6
} else {
@@ -1297,6 +1436,7 @@ private fun parseInlineSource(
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(
@@ -1305,7 +1445,7 @@ private fun parseInlineSource(
textDecoration = TextDecoration.Underline,
),
) {
append(label)
append(labelToAppend)
}
pop()
i = urlEnd + 1
@@ -1323,6 +1463,8 @@ private fun parseInlineSource(
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,
@@ -1330,7 +1472,7 @@ private fun parseInlineSource(
background = textColor.copy(alpha = 0.08f),
),
) {
append(src.substring(i + 1, end))
append(toAppend)
}
i = end + 1
} else {
@@ -1343,6 +1485,7 @@ private fun parseInlineSource(
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(
@@ -1350,7 +1493,7 @@ private fun parseInlineSource(
textDecoration = TextDecoration.Underline,
),
) {
append(url)
append(urlToAppend)
}
pop()
i = match.range.last + 1
@@ -43,6 +43,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -53,11 +54,15 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
@@ -68,6 +73,7 @@ import com.m57.hermescontrol.data.ws.CommandBlocklist
import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
import com.m57.hermescontrol.ui.common.BotAvatar
import com.m57.hermescontrol.util.BidiUtils
/**
* The chat input bar with a two-row layout: input+send on top,
@@ -318,91 +324,129 @@ fun ChatInputBar(
}
}
BasicTextField(
value = inputFieldValue,
onValueChange = onInputChange,
modifier =
Modifier
.weight(1f)
.heightIn(min = 42.dp, max = 120.dp)
.padding(vertical = 4.dp)
.onFocusChanged { isFocused = it.isFocused }
.testTag("chat_input"),
enabled = isConnected,
textStyle =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
),
singleLine = false,
maxLines = 4,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Surface(
shape = RoundedCornerShape(18.dp),
border =
BorderStroke(
width = if (isFocused) 2.dp else 1.dp,
color =
if (isFocused) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
},
),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.padding(start = 12.dp, end = 4.dp, top = 9.dp, bottom = 9.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.weight(1f)) {
if (inputFieldValue.text.isEmpty()) {
Text(
text = placeholderText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
innerTextField()
}
val ambientLayoutDirection = LocalLayoutDirection.current
val inputLayoutDirection =
remember(inputFieldValue.text, ambientLayoutDirection) {
BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection)
}
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
// Send button INSIDE the field. Shown whenever a send is
// possible — text typed OR an attachment pending (issue
// #956): the old text-only gate hid the button entirely
// for attachment-only sends.
AnimatedContent(
targetState = canSend,
transitionSpec = {
(scaleIn(initialScale = 0.8f) + fadeIn())
.togetherWith(scaleOut(targetScale = 0.8f) + fadeOut())
},
label = "send_toggle",
) { showSend ->
if (showSend) {
IconButton(
onClick = onSend,
enabled = canSend,
colors = IconButtonDefaults.filledTonalIconButtonColors(),
modifier =
Modifier
.size(36.dp)
.testTag("send_button"),
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
BasicTextField(
value = inputFieldValue,
onValueChange = onInputChange,
modifier =
Modifier
.weight(1f)
.heightIn(min = 42.dp, max = 120.dp)
.padding(vertical = 4.dp)
.onFocusChanged { isFocused = it.isFocused }
.testTag("chat_input"),
enabled = isConnected,
textStyle =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
),
singleLine = false,
maxLines = 4,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
Surface(
shape = RoundedCornerShape(18.dp),
border =
BorderStroke(
width = if (isFocused) 2.dp else 1.dp,
color =
if (isFocused) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
},
),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.padding(start = 12.dp, end = 4.dp, top = 9.dp, bottom = 9.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.weight(1f),
contentAlignment =
if (isInputRtl) {
Alignment.CenterEnd
} else {
Alignment.CenterStart
},
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.chat_send_desc),
)
CompositionLocalProvider(
LocalLayoutDirection provides inputLayoutDirection,
) {
if (inputFieldValue.text.isEmpty()) {
Text(
text = placeholderText,
style = MaterialTheme.typography.bodyMedium,
textAlign =
if (isInputRtl) {
TextAlign.Right
} else {
TextAlign.Left
},
color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.6f,
),
modifier = Modifier.fillMaxWidth(),
)
}
innerTextField()
}
}
// Send button INSIDE the field. Shown whenever a send is
// possible — text typed OR an attachment pending (issue
// #956): the old text-only gate hid the button entirely
// for attachment-only sends.
AnimatedContent(
targetState = canSend,
transitionSpec = {
(scaleIn(initialScale = 0.8f) + fadeIn())
.togetherWith(scaleOut(targetScale = 0.8f) + fadeOut())
},
label = "send_toggle",
) { showSend ->
if (showSend) {
IconButton(
onClick = onSend,
enabled = canSend,
colors = IconButtonDefaults.filledTonalIconButtonColors(),
modifier =
Modifier
.size(36.dp)
.testTag("send_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription =
stringResource(
R.string.chat_send_desc,
),
)
}
}
}
}
}
}
}
},
)
},
)
}
}
// ── BOTTOM ROW: Toolbar ──
@@ -0,0 +1,153 @@
package com.m57.hermescontrol.util
import androidx.compose.ui.unit.LayoutDirection
/**
* Bidirectional (BiDi) text utilities for mixed Arabic/English content,
* dynamic layout direction resolution, and Unicode directional isolates.
*/
object BidiUtils {
const val LRI = "\u2066" // Left-to-Right Isolate
const val RLI = "\u2067" // Right-to-Left Isolate
const val FSI = "\u2068" // First-Strong Isolate
const val PDI = "\u2069" // Pop Directional Isolate
const val LRM = "\u200E" // Left-to-Right Mark
const val RLM = "\u200F" // Right-to-Left Mark
enum class BidiDirection {
LTR,
RTL,
NEUTRAL,
}
/**
* Determines whether the given text should be rendered as RTL.
*
* 1. If the first strong directional character is RTL (Arabic/Hebrew), returns true.
* 2. If the first strong directional character is LTR (e.g. an Arabic sentence starting with
* an English term like "Android هو نظام..."), returns true if RTL characters outnumber LTR characters.
* 3. If there are no strong characters (empty, numbers, emojis, symbols only), returns false.
*/
fun isRtlText(text: CharSequence): Boolean {
var firstStrongRtl: Boolean? = null
var rtlCount = 0
var ltrCount = 0
var i = 0
val len = text.length
while (i < len) {
val cp = Character.codePointAt(text, i)
when (Character.getDirectionality(cp)) {
Character.DIRECTIONALITY_RIGHT_TO_LEFT,
Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC,
-> {
if (firstStrongRtl == null) firstStrongRtl = true
rtlCount++
}
Character.DIRECTIONALITY_LEFT_TO_RIGHT -> {
if (firstStrongRtl == null) firstStrongRtl = false
ltrCount++
}
}
i += Character.charCount(cp)
}
return when {
firstStrongRtl == true -> true
firstStrongRtl == false -> rtlCount > ltrCount
else -> false
}
}
/**
* Detects the dominant [BidiDirection] of [text].
* Returns [BidiDirection.NEUTRAL] when no strong directional characters exist.
*/
fun detectTextDirection(text: CharSequence): BidiDirection {
var hasStrong = false
var firstStrongRtl: Boolean? = null
var rtlCount = 0
var ltrCount = 0
var i = 0
val len = text.length
while (i < len) {
val cp = Character.codePointAt(text, i)
when (Character.getDirectionality(cp)) {
Character.DIRECTIONALITY_RIGHT_TO_LEFT,
Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC,
-> {
hasStrong = true
if (firstStrongRtl == null) firstStrongRtl = true
rtlCount++
}
Character.DIRECTIONALITY_LEFT_TO_RIGHT -> {
hasStrong = true
if (firstStrongRtl == null) firstStrongRtl = false
ltrCount++
}
}
i += Character.charCount(cp)
}
if (!hasStrong) return BidiDirection.NEUTRAL
val isRtl =
when {
firstStrongRtl == true -> true
firstStrongRtl == false -> rtlCount > ltrCount
else -> false
}
return if (isRtl) BidiDirection.RTL else BidiDirection.LTR
}
/**
* Resolves the Compose [LayoutDirection] for [text].
* If [text] is neutral/empty, falls back to [fallback].
*/
fun resolveLayoutDirection(
text: CharSequence,
fallback: LayoutDirection = LayoutDirection.Ltr,
): LayoutDirection =
when (detectTextDirection(text)) {
BidiDirection.RTL -> LayoutDirection.Rtl
BidiDirection.LTR -> LayoutDirection.Ltr
BidiDirection.NEUTRAL -> fallback
}
/**
* Returns true if [text] contains strong LTR characters (Latin letters, digits)
* and no strong RTL characters (Arabic, Hebrew).
*/
fun isLtrSnippet(text: CharSequence): Boolean {
var hasLtr = false
var i = 0
val len = text.length
while (i < len) {
val cp = Character.codePointAt(text, i)
when (Character.getDirectionality(cp)) {
Character.DIRECTIONALITY_RIGHT_TO_LEFT,
Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC,
-> return false
Character.DIRECTIONALITY_LEFT_TO_RIGHT -> hasLtr = true
}
i += Character.charCount(cp)
}
return hasLtr
}
/**
* Wraps [text] in a Left-to-Right Isolate (LRI ... PDI) if [isLtrSnippet] is true.
* Used to prevent inline English terms and code from bleeding into surrounding RTL runs.
*/
fun wrapLtrIsolate(text: String): String = if (isLtrSnippet(text)) "$LRI$text$PDI" else text
/**
* Appends a Right-to-Left Mark (RLM) to [text] if it is RTL, ensuring trailing
* punctuation and neutral emojis anchor to the visual end of the RTL sentence.
*/
fun anchorTrailingRtl(text: String): String = if (isRtlText(text) && !text.endsWith(RLM)) "$text$RLM" else text
}