feat(android): palette resolved before the first frame

storedPalette() reads and resolves the theme file without building the
engine, so onCreate can set the window background and system-bar icon
polarity before anything draws. Previously the background was a hex literal
in themes.xml and the bar icons were pinned dark, which meant every cold
start flashed one flavour's colour and Latte drew white icons on a
near-white page.

Adds the Material scheme mapping, type ramp and shape tokens the palette
feeds, plus unit tests for both.
This commit is contained in:
nvrl committed 2026-08-10 01:10:08 +02:00
1 parent cb03f96eca
commit 1a9a4624f8
11 files changed
+918 -31

No files matched your search

@@ -0,0 +1,223 @@
package io.narl.protonstream.ui.theme
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
import androidx.compose.material3.SliderState
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.unit.dp
/**
* The accent, as a gradient when there is one to draw.
*
* The desktop paints the accent as a two-hue ramp on everything that is a
* control — the seek bar, the primary buttons, the tile progress bar — and the
* viewer's "paint the accent as a gradient" toggle is what turns it off. That
* toggle was already stored, already offered on this screen, and drawn nowhere
* on Android, so the same stored setting meant two different things depending
* on which client read it.
*
* Nothing here reads the toggle. It does not need to: when gradients are off,
* `Palette::resolve` sets `accent_alt` equal to `accent`, so the ramp collapses
* to a flat fill on its own. `primary` and `secondary` are those two colours —
* see [schemeOf] — which is why this asks Material for them rather than
* reaching past it for the palette.
*/
@Composable
internal fun accentBrush(): Brush {
val scheme = MaterialTheme.colorScheme
return remember(scheme.primary, scheme.secondary) {
Brush.horizontalGradient(listOf(scheme.primary, scheme.secondary))
}
}
/**
* The primary button: the accent ramp, at the shared corner radius.
*
* Material's filled button has its own shape token (`CornerFull`) that does not
* read `MaterialTheme.shapes`, so the radius has to be handed to it — which is
* the whole reason the plain buttons below exist as well. They add nothing but
* that.
*/
@Composable
internal fun AccentButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) {
val shape = MaterialTheme.shapes.small
Button(
onClick = onClick,
// Only while it is live: a disabled button keeps Material's flat
// disabled container, because a greyed control that still wears the
// one strong colour in the window reads as pressable.
modifier = if (enabled) modifier.background(accentBrush(), shape) else modifier,
enabled = enabled,
shape = shape,
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
content = content,
)
}
@Composable
internal fun TonalButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) = FilledTonalButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
shape = MaterialTheme.shapes.small,
content = content,
)
@Composable
internal fun EdgedButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) = OutlinedButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
shape = MaterialTheme.shapes.small,
content = content,
)
@Composable
internal fun QuietButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) = TextButton(
onClick = onClick,
modifier = modifier,
enabled = enabled,
shape = MaterialTheme.shapes.small,
content = content,
)
/**
* How far along something is, drawn in the accent.
*
* Hand-drawn rather than `LinearProgressIndicator` because that one takes a
* `Color` and not a `Brush`, and because Material 1.4 draws a gap and a stop
* indicator either side of the bar — detail at a size where the desktop draws a
* plain bar. [fill] is a parameter for the one bar that is deliberately not the
* accent: a download in flight sits directly under the watch-progress bar of
* the same row, and two ramps in the same colour stacked two pixels apart say
* nothing.
*/
@Composable
internal fun AccentProgress(
progress: () -> Float,
modifier: Modifier = Modifier,
fill: Brush? = null,
) {
val brush = fill ?: accentBrush()
val track = MaterialTheme.colorScheme.surfaceContainerHighest
Canvas(modifier.fillMaxWidth().height(PROGRESS_HEIGHT.dp)) {
val radius = CornerRadius(size.height / 2)
drawRoundRect(color = track, cornerRadius = radius)
val done = size.width * progress().coerceIn(0f, 1f)
// Below one bar's width there is nothing to round, and a rounded rect
// narrower than its own corners draws as a wrong-looking pill.
if (done >= size.height) {
drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)
}
}
}
/**
* The seek bar's track, in the accent ramp.
*
* Passed to `Slider`'s `track` slot, so the thumb, the gestures and the
* accessibility semantics stay Material's — this replaces the two rectangles it
* would have drawn and nothing else.
*
* The spent side is the accent and the rest is white at a third: the seek bar
* is the one accent surface that sits over arbitrary film footage, where a
* palette colour for the *unplayed* remainder would be a coloured line over a
* picture rather than the absence of one.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun AccentTrack(state: SliderState, modifier: Modifier = Modifier) {
val brush = accentBrush()
Canvas(modifier.fillMaxWidth().height(TRACK_HEIGHT.dp)) {
val radius = CornerRadius(size.height / 2)
drawRoundRect(color = Color.White.copy(alpha = 0.30f), cornerRadius = radius)
val done = size.width * state.coercedValueAsFraction
if (done >= size.height) {
drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)
}
}
}
/**
* The navigation pill, in the accent.
*
* Solid rather than a ramp, and this is the one place the two clients cannot
* agree exactly: `NavigationSuiteScaffold` takes the indicator as a `Color`,
* with no slot to draw into, so a gradient here would mean reimplementing the
* bar. The accent itself is still the right answer — the desktop's rule is that
* the accent marks the thing you are on — and it is nearer to that than
* `secondaryContainer`, which is the accent taken most of the way back to the
* page.
*/
@Composable
internal fun accentNavigationColors(): NavigationSuiteItemColors {
val scheme = MaterialTheme.colorScheme
return NavigationSuiteDefaults.itemColors(
navigationBarItemColors = NavigationBarItemDefaults.colors(
selectedIconColor = scheme.onPrimary,
selectedTextColor = scheme.onSurface,
indicatorColor = scheme.primary,
),
navigationRailItemColors = NavigationRailItemDefaults.colors(
selectedIconColor = scheme.onPrimary,
selectedTextColor = scheme.onSurface,
indicatorColor = scheme.primary,
),
navigationDrawerItemColors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = scheme.primary,
selectedIconColor = scheme.onPrimary,
selectedTextColor = scheme.onPrimary,
),
)
}
/** A solid fill, for the callers of [AccentProgress] that want one colour. */
internal fun solid(color: Color): Brush = SolidColor(color)
/** Thin enough to sit under a row without becoming part of it. */
private const val PROGRESS_HEIGHT = 4f
/** The seek bar is dragged with a finger, so it is the thicker of the two. */
private const val TRACK_HEIGHT = 6f
@@ -0,0 +1,36 @@
package io.narl.protonstream.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
/**
* One corner radius, which is the desktop client's corner radius.
*
* `pstr-app` rounds every surface it paints to 8 — cards, buttons, the tab
* pill, the progress bars — and only a badge over artwork is tighter, at 4.
* Material's default ramp runs 4 / 8 / 12 / 16 / 28, so a stock Compose screen
* puts a 28dp bottom sheet next to a 12dp card next to a fully-round button,
* and none of those is the shape the other client draws.
*
* Flat rather than a ramp, therefore, with the two ends kept for the two cases
* where a single radius is wrong: `extraSmall` for something small enough that
* 8 would eat it, and `extraLarge` for a sheet whose corners meet the edge of
* the screen and need to read as a lifted sheet rather than a seam.
*/
internal val ProtonShapes = Shapes(
extraSmall = RoundedCornerShape(CORNER_TIGHT.dp),
small = RoundedCornerShape(CORNER.dp),
medium = RoundedCornerShape(CORNER.dp),
large = RoundedCornerShape(CORNER.dp),
extraLarge = RoundedCornerShape(CORNER_SHEET.dp),
)
/** The radius `pstr-app` draws everything at. */
internal const val CORNER = 8f
/** A badge over artwork — `ui/mod.rs` is the other place this number lives. */
internal const val CORNER_TIGHT = 4f
/** A bottom sheet, which has no desktop counterpart. */
internal const val CORNER_SHEET = 16f
@@ -29,8 +29,29 @@ object AppearanceState {
private val current = MutableStateFlow<PaletteRecord?>(null)
val palette: StateFlow<PaletteRecord?> = current.asStateFlow()
/** Read the stored choice. Safe to call repeatedly; the last read wins. */
/**
* The palette read before the first frame, from
* [io.narl.protonstream.native.NativeRuntime.storedPalette].
*
* Separate from [load] because it runs on the main thread during
* `onCreate`: it is the only read that is early enough to decide what the
* first composition is painted in, and it is cheap enough to be allowed
* there.
*/
fun seed(palette: PaletteRecord) {
current.value = palette
}
/**
* Read the stored choice through the engine. Safe to call repeatedly; the
* last read wins.
*
* A no-op once [seed] has run — it resolves the same file through the same
* Rust, so the only thing a second read can do is build the engine earlier
* than the first screen needed it.
*/
suspend fun load() {
if (current.value != null) return
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().palette() } }
.onSuccess { current.value = it }
}
@@ -48,23 +69,48 @@ fun ProtonStreamTheme(content: @Composable () -> Unit) {
// with — a theme file that cannot be read must never be a reason the
// library does not open.
LaunchedEffect(Unit) { AppearanceState.load() }
MaterialTheme(colorScheme = palette?.let(::schemeOf) ?: ProtonColors, content = content)
MaterialTheme(
colorScheme = schemeOf(palette ?: ProtonFallback),
// The other two thirds of what the clients share. Colour was already
// one resolved answer for both; type and shape were Material's defaults
// here and named constants there, which is most of why the two read as
// different apps even where the colours agreed.
typography = ProtonTypography,
shapes = ProtonShapes,
content = content,
)
}
/**
* One resolved palette as Material's roles.
* One resolved palette as Material's roles — **all** of them.
*
* Every role Material names has to be answered here, because a role left out
* does not fall back to something neutral: it keeps the value
* `darkColorScheme()` / `lightColorScheme()` gave it, which is Material's own
* baseline purple-and-grey and belongs to no flavour this app ships.
*
* That is not hypothetical. `surfaceContainerHighest` is what `Card` fills with
* (`FilledCardTokens.ContainerColor`), and while it was unmapped every tile in
* the library grid, every episode row, every share and every download was drawn
* in baseline neutral on top of a flavoured page. `inverseSurface` is the
* snackbar — the app's only error channel — and `scrim` is the dim behind every
* dialog.
*
* Material names more roles than a palette has colours, so several are answered
* by the same one — `surfaceVariant` and `card` above all, which is what makes
* a tile and a form field the same shade in both clients.
* by the same one, and two (`elevated`, `dangerDim`) are derived in Rust so the
* blend behind them is the shared one. `SchemeRolesTest` asserts that nothing
* here is left at its baseline.
*/
private fun schemeOf(palette: PaletteRecord): ColorScheme {
internal fun schemeOf(palette: PaletteRecord): ColorScheme {
val base = if (palette.light) lightColorScheme() else darkColorScheme()
return base.copy(
primary = color(palette.accent),
onPrimary = color(palette.onAccent),
primaryContainer = color(palette.accentDim),
onPrimaryContainer = color(palette.text),
// Readable *on* `inverseSurface`, which is the ink colour: `accentDim`
// is the accent taken towards the page, so it inverts with the rest.
inversePrimary = color(palette.accentDim),
secondary = color(palette.accentAlt),
onSecondary = color(palette.onAccent),
// The accent taken back towards the page, not the card's hover shade.
@@ -74,35 +120,97 @@ private fun schemeOf(palette: PaletteRecord): ColorScheme {
// a dark grey row, readable as text but not as controls.
secondaryContainer = color(palette.accentDim),
onSecondaryContainer = color(palette.text),
// The palette has two hues, not three. Tertiary takes the second one —
// the gradient partner — rather than inventing a third that no flavour
// chose.
tertiary = color(palette.accentAlt),
onTertiary = color(palette.onAccent),
tertiaryContainer = color(palette.accentDim),
onTertiaryContainer = color(palette.text),
background = color(palette.background),
onBackground = color(palette.text),
surface = color(palette.surface),
onSurface = color(palette.text),
surfaceVariant = color(palette.card),
onSurfaceVariant = color(palette.muted),
surfaceContainer = color(palette.card),
surfaceContainerHigh = color(palette.cardHover),
surfaceContainerLow = color(palette.sunken),
outline = color(palette.border),
outlineVariant = color(palette.muted),
// No elevation tint. Material tints a raised surface with `primary`,
// and the desktop draws no such tint at all — its rule is that the
// accent is the only strong colour in the window and everything wearing
// it is something to click. A card that is faintly accent-coloured
// because it happens to be raised breaks that.
surfaceTint = Color.Transparent,
// Genuinely inverted, both ways round: on a dark flavour this is a pale
// snackbar with dark ink, on Latte a dark one with pale ink.
inverseSurface = color(palette.text),
inverseOnSurface = color(palette.background),
error = color(palette.danger),
onError = color(palette.background),
errorContainer = color(palette.dangerDim),
onErrorContainer = color(palette.text),
// Swapped from what was here before, to match what Material draws with
// each: `outline` is a border meant to be seen (an outlined button,
// a swatch that is not selected), `outlineVariant` is a divider meant
// to be barely there. `border` is the fainter of the two.
outline = color(palette.muted),
outlineVariant = color(palette.border),
// Black in both of Material's own schemes, and for the same reason: a
// scrim is not a colour, it is an absence of light.
scrim = Color.Black,
surfaceBright = color(palette.elevated),
surfaceDim = color(palette.background),
// The containment ladder, in the order the palette already runs: the
// deepest colour in the flavour at the bottom, `elevated` — one step
// past the card's hover shade — at the top.
surfaceContainerLowest = color(palette.sunken),
surfaceContainerLow = color(palette.surface),
surfaceContainer = color(palette.card),
surfaceContainerHigh = color(palette.cardHover),
surfaceContainerHighest = color(palette.elevated),
// The fixed roles are Material's "the same in light and dark", which a
// per-flavour palette has no equivalent of. They take the accent pair
// rather than a baseline hue, so a component that reaches for one still
// lands inside the flavour.
primaryFixed = color(palette.accentDim),
primaryFixedDim = color(palette.accent),
onPrimaryFixed = color(palette.text),
onPrimaryFixedVariant = color(palette.muted),
secondaryFixed = color(palette.accentDim),
secondaryFixedDim = color(palette.accentAlt),
onSecondaryFixed = color(palette.text),
onSecondaryFixedVariant = color(palette.muted),
tertiaryFixed = color(palette.accentDim),
tertiaryFixedDim = color(palette.accentAlt),
onTertiaryFixed = color(palette.text),
onTertiaryFixedVariant = color(palette.muted),
)
}
/** A packed `0xAARRGGBB` from the bridge, as Compose wants it. */
private fun color(argb: UInt) = Color(argb.toInt())
/** The shipped default, spelled out for the frames before the store is read. */
private val ProtonColors = darkColorScheme(
primary = Color(0xFF7D4DFF),
onPrimary = Color(0xFFEAEAF0),
secondary = Color(0xFF3F6FE0),
background = Color(0xFF0E0E12),
surface = Color(0xFF17171D),
surfaceVariant = Color(0xFF1E1E26),
onBackground = Color(0xFFEAEAF0),
onSurface = Color(0xFFEAEAF0),
outline = Color(0xFF2A2A35),
error = Color(0xFFE05561),
/**
* The shipped default, spelled out for the case where Rust cannot be asked.
*
* A `PaletteRecord` rather than a `ColorScheme`, so that it goes through
* [schemeOf] like any other palette and inherits every mapping above — a
* fallback that answered only ten roles would be the same bug in a smaller
* place. The values are `pstr_core::appearance::Palette::PROTON`.
*/
internal val ProtonFallback = PaletteRecord(
background = 0xFF0E0E12u,
surface = 0xFF17171Du,
sunken = 0xFF0A0A0Du,
card = 0xFF1E1E26u,
cardHover = 0xFF2A2A35u,
border = 0xFF262630u,
text = 0xFFEAEAF0u,
muted = 0xFF8E8E9Cu,
accent = 0xFF7D4DFFu,
accentAlt = 0xFF3F6FE0u,
accentDim = 0xFF5333ADu,
onAccent = 0xFFEAEAF0u,
danger = 0xFFE05561u,
elevated = 0xFF333340u,
dangerDim = 0xFF7F2F37u,
light = false,
)
@@ -0,0 +1,75 @@
package io.narl.protonstream.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
/**
* The type ramp, which is the desktop client's type ramp.
*
* `pstr_app::theme::Role` names nine sizes and the desktop asks for a role
* rather than a number at every call site. Material names fifteen styles, so
* several of them land on the same rung — that is the point. The two clients
* were built to share one palette; sharing one scale is the other half of why
* they should read as one app.
*
* The rungs, largest first: 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11.
*
* Where a role maps is decided by what actually draws with it here, not by
* Material's own sizes — Material's `displayLarge` is 57sp, which is a size for
* a phone's lock screen and not for anything in a library grid. `bodyLarge` is
* what an unstyled `Text` gets, so it takes the desktop's body rung and the two
* smaller body styles step down from there.
*
* [TypeRampTest] pins every one of these against the Rust ramp.
*/
internal val ProtonTypography = Typography(
displayLarge = role(DISPLAY, FontWeight.SemiBold),
displayMedium = role(DISPLAY, FontWeight.SemiBold),
displaySmall = role(TITLE, FontWeight.SemiBold),
// The name of the title you opened — the desktop's display line.
headlineLarge = role(DISPLAY, FontWeight.SemiBold),
headlineMedium = role(DISPLAY, FontWeight.SemiBold),
// A dialog's heading — `AlertDialog` titles draw with this one.
headlineSmall = role(HEADING, FontWeight.SemiBold),
// A page heading: "Offline downloads", "Settings".
titleLarge = role(TITLE, FontWeight.SemiBold),
// A run of content under a heading: "Continue watching", "Playback".
titleMedium = role(SECTION, FontWeight.Medium),
// A row that groups the rows under it — a season header.
titleSmall = role(SUBHEAD, FontWeight.Medium),
bodyLarge = role(BODY, FontWeight.Normal),
bodyMedium = role(LABEL, FontWeight.Normal),
// Context rather than content: counts, sizes, the note under a field.
bodySmall = role(CAPTION, FontWeight.Normal),
// The text on a button.
labelLarge = role(LABEL, FontWeight.Medium),
labelMedium = role(CAPTION, FontWeight.Medium),
// The smallest thing that stays legible.
labelSmall = role(MICRO, FontWeight.Medium),
)
internal const val DISPLAY = 26f
internal const val TITLE = 20f
internal const val HEADING = 18f
internal const val SECTION = 17f
internal const val SUBHEAD = 15f
internal const val BODY = 14f
internal const val LABEL = 13f
internal const val CAPTION = 12f
internal const val MICRO = 11f
/**
* One rung as a `TextStyle`.
*
* Line height is derived rather than named per style: a ramp whose leading was
* chosen nine separate times is the same problem the sizes had. 1.35 is loose
* enough for the two-line title names in the grid and tight enough that a
* label on a control does not float.
*/
private fun role(size: Float, weight: FontWeight) = TextStyle(
fontSize = size.sp,
lineHeight = (size * 1.35f).sp,
fontWeight = weight,
)