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