diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index a1d9c710..80dde78e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 241bd522..fe671c64 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -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, - ) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 6df12b52..19a29724 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -789,9 +789,7 @@ class ChatViewModel( streamingController.flushPendingTokens() } - else -> { - Unit - } + else -> {} } // First, let the reducer compute the new state and any effects diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index d11f5c2c..2b8b701e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -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() - 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() + 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("", i) -> { val end = src.indexOf("", i) if (end != -1) { + val raw = src.substring(i + 5, end) + val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw withStyle( SpanStyle( fontFamily = FontFamily.Monospace, background = textColor.copy(alpha = 0.12f), ), ) { - append(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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index e018f609..f99f546c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -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 ── diff --git a/app/src/main/java/com/m57/hermescontrol/util/BidiUtils.kt b/app/src/main/java/com/m57/hermescontrol/util/BidiUtils.kt new file mode 100644 index 00000000..879902cf --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/util/BidiUtils.kt @@ -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 +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt index 032c9db4..ac37ca12 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt @@ -11,6 +11,7 @@ import com.m57.hermescontrol.theme.StatusRedContainer import com.m57.hermescontrol.theme.StatusYellow import com.m57.hermescontrol.theme.StatusYellowContainer import com.m57.hermescontrol.theme.searchHighlightColors +import com.m57.hermescontrol.util.BidiUtils import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -538,4 +539,28 @@ class MarkdownTextFeatureTest { val codeBlock = blocks.single() as MdBlock.Code 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()) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/util/BidiUtilsTest.kt b/app/src/test/java/com/m57/hermescontrol/util/BidiUtilsTest.kt new file mode 100644 index 00000000..05880459 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/util/BidiUtilsTest.kt @@ -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)) + } +}