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 index 8f5de1c8..f76cca5f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -61,9 +61,10 @@ import com.m57.hermescontrol.theme.searchHighlightColors private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""") private val TABLE_COL_WIDTH = 160.dp private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""") -private val BULLET_RE = Regex("""^\s*[-*+]\s+(.*)""") -private val TASK_RE = Regex("""^\s*[-*+]\s+\[([ xX])\]\s+(.*)""") -private val ORDERED_RE = Regex("""^\s*(\d+)\.\s+(.*)""") +private val TASK_LINE_RE = Regex("""^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$""") +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+)(.*)$""") /** * Renders chat assistant text as Markdown — but ONLY once the message has finished streaming. @@ -158,12 +159,23 @@ fun MarkdownText( } is MdBlock.Bullet -> { + val indent = (block.level * 16).dp Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(start = indent) + .padding(vertical = 1.dp), verticalAlignment = Alignment.Top, ) { + val bulletChar = + when (block.level % 3) { + 0 -> "•" + 1 -> "◦" + else -> "▪" + } Text( - text = "•", + text = bulletChar, color = textColor, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(end = 6.dp), @@ -183,8 +195,13 @@ fun MarkdownText( } is MdBlock.Task -> { + val indent = (block.level * 16).dp Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(start = indent) + .padding(vertical = 1.dp), verticalAlignment = Alignment.Top, ) { Icon( @@ -220,8 +237,13 @@ fun MarkdownText( } is MdBlock.Ordered -> { + val indent = (block.level * 16).dp Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(start = indent) + .padding(vertical = 1.dp), verticalAlignment = Alignment.Top, ) { Text( @@ -738,41 +760,10 @@ internal fun parseBlocks(src: String): List { blocks.add(MdBlock.DefList(listOf(DefItem(term, defs)))) } - TASK_RE.matches(line) -> { - val m = TASK_RE.find(line)!! - blocks.add(MdBlock.Task(m.groupValues[1].equals("x", ignoreCase = true), m.groupValues[2])) - i++ - } - - BULLET_RE.matches(line) -> { - val items = mutableListOf() - while (i < lines.size && BULLET_RE.matches(lines[i]) && !TASK_RE.matches(lines[i])) { - items.add(BULLET_RE.find(lines[i])!!.groupValues[1]) - i++ - } - items.forEach { blocks.add(MdBlock.Bullet(it)) } - } - - ORDERED_RE.matches(line) -> { - // Capture the source number so custom/loose lists keep their order. - // A blank line only breaks the list when the next non-blank line is NOT an - // ordered item — loose lists (blank lines between items) stay one list. - val items = mutableListOf>() - var j = i - while (j < lines.size) { - val m = ORDERED_RE.find(lines[j]) - if (m != null) { - val n = m.groupValues[1].toIntOrNull() ?: (items.size + 1) - items.add(n to m.groupValues[2]) - j++ - } else if (lines[j].isBlank() && j + 1 < lines.size && ORDERED_RE.matches(lines[j + 1])) { - j++ - } else { - break - } - } - items.forEach { (n, t) -> blocks.add(MdBlock.Ordered(n, t)) } - i = j + LIST_ITEM_LINE_RE.matches(line) -> { + val (listBlocks, nextIndex) = parseList(lines, i) + blocks.addAll(listBlocks) + i = nextIndex } // Standalone Markdown image: ![alt](uri) on its own line. @@ -800,6 +791,182 @@ internal fun parseBlocks(src: String): List { return blocks } +private sealed interface ParsedItem { + val indent: Int + val continuationLines: MutableList + + data class Bullet( + override val indent: Int, + val text: String, + override val continuationLines: MutableList = mutableListOf(), + ) : ParsedItem + + data class Task( + override val indent: Int, + val checked: Boolean, + val text: String, + override val continuationLines: MutableList = mutableListOf(), + ) : ParsedItem + + data class Ordered( + override val indent: Int, + val rawNumber: Int, + val text: String, + override val continuationLines: MutableList = mutableListOf(), + ) : ParsedItem +} + +private fun computeIndent(prefix: String): Int { + var count = 0 + for (ch in prefix) { + if (ch == '\t') { + count += 4 - (count % 4) + } else { + count++ + } + } + return count +} + +private fun parseList( + lines: List, + startIndex: Int, +): Pair, Int> { + val items = mutableListOf() + var j = startIndex + + while (j < lines.size) { + val line = lines[j] + + val taskMatch = TASK_LINE_RE.matchEntire(line) + val bulletMatch = if (taskMatch == null) BULLET_LINE_RE.matchEntire(line) else null + val orderedMatch = if (taskMatch == null && bulletMatch == null) ORDERED_LINE_RE.matchEntire(line) else null + + if (taskMatch != null) { + val indent = computeIndent(taskMatch.groupValues[1]) + val checked = taskMatch.groupValues[2].equals("x", ignoreCase = true) + val text = taskMatch.groupValues[3] + items.add(ParsedItem.Task(indent, checked, text)) + j++ + } else if (bulletMatch != null) { + val indent = computeIndent(bulletMatch.groupValues[1]) + val text = bulletMatch.groupValues[2] + items.add(ParsedItem.Bullet(indent, text)) + j++ + } else if (orderedMatch != null) { + val indent = computeIndent(orderedMatch.groupValues[1]) + val num = orderedMatch.groupValues[2].toIntOrNull() ?: 1 + val text = orderedMatch.groupValues[3] + items.add(ParsedItem.Ordered(indent, num, text)) + j++ + } else if (line.isBlank()) { + // Check if there is a following list item + var lookahead = j + 1 + while (lookahead < lines.size && lines[lookahead].isBlank()) { + lookahead++ + } + if (lookahead < lines.size && LIST_ITEM_LINE_RE.matches(lines[lookahead])) { + j = lookahead + } else { + break + } + } else { + // Check if this is a continuation line for the previous list item + val indent = computeIndent(line.takeWhile { it == ' ' || it == '\t' }) + if (items.isNotEmpty() && (indent >= 2 || line.startsWith(" "))) { + items.last().continuationLines.add(line.trim()) + j++ + } else { + break + } + } + } + + if (items.isEmpty()) { + return emptyList() to j + } + + val blocks = mutableListOf() + + // Track state of list items and their relative nesting depth using an indent stack + val indentStack = mutableListOf() // maps level (index) -> base indent + var lastItemWasOrdered = false + var currentSequenceNumber = 1 + var lastLevel = -1 + + for (item in items) { + // Resolve level using the indent stack + val level: Int + if (indentStack.isEmpty() || item.indent == 0) { + indentStack.clear() + indentStack.add(item.indent) + level = 0 + } else if (item.indent > indentStack.last()) { + indentStack.add(item.indent) + level = indentStack.size - 1 + } else if (item.indent == indentStack.last()) { + level = indentStack.size - 1 + } else { + // Unwind stack to the matching or nearest smaller indent + while (indentStack.size > 1 && indentStack.last() > item.indent) { + indentStack.removeAt(indentStack.size - 1) + } + level = indentStack.size - 1 + } + + val fullText = + if (item.continuationLines.isEmpty()) { + when (item) { + is ParsedItem.Bullet -> item.text + is ParsedItem.Task -> item.text + is ParsedItem.Ordered -> item.text + } + } else { + val base = + when (item) { + is ParsedItem.Bullet -> item.text + is ParsedItem.Task -> item.text + is ParsedItem.Ordered -> item.text + } + base + " " + item.continuationLines.joinToString(" ") + } + + when (item) { + is ParsedItem.Bullet -> { + blocks.add(MdBlock.Bullet(fullText, level = level)) + lastItemWasOrdered = false + lastLevel = level + } + + is ParsedItem.Task -> { + blocks.add(MdBlock.Task(item.checked, fullText, level = level)) + lastItemWasOrdered = false + lastLevel = level + } + + is ParsedItem.Ordered -> { + val indexToUse = + if (level == lastLevel && lastItemWasOrdered) { + if (item.rawNumber > currentSequenceNumber) { + currentSequenceNumber = item.rawNumber + } else { + currentSequenceNumber++ + } + currentSequenceNumber + } else { + currentSequenceNumber = item.rawNumber + currentSequenceNumber + } + blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level)) + lastItemWasOrdered = true + lastLevel = level + } + } + } + + return blocks to j +} + private fun isHorizontalRule(line: String): Boolean { val trimmed = line.trim() if (trimmed.length < 3) return false @@ -862,7 +1029,7 @@ private fun isDefListStart( ): Boolean { val l = lines[i].trim() if (l.isBlank() || l.startsWith("#") || l.startsWith(">") || l.startsWith("```")) return false - if (BULLET_RE.matches(lines[i]) || ORDERED_RE.matches(lines[i])) return false + if (LIST_ITEM_LINE_RE.matches(lines[i])) return false if (i + 1 >= lines.size) return false return lines[i + 1].trim().startsWith(":") } @@ -886,8 +1053,7 @@ private fun fallthroughToParagraph( !lines[i].startsWith("```") && !isValidHeading(lines[i]) && !lines[i].startsWith(">") && - !BULLET_RE.matches(lines[i]) && - !ORDERED_RE.matches(lines[i]) && + !LIST_ITEM_LINE_RE.matches(lines[i]) && !isHorizontalRule(lines[i]) && !isTableStart(lines, i) && !isDefListStart(lines, i) && @@ -1193,16 +1359,19 @@ internal sealed interface MdBlock { data class Bullet( val text: String, + val level: Int = 0, ) : MdBlock data class Task( val checked: Boolean, val text: String, + val level: Int = 0, ) : MdBlock data class Ordered( val index: Int, val text: String, + val level: Int = 0, ) : MdBlock data class Image( 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 636e7366..0139b0d2 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 @@ -335,4 +335,121 @@ class MarkdownTextFeatureTest { val blocks = parseBlocks(md).filterIsInstance() assertEquals(listOf(1, 2, 3), blocks.map { it.index }) } + + // 17. NESTED LISTS (issue #965) + @Test + fun testNestedLists_mixedBulletsAndOrdered() { + val md = + """ + - top bullet A + - sub bullet A1 + 1. deep number 1 + 2. deep number 2 + - deeper bullet x + 3. deep number 3 + - sub bullet A2 + - top bullet B + 1. number under B + - bullet under that number + 1. numbered deep + 2. second number under B + + 1. one + - a bullet + - nested bullet + - b bullet + 2. two + """.trimIndent() + + val blocks = parseBlocks(md) + assertTrue("Blocks should not be empty", blocks.isNotEmpty()) + + // Top bullet A + val b0 = blocks[0] as MdBlock.Bullet + assertEquals("top bullet A", b0.text) + assertEquals(0, b0.level) + + // Sub bullet A1 + val b1 = blocks[1] as MdBlock.Bullet + assertEquals("sub bullet A1", b1.text) + assertEquals(1, b1.level) + + // Deep number 1 & 2 + val o2 = blocks[2] as MdBlock.Ordered + assertEquals("deep number 1", o2.text) + assertEquals(1, o2.index) + assertEquals(2, o2.level) + + val o3 = blocks[3] as MdBlock.Ordered + assertEquals("deep number 2", o3.text) + assertEquals(2, o3.index) + assertEquals(2, o3.level) + + // Deeper bullet x + val b4 = blocks[4] as MdBlock.Bullet + assertEquals("deeper bullet x", b4.text) + assertEquals(3, b4.level) + + // Deep number 3 + val o5 = blocks[5] as MdBlock.Ordered + assertEquals("deep number 3", o5.text) + assertEquals(3, o5.index) + assertEquals(2, o5.level) + + // Sub bullet A2 + val b6 = blocks[6] as MdBlock.Bullet + assertEquals("sub bullet A2", b6.text) + assertEquals(1, b6.level) + + // Top bullet B + val b7 = blocks[7] as MdBlock.Bullet + assertEquals("top bullet B", b7.text) + assertEquals(0, b7.level) + + // Number under B + val o8 = blocks[8] as MdBlock.Ordered + assertEquals("number under B", o8.text) + assertEquals(1, o8.index) + assertEquals(1, o8.level) + + // Bullet under that number + val b9 = blocks[9] as MdBlock.Bullet + assertEquals("bullet under that number", b9.text) + assertEquals(2, b9.level) + + // Numbered deep + val o10 = blocks[10] as MdBlock.Ordered + assertEquals("numbered deep", o10.text) + assertEquals(1, o10.index) + assertEquals(3, o10.level) + + // Second number under B + val o11 = blocks[11] as MdBlock.Ordered + assertEquals("second number under B", o11.text) + assertEquals(2, o11.index) + assertEquals(1, o11.level) + + // List 2: starting with 1. one + val o12 = blocks[12] as MdBlock.Ordered + assertEquals("one", o12.text) + assertEquals(1, o12.index) + assertEquals(0, o12.level) + + val b13 = blocks[13] as MdBlock.Bullet + assertEquals("a bullet", b13.text) + assertEquals(1, b13.level) + + val b14 = blocks[14] as MdBlock.Bullet + assertEquals("nested bullet", b14.text) + assertEquals(2, b14.level) + + val b15 = blocks[15] as MdBlock.Bullet + assertEquals("b bullet", b15.text) + assertEquals(1, b15.level) + + val o16 = blocks[16] as MdBlock.Ordered + assertEquals("two", o16.text) + assertEquals(2, o16.index) + assertEquals(0, o16.level) + } }