Measure the explorer, and cap edit mode at what it can carry
Three numbers, taken on the emulator through the app's own render report and written into EXPLORER.md; the fixture tree the sandbox now builds is what they were taken against. The viewer's scan was on the main thread. Decision 8 said off it, and the first version did it in a `remember` inside the composition, which is not that -- 460ms of frozen screen on a 1 MiB file, long enough that the accessibility tree cannot be read, which is exactly what "the app has stopped" looks like from outside. It runs on Dispatchers.Default now, with a spinner where the file will be. Reading a megabyte is otherwise fine: the viewer is a row per line, and it opens and scrolls 28,660 of them. Edit mode needed a cap, and not the one the plan expected. The cost that matters is not the highlighting -- 40ms a keystroke at 128 kB, which is survivable -- it is Compose laying out one enormous text in the field: 2,027ms per frame at 128 kB, with typed characters dropped, and no response at all at 1 MiB. Switching highlighting off would have saved nothing, since every arrangement of a single text field pays it. So EDIT_LIMIT is 32 kB, the largest size actually measured as usable, and above it the pencil is disabled with the reason in words beside it: a disabled control teaches what the thing can do but cannot say why it is off, and a reader who cannot edit a file they can plainly read would otherwise conclude the app is broken. `FileLines.of` is timed like everything else here, so the figure lands in the render report rather than needing a harness to ask for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9c4d33273b
commit
2c12274285
6 files changed
+142
-19
No files matched your search
@@ -149,6 +149,13 @@ repo is in PLAN.md's "Backend layout" section.
|
|||||||
`./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`.
|
`./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`.
|
||||||
The 409 is produced by editing the file on the machine (`printf … > file`)
|
The 409 is produced by editing the file on the machine (`printf … > file`)
|
||||||
between pressing the pencil and pressing save.
|
between pressing the pencil and pressing save.
|
||||||
|
**Reading is cheap and editing is not**, and the sizes are measured
|
||||||
|
rather than guessed -- see EXPLORER.md's "What the measurements said".
|
||||||
|
The viewer handles a 1 MiB, 28,000-line file because it draws one row per
|
||||||
|
line; the editor is one `BasicTextField`, which costs two seconds a frame
|
||||||
|
at 128 kB and stops the app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB
|
||||||
|
with the reason said on screen. If you make the editor faster, that
|
||||||
|
number is what to move.
|
||||||
- `.dev-updater.ron` — what Dev Updater is asked to do with this checkout:
|
- `.dev-updater.ron` — what Dev Updater is asked to do with this checkout:
|
||||||
the server (built in `server/`, run as `service: Managed(...)`) and the
|
the server (built in `server/`, run as `service: Managed(...)`) and the
|
||||||
APK (built in `app/`), built in parallel. The project it serves is the
|
APK (built in `app/`), built in parallel. The project it serves is the
|
||||||
|
|||||||
+51
-11
@@ -393,18 +393,51 @@ pure functions with tests.
|
|||||||
`~/repos/emulator-tools`, `ui-trace`'s tap-by-label action; then the
|
`~/repos/emulator-tools`, `ui-trace`'s tap-by-label action; then the
|
||||||
two bench scripts onto it, with no coordinate tap left in `app/*.sh`.
|
two bench scripts onto it, with no coordinate tap left in `app/*.sh`.
|
||||||
|
|
||||||
## Numbers to measure, before deciding
|
## What the measurements said (2026-09-04)
|
||||||
|
|
||||||
- Scan time for a 1 MiB source file on the emulator, and on the phone
|
Taken on the emulator in a **debug** build, which runs Compose at a
|
||||||
through the render report. That decides whether `FILE_LIMIT` is right
|
fraction of release speed and renders in software -- so these rank
|
||||||
and whether edit mode highlights every keystroke or only below a size.
|
correctly against each other and are pessimistic in absolute terms.
|
||||||
- Time to first line for a 1 MiB file over the tunnel: the read, the
|
Generated Rust, through the app's own render report.
|
||||||
transfer, the scan, the first composition. If the transfer dominates,
|
|
||||||
the route gains nothing from streaming; if the scan does, it moves to
|
| file | lines | scan + cut | scan per keystroke | worst frame record |
|
||||||
a worker with the plain text drawn first.
|
|--------|--------|------------|--------------------|--------------------|
|
||||||
- The `BasicTextField` at 20,000 lines: whether typing stays responsive.
|
| 32 kB | 917 | 11ms | 10ms | 183ms |
|
||||||
If not, edit mode gets a lower cap than the viewer, stated in the
|
| 128 kB | 3,633 | -- | 40ms | 2,027ms |
|
||||||
editor rather than discovered by a stuck keyboard.
|
| 1 MB | 28,660 | 460ms | -- | -- |
|
||||||
|
|
||||||
|
Three things followed.
|
||||||
|
|
||||||
|
**The viewer's scan had to leave the main thread.** Decision 8 said "off
|
||||||
|
the main thread" and the first version did it in a `remember` inside the
|
||||||
|
composition, which is not that: 460ms of frozen screen at the size the
|
||||||
|
server is willing to send, long enough that the accessibility tree cannot
|
||||||
|
be read -- which is exactly what "the app has stopped" looks like from
|
||||||
|
outside. It now runs on `Dispatchers.Default` with a spinner where the file
|
||||||
|
will be.
|
||||||
|
|
||||||
|
**`FILE_LIMIT` at 1 MiB is right for reading.** Time to first line for a
|
||||||
|
1 MiB file, tap to text on screen, was **2.4s** against the sandbox --
|
||||||
|
1.2s of which is that server's deliberate `--delay`, and 460ms the scan.
|
||||||
|
The transfer is not what dominates, so the route gains nothing from
|
||||||
|
streaming.
|
||||||
|
|
||||||
|
**Edit mode needed a cap, and not the one that was expected.** The plan
|
||||||
|
expected to be deciding a size below which highlighting stays on. That is
|
||||||
|
not the cost that matters: highlighting 128 kB costs 40ms a keystroke,
|
||||||
|
which is survivable, while laying the same text out in one
|
||||||
|
`BasicTextField` costs two seconds -- characters typed into it were
|
||||||
|
dropped, and a 1 MiB file stopped the app responding altogether. Since
|
||||||
|
every arrangement of a single text field pays that, switching highlighting
|
||||||
|
off would have saved nothing. So `EDIT_LIMIT` is **32 kB**, the largest
|
||||||
|
size measured as usable, and above it the pencil is disabled with the
|
||||||
|
reason said in words beside it -- a disabled control teaches what the thing
|
||||||
|
can do but cannot say why it is off, and a reader who cannot edit a file
|
||||||
|
they can plainly read would otherwise conclude the app is broken.
|
||||||
|
|
||||||
|
Reading is unaffected: the viewer opens and scrolls the 1 MiB file fine,
|
||||||
|
because it is a `LazyColumn` of lines rather than one text object. That
|
||||||
|
difference is the whole of decision 8.
|
||||||
|
|
||||||
## Later, deliberately not now
|
## Later, deliberately not now
|
||||||
|
|
||||||
@@ -419,3 +452,10 @@ pure functions with tests.
|
|||||||
- Uploading from the phone into a directory. Attachments already do the
|
- Uploading from the phone into a directory. Attachments already do the
|
||||||
upload half; this would be the same route with a chosen destination.
|
upload half; this would be the same route with a chosen destination.
|
||||||
- Search within a file, and find-in-files.
|
- Search within a file, and find-in-files.
|
||||||
|
- **A line-by-line editor**, which is the way past `EDIT_LIMIT`. The
|
||||||
|
viewer already draws a file as rows and stays fast on a megabyte; an
|
||||||
|
editor built the same way -- a field per line, or a field over the lines
|
||||||
|
on screen -- would not pay Compose's cost of laying out one enormous
|
||||||
|
text. It is a good deal more than this feature needed, and 32 kB covers
|
||||||
|
the config files, notes and ordinary source files anybody edits from a
|
||||||
|
phone.
|
||||||
@@ -23,6 +23,30 @@ import androidx.compose.ui.text.input.TransformedText
|
|||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The largest file this app will open in the editor, in bytes.
|
||||||
|
*
|
||||||
|
* Measured on the emulator on 2026-09-04, in a debug build, on generated Rust:
|
||||||
|
*
|
||||||
|
* | file | lines | scan per keystroke | worst frame record | typing |
|
||||||
|
* |--------|--------|--------------------|--------------------|-------------------|
|
||||||
|
* | 32 kB | 917 | 10ms | 183ms | sluggish, correct |
|
||||||
|
* | 128 kB | 3,633 | 40ms | 2,027ms | characters lost |
|
||||||
|
* | 1 MB | 28,660 | -- | -- | stops responding |
|
||||||
|
*
|
||||||
|
* The number that decides this is the **frame record**, not the scan: highlighting a 128 kB file
|
||||||
|
* costs 40ms a keystroke, which is noticeable and survivable, while laying the same text out in one
|
||||||
|
* `BasicTextField` costs two seconds. So switching highlighting off above a size -- which is what
|
||||||
|
* EXPLORER.md expected to have to decide -- would not have saved it; the cost is Compose laying out
|
||||||
|
* one enormous text, and every arrangement of a single text field pays it. A line-by-line editor is
|
||||||
|
* the way past this and is a good deal more than this feature needed.
|
||||||
|
*
|
||||||
|
* 32 kB rather than something between it and 128 kB, because 32 kB is the largest size that was
|
||||||
|
* actually measured as usable. The viewer's own limit stays the server's `FILE_LIMIT` of 1 MiB:
|
||||||
|
* reading a big file is fine, and it is only editing one that is not.
|
||||||
|
*/
|
||||||
|
const val EDIT_LIMIT = 32L * 1024
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The same file, editable, in the same face and colours it was being read in.
|
* The same file, editable, in the same face and colours it was being read in.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -56,12 +56,17 @@ private constructor(
|
|||||||
* file is one empty line numbered 1, which is what it is: a file with nothing in it still
|
* file is one empty line numbered 1, which is what it is: a file with nothing in it still
|
||||||
* has somewhere for a cursor to go.
|
* has somewhere for a cursor to go.
|
||||||
*/
|
*/
|
||||||
fun of(text: String, language: Language?): FileLines {
|
fun of(text: String, language: Language?): FileLines =
|
||||||
|
// Timed, and always, for the same 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. It
|
||||||
|
// lands in the render report beside the transcript's own figures.
|
||||||
|
DebugStats.timed("file scanned and cut into lines") {
|
||||||
val body = text.removeSuffix("\n")
|
val body = text.removeSuffix("\n")
|
||||||
val lines = body.split('\n')
|
val lines = body.split('\n')
|
||||||
val rules = language?.let { rulesOf(it) }
|
val rules = language?.let { rulesOf(it) }
|
||||||
val scanned = if (rules == null) emptyList() else scan(body, rules)
|
val scanned = if (rules == null) emptyList() else scan(body, rules)
|
||||||
return FileLines(lines, bucket(lines, scanned))
|
FileLines(lines, bucket(lines, scanned))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,15 +4,21 @@ import androidx.compose.foundation.horizontalScroll
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@@ -23,12 +29,39 @@ import androidx.compose.ui.text.rememberTextMeasurer
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
/** The face every verbatim thing in this app is drawn in, and the one the gutter has to match. */
|
/** The face every verbatim thing in this app is drawn in, and the one the gutter has to match. */
|
||||||
@Composable
|
@Composable
|
||||||
fun codeStyle(): TextStyle =
|
fun codeStyle(): TextStyle =
|
||||||
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace)
|
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [content] scanned off the main thread, then drawn.
|
||||||
|
*
|
||||||
|
* Measured on the emulator on 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file
|
||||||
|
* (28,660 lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was
|
||||||
|
* first written, that is 460ms of frozen screen at the size the server is willing to send -- long
|
||||||
|
* enough that the accessibility tree cannot be read, which is what "the app has stopped" looks like
|
||||||
|
* from outside. So it runs on [Dispatchers.Default] and the spinner is what the reader sees
|
||||||
|
* meanwhile, in the place the file will appear.
|
||||||
|
*
|
||||||
|
* Keyed on the text and the language, so re-reading the same file does not rescan it and a file
|
||||||
|
* that changed does.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modifier) {
|
||||||
|
var lines by remember(content, language) { mutableStateOf<FileLines?>(null) }
|
||||||
|
LaunchedEffect(content, language) {
|
||||||
|
lines = withContext(Dispatchers.Default) { FileLines.of(content, language) }
|
||||||
|
}
|
||||||
|
when (val ready = lines) {
|
||||||
|
null -> CircularProgressIndicator(Modifier.padding(8.dp))
|
||||||
|
else -> FileViewer(ready, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A file, one line per row, coloured by the same scanner that colours a reply's code fences.
|
* A file, one line per row, coloured by the same scanner that colours a reply's code fences.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -375,6 +375,10 @@ private fun ColumnScope.DocPane(
|
|||||||
val editScroll = rememberScrollState()
|
val editScroll = rememberScrollState()
|
||||||
val language = remember(name) { fileLanguage(name) }
|
val language = remember(name) { fileLanguage(name) }
|
||||||
val loaded = (state as? LoadState.Loaded)?.value as? FileContent.Text
|
val loaded = (state as? LoadState.Loaded)?.value as? FileContent.Text
|
||||||
|
// Readable but not editable: see [EDIT_LIMIT]. The size is the one the machine reported, so
|
||||||
|
// this is decided before anything is typed rather than discovered by a keyboard that stops
|
||||||
|
// answering.
|
||||||
|
val editable = loaded != null && loaded.size <= EDIT_LIMIT
|
||||||
|
|
||||||
suspend fun fetch() {
|
suspend fun fetch() {
|
||||||
state = LoadState.Loading
|
state = LoadState.Loading
|
||||||
@@ -450,7 +454,7 @@ private fun ColumnScope.DocPane(
|
|||||||
{ scope.launch { fetch() } },
|
{ scope.launch { fetch() } },
|
||||||
enabled = state !is LoadState.Loading,
|
enabled = state !is LoadState.Loading,
|
||||||
)
|
)
|
||||||
GlyphButton(EDIT_GLYPH, "Edit", { onEditing(true) }, enabled = loaded != null)
|
GlyphButton(EDIT_GLYPH, "Edit", { onEditing(true) }, enabled = editable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,6 +467,20 @@ private fun ColumnScope.DocPane(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Why the pencil is off. A disabled control teaches what the thing can do, but it cannot say
|
||||||
|
// why it is disabled -- and a reader who cannot edit a file they can plainly read will
|
||||||
|
// otherwise conclude the app is broken. Said once, here, rather than waiting for a tap that a
|
||||||
|
// disabled button never receives.
|
||||||
|
if (loaded != null && !editable) {
|
||||||
|
Text(
|
||||||
|
"Too big to edit here (${humanSize(loaded.size)}; the limit is " +
|
||||||
|
"${humanSize(EDIT_LIMIT)}). A text field this large stops answering the keyboard.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Box(Modifier.weight(1f).fillMaxWidth().background(rawSurface).padding(horizontal = 8.dp)) {
|
Box(Modifier.weight(1f).fillMaxWidth().background(rawSurface).padding(horizontal = 8.dp)) {
|
||||||
when (val current = state) {
|
when (val current = state) {
|
||||||
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(8.dp))
|
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(8.dp))
|
||||||
@@ -484,11 +502,7 @@ private fun ColumnScope.DocPane(
|
|||||||
Modifier.verticalScroll(editScroll),
|
Modifier.verticalScroll(editScroll),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val lines =
|
ScannedFile(file.content, language)
|
||||||
remember(file.content, language) {
|
|
||||||
FileLines.of(file.content, language)
|
|
||||||
}
|
|
||||||
FileViewer(lines)
|
|
||||||
}
|
}
|
||||||
// Said in words, with the measurement that makes it make sense. Neither of
|
// Said in words, with the measurement that makes it make sense. Neither of
|
||||||
// these is an empty file and neither is an error, so neither may look like one.
|
// these is an empty file and neither is an error, so neither may look like one.
|
||||||
|
|||||||
Reference in new issue
Block a user