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>
123 lines
5.5 KiB
Kotlin
123 lines
5.5 KiB
Kotlin
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<String>,
|
|
/** Per line, the spans that fall in it, with offsets relative to that line's start. */
|
|
private val spans: List<List<Span>>,
|
|
/**
|
|
* 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<String>, spans: List<Span>): List<List<Span>> {
|
|
val out = ArrayList<List<Span>>(lines.size)
|
|
var lineStart = 0
|
|
var next = 0
|
|
for (line in lines) {
|
|
val lineEnd = lineStart + line.length
|
|
var here: ArrayList<Span>? = 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<Span>().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
|
|
}
|
|
}
|
|
}
|