The same pass the server had, on the Kotlin side: comments restating what the code says are gone, and the ones recording a measurement, a constraint or an incident are kept but cut to a few lines each. 6540 comment lines to 5674, and 920 lines off the app. Two doc comments had drifted onto the item above the one they describe -- `contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s equivalent on the server was fixed in the previous commit. Each is back on its own item, which is the only non-comment line this diff moves. The comments are reflowed to the column limit at their own indentation: several were written wide, and ktfmt re-wrapped them into lines holding a single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the result, which is the check. Left alone deliberately: this codebase's remaining comment density is high because the comments carry things the code cannot say -- what a null means, what a number was measured against, which bug a guard exists for. Of the 238 one-line doc comments in the app, five were pure restatement of the name and were removed; the rest each say something the signature does not. ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass; cargo test (127), clippy --all-targets and fmt still clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
312 lines
12 KiB
Kotlin
312 lines
12 KiB
Kotlin
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 so nothing on screen is a colour from somewhere else, but the two are not one
|
|
* table -- 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<Color>,
|
|
/** 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. Stripping them all would be
|
|
* the other half-answer -- colour is often the whole of what a diff or a test run is saying.
|
|
*
|
|
* So the sequences that decide how text *looks* become spans, and every other one is dropped rather
|
|
* than shown: the rest move a cursor around a grid this is not, and "go to column 40" has no
|
|
* meaning in a scrolling document.
|
|
*
|
|
* 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.
|
|
*
|
|
* Not a composable, and the palette is a parameter, so this can 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<Run>()
|
|
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.
|
|
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<Run>) {
|
|
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. Three shapes -- the CSI
|
|
* (`ESC [ … letter`), the string escapes 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<Int>, at: Int, palette: AnsiPalette): Pair<Color?, Int> =
|
|
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)
|