package com.example.aiapp import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString /** * A file split into lines, with the highlighter's colours already worked out for each one. * * The pure half of the viewer, so it has a JVM unit test and so [of] can run off the main thread: * scanning a megabyte is work, and doing it inside a composable would do it on the drawing thread * and again on every recomposition. * * Why per line at all: the viewer is a `LazyColumn` of lines rather than one `Text`, because text * layout is linear in the text. That means each row needs *its* colours, and the scanner answers in * offsets into the whole file -- so the spans are bucketed here, once, in one pass. */ class FileLines private constructor( /** The text of each line, without its newline. */ val lines: List, /** Per line, the spans that fall in it, with offsets relative to that line's start. */ private val spans: List>, /** * The longest line, in character columns -- what the viewer sizes every row to. * * Every row has to be the *same* width or they scroll sideways by different amounts; see * [FileViewer]. Columns rather than measured pixels because the face is monospace, so one * number and one character's advance give the width of the widest line without measuring twenty * thousand strings. */ val columns: Int, ) { val size: Int get() = lines.size /** * One line, coloured. Built when the row is composed rather than up front: a file has far more * lines than a screen shows, and an `AnnotatedString` per line for all of them is the cost the * lazy list exists to avoid. */ fun line(index: Int): AnnotatedString { val text = lines[index] val here = spans[index] if (here.isEmpty()) return AnnotatedString(text) val palette = catppuccinSyntax() return buildAnnotatedString { append(text) here.forEach { addStyle(SpanStyle(color = palette.of(it.kind)), it.start, it.end) } } } companion object { /** * [text] scanned as [language] and cut into lines. * * Exactly one trailing newline is dropped before splitting, so a file that ends the way * text files are supposed to end has the number of lines its author would count -- `wc -l` * agrees. Without that, every well-formed file gained a phantom empty last line. An empty * file is one empty line numbered 1, which is what it is. */ fun of(text: String, language: Language?): FileLines = // Timed, and always, for the reason everything else here is: the cost of opening a // large file is the number that decides whether the server's size limit is right, and // an instrument that is only in the build nobody is running answers nothing. DebugStats.timed("file scanned and cut into lines") { val body = text.removeSuffix("\n") val lines = body.split('\n') val scanned = if (language == null) emptyList() else spansOf(body, language) FileLines(lines, bucket(lines, scanned), lines.maxOf(::columnsOf)) } /** * How many columns a line occupies. * * A tab counts as eight rather than one, and deliberately upwards: this decides how far the * viewer can scroll, and over-estimating leaves a little empty space past the longest line * where under-estimating makes the end of that line unreachable. */ private fun columnsOf(line: String): Int { var count = 0 for (character in line) count += if (character == '\t') 8 else 1 return count } /** * The scanner's spans, in file offsets, as spans per line in line offsets. * * One walk down both lists, which is what the scanner's guarantee buys: its spans come out * ordered, non-overlapping and inside the text. A span crossing a line break is cut at each * break and appears in each line it covers, because a row is drawn on its own and cannot * inherit a colour from the row above. */ private fun bucket(lines: List, spans: List): List> { val out = ArrayList>(lines.size) var lineStart = 0 var next = 0 for (line in lines) { val lineEnd = lineStart + line.length var here: ArrayList? = null // Spans that ended before this line begins are behind the walk for good. while (next < spans.size && spans[next].end <= lineStart) next++ var at = next while (at < spans.size && spans[at].start < lineEnd) { val span = spans[at] val start = maxOf(span.start, lineStart) - lineStart val end = minOf(span.end, lineEnd) - lineStart if (end > start) { (here ?: ArrayList().also { here = it }).add( Span(start, end, span.kind) ) } at++ } out.add(here ?: emptyList()) // The newline itself, which is in the text and not in any line. lineStart = lineEnd + 1 } return out } } }