superintelligence-android / android/app/src/main/java/net/kentonc/si/ui/MarkdownText.kt

raw file 10393 bytes
package net.kentonc.si.ui

import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
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.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

/**
 * A deliberately small Markdown renderer: headings, bullet/numbered lists, code
 * fences, blockquotes, and inline bold/italic/code/links are enough for agent
 * replies without adding a whole Markdown dependency to the bundle.
 */

@Composable
fun MarkdownText(markdown: String, modifier: Modifier = Modifier) {
    val scheme = MaterialTheme.colorScheme
    SelectionContainer {
        Column(modifier) {
            for (block in remember(markdown) { parseBlocks(markdown) }) {
                when (block) {
                    is Block.Paragraph -> Text(
                        inline(block.text, scheme),
                        style = MaterialTheme.typography.bodyMedium,
                    )
                    is Block.Heading -> Text(
                        inline(block.text.trimStart('#', ' '), scheme),
                        style = MaterialTheme.typography.titleLarge.copy(
                            fontSize = (18 - 2 * (block.level - 1)).coerceAtLeast(14).sp,
                            fontWeight = FontWeight.SemiBold,
                        ),
                    )
                    is Block.Bullets -> Column {
                        for (item in block.items) {
                            Row(Modifier.padding(start = 12.dp, bottom = 2.dp)) {
                                Text(
                                    block.marker,
                                    style = MaterialTheme.typography.bodyMedium,
                                    modifier = Modifier.width(widthMarker(block.marker)),
                                )
                                Spacer(Modifier.width(4.dp))
                                Text(inline(item, scheme), style = MaterialTheme.typography.bodyMedium)
                            }
                        }
                    }
                    is Block.Code -> Surface(
                        shape = RoundedCornerShape(8.dp),
                        color = scheme.surfaceVariant,
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(vertical = 4.dp),
                    ) {
                        Text(
                            block.lines.joinToString("\n"),
                            fontFamily = FontFamily.Monospace,
                            fontSize = 13.sp,
                            modifier = Modifier
                                .horizontalScroll(rememberScrollState())
                                .padding(10.dp),
                        )
                    }
                    is Block.Quote -> Text(
                        inline(block.text, scheme),
                        style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        modifier = Modifier.padding(start = 12.dp, top = 2.dp, bottom = 2.dp),
                    )
                }
            }
        }
    }
}

private fun widthMarker(marker: String) = (marker.length * 10).dp

// ─── block parsing ─────────────────────────────────────────────────────────────

sealed interface Block {
    data class Paragraph(val text: String) : Block
    data class Heading(val level: Int, val text: String) : Block
    data class Bullets(val items: List<String>, val marker: String) : Block
    data class Code(val lines: List<String>) : Block
    data class Quote(val text: String) : Block
}

/** Numbered list markers: "1." "2)" … */
private val ORDERED = Regex("^\\d+[.)] ")

private fun isHeading(line: String) =
    line.startsWith("#") && line.dropWhile { it == '#' }.startsWith(" ")

private fun isFence(line: String) = line.trimStart().startsWith("```")

private fun parseBlocks(markdown: String): List<Block> {
    val blocks = ArrayList<Block>()
    val lines = markdown.split('\n')
    var i = 0

    while (i < lines.size) {
        val line = lines[i].trimEnd()
        when {
            isFence(line) -> {
                i++
                val code = ArrayList<String>()
                while (i < lines.size && !isFence(lines[i])) { code.add(lines[i]); i++ }
                blocks.add(Block.Code(code))
                i++  // skip the closing fence (or run off the end)
            }
            line.isBlank() -> i++
            isHeading(line) -> {
                blocks.add(Block.Heading(line.takeWhile { it == '#' }.length, line))
                i++
            }
            line.startsWith("- ") || line.startsWith("* ") -> {
                val items = ArrayList<String>()
                while (i < lines.size) {
                    val l = lines[i].trimEnd()
                    if (l.startsWith("- ") || l.startsWith("* ")) { items.add(l.substring(2)); i++ } else break
                }
                blocks.add(Block.Bullets(items, "•"))
            }
            ORDERED.matches(line) -> {
                val marker = ORDERED.find(line)!!.value
                val items = ArrayList<String>()
                while (i < lines.size && ORDERED.matches(lines[i].trimEnd())) {
                    items.add(lines[i].trimEnd().dropWhile { it != ' ' }.trim()); i++
                }
                blocks.add(Block.Bullets(items, marker))
            }
            line.startsWith(">") -> {
                blocks.add(Block.Quote(line.dropWhile { it == '>' }.trimStart()))
                i++
            }
            else -> {
                // Merge consecutive plain lines into one paragraph.
                val paragraph = ArrayList<String>()
                while (i < lines.size) {
                    val l = lines[i].trimEnd()
                    if (l.isBlank() || isFence(l) || isHeading(l) || l.startsWith(">") ||
                        l.startsWith("- ") || l.startsWith("* ") || ORDERED.matches(l)
                    ) break
                    paragraph.add(l); i++
                }
                blocks.add(Block.Paragraph(paragraph.joinToString("\n")))
            }
        }
    }
    return blocks
}

// ─── inline formatting ─────────────────────────────────────────────────────────

/** Renders **bold**, *italic*, `code`, and `[text](url)` into an AnnotatedString. */
private fun inline(text: String, scheme: ColorScheme): AnnotatedString =
    buildAnnotatedString {
        val plain = StringBuilder()
        fun flushPlain() { append(plain.toString()); plain.clear() }

        var i = 0
        while (i < text.length) {
            when {
                // **bold**
                text.startsWith("**", i) -> {
                    val end = text.indexOf("**", i + 2)
                    if (end > i + 1) {
                        flushPlain()
                        pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
                        append(text.substring(i + 2, end))
                        pop()
                        i = end + 2
                    } else { plain.append("**"); i += 2 }
                }
                // `code`
                text[i] == '`' -> {
                    val end = text.indexOf('`', i + 1)
                    if (end > i) {
                        flushPlain()
                        pushStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = scheme.surfaceVariant))
                        append(text.substring(i + 1, end))
                        pop()
                        i = end + 1
                    } else { plain.append(text[i]); i++ }
                }
                // *italic* (not a bare '*' followed by space)
                text[i] == '*' && i + 1 < text.length && text[i + 1] != ' ' -> {
                    val end = text.indexOf('*', i + 1)
                    if (end > i + 1) {
                        flushPlain()
                        pushStyle(SpanStyle(fontStyle = FontStyle.Italic))
                        append(text.substring(i + 1, end))
                        pop()
                        i = end + 1
                    } else { plain.append(text[i]); i++ }
                }
                // [label](url)
                text[i] == '[' -> {
                    val closeBracket = text.indexOf("](", i)
                    val closeParen = if (closeBracket >= 0) text.indexOf(')', closeBracket) else -1
                    if (closeBracket > i && closeParen > closeBracket) {
                        flushPlain()
                        pushStyle(SpanStyle(color = Link, textDecoration = TextDecoration.Underline))
                        append(text.substring(i + 1, closeBracket))
                        pop()
                        i = closeParen + 1
                    } else { plain.append(text[i]); i++ }
                }
                else -> { plain.append(text[i]); i++ }
            }
        }
        flushPlain()
    }

private val Link = Color(0xFF8AB4F8)