diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt new file mode 100644 index 00000000..ec36d0dc --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt @@ -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() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt index 2aed606e..79c83b0c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt @@ -66,33 +66,84 @@ object MarkdownInlineStyler { return buildAnnotatedString { var i = 0 val src = text + while (i < src.length) { when { - // ***bold italic*** - 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(toAppend) + // Inline code is opaque to emphasis parsing, and delimiters must match by run length. + src[i] == '`' -> { + var runEnd = i + while (runEnd < src.length && src[runEnd] == '`') runEnd++ + val runLength = runEnd - i + var end = runEnd + var matchingEnd = -1 + 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 { - append(src[i]) - i++ + append(src.substring(i, runEnd)) + 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) -> { - 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) { val raw = src.substring(i + 2, end) - val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(toAppend) - } + val nested = + parseInlineSource( + raw, + textColor, + searchQuery, + isCurrentMatch, + linkColor, + highlights, + isRtl, + ) + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(nested) } i = end + 2 } else { append(src[i]) @@ -116,16 +167,24 @@ object MarkdownInlineStyler { } } - // *italic* + // *italic*; bold spans keep their styles while nested text is appended. 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(toAppend) - } - i = end + 1 + val close = src.indexOf('*', i + 1) + if (close > i + 1) { + val raw = src.substring(i + 1, close) + val italic = SpanStyle(fontStyle = FontStyle.Italic) + val nested = + parseInlineSource( + raw, + textColor, + searchQuery, + isCurrentMatch, + linkColor, + highlights, + isRtl, + ) + withStyle(italic) { append(nested) } + i = close + 1 } else { append(src[i]) i++ @@ -225,16 +284,20 @@ object MarkdownInlineStyler { 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( - SpanStyle( - color = linkColor, - textDecoration = TextDecoration.Underline, - ), - ) { - append(labelToAppend) + val labelText = + parseInlineSource( + label, + textColor, + searchQuery, + isCurrentMatch, + linkColor, + highlights, + isRtl, + ) + withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) { + append(labelText) } pop() 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 URL_PATTERN.matchAt(src, i) != null -> { val match = URL_PATTERN.matchAt(src, i)!! diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt index 6cd4de15..91bed590 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt @@ -568,6 +568,64 @@ class MarkdownTextFeatureTest { 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 fun testArabicStartingWithEnglishWord_wrapsEnglishInLtrIsolate() { val input = "Okay سكرت كلشي وصار كامل عربي متل ما بدك يا بوبو"