From 594255d53443a15a3cb8a4c324c16b484e2a106f Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:24:59 +0300 Subject: [PATCH] fix(#572): render Markdown in assistant chat bubbles after streaming (#577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#572): render Markdown in assistant chat bubbles after streaming - Add MarkdownText composable: block-aware parser (headings, lists, blockquotes, fenced code, inline code, bold, italic, links) - Gate formatting on message.isStreaming: plain text during streaming, formatted view swapped in on completion (kills flicker/re-parse churn) - Fenced code blocks render as a scrollable card with a copy button - Remove dead RichText; wire AssistantBubble to MarkdownText - ktlintCheck + compileDebugKotlin + testDebugUnitTest green * fix(#572): address agy review — memoize inline parse, drop nested SelectionContainers, fix heading detection, reset copy state * fix(#572): expand Markdown support — tables, strike, bold+italic, task lists, hr, footnotes, def-lists, highlight, sup/sub, kbd (math excluded) * fix(#572): address agy review — fix table alignment (textAlign) + quote bar height (IntrinsicSize), fix ktlint line length * fix(#572): resolve infinite loop hang on hashtag refs in markdown parser --------- Co-authored-by: M57 --- .../m57/hermescontrol/ui/chat/ChatBubble.kt | 119 +-- .../m57/hermescontrol/ui/chat/MarkdownText.kt | 949 ++++++++++++++++++ .../ui/chat/MarkdownTextFeatureTest.kt | 168 ++++ 3 files changed, 1119 insertions(+), 117 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index cc4d8e02..ee5d13c9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -68,7 +68,6 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -383,9 +382,10 @@ private fun AssistantBubble( ) { Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { SelectionContainer { - RichText( + MarkdownText( text = message.content, textColor = textColor, + isStreaming = message.isStreaming, searchQuery = searchQuery, isCurrentMatch = isCurrentMatch, ) @@ -2182,121 +2182,6 @@ private fun CopyButton( } } -/** - * Simple rich text renderer supporting **bold**, `inline code`, ```code blocks```, and clickable URLs. - */ -@Composable -private fun RichText( - text: String, - textColor: Color, - searchQuery: String = "", - isCurrentMatch: Boolean = false, -) { - val urlPattern = remember { Regex("""https?://[^\s)>\]\u0022\u0027]+""") } - val linkColor = MaterialTheme.colorScheme.primary - val searchHighlightColor = if (isCurrentMatch) Color(0xFFF57C00) else Color(0xFFFFF176).copy(alpha = 0.9f) - val annotated = - remember(text, searchQuery, isCurrentMatch) { - buildAnnotatedString { - var i = 0 - val src = text - while (i < src.length) { - when { - // Code block: ```...``` - src.startsWith("```", i) -> { - val end = src.indexOf("```", i + 3) - if (end != -1) { - val code = src.substring(i + 3, end).trimStart('\n').trimEnd('\n') - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - background = textColor.copy(alpha = 0.08f), - ), - ) { - append(code) - } - i = end + 3 - } else { - append(src[i]) - i++ - } - } - - // Inline code: `...` - src[i] == '`' -> { - val end = src.indexOf('`', i + 1) - if (end != -1) { - withStyle( - SpanStyle( - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - background = textColor.copy(alpha = 0.08f), - ), - ) { - append(src.substring(i + 1, end)) - } - i = end + 1 - } else { - append(src[i]) - i++ - } - } - - // Bold: **...** - src.startsWith("**", i) -> { - val end = src.indexOf("**", i + 2) - if (end != -1) { - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(src.substring(i + 2, end)) - } - i = end + 2 - } else { - append(src[i]) - i++ - } - } - - // URL: https://... - urlPattern.matchAt(src, i) != null -> { - val match = urlPattern.matchAt(src, i)!! - pushLink(LinkAnnotation.Url(match.value)) - withStyle( - SpanStyle( - color = linkColor, - textDecoration = TextDecoration.Underline, - ), - ) { - append(match.value) - } - pop() - i = match.range.last + 1 - } - - // Search match highlight - searchQuery.isNotEmpty() && - src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> { - withStyle(SpanStyle(background = searchHighlightColor, color = Color(0xFF1A1A24))) { - append(src.substring(i, i + searchQuery.length)) - } - i += searchQuery.length - } - - else -> { - append(src[i]) - i++ - } - } - } - } - } - - Text( - text = annotated, - style = MaterialTheme.typography.bodyMedium.copy(color = textColor), - ) -} - private fun formatTimestamp( timestamp: Long, is24Hour: Boolean, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt new file mode 100644 index 00000000..940d4b19 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -0,0 +1,949 @@ +package com.m57.hermescontrol.ui.chat + +import android.content.ClipData +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.outlined.CheckBox +import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +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.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.R +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"‘’]+""") +private val HIGHLIGHT_BG = Color(0xFFFFFF00).copy(alpha = 0.3f) +private val TABLE_COL_WIDTH = 160.dp +private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""") + +/** + * 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 + * to the formatted view on completion (and for all historical/restored messages). + * + * Supports: fenced ```code``` blocks (horizontal scroll + copy), inline `code`, **bold**, *italic*, + * ***bold italic***, ~~strike~~, ==highlight==, ^sup^ / ~sub~, keys, headings, + * bullet/ordered/task lists, > blockquotes, definition lists, tables, --- rules, footnotes, and + * [links](url) / bare URLs. (Math is intentionally out of scope for this hand-rolled renderer.) + */ +@Composable +fun MarkdownText( + text: String, + textColor: Color, + isStreaming: Boolean = false, + searchQuery: String = "", + isCurrentMatch: Boolean = false, + modifier: Modifier = Modifier, +) { + if (isStreaming) { + Text( + text = text, + color = textColor, + style = MaterialTheme.typography.bodyMedium, + modifier = modifier, + ) + return + } + + val linkColor = MaterialTheme.colorScheme.primary + val blocks = remember(text) { parseBlocks(text) } + + Column(modifier = modifier.fillMaxWidth()) { + for (block in blocks) { + when (block) { + is MdBlock.Code -> CodeBlockCard(code = block.code, textColor = textColor) + + is MdBlock.Hr -> + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp), + color = textColor.copy(alpha = 0.25f), + ) + + is MdBlock.Heading -> { + val fontSize = + when (block.level) { + 1 -> 22.sp + 2 -> 20.sp + 3 -> 18.sp + 4 -> 16.sp + 5 -> 15.sp + else -> 14.sp + } + Text( + text = + remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) { + parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor) + }, + color = textColor, + style = + MaterialTheme.typography.bodyMedium + .copy(fontSize = fontSize, fontWeight = FontWeight.Bold), + modifier = Modifier.padding(vertical = 2.dp), + ) + } + + is MdBlock.Bullet -> { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = "•", + color = textColor, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(end = 6.dp), + ) + Text( + text = + remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) { + parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor) + }, + color = textColor, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + } + } + + is MdBlock.Task -> { + Row( + modifier = Modifier.fillMaxWidth().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), + ) + Text( + text = + remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) { + parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor) + }, + color = textColor, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + } + } + + is MdBlock.Ordered -> { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = "${block.index}.", + color = textColor, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(end = 6.dp), + ) + Text( + text = + remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) { + parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor) + }, + color = textColor, + style = MaterialTheme.typography.bodyMedium, + 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)) + Text( + text = + remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) { + parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor) + }, + color = textColor, + style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic), + modifier = Modifier.weight(1f), + ) + } + } + + 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 -> + Text( + text = def, + color = textColor, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 16.dp, bottom = 2.dp), + ) + } + Spacer(modifier = Modifier.height(2.dp)) + } + } + } + + is MdBlock.Table -> MarkdownTable(block = block, textColor = textColor) + + is MdBlock.Footnotes -> { + HorizontalDivider( + modifier = Modifier.padding(vertical = 6.dp), + color = textColor.copy(alpha = 0.2f), + ) + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Footnotes", + color = textColor, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold), + 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), + ) + Text( + text = + remember(note.text, searchQuery, isCurrentMatch, textColor, linkColor) { + parseInline(note.text, textColor, searchQuery, isCurrentMatch, linkColor) + }, + color = textColor, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + } + } + } + } + + is MdBlock.Paragraph -> { + Text( + text = + remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) { + parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor) + }, + color = textColor, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + ) + } + } + } + } +} + +/** + * Fenced code block rendered as a card: monospaced text with horizontal scroll and a copy button. + */ +@Composable +private fun CodeBlockCard( + code: String, + textColor: Color, +) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + var copied by remember { mutableStateOf(false) } + + // Auto-dismiss the "copied" checkmark after 2s + LaunchedEffect(copied) { + if (copied) { + delay(2000) + copied = false + } + } + + Surface( + shape = RoundedCornerShape(8.dp), + color = textColor.copy(alpha = 0.06f), + border = BorderStroke(1.dp, textColor.copy(alpha = 0.15f)), + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Column { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, code))) + } + copied = true + }, + modifier = Modifier.size(28.dp), + ) { + Icon( + imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.content_desc_copy), + modifier = Modifier.size(15.dp), + tint = textColor.copy(alpha = 0.7f), + ) + } + } + Text( + text = code, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + color = textColor, + softWrap = false, + modifier = + Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + } +} + +@Composable +private fun MarkdownTable( + block: MdBlock.Table, + textColor: Color, +) { + 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 -> + Text( + text = cell, + textAlign = tableTextAlign(alignments.getOrNull(idx)), + color = textColor, + style = MaterialTheme.typography.bodySmall, + modifier = + Modifier + .width(TABLE_COL_WIDTH) + .padding(6.dp), + ) + } + } + HorizontalDivider(color = textColor.copy(alpha = 0.1f)) + } + } +} + +private fun tableTextAlign(align: TableAlign?): TextAlign? = + when (align) { + TableAlign.CENTER -> TextAlign.Center + TableAlign.RIGHT -> TextAlign.End + else -> null + } + +/** + * Splits source text into Markdown blocks. Fenced code blocks (```...```) are extracted first; + * everything else is grouped into headings, lists, tables, rules, footnotes, or paragraphs. + */ +internal fun parseBlocks(src: String): List { + val lines = src.lines() + val blocks = mutableListOf() + val footnotes = mutableListOf() + val bulletRe = Regex("""^\s*[-*+]\s+(.*)""") + val taskRe = Regex("""^\s*[-*+]\s+\[([ xX])\]\s+(.*)""") + val orderedRe = Regex("""^\s*\d+\.\s+(.*)""") + var i = 0 + + while (i < lines.size) { + val line = lines[i] + + // Footnote definition: [^id]: text (collected, not rendered inline) + val fnMatch = FN_DEF_RE.matchAt(line, 0) + if (fnMatch != null) { + footnotes.add(Footnote(fnMatch.groupValues[1], fnMatch.groupValues[2])) + i++ + continue + } + + when { + line.startsWith("```") -> { + val end = (i + 1 until lines.size).firstOrNull { lines[it].startsWith("```") } + if (end != null) { + blocks.add(MdBlock.Code(lines.subList(i + 1, end).joinToString("\n"))) + i = end + 1 + } else { + blocks.add(MdBlock.Code(lines.subList(i + 1, lines.size).joinToString("\n"))) + i = lines.size + } + } + + line.isBlank() -> i++ + + isHorizontalRule(line) -> { + blocks.add(MdBlock.Hr) + i++ + } + + // Heading: requires a space after the '#' run so "#572" stays a paragraph + isValidHeading(line) -> { + val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6) + blocks.add(MdBlock.Heading(level, line.substring(level).trim())) + i++ + } + + isTableStart(lines, i) -> { + val (header, alignments, body) = parseTable(lines, i) + blocks.add(MdBlock.Table(header, alignments, body)) + i += body.size + 2 + } + + line.startsWith(">") -> { + val quote = mutableListOf() + while (i < lines.size && lines[i].startsWith(">")) { + quote.add(lines[i].removePrefix(">").trim()) + i++ + } + blocks.add(MdBlock.Quote(quote.joinToString("\n"))) + } + + // Definition list: term line followed by one+ ": definition" lines + isDefListStart(lines, i, bulletRe, orderedRe) -> { + val term = line.trim() + val defs = mutableListOf() + i++ + while (i < lines.size && lines[i].trim().startsWith(":")) { + defs.add(lines[i].trim().removePrefix(":").trim()) + i++ + } + blocks.add(MdBlock.DefList(listOf(DefItem(term, defs)))) + } + + taskRe.matches(line) -> { + val m = taskRe.find(line)!! + blocks.add(MdBlock.Task(m.groupValues[1].equals("x", ignoreCase = true), m.groupValues[2])) + i++ + } + + bulletRe.matches(line) -> { + val items = mutableListOf() + while (i < lines.size && bulletRe.matches(lines[i]) && !taskRe.matches(lines[i])) { + items.add(bulletRe.find(lines[i])!!.groupValues[1]) + i++ + } + items.forEach { blocks.add(MdBlock.Bullet(it)) } + } + + orderedRe.matches(line) -> { + val items = mutableListOf() + while (i < lines.size && orderedRe.matches(lines[i])) { + items.add(orderedRe.find(lines[i])!!.groupValues[1]) + i++ + } + items.forEachIndexed { idx, t -> blocks.add(MdBlock.Ordered(idx + 1, t)) } + } + + else -> { + i = fallthroughToParagraph(lines, i, bulletRe, orderedRe, blocks) + } + } + } + + if (footnotes.isNotEmpty()) { + blocks.add(MdBlock.Footnotes(footnotes.map { FnNote(it.id, it.text) })) + } + return blocks +} + +private fun isHorizontalRule(line: String): Boolean { + val trimmed = line.trim() + if (trimmed.length < 3) return false + val c = trimmed[0] + return (c == '-' || c == '*' || c == '_') && trimmed.all { it == c } +} + +private fun isTableStart( + lines: List, + i: Int, +): Boolean { + val l = lines[i] + if (!l.contains('|')) return false + if (i + 1 >= lines.size) return false + return isTableSeparator(lines[i + 1]) +} + +private fun isTableSeparator(line: String): Boolean { + val t = line.trim().trim('|') + if (t.isEmpty()) return false + // every cell must be only dashes/colons/spaces, with at least one dash + return t.split('|').all { cell -> + val c = cell.trim() + c.isNotEmpty() && c.all { it == '-' || it == ':' || it == ' ' } && c.any { it == '-' } + } +} + +private fun parseTable( + lines: List, + i: Int, +): Triple, List, List>> { + val header = splitRow(lines[i]) + val alignments = + splitRow(lines[i + 1]).map { cell -> + val c = cell.trim() + when { + c.startsWith(":") && c.endsWith(":") -> TableAlign.CENTER + c.endsWith(":") -> TableAlign.RIGHT + c.startsWith(":") -> TableAlign.LEFT + else -> TableAlign.LEFT + } + } + val body = mutableListOf>() + var j = i + 2 + while (j < lines.size && lines[j].contains('|') && lines[j].trim().isNotEmpty()) { + body.add(splitRow(lines[j])) + j++ + } + return Triple(header, alignments, body) +} + +private fun splitRow(line: String): List { + val trimmed = line.trim().trim('|') + return trimmed.split('|').map { it.trim() } +} + +private fun isDefListStart( + lines: List, + i: Int, + bulletRe: Regex, + orderedRe: Regex, +): Boolean { + val l = lines[i].trim() + if (l.isBlank() || l.startsWith("#") || l.startsWith(">") || l.startsWith("```")) return false + if (bulletRe.matches(lines[i]) || orderedRe.matches(lines[i])) return false + if (i + 1 >= lines.size) return false + return lines[i + 1].trim().startsWith(":") +} + +private fun isValidHeading(line: String): Boolean { + if (!line.startsWith("#")) return false + val level = line.takeWhile { it == '#' }.length.coerceIn(1, 6) + return level < line.length && line[level] == ' ' +} + +private fun fallthroughToParagraph( + lines: List, + start: Int, + bulletRe: Regex, + orderedRe: Regex, + blocks: MutableList, +): Int { + var i = start + val para = mutableListOf() + while ( + i < lines.size && + lines[i].isNotBlank() && + !lines[i].startsWith("```") && + !isValidHeading(lines[i]) && + !lines[i].startsWith(">") && + !bulletRe.matches(lines[i]) && + !orderedRe.matches(lines[i]) && + !isHorizontalRule(lines[i]) && + !isTableStart(lines, i) && + !isDefListStart(lines, i, bulletRe, orderedRe) && + FN_DEF_RE.matchAt(lines[i], 0) == null + ) { + para.add(lines[i]) + i++ + } + if (para.isNotEmpty()) blocks.add(MdBlock.Paragraph(para.joinToString("\n"))) + return i +} + +/** + * Inline Markdown -> AnnotatedString. Handles `code`, **bold**, *italic*, ***bold italic***, + * ~~strike~~, ==highlight==, ^sup^, ~sub~, keys, [^ref] footnotes, [text](url) and + * bare URLs, plus search-query highlighting. + */ +internal fun parseInline( + text: String, + textColor: Color, + searchQuery: String, + isCurrentMatch: Boolean, + linkColor: Color, +): AnnotatedString { + val searchHighlightColor = + if (isCurrentMatch) Color(0xFFF57C00) else Color(0xFFFFF176).copy(alpha = 0.9f) + + 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) { + withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) { + append(src.substring(i + 3, end)) + } + i = end + 3 + } else { + append(src[i]) + i++ + } + } + + // **bold** + src.startsWith("**", i) -> { + val end = src.indexOf("**", i + 2) + if (end != -1) { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(src.substring(i + 2, end)) + } + i = end + 2 + } else { + append(src[i]) + i++ + } + } + + // ~~strike~~ + src.startsWith("~~", i) -> { + val end = src.indexOf("~~", i + 2) + if (end != -1) { + withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { + append(src.substring(i + 2, end)) + } + i = end + 2 + } else { + append(src[i]) + i++ + } + } + + // *italic* + src.startsWith("*", i) -> { + val end = src.indexOf('*', i + 1) + if (end != -1 && end > i + 1) { + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append(src.substring(i + 1, end)) + } + i = end + 1 + } else { + append(src[i]) + i++ + } + } + + // ==highlight== + src.startsWith("==", i) -> { + val end = src.indexOf("==", i + 2) + if (end != -1) { + withStyle(SpanStyle(background = HIGHLIGHT_BG)) { + append(src.substring(i + 2, end)) + } + i = end + 2 + } else { + append(src[i]) + i++ + } + } + + // ^superscript^ + src.startsWith("^", i) -> { + val end = src.indexOf('^', i + 1) + if (end != -1 && end > i + 1) { + withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) { + append(src.substring(i + 1, end)) + } + i = end + 1 + } else { + append(src[i]) + i++ + } + } + + // ~subscript~ (single tilde; ~~ handled above) + src.startsWith("~", i) -> { + val end = src.indexOf('~', i + 1) + if (end != -1 && end > i + 1) { + withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) { + append(src.substring(i + 1, end)) + } + i = end + 1 + } else { + append(src[i]) + i++ + } + } + + // key + src.startsWith("", i) -> { + val end = src.indexOf("", i) + if (end != -1) { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + background = textColor.copy(alpha = 0.12f), + ), + ) { + append(src.substring(i + 5, end)) + } + i = end + 6 + } else { + append(src[i]) + i++ + } + } + + // [^ref] footnote marker -> superscript + src.startsWith("[^", i) -> { + val close = src.indexOf(']', i) + if (close != -1) { + val id = src.substring(i + 2, close) + withStyle( + SpanStyle( + baselineShift = BaselineShift.Superscript, + color = linkColor, + fontWeight = FontWeight.Bold, + ), + ) { + append("[$id]") + } + i = close + 1 + } else { + append(src[i]) + i++ + } + } + + // [text](url) + src.startsWith("[", i) -> { + val close = src.indexOf(']', i) + if (close != -1 && close + 1 < src.length && src[close + 1] == '(') { + val urlEnd = src.indexOf(')', close + 2) + if (urlEnd != -1) { + val label = src.substring(i + 1, close) + val url = src.substring(close + 2, urlEnd) + pushLink(LinkAnnotation.Url(url)) + withStyle( + SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + ), + ) { + append(label) + } + pop() + i = urlEnd + 1 + } else { + append(src[i]) + i++ + } + } else { + append(src[i]) + i++ + } + } + + // `inline code` + src.startsWith("`", i) -> { + val end = src.indexOf('`', i + 1) + if (end != -1) { + withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + background = textColor.copy(alpha = 0.08f), + ), + ) { + append(src.substring(i + 1, end)) + } + i = end + 1 + } else { + append(src[i]) + i++ + } + } + + // bare URL + URL_PATTERN.matchAt(src, i) != null -> { + val match = URL_PATTERN.matchAt(src, i)!! + val url = match.value + pushLink(LinkAnnotation.Url(url)) + withStyle( + SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + ), + ) { + append(url) + } + pop() + i = match.range.last + 1 + } + + // search highlight + searchQuery.isNotEmpty() && + src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> { + withStyle( + SpanStyle( + background = searchHighlightColor, + color = Color(0xFF1A1A24), + ), + ) { + append(src.substring(i, i + searchQuery.length)) + } + i += searchQuery.length + } + + else -> { + append(src[i]) + i++ + } + } + } + } +} + +internal sealed interface MdBlock { + data class Code(val code: String) : MdBlock + + data class Heading(val level: Int, val text: String) : MdBlock + + data class Bullet(val text: String) : MdBlock + + data class Task(val checked: Boolean, val text: String) : MdBlock + + data class Ordered(val index: Int, val text: String) : MdBlock + + data class Quote(val text: String) : MdBlock + + data class Paragraph(val text: String) : MdBlock + + data class Table( + val header: List, + val alignments: List, + val rows: List>, + ) : MdBlock + + object Hr : MdBlock + + data class DefList(val items: List) : MdBlock + + data class Footnotes(val notes: List) : MdBlock +} + +internal data class DefItem( + val term: String, + val definitions: List, +) + +internal data class Footnote( + val id: String, + val text: String, +) + +internal data class FnNote( + val id: String, + val text: String, +) + +internal enum class TableAlign { + LEFT, + CENTER, + RIGHT, +} 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 new file mode 100644 index 00000000..b43b9c03 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt @@ -0,0 +1,168 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Verifies the hand-rolled Markdown parser covers the feature set requested for issue #572 + * (math excluded by decision). Each test asserts the block/inline structure actually parses — + * not just that it compiles. Inline assertions avoid referencing Compose text types (FontWeight, + * BaselineShift, etc.) that aren't on the unit-test classpath; we inspect SpanStyle fields + * structurally instead. + */ +class MarkdownTextFeatureTest { + // 1. TABLES + @Test + fun testTable_parsesHeaderAndRows() { + val md = + """ + | Name | Age | Role | + |------|:---:|-----:| + | Alice | 30 | dev | + | Bob | 25 | ops | + """.trimIndent() + val blocks = parseBlocks(md) + val table = blocks.singleOrNull { it is MdBlock.Table } as MdBlock.Table? + assertTrue("table block expected", table != null) + assertEquals(listOf("Name", "Age", "Role"), table!!.header) + assertEquals(2, table.rows.size) + assertEquals("Alice", table.rows[0][0]) + assertEquals("ops", table.rows[1][2]) + // alignment inference: center, left, right + assertEquals(TableAlign.CENTER, table.alignments[1]) + assertEquals(TableAlign.RIGHT, table.alignments[2]) + } + + // 2. MATH — excluded; ensure a math-looking string stays plain, not crashed + @Test + fun testMath_isTreatedAsPlainText() { + val md = "E = mc^2^ and `x = (-b ± √(b²-4ac)) / 2a`" + val block = parseBlocks(md).single() as MdBlock.Paragraph + assertEquals(md, block.text) + } + + // 3. STRIKETHROUGH + @Test + fun testStrikethrough_parses() { + val an = parseInline("~~gone~~", Color.Black, "", false, Color.Blue) + assertEquals("gone", an.toString()) + assertTrue(an.spanStyles.any { it.item.textDecoration != null }) + } + + // 4. BOLD + ITALIC in same word (***bolditalic***) + @Test + fun testBoldItalic_combined() { + val an = parseInline("***both***", Color.Black, "", false, Color.Blue) + assertEquals("both", an.toString()) + assertTrue(an.spanStyles.isNotEmpty()) + } + + // 5. TASK LIST / CHECKBOX + @Test + fun testTaskList_parsesCheckedAndUnchecked() { + val md = + """ + - [x] done + - [ ] todo + """.trimIndent() + val blocks = parseBlocks(md) + assertEquals(2, blocks.size) + val done = blocks[0] as MdBlock.Task + val todo = blocks[1] as MdBlock.Task + assertTrue(done.checked) + assertFalse(todo.checked) + assertEquals("done", done.text) + assertEquals("todo", todo.text) + } + + // 6. HORIZONTAL RULE (---) + @Test + fun testHorizontalRule_parses() { + val md = + """ + above + + --- + + below + """.trimIndent() + val blocks = parseBlocks(md) + assertTrue(blocks.any { it is MdBlock.Hr }) + } + + // 7. FOOTNOTES + @Test + fun testFootnotes_collectedAndRendered() { + val md = + """ + Science is cool.[^1] + + [^1]: A famous claim. + """.trimIndent() + val blocks = parseBlocks(md) + val fn = blocks.singleOrNull { it is MdBlock.Footnotes } as MdBlock.Footnotes? + assertTrue(fn != null) + assertEquals("1", fn!!.notes[0].id) + assertEquals("A famous claim.", fn.notes[0].text) + } + + // 8. DEFINITION LIST + @Test + fun testDefinitionList_parses() { + val md = + """ + Term + : first definition + : second definition + """.trimIndent() + val dl = parseBlocks(md).singleOrNull { it is MdBlock.DefList } as MdBlock.DefList? + assertTrue(dl != null) + assertEquals("Term", dl!!.items[0].term) + assertEquals(2, dl.items[0].definitions.size) + assertEquals("first definition", dl.items[0].definitions[0]) + } + + // 9. HIGHLIGHT + @Test + fun testHighlight_parses() { + val an = parseInline("==mark==", Color.Black, "", false, Color.Blue) + assertEquals("mark", an.toString()) + assertTrue(an.spanStyles.any { it.item.background != null }) + } + + // 10. SUPERSCRIPT / SUBSCRIPT + @Test + fun testSuperscriptAndSubscript_parse() { + val sup = parseInline("x^2^", Color.Black, "", false, Color.Blue) + assertTrue(sup.spanStyles.any { it.item.baselineShift != null }) + val sub = parseInline("H~2~O", Color.Black, "", false, Color.Blue) + assertTrue(sub.spanStyles.any { it.item.baselineShift != null }) + } + + // 11. KEYBOARD KEYS + @Test + fun testKbd_parses() { + val an = parseInline("Press Ctrl+C", Color.Black, "", false, Color.Blue) + assertTrue(an.toString().contains("Ctrl")) + assertTrue(an.toString().contains("C")) + assertTrue(an.spanStyles.any { it.item.fontFamily != null }) + } + + // --- regression: issue ref "#572" must NOT become a heading --- + @Test + fun testHashRef_notHeading() { + val blocks = parseBlocks("#572 should stay text") + assertTrue(blocks.single() is MdBlock.Paragraph) + } + + // --- regression: streaming gate handled in composable, parser must not split inline code --- + @Test + fun testInlineCode_preserved() { + val an = parseInline("use `val x = 1` here", Color.Black, "", false, Color.Blue) + assertEquals("use val x = 1 here", an.toString()) + assertTrue(an.spanStyles.any { it.item.fontFamily != null }) + } +}