The other half of EXPLORER.md: a folder button on the session header opens the machine's filesystem, starting where the session works. It draws **over** the session in the same `Box`, so the session under it stays composed -- its event stream keeps flowing, its draft and scroll position stay where they were, and coming back from a file costs nothing. Back steps one level inside it (editor, viewer, directory, parent) and only closes from where it opened; the platform gesture, the button and the swipe all go through the one function, so they cannot mean different things. The viewer is a `LazyColumn` of lines rather than one `Text`, because text layout is linear in the text and a twenty-thousand-line file in a single `Text` measures all of it to draw a screenful. Lines do not wrap and share one horizontal scroll, so a logical line is a visual line and the gutter cannot come to number the wrong text; the gutter's width is measured from the digit count of the line count in the style it is drawn in. The editor is a `BasicTextField` with a `VisualTransformation` carrying the scanner's spans, which is the one Compose API that colours a field's own text rather than replacing the field. `fileLanguage` reads the same table `fenceLanguage` does, so a language added for fences is a language added for files. A file that changed on the machine while it was open here refuses to be overwritten and asks, with what each of the three answers costs. That is the ordinary case, not the exotic one: an agent editing the file somebody is reading is what this whole feature is for. The speedometer moves off the header into the session settings dialog, where the session's other about-the-session controls are, and the folder takes a place between the usage chart and the cog -- widest scope to narrowest, cog at the end, as Iris asked. Both benchmark scripts move onto `ui-trace`'s new tap-by-label action in the same change, so the render report is never unavailable and never pressed at a coordinate that has stopped meaning anything; `app/bench-lib.sh` is what they share, and `grep -n "tap [0-9]" app/*.sh` is the check. Exercised on the emulator against the sandbox's new fixture tree, with a screenshot or a ui-trace for each: the listing (dotfiles, directories first, a symlink to a directory sorted with them, a name with a tab in it), a highlighted file, binary, too big, a permission error, editing and saving, the 409 and its Overwrite, back with unsaved edits, creating a name that exists, creating one that does not and landing in the editor, an empty directory, and `..` above the directory the session opened in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
289 lines
13 KiB
Kotlin
289 lines
13 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.IconButton
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.semantics.contentDescription
|
|
import androidx.compose.ui.semantics.semantics
|
|
import androidx.compose.ui.text.font.Font
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.unit.TextUnit
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
|
|
/**
|
|
* The icons the app draws, as glyphs in a Nerd Fonts subset rather than as vector assets.
|
|
*
|
|
* Drawing them as *text* is what makes them cheap: an icon beside a line of text wants that line's
|
|
* size, colour and baseline, and a `Text` gets all three for free where an `Icon` needs each one
|
|
* set and kept in step by hand.
|
|
*
|
|
* This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the
|
|
* grounds that a system font may not have the glyph and whoever gets the empty box instead is never
|
|
* the person who wrote it. That objection is about *relying* on a system font, and it is exactly
|
|
* right: the answer is not to avoid glyphs but to ship them. The font here is
|
|
* `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols
|
|
* font and committed -- so the codepoints below are resolved by an asset in the APK and cannot come
|
|
* back as tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the
|
|
* script did not subset is a glyph that silently isn't there.
|
|
*
|
|
* The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall.
|
|
* That is what makes two icons the same size without either of them being given a size: the
|
|
* proportional face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side
|
|
* by side came out visibly different widths, and matching them at the call site would have meant
|
|
* one hardcoded measurement per pair. [GLYPH_SIZE] carries the cost.
|
|
*
|
|
* The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two
|
|
* Material Design codepoints. Those two must not drift: an icon that means "settings" in one app
|
|
* and something else in the other is the failure this is worth preventing. The script is copied
|
|
* rather than shared because most of what looks like duplication is the `GLYPHS` list, which has to
|
|
* differ -- the point of subsetting is to ship only the codepoints one app draws. All Material
|
|
* Design bar one, so they read as one family; the exception is noted where it is declared.
|
|
*/
|
|
val NerdIcons = FontFamily(Font(R.font.nerd_icons))
|
|
|
|
/** Nerd Fonts puts these in plane 15, so each is a surrogate pair. */
|
|
private fun glyph(codePoint: Int) = String(Character.toChars(codePoint))
|
|
|
|
/** `md-cog` -- settings for the thing it sits beside. */
|
|
val SETTINGS_GLYPH = glyph(0xF0493)
|
|
|
|
/** `md-refresh` -- ask the server again for whatever is on screen. */
|
|
val REFRESH_GLYPH = glyph(0xF0450)
|
|
|
|
/** `md-send` -- the filled paper plane: submit what is in the composer. */
|
|
val SEND_GLYPH = glyph(0xF048A)
|
|
|
|
/**
|
|
* `md-stop` -- a filled square: end the process behind this session.
|
|
*
|
|
* The square is what stop has meant since tape decks, and it is spent here on the thing that
|
|
* actually stops rather than on pausing. [PAUSE_GLYPH] is the turn; this is the session.
|
|
*/
|
|
val STOP_GLYPH = glyph(0xF04DB)
|
|
|
|
/**
|
|
* `md-pause` -- two bars: take the running turn away and leave the session there.
|
|
*
|
|
* The pair with [STOP_GLYPH] and [PLAY_GLYPH] is the point: one button in the composer says what
|
|
* pressing it now would do to the process, and the three marks are the three answers. An interrupt
|
|
* ends a turn and nothing else -- the CLI is still there and still holds the conversation -- which
|
|
* is a pause, not a stop, and drawing it as a square said otherwise.
|
|
*/
|
|
val PAUSE_GLYPH = glyph(0xF03E4)
|
|
|
|
/** `md-play` -- start the process again, on the conversation it left. See [PAUSE_GLYPH]. */
|
|
val PLAY_GLYPH = glyph(0xF040A)
|
|
|
|
/**
|
|
* `md-send_clock` -- the same paper plane with a clock on it: this message will wait its turn.
|
|
*
|
|
* The pair with [SEND_GLYPH] is the point. Sending during a turn queues the message rather than
|
|
* starting one, and the two buttons have to be told apart at a glance -- one glyph doing both jobs
|
|
* while looking identical would promise something immediate and do something that waits.
|
|
*/
|
|
val QUEUE_GLYPH = glyph(0xF1163)
|
|
|
|
/** `md-close` -- take this off again: an attachment picked and not wanted. */
|
|
val CLOSE_GLYPH = glyph(0xF0156)
|
|
|
|
/** `md-arrow_left` -- back one level, to whatever this was opened from. */
|
|
val BACK_GLYPH = glyph(0xF004D)
|
|
|
|
/** `md-bell` -- the notifications this session is allowed to raise. */
|
|
val BELL_GLYPH = glyph(0xF009A)
|
|
|
|
/**
|
|
* `fa-line_chart` -- how much of the account's rate limits is gone.
|
|
*
|
|
* Font Awesome's rather than Material's, which is the one break in the family above: it was asked
|
|
* for by name, and Material's chart glyphs are a bare line where this one has its axes, which is
|
|
* what makes it read as a measurement rather than as a trend.
|
|
*/
|
|
val USAGE_GLYPH = glyph(0xF201)
|
|
|
|
/**
|
|
* `md-speedometer` -- what this session is costing to draw.
|
|
*
|
|
* A speedometer rather than a bug, because what it copies is a measurement rather than a fault
|
|
* report: it is as useful on a screen that feels fine, where the answer is that nothing is slow.
|
|
*/
|
|
val SPEED_GLYPH = glyph(0xF04C5)
|
|
|
|
/**
|
|
* `md-folder` -- the files on the machine this session runs on.
|
|
*
|
|
* The same codepoint dev-updater uses, and it must not drift from it, for the reason the cog and
|
|
* the refresh arrow must not: a folder that meant something else in one of the two apps is exactly
|
|
* the confusion sharing them prevents. Doubles as the mark on a directory row inside the explorer,
|
|
* which is what makes the button say where it leads.
|
|
*/
|
|
val FOLDER_GLYPH = glyph(0xF024B)
|
|
|
|
/** `md-file_outline` -- one file, in a listing beside the directories. */
|
|
val FILE_GLYPH = glyph(0xF0224)
|
|
|
|
/** `md-plus` -- make something here. dev-updater's codepoint as well. */
|
|
val PLUS_GLYPH = glyph(0xF0415)
|
|
|
|
/** `md-pencil` -- change what this file says, rather than only reading it. */
|
|
val EDIT_GLYPH = glyph(0xF03EB)
|
|
|
|
/**
|
|
* `md-content_save` -- write the edits back to the machine.
|
|
*
|
|
* The floppy disk, which is what save has meant for longer than most of the people reading it have
|
|
* been alive and is still the only mark anybody recognises for it.
|
|
*/
|
|
val SAVE_GLYPH = glyph(0xF0193)
|
|
|
|
/**
|
|
* The size an icon draws at beside a line of text.
|
|
*
|
|
* 17 rather than the 20 it was while the font was the proportional face. A glyph there filled at
|
|
* most 0.83 em of its point size and most filled a good deal less, so the number was standing in
|
|
* for the headroom above the tallest one; in the Mono face every glyph fills its em exactly, and
|
|
* keeping 20 would have made every icon in the app step up by a fifth for no reason anybody asked
|
|
* for. This is what the largest of them already drew at.
|
|
*/
|
|
private val GLYPH_SIZE = 17.sp
|
|
|
|
/**
|
|
* The same measurement in dp: a glyph's em box is its point size, and a layout is laid out in dp.
|
|
*/
|
|
private val GLYPH_EXTENT = GLYPH_SIZE.value.dp
|
|
|
|
/**
|
|
* The square a glyph button occupies: the mark, plus the same ring of padding on all four sides.
|
|
*
|
|
* The ring is the whole spacing rule. Every gap around a header icon comes out of it -- one ring to
|
|
* the screen edge, two where a button meets its neighbour -- so nothing outside has to add a gap of
|
|
* its own, and a mark cannot end up further from the button beside it than from the edge of the
|
|
* screen. That is what it was: the box was the size of the mark (28dp) and the separation was
|
|
* bolted on beside it, which left the two header icons 31dp apart and the outer one 14dp from the
|
|
* edge, so a pair that acts on one screen read as two unrelated marks with one falling off it.
|
|
*
|
|
* 48dp is the platform's minimum touch target, so the square is also the whole of what a finger has
|
|
* to find. It is what the pressed-state ripple draws, too: at 28dp that circle was inscribed in the
|
|
* mark's own corners, and beside a title it arrived at the first letter. And it is taller than any
|
|
* header's text, which is what lets the button fill a header row rather than sit in the middle of
|
|
* one -- the rows add no vertical padding of their own for the same reason they add no gap.
|
|
*/
|
|
private val GLYPH_BUTTON_SIZE = 48.dp
|
|
|
|
/**
|
|
* The ring itself, for putting something that is *not* a glyph button next to one -- a title beside
|
|
* a back arrow.
|
|
*
|
|
* Two glyph buttons need nothing between them: each brings its own ring and the two add up, which
|
|
* is why a row of them sets no spacing. Text brings none, so the second ring has to be asked for.
|
|
* Without it the pressed-state circle, which fills the whole square, arrives at the first letter of
|
|
* the title -- and the gap a reader sees between the mark and that title is then half the one
|
|
* between the two marks at the other end of the same row.
|
|
*/
|
|
val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2
|
|
|
|
/**
|
|
* A glyph you can press: the icon equivalent of a `TextButton`.
|
|
*
|
|
* Its own composable so that every icon button in the app is one size and one colour without each
|
|
* caller saying so, and so the [label] none of them displays is still there for a screen reader --
|
|
* which is all assistive technology has to go on, and also the answer to "what was that button for"
|
|
* six months from now.
|
|
*
|
|
* [enabled] is passed through rather than left to callers hiding the button: a control that comes
|
|
* and goes makes its own absence the signal, and absence cannot say whether there was nothing to do
|
|
* or nobody checked.
|
|
*/
|
|
@Composable
|
|
fun GlyphButton(
|
|
glyph: String,
|
|
label: String,
|
|
onClick: () -> Unit,
|
|
modifier: Modifier = Modifier,
|
|
enabled: Boolean = true,
|
|
colour: Color = MaterialTheme.colorScheme.primary,
|
|
) {
|
|
MarkButton(label, onClick, modifier, enabled) {
|
|
Glyph(glyph, colour = if (enabled) colour else MaterialTheme.colorScheme.outline)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The same square, around a mark that is not a glyph.
|
|
*
|
|
* A [Chevron] is drawn rather than set in a font, and a pair of them used as buttons has to be the
|
|
* size, spacing and touch target every other icon button on this app's headers already is -- so
|
|
* this is [GlyphButton] with the mark left to the caller rather than a second set of measurements
|
|
* beside it. The caller still owes it a [label]: nothing here draws a word.
|
|
*/
|
|
@Composable
|
|
fun MarkButton(
|
|
label: String,
|
|
onClick: () -> Unit,
|
|
modifier: Modifier = Modifier,
|
|
enabled: Boolean = true,
|
|
mark: @Composable () -> Unit,
|
|
) {
|
|
IconButton(
|
|
onClick = onClick,
|
|
enabled = enabled,
|
|
modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
|
|
) {
|
|
mark()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The square a glyph button occupies, with a spinner in it instead of a mark.
|
|
*
|
|
* For a button whose work is under way. It takes the button's whole box rather than the mark's, so
|
|
* swapping one for the other leaves everything in the row exactly where it was -- a control that
|
|
* changed the width of its header while it worked would move its neighbours at the moment somebody
|
|
* was pressing them.
|
|
*/
|
|
@Composable
|
|
fun GlyphSpinner(label: String, modifier: Modifier = Modifier) {
|
|
Box(
|
|
contentAlignment = Alignment.Center,
|
|
modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
|
|
) {
|
|
CircularProgressIndicator(Modifier.size(GLYPH_EXTENT), strokeWidth = 2.dp)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One icon, drawn as text.
|
|
*
|
|
* Callers that are already inside something pressable use this; [GlyphButton] is the one that adds
|
|
* the press. Either way the caller owes it a description, since neither draws a word.
|
|
*/
|
|
@Composable
|
|
fun Glyph(
|
|
glyph: String,
|
|
modifier: Modifier = Modifier,
|
|
colour: Color = MaterialTheme.colorScheme.primary,
|
|
size: TextUnit = GLYPH_SIZE,
|
|
) {
|
|
// Line height of the point size, which for this font is the square the glyph draws in: its
|
|
// ascent and descent add up to exactly one em, and every glyph in the Mono face fills that em.
|
|
// Left to the inherited body style the line box was 24sp tall around a 17sp-wide mark, so a
|
|
// glyph took a seventh more vertical space than horizontal wherever one is drawn without a box
|
|
// around it -- and where there is a box, that leading is what its padding is measured through.
|
|
Text(
|
|
glyph,
|
|
fontFamily = NerdIcons,
|
|
fontSize = size,
|
|
lineHeight = size,
|
|
color = colour,
|
|
modifier = modifier,
|
|
)
|
|
}
|