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, ) }