feat(android): the console tables stop drifting in silence, and the stats overlay gets a pad route

WP8 and WP9 of `punktfunk-planning/design/android-console-ui-visual-refresh.md`, in part.

**WP9.3 — shared parity vectors.** The console's background palettes, its settings section names
and its screen-transition motion each existed in three hand-written copies (`pf-console-ui`, this
client, the Apple client) held together by a comment asking the next person to keep them in step.
`clients/shared/console-vectors.json` now holds them, read the way `deeplink-vectors.json` already
is: `include_str!` in Rust, a relative path in Kotlin, `#filePath` in Swift — never a copy, because
a copy is a fourth contract free to go stale. It carries the DERIVED tables too, the 16-cell mesh
and the 4 blob colours per palette, which is the half that reaches the screen and the half Android
never checked: `GamepadPaletteTest` only ever measured the `stops` they are computed from.

Two drifts it immediately caught, both now closed:

* **The easing was the wrong curve.** `ConsoleMotion.EaseOutCubic` shipped as
  `cubic-bezier(0.215, 0.61, 0.355, 1)` while claiming to be the desktop's `ease_out_cubic`. It is
  not: that is the Penner/Ceaser table's curve, ~0.80 at the midpoint where `1 − (1−t)³` is 0.875 —
  visibly slacker over a 260 ms transition. Compose's `Easing` is a plain function, so it now
  evaluates the real thing analytically rather than approximating it at all. (Apple approximates
  with a different bezier only because SwiftUI's `timingCurve` cannot take a closure; the vectors
  sample the curve with a tolerance so all three can meet it.)
* **The desktop has a seventh tab.** Input — touch mode, mouse, invert-scroll, shortcuts — with
  nothing to set on a phone or a TV. `settings.rs` claims in prose that a setting is found under
  the same word on every client; that was true modulo an omission nobody could see. The vectors
  model it with `desktop_only` rather than picking a side, so neither client has to be wrong.

Rust reads it from three tests placed in the files that own the constants, so nothing had to be
made `pub` to be checkable. Verified green under Linux (the crate is `cfg(linux|windows)` throughout
— `cargo test` on a Mac compiles nothing and passes vacuously): 77 passed, 0 failed. Android's side
gates in CI as a FILTERED task; a plain `:app:testDebugUnitTest` would drag the ~20 Roborazzi
screenshot scenes into every push, and those are a release-artifact job.

**WP8.1 — a pad route to the stats overlay.** The tier could only be cycled by a three-finger tap,
which does not exist on a TV, on a gamepad-only session, or under touch passthrough — while the
settings row promised a live cycle. `Select + X` now cycles it, byte-identical to the Apple
client's `GamepadWire.back | GamepadWire.x`, implemented as the mic chord's twin in `GamepadRouter`
and edge-triggered on the button that completes the mask. The buttons still reach the game, as both
existing chords do. `GamepadChordTest` pins eight cases the kit had no cover for at all, including
that the three chords intersect only on Select and that none is reachable through another.

**WP8.5 — a start-of-stream banner.** The desktop's `skia_overlay` banner, ported with its timing
(opaque 5.4 s, then a 0.6 s fade) and its rule of naming only shortcuts that exist: pad chords when
a pad is present, the touch gesture when there is a touchscreen and the mode can use it. Nothing
`Ctrl+Alt+Shift` is advertised, because Android has none of it. It yields to the motion-unreachable
notice rather than stacking with it — that one reports something broken about *this* session.

**WP8.6 — the home card says which profile it connects with.** `HomeTile` carried a
`pinnedProfileId` the card never drew, so a pinned host+profile card was distinguishable from the
host's own only by a subtitle that had been quietly repurposed to hold the profile name. Both now
show the address like every other card and wear a tinted profile chip — the touch grid's own
convention and the Apple client's, inked from the console palette. Unsaved tiles (discovered, Add
Host) take a dashed edge, which is what the other two surfaces already use to say "not yours yet".

⚠ Not a detail panel: the Apple client REMOVED its own and moved the status onto the card, which is
where the lock and the online pip already were here.

**WP8.7 — accessibility, in part.** The console screens carried three `contentDescription`s and no
`semantics`, `Role` or `stateDescription` at all. A settings row now announces once, merged —
label, value, and the description that lives in the floating band far from it — with `Role.Switch`
and a real toggle state, because a toggle row's on/off string was drawn by nothing at all: the
switch replaces the value text, and the switch was two undescribed `Box`es. Decoration is silenced
rather than labelled (the chevrons were read aloud as punctuation on every focused row). The hint
bar's glyphs, the tab strip and the home tiles are done; `GamepadAddHostScreen` and `LibraryScreen`
are not yet.
This commit is contained in:
2026-08-10 19:20:40 +02:00
parent cac500acc7
commit 0b550dad84
19 changed files with 3090 additions and 28 deletions
+13
View File
@@ -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:
+4
View File
@@ -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")
@@ -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) },
),
)
@@ -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 (1t)³`, 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(
@@ -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,
)
}
}
@@ -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(
@@ -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 {
@@ -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:
@@ -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<Double> =
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 2832 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 (1t)³` 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,
)
}
}
}
@@ -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") {
@@ -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
@@ -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
@@ -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<Int> {
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<Int> = 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<Int>(), pad.press(Gamepad.BTN_BACK))
assertEquals(listOf(GamepadRouter.STATS_CHORD), pad.press(Gamepad.BTN_X))
assertEquals("auto-repeat re-fired the chord", emptyList<Int>(), pad.repeat(Gamepad.BTN_X))
assertEquals("a press on top re-fired the chord", emptyList<Int>(), pad.press(Gamepad.BTN_A))
assertEquals(emptyList<Int>(), 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<Int>(), pad.press(opening))
for (other in listOf(Gamepad.BTN_A, Gamepad.BTN_B, Gamepad.BTN_LB, Gamepad.BTN_DPAD_UP)) {
assertEquals(emptyList<Int>(), 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<Int>(), 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 <T> List<T>.permutations(): List<List<T>> =
if (size <= 1) listOf(this)
else flatMap { head -> (this - head).permutations().map { listOf(head) + it } }
}
}
@@ -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)")
}
}
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -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
+78
View File
@@ -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<f64> {
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() {
@@ -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<pf_client_core::gamepad::PadInfo>) {
(Settings::default(), Vec::new())
}
+40
View File
@@ -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 (1t)³`, 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() {