A markdown paragraph took every tap that landed on its glyphs, so an opened peer message or memory note could be shut anywhere except on the text -- which is most of it, and reads as a card that has stopped working. Measured on the emulator: with a handler on the text the tap did nothing at all, and with the handler removed the same tap shut the card. The words now do the shutting, through a composition local, since the renderer composes those paragraphs out of its own component table and there is nothing between the card and them to pass a parameter through. The link handler is bounded by the long-press timeout, so holding to select is not a tap. The other half is the tap that puts a selection away, which used to shut whatever card the words were in. The container clears the selection from that same press, milliseconds before the card reads it, so the answer is taken at composition instead -- what was true when the reader touched the screen. Selection colours are the app's own. Material's 40% of primary is a tint of whatever is behind it, and over the near-black a code block sits on it composited to a smudge, so selecting a line of code looked like nothing had happened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
356 lines
15 KiB
Kotlin
356 lines
15 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.text.selection.TextSelectionColors
|
|
import androidx.compose.material3.ButtonColors
|
|
import androidx.compose.material3.ButtonDefaults
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.darkColorScheme
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.ui.graphics.Color
|
|
|
|
/**
|
|
* Catppuccin Mocha, as published in `catppuccin/palette`.
|
|
*
|
|
* Named rather than used as literals at the point of need, so the mapping below reads as the
|
|
* decision it is -- "a card is Surface 0" -- and so a value can be checked against the upstream
|
|
* palette without reading the layout that uses it.
|
|
*/
|
|
private object Mocha {
|
|
val Rosewater = Color(0xFFF5E0DC)
|
|
val Mauve = Color(0xFFCBA6F7)
|
|
val Red = Color(0xFFF38BA8)
|
|
val Peach = Color(0xFFFAB387)
|
|
val Yellow = Color(0xFFF9E2AF)
|
|
val Green = Color(0xFFA6E3A1)
|
|
val Teal = Color(0xFF94E2D5)
|
|
val Sky = Color(0xFF89DCEB)
|
|
val Blue = Color(0xFF89B4FA)
|
|
val Lavender = Color(0xFFB4BEFE)
|
|
val Pink = Color(0xFFF5C2E7)
|
|
val Text = Color(0xFFCDD6F4)
|
|
val Subtext1 = Color(0xFFBAC2DE)
|
|
val Subtext0 = Color(0xFFA6ADC8)
|
|
val Overlay0 = Color(0xFF6C7086)
|
|
val Surface2 = Color(0xFF585B70)
|
|
val Surface1 = Color(0xFF45475A)
|
|
val Surface0 = Color(0xFF313244)
|
|
val Base = Color(0xFF1E1E2E)
|
|
val Mantle = Color(0xFF181825)
|
|
val Crust = Color(0xFF11111B)
|
|
}
|
|
|
|
/**
|
|
* The app's colour scheme: Catppuccin Mocha mapped onto Material's roles.
|
|
*
|
|
* Copied from dev-updater rather than shared, which is a deliberate line: wg-app-link is the *link*
|
|
* -- the tunnel, the pinned CA, enrollment -- and a palette is not that. The two apps looking alike
|
|
* is a preference, not a contract, and the moment one wants a different accent the shared version
|
|
* becomes a thing to fight rather than a thing to use.
|
|
*
|
|
* The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle,
|
|
* Base, Surface 0, Surface 1 -- and Material asks for the same thing under different names, so the
|
|
* page is Base, a component's outlined card stays Base beside it, and a project's card is Surface
|
|
* 0: one visible step up, which is the whole of what the nesting has to say.
|
|
*
|
|
* Accents on this palette are light, so anything filled with one takes Crust for its text rather
|
|
* than the near-white the roles default to.
|
|
*/
|
|
val AiAppColors =
|
|
darkColorScheme(
|
|
primary = Mocha.Mauve,
|
|
onPrimary = Mocha.Crust,
|
|
primaryContainer = Mocha.Surface1,
|
|
onPrimaryContainer = Mocha.Mauve,
|
|
secondary = Mocha.Lavender,
|
|
onSecondary = Mocha.Crust,
|
|
secondaryContainer = Mocha.Surface1,
|
|
onSecondaryContainer = Mocha.Lavender,
|
|
tertiary = Mocha.Rosewater,
|
|
onTertiary = Mocha.Crust,
|
|
background = Mocha.Base,
|
|
onBackground = Mocha.Text,
|
|
surface = Mocha.Base,
|
|
onSurface = Mocha.Text,
|
|
surfaceVariant = Mocha.Surface0,
|
|
onSurfaceVariant = Mocha.Subtext0,
|
|
surfaceContainerLowest = Mocha.Crust,
|
|
surfaceContainerLow = Mocha.Mantle,
|
|
surfaceContainer = Mocha.Base,
|
|
surfaceContainerHigh = Mocha.Surface0,
|
|
surfaceContainerHighest = Mocha.Surface0,
|
|
inverseSurface = Mocha.Text,
|
|
inverseOnSurface = Mocha.Base,
|
|
inversePrimary = Mocha.Mauve,
|
|
outline = Mocha.Overlay0,
|
|
outlineVariant = Mocha.Surface2,
|
|
error = Mocha.Red,
|
|
onError = Mocha.Crust,
|
|
errorContainer = Mocha.Surface1,
|
|
onErrorContainer = Mocha.Red,
|
|
scrim = Mocha.Crust,
|
|
)
|
|
|
|
/**
|
|
* What a session is doing, said in colour.
|
|
*
|
|
* Here rather than beside each screen that shows a status. These were separate literals in two
|
|
* other files -- an amber, a green and a red picked off Material's defaults -- so the same state
|
|
* was a slightly different colour depending which screen you looked at, and none of them belonged
|
|
* to this palette at all. A colour that carries meaning is part of the scheme, not a value typed
|
|
* where it happened to be needed.
|
|
*/
|
|
val runningColor: Color
|
|
@Composable get() = Mocha.Green
|
|
|
|
/**
|
|
* "This went wrong on its own": a session that fell over.
|
|
*
|
|
* The scheme's error colour, and deliberately not "the same red as a destructive button" even
|
|
* though it is the same red. They are the same red for different reasons, and a state is not an
|
|
* action -- nothing here is a button.
|
|
*/
|
|
val failedColor: Color
|
|
@Composable get() = MaterialTheme.colorScheme.error
|
|
|
|
/**
|
|
* About the session rather than about the task: a command, and the compaction one of them starts.
|
|
*
|
|
* Its own colour because it is its own kind of work. Everything else a session does is progress
|
|
* through what was asked of it; this is the session acting on itself -- rewriting what it
|
|
* remembers, taking a new name -- and none of it appears in the transcript as an answer to
|
|
* anything. A reader who has learned that blue means "not stuck, but not replying to you either"
|
|
* has learned the thing that distinguishes it from a session that has hung.
|
|
*/
|
|
val commandColor: Color
|
|
@Composable get() = Mocha.Blue
|
|
|
|
/**
|
|
* A clear: the conversation taken out of what the session is given.
|
|
*
|
|
* Red because of what it does, not because anything went wrong -- somebody asked for this, and a
|
|
* deliberate choice is not a problem to report. It is the same red as [failedColor] and [stopColor]
|
|
* for a third reason, which is worth naming rather than collapsing: this is neither a fault nor a
|
|
* button, it is the mark left where something was taken away. The reader never has to tell the
|
|
* three apart, because no two of them can appear as the same kind of thing.
|
|
*/
|
|
val clearedColor: Color
|
|
@Composable get() = Mocha.Red
|
|
|
|
/** Waiting on a person: a question, a permission, a turn that is theirs. */
|
|
val awaitingColor: Color
|
|
@Composable get() = Mocha.Peach
|
|
|
|
/** Approaching a limit -- still fine, worth seeing. */
|
|
val warningColor: Color
|
|
@Composable get() = Mocha.Yellow
|
|
|
|
/**
|
|
* The fill of a progress bar that is only reporting how far along something is.
|
|
*
|
|
* Blue because a bar like this reports a quantity rather than a verdict, and the scheme's primary
|
|
* made it the loudest thing on a screen the reader opened to do something else. A download, or a
|
|
* compaction, has no limit to be near: it finishes. Only a bar measuring a *quota* escalates, and
|
|
* that one is [quotaColor].
|
|
*/
|
|
val progressColor: Color
|
|
@Composable get() = Mocha.Blue
|
|
|
|
/**
|
|
* The fill of a bar measuring how much of a quota is gone: blue, then yellow, then red.
|
|
*
|
|
* One function rather than the same `when` written beside each bar, because the whole point of
|
|
* colouring by consequence is that the reader learns the step once -- two bars showing the same 80%
|
|
* in different colours teaches nothing except that the colour cannot be trusted. It reads as a
|
|
* difference in degree, which is all colour can carry: the states that differ in *kind* from this
|
|
* -- a window nobody could read, a machine that meters nothing -- are said in words elsewhere,
|
|
* because a reader has no way to tell those from an ordinary low number by colour alone.
|
|
*
|
|
* [percent] is the API's own 0-100 rather than a fraction, so callers pass what the server sent
|
|
* without each converting it first and one of them getting it wrong by a factor of a hundred.
|
|
*/
|
|
@Composable
|
|
fun quotaColor(percent: Double): Color =
|
|
when {
|
|
percent >= OVER_LIMIT_PERCENT -> overLimitColor
|
|
percent >= WARNING_PERCENT -> warningColor
|
|
else -> progressColor
|
|
}
|
|
|
|
/** Close enough to the limit to be worth seeing before starting something big. */
|
|
private const val WARNING_PERCENT = 75.0
|
|
|
|
/** Close enough that the next turn may be the one that is refused. */
|
|
private const val OVER_LIMIT_PERCENT = 90.0
|
|
|
|
/**
|
|
* The surface verbatim text sits on: a command, a tool's output, a code block in a reply.
|
|
*
|
|
* The darkest value in the palette rather than a step up from the page, and that is the whole point
|
|
* -- everything else on this screen is somebody's prose, and this is what a machine was handed and
|
|
* what it said back, character for character. Crust sits *below* Base, so the same colour reads as
|
|
* one clear step down both on the page, where a reply is drawn, and on a card, where a tool call
|
|
* is; a tint chosen upwards has to be picked twice and still collides with the card it lands on.
|
|
* The renderer's default code background was `surfaceVariant`, which is exactly a card's own fill
|
|
* -- so a code block inside a tool call had no background at all.
|
|
*
|
|
* One colour for all three, so "this is verbatim" is learnable once.
|
|
*/
|
|
val rawSurface: Color
|
|
@Composable get() = Mocha.Crust
|
|
|
|
/**
|
|
* Catppuccin Mocha as the highlighter's palette; see [SyntaxPalette].
|
|
*
|
|
* Here with the rest of the palette rather than beside the code that highlights: the colours a
|
|
* fence is drawn in are the same accents every other coloured thing in the app already uses, and
|
|
* splitting them out would make code the one surface whose palette came from somewhere else.
|
|
*
|
|
* Not a composable, because [highlight] runs off the drawing thread; these colours never vary with
|
|
* the theme.
|
|
*/
|
|
fun catppuccinSyntax(): SyntaxPalette =
|
|
SyntaxPalette(
|
|
keyword = Mocha.Mauve,
|
|
string = Mocha.Green,
|
|
literal = Mocha.Peach,
|
|
comment = Mocha.Overlay0,
|
|
metadata = Mocha.Yellow,
|
|
punctuation = Mocha.Subtext0,
|
|
mark = Mocha.Sky,
|
|
)
|
|
|
|
/**
|
|
* The sixteen terminal colours, for what a Bash tool call printed; see [AnsiPalette].
|
|
*
|
|
* Catppuccin publishes its own ANSI mapping and this is it, rather than the eight accents picked by
|
|
* eye: a program printing in "colour 4" means blue, and which blue is a decision the palette has
|
|
* already made for every other blue on the screen.
|
|
*
|
|
* Mocha's bright half is the same accents as its normal half -- only the two greys differ -- which
|
|
* is upstream's choice and not an omission here. A program that uses bright red to mean something
|
|
* other than red is relying on a distinction its own terminal may not draw either.
|
|
*
|
|
* The background is [rawSurface] because that is what a tool's output is drawn on, and reverse
|
|
* video needs to know what it is reversing against.
|
|
*/
|
|
fun ansiPalette(): AnsiPalette =
|
|
AnsiPalette(
|
|
colours =
|
|
listOf(
|
|
Mocha.Surface1,
|
|
Mocha.Red,
|
|
Mocha.Green,
|
|
Mocha.Yellow,
|
|
Mocha.Blue,
|
|
Mocha.Pink,
|
|
Mocha.Teal,
|
|
Mocha.Subtext1,
|
|
Mocha.Surface2,
|
|
Mocha.Red,
|
|
Mocha.Green,
|
|
Mocha.Yellow,
|
|
Mocha.Blue,
|
|
Mocha.Pink,
|
|
Mocha.Teal,
|
|
Mocha.Subtext0,
|
|
),
|
|
foreground = Mocha.Text,
|
|
background = Mocha.Crust,
|
|
)
|
|
|
|
/**
|
|
* What a selection looks like, stated rather than left to Material's default.
|
|
*
|
|
* The default is `primary` at 40% alpha, which is a tint of whatever is behind it -- and this app
|
|
* draws text on surfaces two full steps apart. Over a reply, on Base, that reads clearly. Over a
|
|
* code block or a tool's output, on Crust, the same 40% composites to a barely-there smudge, so
|
|
* selecting a line of code looks like nothing happened even though the selection is there and
|
|
* copies correctly.
|
|
*
|
|
* Fixed and stronger, because "this is selected" is a meaning rather than decoration: a colour that
|
|
* means something must carry its own contrast instead of borrowing it from the surface it happens
|
|
* to land on. Raised only as far as it takes to read on the darkest of them -- past this the fill
|
|
* starts competing with the syntax colours it sits behind, which are the thing being read.
|
|
*/
|
|
val AiAppSelectionColors =
|
|
TextSelectionColors(
|
|
handleColor = Mocha.Mauve,
|
|
backgroundColor = Mocha.Mauve.copy(alpha = 0.55f),
|
|
)
|
|
|
|
/**
|
|
* A link. Blue is what a link is on every Catppuccin surface, and the one colour to leave alone.
|
|
*/
|
|
val linkColor: Color
|
|
@Composable get() = Mocha.Blue
|
|
|
|
/**
|
|
* A list's markers: the bullets and numbers down its left edge.
|
|
*
|
|
* The scheme's secondary accent rather than the text colour, because a marker is structure rather
|
|
* than words: coloured, the items of a list can be counted without reading them, and a nested list
|
|
* reads as a shape before it reads as text. Lavender is not one of the colours that mean something
|
|
* here -- green, red, peach and yellow are states and actions -- and it is the same at every depth,
|
|
* since depth is said by the glyph and the indent; a colour per depth would make a difference in
|
|
* degree look like one in kind.
|
|
*/
|
|
val listMarkerColor: Color
|
|
@Composable get() = Mocha.Lavender
|
|
|
|
/** Past a limit. The scheme's error colour, for the reason [failedColor] gives. */
|
|
val overLimitColor: Color
|
|
@Composable get() = MaterialTheme.colorScheme.error
|
|
|
|
/**
|
|
* The composer's buttons, coloured by what pressing one does rather than by where it sits.
|
|
*
|
|
* Green makes something happen now, blue makes it happen later, orange takes back what is in
|
|
* flight, red ends the process. The near-collisions with the states above are deliberate and worth
|
|
* naming rather than collapsing: [runningColor] is green because a session is working,
|
|
* [failedColor] is red because one fell over, [awaitingColor] is the same orange because a session
|
|
* is waiting on somebody -- those are *states*, and these are *actions*. A reader never has to tell
|
|
* them apart, because nothing here is a state and nothing there is pressable.
|
|
*/
|
|
val sendColor: Color
|
|
@Composable get() = Mocha.Green
|
|
|
|
/** Sending while a turn runs: the message waits rather than starting one. See [sendColor]. */
|
|
val queueColor: Color
|
|
@Composable get() = Mocha.Blue
|
|
|
|
/**
|
|
* Interrupting the running turn: the work stops and the session stays.
|
|
*
|
|
* Orange rather than red because of how much it takes: only what is in flight. The process is still
|
|
* there holding the conversation, and the next message starts a turn as though nothing had
|
|
* happened. Red is spent on [stopColor], which is the same button in the same place when what it
|
|
* would end is the session's process.
|
|
*/
|
|
val pauseColor: Color
|
|
@Composable get() = Mocha.Peach
|
|
|
|
/** Ending the session's process -- the one button here that takes something away. */
|
|
val stopColor: Color
|
|
@Composable get() = Mocha.Red
|
|
|
|
/**
|
|
* Starting the process again, on the conversation it left.
|
|
*
|
|
* The same green as [sendColor] on purpose: both mean "this happens now", and they are never the
|
|
* same button -- the process button only offers to start when there is nothing running to stop.
|
|
*/
|
|
val startColor: Color
|
|
@Composable get() = Mocha.Green
|
|
|
|
/**
|
|
* A filled button in one of the action colours above.
|
|
*
|
|
* The content colour is stated here beside the fill rather than inherited. A semantic colour has to
|
|
* carry its own contrast: these fills are fixed whatever the surface under them does, so the theme
|
|
* will not change to rescue a foreground that stops being readable on one of them. Crust is what
|
|
* every accent on this palette takes, which is the same reason `onPrimary` is Crust above.
|
|
*/
|
|
@Composable
|
|
fun actionButtonColors(fill: Color): ButtonColors =
|
|
ButtonDefaults.buttonColors(containerColor = fill, contentColor = Mocha.Crust)
|