mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +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
@@ -25,19 +25,25 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import io.narl.protonstream.native.NativeRuntime
|
||||
import io.narl.protonstream.playback.MpvPlaybackState
|
||||
import io.narl.protonstream.playback.NativeMpvHost
|
||||
import io.narl.protonstream.playback.PlaybackService
|
||||
import io.narl.protonstream.settings.SettingsStore
|
||||
import io.narl.protonstream.ui.ProtonStreamApp
|
||||
import io.narl.protonstream.ui.theme.AppearanceState
|
||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val playerHost = mutableStateOf<NativeMpvHost?>(null)
|
||||
@@ -85,7 +91,19 @@ class MainActivity : ComponentActivity() {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Before anything draws. The palette decides the window's own
|
||||
// background and which way round the system bars' icons go, and both
|
||||
// are wrong for a frame if they are set from a composition instead.
|
||||
// Infallible by construction — an unreadable theme file resolves to the
|
||||
// shipped default rather than throwing.
|
||||
runCatching { NativeRuntime.storedPalette() }.onSuccess(AppearanceState::seed)
|
||||
enableEdgeToEdge()
|
||||
// Re-applied on every change, not just at startup: picking Latte from
|
||||
// the settings page has to turn the status bar's icons dark in the same
|
||||
// frame the page behind them turns light.
|
||||
lifecycleScope.launch {
|
||||
AppearanceState.palette.filterNotNull().distinctUntilChanged().collect(::dressWindow)
|
||||
}
|
||||
bound = bindService(
|
||||
Intent(this, PlaybackService::class.java),
|
||||
playbackConnection,
|
||||
@@ -121,6 +139,26 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of the window Compose does not reach: the background behind the
|
||||
* whole activity, and the polarity of the system bars' icons.
|
||||
*
|
||||
* The background matters for the frames before and between compositions —
|
||||
* the launch frame, and a rotation. It used to be a hex literal in
|
||||
* `themes.xml`, which meant every cold start showed a colour from one
|
||||
* flavour regardless of which one was chosen.
|
||||
*
|
||||
* The icons matter because the app ships a light flavour. Pinned dark, as
|
||||
* they were, Latte drew white status-bar icons onto a near-white page.
|
||||
*/
|
||||
private fun dressWindow(palette: PaletteRecord) {
|
||||
window.setBackgroundDrawable(palette.background.toInt().toDrawable())
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
isAppearanceLightStatusBars = palette.light
|
||||
isAppearanceLightNavigationBars = palette.light
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
// The backstop for launchers that still send this instead of arming
|
||||
|
||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import uniffi.pstr_android.AndroidEngine
|
||||
import uniffi.pstr_android.AndroidPaths
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
|
||||
/** Process-wide owner of the Rust engine; records and decrypted media remain app-private. */
|
||||
object NativeRuntime {
|
||||
@@ -36,14 +37,24 @@ object NativeRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
private fun createEngine(): AndroidEngine {
|
||||
/**
|
||||
* The stored palette, without building the engine.
|
||||
*
|
||||
* Callable from the main thread, and meant to be: it is the only palette
|
||||
* read early enough to decide what the first frame is painted in, and
|
||||
* [engine] is not — that opens SQLite, spins a Tokio runtime and unlocks a
|
||||
* Keystore key. This reads one small JSON file and resolves it.
|
||||
*/
|
||||
fun storedPalette(): PaletteRecord = uniffi.pstr_android.storedPalette(paths())
|
||||
|
||||
private fun paths(): AndroidPaths {
|
||||
check(::appContext.isInitialized) { "NativeRuntime is not initialized" }
|
||||
val config = File(appContext.noBackupFilesDir, "config")
|
||||
val data = File(appContext.filesDir, "data")
|
||||
val cache = File(appContext.cacheDir, "stream")
|
||||
return AndroidEngine(
|
||||
AndroidPaths(config.absolutePath, data.absolutePath, cache.absolutePath),
|
||||
KeystoreSecretStore(appContext),
|
||||
)
|
||||
return AndroidPaths(config.absolutePath, data.absolutePath, cache.absolutePath)
|
||||
}
|
||||
|
||||
private fun createEngine(): AndroidEngine =
|
||||
AndroidEngine(paths(), KeystoreSecretStore(appContext))
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources>
|
||||
<!--
|
||||
`pstr_core::appearance::Palette::PROTON.background` — the shipped default,
|
||||
and the only colour in this file. It is what the window is painted before
|
||||
the stored palette has been read; from then on
|
||||
MainActivity.dressWindow overrides it with the flavour in force.
|
||||
-->
|
||||
<color name="window_background">#0E0E12</color>
|
||||
</resources>
|
||||
@@ -1,8 +1,23 @@
|
||||
<resources>
|
||||
<!--
|
||||
The window before Compose owns it.
|
||||
|
||||
Everything about the *look* is decided by the stored palette and applied
|
||||
in MainActivity.dressWindow: the background colour here is only the one
|
||||
the shipped default resolves to, so that a first-ever launch — before
|
||||
there is a theme file to read — starts on the right colour rather than on
|
||||
the platform's white. The bar polarity is likewise set from the palette;
|
||||
what is left here is the dark reading, which is right for four of the five
|
||||
flavours and is corrected in the same frame for the fifth.
|
||||
|
||||
A hex literal from one specific flavour used to live here, and it was the
|
||||
reason every cold start flashed Catppuccin Mocha's base regardless of what
|
||||
the viewer had chosen.
|
||||
-->
|
||||
<style name="Theme.ProtonStream" parent="android:style/Theme.Material.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:windowLightNavigationBar">false</item>
|
||||
<item name="android:windowBackground">#1E1E2E</item>
|
||||
<item name="android:windowBackground">@color/window_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -196,9 +196,40 @@ pub struct PaletteRecord {
|
||||
pub accent_dim: u32,
|
||||
pub on_accent: u32,
|
||||
pub danger: u32,
|
||||
/// One step further from the page than `card_hover`.
|
||||
///
|
||||
/// Not a colour the palette names, and not one the desktop needs: Material's
|
||||
/// containment ladder has five rungs where the palette has three, and the
|
||||
/// top one is what `Card` fills with. Derived here rather than in Kotlin so
|
||||
/// that the blend is still the shared one — mixing in sRGB rather than in
|
||||
/// linear light would put this rung on a different curve to every other
|
||||
/// colour in the app.
|
||||
pub elevated: u32,
|
||||
/// `danger` taken down towards the page, for an error *container* rather
|
||||
/// than error ink.
|
||||
pub danger_dim: u32,
|
||||
pub light: bool,
|
||||
}
|
||||
|
||||
/// The stored palette, read without building an engine.
|
||||
///
|
||||
/// [`AndroidEngine`] opens SQLite, spins a Tokio runtime and unlocks a Keystore
|
||||
/// key — far too much to block an activity's `onCreate` on, and blocking it is
|
||||
/// the point: a palette that arrives one frame late is a flash of the wrong
|
||||
/// theme on every cold start. The choice is one small JSON file, so this reads
|
||||
/// exactly that and resolves it.
|
||||
///
|
||||
/// Infallible on purpose. A theme file that will not parse falls back to the
|
||||
/// defaults here exactly as it does everywhere else — it must never be a reason
|
||||
/// the library does not open.
|
||||
#[uniffi::export]
|
||||
pub fn stored_palette(paths: AndroidPaths) -> PaletteRecord {
|
||||
let appearance = AppDirs::from_paths(paths.config, paths.data, paths.cache)
|
||||
.and_then(|dirs| pstr_core::appearance::load(&dirs))
|
||||
.unwrap_or_default();
|
||||
palette_record(&appearance_record(appearance))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, uniffi::Record)]
|
||||
pub struct MetadataSettingsRecord {
|
||||
pub enabled: bool,
|
||||
@@ -2357,6 +2388,13 @@ fn appearance_choice(record: AppearanceRecord) -> Appearance {
|
||||
|
||||
fn palette_record(record: &AppearanceRecord) -> PaletteRecord {
|
||||
let palette = Palette::resolve(appearance_choice(record.clone()));
|
||||
// The flavour's own step, taken once more: `card` to `card_hover` is the
|
||||
// size of one rung as the flavour drew it, so extrapolating past the end of
|
||||
// that pair gives a rung the same size and — unlike a fixed blend towards
|
||||
// the ink — one that works in both polarities. A light flavour's ladder
|
||||
// descends, and this descends with it.
|
||||
let elevated = pstr_core::appearance::mix(palette.card, palette.card_hover, 2.0);
|
||||
let danger_dim = pstr_core::appearance::mix(palette.danger, palette.background, 0.72);
|
||||
PaletteRecord {
|
||||
background: palette.background.argb(),
|
||||
surface: palette.surface.argb(),
|
||||
@@ -2371,6 +2409,8 @@ fn palette_record(record: &AppearanceRecord) -> PaletteRecord {
|
||||
accent_dim: palette.accent_dim.argb(),
|
||||
on_accent: palette.on_accent.argb(),
|
||||
danger: palette.danger.argb(),
|
||||
elevated: elevated.argb(),
|
||||
danger_dim: danger_dim.argb(),
|
||||
light: palette.light,
|
||||
}
|
||||
}
|
||||
@@ -2567,13 +2607,107 @@ mod tests {
|
||||
use pstr_stream::{MemoryBlocks, VideoStream};
|
||||
|
||||
use super::{
|
||||
AndroidEngine, AndroidPaths, AndroidSecretStore, AndroidStream, BridgeError, ChapterKind,
|
||||
ChapterRecord, PartialMarker, chapter_plan, match_record, node_uid, normalized_language,
|
||||
AccentChoice, AndroidEngine, AndroidPaths, AndroidSecretStore, AndroidStream,
|
||||
AppearanceRecord, BridgeError, ChapterKind, ChapterRecord, FlavorChoice, PartialMarker,
|
||||
chapter_plan, match_record, node_uid, normalized_language, palette_record,
|
||||
prepare_partial_file, pstr_android_stream_read, pstr_android_stream_release,
|
||||
pstr_android_stream_size, read_partial_marker, resume_position, skip_offer, title_metadata,
|
||||
write_partial_marker,
|
||||
};
|
||||
|
||||
fn appearance_of(flavor: FlavorChoice) -> AppearanceRecord {
|
||||
AppearanceRecord {
|
||||
flavor,
|
||||
accent: AccentChoice::Mauve,
|
||||
gradients: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every flavour, so that a rung that only behaves on the dark ones is
|
||||
/// caught: Latte's ladder descends, and a derived colour written as "a bit
|
||||
/// brighter" would climb out of it.
|
||||
const FLAVORS: [FlavorChoice; 5] = [
|
||||
FlavorChoice::Proton,
|
||||
FlavorChoice::Latte,
|
||||
FlavorChoice::Frappe,
|
||||
FlavorChoice::Macchiato,
|
||||
FlavorChoice::Mocha,
|
||||
];
|
||||
|
||||
fn luminance(argb: u32) -> f32 {
|
||||
pstr_core::appearance::luminance(pstr_core::appearance::Rgb::new(
|
||||
((argb >> 16) & 0xff) as u8,
|
||||
((argb >> 8) & 0xff) as u8,
|
||||
(argb & 0xff) as u8,
|
||||
))
|
||||
}
|
||||
|
||||
/// Material's containment ladder has five rungs where the palette names
|
||||
/// three, and Kotlin lays them out in this order. A rung that is not past
|
||||
/// the one below it is a rung that reads as the same surface.
|
||||
#[test]
|
||||
fn the_derived_rung_continues_the_ladder_in_both_polarities() {
|
||||
for flavor in FLAVORS {
|
||||
let palette = palette_record(&appearance_of(flavor));
|
||||
let (card, hover, elevated) = (
|
||||
luminance(palette.card),
|
||||
luminance(palette.card_hover),
|
||||
luminance(palette.elevated),
|
||||
);
|
||||
if palette.light {
|
||||
assert!(
|
||||
elevated < hover && hover < card,
|
||||
"{flavor:?}: a light flavour's ladder descends, got {card} → {hover} → {elevated}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
elevated > hover && hover > card,
|
||||
"{flavor:?}: a dark flavour's ladder climbs, got {card} → {hover} → {elevated}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An error *container* is a surface, not ink: it has to sit near the page
|
||||
/// so that `text` reads on it, which is what Kotlin pairs it with.
|
||||
#[test]
|
||||
fn the_dim_danger_sits_between_the_danger_and_the_page() {
|
||||
for flavor in FLAVORS {
|
||||
let palette = palette_record(&appearance_of(flavor));
|
||||
let (danger, dim, page) = (
|
||||
luminance(palette.danger),
|
||||
luminance(palette.danger_dim),
|
||||
luminance(palette.background),
|
||||
);
|
||||
let between = (dim - danger).abs() > f32::EPSILON && (dim - page).abs() > f32::EPSILON;
|
||||
assert!(between, "{flavor:?}: the dim danger is one of its own ends");
|
||||
assert!(
|
||||
dim > danger.min(page) && dim < danger.max(page),
|
||||
"{flavor:?}: {dim} is outside {danger}..{page}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The read that runs before the first frame. A directory with no theme
|
||||
/// file in it is a first launch, and it has to answer with the defaults
|
||||
/// rather than fail — the whole point of it is that it cannot be a reason
|
||||
/// the window does not open.
|
||||
#[test]
|
||||
fn the_stored_palette_falls_back_to_the_shipped_default() {
|
||||
let root = std::env::temp_dir().join(format!("pstr-palette-{}", std::process::id()));
|
||||
let paths = AndroidPaths {
|
||||
config: root.join("config").to_string_lossy().into_owned(),
|
||||
data: root.join("data").to_string_lossy().into_owned(),
|
||||
cache: root.join("cache").to_string_lossy().into_owned(),
|
||||
};
|
||||
let palette = super::stored_palette(paths);
|
||||
let default = palette_record(&appearance_of(FlavorChoice::Proton));
|
||||
assert_eq!(palette.background, default.background);
|
||||
assert_eq!(palette.accent, default.accent);
|
||||
assert_eq!(palette.elevated, default.elevated);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemorySecrets(Mutex<HashMap<String, String>>);
|
||||
|
||||
|
||||
Reference in new issue
Block a user