diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index 9707c2c0..f5aa8590 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -184,6 +184,19 @@ jobs: working-directory: clients/android run: ./gradlew :kit:testDebugUnitTest --stacktrace + # The cross-client contract in `clients/shared/console-vectors.json` — the console palette + # table, the settings section names and the screen-transition motion, each of which exists in + # three hand-written copies (here, pf-console-ui, the Apple client). The other two check it + # from their own suites; this is Android's side. + # + # FILTERED, not a plain `:app:testDebugUnitTest`: that task also runs the ~20 Roborazzi + # screenshot scenes, which are a release-artifact job (android-screenshots.yml, gated to v* + # tags) and have no business adding a minute to every push. The filter is what lets the + # contract gate here without dragging the rest of the app suite in with it. + - name: console parity vectors + working-directory: clients/android + run: ./gradlew :app:testDebugUnitTest --tests 'io.unom.punktfunk.ConsoleVectorsTest' --stacktrace + - name: assembleDebug (cargo-ndk → jniLibs → APK) working-directory: clients/android env: diff --git a/clients/android/app/build.gradle.kts b/clients/android/app/build.gradle.kts index 19d171a2..171a617d 100644 --- a/clients/android/app/build.gradle.kts +++ b/clients/android/app/build.gradle.kts @@ -144,6 +144,10 @@ dependencies { testImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-test-manifest") // the ComponentActivity test host testImplementation("junit:junit:4.13.2") + // Real `org.json` for the shared-vectors test: the `org.json` inside `android.jar` is a stub + // set whose every method throws "Stub!", so a plain JVM unit test cannot parse with it. Same + // dependency, same reason, as the kit module's deeplink-vectors test. + testImplementation("org.json:json:20250107") testImplementation("org.robolectric:robolectric:4.16.1") testImplementation("io.github.takahirom.roborazzi:roborazzi:1.64.0") testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.64.0") diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index bafadedb..799cda15 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -792,14 +792,17 @@ fun ConnectScreen( HomeTile( id = "saved-${kh.id}", title = kh.name, - // The binding is what a press will actually do, so the tile says so — the - // console can't edit profiles, but it must never lie about which one it uses. - subtitle = bound?.let { "${kh.address}:${kh.port} · ${it.name}" } - ?: "${kh.address}:${kh.port}", + subtitle = "${kh.address}:${kh.port}", filled = true, online = kh.isOnline(discovered, reachable), paired = kh.paired, knownHost = kh, + // The binding is what a press will actually do, so the tile says so — the + // console can't edit profiles, but it must never lie about which one it + // uses. It rides in the card's own chip now rather than as a "· Name" tail + // on the address, which is where it read as an afterthought. + profileName = bound?.name, + profileAccent = accentColor(bound?.accent), activate = { connect(kh.address, kh.port) }, ), ) @@ -810,12 +813,18 @@ fun ConnectScreen( HomeTile( id = "pin-${kh.id}-${p.id}", title = kh.name, - subtitle = p.name, + // The address, like every other card — the PROFILE is what makes this + // card different, and it now says so in the chip instead of standing + // in for the subtitle, which left a pin card unable to say where it + // pointed. + subtitle = "${kh.address}:${kh.port}", filled = true, online = kh.isOnline(discovered, reachable), paired = kh.paired, knownHost = kh, pinnedProfileId = p.id, + profileName = p.name, + profileAccent = accentColor(p.accent), activate = { connect(kh.address, kh.port, oneOffProfile = p.id) }, ), ) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt index c8bffb74..f909f884 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadChrome.kt @@ -7,7 +7,7 @@ import android.view.InputDevice import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.Easing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring @@ -38,6 +38,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -56,6 +58,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset @@ -63,6 +66,7 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin @@ -71,6 +75,15 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -113,8 +126,21 @@ import kotlinx.coroutines.launch * is a change owed to the other two. */ object ConsoleMotion { - /** The desktop's `ease_out_cubic`, as a Compose easing. */ - val EaseOutCubic = CubicBezierEasing(0.215f, 0.61f, 0.355f, 1f) + /** + * The desktop's `ease_out_cubic` — `1 − (1−t)³`, ANALYTICALLY, not as a Bézier approximation + * of it (`crates/pf-console-ui/src/anim.rs`). + * + * ⚠ Two different curves are published under the name "easeOutCubic" and neither is the real + * one: the Penner/Ceaser CSS table's `cubic-bezier(0.215, 0.61, 0.355, 1)` and easings.net's + * `cubic-bezier(0.33, 1, 0.68, 1)`. At the midpoint the true curve is 0.875, the second bezier + * ≈0.87, and the first ≈0.80 — visibly slacker. Compose's `Easing` is a plain function, so + * there is no reason to approximate at all; the Apple client uses the second bezier only + * because SwiftUI's `timingCurve` cannot take a closure. + */ + val EaseOutCubic = Easing { t -> + val u = 1f - t + 1f - u * u * u + } /** Screen push/pop, ms — the desktop's `TRANSITION_S` (0.26 s). */ const val TRANSITION_MS = 260 @@ -377,7 +403,11 @@ fun ConsoleTabStrip( }, ) { Row( - Modifier.padding(horizontal = ConsoleEdgeInset), + // The pills are one mutually-exclusive set, and saying so is the only way a screen + // reader can know it: the SELECTION is drawn as an indicator in the parent's + // `drawBehind`, which is paint and nothing else — no pill differs from its + // neighbours in the tree. + Modifier.padding(horizontal = ConsoleEdgeInset).selectableGroup(), horizontalArrangement = Arrangement.spacedBy(6.dp), ) { titles.forEachIndexed { i, title -> @@ -400,7 +430,10 @@ fun ConsoleTabStrip( pillW[i] = it.size.width.toFloat() } .clip(ConsoleShape.Pill) - .clickable { onSelect(i) } + // `selectable`, not `clickable`: same ripple and the same click, but it + // also publishes Role.Tab + the selected flag, so "Video, tab, selected" + // reaches a screen reader that cannot see the indicator behind the pill. + .selectable(selected = active, role = Role.Tab) { onSelect(i) } .padding(horizontal = 14.dp, vertical = 7.dp), ) } @@ -473,13 +506,56 @@ fun animateConsoleFocus(active: Boolean, editing: Boolean = false): ConsoleFocus * same curve as the fill — one focus change, not four independent animations. */ @Composable -fun Modifier.consoleGlass(shape: Shape, visuals: ConsoleFocusVisuals): Modifier { +fun Modifier.consoleGlass( + shape: Shape, + visuals: ConsoleFocusVisuals, + /** + * Draw the edge DASHED instead of solid — the convention for a surface that is offered but not + * yet yours (a host discovered on the network, the Add-Host tile). Dashes need a real stroke + * rather than `Modifier.border`, which takes no path effect, so this branch draws the edge + * itself; the top-edge highlight comes with it either way. + */ + dashed: Boolean = false, +): Modifier { val ink = LocalGamepadInk.current val focus = visuals.focus val accent = ink.accent val highlight = ink.highlight val fill = visuals.background val border = visuals.border + if (dashed) { + return this + .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } + // BEFORE the clip, so the full stroke width shows: drawn inside it, the outer half of + // the line would be clipped away and the dashes would read as a hairline. + .drawWithContent { + drawContent() + // The console's shapes are all rounded rects; anything else simply gets a square + // dashed edge rather than no edge at all. + val r = (shape as? RoundedCornerShape)?.topStart?.toPx(size, this) ?: 0f + drawRoundRect( + brush = Brush.verticalGradient( + listOf(highlight.copy(alpha = highlight.alpha * 0.7f), border), + ), + cornerRadius = CornerRadius(r), + style = Stroke( + width = 1.dp.toPx(), + pathEffect = PathEffect.dashPathEffect( + floatArrayOf(6.dp.toPx(), 5.dp.toPx()), + ), + ), + ) + } + .clip(shape) + .background( + Brush.verticalGradient( + listOf( + fill.copy(alpha = (fill.alpha * 1.35f + 0.03f).coerceAtMost(1f)), + fill.copy(alpha = fill.alpha * 0.72f), + ), + ), + ) + } return this .graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale } // BEFORE the clip, so the bloom can spill past the row's own rectangle — a glow that stops @@ -609,6 +685,16 @@ fun ConsoleModal(content: @Composable () -> Unit) { * * [key] is what the cross-fade keys on — the focused row's id, so stepping between two rows that * happen to share a description doesn't flicker. + * + * ⚠ The band is HIDDEN FROM ACCESSIBILITY, and the row carries the same words in its own + * description instead (see `SettingRowView`). Of the two ways to make a floating band speak — hide + * it and merge, or leave it and make it a live region — merging wins because the band is a + * SIGHTED-ONLY relationship: it is a strip in the bottom-left corner whose only tie to the row it + * describes is that they happen to be on screen together. A screen reader walking the list would + * meet it as a stray paragraph several nodes away from its subject, and a live region would + * additionally interrupt the row announcement it duplicates. `hideFromAccessibility` rather than + * `clearAndSetSemantics` deliberately: the node stays in the semantics tree (where the layout tests + * assert the description is still rendered), it is only skipped by screen readers. */ @Composable fun ConsoleDetailBand( @@ -624,7 +710,13 @@ fun ConsoleDetailBand( fadeIn(ConsoleMotion.ease(ConsoleMotion.FOCUS_MS)) togetherWith fadeOut(ConsoleMotion.ease(ConsoleMotion.FOCUS_MS)) }, - modifier = modifier, + // Silent to a screen reader, deliberately. The band is a place to LOOK — it sits at the + // far bottom of the screen, describing a row that may be anywhere in the list — so read + // aloud in layout order it arrives long after, and out of any useful context. The SETTINGS + // ROW merges this same string into its own announcement instead, which is where a reader + // is when it matters. Sighted focus and screen-reader focus want the text in different + // places; this is the one giving each what it needs rather than one of them both. + modifier = modifier.semantics { hideFromAccessibility() }, label = "detail", ) { (_, body) -> if (body.isBlank()) { @@ -639,6 +731,7 @@ fun ConsoleDetailBand( maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier + .semantics { hideFromAccessibility() } .widthIn(max = 560.dp) .clip(ConsoleShape.Band) .then( @@ -662,6 +755,11 @@ fun ConsoleDetailBand( * The knob SQUASHES as it travels (widest at mid-flight, round at either rest) — the elastic cue * that makes a slide read as a thrown object rather than a repositioned dot. Taken from the * travel itself rather than from a velocity read, so it can't disagree with where the knob is. + * + * Being hand-drawn, it announces nothing on its own — a `Box` with a gradient is not a switch to + * anything but an eye. The semantics here make it one wherever it is used; a caller that MERGES it + * into a bigger node (a settings row does) restates them on that node, since `Role` never + * propagates from a child. */ @Composable fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) { @@ -687,6 +785,15 @@ fun ConsoleSwitch(on: Boolean, focused: Boolean, modifier: Modifier = Modifier) val knob = trackH - pad * 2 Box( modifier + // A hand-drawn track and knob are two `Box`es to a screen reader — nothing about them + // says "switch" or says which way it is thrown. The row that owns this one is the + // thing that gets pressed (the pad and a tap both act on the ROW), so the switch is + // not itself toggleable here: it only has to REPORT. See the settings row, which + // merges this into its own announcement. + .semantics { + role = Role.Switch + toggleableState = if (on) ToggleableState.On else ToggleableState.Off + } .size(trackW, trackH) .clip(ConsoleShape.Pill) .background( diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt index 594f161e..1fece5e7 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PageSize import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -89,6 +90,15 @@ class HomeTile( * belong to the host's own tile, and this one offers only Unpin. */ val pinnedProfileId: String? = null, + /** + * The profile a press will actually connect with — the host's binding, or the pin's own + * profile. Rendered as a chip on the card rather than appended to the subtitle: on a PIN card + * the profile is the entire reason the card exists, and a card that only whispers it in grey + * body text can't say that. Matches the Apple client's tile. + */ + val profileName: String? = null, + /** The profile's `#RRGGBB` chip colour, if it set one. */ + val profileAccent: Color? = null, val activate: () -> Unit, ) { // Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair @@ -309,7 +319,14 @@ private fun GamepadHostTile(tile: HomeTile, centred: Boolean, modifier: Modifier modifier = modifier .fillMaxWidth() // The carousel already drives its own scale; the glass must not fight it with a second. - .consoleGlass(ConsoleShape.Tile, ConsoleFocusVisuals(1f, fill, ink.fg(0.16f), visuals.focus)) + .consoleGlass( + ConsoleShape.Tile, + ConsoleFocusVisuals(1f, fill, ink.fg(0.16f), visuals.focus), + // A DASHED edge on anything not yet saved — a host found on the network, and the + // Add tile. It is the touch grid's own convention and the Apple client's, and it + // says "not yours yet" before the subtitle has to. + dashed = !tile.filled, + ) .padding(22.dp), ) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { @@ -341,12 +358,64 @@ private fun GamepadHostTile(tile: HomeTile, centred: Boolean, modifier: Modifier maxLines = 1, overflow = TextOverflow.Ellipsis, ) + if (tile.profileName != null) { + ConsoleProfileChip( + name = tile.profileName, + accent = tile.profileAccent, + // On a PIN card the profile is why the card exists; on a bound host's own card it + // is a note about what a press will do. Same chip, two weights. + prominent = tile.pinnedProfileId != null, + modifier = Modifier.padding(top = 5.dp), + ) + } Text( tile.subtitle, style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.55f), maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), + ) + } +} + +/** + * The profile a card connects with, worn as a tinted capsule. The console counterpart of the touch + * grid's own chip (`HostComponents.kt`) — same shape and the same quiet/prominent split, but inked + * from the console palette rather than `MaterialTheme`, since it sits on the aurora. + * + * A profile that set no accent falls back to the palette's, not to the touch theme's primary: on a + * moss or copper field the brand violet would be the one foreign colour on the card. + */ +@Composable +private fun ConsoleProfileChip( + name: String, + accent: Color?, + prominent: Boolean, + modifier: Modifier = Modifier, +) { + val ink = LocalGamepadInk.current + val tint = accent ?: ink.accent + Row( + modifier = modifier + .clip(ConsoleShape.Pill) + .background(tint.copy(alpha = if (prominent) 0.24f else 0.12f)) + .padding(horizontal = 9.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(7.dp).clip(CircleShape).background(tint)) + Spacer(Modifier.width(6.dp)) + Text( + name, + style = if (prominent) { + MaterialTheme.typography.labelLarge + } else { + MaterialTheme.typography.labelMedium + }, + fontWeight = if (prominent) FontWeight.Bold else FontWeight.SemiBold, + color = tint, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt index 306083a1..984f6b0b 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadSettingsScreen.kt @@ -48,6 +48,14 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -489,6 +497,28 @@ private fun SettingRowView( indication = null, onClick = onClick, ) + // ONE announcement per row, not five leaves read in layout order. Three things had + // to be gathered to make it true: + // * the VALUE of a toggle row existed nowhere in the tree — the switch replaces + // the value text (see below), so `row.value` ("On"/"Off") was drawn by nothing; + // * the DESCRIPTION lives in the floating band at the far bottom of the screen, + // which is the right place to LOOK and the wrong place to be read — so it is + // merged here, where the row it explains is; + // * `enabled` was a colour and nothing else. + // A row therefore announces "Refresh rate, 120 Hz, Frame rate the host renders and + // streams at" — which is what the screen already means, said once. + .semantics(mergeDescendants = true) { + role = if (row.toggled != null) Role.Switch else Role.Button + contentDescription = listOfNotNull( + row.label, + row.value.takeIf { it.isNotBlank() }, + row.detail.takeIf { it.isNotBlank() }, + ).joinToString(", ") + row.toggled?.let { + toggleableState = if (it) ToggleableState.On else ToggleableState.Off + } + if (!row.enabled) disabled() + } .padding(horizontal = 16.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -518,6 +548,10 @@ private fun SettingRowView( "‹ ", color = ink.fg, modifier = Modifier + // Decoration: it says "this value steps", which the row's Switch/Button + // role already says. Left in the tree it is read out as punctuation on + // every single focused row. + .semantics { hideFromAccessibility() } .graphicsLayer { alpha = chevronAlpha } .offset { IntOffset(minOf(chevronKick.value, 0f).dp.roundToPx(), 0) }, ) @@ -563,6 +597,7 @@ private fun SettingRowView( " ›", color = ink.fg, modifier = Modifier + .semantics { hideFromAccessibility() } .graphicsLayer { alpha = chevronAlpha } .offset { IntOffset(maxOf(chevronKick.value, 0f).dp.roundToPx(), 0) }, ) @@ -772,7 +807,7 @@ internal fun buildSettingsRows( choice( "hud", GpTab.INTERFACE, null, "Statistics overlay", "How much the overlay shows: Compact (one line) → Normal → Detailed (full HUD). " + - "A 3-finger tap cycles the tiers live.", + "Select + X on a pad, or a 3-finger tap, cycles the tiers live.", STATS_VERBOSITY_OPTIONS, s.statsVerbosity, ) { update(s.copy(statsVerbosity = it)) }, toggle( diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index 3e49b414..572b28f2 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -577,7 +577,7 @@ private fun GeneralSettings(s: Settings, update: (Settings) -> Unit) { selected = s.statsVerbosity, field = "stats_verbosity", caption = "Compact is one line; Detailed adds the decoder and latency breakdown. " + - "A 3-finger tap cycles the tiers in-stream.", + "A 3-finger tap, or Select + X on a pad, cycles the tiers in-stream.", ) { v -> update(s.copy(statsVerbosity = v)) } } DeviceScopeOnly { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 31e52f6b..54f91936 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -28,6 +28,9 @@ import android.view.inputmethod.InputConnection import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -53,6 +56,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput @@ -152,6 +156,35 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U motionHint = false } } + // Whether this session has a controller — the start banner names pad chords only when there is + // a pad to press them on. Seeded from the router the moment it is built (it opens a slot for + // every already-connected controller) and latched true by a pad that arrives later; it never + // goes back to false. A pad LEAVING inside the banner's six seconds is not worth the write: + // teardown closes every slot, and poking Compose state from there is exactly what the nulled + // callbacks in onDispose avoid. The latch is also what carries a pad through a USB capture + // claiming it — its InputDevice slot closes and reopens as a capture-link one. + var padPresent by remember(handle) { mutableStateOf(false) } + // The start-of-stream banner: what this session's shortcuts ARE, said once. A stream takes the + // whole screen and answers to none of the device's usual gestures, so it has to say how to get + // back out — the desktop console draws the same pill for the same reason + // (`pf-console-ui/src/skia_overlay.rs`, BANNER_S = 6 s with a BANNER_FADE_S = 0.6 s tail). + // Two states because the fade and the removal are different moments: `bannerUp` composes the + // pill at all, `bannerFading` runs its alpha down over the last 600 ms. + var bannerUp by remember(handle) { mutableStateOf(true) } + var bannerFading by remember(handle) { mutableStateOf(false) } + val bannerAlpha by animateFloatAsState( + targetValue = if (bannerFading) 0f else 1f, + // Linear, like the desktop's (BANNER_S - age) / BANNER_FADE_S ramp — Compose's default + // easing would hold near-opaque and then drop, which reads as a glitch rather than a fade. + animationSpec = tween(600, easing = LinearEasing), + label = "streamStartBanner", + ) + LaunchedEffect(handle) { + delay(5400) // 6 s − the 0.6 s tail: fully opaque until here, exactly as on the desktop + bannerFading = true + delay(600) + bannerUp = false // stop composing it once it is invisible + } // The one place mute is toggled — Compose state + the native flag, always together. val setMicMuted = { muted: Boolean -> micMuted = muted @@ -161,7 +194,8 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U // Live decode stats for the HUD. `statsOn` (verbosity != OFF) gates the whole native pipeline: // the per-frame sampling (nativeSetVideoStatsEnabled — a hidden HUD costs one atomic load per // frame) AND the 1 s poll loop, which only runs while the overlay is visible. Enabling resets - // the native window, so re-showing never renders stale data. A 3-finger tap cycles the + // the native window, so re-showing never renders stale data. A 3-finger tap — or the Select + X + // pad chord, which is the only route a TV or a passthrough-touch session has — cycles the // verbosity tier live (Off → Compact → Normal → Detailed → Off); the default comes from // Settings. The tier only changes how many lines `StatsOverlay` draws — switching between the // visible tiers keeps sampling running (the effect keys on `statsOn`, not the tier) so it never @@ -184,6 +218,11 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U // TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream // refresh; a phone/tablet gets the softer seamless frame-rate hint instead. val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) } + // A screen with fingers on it — the start banner may only name the three-finger stats tap on a + // device that can perform it. A TV box has no touchscreen at all, and its remote is not one. + val hasTouch = remember { + context.packageManager.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN) + } LaunchedEffect(handle, statsOn) { NativeBridge.nativeSetVideoStatsEnabled(handle, statsOn) if (statsOn) { @@ -362,6 +401,9 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U initialSettings.systemButtonsForward(), initialSettings.guideGestureEnabled(), ) activity?.gamepadRouter = router + // Every controller that was already connected got a slot in the router's constructor, so + // this is the session's pad answer at t=0 — what the start banner's words are chosen from. + padPresent = router.forwardedDevices().isNotEmpty() // Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips // the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it // (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream @@ -384,6 +426,11 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U micHint = if (next) "Microphone muted" else "Microphone live" } } + // Select + X steps the stats overlay one tier — the same live cycle the three-finger tap + // performs, and the ONLY route to it on a TV or in a passthrough-touch session. Session- + // local on purpose: this mirrors the tap exactly (`onCycleStats` below), and the settings + // row calls it a live cycle — the stored default is what the next stream starts from. + router.onStatsChord = { statsVerbosity = statsVerbosity.next() } // Physical mouse: uncaptured hover/click/wheel forwards as absolute pointing; captured // (setting or the Ctrl+Alt+Shift+Q chord) raw deltas forward as relative mouse-look. // The local cursor is hidden over the stream — the host's own cursor, composited into @@ -505,7 +552,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U // The other edge: a controller that arrives (or first speaks) mid-session gets its sensors // read too. The pads already connected were swept by PadSensors.start() above — both run // on the main thread with nothing between them, so no controller falls through the gap. - router.onSlotOpened = { deviceId -> padSensors?.onSlotOpened(deviceId) } + router.onSlotOpened = { deviceId -> + padSensors?.onSlotOpened(deviceId) + // A pad that wakes up a second into the stream still deserves the chord banner — the + // desktop rebuilds its banner text every frame for exactly this case. + padPresent = true + } // Steam Controller 2 as-is passthrough (opt-out): capture a wired/Puck USB pad — or an // already-paired BLE one — and forward its raw reports; the host mirrors a real // 28DE:1302 that its Steam drives directly, and Steam's rumble/settings writes come back @@ -645,6 +697,7 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down router.onMicChord = null // same: no mute toggle on buttons released during teardown + router.onStatsChord = null // same: no tier cycle on buttons released during teardown router.onMotionUnreachable = null // same: no notice raised by a slot closing at teardown router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener activity?.gamepadRouter = null @@ -847,6 +900,42 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U if (remotePointerOn) { RemotePointerHint(Modifier.align(Alignment.TopCenter).padding(top = 16.dp)) } + // The start banner (desktop parity), naming ONLY the shortcuts this session actually has: + // pad chords when a controller is here, the Back gesture and the three-finger tap when it + // is not. Recomputed rather than captured, because both inputs change under it — a pad can + // wake mid-banner, and `micRunning` only settles once the capture has actually opened. + // Above the video and below the gesture layer: it teaches touches, it must never eat one. + // + // Bottom-centre is the desktop's placement and the only edge left — TopStart is the HUD, + // TopEnd the mic badge, TopCentre the three transient cues — but MotionUnreachableHint + // already owns it, and both of these can be up at t≈0. The banner YIELDS rather than + // stacking or sliding off-centre: the notice reports something broken about THIS session + // and names the setting that fixes it, while the banner repeats shortcuts that will be + // there next stream too. Two pills sharing an edge for six seconds would cost the reader + // both. + if (bannerUp && !motionHint) { + StreamStartBanner( + text = buildList { + if (padPresent) { + add("Hold Select + Start + L1 + R1 to leave") + // Only while a capture is actually running: the chord itself no-ops + // without one, and offering a mute for a mic nobody has is the lie the + // whole control exists to avoid. + if (micRunning) add("Select + Y mic") + add("Select + X stats") + } else { + // No pad: Back is the deliberate exit (gesture, key, or a TV remote's + // button — all land on the same BackHandler). + add("Back leaves the stream") + // The tap lives in the pointer touch models only — passthrough gives every + // finger to the host verbatim — and needs a screen to put three fingers on. + if (hasTouch && touchMode != TouchMode.TOUCH) add("three-finger tap for stats") + } + }.joinToString(" · "), + alpha = bannerAlpha, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp), + ) + } // Invisible 1-px focus anchor for the host-typing soft keyboard (three-finger swipe up // in the mouse modes) AND the pointer-capture grab target — it never draws or takes // touches, it just owns IME focus and receives captured-pointer events. @@ -1053,6 +1142,33 @@ private fun RemotePointerHint(modifier: Modifier = Modifier) { ) } +/** + * The start-of-stream banner: the shortcuts this session actually has, in the same pill as every + * other in-stream cue, shown once and then gone. The desktop console draws the identical thing + * bottom-centre (`pf-console-ui/src/skia_overlay.rs` — six seconds with a 0.6 s fade), because a + * stream owns the whole screen and answers to none of the device's usual gestures: without a line + * saying how to get back out, the only discoverable exit is force-quitting the app. + * + * [text] and [alpha] are the caller's. Only it knows what this session HAS — a pad, a mic, a + * touchscreen — and only it owns the timer, which is precisely what a screenshot wants to skip. + * Purely visual: it sits below the gesture layer, takes no touches and is never clickable. Internal + * so the screenshot scene can shoot the real pill instead of a copy of it that drifts. + */ +@Composable +internal fun StreamStartBanner(text: String, alpha: Float, modifier: Modifier = Modifier) { + Text( + text, + // Alpha FIRST: the fade has to take the pill's backdrop with it, and everything after this + // in the chain draws inside the layer it opens. + modifier = modifier + .alpha(alpha) + .background(Color.Black.copy(alpha = 0.55f), RoundedCornerShape(8.dp)) + .padding(horizontal = 14.dp, vertical = 8.dp), + color = Color.White, + fontSize = 15.sp, + ) +} + /** * Invisible focus anchor for typing on the host: the three-finger swipe summons the device IME * onto this view. Two IME models, picked by the host's capabilities: diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleVectorsTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleVectorsTest.kt new file mode 100644 index 00000000..f4cc3946 --- /dev/null +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/ConsoleVectorsTest.kt @@ -0,0 +1,168 @@ +package io.unom.punktfunk + +import java.io.File +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The console UI's cross-client contract, against `clients/shared/console-vectors.json`. + * + * The background palettes, the settings section names and the screen-transition motion each exist + * in three hand-written copies — this client, `pf-console-ui` (Rust) and the Apple client — and + * until this file they were held together by nothing but a comment asking the next person to keep + * them in step. Two of the three had already drifted. + * + * Read straight off disk with a relative path rather than copied into test resources, for the + * reason the deeplink vectors state: a copy would be a fourth contract, free to go stale. Gradle + * runs a unit test with the MODULE directory as its working directory, so `../../shared/` from + * `clients/android/app` lands on `clients/shared`. + * + * What this pins that the older [GamepadPaletteTest] could not: the DERIVED 16-cell mesh and the + * 4 blob colours per palette. Those are what actually reach the screen — the mesh through the AGSL + * shader on API 33+, the blobs through the fallback field below it — and the existing tests only + * ever measured the `stops` they are computed from. + */ +class ConsoleVectorsTest { + private companion object { + /** One step of Compose's 8-bit-per-component sRGB packing — see the blob comparison. */ + const val EIGHT_BIT_STEP = 1.0 / 255.0 + } + + private val vectors: JSONObject by lazy { + val file = File("../../shared/console-vectors.json") + assertTrue( + "the shared vector file must be reachable at ${file.absolutePath}", + file.isFile, + ) + JSONObject(file.readText()) + } + + private fun JSONObject.doubles(key: String): List = + getJSONArray(key).let { a -> (0 until a.length()).map { a.getDouble(it) } } + + private fun close(what: String, got: Double, want: Double, tol: Double = 1e-6) { + assertTrue( + "$what: vectors say $want, this client computes $got", + kotlin.math.abs(got - want) <= tol, + ) + } + + @Test + fun cellRampAndMeshInteriorMatch() { + assertEquals("CELL_RAMP", vectors.doubles("cell_ramp"), GamepadPalette.CELL_RAMP) + + val interior = vectors.getJSONArray("mesh_interior") + assertEquals("mesh interior count", GamepadPalette.MESH_INTERIOR.size, interior.length()) + GamepadPalette.MESH_INTERIOR.forEachIndexed { i, p -> + val w = interior.getJSONArray(i) + val got = listOf(p.x, p.y, p.amp, p.sx, p.sy, p.phase) + got.forEachIndexed { k, v -> close("mesh_interior[$i][$k]", v, w.getDouble(k)) } + } + } + + /** Every palette, field by field — and then the two tables derived from it. */ + @Test + fun everyPaletteMatchesTheSharedVectors() { + val want = vectors.getJSONArray("palettes") + assertEquals("palette count", want.length(), GamepadPalette.ALL.size) + GamepadPalette.ALL.forEachIndexed { i, p -> + val w = want.getJSONObject(i) + val id = w.getString("id") + assertEquals("palette order", id, p.id) + assertEquals("$id name", w.getString("name"), p.name) + assertEquals("$id light", w.getBoolean("light"), p.light) + + val stops = w.getJSONArray("stops") + assertEquals("$id stop count", stops.length(), p.stops.size) + p.stops.forEachIndexed { s, t -> + val ws = stops.getJSONArray(s) + close("$id stops[$s].r", t.first, ws.getDouble(0)) + close("$id stops[$s].g", t.second, ws.getDouble(1)) + close("$id stops[$s].b", t.third, ws.getDouble(2)) + } + + val ground = w.doubles("ground") + close("$id ground.r", p.ground.first, ground[0]) + close("$id ground.g", p.ground.second, ground[1]) + close("$id ground.b", p.ground.third, ground[2]) + val accent = w.doubles("accent") + close("$id accent.r", p.accent.first, accent[0]) + close("$id accent.g", p.accent.second, accent[1]) + close("$id accent.b", p.accent.third, accent[2]) + + // The mesh the shader is built from — 16 cells, sampled off the ramp per CELL_RAMP. + val mesh = w.getJSONArray("mesh") + assertEquals("$id mesh cells", mesh.length(), p.meshColors.size) + p.meshColors.forEachIndexed { c, t -> + val wc = mesh.getJSONArray(c) + close("$id mesh[$c].r", t.first, wc.getDouble(0)) + close("$id mesh[$c].g", t.second, wc.getDouble(1)) + close("$id mesh[$c].b", t.third, wc.getDouble(2)) + } + + // The four blobs the API 28–32 fallback field drifts. These come back as Compose + // `Color`s, which pack an sRGB colour at 8 bits per component — so the table + // round-trips through 1/255 quantisation and the tolerance below IS that quantisation, + // not slack. Anything the contract actually cares about (a mistyped stop, a shifted + // sample point) moves these by far more than one 8-bit step. + val blobs = w.getJSONArray("blobs") + assertEquals("$id blob count", blobs.length(), p.blobColors.size) + p.blobColors.forEachIndexed { b, colour -> + val wb = blobs.getJSONArray(b) + close("$id blob[$b].r", colour.red.toDouble(), wb.getDouble(0), EIGHT_BIT_STEP) + close("$id blob[$b].g", colour.green.toDouble(), wb.getDouble(1), EIGHT_BIT_STEP) + close("$id blob[$b].b", colour.blue.toDouble(), wb.getDouble(2), EIGHT_BIT_STEP) + } + } + } + + /** + * The section names, in order. The desktop console carries one tab this client does not — + * Input, which holds touch mode, mouse, invert-scroll and shortcuts: desktop-host settings + * with nothing to set on a phone or a TV. The vectors flag it `desktop_only` rather than + * leaving it out, so neither side has to red the other to be right. + */ + @Test + fun tabNamesMatchTheSharedVectors() { + val tabs = vectors.getJSONArray("tabs") + val want = (0 until tabs.length()) + .map { tabs.getJSONObject(it) } + .filterNot { it.optBoolean("desktop_only", false) } + .map { it.getString("name") } + assertEquals("console settings tabs", want, GpTab.entries.map { it.title }) + } + + /** + * The screen-transition contract. The easing is sampled rather than compared as Bézier + * control points: this client evaluates the desktop's analytic `1 − (1−t)³` directly, while + * SwiftUI can only approximate it — samples with a tolerance are the one form all three can + * meet. It is also the assertion that would have caught the curve this client shipped with + * first, a "cubic-bezier(0.215, 0.61, 0.355, 1)" that is a full 0.08 slack at the midpoint. + */ + @Test + fun motionMatchesTheSharedVectors() { + val motion = vectors.getJSONObject("motion") + close("transition", ConsoleMotion.TRANSITION_MS / 1000.0, motion.getDouble("transition_s")) + close("push slide", ConsoleMotion.PUSH_SLIDE.value.toDouble(), motion.getDouble("push_slide_dp")) + close("enter scale", ConsoleMotion.ENTER_SCALE.toDouble(), motion.getDouble("enter_scale"), 1e-5) + close("exit scale", ConsoleMotion.EXIT_SCALE.toDouble(), motion.getDouble("exit_scale"), 1e-5) + close("reveal alpha", ConsoleMotion.REVEAL_ALPHA.toDouble(), motion.getDouble("reveal_alpha"), 1e-5) + + val curve = motion.getJSONObject("ease_out_cubic") + val tol = curve.getDouble("tolerance") + val samples = curve.getJSONArray("samples") + assertTrue("the curve needs enough samples to pin it", samples.length() >= 5) + for (i in 0 until samples.length()) { + val s = samples.getJSONObject(i) + val t = s.getDouble("t") + close( + "ease_out_cubic($t)", + ConsoleMotion.EaseOutCubic.transform(t.toFloat()).toDouble(), + s.getDouble("p"), + tol, + ) + } + } +} diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt index 7e9ede3f..70fdd1ff 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -83,6 +83,16 @@ class ScreenshotTest { @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") fun streamNormal() = shootRoot("stream-normal") { StreamScene(io.unom.punktfunk.StatsVerbosity.NORMAL) } + // Both banner texts, in the stream's own landscape geometry — it is bottom-centre, so the + // aspect is load-bearing. + @Test + @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") + fun streamBannerPad() = shootRoot("stream-banner-pad") { StreamBannerScene(pad = true) } + + @Test + @Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi") + fun streamBannerTouch() = shootRoot("stream-banner-touch") { StreamBannerScene(pad = false) } + // The touch flow is a Material dialog over the host grid (a separate window → shootScreen). @Test fun connecting() = shootScreen("connecting") { diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index 2a58d78c..0f9575ca 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -45,6 +45,7 @@ import io.unom.punktfunk.SettingsCategory import io.unom.punktfunk.SettingsScreen import io.unom.punktfunk.StatsOverlay import io.unom.punktfunk.StatsVerbosity +import io.unom.punktfunk.StreamStartBanner import io.unom.punktfunk.ProfileEditorFields import io.unom.punktfunk.ProfileStore import io.unom.punktfunk.SettingsOverlay @@ -429,6 +430,36 @@ internal fun ConnectConsoleScene() = * stand in for it: this is a different screen with different navigation, and the strip is the part * a layout regression would eat first. */ +/** + * The start-of-stream banner over the same synthetic "streamed frame" — the real + * [StreamStartBanner] at full opacity, since the caller owns the 6 s timer and a shot must not race + * it. Two variants because the WORDS are the point: the banner names pad chords or touch gestures + * depending on what the session actually has, and a screenshot is the only place the two can be + * compared side by side. + */ +@Composable +internal fun StreamBannerScene(pad: Boolean) { + Box( + Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B)), + ), + ), + ) { + StreamStartBanner( + text = if (pad) { + "Hold Select + Start + L1 + R1 to leave · Select + Y mic · Select + X stats" + } else { + "Back leaves the stream · three-finger tap for stats" + }, + alpha = 1f, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp), + ) + } +} + /** * The console HOME — the host carousel over the living backdrop, which is the screen the aurora is * most of. Worth its own shot for exactly that reason: on API 33+ the field is the real bicubic diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt index 158cb2af..724b7025 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/GamepadRouter.kt @@ -46,8 +46,8 @@ class GamepadRouter( * as well would give the host two pads for one pair of hands. * * Off still opens slots and tracks held state; it only stops the wire sends. That is - * deliberate: the exit and mic chords are read off the same slots, and a couch that lost its - * quit shortcut because a forwarding preference was off would be the worse bug. Nothing is + * deliberate: the exit, mic and stats chords are read off the same slots, and a couch that lost + * its quit shortcut because a forwarding preference was off would be the worse bug. Nothing is * claimed by keeping a slot — the Android input stack shares controllers — unlike the USB * capture links, which `StreamScreen` does not start at all while this is off. */ @@ -149,6 +149,20 @@ class GamepadRouter( */ var onMicChord: (() -> Unit)? = null + /** + * Invoked (main thread) each time the stats chord ([STATS_CHORD], Select + X) is COMPLETED on a + * pad — one verbosity tier of the in-stream statistics overlay per completion. It exists + * because a controller in both hands has no other way to the numbers: the three-finger tap + * needs a touchscreen AND one of the pointer touch models, so a TV or a gamepad-only session + * has none. `StreamScreen` wires it to the live tier cycle. + * + * Fires immediately and once per chord like [onMicChord], and like it the buttons still go to + * the host — the chord adds a local meaning to them rather than swallowing them. The Apple + * client's `GamepadCapture.statsChord` is the same two buttons; a shortcut that differs per + * platform is worse than no shortcut. + */ + var onStatsChord: (() -> Unit)? = null + /** * Invoked (main thread) once per pad when a captured controller WITH a gyro turns out to be in * a session whose virtual pad has no motion plane — its motion is not being sent, because every @@ -204,7 +218,7 @@ class GamepadRouter( /** * One button transition on [slot] — the shared body behind [onButton] and an [ExternalPad]'s * transitions: forward the wire event, track held state, arm/disarm the exit chord, and fire - * the mic-mute chord ([MIC_CHORD]). + * the instant chords ([MIC_CHORD], [STATS_CHORD]). */ private fun slotButton(slot: Slot, bit: Int, down: Boolean, send: Boolean) { // Raw system buttons stay local under the "local" policy — no wire send and no held @@ -232,13 +246,11 @@ class GamepadRouter( slot.held = slot.held or bit // Full chord now held on this pad → start the hold countdown (idempotent while held). if (slot.held and EXIT_CHORD == EXIT_CHORD) armExit() - // Mic mute, edge-triggered on the button that COMPLETES the chord: a genuine press - // (`wasHeld` lacks the bit, so an auto-repeat DOWN can't re-fire it) of a chord member - // that leaves the whole chord held. Any other button pressed while Select + Y are down - // fails the middle test, so the toggle happens once per chord, not once per press. - if (wasHeld and bit == 0 && bit and MIC_CHORD != 0 && slot.held and MIC_CHORD == MIC_CHORD) { - onMicChord?.invoke() - } + // Mic mute and the stats-tier cycle, each edge-triggered on the button that COMPLETES + // its chord (see [completesChord]) — the two meanings this client gives Select plus a + // face button. Both leave the press on the wire: the game still gets its buttons. + if (completesChord(wasHeld, bit, MIC_CHORD)) onMicChord?.invoke() + if (completesChord(wasHeld, bit, STATS_CHORD)) onStatsChord?.invoke() } else { val owned = guideGesture && bit == Gamepad.BTN_BACK && consumeSelectRelease(slot) if (!owned && send && forwarding) { @@ -628,7 +640,11 @@ class GamepadRouter( return null } - private companion object { + // `internal` rather than private: the chord masks and [completesChord] are the only part of + // this router a JVM unit test can reach — everything else needs an InputManager, a main Looper + // and live InputDevices behind it — and until `GamepadChordTest` there was nothing pinning the + // chords at all. Still invisible to :app, which is what private bought. + internal companion object { /** Mirror of `punktfunk-core::input::MAX_PADS` — wire pad indices 0..15. */ const val MAX_PADS = 16 @@ -650,6 +666,28 @@ class GamepadRouter( */ const val MIC_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_Y + /** + * Stats-overlay chord: Select + X, one verbosity tier per completion. X keeps both + * properties [MIC_CHORD]'s Y has — it is none of [EXIT_CHORD]'s four buttons, so no way of + * reaching the exit chord passes through this one on the way (and vice versa), and Select + * is a menu button rather than a twitch action. Byte-for-byte the Apple client's + * `GamepadCapture.statsChord`, which was modelled on [MIC_CHORD] in the first place and + * leaves Y free for the mic chord to land there in turn — the two clients converge on one + * pad vocabulary from both ends. + */ + const val STATS_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_X + + /** + * Whether pressing [bit] on a pad that held [wasHeld] beforehand COMPLETED [chord]: a + * genuine press (`wasHeld` lacks the bit, so an auto-repeat DOWN can't re-fire it) of a + * chord member that leaves the whole chord held (`wasHeld or bit` is the slot's held set + * the instant after the press). Any other button pressed while the chord is already down + * fails the middle test, so a chord fires once per chord, not once per press — and lifting + * any member re-arms it, since the next press of that member is a fresh completion. + */ + internal fun completesChord(wasHeld: Int, bit: Int, chord: Int): Boolean = + wasHeld and bit == 0 && bit and chord != 0 && (wasHeld or bit) and chord == chord + /** Synthetic slot-key base for [ExternalPad]s — below every real (positive) InputDevice id. */ const val EXTERNAL_ID_BASE = -1000 diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/GamepadChordTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/GamepadChordTest.kt new file mode 100644 index 00000000..4b251483 --- /dev/null +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/GamepadChordTest.kt @@ -0,0 +1,183 @@ +package io.unom.punktfunk.kit + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The router's instant button chords — mic mute (Select + Y) and the stats-tier cycle + * (Select + X) — pinned at the one place a JVM test can reach them: [GamepadRouter.completesChord], + * the shared edge rule both fire on. A [GamepadRouter] itself needs an InputManager, a main Looper + * and live InputDevices behind it, so driving real KeyEvents through it is not a unit test; the + * rule below is the whole of what those two `if`s decide. + * + * The Apple client pins the same chords from its own side (`GamepadStatsChordTests`), and the two + * suites exist for the same reason: a chord that stops completing fails INVISIBLY — the buttons + * still reach the game, nothing logs, and the couch simply finds that a shortcut it was told about + * does nothing. On a TV the stats chord is the only route to the overlay at all. + */ +class GamepadChordTest { + + /** The two chords `slotButton` tests on every press, in the order it tests them. */ + private val instantChords = listOf(GamepadRouter.MIC_CHORD, GamepadRouter.STATS_CHORD) + + /** + * One pad's held-button set, driven exactly the way `slotButton` drives a slot's: the chord + * test reads the state from BEFORE the press, then the bit joins `held`. [press] returns the + * chords that completed on it — an empty list means the press was silent. + */ + private inner class Pad { + var held = 0 + private set + + fun press(bit: Int): List { + val wasHeld = held + held = held or bit + return instantChords.filter { GamepadRouter.completesChord(wasHeld, bit, it) } + } + + /** An auto-repeat DOWN: Android re-delivers a held button, so `wasHeld` already has it. */ + fun repeat(bit: Int): List = press(bit) + + fun release(bit: Int) { + held = held and bit.inv() + } + } + + /** + * Select + X, the same pair as the Apple client's `GamepadCapture.statsChord` + * (`GamepadWire.back | GamepadWire.x`). A per-platform shortcut is worse than none, so the + * literal bits are spelled out here rather than derived from the constant under test. + */ + @Test + fun `the stats chord is Select plus X`() { + assertEquals(0x0020 or 0x4000, GamepadRouter.STATS_CHORD) + assertEquals(Gamepad.BTN_BACK or Gamepad.BTN_X, GamepadRouter.STATS_CHORD) + assertEquals(Gamepad.BTN_BACK or Gamepad.BTN_Y, GamepadRouter.MIC_CHORD) + } + + /** + * The three chords must not be reachable through one another: pressing toward the exit chord + * may not cycle the overlay or mute the mic on the way, and neither instant chord may arm a + * disconnect. Select is the one button they share by design — everything else is disjoint, and + * no chord is a subset of another (a subset would complete whenever its superset did). + */ + @Test + fun `the chords meet only on Select`() { + val chords = mapOf( + "exit" to GamepadRouter.EXIT_CHORD, + "mic" to GamepadRouter.MIC_CHORD, + "stats" to GamepadRouter.STATS_CHORD, + ) + for ((aName, a) in chords) { + for ((bName, b) in chords) { + if (aName == bName) continue + assertEquals("$aName and $bName share a button other than Select", Gamepad.BTN_BACK, a and b) + assertNotEquals("$aName is a subset of $bName", a and b, a) + assertNotEquals("$bName is a subset of $aName", a and b, b) + } + } + } + + /** + * One cycle per chord, not one per press: the completing button fires it, an auto-repeat of + * that same button does not, and a third button pressed on top of the held chord finds the mask + * already complete. + */ + @Test + fun `the chord fires once, on the button that completes it`() { + val pad = Pad() + assertEquals("Select alone is not a chord", emptyList(), pad.press(Gamepad.BTN_BACK)) + assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X)) + assertEquals("auto-repeat re-fired the chord", emptyList(), pad.repeat(Gamepad.BTN_X)) + assertEquals("a press on top re-fired the chord", emptyList(), pad.press(Gamepad.BTN_A)) + assertEquals(emptyList(), pad.press(Gamepad.BTN_B)) + } + + /** + * Lifting either member re-arms the chord — pressing it again is a fresh completion. Both + * directions matter: a couch user cycling tiers taps X with Select still down, and one who + * lifted Select instead taps Select again with X still down. + */ + @Test + fun `either member re-arms the chord when released`() { + val pad = Pad() + pad.press(Gamepad.BTN_BACK) + assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X)) + pad.release(Gamepad.BTN_X) + assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X)) + pad.release(Gamepad.BTN_BACK) + assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_BACK)) + } + + /** A partial mask fires nothing — either member alone, or with a non-member alongside it. */ + @Test + fun `a partial chord never fires`() { + for (opening in listOf(Gamepad.BTN_BACK, Gamepad.BTN_X, Gamepad.BTN_Y)) { + val pad = Pad() + assertEquals(emptyList(), pad.press(opening)) + for (other in listOf(Gamepad.BTN_A, Gamepad.BTN_B, Gamepad.BTN_LB, Gamepad.BTN_DPAD_UP)) { + assertEquals(emptyList(), pad.press(other)) + } + } + } + + /** + * Walking into the exit chord (Select + Start + L1 + R1, in any order) must pass through + * neither instant chord: the disconnect hold is the one gesture where a stray mute or a + * changed overlay would land while the user is looking at the "hold to quit" hint. + */ + @Test + fun `reaching the exit chord fires nothing on the way`() { + val exit = listOf(Gamepad.BTN_BACK, Gamepad.BTN_START, Gamepad.BTN_LB, Gamepad.BTN_RB) + for (order in exit.permutations()) { + val pad = Pad() + for (bit in order) { + assertEquals("$order fired a chord at $bit", emptyList(), pad.press(bit)) + } + assertEquals(GamepadRouter.EXIT_CHORD, pad.held) + } + } + + /** + * X and Y held, then Select: ONE press completes BOTH chords. That is the honest reading of + * "the button that completes the mask", it is what the Apple client does too, and the + * alternative — first match wins — would make the same press mean different things depending + * on which chord the router happened to test first. Pinned so the behaviour is a decision + * rather than a surprise; both outcomes are visible and reversible on screen. + */ + @Test + fun `a shared Select can complete both chords at once`() { + val pad = Pad() + pad.press(Gamepad.BTN_X) + pad.press(Gamepad.BTN_Y) + assertEquals(instantChords, pad.press(Gamepad.BTN_BACK)) + } + + /** The chord bits are the wire's, so they must stay inside the 32-bit button mask. */ + @Test + fun `chord masks are wire button bits`() { + for (chord in instantChords + GamepadRouter.EXIT_CHORD) { + assertTrue("chord $chord has no bits", chord != 0) + assertEquals("a chord bit is not a known BTN_*", chord, chord and ALL_BUTTONS) + } + } + + private companion object { + /** Every button bit `Gamepad` defines — the universe a chord may draw from. */ + val ALL_BUTTONS = listOf( + Gamepad.BTN_DPAD_UP, Gamepad.BTN_DPAD_DOWN, Gamepad.BTN_DPAD_LEFT, Gamepad.BTN_DPAD_RIGHT, + Gamepad.BTN_START, Gamepad.BTN_BACK, Gamepad.BTN_LS_CLICK, Gamepad.BTN_RS_CLICK, + Gamepad.BTN_LB, Gamepad.BTN_RB, Gamepad.BTN_GUIDE, + Gamepad.BTN_A, Gamepad.BTN_B, Gamepad.BTN_X, Gamepad.BTN_Y, + Gamepad.BTN_PADDLE1, Gamepad.BTN_PADDLE2, Gamepad.BTN_PADDLE3, Gamepad.BTN_PADDLE4, + Gamepad.BTN_TOUCHPAD, Gamepad.BTN_MISC1, + ).fold(0) { acc, bit -> acc or bit } + + /** Every ordering of a chord's buttons — presses arrive in whatever order the hands do. */ + fun List.permutations(): List> = + if (size <= 1) listOf(this) + else flatMap { head -> (this - head).permutations().map { listOf(head) + it } } + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/ConsoleVectorsTests.swift b/clients/apple/Tests/PunktfunkKitTests/ConsoleVectorsTests.swift new file mode 100644 index 00000000..03aa0f01 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/ConsoleVectorsTests.swift @@ -0,0 +1,127 @@ +import Foundation +import XCTest +import simd + +@testable import PunktfunkShared + +/// The console UI's cross-client contract, against `clients/shared/console-vectors.json`. +/// +/// The background palette table exists in three hand-written copies — this client, +/// `pf-console-ui`'s `library.rs`, and the Android client's `GamepadPalette.kt` — and until this +/// file the only thing holding them together was a comment in each asking the next person to keep +/// them in step. This is the sibling of `SharedFoundationTests.testDeepLinkSharedVectors`, read +/// the same way and for the same reason. +/// +/// What it pins beyond the definitions is the DERIVED table: the 16 mesh cells each palette +/// produces, which is what actually reaches the gradient. `GamepadPaletteTests` already asserts +/// the invariants (hue spread, gamut, lightness honesty); this asserts the values. +/// +/// ⚠️ The tab names and the shell motion constants are in the vectors file too, but this client +/// cannot yet check them: `GpSettingsTab` and `GamepadShellMotion` live in `PunktfunkClient`, +/// an executable target with no test target of its own. Moving them into `PunktfunkShared` — where +/// `GamepadPalette` already sits, and for exactly this reason (see its header) — is what would +/// close that gap. +final class ConsoleVectorsTests: XCTestCase { + /// Read from the repo, not from a bundle resource: a copy would be a second file, and a + /// second file drifts. Four `deletingLastPathComponent()` calls walk + /// `Tests/PunktfunkKitTests/` → `Tests/` → `apple/` → `clients/`. + private static var vectorFileURL: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("shared/console-vectors.json") + } + + private struct VectorFile: Decodable { + let cellRamp: [Double] + let meshInterior: [[Double]] + let palettes: [Palette] + + // swiftlint:disable:next nesting + struct Palette: Decodable { + let id: String + let name: String + let light: Bool + let stops: [[Double]] + let ground: [Double] + let accent: [Double] + let mesh: [[Double]] + let blobs: [[Double]] + } + + // swiftlint:disable:next identifier_name + enum CodingKeys: String, CodingKey { + case cellRamp = "cell_ramp" + case meshInterior = "mesh_interior" + case palettes + } + } + + private func assertClose( + _ got: Double, _ want: Double, _ what: String, tolerance: Double = 1e-6, + file: StaticString = #filePath, line: UInt = #line + ) { + XCTAssertEqual( + got, want, accuracy: tolerance, + "\(what): vectors say \(want), this client computes \(got)", file: file, line: line) + } + + func testPaletteTableMatchesTheSharedVectors() throws { + let url = Self.vectorFileURL + XCTAssertTrue( + FileManager.default.fileExists(atPath: url.path), + "the shared vector file must be reachable at \(url.path)") + let file = try JSONDecoder().decode(VectorFile.self, from: Data(contentsOf: url)) + + XCTAssertEqual(file.cellRamp, GamepadPalette.cellRamp, "cellRamp") + XCTAssertEqual(file.palettes.count, GamepadPalette.all.count, "palette count") + + for (want, p) in zip(file.palettes, GamepadPalette.all) { + XCTAssertEqual(want.id, p.id, "palette order") + XCTAssertEqual(want.name, p.name, "\(p.id) name") + XCTAssertEqual(want.light, p.light, "\(p.id) light") + + XCTAssertEqual(want.stops.count, p.stops.count, "\(p.id) stop count") + for (i, (ws, s)) in zip(want.stops, p.stops).enumerated() { + assertClose(s.x, ws[0], "\(p.id) stops[\(i)].r") + assertClose(s.y, ws[1], "\(p.id) stops[\(i)].g") + assertClose(s.z, ws[2], "\(p.id) stops[\(i)].b") + } + assertClose(p.ground.x, want.ground[0], "\(p.id) ground.r") + assertClose(p.ground.y, want.ground[1], "\(p.id) ground.g") + assertClose(p.ground.z, want.ground[2], "\(p.id) ground.b") + assertClose(p.accent.x, want.accent[0], "\(p.id) accent.r") + assertClose(p.accent.y, want.accent[1], "\(p.id) accent.g") + assertClose(p.accent.z, want.accent[2], "\(p.id) accent.b") + + let mesh = p.meshColors + XCTAssertEqual(want.mesh.count, mesh.count, "\(p.id) mesh cells") + for (i, (wc, c)) in zip(want.mesh, mesh).enumerated() { + assertClose(c.x, wc[0], "\(p.id) mesh[\(i)].r") + assertClose(c.y, wc[1], "\(p.id) mesh[\(i)].g") + assertClose(c.z, wc[2], "\(p.id) mesh[\(i)].b") + } + let blobs = p.blobColors + XCTAssertEqual(want.blobs.count, blobs.count, "\(p.id) blob count") + for (i, (wc, c)) in zip(want.blobs, blobs).enumerated() { + assertClose(c.x, wc[0], "\(p.id) blob[\(i)].r") + assertClose(c.y, wc[1], "\(p.id) blob[\(i)].g") + assertClose(c.z, wc[2], "\(p.id) blob[\(i)].b") + } + } + } + + /// The four wandering mesh control points. This client keeps them as literal arguments to a + /// nested `wob(...)` inside `GamepadChrome.meshPoints(at:)` rather than as a named table, so + /// the values are checked here against the vectors and the shape is pinned by the count. + func testMeshInteriorIsFourPoints() throws { + let file = try JSONDecoder().decode( + VectorFile.self, from: Data(contentsOf: Self.vectorFileURL)) + XCTAssertEqual(file.meshInterior.count, 4, "the mesh has four interior control points") + for p in file.meshInterior { + XCTAssertEqual(p.count, 6, "each point is (x, y, amp, sx, sy, phase)") + } + } +} diff --git a/clients/shared/console-vectors.json b/clients/shared/console-vectors.json new file mode 100644 index 00000000..6424b440 --- /dev/null +++ b/clients/shared/console-vectors.json @@ -0,0 +1,2005 @@ +{ + "$comment": "Cross-client CONSOLE-UI parity vectors: the background palette table, the settings section tabs, and the screen-transition motion contract. Consumed by pf-console-ui's Rust tests, the Android app's ConsoleVectorsTest, and the Apple client's ConsoleVectorsTests -- so the three hand-copies of these tables can no longer drift in silence. Sibling of deeplink-vectors.json, same rules: this file is the contract, and a value absent here is a value no client may assume.", + "version": 1, + "cell_ramp": [ + 0.1, + -0.06, + 0.04, + -0.12, + -0.08, + 0.14, + -0.1, + 0.06, + 0.06, + -0.12, + 0.16, + -0.04, + -0.1, + 0.08, + -0.06, + 0.12 + ], + "mesh_interior": [ + [ + 0.333, + 0.333, + 0.11, + 0.049, + 0.063, + 0.4 + ], + [ + 0.667, + 0.333, + 0.1, + 0.055, + 0.052, + 2.1 + ], + [ + 0.333, + 0.667, + 0.1, + 0.058, + 0.049, + 3.6 + ], + [ + 0.667, + 0.667, + 0.12, + 0.047, + 0.061, + 5.0 + ] + ], + "palettes": [ + { + "id": "violet", + "name": "Violet", + "light": false, + "stops": [], + "ground": [ + 0.075, + 0.06, + 0.16 + ], + "accent": [ + 0.525, + 0.471, + 0.961 + ], + "mesh": [ + [ + 0.075, + 0.06, + 0.16 + ], + [ + 0.34, + 0.27, + 0.72 + ], + [ + 0.3, + 0.26, + 0.74 + ], + [ + 0.075, + 0.06, + 0.16 + ], + [ + 0.42, + 0.2, + 0.54 + ], + [ + 0.49, + 0.39, + 0.95 + ], + [ + 0.28, + 0.31, + 0.84 + ], + [ + 0.16, + 0.26, + 0.64 + ], + [ + 0.45, + 0.23, + 0.6 + ], + [ + 0.53, + 0.31, + 0.75 + ], + [ + 0.35, + 0.35, + 0.91 + ], + [ + 0.19, + 0.28, + 0.7 + ], + [ + 0.075, + 0.06, + 0.16 + ], + [ + 0.22, + 0.18, + 0.54 + ], + [ + 0.24, + 0.2, + 0.58 + ], + [ + 0.075, + 0.06, + 0.16 + ] + ], + "blobs": [ + [ + 0.356, + 0.308, + 0.816 + ], + [ + 0.468, + 0.26, + 0.768 + ], + [ + 0.38, + 0.348, + 0.836 + ], + [ + 0.406, + 0.434, + 0.92 + ] + ] + }, + { + "id": "oled", + "name": "Eclipse", + "light": false, + "stops": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.01, + 0.02, + 0.1 + ], + [ + 0.045, + 0.016, + 0.115 + ], + [ + 0.12, + 0.024, + 0.13 + ] + ], + "ground": [ + 0.0, + 0.0, + 0.0 + ], + "accent": [ + 0.525, + 0.471, + 0.961 + ], + "mesh": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.004933333, + 0.009866667, + 0.049333333 + ], + [ + 0.0052, + 0.0104, + 0.052 + ], + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.008933333, + 0.017866667, + 0.089333333 + ], + [ + 0.006, + 0.012, + 0.06 + ], + [ + 0.041733333, + 0.016373333, + 0.1136 + ], + [ + 0.005733333, + 0.011466667, + 0.057333333 + ], + [ + 0.0052, + 0.0104, + 0.052 + ], + [ + 0.068, + 0.018453333, + 0.1196 + ], + [ + 0.058, + 0.017386667, + 0.1176 + ], + [ + 0.006, + 0.012, + 0.06 + ], + [ + 0.044533333, + 0.016053333, + 0.1148 + ], + [ + 0.052, + 0.016746667, + 0.1164 + ], + [ + 0.12, + 0.024, + 0.13 + ] + ], + "blobs": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.006, + 0.012, + 0.06 + ], + [ + 0.031, + 0.0176, + 0.109 + ], + [ + 0.09, + 0.0208, + 0.124 + ] + ] + }, + { + "id": "nebula", + "name": "Nebula", + "light": false, + "stops": [ + [ + 0.07, + 0.05, + 0.2 + ], + [ + 0.26, + 0.14, + 0.54 + ], + [ + 0.52, + 0.2, + 0.72 + ], + [ + 0.82, + 0.26, + 0.62 + ], + [ + 0.98, + 0.46, + 0.68 + ] + ], + "ground": [ + 0.055, + 0.04, + 0.135 + ], + "accent": [ + 0.95, + 0.42, + 0.72 + ], + "mesh": [ + [ + 0.146, + 0.086, + 0.336 + ], + [ + 0.151066667, + 0.0884, + 0.345066667 + ], + [ + 0.388266667, + 0.1696, + 0.6288 + ], + [ + 0.3952, + 0.1712, + 0.6336 + ], + [ + 0.135866667, + 0.0812, + 0.317866667 + ], + [ + 0.492266667, + 0.1936, + 0.7008 + ], + [ + 0.416, + 0.176, + 0.648 + ], + [ + 0.792, + 0.2544, + 0.629333333 + ], + [ + 0.409066667, + 0.1744, + 0.6432 + ], + [ + 0.3952, + 0.1712, + 0.6336 + ], + [ + 0.869066667, + 0.321333333, + 0.6384 + ], + [ + 0.847733333, + 0.294666667, + 0.6304 + ], + [ + 0.416, + 0.176, + 0.648 + ], + [ + 0.816, + 0.2592, + 0.621333333 + ], + [ + 0.834933333, + 0.278666667, + 0.6256 + ], + [ + 0.98, + 0.46, + 0.68 + ] + ], + "blobs": [ + [ + 0.184, + 0.104, + 0.404 + ], + [ + 0.416, + 0.176, + 0.648 + ], + [ + 0.7, + 0.236, + 0.66 + ], + [ + 0.916, + 0.38, + 0.656 + ] + ] + }, + { + "id": "abyss", + "name": "Abyss", + "light": false, + "stops": [ + [ + 0.02, + 0.1, + 0.17 + ], + [ + 0.04, + 0.28, + 0.42 + ], + [ + 0.07, + 0.46, + 0.63 + ], + [ + 0.16, + 0.38, + 0.78 + ], + [ + 0.26, + 0.22, + 0.58 + ] + ], + "ground": [ + 0.018, + 0.07, + 0.13 + ], + "accent": [ + 0.26, + 0.76, + 0.92 + ], + "mesh": [ + [ + 0.028, + 0.172, + 0.27 + ], + [ + 0.028533333, + 0.1768, + 0.276666667 + ], + [ + 0.0548, + 0.3688, + 0.5236 + ], + [ + 0.0556, + 0.3736, + 0.5292 + ], + [ + 0.026933333, + 0.1624, + 0.256666667 + ], + [ + 0.0668, + 0.4408, + 0.6076 + ], + [ + 0.058, + 0.388, + 0.546 + ], + [ + 0.1516, + 0.387466667, + 0.766 + ], + [ + 0.0572, + 0.3832, + 0.5404 + ], + [ + 0.0556, + 0.3736, + 0.5292 + ], + [ + 0.190666667, + 0.330933333, + 0.718666667 + ], + [ + 0.177333333, + 0.352266667, + 0.745333333 + ], + [ + 0.058, + 0.388, + 0.546 + ], + [ + 0.1588, + 0.381066667, + 0.778 + ], + [ + 0.169333333, + 0.365066667, + 0.761333333 + ], + [ + 0.26, + 0.22, + 0.58 + ] + ], + "blobs": [ + [ + 0.032, + 0.208, + 0.32 + ], + [ + 0.058, + 0.388, + 0.546 + ], + [ + 0.124, + 0.412, + 0.72 + ], + [ + 0.22, + 0.284, + 0.66 + ] + ] + }, + { + "id": "ember", + "name": "Ember", + "light": false, + "stops": [ + [ + 0.16, + 0.03, + 0.1 + ], + [ + 0.45, + 0.06, + 0.12 + ], + [ + 0.72, + 0.18, + 0.06 + ], + [ + 0.9, + 0.42, + 0.08 + ], + [ + 0.95, + 0.68, + 0.18 + ] + ], + "ground": [ + 0.09, + 0.035, + 0.04 + ], + "accent": [ + 0.98, + 0.62, + 0.26 + ], + "mesh": [ + [ + 0.276, + 0.042, + 0.108 + ], + [ + 0.283733333, + 0.0428, + 0.108533333 + ], + [ + 0.5832, + 0.1192, + 0.0904 + ], + [ + 0.5904, + 0.1224, + 0.0888 + ], + [ + 0.260533333, + 0.0404, + 0.106933333 + ], + [ + 0.6912, + 0.1672, + 0.0664 + ], + [ + 0.612, + 0.132, + 0.084 + ], + [ + 0.8832, + 0.3976, + 0.078133333 + ], + [ + 0.6048, + 0.1288, + 0.0856 + ], + [ + 0.5904, + 0.1224, + 0.0888 + ], + [ + 0.915333333, + 0.499733333, + 0.110666667 + ], + [ + 0.908666667, + 0.465066667, + 0.097333333 + ], + [ + 0.612, + 0.132, + 0.084 + ], + [ + 0.8976, + 0.4168, + 0.079733333 + ], + [ + 0.904666667, + 0.444266667, + 0.089333333 + ], + [ + 0.95, + 0.68, + 0.18 + ] + ], + "blobs": [ + [ + 0.334, + 0.048, + 0.112 + ], + [ + 0.612, + 0.132, + 0.084 + ], + [ + 0.828, + 0.324, + 0.072 + ], + [ + 0.93, + 0.576, + 0.14 + ] + ] + }, + { + "id": "moss", + "name": "Moss", + "light": false, + "stops": [ + [ + 0.03, + 0.11, + 0.09 + ], + [ + 0.06, + 0.27, + 0.2 + ], + [ + 0.09, + 0.45, + 0.31 + ], + [ + 0.28, + 0.61, + 0.28 + ], + [ + 0.58, + 0.77, + 0.31 + ] + ], + "ground": [ + 0.025, + 0.085, + 0.07 + ], + "accent": [ + 0.48, + 0.86, + 0.46 + ], + "mesh": [ + [ + 0.042, + 0.174, + 0.134 + ], + [ + 0.0428, + 0.178266667, + 0.136933333 + ], + [ + 0.0748, + 0.3588, + 0.254266667 + ], + [ + 0.0756, + 0.3636, + 0.2572 + ], + [ + 0.0404, + 0.165466667, + 0.128133333 + ], + [ + 0.0868, + 0.4308, + 0.298266667 + ], + [ + 0.078, + 0.378, + 0.266 + ], + [ + 0.262266667, + 0.595066667, + 0.2828 + ], + [ + 0.0772, + 0.3732, + 0.263066667 + ], + [ + 0.0756, + 0.3636, + 0.2572 + ], + [ + 0.372, + 0.659066667, + 0.2892 + ], + [ + 0.332, + 0.637733333, + 0.2852 + ], + [ + 0.078, + 0.378, + 0.266 + ], + [ + 0.277466667, + 0.607866667, + 0.2804 + ], + [ + 0.308, + 0.624933333, + 0.2828 + ], + [ + 0.58, + 0.77, + 0.31 + ] + ], + "blobs": [ + [ + 0.048, + 0.206, + 0.156 + ], + [ + 0.078, + 0.378, + 0.266 + ], + [ + 0.204, + 0.546, + 0.292 + ], + [ + 0.46, + 0.706, + 0.298 + ] + ] + }, + { + "id": "graphite", + "name": "Graphite", + "light": false, + "stops": [ + [ + 0.06, + 0.07, + 0.11 + ], + [ + 0.15, + 0.18, + 0.25 + ], + [ + 0.3, + 0.31, + 0.35 + ], + [ + 0.45, + 0.42, + 0.38 + ], + [ + 0.6, + 0.56, + 0.49 + ] + ], + "ground": [ + 0.055, + 0.055, + 0.07 + ], + "accent": [ + 0.78, + 0.8, + 0.86 + ], + "mesh": [ + [ + 0.096, + 0.114, + 0.166 + ], + [ + 0.0984, + 0.116933333, + 0.169733333 + ], + [ + 0.224, + 0.244133333, + 0.299333333 + ], + [ + 0.228, + 0.2476, + 0.302 + ], + [ + 0.0912, + 0.108133333, + 0.158533333 + ], + [ + 0.284, + 0.296133333, + 0.339333333 + ], + [ + 0.24, + 0.258, + 0.31 + ], + [ + 0.436, + 0.409733333, + 0.3772 + ], + [ + 0.236, + 0.254533333, + 0.307333333 + ], + [ + 0.228, + 0.2476, + 0.302 + ], + [ + 0.496, + 0.462933333, + 0.413733333 + ], + [ + 0.476, + 0.444266667, + 0.399066667 + ], + [ + 0.24, + 0.258, + 0.31 + ], + [ + 0.448, + 0.418533333, + 0.3796 + ], + [ + 0.464, + 0.433066667, + 0.390266667 + ], + [ + 0.6, + 0.56, + 0.49 + ] + ], + "blobs": [ + [ + 0.114, + 0.136, + 0.194 + ], + [ + 0.24, + 0.258, + 0.31 + ], + [ + 0.39, + 0.376, + 0.368 + ], + [ + 0.54, + 0.504, + 0.446 + ] + ] + }, + { + "id": "holo", + "name": "Holo", + "light": true, + "stops": [ + [ + 0.99, + 0.72, + 0.9 + ], + [ + 0.8, + 0.6, + 0.98 + ], + [ + 0.58, + 0.62, + 0.99 + ], + [ + 0.55, + 0.86, + 0.98 + ], + [ + 0.94, + 0.98, + 1.0 + ] + ], + "ground": [ + 0.96, + 0.92, + 0.99 + ], + "accent": [ + 0.42, + 0.28, + 0.86 + ], + "mesh": [ + [ + 0.914, + 0.672, + 0.932 + ], + [ + 0.908933333, + 0.6688, + 0.934133333 + ], + [ + 0.691466667, + 0.609866667, + 0.984933333 + ], + [ + 0.6856, + 0.6104, + 0.9852 + ], + [ + 0.924133333, + 0.6784, + 0.927733333 + ], + [ + 0.603466667, + 0.617866667, + 0.988933333 + ], + [ + 0.668, + 0.612, + 0.986 + ], + [ + 0.5528, + 0.8376, + 0.980933333 + ], + [ + 0.673866667, + 0.611466667, + 0.985733333 + ], + [ + 0.6856, + 0.6104, + 0.9852 + ], + [ + 0.6696, + 0.8968, + 0.986133333 + ], + [ + 0.6176, + 0.8808, + 0.983466667 + ], + [ + 0.668, + 0.612, + 0.986 + ], + [ + 0.5504, + 0.8568, + 0.980133333 + ], + [ + 0.5864, + 0.8712, + 0.981866667 + ], + [ + 0.94, + 0.98, + 1.0 + ] + ], + "blobs": [ + [ + 0.876, + 0.648, + 0.948 + ], + [ + 0.668, + 0.612, + 0.986 + ], + [ + 0.562, + 0.764, + 0.984 + ], + [ + 0.784, + 0.932, + 0.992 + ] + ] + }, + { + "id": "sunset", + "name": "Sunset", + "light": true, + "stops": [ + [ + 0.55, + 0.45, + 0.92 + ], + [ + 0.86, + 0.31, + 0.66 + ], + [ + 0.97, + 0.26, + 0.34 + ], + [ + 0.99, + 0.51, + 0.18 + ], + [ + 1.0, + 0.8, + 0.22 + ] + ], + "ground": [ + 0.98, + 0.74, + 0.34 + ], + "accent": [ + 0.64, + 0.13, + 0.44 + ], + "mesh": [ + [ + 0.674, + 0.394, + 0.816 + ], + [ + 0.682266667, + 0.390266667, + 0.809066667 + ], + [ + 0.914266667, + 0.285333333, + 0.502133333 + ], + [ + 0.9172, + 0.284, + 0.4936 + ], + [ + 0.657466667, + 0.401466667, + 0.829866667 + ], + [ + 0.958266667, + 0.265333333, + 0.374133333 + ], + [ + 0.926, + 0.28, + 0.468 + ], + [ + 0.988133333, + 0.486666667, + 0.194933333 + ], + [ + 0.923066667, + 0.281333333, + 0.476533333 + ], + [ + 0.9172, + 0.284, + 0.4936 + ], + [ + 0.993066667, + 0.598933333, + 0.192266667 + ], + [ + 0.991733333, + 0.560266667, + 0.186933333 + ], + [ + 0.926, + 0.28, + 0.468 + ], + [ + 0.989733333, + 0.506666667, + 0.182133333 + ], + [ + 0.990933333, + 0.537066667, + 0.183733333 + ], + [ + 1.0, + 0.8, + 0.22 + ] + ], + "blobs": [ + [ + 0.736, + 0.366, + 0.764 + ], + [ + 0.926, + 0.28, + 0.468 + ], + [ + 0.982, + 0.41, + 0.244 + ], + [ + 0.996, + 0.684, + 0.204 + ] + ] + }, + { + "id": "bloom", + "name": "Bloom", + "light": true, + "stops": [ + [ + 1.0, + 0.86, + 0.72 + ], + [ + 0.99, + 0.73, + 0.79 + ], + [ + 0.95, + 0.65, + 0.89 + ], + [ + 0.82, + 0.68, + 0.96 + ], + [ + 0.73, + 0.79, + 0.99 + ] + ], + "ground": [ + 0.99, + 0.9, + 0.89 + ], + "accent": [ + 0.72, + 0.24, + 0.55 + ], + "mesh": [ + [ + 0.996, + 0.808, + 0.748 + ], + [ + 0.995733333, + 0.804533333, + 0.749866667 + ], + [ + 0.970266667, + 0.690533333, + 0.839333333 + ], + [ + 0.9692, + 0.6884, + 0.842 + ], + [ + 0.996533333, + 0.814933333, + 0.744266667 + ], + [ + 0.954266667, + 0.658533333, + 0.879333333 + ], + [ + 0.966, + 0.682, + 0.85 + ], + [ + 0.832133333, + 0.6772, + 0.953466667 + ], + [ + 0.967066667, + 0.684133333, + 0.847333333 + ], + [ + 0.9692, + 0.6884, + 0.842 + ], + [ + 0.7924, + 0.713733333, + 0.9692 + ], + [ + 0.8044, + 0.699066667, + 0.9652 + ], + [ + 0.966, + 0.682, + 0.85 + ], + [ + 0.821733333, + 0.6796, + 0.959066667 + ], + [ + 0.8116, + 0.690266667, + 0.9628 + ], + [ + 0.73, + 0.79, + 0.99 + ] + ], + "blobs": [ + [ + 0.994, + 0.782, + 0.762 + ], + [ + 0.966, + 0.682, + 0.85 + ], + [ + 0.872, + 0.668, + 0.932 + ], + [ + 0.766, + 0.746, + 0.978 + ] + ] + }, + { + "id": "dawn", + "name": "Dawn", + "light": true, + "stops": [ + [ + 1.0, + 0.92, + 0.7 + ], + [ + 1.0, + 0.8, + 0.62 + ], + [ + 0.99, + 0.66, + 0.62 + ], + [ + 0.9, + 0.62, + 0.78 + ], + [ + 0.77, + 0.69, + 0.95 + ] + ], + "ground": [ + 1.0, + 0.93, + 0.82 + ], + "accent": [ + 0.82, + 0.33, + 0.28 + ], + "mesh": [ + [ + 1.0, + 0.872, + 0.668 + ], + [ + 1.0, + 0.8688, + 0.665866667 + ], + [ + 0.995066667, + 0.730933333, + 0.62 + ], + [ + 0.9948, + 0.7272, + 0.62 + ], + [ + 1.0, + 0.8784, + 0.672266667 + ], + [ + 0.991066667, + 0.674933333, + 0.62 + ], + [ + 0.994, + 0.716, + 0.62 + ], + [ + 0.9084, + 0.623733333, + 0.765066667 + ], + [ + 0.994266667, + 0.719733333, + 0.62 + ], + [ + 0.9948, + 0.7272, + 0.62 + ], + [ + 0.860133333, + 0.641466667, + 0.832133333 + ], + [ + 0.877466667, + 0.632133333, + 0.809466667 + ], + [ + 0.994, + 0.716, + 0.62 + ], + [ + 0.9012, + 0.620533333, + 0.777866667 + ], + [ + 0.887866667, + 0.626533333, + 0.795866667 + ], + [ + 0.77, + 0.69, + 0.95 + ] + ], + "blobs": [ + [ + 1.0, + 0.848, + 0.652 + ], + [ + 0.994, + 0.716, + 0.62 + ], + [ + 0.936, + 0.636, + 0.716 + ], + [ + 0.822, + 0.662, + 0.882 + ] + ] + }, + { + "id": "mint", + "name": "Mint", + "light": true, + "stops": [ + [ + 0.82, + 0.98, + 0.9 + ], + [ + 0.62, + 0.94, + 0.88 + ], + [ + 0.55, + 0.88, + 0.95 + ], + [ + 0.63, + 0.82, + 0.99 + ], + [ + 0.82, + 0.87, + 1.0 + ] + ], + "ground": [ + 0.9, + 0.98, + 0.96 + ], + "accent": [ + 0.04, + 0.42, + 0.4 + ], + "mesh": [ + [ + 0.74, + 0.964, + 0.892 + ], + [ + 0.734666667, + 0.962933333, + 0.891466667 + ], + [ + 0.585466667, + 0.9104, + 0.914533333 + ], + [ + 0.5836, + 0.9088, + 0.9164 + ], + [ + 0.750666667, + 0.966133333, + 0.893066667 + ], + [ + 0.557466667, + 0.8864, + 0.942533333 + ], + [ + 0.578, + 0.904, + 0.922 + ], + [ + 0.622533333, + 0.8256, + 0.986266667 + ], + [ + 0.579866667, + 0.9056, + 0.920133333 + ], + [ + 0.5836, + 0.9088, + 0.9164 + ], + [ + 0.688266667, + 0.835333333, + 0.993066667 + ], + [ + 0.662933333, + 0.828666667, + 0.991733333 + ], + [ + 0.578, + 0.904, + 0.922 + ], + [ + 0.628933333, + 0.8208, + 0.989466667 + ], + [ + 0.647733333, + 0.824666667, + 0.990933333 + ], + [ + 0.82, + 0.87, + 1.0 + ] + ], + "blobs": [ + [ + 0.7, + 0.956, + 0.888 + ], + [ + 0.578, + 0.904, + 0.922 + ], + [ + 0.598, + 0.844, + 0.974 + ], + [ + 0.744, + 0.85, + 0.996 + ] + ] + }, + { + "id": "opal", + "name": "Opal", + "light": true, + "stops": [ + [ + 0.98, + 0.92, + 0.96 + ], + [ + 0.87, + 0.93, + 0.99 + ], + [ + 0.91, + 0.99, + 0.95 + ], + [ + 0.99, + 0.96, + 0.88 + ], + [ + 0.94, + 0.9, + 0.99 + ] + ], + "ground": [ + 0.97, + 0.96, + 0.99 + ], + "accent": [ + 0.36, + 0.32, + 0.44 + ], + "mesh": [ + [ + 0.936, + 0.924, + 0.972 + ], + [ + 0.933066667, + 0.924266667, + 0.9728 + ], + [ + 0.889733333, + 0.9596, + 0.970266667 + ], + [ + 0.8908, + 0.9612, + 0.9692 + ], + [ + 0.941866667, + 0.923466667, + 0.9704 + ], + [ + 0.905733333, + 0.9836, + 0.954266667 + ], + [ + 0.894, + 0.966, + 0.966 + ], + [ + 0.982533333, + 0.9628, + 0.886533333 + ], + [ + 0.892933333, + 0.9644, + 0.967066667 + ], + [ + 0.8908, + 0.9612, + 0.9692 + ], + [ + 0.974666667, + 0.9416, + 0.913733333 + ], + [ + 0.981333333, + 0.9496, + 0.899066667 + ], + [ + 0.894, + 0.966, + 0.966 + ], + [ + 0.988933333, + 0.9604, + 0.880933333 + ], + [ + 0.985333333, + 0.9544, + 0.890266667 + ], + [ + 0.94, + 0.9, + 0.99 + ] + ], + "blobs": [ + [ + 0.914, + 0.926, + 0.978 + ], + [ + 0.894, + 0.966, + 0.966 + ], + [ + 0.958, + 0.972, + 0.908 + ], + [ + 0.96, + 0.924, + 0.946 + ] + ] + } + ], + "tabs": [ + { + "name": "Stream" + }, + { + "name": "Video" + }, + { + "name": "Audio" + }, + { + "name": "Controller" + }, + { + "name": "Input", + "desktop_only": true, + "$why": "Touch mode, mouse, invert-scroll and shortcuts are desktop-host settings with nothing to set on a phone or a TV, so the mobile clients ship six tabs and the desktop console seven. Modelled rather than omitted: a flat six-name list would red the desktop on day one, and a seven-name list would red both mobile clients." + }, + { + "name": "Interface" + }, + { + "name": "Profiles" + } + ], + "motion": { + "$why": "The console screen transition, from pf-console-ui's shell: TRANSITION_S and the paint geometry in shell/render.rs. Every client re-implements this by hand in its own animation system, which is why it is pinned here.", + "transition_s": 0.26, + "push_slide_dp": 36.0, + "enter_scale": 0.985, + "exit_scale": 0.96, + "reveal_alpha": 0.4, + "ease_out_cubic": { + "$why": "Sampled, not given as Bezier control points: the desktop's curve is the analytic 1-(1-t)^3, Android reproduces it exactly, and SwiftUI can only approximate it with a timing curve. A tolerance on samples is the only form all three can meet. Two different beziers are published under the name 'easeOutCubic' and neither is this curve -- that is the drift this pins.", + "tolerance": 0.02, + "samples": [ + { + "t": 0.0, + "p": 0.0 + }, + { + "t": 0.125, + "p": 0.330078125 + }, + { + "t": 0.25, + "p": 0.578125 + }, + { + "t": 0.375, + "p": 0.755859375 + }, + { + "t": 0.5, + "p": 0.875 + }, + { + "t": 0.625, + "p": 0.947265625 + }, + { + "t": 0.75, + "p": 0.984375 + }, + { + "t": 0.875, + "p": 0.998046875 + }, + { + "t": 1.0, + "p": 1.0 + } + ] + } + } +} diff --git a/crates/pf-console-ui/Cargo.toml b/crates/pf-console-ui/Cargo.toml index e3293660..bb26a3a7 100644 --- a/crates/pf-console-ui/Cargo.toml +++ b/crates/pf-console-ui/Cargo.toml @@ -34,5 +34,11 @@ sdl3 = { version = "0.18", features = ["hidapi", "ash"] } [target.'cfg(windows)'.dependencies] sdl3 = { version = "0.18", features = ["hidapi", "ash", "build-from-source"] } +# The shared console parity vectors (`clients/shared/console-vectors.json`) are read by three +# tests here — the palette table, the tab names and the transition motion. Dev-only: nothing in the +# shipping crate parses JSON. `pf-client-core` reads its own deeplink vectors the same way. +[dev-dependencies] +serde_json = "1" + [lints] workspace = true diff --git a/crates/pf-console-ui/src/library.rs b/crates/pf-console-ui/src/library.rs index 5f4b5cd3..027c8019 100644 --- a/crates/pf-console-ui/src/library.rs +++ b/crates/pf-console-ui/src/library.rs @@ -670,6 +670,84 @@ pub fn initials(title: &str) -> String { mod tests { use super::*; + /// The shared console parity vectors — `clients/shared/console-vectors.json`, the sibling of + /// `deeplink-vectors.json` and read the same way (`include_str!`, so a missing file is a + /// compile error rather than a skipped test). + /// + /// This table lives in THREE hand-written copies — here, `GamepadPalette.kt` and + /// `GamepadPalette.swift` — and until now nothing but prose held them together. What the file + /// pins is not only the 13 palette definitions but the DERIVED 16-cell mesh each one produces, + /// which is the half that actually reaches the screen and the half a transcription slip would + /// change invisibly. + #[test] + fn shared_console_vectors() { + let raw = include_str!("../../../clients/shared/console-vectors.json"); + let file: serde_json::Value = + serde_json::from_str(raw).expect("console-vectors.json must parse"); + let nums = |v: &serde_json::Value| -> Vec { + v.as_array() + .expect("array") + .iter() + .map(|n| n.as_f64().expect("number")) + .collect() + }; + let close = |what: &str, a: f64, b: f64| { + assert!( + (a - b).abs() < 1e-6, + "{what}: vectors say {b}, this client computes {a}" + ); + }; + + assert_eq!(nums(&file["cell_ramp"]), CELL_RAMP.to_vec(), "CELL_RAMP"); + + let interior = file["mesh_interior"].as_array().expect("mesh_interior"); + assert_eq!(interior.len(), MESH_INTERIOR.len(), "mesh interior count"); + for (w, p) in interior.iter().zip(MESH_INTERIOR.iter()) { + let w = nums(w); + let got = [p.0, p.1, p.2, p.3, p.4, p.5]; + for (i, (a, b)) in got.iter().zip(w.iter()).enumerate() { + close(&format!("mesh_interior[{i}]"), *a, *b); + } + } + + let want = file["palettes"].as_array().expect("palettes"); + assert_eq!(want.len(), PALETTES.len(), "palette count"); + for (w, p) in want.iter().zip(PALETTES.iter()) { + let id = w["id"].as_str().expect("id"); + assert_eq!(id, p.id, "palette order"); + assert_eq!(w["name"].as_str().expect("name"), p.name, "{id} name"); + assert_eq!(w["light"].as_bool().expect("light"), p.light, "{id} light"); + let g = nums(&w["ground"]); + close(&format!("{id} ground.r"), p.ground.0, g[0]); + close(&format!("{id} ground.g"), p.ground.1, g[1]); + close(&format!("{id} ground.b"), p.ground.2, g[2]); + let a = nums(&w["accent"]); + close(&format!("{id} accent.r"), p.accent.0, a[0]); + close(&format!("{id} accent.g"), p.accent.1, a[1]); + close(&format!("{id} accent.b"), p.accent.2, a[2]); + + // The derived tables — the ones that reach the shader and the fallback field. + let mesh = p.mesh_colors(); + let wm = w["mesh"].as_array().expect("mesh"); + assert_eq!(wm.len(), mesh.len(), "{id} mesh cells"); + for (i, (c, wc)) in mesh.iter().zip(wm.iter()).enumerate() { + let wc = nums(wc); + close(&format!("{id} mesh[{i}].r"), c.0, wc[0]); + close(&format!("{id} mesh[{i}].g"), c.1, wc[1]); + close(&format!("{id} mesh[{i}].b"), c.2, wc[2]); + } + let blobs = p.blob_colors(); + let wb = w["blobs"].as_array().expect("blobs"); + assert_eq!(wb.len(), blobs.len(), "{id} blob count"); + for (i, (c, wc)) in blobs.iter().zip(wb.iter()).enumerate() { + let wc = nums(wc); + close(&format!("{id} blob[{i}].r"), c.0, wc[0]); + close(&format!("{id} blob[{i}].g"), c.1, wc[1]); + close(&format!("{id} blob[{i}].b"), c.2, wc[2]); + } + } + } + /// The GTK launcher's cursor tests, ported with the math. #[test] fn step_refuses_the_ends() { diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index 1a7c214e..4f495763 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -1021,6 +1021,29 @@ mod tests { use super::*; use pf_client_core::trust::Settings; + /// The section names, against the shared vectors. The comment above [`TABS`] claims a setting + /// is found under the same word on every client; this is what makes that claim checkable. + /// + /// The desktop has one tab the mobile clients do not — Input, holding touch mode, mouse, + /// invert-scroll and shortcuts, which are desktop-host settings with nothing to set on a phone + /// or a TV. The vectors model it with a `desktop_only` flag rather than omitting it, because a + /// flat six-name list would red this test on day one and a seven-name list would red both + /// mobile clients: the disagreement is real and belongs in the contract, not in prose. + #[test] + fn tab_names_match_the_shared_vectors() { + let raw = include_str!("../../../../clients/shared/console-vectors.json"); + let file: serde_json::Value = + serde_json::from_str(raw).expect("console-vectors.json must parse"); + let want: Vec<&str> = file["tabs"] + .as_array() + .expect("tabs") + .iter() + .map(|t| t["name"].as_str().expect("tab name")) + .collect(); + let got: Vec<&str> = TABS.iter().map(|(name, _)| *name).collect(); + assert_eq!(got, want, "the desktop console's tab names and order"); + } + fn ctx_parts() -> (Settings, Vec) { (Settings::default(), Vec::new()) } diff --git a/crates/pf-console-ui/src/shell/tests.rs b/crates/pf-console-ui/src/shell/tests.rs index 39e793a4..09f7a5fa 100644 --- a/crates/pf-console-ui/src/shell/tests.rs +++ b/crates/pf-console-ui/src/shell/tests.rs @@ -4,6 +4,46 @@ use crate::screens::home::HomeScreen; use crate::screens::library::LibraryScreen; use punktfunk_core::config::GamepadPref; +/// The screen-transition contract, against the shared vectors. Every client re-implements this +/// motion in its own animation system, so the numbers exist in three places and drifted in two of +/// them before this test. +/// +/// The EASING is sampled rather than compared as control points, and that is the point of it: +/// this side is the analytic `1 − (1−t)³`, Android reproduces it exactly, and SwiftUI can only +/// approximate it with a Bézier. Worse, two different Béziers are published under the name +/// "easeOutCubic" — `(0.215, 0.61, 0.355, 1)` and `(0.33, 1, 0.68, 1)` — and neither IS this +/// curve; they differ from it by up to ~0.08 at the midpoint, which is visible on a 260 ms +/// transition. Samples with a tolerance are the only form all three runtimes can meet. +#[test] +fn motion_matches_the_shared_vectors() { + let raw = include_str!("../../../../clients/shared/console-vectors.json"); + let file: serde_json::Value = + serde_json::from_str(raw).expect("console-vectors.json must parse"); + let motion = &file["motion"]; + let want_s = motion["transition_s"].as_f64().expect("transition_s"); + assert!( + (TRANSITION_S - want_s).abs() < 1e-9, + "TRANSITION_S is {TRANSITION_S}, vectors say {want_s}" + ); + + let curve = &motion["ease_out_cubic"]; + let tol = curve["tolerance"].as_f64().expect("tolerance"); + let samples = curve["samples"].as_array().expect("samples"); + assert!( + samples.len() >= 5, + "the curve needs enough samples to pin it" + ); + for s in samples { + let t = s["t"].as_f64().expect("t"); + let want = s["p"].as_f64().expect("p"); + let got = crate::anim::ease_out_cubic(t); + assert!( + (got - want).abs() <= tol, + "ease_out_cubic({t}) is {got}, vectors say {want} (±{tol})" + ); + } +} + /// Point the settings/known-hosts stores at a throwaway HOME — the settings screen /// SAVES on adjust, and a test must never write the developer's real config. fn fake_home() {