mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 16:57:00 +01:00
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:
1 parent
cb03f96eca
commit
1a9a4624f8
11 files changed
+918
-31
No files matched your search
@@ -0,0 +1,129 @@
|
||||
package io.narl.protonstream.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
|
||||
/**
|
||||
* That every Material colour role is answered by the palette.
|
||||
*
|
||||
* Material names roughly fifty roles and a `ColorScheme` is built by copying a
|
||||
* baseline, so a role nobody names keeps Material's own purple-and-grey —
|
||||
* silently, and only where that particular role happens to be drawn. That is
|
||||
* how `surfaceContainerHighest` went unnoticed: nothing referred to it by name,
|
||||
* but it is what `Card` fills with, so every tile, episode row, share and
|
||||
* download in the app was baseline neutral on a flavoured page.
|
||||
*
|
||||
* The check is reflective rather than a list, because a list is exactly as
|
||||
* incomplete as the mapping it was written from — and because Material adds
|
||||
* roles between versions, and a role added tomorrow should fail this the day
|
||||
* the dependency moves.
|
||||
*/
|
||||
class SchemeRolesTest {
|
||||
/**
|
||||
* A palette whose every colour is distinct and none of which Material would
|
||||
* ever produce, so that "this role came from the palette" is decidable by
|
||||
* looking at the value.
|
||||
*/
|
||||
private fun sentinels(light: Boolean) = PaletteRecord(
|
||||
background = 0xFF010203u,
|
||||
surface = 0xFF040506u,
|
||||
sunken = 0xFF070809u,
|
||||
card = 0xFF0A0B0Cu,
|
||||
cardHover = 0xFF0D0E0Fu,
|
||||
border = 0xFF101112u,
|
||||
text = 0xFF131415u,
|
||||
muted = 0xFF161718u,
|
||||
accent = 0xFF191A1Bu,
|
||||
accentAlt = 0xFF1C1D1Eu,
|
||||
accentDim = 0xFF1F2021u,
|
||||
onAccent = 0xFF222324u,
|
||||
danger = 0xFF252627u,
|
||||
elevated = 0xFF28292Au,
|
||||
dangerDim = 0xFF2B2C2Du,
|
||||
light = light,
|
||||
)
|
||||
|
||||
/** Every role on a `ColorScheme`, read by name, whatever the version names. */
|
||||
private fun roles(scheme: ColorScheme): Map<String, Color> =
|
||||
ColorScheme::class.java.declaredMethods
|
||||
.filter { it.name.startsWith("get") && it.parameterCount == 0 && it.returnType == Long::class.java }
|
||||
.associate { method ->
|
||||
method.isAccessible = true
|
||||
// Kotlin mangles the getter of a value-class property:
|
||||
// `getPrimary-0d7_KjU`. The property is what the name says up
|
||||
// to the dash.
|
||||
method.name.removePrefix("get").substringBefore('-') to
|
||||
Color((method.invoke(scheme) as Long).toULong())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every role is answered by the palette`() {
|
||||
for (light in listOf(false, true)) {
|
||||
val palette = sentinels(light)
|
||||
val allowed = buildSet {
|
||||
addAll(
|
||||
listOf(
|
||||
palette.background, palette.surface, palette.sunken, palette.card,
|
||||
palette.cardHover, palette.border, palette.text, palette.muted,
|
||||
palette.accent, palette.accentAlt, palette.accentDim, palette.onAccent,
|
||||
palette.danger, palette.elevated, palette.dangerDim,
|
||||
).map { Color(it.toInt()) },
|
||||
)
|
||||
// The two roles that are deliberately not a palette colour: no
|
||||
// elevation tint at all, and a scrim that is an absence of
|
||||
// light rather than a hue.
|
||||
add(Color.Transparent)
|
||||
add(Color.Black)
|
||||
}
|
||||
|
||||
val leaked = roles(schemeOf(palette)).filterValues { it !in allowed }
|
||||
assertTrue(
|
||||
"light=$light: ${leaked.keys.sorted()} still carry Material's baseline",
|
||||
leaked.isEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The count is asserted too. A reflective sweep that found no roles at all
|
||||
* would pass the check above for the wrong reason.
|
||||
*/
|
||||
@Test
|
||||
fun `the sweep sees the whole scheme`() {
|
||||
val count = roles(darkColorScheme()).size
|
||||
assertTrue("only $count roles found; the reflective sweep is not working", count >= 40)
|
||||
assertEquals(count, roles(lightColorScheme()).size)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which baseline was copied stops being observable once every role is
|
||||
* mapped — and that is the point.
|
||||
*
|
||||
* `light` still decides plenty, but it decides it in Rust, where it picks
|
||||
* the flavour's colours. By the time a palette reaches Kotlin the
|
||||
* light-or-dark question is already answered *inside* the colours, so two
|
||||
* palettes that differ only in the flag have to produce the same scheme. If
|
||||
* they ever stop doing so, something is reading `light` here rather than
|
||||
* reading the colours.
|
||||
*/
|
||||
@Test
|
||||
fun `the baseline no longer shows through`() {
|
||||
assertEquals(roles(schemeOf(sentinels(false))), roles(schemeOf(sentinels(true))))
|
||||
assertEquals(Color.Transparent, schemeOf(sentinels(false)).surfaceTint)
|
||||
}
|
||||
|
||||
/** The fallback goes through the same mapping as any other palette. */
|
||||
@Test
|
||||
fun `the shipped fallback is fully mapped too`() {
|
||||
val scheme = schemeOf(ProtonFallback)
|
||||
assertEquals(Color(ProtonFallback.elevated.toInt()), scheme.surfaceContainerHighest)
|
||||
assertEquals(Color(ProtonFallback.card.toInt()), scheme.surfaceContainer)
|
||||
assertEquals(Color(ProtonFallback.dangerDim.toInt()), scheme.errorContainer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package io.narl.protonstream.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* That the type ramp is the ramp, and the corner radius is the radius.
|
||||
*
|
||||
* The two clients share a palette because one Rust function resolves it for
|
||||
* both. Type and shape have no such bridge — they are named in
|
||||
* `pstr_app::theme::Role` and copied here — so what stops them drifting is this
|
||||
* test and the numbers in it. Change the Rust ramp and this fails; that is the
|
||||
* point of it.
|
||||
*
|
||||
* Every style is named rather than checked against a set, because "some rung"
|
||||
* is not the property that matters: `bodyMedium`'s Material default is 14sp,
|
||||
* which is a rung, so a `bodyMedium` nobody mapped would pass a looser check
|
||||
* while sitting one step off where it belongs.
|
||||
*/
|
||||
class TypeRampTest {
|
||||
/** `Role::size()`, in the order `theme.rs` declares it. */
|
||||
private val ramp = listOf(26f, 20f, 18f, 17f, 15f, 14f, 13f, 12f, 11f)
|
||||
|
||||
private val expected = mapOf(
|
||||
"displayLarge" to 26f,
|
||||
"displayMedium" to 26f,
|
||||
"displaySmall" to 20f,
|
||||
"headlineLarge" to 26f,
|
||||
"headlineMedium" to 26f,
|
||||
"headlineSmall" to 18f,
|
||||
"titleLarge" to 20f,
|
||||
"titleMedium" to 17f,
|
||||
"titleSmall" to 15f,
|
||||
"bodyLarge" to 14f,
|
||||
"bodyMedium" to 13f,
|
||||
"bodySmall" to 12f,
|
||||
"labelLarge" to 13f,
|
||||
"labelMedium" to 12f,
|
||||
"labelSmall" to 11f,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `every text style sits on the shared ramp`() {
|
||||
expected.forEach { (name, size) ->
|
||||
assertEquals(name, size, styles().getValue(name).fontSize.value, 0.001f)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Material adds type styles between versions, and one added tomorrow would
|
||||
* arrive at whatever size Material chose for it. The list above is only as
|
||||
* complete as the day it was written unless something says so.
|
||||
*/
|
||||
@Test
|
||||
fun `no text style is left at Material's own size`() {
|
||||
assertEquals(expected.keys, styles().keys)
|
||||
}
|
||||
|
||||
/**
|
||||
* Leading is derived from the size, so the only thing worth asserting is
|
||||
* that it *was* derived — a style that kept Material's line height carries
|
||||
* a number with no relation to its own size.
|
||||
*/
|
||||
@Test
|
||||
fun `line height follows the size it belongs to`() {
|
||||
styles().forEach { (name, style) ->
|
||||
assertEquals(name, style.fontSize.value * 1.35f, style.lineHeight.value, 0.01f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the ramp has no rung the styles never reach`() {
|
||||
val drawn = styles().values.map { it.fontSize.value }.toSet()
|
||||
ramp.forEach { rung -> assertTrue("$rung is named but never drawn", rung in drawn) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every surface is drawn at the desktop's radius`() {
|
||||
listOf(ProtonShapes.small, ProtonShapes.medium, ProtonShapes.large).forEach { shape ->
|
||||
// `CornerSize` resolves against a size and a density, and neither is
|
||||
// available to a unit test; `toString` is the only handle on the
|
||||
// value that does not need a composition.
|
||||
assertTrue("$shape is not radius $CORNER", shape.toString().contains("$CORNER"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every text style a caller can set, read by name, whatever the version
|
||||
* names.
|
||||
*
|
||||
* Material 1.4 carries a second, `Emphasized` copy of all fifteen for its
|
||||
* expressive components. Those are `internal` — the constructor takes them
|
||||
* but Kotlin will not let this module pass them — so they keep Material's
|
||||
* own sizes and nothing here can do anything about it. Their getters are
|
||||
* name-mangled with `$material3`, which is how they are told apart from a
|
||||
* public style that genuinely went unmapped.
|
||||
*/
|
||||
private fun styles(): Map<String, TextStyle> = Typography::class.java.declaredMethods
|
||||
.filter { it.parameterCount == 0 && it.returnType == TextStyle::class.java }
|
||||
.filterNot { '$' in it.name }
|
||||
.associate { method ->
|
||||
method.isAccessible = true
|
||||
method.name.removePrefix("get").replaceFirstChar(Char::lowercaseChar) to
|
||||
method.invoke(ProtonTypography) as TextStyle
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user