feat(chat): support dynamic BiDi Arabic input and isolated mixed text in Markdown (#1047)
This commit is contained in:
8 files changed
+868
-401
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,67 +181,79 @@ fun ChatBubble(
|
|||||||
color = Color.Transparent,
|
color = Color.Transparent,
|
||||||
tonalElevation = 0.dp,
|
tonalElevation = 0.dp,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
|
val isRtl = remember(message.content) { BidiUtils.isRtlText(message.content) }
|
||||||
SelectionContainer {
|
val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
Text(
|
CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) {
|
||||||
text = highlightedText,
|
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
|
||||||
color = userBubbleTextColor,
|
SelectionContainer {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Text(
|
||||||
)
|
text = highlightedText,
|
||||||
}
|
color = userBubbleTextColor,
|
||||||
// Render inline attachments
|
style =
|
||||||
if (!message.attachments.isNullOrEmpty()) {
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
textDirection =
|
||||||
message.attachments.forEach { attachment ->
|
if (isRtl) {
|
||||||
InlineAttachment(
|
TextDirection.ContentOrRtl
|
||||||
attachment = attachment,
|
} else {
|
||||||
textColor = userBubbleTextColor,
|
TextDirection.ContentOrLtr
|
||||||
onOpen = { onOpenAttachment(it) },
|
},
|
||||||
onSave = { onSaveAttachment(it) },
|
),
|
||||||
savingPath = savingAttachmentPath,
|
|
||||||
openingPath = openingAttachmentPath,
|
|
||||||
canSave = canSaveAttachment,
|
|
||||||
onImageClick = onImageClick,
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
}
|
}
|
||||||
}
|
// Render inline attachments
|
||||||
if (!message.isStreaming) {
|
if (!message.attachments.isNullOrEmpty()) {
|
||||||
Row(
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
modifier =
|
message.attachments.forEach { attachment ->
|
||||||
Modifier
|
InlineAttachment(
|
||||||
.align(Alignment.End)
|
attachment = attachment,
|
||||||
.padding(top = 4.dp),
|
textColor = userBubbleTextColor,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
onOpen = { onOpenAttachment(it) },
|
||||||
) {
|
onSave = { onSaveAttachment(it) },
|
||||||
IconButton(
|
savingPath = savingAttachmentPath,
|
||||||
onClick = {
|
openingPath = openingAttachmentPath,
|
||||||
scope.launch {
|
canSave = canSaveAttachment,
|
||||||
clipboard.setClipEntry(
|
onImageClick = onImageClick,
|
||||||
ClipEntry(ClipData.newPlainText(null, message.content)),
|
)
|
||||||
)
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
copied = true
|
}
|
||||||
},
|
if (!message.isStreaming) {
|
||||||
modifier = Modifier.size(20.dp),
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.End)
|
||||||
|
.padding(top = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
IconButton(
|
||||||
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
onClick = {
|
||||||
contentDescription = stringResource(R.string.content_desc_copy),
|
scope.launch {
|
||||||
modifier = Modifier.size(12.dp),
|
clipboard.setClipEntry(
|
||||||
tint = userBubbleTextColor.copy(alpha = 0.7f),
|
ClipEntry(ClipData.newPlainText(null, message.content)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
copied = true
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
||||||
|
contentDescription = stringResource(R.string.content_desc_copy),
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = userBubbleTextColor.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
formatTimestamp(
|
||||||
|
message.timestamp,
|
||||||
|
DateFormat.is24HourFormat(LocalContext.current),
|
||||||
|
),
|
||||||
|
color = userBubbleTextColor.copy(alpha = 0.6f),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
formatTimestamp(
|
|
||||||
message.timestamp,
|
|
||||||
DateFormat.is24HourFormat(LocalContext.current),
|
|
||||||
),
|
|
||||||
color = userBubbleTextColor.copy(alpha = 0.6f),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -789,9 +789,7 @@ class ChatViewModel(
|
|||||||
streamingController.flushPendingTokens()
|
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) {
|
||||||
Text(
|
val isRtl = remember(text) { BidiUtils.isRtlText(text) }
|
||||||
text = text,
|
val streamingDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
color = textColor,
|
CompositionLocalProvider(LocalLayoutDirection provides streamingDirection) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Text(
|
||||||
modifier = modifier,
|
text = if (isRtl) BidiUtils.anchorTrailingRtl(text) else text,
|
||||||
)
|
color = textColor,
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
textDirection = bidiTextDirection(isRtl),
|
||||||
|
),
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,154 +158,191 @@ fun MarkdownText(
|
|||||||
5 -> 15.sp
|
5 -> 15.sp
|
||||||
else -> 14.sp
|
else -> 14.sp
|
||||||
}
|
}
|
||||||
MarkdownInlineText(
|
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
|
||||||
text = block.text,
|
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
textColor = textColor,
|
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
|
||||||
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),
|
|
||||||
)
|
|
||||||
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(
|
||||||
|
fontSize = fontSize,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textDirection = bidiTextDirection(isRtl),
|
||||||
|
),
|
||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
isCurrentMatch = isCurrentMatch,
|
isCurrentMatch = isCurrentMatch,
|
||||||
linkColor = linkColor,
|
linkColor = linkColor,
|
||||||
highlights = highlights,
|
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 -> {
|
is MdBlock.Task -> {
|
||||||
val indent = (block.level * 16).dp
|
val indent = (block.level * 16).dp
|
||||||
Row(
|
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
|
||||||
modifier =
|
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
Modifier
|
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
|
||||||
.fillMaxWidth()
|
Row(
|
||||||
.padding(start = indent)
|
modifier =
|
||||||
.padding(vertical = 1.dp),
|
Modifier
|
||||||
verticalAlignment = Alignment.Top,
|
.fillMaxWidth()
|
||||||
) {
|
.padding(start = indent)
|
||||||
Icon(
|
.padding(vertical = 1.dp),
|
||||||
imageVector =
|
verticalAlignment = Alignment.Top,
|
||||||
if (block.checked) {
|
) {
|
||||||
Icons.Outlined.CheckBox
|
Icon(
|
||||||
} else {
|
imageVector =
|
||||||
Icons.Outlined.CheckBoxOutlineBlank
|
if (block.checked) {
|
||||||
},
|
Icons.Outlined.CheckBox
|
||||||
contentDescription = null,
|
} else {
|
||||||
tint =
|
Icons.Outlined.CheckBoxOutlineBlank
|
||||||
if (block.checked) {
|
},
|
||||||
MaterialTheme.colorScheme.primary
|
contentDescription = null,
|
||||||
} else {
|
tint =
|
||||||
textColor.copy(
|
if (block.checked) {
|
||||||
alpha = 0.6f,
|
MaterialTheme.colorScheme.primary
|
||||||
)
|
} else {
|
||||||
},
|
textColor.copy(
|
||||||
modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp),
|
alpha = 0.6f,
|
||||||
)
|
)
|
||||||
MarkdownInlineText(
|
},
|
||||||
text = block.text,
|
modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp),
|
||||||
textColor = textColor,
|
)
|
||||||
latexMeasurer = latexMeasurer,
|
MarkdownInlineText(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
text = block.text,
|
||||||
searchQuery = searchQuery,
|
textColor = textColor,
|
||||||
isCurrentMatch = isCurrentMatch,
|
latexMeasurer = latexMeasurer,
|
||||||
linkColor = linkColor,
|
style =
|
||||||
highlights = highlights,
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
modifier = Modifier.weight(1f),
|
textDirection = bidiTextDirection(isRtl),
|
||||||
)
|
),
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
isCurrentMatch = isCurrentMatch,
|
||||||
|
linkColor = linkColor,
|
||||||
|
highlights = highlights,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is MdBlock.Ordered -> {
|
is MdBlock.Ordered -> {
|
||||||
val indent = (block.level * 16).dp
|
val indent = (block.level * 16).dp
|
||||||
Row(
|
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
|
||||||
modifier =
|
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
Modifier
|
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
|
||||||
.fillMaxWidth()
|
Row(
|
||||||
.padding(start = indent)
|
modifier =
|
||||||
.padding(vertical = 1.dp),
|
Modifier
|
||||||
verticalAlignment = Alignment.Top,
|
.fillMaxWidth()
|
||||||
) {
|
.padding(start = indent)
|
||||||
Text(
|
.padding(vertical = 1.dp),
|
||||||
text = "${block.index}.",
|
verticalAlignment = Alignment.Top,
|
||||||
color = textColor,
|
) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Text(
|
||||||
modifier = Modifier.padding(end = 6.dp),
|
text = "${block.index}.",
|
||||||
)
|
color = textColor,
|
||||||
MarkdownInlineText(
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
text = block.text,
|
modifier = Modifier.padding(end = 6.dp),
|
||||||
textColor = textColor,
|
)
|
||||||
latexMeasurer = latexMeasurer,
|
MarkdownInlineText(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
text = block.text,
|
||||||
searchQuery = searchQuery,
|
textColor = textColor,
|
||||||
isCurrentMatch = isCurrentMatch,
|
latexMeasurer = latexMeasurer,
|
||||||
linkColor = linkColor,
|
style =
|
||||||
highlights = highlights,
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
modifier = Modifier.weight(1f),
|
textDirection = bidiTextDirection(isRtl),
|
||||||
)
|
),
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
isCurrentMatch = isCurrentMatch,
|
||||||
|
linkColor = linkColor,
|
||||||
|
highlights = highlights,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is MdBlock.Quote -> {
|
is MdBlock.Quote -> {
|
||||||
Row(
|
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
|
||||||
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min).padding(vertical = 2.dp),
|
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
|
||||||
) {
|
Row(
|
||||||
Box(
|
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min).padding(vertical = 2.dp),
|
||||||
modifier =
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Modifier
|
) {
|
||||||
.width(3.dp)
|
Box(
|
||||||
.fillMaxHeight()
|
modifier =
|
||||||
.clip(RoundedCornerShape(2.dp))
|
Modifier
|
||||||
.background(textColor.copy(alpha = 0.35f)),
|
.width(3.dp)
|
||||||
)
|
.fillMaxHeight()
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
.clip(RoundedCornerShape(2.dp))
|
||||||
MarkdownInlineText(
|
.background(textColor.copy(alpha = 0.35f)),
|
||||||
text = block.text,
|
)
|
||||||
textColor = textColor,
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
latexMeasurer = latexMeasurer,
|
MarkdownInlineText(
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
|
text = block.text,
|
||||||
searchQuery = searchQuery,
|
textColor = textColor,
|
||||||
isCurrentMatch = isCurrentMatch,
|
latexMeasurer = latexMeasurer,
|
||||||
linkColor = linkColor,
|
style =
|
||||||
highlights = highlights,
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
modifier = Modifier.weight(1f),
|
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 -> {
|
is MdBlock.DefList -> {
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
val isRtl =
|
||||||
block.items.forEach { item ->
|
remember(block.items) {
|
||||||
Text(
|
block.items.any { item ->
|
||||||
text = item.term,
|
BidiUtils.isRtlText(item.term) ||
|
||||||
color = textColor,
|
item.definitions.any { BidiUtils.isRtlText(it) }
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold),
|
}
|
||||||
)
|
}
|
||||||
item.definitions.forEach { def ->
|
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(
|
||||||
text = def,
|
text = if (itemRtl) BidiUtils.anchorTrailingRtl(item.term) else item.term,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style =
|
||||||
modifier = Modifier.padding(start = 16.dp, bottom = 2.dp),
|
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),
|
modifier = Modifier.padding(bottom = 2.dp),
|
||||||
)
|
)
|
||||||
block.notes.forEach { note ->
|
block.notes.forEach { note ->
|
||||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
|
val isRtl = remember(note.text) { BidiUtils.isRtlText(note.text) }
|
||||||
Text(
|
val noteDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
text = "[${note.id}] ",
|
CompositionLocalProvider(LocalLayoutDirection provides noteDirection) {
|
||||||
color = textColor,
|
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp)) {
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
|
Text(
|
||||||
)
|
text = "[${note.id}] ",
|
||||||
MarkdownInlineText(
|
color = textColor,
|
||||||
text = note.text,
|
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
|
||||||
textColor = textColor,
|
)
|
||||||
latexMeasurer = latexMeasurer,
|
MarkdownInlineText(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
text = note.text,
|
||||||
searchQuery = searchQuery,
|
textColor = textColor,
|
||||||
isCurrentMatch = isCurrentMatch,
|
latexMeasurer = latexMeasurer,
|
||||||
linkColor = linkColor,
|
style =
|
||||||
highlights = highlights,
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
modifier = Modifier.weight(1f),
|
textDirection = bidiTextDirection(isRtl),
|
||||||
)
|
),
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
isCurrentMatch = isCurrentMatch,
|
||||||
|
linkColor = linkColor,
|
||||||
|
highlights = highlights,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is MdBlock.Paragraph -> {
|
is MdBlock.Paragraph -> {
|
||||||
MarkdownInlineText(
|
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
|
||||||
text = block.text,
|
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
textColor = textColor,
|
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
|
||||||
latexMeasurer = latexMeasurer,
|
MarkdownInlineText(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
text = block.text,
|
||||||
searchQuery = searchQuery,
|
textColor = textColor,
|
||||||
isCurrentMatch = isCurrentMatch,
|
latexMeasurer = latexMeasurer,
|
||||||
linkColor = linkColor,
|
style =
|
||||||
highlights = highlights,
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
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,
|
highlights: SearchHighlightColors,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val segments = remember(text) { splitInlineMath(text) }
|
val isRtl = remember(text) { BidiUtils.isRtlText(text) }
|
||||||
if (segments.none { it is InlineMathSegment.Math }) {
|
val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
Text(
|
val resolvedStyle =
|
||||||
text =
|
style.copy(
|
||||||
remember(text, searchQuery, isCurrentMatch, textColor, linkColor) {
|
textDirection = if (isRtl) TextDirection.ContentOrRtl else TextDirection.ContentOrLtr,
|
||||||
parseInlineSource(text, textColor, searchQuery, isCurrentMatch, linkColor, highlights)
|
|
||||||
},
|
|
||||||
color = textColor,
|
|
||||||
style = style,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
)
|
||||||
return
|
val processedText =
|
||||||
}
|
remember(text, isRtl) {
|
||||||
|
if (isRtl) BidiUtils.anchorTrailingRtl(text) else text
|
||||||
val config =
|
|
||||||
LatexConfig(
|
|
||||||
fontSize = style.fontSize,
|
|
||||||
theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent),
|
|
||||||
accessibilityEnabled = true,
|
|
||||||
)
|
|
||||||
val inlineContent = mutableMapOf<String, InlineTextContent>()
|
|
||||||
segments.forEachIndexed { index, segment ->
|
|
||||||
if (segment is InlineMathSegment.Math) {
|
|
||||||
latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it }
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
val annotated =
|
|
||||||
buildAnnotatedString {
|
|
||||||
segments.forEachIndexed { index, segment ->
|
|
||||||
when (segment) {
|
|
||||||
is InlineMathSegment.Math -> {
|
|
||||||
val id = "latex-$index"
|
|
||||||
if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex)
|
|
||||||
}
|
|
||||||
|
|
||||||
is InlineMathSegment.Text -> {
|
CompositionLocalProvider(LocalLayoutDirection provides direction) {
|
||||||
append(
|
val segments = remember(processedText) { splitInlineMath(processedText) }
|
||||||
parseInlineSource(
|
if (segments.none { it is InlineMathSegment.Math }) {
|
||||||
segment.value,
|
Text(
|
||||||
textColor,
|
text =
|
||||||
searchQuery,
|
remember(processedText, searchQuery, isCurrentMatch, textColor, linkColor, isRtl) {
|
||||||
isCurrentMatch,
|
parseInlineSource(
|
||||||
linkColor,
|
text = processedText,
|
||||||
highlights,
|
textColor = textColor,
|
||||||
),
|
searchQuery = searchQuery,
|
||||||
|
isCurrentMatch = isCurrentMatch,
|
||||||
|
linkColor = linkColor,
|
||||||
|
highlights = highlights,
|
||||||
|
isRtl = isRtl,
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
color = textColor,
|
||||||
|
style = resolvedStyle,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
return@CompositionLocalProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
val config =
|
||||||
|
LatexConfig(
|
||||||
|
fontSize = style.fontSize,
|
||||||
|
theme = LatexTheme.light(color = textColor, backgroundColor = Color.Transparent),
|
||||||
|
accessibilityEnabled = true,
|
||||||
|
)
|
||||||
|
val inlineContent = mutableMapOf<String, InlineTextContent>()
|
||||||
|
segments.forEachIndexed { index, segment ->
|
||||||
|
if (segment is InlineMathSegment.Math) {
|
||||||
|
latexMeasurer.inlineContent(segment.latex, config)?.let { inlineContent["latex-$index"] = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val annotated =
|
||||||
|
buildAnnotatedString {
|
||||||
|
segments.forEachIndexed { index, segment ->
|
||||||
|
when (segment) {
|
||||||
|
is InlineMathSegment.Math -> {
|
||||||
|
val id = "latex-$index"
|
||||||
|
if (id in inlineContent) appendInlineContent(id, segment.latex) else append(segment.latex)
|
||||||
|
}
|
||||||
|
|
||||||
|
is InlineMathSegment.Text -> {
|
||||||
|
append(
|
||||||
|
parseInlineSource(
|
||||||
|
text = segment.value,
|
||||||
|
textColor = textColor,
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
isCurrentMatch = isCurrentMatch,
|
||||||
|
linkColor = linkColor,
|
||||||
|
highlights = highlights,
|
||||||
|
isRtl = isRtl,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Text(
|
||||||
Text(
|
text = annotated,
|
||||||
text = annotated,
|
inlineContent = inlineContent,
|
||||||
inlineContent = inlineContent,
|
color = textColor,
|
||||||
color = textColor,
|
style = resolvedStyle,
|
||||||
style = style,
|
modifier = modifier,
|
||||||
modifier = modifier,
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -526,40 +633,35 @@ 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
|
||||||
Column(
|
CompositionLocalProvider(LocalLayoutDirection provides tableDirection) {
|
||||||
modifier =
|
Column(
|
||||||
Modifier
|
modifier =
|
||||||
.fillMaxWidth()
|
Modifier
|
||||||
.horizontalScroll(rememberScrollState())
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp),
|
.horizontalScroll(rememberScrollState())
|
||||||
) {
|
.padding(vertical = 4.dp),
|
||||||
// Header row
|
) {
|
||||||
Row(modifier = Modifier.background(headerBg)) {
|
// Header row
|
||||||
block.header.forEachIndexed { idx, cell ->
|
Row(modifier = Modifier.background(headerBg)) {
|
||||||
Text(
|
block.header.forEachIndexed { idx, cell ->
|
||||||
text = cell,
|
val cellRtl = BidiUtils.isRtlText(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 ->
|
|
||||||
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(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textDirection = bidiTextDirection(cellRtl),
|
||||||
|
),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.width(TABLE_COL_WIDTH)
|
.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,
|
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,91 +324,129 @@ fun ChatInputBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BasicTextField(
|
val ambientLayoutDirection = LocalLayoutDirection.current
|
||||||
value = inputFieldValue,
|
val inputLayoutDirection =
|
||||||
onValueChange = onInputChange,
|
remember(inputFieldValue.text, ambientLayoutDirection) {
|
||||||
modifier =
|
BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection)
|
||||||
Modifier
|
}
|
||||||
.weight(1f)
|
val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
|
||||||
.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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send button INSIDE the field. Shown whenever a send is
|
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
|
||||||
// possible — text typed OR an attachment pending (issue
|
BasicTextField(
|
||||||
// #956): the old text-only gate hid the button entirely
|
value = inputFieldValue,
|
||||||
// for attachment-only sends.
|
onValueChange = onInputChange,
|
||||||
AnimatedContent(
|
modifier =
|
||||||
targetState = canSend,
|
Modifier
|
||||||
transitionSpec = {
|
.weight(1f)
|
||||||
(scaleIn(initialScale = 0.8f) + fadeIn())
|
.heightIn(min = 42.dp, max = 120.dp)
|
||||||
.togetherWith(scaleOut(targetScale = 0.8f) + fadeOut())
|
.padding(vertical = 4.dp)
|
||||||
},
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
label = "send_toggle",
|
.testTag("chat_input"),
|
||||||
) { showSend ->
|
enabled = isConnected,
|
||||||
if (showSend) {
|
textStyle =
|
||||||
IconButton(
|
MaterialTheme.typography.bodyMedium.copy(
|
||||||
onClick = onSend,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
enabled = canSend,
|
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||||
colors = IconButtonDefaults.filledTonalIconButtonColors(),
|
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||||
modifier =
|
),
|
||||||
Modifier
|
singleLine = false,
|
||||||
.size(36.dp)
|
maxLines = 4,
|
||||||
.testTag("send_button"),
|
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(
|
CompositionLocalProvider(
|
||||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
LocalLayoutDirection provides inputLayoutDirection,
|
||||||
contentDescription = stringResource(R.string.chat_send_desc),
|
) {
|
||||||
)
|
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 ──
|
// ── BOTTOM ROW: Toolbar ──
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package com.m57.hermescontrol.util
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bidirectional (BiDi) text utilities for mixed Arabic/English content,
|
||||||
|
* dynamic layout direction resolution, and Unicode directional isolates.
|
||||||
|
*/
|
||||||
|
object BidiUtils {
|
||||||
|
const val LRI = "\u2066" // Left-to-Right Isolate
|
||||||
|
const val RLI = "\u2067" // Right-to-Left Isolate
|
||||||
|
const val FSI = "\u2068" // First-Strong Isolate
|
||||||
|
const val PDI = "\u2069" // Pop Directional Isolate
|
||||||
|
const val LRM = "\u200E" // Left-to-Right Mark
|
||||||
|
const val RLM = "\u200F" // Right-to-Left Mark
|
||||||
|
|
||||||
|
enum class BidiDirection {
|
||||||
|
LTR,
|
||||||
|
RTL,
|
||||||
|
NEUTRAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether the given text should be rendered as RTL.
|
||||||
|
*
|
||||||
|
* 1. If the first strong directional character is RTL (Arabic/Hebrew), returns true.
|
||||||
|
* 2. If the first strong directional character is LTR (e.g. an Arabic sentence starting with
|
||||||
|
* an English term like "Android هو نظام..."), returns true if RTL characters outnumber LTR characters.
|
||||||
|
* 3. If there are no strong characters (empty, numbers, emojis, symbols only), returns false.
|
||||||
|
*/
|
||||||
|
fun isRtlText(text: CharSequence): Boolean {
|
||||||
|
var firstStrongRtl: Boolean? = null
|
||||||
|
var rtlCount = 0
|
||||||
|
var ltrCount = 0
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
val len = text.length
|
||||||
|
while (i < len) {
|
||||||
|
val cp = Character.codePointAt(text, i)
|
||||||
|
when (Character.getDirectionality(cp)) {
|
||||||
|
Character.DIRECTIONALITY_RIGHT_TO_LEFT,
|
||||||
|
Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC,
|
||||||
|
-> {
|
||||||
|
if (firstStrongRtl == null) firstStrongRtl = true
|
||||||
|
rtlCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
Character.DIRECTIONALITY_LEFT_TO_RIGHT -> {
|
||||||
|
if (firstStrongRtl == null) firstStrongRtl = false
|
||||||
|
ltrCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += Character.charCount(cp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when {
|
||||||
|
firstStrongRtl == true -> true
|
||||||
|
firstStrongRtl == false -> rtlCount > ltrCount
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects the dominant [BidiDirection] of [text].
|
||||||
|
* Returns [BidiDirection.NEUTRAL] when no strong directional characters exist.
|
||||||
|
*/
|
||||||
|
fun detectTextDirection(text: CharSequence): BidiDirection {
|
||||||
|
var hasStrong = false
|
||||||
|
var firstStrongRtl: Boolean? = null
|
||||||
|
var rtlCount = 0
|
||||||
|
var ltrCount = 0
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
val len = text.length
|
||||||
|
while (i < len) {
|
||||||
|
val cp = Character.codePointAt(text, i)
|
||||||
|
when (Character.getDirectionality(cp)) {
|
||||||
|
Character.DIRECTIONALITY_RIGHT_TO_LEFT,
|
||||||
|
Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC,
|
||||||
|
-> {
|
||||||
|
hasStrong = true
|
||||||
|
if (firstStrongRtl == null) firstStrongRtl = true
|
||||||
|
rtlCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
Character.DIRECTIONALITY_LEFT_TO_RIGHT -> {
|
||||||
|
hasStrong = true
|
||||||
|
if (firstStrongRtl == null) firstStrongRtl = false
|
||||||
|
ltrCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += Character.charCount(cp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasStrong) return BidiDirection.NEUTRAL
|
||||||
|
|
||||||
|
val isRtl =
|
||||||
|
when {
|
||||||
|
firstStrongRtl == true -> true
|
||||||
|
firstStrongRtl == false -> rtlCount > ltrCount
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
return if (isRtl) BidiDirection.RTL else BidiDirection.LTR
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the Compose [LayoutDirection] for [text].
|
||||||
|
* If [text] is neutral/empty, falls back to [fallback].
|
||||||
|
*/
|
||||||
|
fun resolveLayoutDirection(
|
||||||
|
text: CharSequence,
|
||||||
|
fallback: LayoutDirection = LayoutDirection.Ltr,
|
||||||
|
): LayoutDirection =
|
||||||
|
when (detectTextDirection(text)) {
|
||||||
|
BidiDirection.RTL -> LayoutDirection.Rtl
|
||||||
|
BidiDirection.LTR -> LayoutDirection.Ltr
|
||||||
|
BidiDirection.NEUTRAL -> fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if [text] contains strong LTR characters (Latin letters, digits)
|
||||||
|
* and no strong RTL characters (Arabic, Hebrew).
|
||||||
|
*/
|
||||||
|
fun isLtrSnippet(text: CharSequence): Boolean {
|
||||||
|
var hasLtr = false
|
||||||
|
var i = 0
|
||||||
|
val len = text.length
|
||||||
|
while (i < len) {
|
||||||
|
val cp = Character.codePointAt(text, i)
|
||||||
|
when (Character.getDirectionality(cp)) {
|
||||||
|
Character.DIRECTIONALITY_RIGHT_TO_LEFT,
|
||||||
|
Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC,
|
||||||
|
-> return false
|
||||||
|
|
||||||
|
Character.DIRECTIONALITY_LEFT_TO_RIGHT -> hasLtr = true
|
||||||
|
}
|
||||||
|
i += Character.charCount(cp)
|
||||||
|
}
|
||||||
|
return hasLtr
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps [text] in a Left-to-Right Isolate (LRI ... PDI) if [isLtrSnippet] is true.
|
||||||
|
* Used to prevent inline English terms and code from bleeding into surrounding RTL runs.
|
||||||
|
*/
|
||||||
|
fun wrapLtrIsolate(text: String): String = if (isLtrSnippet(text)) "$LRI$text$PDI" else text
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends a Right-to-Left Mark (RLM) to [text] if it is RTL, ensuring trailing
|
||||||
|
* punctuation and neutral emojis anchor to the visual end of the RTL sentence.
|
||||||
|
*/
|
||||||
|
fun anchorTrailingRtl(text: String): String = if (isRtlText(text) && !text.endsWith(RLM)) "$text$RLM" else text
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user