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>
271 lines
11 KiB
Kotlin
271 lines
11 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. 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. 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 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. [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. 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.
|
|
*/
|
|
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, which is a pause, not a stop.
|
|
*/
|
|
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 one glyph doing both jobs 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.
|
|
*/
|
|
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. 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, 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 stepped every
|
|
* icon in the app up by a fifth.
|
|
*/
|
|
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. 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.
|
|
*
|
|
* 48dp is the platform's minimum touch target, so the square is also the whole of what a finger has
|
|
* to find, and what the pressed-state ripple draws: 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.
|
|
*/
|
|
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. Text
|
|
* brings none, so the second ring has to be asked for -- without it the pressed-state circle
|
|
* arrives at the first letter of the title.
|
|
*/
|
|
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 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 already is. 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.
|
|
*/
|
|
@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. 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.
|
|
Text(
|
|
glyph,
|
|
fontFamily = NerdIcons,
|
|
fontSize = size,
|
|
lineHeight = size,
|
|
color = colour,
|
|
modifier = modifier,
|
|
)
|
|
}
|