forked from unom/punktfunk
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:
@@ -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:
|
||||
|
||||
@@ -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 − (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(
|
||||
|
||||
@@ -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 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user