package com.example.aiapp 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.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration /** * The sixteen colours a terminal program names, and the two it assumes. * * Its own palette rather than the syntax one: a program that prints in red has chosen red, where a * highlighter's colours are this app's reading of somebody else's code. They come out of the same * Catppuccin values (see `ansiPalette` in `Theme.kt`) so nothing on screen is a colour from * somewhere else, but the two are not one table and must not become one -- adding a syntax role to * this list would silently move `ls`'s directory blue. */ data class AnsiPalette( /** Indexes 0-7, then 8-15 bright, in the terminal's own order. */ val colours: List, /** What uncoloured text is, needed only where a style has to state a colour. */ val foreground: Color, /** What the text sits on, needed for reverse video. */ val background: Color, ) /** * What a tool printed, with its terminal styling applied and everything else taken out. * * Bash output arrives exactly as the program wrote it, escape sequences included, and drawn * verbatim those are line noise in the middle of the thing being read: `ESC[0;32m` in front of * every green word. Stripping them all would be the other half-answer -- colour is often the whole * of what a diff, a test run or a linter is saying. * * So the sequences that decide how text *looks* become spans, and every other one is dropped. * Dropped rather than shown, because the rest move a cursor around a grid this is not: a transcript * is a scrolling document, and "go to column 40" has no meaning here that is better than nothing. * * A carriage return is honoured the way a terminal honours it: what was written since the last line * break is thrown away and the line starts again. That is what makes a progress bar show its final * state rather than every state it passed through, which was tens of lines run together. * * Not a composable, and the palette is a parameter: this can then be remembered against the text it * parsed rather than re-run on every recomposition of the card holding it. */ fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString { // The common case by a long way -- nothing to do, and nothing allocated to find that out. if (text.indexOf(ESC) < 0 && text.indexOf('\r') < 0) return AnnotatedString(text) val runs = mutableListOf() var sgr = Sgr.PLAIN var at = 0 val plain = StringBuilder() fun flush() { if (plain.isNotEmpty()) { runs.add(Run(plain.toString(), sgr.span(palette))) plain.clear() } } while (at < text.length) { val c = text[at] when { c == ESC -> { flush() at = skipEscape(text, at) { params, final -> if (final == 'm') sgr = sgr.apply(params, palette) } } // A bare carriage return rewrites the line. One before a newline is the other half // of a Windows line ending: it rewrites nothing, and it is dropped rather than kept, // since that pair is one line break and the return itself would draw as a stray // control character. c == '\r' && text.getOrNull(at + 1) != '\n' -> { flush() dropLine(runs) at++ } c == '\r' -> at++ // Everything printable, plus the two control characters that are layout rather than // terminal commands. A stray bell or backspace goes for the same reason a cursor // move does. c >= ' ' || c == '\n' || c == '\t' -> { plain.append(c) at++ } else -> at++ } } flush() return buildAnnotatedString { runs.forEach { run -> if (run.style == null) { append(run.text) } else { val pushed = pushStyle(run.style) append(run.text) pop(pushed) } } } } /** One stretch of text that shares a style. */ private class Run(val text: String, val style: SpanStyle?) /** Throws away everything written since the last line break, as a carriage return does. */ private fun dropLine(runs: MutableList) { while (runs.isNotEmpty()) { val last = runs.removeAt(runs.size - 1) val breakAt = last.text.lastIndexOf('\n') if (breakAt >= 0) { runs.add(Run(last.text.substring(0, breakAt + 1), last.style)) return } } } private const val ESC = '\u001B' private const val BELL = '\u0007' /** * Steps over the escape sequence starting at [at], reporting a CSI's parameters and final byte. * * One reader for every kind, because the point is to *leave* them all behind: a sequence this did * not recognise would otherwise have its body printed as ordinary text, which is worse than the * escape it was meant to remove. Three shapes -- the CSI (`ESC [ … letter`), the string escapes * (OSC, DCS, APC, PM) which run to a terminator, and the two-character ones. */ private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Unit): Int { val next = text.getOrNull(at + 1) ?: return at + 1 return when (next) { '[' -> { var end = at + 2 while (end < text.length && text[end] !in CSI_FINAL) end++ if (end >= text.length) { // Cut off mid-sequence, which is what a stream that has not finished arriving // looks like: drop the fragment rather than printing it, and the whole sequence // arrives with the next delta. text.length } else { onCsi(text.substring(at + 2, end), text[end]) end + 1 } } ']', 'P', 'X', '^', '_' -> { // Runs to a string terminator: `ESC \`, or the bell that xterm allows after an OSC. var end = at + 2 while (end < text.length) { if (text[end] == BELL) return end + 1 if (text[end] == ESC && text.getOrNull(end + 1) == '\\') return end + 2 end++ } text.length } else -> at + 2 } } /** The bytes that end a CSI sequence. */ private val CSI_FINAL = '@'..'~' /** Everything an SGR sequence can turn on, as the terminal tracks it. */ private data class Sgr( val fg: Color?, val bg: Color?, val bold: Boolean, val dim: Boolean, val italic: Boolean, val underline: Boolean, val strike: Boolean, val reverse: Boolean, ) { /** Null while nothing is set, so unstyled output costs no spans at all. */ fun span(palette: AnsiPalette): SpanStyle? { if (this == PLAIN) return null val front = if (reverse) bg ?: palette.background else fg val back = if (reverse) fg ?: palette.foreground else bg // Dim has to have a colour to dim, so where none was named it dims the ordinary one. val stated = front ?: palette.foreground.takeIf { dim } return SpanStyle( color = stated?.let { if (dim) it.copy(alpha = DIM_ALPHA) else it } ?: Color.Unspecified, background = back ?: Color.Unspecified, fontWeight = if (bold) FontWeight.Bold else null, fontStyle = if (italic) FontStyle.Italic else null, textDecoration = when { underline && strike -> TextDecoration.combine( listOf(TextDecoration.Underline, TextDecoration.LineThrough) ) underline -> TextDecoration.Underline strike -> TextDecoration.LineThrough else -> null }, ) } /** * This state with [params] applied -- one `ESC[…m`, which carries any number of them. * * A code this does not model is ignored rather than reset from: the program meant something by * it, and starting again would also drop the codes beside it that are understood. */ fun apply(params: String, palette: AnsiPalette): Sgr { // `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a zero too. val codes = params.split(';').map { it.trim().toIntOrNull() ?: 0 } var state = this var at = 0 while (at < codes.size) { val code = codes[at] state = when (code) { 0 -> PLAIN 1 -> state.copy(bold = true) 2 -> state.copy(dim = true) 3 -> state.copy(italic = true) 4 -> state.copy(underline = true) 7 -> state.copy(reverse = true) 9 -> state.copy(strike = true) 21, 22 -> state.copy(bold = false, dim = false) 23 -> state.copy(italic = false) 24 -> state.copy(underline = false) 27 -> state.copy(reverse = false) 29 -> state.copy(strike = false) in 30..37 -> state.copy(fg = palette.colours[code - 30]) in 90..97 -> state.copy(fg = palette.colours[code - 90 + 8]) in 40..47 -> state.copy(bg = palette.colours[code - 40]) in 100..107 -> state.copy(bg = palette.colours[code - 100 + 8]) 39 -> state.copy(fg = null) 49 -> state.copy(bg = null) 38, 48 -> { val (colour, last) = extendedColour(codes, at, palette) at = last if (code == 38) state.copy(fg = colour) else state.copy(bg = colour) } else -> state } at++ } return state } companion object { val PLAIN = Sgr( fg = null, bg = null, bold = false, dim = false, italic = false, underline = false, strike = false, reverse = false, ) } } /** How much of its colour dim text keeps: enough to read, little enough to recede. */ private const val DIM_ALPHA = 0.65f /** * The colour named by a `38`/`48` at [at], and the index of that colour's last parameter. * * Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal one. The first sixteen of * that table are the palette's own, so a program asking for "colour 1" through either spelling gets * the same red. */ private fun extendedColour(codes: List, at: Int, palette: AnsiPalette): Pair = when (codes.getOrNull(at + 1)) { 5 -> { val n = codes.getOrNull(at + 2) if (n == null) null to at + 1 else indexedColour(n, palette) to at + 2 } 2 -> { val r = codes.getOrNull(at + 2) val g = codes.getOrNull(at + 3) val b = codes.getOrNull(at + 4) if (r == null || g == null || b == null) null to at + 1 else Color(r.coerceIn(0, 255), g.coerceIn(0, 255), b.coerceIn(0, 255)) to at + 4 } else -> null to at + 1 } /** One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a grey ramp. */ private fun indexedColour(n: Int, palette: AnsiPalette): Color = when { n < 0 -> palette.foreground n < 16 -> palette.colours[n] n < 232 -> { val i = n - 16 Color(CUBE[i / 36], CUBE[i / 6 % 6], CUBE[i % 6]) } n < 256 -> { val grey = 8 + (n - 232) * 10 Color(grey, grey, grey) } else -> palette.foreground } /** The six levels of each channel in the 256-colour cube, as xterm defines them. */ private val CUBE = intArrayOf(0, 95, 135, 175, 215, 255)