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
+515 -48

No files matched your search

@@ -1226,9 +1226,7 @@ object HermesWsClient {
disconnectIfIdleInBackground() disconnectIfIdleInBackground()
} }
else -> { else -> {}
Unit
}
} }
// tryEmit on a DROP_OLDEST flow only returns false when the // tryEmit on a DROP_OLDEST flow only returns false when the
// buffer is full AND no subscriber is draining; with extraBuffer=512 // 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.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.LocalClipboard
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString 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.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight 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.style.TextOverflow
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment 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.LightOnSurface
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.onColorFor import com.m57.hermescontrol.theme.onColorFor
import com.m57.hermescontrol.util.BidiUtils
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Instant import java.time.Instant
@@ -176,12 +181,23 @@ fun ChatBubble(
color = Color.Transparent, color = Color.Transparent,
tonalElevation = 0.dp, tonalElevation = 0.dp,
) { ) {
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)) { Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
SelectionContainer { SelectionContainer {
Text( Text(
text = highlightedText, text = highlightedText,
color = userBubbleTextColor, color = userBubbleTextColor,
style = MaterialTheme.typography.bodyMedium, style =
MaterialTheme.typography.bodyMedium.copy(
textDirection =
if (isRtl) {
TextDirection.ContentOrRtl
} else {
TextDirection.ContentOrLtr
},
),
) )
} }
// Render inline attachments // Render inline attachments
@@ -244,6 +260,7 @@ fun ChatBubble(
} }
} }
} }
}
} }
@Composable @Composable
@@ -789,9 +789,7 @@ class ChatViewModel(
streamingController.flushPendingTokens() streamingController.flushPendingTokens()
} }
else -> { else -> {}
Unit
}
} }
// First, let the reducer compute the new state and any effects // 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -33,6 +34,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle 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.BaselineShift
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.hrm.latex.renderer.LatexAutoWrap 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.LocalHermesStatusColors
import com.m57.hermescontrol.theme.SearchHighlightColors import com.m57.hermescontrol.theme.SearchHighlightColors
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 URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""")
private val TABLE_COL_WIDTH = 160.dp 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 ORDERED_LINE_RE = Regex("""^(\s*)(\d+)\.\s+(.*)$""")
private val LIST_ITEM_LINE_RE = Regex("""^(\s*)(?:[-*+]\s+(?:\[([ xX])\]\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. * 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 * 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 statusColors = LocalHermesStatusColors.current
val highlights = searchHighlightColors(statusColors) val highlights = searchHighlightColors(statusColors)
if (isStreaming) { if (isStreaming) {
val isRtl = remember(text) { BidiUtils.isRtlText(text) }
val streamingDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides streamingDirection) {
Text( Text(
text = text, text = if (isRtl) BidiUtils.anchorTrailingRtl(text) else text,
color = textColor, color = textColor,
style = MaterialTheme.typography.bodyMedium, style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
modifier = modifier, modifier = modifier,
) )
}
return return
} }
@@ -143,13 +158,20 @@ fun MarkdownText(
5 -> 15.sp 5 -> 15.sp
else -> 14.sp else -> 14.sp
} }
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
MarkdownInlineText( MarkdownInlineText(
text = block.text, text = block.text,
textColor = textColor, textColor = textColor,
latexMeasurer = latexMeasurer, latexMeasurer = latexMeasurer,
style = style =
MaterialTheme.typography.bodyMedium MaterialTheme.typography.bodyMedium
.copy(fontSize = fontSize, fontWeight = FontWeight.Bold), .copy(
fontSize = fontSize,
fontWeight = FontWeight.Bold,
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery, searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch, isCurrentMatch = isCurrentMatch,
linkColor = linkColor, linkColor = linkColor,
@@ -157,9 +179,13 @@ fun MarkdownText(
modifier = Modifier.padding(vertical = 2.dp), modifier = Modifier.padding(vertical = 2.dp),
) )
} }
}
is MdBlock.Bullet -> { is MdBlock.Bullet -> {
val indent = (block.level * 16).dp 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( Row(
modifier = modifier =
Modifier Modifier
@@ -184,7 +210,10 @@ fun MarkdownText(
text = block.text, text = block.text,
textColor = textColor, textColor = textColor,
latexMeasurer = latexMeasurer, latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium, style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery, searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch, isCurrentMatch = isCurrentMatch,
linkColor = linkColor, linkColor = linkColor,
@@ -193,9 +222,13 @@ fun MarkdownText(
) )
} }
} }
}
is MdBlock.Task -> { is MdBlock.Task -> {
val indent = (block.level * 16).dp 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( Row(
modifier = modifier =
Modifier Modifier
@@ -226,7 +259,10 @@ fun MarkdownText(
text = block.text, text = block.text,
textColor = textColor, textColor = textColor,
latexMeasurer = latexMeasurer, latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium, style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery, searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch, isCurrentMatch = isCurrentMatch,
linkColor = linkColor, linkColor = linkColor,
@@ -235,9 +271,13 @@ fun MarkdownText(
) )
} }
} }
}
is MdBlock.Ordered -> { is MdBlock.Ordered -> {
val indent = (block.level * 16).dp 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( Row(
modifier = modifier =
Modifier Modifier
@@ -256,7 +296,10 @@ fun MarkdownText(
text = block.text, text = block.text,
textColor = textColor, textColor = textColor,
latexMeasurer = latexMeasurer, latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium, style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery, searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch, isCurrentMatch = isCurrentMatch,
linkColor = linkColor, linkColor = linkColor,
@@ -265,8 +308,12 @@ fun MarkdownText(
) )
} }
} }
}
is MdBlock.Quote -> { is MdBlock.Quote -> {
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
Row( Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min).padding(vertical = 2.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min).padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -284,7 +331,11 @@ fun MarkdownText(
text = block.text, text = block.text,
textColor = textColor, textColor = textColor,
latexMeasurer = latexMeasurer, latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic), style =
MaterialTheme.typography.bodyMedium.copy(
fontStyle = FontStyle.Italic,
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery, searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch, isCurrentMatch = isCurrentMatch,
linkColor = linkColor, linkColor = linkColor,
@@ -293,6 +344,7 @@ fun MarkdownText(
) )
} }
} }
}
is MdBlock.Video -> { is MdBlock.Video -> {
val resolvedUri = remember(block.uri) { resolveImageUrl(block.uri) } val resolvedUri = remember(block.uri) { resolveImageUrl(block.uri) }
@@ -347,18 +399,36 @@ fun MarkdownText(
} }
is MdBlock.DefList -> { is MdBlock.DefList -> {
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)) { Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
block.items.forEach { item -> block.items.forEach { item ->
val itemRtl = BidiUtils.isRtlText(item.term)
Text( Text(
text = item.term, text = if (itemRtl) BidiUtils.anchorTrailingRtl(item.term) else item.term,
color = textColor, color = textColor,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold), style =
MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Bold,
textDirection = bidiTextDirection(itemRtl),
),
) )
item.definitions.forEach { def -> item.definitions.forEach { def ->
val defRtl = BidiUtils.isRtlText(def)
Text( Text(
text = def, text = if (defRtl) BidiUtils.anchorTrailingRtl(def) else def,
color = textColor, color = textColor,
style = MaterialTheme.typography.bodyMedium, style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(defRtl),
),
modifier = Modifier.padding(start = 16.dp, bottom = 2.dp), modifier = Modifier.padding(start = 16.dp, bottom = 2.dp),
) )
} }
@@ -366,6 +436,7 @@ fun MarkdownText(
} }
} }
} }
}
is MdBlock.Table -> { is MdBlock.Table -> {
MarkdownTable(block = block, textColor = textColor) MarkdownTable(block = block, textColor = textColor)
@@ -384,6 +455,9 @@ fun MarkdownText(
modifier = Modifier.padding(bottom = 2.dp), modifier = Modifier.padding(bottom = 2.dp),
) )
block.notes.forEach { note -> block.notes.forEach { note ->
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)) { Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
Text( Text(
text = "[${note.id}] ", text = "[${note.id}] ",
@@ -394,7 +468,10 @@ fun MarkdownText(
text = note.text, text = note.text,
textColor = textColor, textColor = textColor,
latexMeasurer = latexMeasurer, latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodySmall, style =
MaterialTheme.typography.bodySmall.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery, searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch, isCurrentMatch = isCurrentMatch,
linkColor = linkColor, linkColor = linkColor,
@@ -405,13 +482,20 @@ fun MarkdownText(
} }
} }
} }
}
is MdBlock.Paragraph -> { is MdBlock.Paragraph -> {
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
MarkdownInlineText( MarkdownInlineText(
text = block.text, text = block.text,
textColor = textColor, textColor = textColor,
latexMeasurer = latexMeasurer, latexMeasurer = latexMeasurer,
style = MaterialTheme.typography.bodyMedium, style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery, searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch, isCurrentMatch = isCurrentMatch,
linkColor = linkColor, linkColor = linkColor,
@@ -422,6 +506,7 @@ fun MarkdownText(
} }
} }
} }
}
} }
/** /**
@@ -462,18 +547,38 @@ private fun MarkdownInlineText(
highlights: SearchHighlightColors, highlights: SearchHighlightColors,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val segments = remember(text) { splitInlineMath(text) } 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,
)
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 }) { if (segments.none { it is InlineMathSegment.Math }) {
Text( Text(
text = text =
remember(text, searchQuery, isCurrentMatch, textColor, linkColor) { remember(processedText, searchQuery, isCurrentMatch, textColor, linkColor, isRtl) {
parseInlineSource(text, textColor, searchQuery, isCurrentMatch, linkColor, highlights) parseInlineSource(
text = processedText,
textColor = textColor,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
isRtl = isRtl,
)
}, },
color = textColor, color = textColor,
style = style, style = resolvedStyle,
modifier = modifier, modifier = modifier,
) )
return return@CompositionLocalProvider
} }
val config = val config =
@@ -500,12 +605,13 @@ private fun MarkdownInlineText(
is InlineMathSegment.Text -> { is InlineMathSegment.Text -> {
append( append(
parseInlineSource( parseInlineSource(
segment.value, text = segment.value,
textColor, textColor = textColor,
searchQuery, searchQuery = searchQuery,
isCurrentMatch, isCurrentMatch = isCurrentMatch,
linkColor, linkColor = linkColor,
highlights, highlights = highlights,
isRtl = isRtl,
), ),
) )
} }
@@ -516,9 +622,10 @@ private fun MarkdownInlineText(
text = annotated, text = annotated,
inlineContent = inlineContent, inlineContent = inlineContent,
color = textColor, color = textColor,
style = style, style = resolvedStyle,
modifier = modifier, modifier = modifier,
) )
}
} }
@Composable @Composable
@@ -526,8 +633,15 @@ private fun MarkdownTable(
block: MdBlock.Table, block: MdBlock.Table,
textColor: Color, 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 headerBg = textColor.copy(alpha = 0.08f)
val alignments = block.alignments val alignments = block.alignments
CompositionLocalProvider(LocalLayoutDirection provides tableDirection) {
Column( Column(
modifier = modifier =
Modifier Modifier
@@ -538,11 +652,16 @@ private fun MarkdownTable(
// Header row // Header row
Row(modifier = Modifier.background(headerBg)) { Row(modifier = Modifier.background(headerBg)) {
block.header.forEachIndexed { idx, cell -> block.header.forEachIndexed { idx, cell ->
val cellRtl = BidiUtils.isRtlText(cell)
Text( Text(
text = cell, text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
textAlign = tableTextAlign(alignments.getOrNull(idx)), textAlign = tableTextAlign(alignments.getOrNull(idx)),
color = textColor, color = textColor,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold), style =
MaterialTheme.typography.bodySmall.copy(
fontWeight = FontWeight.Bold,
textDirection = bidiTextDirection(cellRtl),
),
modifier = modifier =
Modifier Modifier
.width(TABLE_COL_WIDTH) .width(TABLE_COL_WIDTH)
@@ -555,11 +674,15 @@ private fun MarkdownTable(
block.rows.forEach { row -> block.rows.forEach { row ->
Row { Row {
row.forEachIndexed { idx, cell -> row.forEachIndexed { idx, cell ->
val cellRtl = BidiUtils.isRtlText(cell)
Text( Text(
text = cell, text = if (cellRtl) BidiUtils.anchorTrailingRtl(cell) else cell,
textAlign = tableTextAlign(alignments.getOrNull(idx)), textAlign = tableTextAlign(alignments.getOrNull(idx)),
color = textColor, color = textColor,
style = MaterialTheme.typography.bodySmall, style =
MaterialTheme.typography.bodySmall.copy(
textDirection = bidiTextDirection(cellRtl),
),
modifier = modifier =
Modifier Modifier
.width(TABLE_COL_WIDTH) .width(TABLE_COL_WIDTH)
@@ -570,6 +693,7 @@ private fun MarkdownTable(
HorizontalDivider(color = textColor.copy(alpha = 0.1f)) HorizontalDivider(color = textColor.copy(alpha = 0.1f))
} }
} }
}
} }
private fun tableTextAlign(align: TableAlign?): TextAlign? = private fun tableTextAlign(align: TableAlign?): TextAlign? =
@@ -1116,6 +1240,7 @@ internal fun parseInline(
isCurrentMatch: Boolean, isCurrentMatch: Boolean,
linkColor: Color, linkColor: Color,
highlights: SearchHighlightColors, highlights: SearchHighlightColors,
isRtl: Boolean = BidiUtils.isRtlText(text),
): AnnotatedString = ): AnnotatedString =
parseInlineSource( parseInlineSource(
text = text =
@@ -1130,6 +1255,7 @@ internal fun parseInline(
isCurrentMatch = isCurrentMatch, isCurrentMatch = isCurrentMatch,
linkColor = linkColor, linkColor = linkColor,
highlights = highlights, highlights = highlights,
isRtl = isRtl,
) )
private fun parseInlineSource( private fun parseInlineSource(
@@ -1139,6 +1265,7 @@ private fun parseInlineSource(
isCurrentMatch: Boolean, isCurrentMatch: Boolean,
linkColor: Color, linkColor: Color,
highlights: SearchHighlightColors, highlights: SearchHighlightColors,
isRtl: Boolean = BidiUtils.isRtlText(text),
): AnnotatedString { ): AnnotatedString {
val searchHighlightColor = val searchHighlightColor =
if (isCurrentMatch) { if (isCurrentMatch) {
@@ -1156,8 +1283,10 @@ private fun parseInlineSource(
src.startsWith("***", i) -> { src.startsWith("***", i) -> {
val end = src.indexOf("***", i + 3) val end = src.indexOf("***", i + 3)
if (end != -1) { 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)) { withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
append(src.substring(i + 3, end)) append(toAppend)
} }
i = end + 3 i = end + 3
} else { } else {
@@ -1170,8 +1299,10 @@ private fun parseInlineSource(
src.startsWith("**", i) -> { src.startsWith("**", i) -> {
val end = src.indexOf("**", i + 2) val end = src.indexOf("**", i + 2)
if (end != -1) { if (end != -1) {
val raw = src.substring(i + 2, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(src.substring(i + 2, end)) append(toAppend)
} }
i = end + 2 i = end + 2
} else { } else {
@@ -1184,8 +1315,10 @@ private fun parseInlineSource(
src.startsWith("~~", i) -> { src.startsWith("~~", i) -> {
val end = src.indexOf("~~", i + 2) val end = src.indexOf("~~", i + 2)
if (end != -1) { if (end != -1) {
val raw = src.substring(i + 2, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
append(src.substring(i + 2, end)) append(toAppend)
} }
i = end + 2 i = end + 2
} else { } else {
@@ -1198,8 +1331,10 @@ private fun parseInlineSource(
src.startsWith("*", i) -> { src.startsWith("*", i) -> {
val end = src.indexOf('*', i + 1) val end = src.indexOf('*', i + 1)
if (end != -1 && end > 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)) { withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
append(src.substring(i + 1, end)) append(toAppend)
} }
i = end + 1 i = end + 1
} else { } else {
@@ -1212,8 +1347,10 @@ private fun parseInlineSource(
src.startsWith("==", i) -> { src.startsWith("==", i) -> {
val end = src.indexOf("==", i + 2) val end = src.indexOf("==", i + 2)
if (end != -1) { if (end != -1) {
val raw = src.substring(i + 2, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(SpanStyle(background = highlights.markupBackground)) { withStyle(SpanStyle(background = highlights.markupBackground)) {
append(src.substring(i + 2, end)) append(toAppend)
} }
i = end + 2 i = end + 2
} else { } else {
@@ -1254,13 +1391,15 @@ private fun parseInlineSource(
src.startsWith("<kbd>", i) -> { src.startsWith("<kbd>", i) -> {
val end = src.indexOf("</kbd>", i) val end = src.indexOf("</kbd>", i)
if (end != -1) { if (end != -1) {
val raw = src.substring(i + 5, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle( withStyle(
SpanStyle( SpanStyle(
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
background = textColor.copy(alpha = 0.12f), background = textColor.copy(alpha = 0.12f),
), ),
) { ) {
append(src.substring(i + 5, end)) append(toAppend)
} }
i = end + 6 i = end + 6
} else { } else {
@@ -1297,6 +1436,7 @@ private fun parseInlineSource(
val urlEnd = src.indexOf(')', close + 2) val urlEnd = src.indexOf(')', close + 2)
if (urlEnd != -1) { if (urlEnd != -1) {
val label = src.substring(i + 1, close) val label = src.substring(i + 1, close)
val labelToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(label) else label
val url = src.substring(close + 2, urlEnd) val url = src.substring(close + 2, urlEnd)
pushLink(LinkAnnotation.Url(url)) pushLink(LinkAnnotation.Url(url))
withStyle( withStyle(
@@ -1305,7 +1445,7 @@ private fun parseInlineSource(
textDecoration = TextDecoration.Underline, textDecoration = TextDecoration.Underline,
), ),
) { ) {
append(label) append(labelToAppend)
} }
pop() pop()
i = urlEnd + 1 i = urlEnd + 1
@@ -1323,6 +1463,8 @@ private fun parseInlineSource(
src.startsWith("`", i) -> { src.startsWith("`", i) -> {
val end = src.indexOf('`', i + 1) val end = src.indexOf('`', i + 1)
if (end != -1) { if (end != -1) {
val raw = src.substring(i + 1, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle( withStyle(
SpanStyle( SpanStyle(
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
@@ -1330,7 +1472,7 @@ private fun parseInlineSource(
background = textColor.copy(alpha = 0.08f), background = textColor.copy(alpha = 0.08f),
), ),
) { ) {
append(src.substring(i + 1, end)) append(toAppend)
} }
i = end + 1 i = end + 1
} else { } else {
@@ -1343,6 +1485,7 @@ private fun parseInlineSource(
URL_PATTERN.matchAt(src, i) != null -> { URL_PATTERN.matchAt(src, i) != null -> {
val match = URL_PATTERN.matchAt(src, i)!! val match = URL_PATTERN.matchAt(src, i)!!
val url = match.value val url = match.value
val urlToAppend = if (isRtl) BidiUtils.wrapLtrIsolate(url) else url
pushLink(LinkAnnotation.Url(url)) pushLink(LinkAnnotation.Url(url))
withStyle( withStyle(
SpanStyle( SpanStyle(
@@ -1350,7 +1493,7 @@ private fun parseInlineSource(
textDecoration = TextDecoration.Underline, textDecoration = TextDecoration.Underline,
), ),
) { ) {
append(url) append(urlToAppend)
} }
pop() pop()
i = match.range.last + 1 i = match.range.last + 1
@@ -43,6 +43,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue 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.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage 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.data.ws.CommandCatalog
import com.m57.hermescontrol.ui.chat.ChatInputPolicy import com.m57.hermescontrol.ui.chat.ChatInputPolicy
import com.m57.hermescontrol.ui.common.BotAvatar 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, * The chat input bar with a two-row layout: input+send on top,
@@ -318,6 +324,14 @@ fun ChatInputBar(
} }
} }
val ambientLayoutDirection = LocalLayoutDirection.current
val inputLayoutDirection =
remember(inputFieldValue.text, ambientLayoutDirection) {
BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection)
}
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
BasicTextField( BasicTextField(
value = inputFieldValue, value = inputFieldValue,
onValueChange = onInputChange, onValueChange = onInputChange,
@@ -332,11 +346,14 @@ fun ChatInputBar(
textStyle = textStyle =
MaterialTheme.typography.bodyMedium.copy( MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
), ),
singleLine = false, singleLine = false,
maxLines = 4, maxLines = 4,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField -> decorationBox = { innerTextField ->
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
Surface( Surface(
shape = RoundedCornerShape(18.dp), shape = RoundedCornerShape(18.dp),
border = border =
@@ -359,16 +376,38 @@ fun ChatInputBar(
.fillMaxWidth(), .fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box(modifier = Modifier.weight(1f)) { Box(
modifier = Modifier.weight(1f),
contentAlignment =
if (isInputRtl) {
Alignment.CenterEnd
} else {
Alignment.CenterStart
},
) {
CompositionLocalProvider(
LocalLayoutDirection provides inputLayoutDirection,
) {
if (inputFieldValue.text.isEmpty()) { if (inputFieldValue.text.isEmpty()) {
Text( Text(
text = placeholderText, text = placeholderText,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), textAlign =
if (isInputRtl) {
TextAlign.Right
} else {
TextAlign.Left
},
color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.6f,
),
modifier = Modifier.fillMaxWidth(),
) )
} }
innerTextField() innerTextField()
} }
}
// Send button INSIDE the field. Shown whenever a send is // Send button INSIDE the field. Shown whenever a send is
// possible — text typed OR an attachment pending (issue // possible — text typed OR an attachment pending (issue
@@ -394,16 +433,21 @@ fun ChatInputBar(
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.Send, imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.chat_send_desc), contentDescription =
stringResource(
R.string.chat_send_desc,
),
) )
} }
} }
} }
} }
} }
}
}, },
) )
} }
}
// ── BOTTOM ROW: Toolbar ── // ── BOTTOM ROW: Toolbar ──
ComposerToolbar( ComposerToolbar(
@@ -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
}
@@ -11,6 +11,7 @@ import com.m57.hermescontrol.theme.StatusRedContainer
import com.m57.hermescontrol.theme.StatusYellow import com.m57.hermescontrol.theme.StatusYellow
import com.m57.hermescontrol.theme.StatusYellowContainer import com.m57.hermescontrol.theme.StatusYellowContainer
import com.m57.hermescontrol.theme.searchHighlightColors import com.m57.hermescontrol.theme.searchHighlightColors
import com.m57.hermescontrol.util.BidiUtils
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
@@ -538,4 +539,28 @@ class MarkdownTextFeatureTest {
val codeBlock = blocks.single() as MdBlock.Code val codeBlock = blocks.single() as MdBlock.Code
assertTrue(codeBlock.code.contains("```python")) assertTrue(codeBlock.code.contains("```python"))
} }
// 25. ARABIC BIDI MIXED TEXT (issue #1044)
@Test
fun testArabicMixedWithInlineCode_wrapsWithLtrIsolate() {
val input = "تم اختبار كود `val x = 1` بنجاح"
val parsed = parseInline(input, Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
val expected = "تم اختبار كود ${BidiUtils.LRI}val x = 1${BidiUtils.PDI} بنجاح"
assertEquals(expected, parsed.toString())
}
@Test
fun testArabicMixedWithBoldEnglish_wrapsWithLtrIsolate() {
val input = "هذا النص يحتوي على **Android** داخل فقرة"
val parsed = parseInline(input, Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
val expected = "هذا النص يحتوي على ${BidiUtils.LRI}Android${BidiUtils.PDI} داخل فقرة"
assertEquals(expected, parsed.toString())
}
@Test
fun testEnglishWithInlineCode_doesNotWrapWithIsolate() {
val input = "Run `val x = 1` here"
val parsed = parseInline(input, Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
assertEquals("Run val x = 1 here", parsed.toString())
}
} }
@@ -0,0 +1,89 @@
package com.m57.hermescontrol.util
import androidx.compose.ui.unit.LayoutDirection
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BidiUtilsTest {
@Test
fun testIsRtlText_pureArabic() {
assertTrue(BidiUtils.isRtlText("مرحبا بك في تطبيق هيرمس"))
}
@Test
fun testIsRtlText_pureEnglish() {
assertFalse(BidiUtils.isRtlText("Hello from Hermes Control"))
}
@Test
fun testIsRtlText_mixedArabicWithEnglishWords() {
val text = "هذا النص يحتوي على Android و Compose داخل فقرة عربية"
assertTrue(BidiUtils.isRtlText(text))
}
@Test
fun testIsRtlText_arabicStartingWithEnglishTerm() {
val text = "Android هو نظام تشغيل رائع ومميز جدا"
assertTrue(BidiUtils.isRtlText(text))
}
@Test
fun testIsRtlText_englishSentenceWithArabicWord() {
val text = "The Arabic word for welcome is مرحبا in most dialects"
assertFalse(BidiUtils.isRtlText(text))
}
@Test
fun testIsRtlText_neutralOnly() {
assertFalse(BidiUtils.isRtlText(""))
assertFalse(BidiUtils.isRtlText(" "))
assertFalse(BidiUtils.isRtlText("12345"))
assertFalse(BidiUtils.isRtlText("🚀✨🔥"))
assertFalse(BidiUtils.isRtlText("/status"))
}
@Test
fun testIsRtlText_neutralPrefixWithArabic() {
assertTrue(BidiUtils.isRtlText("🚀 مرحبا يا صديقي"))
assertTrue(BidiUtils.isRtlText("- [x] مهمة جديدة مع Compose"))
}
@Test
fun testDetectTextDirection() {
assertEquals(BidiUtils.BidiDirection.RTL, BidiUtils.detectTextDirection("أهلا"))
assertEquals(BidiUtils.BidiDirection.LTR, BidiUtils.detectTextDirection("Hello"))
assertEquals(BidiUtils.BidiDirection.NEUTRAL, BidiUtils.detectTextDirection("123 🚀"))
}
@Test
fun testResolveLayoutDirection() {
assertEquals(LayoutDirection.Rtl, BidiUtils.resolveLayoutDirection("صباح الخير"))
assertEquals(LayoutDirection.Ltr, BidiUtils.resolveLayoutDirection("Good morning"))
assertEquals(LayoutDirection.Ltr, BidiUtils.resolveLayoutDirection("123", fallback = LayoutDirection.Ltr))
assertEquals(LayoutDirection.Rtl, BidiUtils.resolveLayoutDirection("123", fallback = LayoutDirection.Rtl))
}
@Test
fun testWrapLtrIsolate() {
assertEquals("${BidiUtils.LRI}Android${BidiUtils.PDI}", BidiUtils.wrapLtrIsolate("Android"))
assertEquals("${BidiUtils.LRI}val x = 1${BidiUtils.PDI}", BidiUtils.wrapLtrIsolate("val x = 1"))
// RTL snippet should not be wrapped
assertEquals("مرحبا", BidiUtils.wrapLtrIsolate("مرحبا"))
}
@Test
fun testAnchorTrailingRtl() {
val rtlWithEmoji = "مرحبا بكم 🚀"
assertEquals("مرحبا بكم 🚀${BidiUtils.RLM}", BidiUtils.anchorTrailingRtl(rtlWithEmoji))
// Does not append duplicate RLM
val alreadyAnchored = "مرحبا بكم 🚀${BidiUtils.RLM}"
assertEquals(alreadyAnchored, BidiUtils.anchorTrailingRtl(alreadyAnchored))
// LTR text remains unmodified
val ltrWithEmoji = "Welcome to Hermes 🚀"
assertEquals(ltrWithEmoji, BidiUtils.anchorTrailingRtl(ltrWithEmoji))
}
}