fix(bidi): isolate leading and inline English words in RTL paragraphs

- In parseInlineSource, wrap consecutive Latin/LTR tokens in LTR isolates (LRI...PDI)
  when rendering inside an RTL block, preventing leading English terms like "Okay"
  from flipping paragraph layout direction and causing BiDi line shuffling.
- Switch textDirection from TextDirection.ContentOrRtl to explicit TextDirection.Rtl
  when isRtl is true across MarkdownText, ChatBubble, and GroupChatScreen, preventing
  first-strong character fallbacks from overriding resolved paragraph direction.
This commit is contained in:
M57 committed 2026-09-04 23:22:22 +03:00
1 parent 199771b798
commit 5ce01e429b
4 files changed
+50 -15

No files matched your search

@@ -292,9 +292,9 @@ fun GroupChatScreen(
MaterialTheme.typography.bodySmall.copy(
textDirection =
if (isThinkingRtl) {
TextDirection.ContentOrRtl
TextDirection.Rtl
} else {
TextDirection.ContentOrLtr
TextDirection.Ltr
},
),
color = MaterialTheme.colorScheme.primary,
@@ -914,9 +914,9 @@ private fun GroupMessageCard(
MaterialTheme.typography.labelSmall.copy(
textDirection =
if (isSystemRtl) {
TextDirection.ContentOrRtl
TextDirection.Rtl
} else {
TextDirection.ContentOrLtr
TextDirection.Ltr
},
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -946,9 +946,9 @@ private fun GroupMessageCard(
MaterialTheme.typography.bodyMedium.copy(
textDirection =
if (isUserRtl) {
TextDirection.ContentOrRtl
TextDirection.Rtl
} else {
TextDirection.ContentOrLtr
TextDirection.Ltr
},
),
color = MaterialTheme.colorScheme.onPrimary,
@@ -1017,9 +1017,9 @@ private fun GroupMessageCard(
MaterialTheme.typography.labelMedium.copy(
textDirection =
if (isBotNameRtl) {
TextDirection.ContentOrRtl
TextDirection.Rtl
} else {
TextDirection.ContentOrLtr
TextDirection.Ltr
},
),
fontWeight = FontWeight.Bold,
@@ -1168,7 +1168,7 @@ private fun GroupChatToolChip(
text = label,
style =
MaterialTheme.typography.labelSmall.copy(
textDirection = if (isLabelRtl) TextDirection.ContentOrRtl else TextDirection.ContentOrLtr,
textDirection = if (isLabelRtl) TextDirection.Rtl else TextDirection.Ltr,
),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
@@ -1246,7 +1246,7 @@ private fun GroupChatToolChip(
fontFamily = FontFamily.Monospace,
textDirection =
if (isOutputRtl) {
TextDirection.ContentOrRtl
TextDirection.Rtl
} else {
TextDirection.Ltr
},
@@ -197,9 +197,9 @@ fun ChatBubble(
MaterialTheme.typography.bodyMedium.copy(
textDirection =
if (isRtl) {
TextDirection.ContentOrRtl
TextDirection.Rtl
} else {
TextDirection.ContentOrLtr
TextDirection.Ltr
},
),
)
@@ -71,8 +71,7 @@ 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
private fun bidiTextDirection(isRtl: Boolean): TextDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr
/**
* Renders chat assistant text as Markdown — but ONLY once the message has finished streaming.
@@ -551,7 +550,7 @@ private fun MarkdownInlineText(
val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
val resolvedStyle =
style.copy(
textDirection = if (isRtl) TextDirection.ContentOrRtl else TextDirection.ContentOrLtr,
textDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr,
)
val processedText =
remember(text, isRtl) {
@@ -1499,6 +1498,34 @@ private fun parseInlineSource(
i = match.range.last + 1
}
// Plain text / words: when inside RTL text, wrap sequences of Latin/LTR words in LTR isolate
// so an English word at the start/middle of an Arabic line doesn't flip the layout direction.
isRtl && Character.isLetterOrDigit(src.codePointAt(i)) &&
Character.getDirectionality(src.codePointAt(i)) == Character.DIRECTIONALITY_LEFT_TO_RIGHT -> {
var end = i + Character.charCount(src.codePointAt(i))
while (end < src.length) {
val cp = src.codePointAt(end)
val dir = Character.getDirectionality(cp)
if (dir == Character.DIRECTIONALITY_LEFT_TO_RIGHT ||
dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER ||
dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_SEPARATOR ||
dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_TERMINATOR ||
(
dir == Character.DIRECTIONALITY_WHITESPACE && end + 1 < src.length &&
Character.getDirectionality(src.codePointAt(end + 1)) ==
Character.DIRECTIONALITY_LEFT_TO_RIGHT
)
) {
end += Character.charCount(cp)
} else {
break
}
}
val ltrSnippet = src.substring(i, end)
append(BidiUtils.wrapLtrIsolate(ltrSnippet))
i = end
}
// search highlight
searchQuery.isNotEmpty() &&
src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> {