3 files changed
+188
-56
No files matched your search
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.MediumTest
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@MediumTest
|
||||||
|
class MarkdownHeadingRenderingTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun headingRendersCodeInsideBold() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
MarkdownText(
|
||||||
|
text = "## **Set `foo` now**",
|
||||||
|
textColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Set foo now").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText("**Set `foo` now**").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,33 +66,84 @@ object MarkdownInlineStyler {
|
|||||||
return buildAnnotatedString {
|
return buildAnnotatedString {
|
||||||
var i = 0
|
var i = 0
|
||||||
val src = text
|
val src = text
|
||||||
|
|
||||||
while (i < src.length) {
|
while (i < src.length) {
|
||||||
when {
|
when {
|
||||||
// ***bold italic***
|
// Inline code is opaque to emphasis parsing, and delimiters must match by run length.
|
||||||
src.startsWith("***", i) -> {
|
src[i] == '`' -> {
|
||||||
val end = src.indexOf("***", i + 3)
|
var runEnd = i
|
||||||
if (end != -1) {
|
while (runEnd < src.length && src[runEnd] == '`') runEnd++
|
||||||
val raw = src.substring(i + 3, end)
|
val runLength = runEnd - i
|
||||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
var end = runEnd
|
||||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
|
var matchingEnd = -1
|
||||||
append(toAppend)
|
while (end < src.length) {
|
||||||
|
if (src[end] == '`') {
|
||||||
|
var candidateEnd = end
|
||||||
|
while (candidateEnd < src.length && src[candidateEnd] == '`') candidateEnd++
|
||||||
|
if (candidateEnd - end == runLength) {
|
||||||
|
matchingEnd = end
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end = candidateEnd
|
||||||
|
} else {
|
||||||
|
end++
|
||||||
}
|
}
|
||||||
i = end + 3
|
}
|
||||||
|
if (matchingEnd >= 0) {
|
||||||
|
val raw = src.substring(runEnd, matchingEnd)
|
||||||
|
val content = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||||
|
withStyle(
|
||||||
|
SpanStyle(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
background = textColor.copy(alpha = 0.08f),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
append(content)
|
||||||
|
}
|
||||||
|
i = matchingEnd + runLength
|
||||||
} else {
|
} else {
|
||||||
append(src[i])
|
append(src.substring(i, runEnd))
|
||||||
i++
|
i = runEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combined / nested emphasis delimiters.
|
||||||
|
src.startsWith("***", i) -> {
|
||||||
|
val end = src.indexOf("***", i + 3)
|
||||||
|
if (end >= 0) {
|
||||||
|
val raw = src.substring(i + 3, end)
|
||||||
|
val content = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
|
||||||
|
append(content)
|
||||||
|
}
|
||||||
|
i = end + 3
|
||||||
|
} else {
|
||||||
|
append("***")
|
||||||
|
i += 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// **bold**
|
|
||||||
src.startsWith("**", i) -> {
|
src.startsWith("**", i) -> {
|
||||||
val end = src.indexOf("**", i + 2)
|
var end = src.indexOf("**", i + 2)
|
||||||
|
if (end >= 0 && src.startsWith("***", end) &&
|
||||||
|
src.substring(i + 2, end).count { it == '*' } % 2 == 1
|
||||||
|
) {
|
||||||
|
end++ // Inner italic closes with the first star of the trailing run.
|
||||||
|
}
|
||||||
if (end != -1) {
|
if (end != -1) {
|
||||||
val raw = src.substring(i + 2, end)
|
val raw = src.substring(i + 2, end)
|
||||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
val nested =
|
||||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
parseInlineSource(
|
||||||
append(toAppend)
|
raw,
|
||||||
}
|
textColor,
|
||||||
|
searchQuery,
|
||||||
|
isCurrentMatch,
|
||||||
|
linkColor,
|
||||||
|
highlights,
|
||||||
|
isRtl,
|
||||||
|
)
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(nested) }
|
||||||
i = end + 2
|
i = end + 2
|
||||||
} else {
|
} else {
|
||||||
append(src[i])
|
append(src[i])
|
||||||
@@ -116,16 +167,24 @@ object MarkdownInlineStyler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// *italic*
|
// *italic*; bold spans keep their styles while nested text is appended.
|
||||||
src.startsWith("*", i) -> {
|
src.startsWith("*", i) -> {
|
||||||
val end = src.indexOf('*', i + 1)
|
val close = src.indexOf('*', i + 1)
|
||||||
if (end != -1 && end > i + 1) {
|
if (close > i + 1) {
|
||||||
val raw = src.substring(i + 1, end)
|
val raw = src.substring(i + 1, close)
|
||||||
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
|
val italic = SpanStyle(fontStyle = FontStyle.Italic)
|
||||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
val nested =
|
||||||
append(toAppend)
|
parseInlineSource(
|
||||||
}
|
raw,
|
||||||
i = end + 1
|
textColor,
|
||||||
|
searchQuery,
|
||||||
|
isCurrentMatch,
|
||||||
|
linkColor,
|
||||||
|
highlights,
|
||||||
|
isRtl,
|
||||||
|
)
|
||||||
|
withStyle(italic) { append(nested) }
|
||||||
|
i = close + 1
|
||||||
} else {
|
} else {
|
||||||
append(src[i])
|
append(src[i])
|
||||||
i++
|
i++
|
||||||
@@ -225,16 +284,20 @@ object MarkdownInlineStyler {
|
|||||||
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(
|
val labelText =
|
||||||
SpanStyle(
|
parseInlineSource(
|
||||||
color = linkColor,
|
label,
|
||||||
textDecoration = TextDecoration.Underline,
|
textColor,
|
||||||
),
|
searchQuery,
|
||||||
) {
|
isCurrentMatch,
|
||||||
append(labelToAppend)
|
linkColor,
|
||||||
|
highlights,
|
||||||
|
isRtl,
|
||||||
|
)
|
||||||
|
withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) {
|
||||||
|
append(labelText)
|
||||||
}
|
}
|
||||||
pop()
|
pop()
|
||||||
i = urlEnd + 1
|
i = urlEnd + 1
|
||||||
@@ -248,28 +311,6 @@ object MarkdownInlineStyler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// `inline code`
|
|
||||||
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,
|
|
||||||
fontSize = 13.sp,
|
|
||||||
background = textColor.copy(alpha = 0.08f),
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
append(toAppend)
|
|
||||||
}
|
|
||||||
i = end + 1
|
|
||||||
} else {
|
|
||||||
append(src[i])
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// bare URL
|
// bare URL
|
||||||
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)!!
|
||||||
|
|||||||
@@ -568,6 +568,64 @@ class MarkdownTextFeatureTest {
|
|||||||
assertEquals("Run val x = 1 here", parsed.toString())
|
assertEquals("Run val x = 1 here", parsed.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testNestedInlineStyles_preserveCodeAndEmphasisFormatting() {
|
||||||
|
val parsed = parseInline("**bold `code` and *italic***", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
|
||||||
|
|
||||||
|
assertEquals("bold code and italic", parsed.toString())
|
||||||
|
assertTrue(parsed.spanStyles.any { it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold })
|
||||||
|
assertTrue(parsed.spanStyles.any { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace })
|
||||||
|
assertTrue(parsed.spanStyles.any { it.item.fontStyle == androidx.compose.ui.text.font.FontStyle.Italic })
|
||||||
|
val codeStart = parsed.indexOf("code")
|
||||||
|
assertTrue(
|
||||||
|
parsed.spanStyles.any {
|
||||||
|
it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold &&
|
||||||
|
it.start <= codeStart &&
|
||||||
|
it.end >= codeStart + 4
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
parsed.spanStyles.any {
|
||||||
|
it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace &&
|
||||||
|
it.start <= codeStart &&
|
||||||
|
it.end >= codeStart + 4
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testLinkLabel_nestedEmphasisAndCode() {
|
||||||
|
val parsed =
|
||||||
|
parseInline(
|
||||||
|
"[**bold `code`**](https://example.com)",
|
||||||
|
Color.Black,
|
||||||
|
"",
|
||||||
|
false,
|
||||||
|
Color.Blue,
|
||||||
|
DEFAULT_HIGHLIGHTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("bold code", parsed.toString())
|
||||||
|
assertTrue(parsed.getLinkAnnotations(0, parsed.length).isNotEmpty())
|
||||||
|
assertTrue(parsed.spanStyles.any { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCodeSpan_doesNotParseInnerEmphasis() {
|
||||||
|
val parsed = parseInline("`**literal**`", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
|
||||||
|
|
||||||
|
assertEquals("**literal**", parsed.toString())
|
||||||
|
assertFalse(parsed.spanStyles.any { it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testInlineCode_matchesSameLengthBacktickRunAndPreservesUnmatchedRun() {
|
||||||
|
val parsed = parseInline("``a ` tick`` and `unfinished", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
|
||||||
|
|
||||||
|
assertEquals("a ` tick and `unfinished", parsed.toString())
|
||||||
|
assertTrue(parsed.spanStyles.any { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testArabicStartingWithEnglishWord_wrapsEnglishInLtrIsolate() {
|
fun testArabicStartingWithEnglishWord_wrapsEnglishInLtrIsolate() {
|
||||||
val input = "Okay سكرت كلشي وصار كامل عربي متل ما بدك يا بوبو"
|
val input = "Okay سكرت كلشي وصار كامل عربي متل ما بدك يا بوبو"
|
||||||
|
|||||||
Reference in new issue
Block a user