Merge pull request 'The Android console stops losing the pad, owns its Controllers page, and takes the whole phone panel' (#317) from worktree-phone-console-ui into main
ci / rust-arm64 (push) Failing after 1m7s
ci / rust (push) Failing after 3m30s
ci / bun-nix (push) Successful in 30s
ci / docs-site (push) Failing after 1m0s
ci / web (push) Successful in 1m20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish-gamescope (push) Successful in 51s
arch / build-publish (push) Successful in 10m22s
deb / build-publish-client-arm64 (push) Successful in 1m41s
android / android (push) Successful in 12m58s
deb / build-publish (push) Successful in 5m12s
deb / build-publish-host (push) Successful in 4m56s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 3m19s
flatpak / build-publish (push) Successful in 8m35s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m8s
deb / smoke-install (push) Successful in 3m31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m0s
ci / rust-arm64 (push) Failing after 1m7s
ci / rust (push) Failing after 3m30s
ci / bun-nix (push) Successful in 30s
ci / docs-site (push) Failing after 1m0s
ci / web (push) Successful in 1m20s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish-gamescope (push) Successful in 51s
arch / build-publish (push) Successful in 10m22s
deb / build-publish-client-arm64 (push) Successful in 1m41s
android / android (push) Successful in 12m58s
deb / build-publish (push) Successful in 5m12s
deb / build-publish-host (push) Successful in 4m56s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Failing after 3m19s
flatpak / build-publish (push) Successful in 8m35s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m8s
deb / smoke-install (push) Successful in 3m31s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m0s
Reviewed-on: #317
This commit was merged in pull request #317.
This commit is contained in:
@@ -94,7 +94,10 @@ fun App(forceGamepadUi: Boolean = false) {
|
||||
// `debug.punktfunk.console_backend=none` forces the touch UI for on-glass triage). Without a
|
||||
// console to draw, a controller drives the touch UI through Compose's own focus.
|
||||
val skiaConsole = remember { SkiaConsole.wanted() }
|
||||
val gamepadUi = skiaConsole && gamepadUiActive(
|
||||
// …AND it actually came up: a console whose native create failed or whose render thread died
|
||||
// ([SkiaConsole.healthy], observable) would front a SurfaceView nothing ever paints — a gray
|
||||
// screen with a working pad probe, which is worse than the touch UI it replaced.
|
||||
val gamepadUi = skiaConsole && SkiaConsole.healthy && gamepadUiActive(
|
||||
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package io.unom.punktfunk
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Build
|
||||
import android.os.CombinedVibration
|
||||
@@ -14,7 +13,6 @@ import android.view.MotionEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -49,11 +47,8 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.DsDevice
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.Sc2BleLink
|
||||
@@ -61,158 +56,34 @@ import io.unom.punktfunk.kit.Sc2Capture
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Connected-controllers debug view (Settings → Host → Connected controllers): everything the app
|
||||
* can see about attached input devices, plus a live input test. This exists for exactly the support
|
||||
* case where a pad "doesn't work" — adapters and BT-to-USB dongles often enumerate with a different
|
||||
* identity than the physical pad, or not as a gamepad at all, and punktfunk only forwards devices
|
||||
* Android classifies as gamepad/joystick. This screen makes that visible on the device itself.
|
||||
* Connected-controllers debug view (Settings -> Controller -> Connected controllers): everything
|
||||
* the app can see about attached input devices, plus a live input test. This exists for exactly
|
||||
* the support case where a pad "doesn't work" - adapters and BT-to-USB dongles often enumerate
|
||||
* with a different identity than the physical pad, or not as a gamepad at all, and punktfunk only
|
||||
* forwards devices Android classifies as gamepad/joystick. This screen makes that visible on the
|
||||
* device itself.
|
||||
*
|
||||
* This is the TOUCH entry point; [ConsoleControllersScreen] shows the same body on the console's
|
||||
* field. Both drive [ControllersBody] — the screen exists once, and the support answer it gives has
|
||||
* to be the same one whichever interface asked.
|
||||
* The TOUCH presentation, and since 2026-08 the only one: the console reaches the same answer
|
||||
* through its own Skia screen (`crates/pf-console-ui/src/screens/controllers.rs`), which keeps the
|
||||
* console's input on the page instead of suspending it behind a Compose takeover. What this screen
|
||||
* still owns alone is the live input test - the console receives only the aggregated navigation
|
||||
* sample, which is nowhere near a per-device axis/trigger readout. Everything the console DOES
|
||||
* need from here it asks for as a `ConsoleCmd::PadAction` (see [SkiaConsoleShell]), which is why
|
||||
* [padInfoOf] and [testRumble] are internal rather than private.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit, padsOverride: List<PadInfo>? = null) {
|
||||
BackHandler(onBack = onBack)
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
ControllersBody(
|
||||
gamepadSetting = gamepadSetting,
|
||||
scroll = rememberScrollState(),
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
padsOverride = padsOverride,
|
||||
// The touch screen holds the probes for its whole life: events are OBSERVED (not consumed)
|
||||
// while the test is off, which is what keeps the "Last input" line live while browsing.
|
||||
// Nothing else here wants the pad, so there is no one to hand them to.
|
||||
observeInput = true,
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp),
|
||||
) {
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same screen on the console's field — the couch route to it, which a TV box has no other way to
|
||||
* reach (there is no touch interface to fall back to there, which is exactly why this matters).
|
||||
*
|
||||
* Navigation, and how the pad is shared with the test:
|
||||
* * up/down scrolls, the shoulders page — the body is cards and prose with no focusable rows, and
|
||||
* Compose only scrolls to keep a FOCUSED child visible (see [rememberConsoleScroller]);
|
||||
* * A starts the input test, which is the one thing on this screen a controller can act on;
|
||||
* * while the test runs it OWNS the pad — that is the whole point of it — so this screen's nav
|
||||
* drops out of the probe slots and B is a HOLD (below). Everything reverts the moment it ends.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleControllersScreen(
|
||||
internal fun ControllersScreen(
|
||||
gamepadSetting: Int,
|
||||
onBack: () -> Unit,
|
||||
navActive: Boolean = true,
|
||||
padsOverride: List<PadInfo>? = null,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
val hazeState = remember { HazeState() }
|
||||
val scroll = rememberScrollState()
|
||||
val scrollBy = rememberConsoleScroller(scroll)
|
||||
// Events are OBSERVED (not consumed) while the test is off, which is what keeps the
|
||||
// "Last input" line live while browsing. Nothing else here wants the pad.
|
||||
var testing by remember { mutableStateOf(false) }
|
||||
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
|
||||
|
||||
GamepadNavEffect2D(
|
||||
// Off while the test runs: both want the same single probe slot, and the test is the one
|
||||
// the user just asked for. The identity check in each teardown (here and in the body) is
|
||||
// what makes the handover safe in either direction.
|
||||
active = navActive && !testing,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> scrollBy(-1, false)
|
||||
NavDir.DOWN -> scrollBy(1, false)
|
||||
// Nothing on this screen steps sideways; paging is the shoulders' job.
|
||||
NavDir.LEFT, NavDir.RIGHT -> {}
|
||||
}
|
||||
},
|
||||
onActivate = { testing = true },
|
||||
onShoulder = { delta -> scrollBy(delta, true) },
|
||||
)
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||
// The calm backdrop, full-bleed under the bars and the cutout: this is a screen to READ,
|
||||
// and the aurora is ambience. Only the content takes the safe area.
|
||||
GamepadFormBackground(Modifier.fillMaxSize())
|
||||
// The body is written against the touch theme; on the console field it has to be inked
|
||||
// from the palette or it is grey-on-pastel over the six pale palettes.
|
||||
ConsoleInkedTheme {
|
||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
||||
ControllersBody(
|
||||
gamepadSetting = gamepadSetting,
|
||||
scroll = scroll,
|
||||
testing = testing,
|
||||
onTestingChange = { testing = it },
|
||||
padsOverride = padsOverride,
|
||||
// Only while testing: the rest of the time the screen's own nav holds the
|
||||
// probes, so the "Last input" line is a test-time readout here rather than
|
||||
// an always-on one. A pad that reaches this screen at all has already
|
||||
// proved it is seen — by moving the cursor here.
|
||||
observeInput = testing,
|
||||
contentPadding = PaddingValues(
|
||||
start = ConsoleEdgeInset,
|
||||
end = ConsoleEdgeInset,
|
||||
// Clears the floating legend zone, like every other console list.
|
||||
bottom = ConsoleLegendClearance,
|
||||
),
|
||||
) {
|
||||
ConsoleHeader("Connected controllers", horizontalInset = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.consoleLegendInsets(landscape)
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
GamepadHintBar(
|
||||
if (testing) {
|
||||
// The rule, stated at the moment it applies: while the test runs, B is a BUTTON
|
||||
// UNDER TEST like any other — it lights its own chip — so only a hold ends the
|
||||
// test, after which B is the universal Back again. Tappable as the touch hatch.
|
||||
listOf(PadGlyph.hint('B', "Hold to finish") { testing = false })
|
||||
} else {
|
||||
listOfNotNull(
|
||||
GamepadHint('↕', PadGlyph.Arrow, "Scroll"),
|
||||
// Advertised only where they exist — a TV remote has no shoulders, and
|
||||
// claiming otherwise is both a lie and the reason a narrow legend overflows.
|
||||
GamepadHint('⇄', PadGlyph.Arrow, "Page").takeIf { padIsGamepad },
|
||||
PadGlyph.hint('A', "Test inputs") { testing = true },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen itself, shared by both interfaces. [contentPadding] and [heading] are where they
|
||||
* differ: the touch screen pads for a thumb and titles with the Material headline, the console pads
|
||||
* to the shared edge inset, clears its floating legend, and titles with [ConsoleHeader].
|
||||
*
|
||||
* [observeInput] decides whether this body installs the shared MainActivity probes at all — see the
|
||||
* two call sites, and [ConsoleControllersScreen] for why they cannot both be on at once.
|
||||
*/
|
||||
@Composable
|
||||
private fun ControllersBody(
|
||||
gamepadSetting: Int,
|
||||
scroll: ScrollState,
|
||||
testing: Boolean,
|
||||
onTestingChange: (Boolean) -> Unit,
|
||||
observeInput: Boolean,
|
||||
contentPadding: PaddingValues,
|
||||
padsOverride: List<PadInfo>? = null,
|
||||
heading: @Composable () -> Unit,
|
||||
) {
|
||||
val onTestingChange: (Boolean) -> Unit = { testing = it }
|
||||
val contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp)
|
||||
val context = LocalContext.current
|
||||
val activity = context as? MainActivity
|
||||
|
||||
@@ -247,19 +118,19 @@ private fun ControllersBody(
|
||||
var bHeld by remember { mutableStateOf(false) }
|
||||
// The hold has lasted long enough; the test ends when B is let go (see the probe).
|
||||
var holdSatisfied by remember { mutableStateOf(false) }
|
||||
// The probes below are built ONCE per `observeInput` and then read these for the life of that
|
||||
// installation. `testing` and the callback arrive as parameters now, so capturing them plainly
|
||||
// would freeze the values they had when the probe was made — the test would consume nothing.
|
||||
// The probes below are built ONCE and then read these for the life of the screen, so
|
||||
// capturing `testing` plainly would freeze the value it had when the probe was made — the
|
||||
// test would consume nothing.
|
||||
val consuming by rememberUpdatedState(testing)
|
||||
// The console's refusal thud, on whatever actuator the driving pad or this device has.
|
||||
val haptics by rememberUpdatedState(rememberConsoleHaptics())
|
||||
|
||||
DisposableEffect(observeInput) {
|
||||
// Stable probe refs, and a teardown that releases the slot only if WE still hold it — the
|
||||
// rule GamepadNavEffect2D follows. Without it this screen's dispose nulls whatever is in the
|
||||
// slot: during the console shell's push/pop BOTH screens are briefly composed, so leaving
|
||||
// here would kill the pad navigation the arriving screen had just installed. The same
|
||||
// teardown also runs when this screen hands the pad to its own input test and back.
|
||||
DisposableEffect(Unit) {
|
||||
// One entry on the MainActivity probe stack, removed by identity on the way out — the rule
|
||||
// GamepadNavEffect2D follows. During the console shell's push/pop BOTH screens are briefly
|
||||
// composed, and only the identity removal keeps this screen's teardown from taking the
|
||||
// arriving screen's claim with it. The same teardown also runs when this screen hands the
|
||||
// pad to its own input test and back.
|
||||
val keyProbe: (KeyEvent) -> Boolean = probe@{ event ->
|
||||
if (!Gamepad.isPad(event.device)) return@probe false
|
||||
// Read ONCE, up front: the test can end inside this very event, and the release that
|
||||
@@ -317,16 +188,9 @@ private fun ControllersBody(
|
||||
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||
consuming
|
||||
}
|
||||
if (observeInput) {
|
||||
activity?.padKeyProbe = keyProbe
|
||||
activity?.padMotionProbe = motionProbe
|
||||
}
|
||||
onDispose {
|
||||
activity?.let { a ->
|
||||
if (a.padKeyProbe === keyProbe) a.padKeyProbe = null
|
||||
if (a.padMotionProbe === motionProbe) a.padMotionProbe = null
|
||||
}
|
||||
}
|
||||
val probes = MainActivity.PadProbes(keyProbe, motionProbe)
|
||||
activity?.pushPadProbes(probes)
|
||||
onDispose { activity?.removePadProbes(probes) }
|
||||
}
|
||||
// Hold-B-to-exit: with events consumed, the pad can't reach the Switch — a 1.2 s hold ends the
|
||||
// test instead (touch still works). This half only ANSWERS the hold once it is long enough; the
|
||||
@@ -351,7 +215,7 @@ private fun ControllersBody(
|
||||
.padding(contentPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
heading()
|
||||
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
// Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
|
||||
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
|
||||
@@ -942,7 +806,8 @@ private fun deviceHasVibrator(dev: InputDevice): Boolean =
|
||||
dev.vibrator.hasVibrator()
|
||||
}
|
||||
|
||||
private fun testRumble(dev: InputDevice) {
|
||||
/** A short pulse on the pad's own motor. Also the console's `PadAction::Rumble`. */
|
||||
internal fun testRumble(dev: InputDevice) {
|
||||
runCatching {
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val vm = dev.vibratorManager
|
||||
|
||||
@@ -82,8 +82,9 @@ fun GamepadNavEffect(
|
||||
val currentOnOptions by rememberUpdatedState(onOptions)
|
||||
|
||||
DisposableEffect(active) {
|
||||
// Stable probe refs (see GamepadNavEffect2D) so onDispose only releases the slot if we still
|
||||
// own it — a cross-fading-out screen mustn't null the incoming screen's probes.
|
||||
// One entry on the MainActivity probe stack (see GamepadNavEffect2D), removed by identity on
|
||||
// dispose — a cross-fading-out screen must take only its OWN claim, never the incoming
|
||||
// screen's, and never the console shell's underneath.
|
||||
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
|
||||
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
|
||||
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
|
||||
@@ -113,13 +114,10 @@ fun GamepadNavEffect(
|
||||
else -> false // B / shoulders / etc. → MainActivity handles (B remaps to BACK)
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
activity.padMotionProbe = motionProbe
|
||||
activity.padKeyProbe = keyProbe
|
||||
}
|
||||
val probes = if (active) MainActivity.PadProbes(keyProbe, motionProbe) else null
|
||||
probes?.let { activity.pushPadProbes(it) }
|
||||
onDispose {
|
||||
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
|
||||
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
|
||||
probes?.let { activity.removePadProbes(it) }
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
@@ -186,9 +184,11 @@ fun GamepadNavEffect2D(
|
||||
val currentOnShoulder by rememberUpdatedState(onShoulder)
|
||||
|
||||
DisposableEffect(active) {
|
||||
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
|
||||
// One entry on the MainActivity probe stack, removed by identity on dispose — during a
|
||||
// cross-fade both the outgoing and incoming screen are briefly composed, and the outgoing's
|
||||
// teardown must not null out the incoming screen's just-installed probes.
|
||||
// teardown must take only its own claim. On the console this effect sits OVER the Skia
|
||||
// shell's probes: pushing (not overwriting) is what lets the shell's pad input resurface
|
||||
// the moment this screen pops, instead of dying with a nulled slot.
|
||||
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
|
||||
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
|
||||
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
|
||||
@@ -220,13 +220,10 @@ fun GamepadNavEffect2D(
|
||||
else -> false // B → MainActivity (remapped to BACK → BackHandler)
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
activity.padMotionProbe = motionProbe
|
||||
activity.padKeyProbe = keyProbe
|
||||
}
|
||||
val probes = if (active) MainActivity.PadProbes(keyProbe, motionProbe) else null
|
||||
probes?.let { activity.pushPadProbes(it) }
|
||||
onDispose {
|
||||
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
|
||||
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
|
||||
probes?.let { activity.removePadProbes(it) }
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,12 +109,29 @@ class MainActivity : ComponentActivity() {
|
||||
var gamepadRouter: GamepadRouter? = null
|
||||
|
||||
/**
|
||||
* Input observers for the Controllers debug screen (set while it is shown, like [streamHandle]).
|
||||
* Called for every key/motion event while not streaming; a `true` return consumes the event —
|
||||
* the screen's "test inputs" mode uses that to keep pad input from also driving focus navigation.
|
||||
* One screen's claim on the pad while not streaming: its key/motion observers, consulted for
|
||||
* every event before the focus-navigation fallbacks below; a `true` return consumes the event.
|
||||
* Holders are the Skia console shell, [GamepadNavEffect2D] on the Compose screens the console
|
||||
* opens over itself, and the Controllers screen's input test.
|
||||
*/
|
||||
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
|
||||
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
|
||||
class PadProbes(val key: (KeyEvent) -> Boolean, val motion: (MotionEvent) -> Boolean)
|
||||
|
||||
/**
|
||||
* The pad-probe claims, a STACK — only the top entry sees events. A single last-writer-wins
|
||||
* slot is how the console shell used to lose the pad for good: a screen composed over it
|
||||
* (Controllers/Licenses) overwrote the slot, then nulled it on its way out, and the shell —
|
||||
* whose install effect had no reason to re-run — never got it back. Pushing on install and
|
||||
* removing BY IDENTITY on dispose survives every ordering Compose produces (cross-fades
|
||||
* compose both screens at once, and dispose is not always LIFO): whatever leaves takes only
|
||||
* its own entry, and whatever is left on top resumes seeing the pad.
|
||||
*/
|
||||
private val padProbes = mutableListOf<PadProbes>()
|
||||
|
||||
fun pushPadProbes(p: PadProbes) { padProbes += p }
|
||||
fun removePadProbes(p: PadProbes) { padProbes.remove(p) }
|
||||
|
||||
private val padKeyProbe: ((KeyEvent) -> Boolean)? get() = padProbes.lastOrNull()?.key
|
||||
private val padMotionProbe: ((MotionEvent) -> Boolean)? get() = padProbes.lastOrNull()?.motion
|
||||
|
||||
/**
|
||||
* Physical-mouse forwarder for the active session (built/released by StreamScreen, like
|
||||
|
||||
@@ -11,6 +11,7 @@ import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
|
||||
import io.unom.punktfunk.kit.library.GameEntry
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.padInfoOf
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
@@ -229,16 +230,25 @@ internal object ConsoleJson {
|
||||
|
||||
/**
|
||||
* `{"label", "pref", "pads": [...]}` — the controller chip's text (the driving pad's name),
|
||||
* the glyph style's pref byte, and one entry per connected pad for the settings rows.
|
||||
* the glyph style's pref byte, and one entry per connected pad for the settings rows and the
|
||||
* console's Connected-controllers screen.
|
||||
*
|
||||
* `detail`/`forwarded`/`rumble` come straight from [padInfoOf], the same reader the touch
|
||||
* Controllers screen renders from: the support answer a user gets must not depend on which
|
||||
* interface asked, and two readers of `InputDevice` would be two answers waiting to drift.
|
||||
*/
|
||||
fun pads(pads: List<InputDevice>, driving: InputDevice?): String {
|
||||
val arr = JSONArray()
|
||||
for (d in pads) {
|
||||
val info = padInfoOf(d)
|
||||
val entry = JSONObject()
|
||||
.put("name", d.name)
|
||||
.put("key", "${d.vendorId}:${d.productId}:${d.name}")
|
||||
.put("pref", Gamepad.prefFor(d))
|
||||
.put("steam_virtual", false)
|
||||
.put("detail", info.detail)
|
||||
.put("forwarded", info.forwarded)
|
||||
.put("rumble", info.canRumble)
|
||||
val battery = if (android.os.Build.VERSION.SDK_INT >= 31) {
|
||||
val b = d.batteryState
|
||||
if (b.isPresent && b.capacity >= 0f) {
|
||||
|
||||
@@ -8,6 +8,9 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.unom.punktfunk.CONNECT_TIMEOUT_MS
|
||||
import io.unom.punktfunk.ConnectErrors
|
||||
import io.unom.punktfunk.ProfileStore
|
||||
@@ -67,6 +70,17 @@ object SkiaConsole {
|
||||
private const val PREFS = "punktfunk_console_settings"
|
||||
|
||||
private var handle = 0L
|
||||
|
||||
/**
|
||||
* False once the console has proven it cannot draw — the native create failed, or the render
|
||||
* thread died (a GL context that never came up, or one Android reclaimed and that would not
|
||||
* come back). Compose observes it: `App` folds it into the gamepad-UI gate, so the answer to a
|
||||
* dead console is the touch UI — not the gray, never-painted `SurfaceView` the shell would
|
||||
* otherwise sit on for the rest of the process.
|
||||
*/
|
||||
var healthy by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
private var appContext: Context? = null
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val ioPool = Executors.newCachedThreadPool { r -> Thread(r, "pf-console-io").apply { isDaemon = true } }
|
||||
@@ -90,6 +104,7 @@ object SkiaConsole {
|
||||
private var onSettingsChange: ((Settings) -> Unit)? = null
|
||||
private var onQuit: (() -> Unit)? = null
|
||||
private var onPlatformScreen: ((String) -> Unit)? = null
|
||||
private var onPadAction: ((String, String) -> Unit)? = null
|
||||
private var onPulse: ((String) -> Unit)? = null
|
||||
|
||||
/** The connect in flight, if any — cancelable through `OverlayAction::CancelConnect`. */
|
||||
@@ -149,6 +164,7 @@ object SkiaConsole {
|
||||
handle = runCatching { NativeBridge.nativeConsoleCreate(opts.toString()) }.getOrDefault(0L)
|
||||
if (handle == 0L) {
|
||||
Log.e(TAG, "console: native create failed")
|
||||
healthy = false // see [healthy] — the touch UI fronts everything from here
|
||||
return 0L
|
||||
}
|
||||
Log.i(TAG, "console: created (gpu cache ${gpuCacheBytes(app) shr 20} MB)")
|
||||
@@ -236,12 +252,14 @@ object SkiaConsole {
|
||||
onSettingsChange: (Settings) -> Unit,
|
||||
onQuit: () -> Unit,
|
||||
onPlatformScreen: (String) -> Unit,
|
||||
onPadAction: (String, String) -> Unit,
|
||||
onPulse: (String) -> Unit,
|
||||
) {
|
||||
this.onConnected = onConnected
|
||||
this.onSettingsChange = onSettingsChange
|
||||
this.onQuit = onQuit
|
||||
this.onPlatformScreen = onPlatformScreen
|
||||
this.onPadAction = onPadAction
|
||||
this.onPulse = onPulse
|
||||
discovery?.restart()
|
||||
// The touch UI may have paired/forgotten/edited hosts or profiles while we were away.
|
||||
@@ -255,6 +273,7 @@ object SkiaConsole {
|
||||
onSettingsChange = null
|
||||
onQuit = null
|
||||
onPlatformScreen = null
|
||||
onPadAction = null
|
||||
onPulse = null
|
||||
}
|
||||
|
||||
@@ -373,7 +392,7 @@ object SkiaConsole {
|
||||
NativeBridge.nativeConsoleSetKnownHosts(handle, ConsoleJson.knownHosts(knownHostStore.all()))
|
||||
}
|
||||
|
||||
private fun notice(text: String) {
|
||||
internal fun notice(text: String) {
|
||||
if (handle != 0L) NativeBridge.nativeConsoleNotice(handle, text)
|
||||
}
|
||||
|
||||
@@ -386,7 +405,10 @@ object SkiaConsole {
|
||||
ev.has("editing") -> {} // the shell draws its own keyboard; nothing to raise here
|
||||
ev.has("settings") -> onSettingsSaved(ev.getJSONObject("settings"))
|
||||
ev.has("gles") -> Log.i(TAG, "console: GLES ${ev.optInt("gles")}")
|
||||
ev.has("dead") -> Log.e(TAG, "console: render thread died: ${ev.optString("dead")}")
|
||||
ev.has("dead") -> {
|
||||
Log.e(TAG, "console: render thread died: ${ev.optString("dead")}")
|
||||
healthy = false // the touch UI takes over; only a process restart tries again
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,6 +543,7 @@ object SkiaConsole {
|
||||
c.optJSONObject("Wake")?.let(::wake)
|
||||
c.optJSONObject("SetPin")?.let(::setPin)
|
||||
c.optJSONObject("OpenPlatformScreen")?.let { onPlatformScreen?.invoke(it.optString("id")) }
|
||||
c.optJSONObject("PadAction")?.let { onPadAction?.invoke(it.optString("action"), it.optString("pad_key")) }
|
||||
c.optString("OpenPlatformScreen").takeIf { c.has("OpenPlatformScreen") && c.opt("OpenPlatformScreen") is String }
|
||||
?.let { onPlatformScreen?.invoke(it) }
|
||||
}
|
||||
|
||||
+132
-16
@@ -1,5 +1,9 @@
|
||||
package io.unom.punktfunk.console
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
@@ -26,15 +30,23 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import io.unom.punktfunk.ConsoleControllersScreen
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import io.unom.punktfunk.ConsoleLicensesScreen
|
||||
import io.unom.punktfunk.DS_USB_PERMISSION_ACTION
|
||||
import io.unom.punktfunk.MainActivity
|
||||
import io.unom.punktfunk.Settings
|
||||
import io.unom.punktfunk.SettingsStore
|
||||
import io.unom.punktfunk.kit.DsDevice
|
||||
import io.unom.punktfunk.kit.Gamepad
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.models.ActiveSession
|
||||
import io.unom.punktfunk.models.LibraryReturn
|
||||
import io.unom.punktfunk.kit.Sc2BleLink
|
||||
import io.unom.punktfunk.rememberConsoleHaptics
|
||||
import io.unom.punktfunk.testRumble
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -44,9 +56,9 @@ import kotlin.math.roundToInt
|
||||
*
|
||||
* What lives here is only what needs a composition: the surface lifecycle, the safe-area insets,
|
||||
* the pad probes (raw pad → the shared menu synthesizer, over JNI), the system Back, the
|
||||
* platform-native sub-screens the console can open (Controllers, Licences — Compose, drawn over the
|
||||
* surface), and the two intents the app hands over on the way in (a deep link, "come back to this
|
||||
* shelf").
|
||||
* platform-native sub-screen the console can open (Licences — Compose, drawn over the surface;
|
||||
* Connected controllers is the console's own Skia screen now), and the two intents the app hands
|
||||
* over on the way in (a deep link, "come back to this shelf").
|
||||
*/
|
||||
@Composable
|
||||
fun SkiaConsoleShell(
|
||||
@@ -74,6 +86,7 @@ fun SkiaConsoleShell(
|
||||
onSettingsChange = { currentOnSettingsChange(it) },
|
||||
onQuit = { activity?.moveTaskToBack(true) },
|
||||
onPlatformScreen = { platformScreen = it },
|
||||
onPadAction = { action, key -> padAction(activity, action, key) },
|
||||
onPulse = { pulse ->
|
||||
when (pulse) {
|
||||
"move" -> haptics.tick()
|
||||
@@ -102,8 +115,24 @@ fun SkiaConsoleShell(
|
||||
SkiaConsole.handleDeepLink(url)
|
||||
}
|
||||
|
||||
// The console owns the whole panel while it fronts the app, exactly like the stream: the
|
||||
// status bar and the gesture bar are hidden (a swipe shows them transiently), restored on the
|
||||
// way out. This is both the space win AND the safe-area fix — hidden bars report zero insets,
|
||||
// so the scroll clips that used to end at the visible gesture-bar line (scrolled rows sliced
|
||||
// off mid-air with bare backdrop below) now run to the panel edge. Only the display cutout
|
||||
// stays a real inset.
|
||||
DisposableEffect(activity) {
|
||||
val window = activity?.window ?: return@DisposableEffect onDispose {}
|
||||
val controller = WindowCompat.getInsetsController(window, window.decorView)
|
||||
controller.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
onDispose { controller.show(WindowInsetsCompat.Type.systemBars()) }
|
||||
}
|
||||
|
||||
// The safe area, in surface pixels: system bars ∪ display cutout — the NP3's landscape punch
|
||||
// is a SIDE inset, and the console's chrome must stay clear of it (its backdrop need not).
|
||||
// With the bars hidden above, this is normally just the cutout.
|
||||
val density = LocalDensity.current
|
||||
val ld = LocalLayoutDirection.current
|
||||
val insets = WindowInsets.systemBars.union(WindowInsets.displayCutout)
|
||||
@@ -115,12 +144,14 @@ fun SkiaConsoleShell(
|
||||
// the same 800-unit field as a Deck); a phone or tablet in the hand gets a density FLOOR
|
||||
// under that formula, so type never shrinks below what the touch UI draws at the same
|
||||
// density (design D5 — a bare height/800 on a 460 dpi phone lands ~26 % smaller than a Deck).
|
||||
// The 0.6 is the on-glass tuning knob.
|
||||
// The 0.75 is the on-glass tuning knob — raised from 0.6 after a 460 dpi phone (Nothing
|
||||
// Phone) still read a step too small in the hand: the floor is what sets the phone scale
|
||||
// (the couch term only wins on tablets and TVs), so this is a phones-only bump.
|
||||
val tv = remember { io.unom.punktfunk.isTvDevice(context) }
|
||||
val scale = if (tv) 0f else {
|
||||
val dm = context.resources.displayMetrics
|
||||
val couch = minOf(dm.widthPixels, dm.heightPixels) / 800f
|
||||
maxOf(couch, density.density * 0.6f).coerceIn(0.75f, 3f)
|
||||
maxOf(couch, density.density * 0.75f).coerceIn(0.75f, 3f)
|
||||
}
|
||||
LaunchedEffect(handle, left, top, right, bottom, scale) {
|
||||
if (handle != 0L) NativeBridge.nativeConsoleSetViewport(handle, left, top, right, bottom, scale)
|
||||
@@ -228,13 +259,13 @@ fun SkiaConsoleShell(
|
||||
padState.push(handle)
|
||||
true
|
||||
}
|
||||
activity.padKeyProbe = keyProbe
|
||||
activity.padMotionProbe = motionProbe
|
||||
val probes = MainActivity.PadProbes(keyProbe, motionProbe)
|
||||
activity.pushPadProbes(probes)
|
||||
SkiaConsole.padsChanged(Gamepad.firstPad())
|
||||
onDispose {
|
||||
// Only clear what is still ours: a screen composed after us must not lose its probes.
|
||||
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
|
||||
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
|
||||
// Remove OUR claim only — a platform screen pushed over us keeps its own, and when it
|
||||
// pops, this one resurfaces (the stack is what fixed the pad dying after Controllers).
|
||||
activity.removePadProbes(probes)
|
||||
padState.reset()
|
||||
if (handle != 0L) padState.push(handle)
|
||||
}
|
||||
@@ -293,16 +324,101 @@ fun SkiaConsoleShell(
|
||||
},
|
||||
)
|
||||
when (platformScreen) {
|
||||
"controllers" -> ConsoleControllersScreen(
|
||||
gamepadSetting = settings.gamepad,
|
||||
onBack = { platformScreen = null },
|
||||
navActive = true,
|
||||
)
|
||||
"licenses" -> ConsoleLicensesScreen(onBack = { platformScreen = null }, navActive = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `ConsoleCmd::PadAction` from the console's Connected-controllers screen — the handful of
|
||||
* things only the platform can do: a rumble pulse on the real [InputDevice], the USB/Bluetooth
|
||||
* grant dialogs, the DualSense pad-audio self test. The touch Controllers screen keeps its own
|
||||
* buttons for the same actions; both routes end in the same helpers ([testRumble], the grant
|
||||
* intents, `nativePadAudioSelfTest`), so the support answer cannot drift between interfaces.
|
||||
* Runs on the main thread (the command drain lives there); results ride [SkiaConsole.notice].
|
||||
*/
|
||||
private fun padAction(activity: MainActivity?, action: String, padKey: String) {
|
||||
if (activity == null) return
|
||||
val settings = SettingsStore(activity).load()
|
||||
val usb = activity.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
when (action) {
|
||||
"rumble" ->
|
||||
Gamepad.pads()
|
||||
.firstOrNull { "${it.vendorId}:${it.productId}:${it.name}" == padKey }
|
||||
?.let(::testRumble)
|
||||
"sc2_bluetooth" -> when {
|
||||
!settings.sc2Capture ->
|
||||
SkiaConsole.notice("Enable \"Steam Controller 2 passthrough\" in Settings first.")
|
||||
Sc2BleLink.permissionGranted(activity) ->
|
||||
SkiaConsole.notice("Bluetooth access is already granted.")
|
||||
// The system dialog pauses the activity; onResume re-probes and engages the capture,
|
||||
// the same way the menu-time auto-ask completes.
|
||||
else -> Sc2BleLink.CONNECT_PERMISSION?.let {
|
||||
ActivityCompat.requestPermissions(activity, arrayOf(it), 5)
|
||||
}
|
||||
}
|
||||
"sc2_usb" ->
|
||||
if (!settings.sc2Capture) {
|
||||
SkiaConsole.notice("Enable \"Steam Controller 2 passthrough\" in Settings first.")
|
||||
} else {
|
||||
// Asks for the USB grant when one is missing and engages the capture on it.
|
||||
activity.startSc2MenuNav(forceAsk = true)
|
||||
}
|
||||
"ds_usb" -> {
|
||||
val dev = usb.deviceList.values.firstOrNull {
|
||||
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
|
||||
}
|
||||
when {
|
||||
!settings.dsCapture ->
|
||||
SkiaConsole.notice(
|
||||
"Enable \"DualSense / DualShock passthrough (USB)\" in Settings first.",
|
||||
)
|
||||
dev == null -> SkiaConsole.notice("No wired DualSense or DualShock 4 detected.")
|
||||
usb.hasPermission(dev) -> SkiaConsole.notice("USB access is already granted.")
|
||||
else -> usb.requestPermission(
|
||||
dev,
|
||||
PendingIntent.getBroadcast(
|
||||
activity, 3, // requestCode 3 — shared with the touch card's button
|
||||
Intent(DS_USB_PERMISSION_ACTION).setPackage(activity.packageName),
|
||||
// MUTABLE: the USB stack appends the grant extras to this intent.
|
||||
PendingIntent.FLAG_MUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
"ds_haptics" -> {
|
||||
val dev = usb.deviceList.values.firstOrNull {
|
||||
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
|
||||
}
|
||||
when {
|
||||
dev == null -> SkiaConsole.notice("No wired DualSense detected.")
|
||||
DsDevice.modelFor(dev.productId) == DsDevice.Model.DUALSHOCK4 ->
|
||||
SkiaConsole.notice("The DualShock 4 has no haptics audio device.")
|
||||
!usb.hasPermission(dev) -> SkiaConsole.notice("Grant USB access first.")
|
||||
else -> Thread({
|
||||
// Its OWN connection: the renderer's descriptor must never be shared with
|
||||
// another transfer engine, and that applies to this test as much as to the
|
||||
// real path (same rule as the touch card's test).
|
||||
val conn = runCatching { usb.openDevice(dev) }.getOrNull()
|
||||
val fd = conn?.fileDescriptor ?: -1
|
||||
val r = if (fd >= 0) NativeBridge.nativePadAudioSelfTest(fd, 3, 60) else -1
|
||||
conn?.close()
|
||||
SkiaConsole.notice(
|
||||
when {
|
||||
r > 0 -> "Haptics test passed — $r frames to the pad."
|
||||
r == -1 ->
|
||||
"Could not open the pad's audio interface. Some kernels " +
|
||||
"refuse it; the pad still works normally."
|
||||
r == -2 -> "The audio stream stopped part-way."
|
||||
else -> "The stream opened but no audio reached the pad."
|
||||
},
|
||||
)
|
||||
}, "pf-pad-selftest-console").start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The raw pad as one `MenuSample`, pushed whenever any part of it changes. */
|
||||
private class PadState {
|
||||
var deviceId = -1
|
||||
|
||||
+3
-16
@@ -127,9 +127,9 @@ class ScreenshotTest {
|
||||
WakeTimedOutScene()
|
||||
}
|
||||
|
||||
// The two screens the console reached for the first time in WP8.3. Each is shot on a dark AND a
|
||||
// pale palette, because the console draws them through a ColorScheme derived from the palette's
|
||||
// ink — and the pale one is the only place a grey-on-pastel slip can show up.
|
||||
// The licences view — the one screen the console still opens as a Compose takeover. Shot on a
|
||||
// dark AND a pale palette, because the console draws it through a ColorScheme derived from the
|
||||
// palette's ink — and the pale one is the only place a grey-on-pastel slip can show up.
|
||||
@Test
|
||||
fun consoleLicenses() = shootRoot("console-licenses", statusBar = false) { ConsoleLicensesScene() }
|
||||
|
||||
@@ -137,9 +137,6 @@ class ScreenshotTest {
|
||||
fun consoleLicensesLight() =
|
||||
shootRoot("console-licenses-light", statusBar = false) { ConsoleLicensesScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers", statusBar = false) { ConsoleControllersScene() }
|
||||
|
||||
/**
|
||||
* The touch presentation, pads connected — landscape, like every store frame: the app is
|
||||
* built for horizontal use, and a portrait capture shows a layout nobody streams in.
|
||||
@@ -148,12 +145,6 @@ class ScreenshotTest {
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun controllers() = shootRoot("controllers") { ControllersScene() }
|
||||
|
||||
/** The console presentation at the same landscape geometry — the store's FEEL THE GAME frame. */
|
||||
@Test
|
||||
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
|
||||
fun consoleControllersLandscape() =
|
||||
shootRoot("console-controllers-landscape", statusBar = false) { ConsoleControllersScene() }
|
||||
|
||||
/**
|
||||
* The same shelf as the TOUCH grid — the presentation a finger gets from a host card's
|
||||
* "Browse library…". Portrait (the default qualifiers), because that is the orientation a
|
||||
@@ -162,10 +153,6 @@ class ScreenshotTest {
|
||||
@Test
|
||||
fun libraryTouch() = shootRoot("library-touch") { TouchLibraryScene() }
|
||||
|
||||
@Test
|
||||
fun consoleControllersLight() =
|
||||
shootRoot("console-controllers-light", statusBar = false) { ConsoleControllersScene(paletteId = "holo") }
|
||||
|
||||
@Test
|
||||
fun trust() = shootScreen("trust") {
|
||||
HostsScene()
|
||||
|
||||
@@ -62,7 +62,6 @@ import coil.test.FakeImageLoaderEngine
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.AddHostSheet
|
||||
import io.unom.punktfunk.ConsoleControllersScreen
|
||||
import io.unom.punktfunk.ConsoleHeader
|
||||
import io.unom.punktfunk.ConsoleLegendInset
|
||||
import io.unom.punktfunk.ConsoleLicensesScreen
|
||||
@@ -559,36 +558,24 @@ private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The two screens the console could not reach at all until WP8.3 — the open-source notices and the
|
||||
* connected-controllers view — in their console presentation.
|
||||
* The one Compose screen the console still opens over itself — the open-source notices — in its
|
||||
* console presentation. (Connected controllers used to be its sibling here; it is the console's
|
||||
* own Skia screen now, covered by pf-console-ui's tests.)
|
||||
*
|
||||
* Worth a shot each, and worth a PALE one: both are ordinary Material screens underneath, and the
|
||||
* console shows them through a `ColorScheme` derived from the palette's ink. That derivation is the
|
||||
* whole risk. Their touch presentation is inked by the app theme, which is always dark, so nothing
|
||||
* Worth a shot, and worth a PALE one: it is an ordinary Material screen underneath, and the
|
||||
* console shows it through a `ColorScheme` derived from the palette's ink. That derivation is the
|
||||
* whole risk. Its touch presentation is inked by the app theme, which is always dark, so nothing
|
||||
* before this could catch light-grey body text stranded on a pastel field.
|
||||
*
|
||||
* Robolectric enumerates no input devices, so the controllers scenes inject [shotPads] — the
|
||||
* deterministic connected-pads state the store listing needs.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ConsoleLicensesScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) { ConsoleLicensesScreen(onBack = {}, navActive = false) }
|
||||
|
||||
@Composable
|
||||
internal fun ConsoleControllersScene(paletteId: String = "violet") =
|
||||
ConsolePalette(paletteId) {
|
||||
// Robolectric enumerates no input devices, so the shot injects the two pads the store
|
||||
// listing talks about — the empty "no controller detected" state proves the palette but
|
||||
// sells nothing.
|
||||
ConsoleControllersScreen(
|
||||
gamepadSetting = 0, onBack = {}, navActive = false, padsOverride = shotPads(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The touch presentation of the same screen, with the same injected pads. Wrapped in a background
|
||||
* [Surface]: the activity provides the dark ground in the app, and without one here the content
|
||||
* color falls back to black-on-white while the cards stay dark.
|
||||
* The controllers screen with [shotPads] injected — Robolectric enumerates no input devices, and
|
||||
* the connected-pad card is the point of the shot. Wrapped in a background [Surface]: the
|
||||
* activity provides the dark ground in the app, and without one here the content color falls
|
||||
* back to black-on-white while the cards stay dark.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ControllersScene() =
|
||||
|
||||
@@ -40,7 +40,4 @@ class TvScreenshotTest {
|
||||
@Test
|
||||
fun streamDetailed() =
|
||||
shootRoot("stream-detailed") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
|
||||
|
||||
@Test
|
||||
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
use super::egl::{EglContext, EglSurface, GlesVersion};
|
||||
use super::gpu::Gpu;
|
||||
use anyhow::Result;
|
||||
use anyhow::{bail, Result};
|
||||
use ndk::native_window::NativeWindow;
|
||||
use pf_client_core::console::{OverlayAction, PointerInput, SessionPhase};
|
||||
use pf_client_core::menu_nav::{MenuEvent, MenuNav, MenuPulse, MenuSample, PadInfo};
|
||||
@@ -267,6 +267,13 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
let mut was_editing = console.editing();
|
||||
let mut saved_gen = store.saved_gen();
|
||||
let mut menu_out: Vec<MenuEvent> = Vec::new();
|
||||
// Consecutive GL setup failures (window surface / Skia wrap). One is a transient (a window
|
||||
// torn down mid-create); a run of them is a context that is not coming back — most likely
|
||||
// reclaimed by Android while the app was backgrounded. Only exiting reports that: each
|
||||
// failure alone is logged, the loop retries, and the screen stays a gray never-painted
|
||||
// SurfaceView forever. Dying raises `Dead`, and Kotlin answers with the touch UI.
|
||||
let mut gl_failures = 0u32;
|
||||
const GL_FAILURE_LIMIT: u32 = 3;
|
||||
|
||||
loop {
|
||||
// Take everything queued. With no surface up, block until something arrives.
|
||||
@@ -347,11 +354,15 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
}
|
||||
surface = Some(s);
|
||||
window = Some(w);
|
||||
gl_failures = 0;
|
||||
// A fresh surface is a fresh entry: snapshot the pad so a button
|
||||
// still held from before does not fire into the first frame.
|
||||
nav.reset();
|
||||
}
|
||||
Err(e) => log::error!("console: window surface: {e:#}"),
|
||||
Err(e) => {
|
||||
log::error!("console: window surface: {e:#}");
|
||||
gl_failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Cmd::SurfaceChanged => {
|
||||
@@ -411,8 +422,14 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
if need_wrap {
|
||||
skia = None;
|
||||
match g.wrap_window(&egl, w, h) {
|
||||
Ok(surf) => skia = Some((surf, w, h)),
|
||||
Err(e) => log::error!("console: {e:#}"),
|
||||
Ok(surf) => {
|
||||
skia = Some((surf, w, h));
|
||||
gl_failures = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("console: {e:#}");
|
||||
gl_failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((surf, _, _)) = skia.as_mut() {
|
||||
@@ -441,6 +458,16 @@ fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotSto
|
||||
}
|
||||
}
|
||||
|
||||
if gl_failures >= GL_FAILURE_LIMIT {
|
||||
// Same release order as `Cmd::Quit`: the Skia surface, the current binding, then (on
|
||||
// return) the EGL surface + window + context drop.
|
||||
drop(skia.take());
|
||||
if surface.is_some() {
|
||||
egl.release_current();
|
||||
}
|
||||
bail!("GL surface failed {gl_failures} times in a row — giving the screen back");
|
||||
}
|
||||
|
||||
// Publish what the console raised.
|
||||
while let Some(a) = console.take_action() {
|
||||
shared.emit(HostEvent::Action(a));
|
||||
|
||||
@@ -83,6 +83,13 @@ struct PadJson {
|
||||
steam_virtual: bool,
|
||||
#[serde(default)]
|
||||
battery: Option<BatteryJson>,
|
||||
/// `VID:PID · gamepad · dpad` — what the controllers screen prints under the name.
|
||||
#[serde(default)]
|
||||
detail: String,
|
||||
#[serde(default)]
|
||||
forwarded: bool,
|
||||
#[serde(default)]
|
||||
rumble: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -454,8 +461,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleNavi
|
||||
}
|
||||
|
||||
/// `NativeBridge.nativeConsoleSetPads(handle, padsJson)` — the connected controllers for the
|
||||
/// chip + settings rows: `{"label": "DualSense", "pref": 1, "pads": [{name, key, pref,
|
||||
/// steam_virtual, battery: {percent, charging} | null}]}`.
|
||||
/// chip, the settings rows and the controllers screen: `{"label": "DualSense", "pref": 1,
|
||||
/// "pads": [{name, key, pref, steam_virtual, battery: {percent, charging} | null, detail,
|
||||
/// forwarded, rumble}]}`.
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetPads(
|
||||
mut env: EnvUnowned,
|
||||
@@ -479,6 +487,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetP
|
||||
percent: b.percent.min(100),
|
||||
charging: b.charging,
|
||||
}),
|
||||
detail: j.detail,
|
||||
forwarded: j.forwarded,
|
||||
rumble: j.rumble,
|
||||
})
|
||||
.collect();
|
||||
h.shared.send(Cmd::Pads {
|
||||
|
||||
@@ -667,9 +667,12 @@ impl ServiceState {
|
||||
r.request();
|
||||
}
|
||||
}
|
||||
// A platform-native screen (Android's Controllers/Licences views) — the desktop
|
||||
// shell has no such rows, so this never arrives here.
|
||||
// A platform-native screen (Android's Licences view) — the desktop shell has no
|
||||
// such row, so this never arrives here.
|
||||
ConsoleCmd::OpenPlatformScreen { .. } => {}
|
||||
// Grants and rumble tests from the controllers screen. Android-only for the same
|
||||
// reason: the settings row that opens that screen is not on the desktop's list.
|
||||
ConsoleCmd::PadAction { .. } => {}
|
||||
ConsoleCmd::SetPin {
|
||||
key,
|
||||
profile_id,
|
||||
|
||||
@@ -1075,6 +1075,14 @@ impl Worker {
|
||||
// Unknowable from an ID-based getter — SDL reports power only for an OPEN
|
||||
// device. `publish` fills it in for the one pad this service holds open.
|
||||
battery: None,
|
||||
// The three below feed the console's controllers screen, which is Android-only
|
||||
// (design android-skia-console-port.md D3) — nothing on the desktop reads them.
|
||||
// SDL enumerates only gamepad-classified devices, so the joystick-only case
|
||||
// `forwarded` exists to name cannot arise here; rumble, like battery, needs the
|
||||
// device OPEN and so is not knowable from this getter.
|
||||
detail: format!("{vid:04X}:{pid:04X}"),
|
||||
forwarded: true,
|
||||
rumble: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +235,20 @@ pub struct PadInfo {
|
||||
/// virtual gamepad reports nothing about the physical device behind it. Anything reading
|
||||
/// this must degrade to "no battery shown" rather than to "0 %".
|
||||
pub battery: Option<PadBattery>,
|
||||
/// The identity line the console's controllers screen shows under the name —
|
||||
/// `VID:PID · gamepad · dpad`. Support's first question when a pad "doesn't work" is
|
||||
/// whether the OS enumerated the pad or the adapter in front of it, and the name alone
|
||||
/// never answers that. Written by whoever enumerated the device; empty is "nothing more
|
||||
/// to say", never an error.
|
||||
pub detail: String,
|
||||
/// Actually forwarded to the host: a real, non-virtual controller the OS classifies as a
|
||||
/// GAMEPAD. A joystick-only node — an adapter that enumerates as a bare joystick, a
|
||||
/// DualSense's motion-sensor sibling — is listed and NOT forwarded, which is the single
|
||||
/// most common cause of "my pad is connected and nothing happens".
|
||||
pub forwarded: bool,
|
||||
/// The device reports a rumble motor. `false` is what turns the controllers screen's
|
||||
/// rumble test into the sentence explaining why host rumble will be silent on this pad.
|
||||
pub rumble: bool,
|
||||
}
|
||||
|
||||
/// A controller's power state, as SDL reports it.
|
||||
|
||||
@@ -226,6 +226,17 @@ pub enum ConsoleCmd {
|
||||
/// while it is up, and the console never learns what it looked like. The desktop raises
|
||||
/// none — its settings list has no such rows.
|
||||
OpenPlatformScreen { id: String },
|
||||
/// Something only the PLATFORM can do to a controller, raised by the controllers screen:
|
||||
/// Android's USB / Bluetooth grant dialogs, a rumble pulse on the real `InputDevice`, the
|
||||
/// DualSense pad-audio self test. `action` is a
|
||||
/// [`crate::screens::controllers::PadAction::id`]; `pad_key` addresses one of
|
||||
/// [`crate::screens::Ctx::pads`] and is empty for the actions that are about a device the
|
||||
/// pad list cannot name (an SC2 in lizard mode is no input device at all).
|
||||
///
|
||||
/// ONE parameterised command rather than one per button: the host's answer to every one
|
||||
/// of them is the same shape — do the platform thing, report back as a notice — and a
|
||||
/// command per grant would make adding the next pad a change in three crates.
|
||||
PadAction { action: String, pad_key: String },
|
||||
}
|
||||
|
||||
/// The overlay→binary command queue. A plain deque under the same locking discipline as
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
//! Which platform the shell fronts. One shell, two hosts (design
|
||||
//! android-skia-console-port.md D3/D7): the screens are the same everywhere, but not every
|
||||
//! settings row means something on every platform — a decoder picker is a desktop concept,
|
||||
//! low-latency decode an Android one — and only Android has native sub-screens (its
|
||||
//! Controllers and Licenses views) for the settings list to open. Everything platform-shaped
|
||||
//! is decided by asking this enum, so the row tables stay one union and no screen carries a
|
||||
//! `cfg`.
|
||||
//! low-latency decode an Android one — and only Android has a native sub-screen (its
|
||||
//! Licenses view) for the settings list to open. Everything platform-shaped is decided by
|
||||
//! asking this enum, so the row tables stay one union and no screen carries a `cfg`.
|
||||
|
||||
/// The host platform.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -20,9 +19,10 @@ pub enum Platform {
|
||||
/// its own input until the host says the screen closed.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PlatformScreen {
|
||||
/// Android's connected-controllers view (USB grant, rumble/haptics tests, DS capture).
|
||||
Controllers,
|
||||
/// The open-source licences view.
|
||||
/// The open-source licences view. The last one: Connected controllers used to be here
|
||||
/// too, and is a shared Skia screen now ([`crate::screens::controllers`]) — the console
|
||||
/// keeps its own input on that page, and only the grant dialogs it cannot draw go back
|
||||
/// to the host, as a [`crate::model::ConsoleCmd::PadAction`].
|
||||
Licenses,
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ impl PlatformScreen {
|
||||
/// The stable id the host matches on (crosses JNI as a string).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
PlatformScreen::Controllers => "controllers",
|
||||
PlatformScreen::Licenses => "licenses",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
pub(crate) mod add_host;
|
||||
pub(crate) mod collections;
|
||||
pub(crate) mod controllers;
|
||||
pub(crate) mod home;
|
||||
pub(crate) mod library;
|
||||
pub(crate) mod options;
|
||||
@@ -179,6 +180,10 @@ pub(crate) enum Screen {
|
||||
AddHost(add_host::AddHostScreen),
|
||||
Pair(pair::PairScreen),
|
||||
PinHosts(pin_hosts::PinHostsScreen),
|
||||
/// "Connected controllers": the attached pads and their identity lines, plus the grants
|
||||
/// and tests only the platform can perform. Android-reachable only — the settings row
|
||||
/// that opens it is in `settings::row_on`'s Android-only list.
|
||||
Controllers(controllers::ControllersScreen),
|
||||
/// The context menu: a subject and the actions that apply to it — a host's Wake / Copy
|
||||
/// link / Edit / Forget, a title's Copy link — raised by [`Outbox::options`]. It still
|
||||
/// carries the host menu's name because [`host_options`] does; both are one rename.
|
||||
@@ -200,6 +205,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.menu(ev, ctx, fx),
|
||||
Screen::Pair(s) => s.menu(ev, ctx, fx),
|
||||
Screen::PinHosts(s) => s.menu(ev, ctx, fx),
|
||||
Screen::Controllers(s) => s.menu(ev, ctx, fx),
|
||||
Screen::HostOptions(s) => s.menu(ev, ctx, fx),
|
||||
}
|
||||
}
|
||||
@@ -218,6 +224,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.pointer(p, ctx, fx),
|
||||
Screen::Pair(s) => s.pointer(p, ctx, fx),
|
||||
Screen::PinHosts(s) => s.pointer(p, ctx, fx),
|
||||
Screen::Controllers(s) => s.pointer(p, ctx, fx),
|
||||
Screen::HostOptions(s) => s.pointer(p, ctx, fx),
|
||||
}
|
||||
}
|
||||
@@ -267,6 +274,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.title(),
|
||||
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
|
||||
Screen::PinHosts(s) => format!("Pin \u{201c}{}\u{201d}", s.profile_name()),
|
||||
Screen::Controllers(_) => "Connected controllers".into(),
|
||||
Screen::HostOptions(s) => s.title(),
|
||||
}
|
||||
}
|
||||
@@ -280,6 +288,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.hints(ctx),
|
||||
Screen::Pair(s) => s.hints(ctx),
|
||||
Screen::PinHosts(s) => s.hints(ctx),
|
||||
Screen::Controllers(s) => s.hints(ctx),
|
||||
Screen::HostOptions(s) => s.hints(ctx),
|
||||
}
|
||||
}
|
||||
@@ -304,6 +313,7 @@ impl Screen {
|
||||
Screen::AddHost(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::Pair(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::PinHosts(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::Controllers(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
Screen::HostOptions(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! "Connected controllers" — everything the client can see about the attached pads, and the
|
||||
//! handful of actions only the platform can perform on them. Reached from the settings
|
||||
//! list's Controller tab.
|
||||
//!
|
||||
//! This exists for exactly one support case: a pad "doesn't work". Adapters and BT-to-USB
|
||||
//! dongles often enumerate with a different identity than the physical pad, or not as a
|
||||
//! gamepad at all, and only devices the OS classifies as a gamepad are forwarded — so the
|
||||
//! screen's real content is the identity line under each name, not the name.
|
||||
//!
|
||||
//! It was a Compose screen the Android host drew OVER the console (the D7 platform-screen
|
||||
//! mechanism) until 2026-08. Drawing it here instead is what lets the console keep its own
|
||||
//! input on the page; what genuinely cannot move — the USB and Bluetooth grant dialogs, a
|
||||
//! rumble pulse on a real `InputDevice` — stays with the host and is asked for by
|
||||
//! [`ConsoleCmd::PadAction`].
|
||||
//
|
||||
// ponytail: the Compose screen's live input test (button grid + axis bars, entered with A,
|
||||
// left by holding B) did NOT move here — the console only receives the aggregated
|
||||
// `MenuSample` (6 buttons, lx/ly, dpad), nowhere near a per-device axis/trigger readout,
|
||||
// and the hold-to-exit gesture has no home in the edge-triggered MenuEvent grammar. The
|
||||
// touch Controllers screen keeps the full test, so the feature exists on-device; add it
|
||||
// here by widening the pad-sample bridge with a per-device payload while the test is open.
|
||||
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::model::ConsoleCmd;
|
||||
use crate::platform::Platform;
|
||||
use crate::pointer::Pointer;
|
||||
use crate::screens::{Ctx, Outbox};
|
||||
use crate::theme::{fg, Fonts, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||
use pf_client_core::menu_nav::{MenuEvent, MenuPulse, PadInfo};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
/// Work on a controller that only the HOST can do — every one of these needs a permission
|
||||
/// dialog or a real device handle, neither of which exists on this side of the bridge.
|
||||
/// Ordered as they are listed.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(crate) enum PadAction {
|
||||
/// Pulse the focused pad's motor (the "is rumble even wired up" test).
|
||||
Rumble,
|
||||
/// `BLUETOOTH_CONNECT`, without which a BLE-paired Steam Controller 2 is invisible —
|
||||
/// not "detected and idle", absent, which is why the row is offered rather than hidden
|
||||
/// behind a detection that cannot run.
|
||||
Sc2Bluetooth,
|
||||
/// USB access for a wired or Puck-dongle Steam Controller 2.
|
||||
Sc2Usb,
|
||||
/// USB access for a wired Sony pad (DualSense, Edge, DualShock 4).
|
||||
DsUsb,
|
||||
/// The DualSense pad-audio self test: can this phone drive the pad's audio endpoint at
|
||||
/// all. Deliberately reachable with no stream running — it exists to rule the pad out
|
||||
/// when a session misbehaves, and gating it behind a session would make it depend on
|
||||
/// the very thing under suspicion.
|
||||
DsHaptics,
|
||||
}
|
||||
|
||||
impl PadAction {
|
||||
/// The stable id the host matches on (crosses JNI inside [`ConsoleCmd::PadAction`]).
|
||||
pub(crate) fn id(self) -> &'static str {
|
||||
match self {
|
||||
PadAction::Rumble => "rumble",
|
||||
PadAction::Sc2Bluetooth => "sc2_bluetooth",
|
||||
PadAction::Sc2Usb => "sc2_usb",
|
||||
PadAction::DsUsb => "ds_usb",
|
||||
PadAction::DsHaptics => "ds_haptics",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The passthrough rows, in list order. Platform-gated as one union exactly like the
|
||||
/// settings row table (`settings::row_on`): the desktop captures nothing over raw USB and
|
||||
/// asks for no grants, so it has no such rows — never a control that changes nothing.
|
||||
const PASSTHROUGH: [(PadAction, &str, &str); 4] = [
|
||||
(
|
||||
PadAction::Sc2Bluetooth,
|
||||
"Steam Controller 2 over Bluetooth",
|
||||
"Grant",
|
||||
),
|
||||
(PadAction::Sc2Usb, "Steam Controller 2 over USB", "Grant"),
|
||||
(
|
||||
PadAction::DsUsb,
|
||||
"DualSense / DualShock over USB",
|
||||
"Grant",
|
||||
),
|
||||
(PadAction::DsHaptics, "DualSense haptics self-test", "Test"),
|
||||
];
|
||||
|
||||
/// One line in the list. Pads first, then whatever the platform can be asked to do.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Row {
|
||||
/// An index into [`Ctx::pads`].
|
||||
Pad(usize),
|
||||
/// No pads at all — an inert row, so the list is never empty and the cursor always has
|
||||
/// something to sit on while the passthrough rows below it stay reachable.
|
||||
NoPads,
|
||||
/// An index into [`PASSTHROUGH`].
|
||||
Passthrough(usize),
|
||||
}
|
||||
|
||||
fn rows_for(ctx: &Ctx) -> Vec<Row> {
|
||||
let mut rows: Vec<Row> = if ctx.pads.is_empty() {
|
||||
vec![Row::NoPads]
|
||||
} else {
|
||||
(0..ctx.pads.len()).map(Row::Pad).collect()
|
||||
};
|
||||
if ctx.platform == Platform::Android {
|
||||
rows.extend((0..PASSTHROUGH.len()).map(Row::Passthrough));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
pub(crate) struct ControllersScreen {
|
||||
list: MenuList,
|
||||
}
|
||||
|
||||
impl ControllersScreen {
|
||||
pub(crate) fn new() -> ControllersScreen {
|
||||
ControllersScreen {
|
||||
list: MenuList::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn menu(
|
||||
&mut self,
|
||||
ev: MenuEvent,
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
if ev == MenuEvent::Back {
|
||||
fx.pop();
|
||||
return None;
|
||||
}
|
||||
let rows = rows_for(ctx);
|
||||
let (msg, pulse) = self.list.menu(ev, rows.len());
|
||||
self.activate(msg, pulse, &rows, ctx, fx)
|
||||
}
|
||||
|
||||
pub(crate) fn pointer(&mut self, p: Pointer, ctx: &mut Ctx, fx: &mut Outbox) -> bool {
|
||||
let rows = rows_for(ctx);
|
||||
let (msg, pulse) = self.list.pointer(p, rows.len());
|
||||
if matches!(msg, ListMsg::None) && pulse.is_none() {
|
||||
return false;
|
||||
}
|
||||
self.activate(msg, pulse, &rows, ctx, fx);
|
||||
true
|
||||
}
|
||||
|
||||
/// One list message against the focused row — shared by the pad path and the pointer's,
|
||||
/// so a click and an A press can never drift apart.
|
||||
fn activate(
|
||||
&mut self,
|
||||
msg: ListMsg,
|
||||
pulse: Option<MenuPulse>,
|
||||
rows: &[Row],
|
||||
ctx: &mut Ctx,
|
||||
fx: &mut Outbox,
|
||||
) -> Option<MenuPulse> {
|
||||
let Some(&focused) = rows.get(self.list.cursor) else {
|
||||
return pulse;
|
||||
};
|
||||
// Nothing here steps: every row is a button or a statement.
|
||||
if matches!(msg, ListMsg::Adjust(_)) {
|
||||
return Some(MenuPulse::Boundary);
|
||||
}
|
||||
if !matches!(msg, ListMsg::Activate) {
|
||||
return pulse;
|
||||
}
|
||||
let (action, pad_key) = match focused {
|
||||
Row::NoPads => return Some(MenuPulse::Boundary),
|
||||
Row::Pad(i) => {
|
||||
// A pad with no motor has nothing to test; say so with the thud rather than
|
||||
// sending a command the host would silently drop.
|
||||
if !ctx.pads[i].rumble {
|
||||
return Some(MenuPulse::Boundary);
|
||||
}
|
||||
(PadAction::Rumble, ctx.pads[i].key.clone())
|
||||
}
|
||||
// The grants are about a device the pad list cannot name (an SC2 in lizard mode
|
||||
// is no input device at all), so they carry no key.
|
||||
Row::Passthrough(i) => (PASSTHROUGH[i].0, String::new()),
|
||||
};
|
||||
fx.cmds.push(ConsoleCmd::PadAction {
|
||||
action: action.id().to_string(),
|
||||
pad_key,
|
||||
});
|
||||
pulse
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
|
||||
let rows = rows_for(ctx);
|
||||
let confirm = match rows.get(self.list.cursor) {
|
||||
Some(Row::Pad(i)) if ctx.pads[*i].rumble => Some("Test rumble"),
|
||||
Some(Row::Passthrough(i)) => Some(match PASSTHROUGH[*i].0 {
|
||||
PadAction::DsHaptics => "Test haptics",
|
||||
_ => "Grant access",
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let mut hints = Vec::new();
|
||||
if let Some(label) = confirm {
|
||||
hints.push(Hint::new(HintKey::Confirm, label));
|
||||
}
|
||||
hints.push(Hint::new(HintKey::Back, "Done"));
|
||||
hints
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
canvas: &Canvas,
|
||||
rect: Rect,
|
||||
k: f64,
|
||||
dt: f64,
|
||||
fonts: &Fonts,
|
||||
ctx: &mut Ctx,
|
||||
) {
|
||||
// The focused row's explainer takes a reserved band under the list — the settings
|
||||
// screen's shape, and here it is the whole point: the identity of the device is the
|
||||
// support answer, and it is far too long to live on the row.
|
||||
let detail_h = 34.0 * k;
|
||||
let rows = rows_for(ctx);
|
||||
let specs: Vec<RowSpec> = rows.iter().map(|r| spec(*r, ctx)).collect();
|
||||
self.list.render(
|
||||
canvas,
|
||||
Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top,
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
),
|
||||
&specs,
|
||||
fonts,
|
||||
k,
|
||||
dt,
|
||||
true,
|
||||
);
|
||||
let detail = rows
|
||||
.get(self.list.cursor)
|
||||
.map_or_else(String::new, |r| detail(*r, ctx));
|
||||
fonts.centered(
|
||||
canvas,
|
||||
&detail,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
fg(0.55),
|
||||
f64::from(rect.left) + f64::from(rect.width()) / 2.0,
|
||||
f64::from(rect.bottom) - detail_h + 6.0 * k,
|
||||
f64::from(rect.width()) * 0.8,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn spec(row: Row, ctx: &Ctx) -> RowSpec {
|
||||
match row {
|
||||
Row::NoPads => RowSpec {
|
||||
header: Some("Gamepads"),
|
||||
..RowSpec::action("No controller detected", false)
|
||||
},
|
||||
Row::Pad(i) => {
|
||||
let pad = &ctx.pads[i];
|
||||
RowSpec {
|
||||
header: (i == 0).then_some("Gamepads"),
|
||||
label: pad.name.clone(),
|
||||
value: Some(if pad.rumble { "Test rumble" } else { "No rumble" }.into()),
|
||||
value_dim: !pad.rumble,
|
||||
caret: false,
|
||||
adjustable: false,
|
||||
enabled: pad.rumble,
|
||||
}
|
||||
}
|
||||
Row::Passthrough(i) => {
|
||||
let (_, label, verb) = PASSTHROUGH[i];
|
||||
RowSpec {
|
||||
header: (i == 0).then_some("Passthrough"),
|
||||
label: label.into(),
|
||||
value: Some(verb.into()),
|
||||
value_dim: false,
|
||||
caret: false,
|
||||
adjustable: false,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The band under the list: what this row is, in one sentence.
|
||||
fn detail(row: Row, ctx: &Ctx) -> String {
|
||||
match row {
|
||||
Row::NoPads => "Punktfunk only forwards devices the system classifies as a gamepad or \
|
||||
joystick — a pad behind an adapter or hub may enumerate with the \
|
||||
adapter's identity, or not at all."
|
||||
.into(),
|
||||
Row::Pad(i) => pad_detail(&ctx.pads[i]),
|
||||
Row::Passthrough(i) => match PASSTHROUGH[i].0 {
|
||||
PadAction::Sc2Bluetooth =>
|
||||
"A Steam Controller 2 paired over Bluetooth cannot be detected at all without \
|
||||
Bluetooth access. Wired and Puck-dongle controllers need no permission."
|
||||
.into(),
|
||||
PadAction::Sc2Usb =>
|
||||
"A wired or Puck-dongle Steam Controller 2 needs USB access to be captured; \
|
||||
until then it stays in its built-in keyboard/mouse mode."
|
||||
.into(),
|
||||
PadAction::DsUsb =>
|
||||
"A wired DualSense or DualShock 4 needs USB access to be captured — with it, \
|
||||
streams drive rumble, adaptive triggers, lightbar and gyro directly."
|
||||
.into(),
|
||||
PadAction::DsHaptics =>
|
||||
"Play a short tone through a wired DualSense's audio endpoint, to tell a pad \
|
||||
that cannot do haptics from a stream that is not sending them."
|
||||
.into(),
|
||||
// Not offered as a passthrough row — the pads carry it.
|
||||
PadAction::Rumble => String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A pad's identity line: what the OS enumerated, whether it is forwarded, what the host
|
||||
/// will build for it, and its charge if it reports one.
|
||||
fn pad_detail(pad: &PadInfo) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if !pad.detail.is_empty() {
|
||||
parts.push(pad.detail.clone());
|
||||
}
|
||||
if !pad.forwarded {
|
||||
parts.push("not forwarded — not classified as a gamepad".into());
|
||||
}
|
||||
let kind = pad.kind_label();
|
||||
parts.push(format!(
|
||||
"streams as {}",
|
||||
if kind.is_empty() { "Xbox 360" } else { kind }
|
||||
));
|
||||
if let Some(b) = pad.battery {
|
||||
parts.push(if b.charging {
|
||||
format!("battery {} %, charging", b.percent)
|
||||
} else {
|
||||
format!("battery {} %", b.percent)
|
||||
});
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pf_client_core::trust::Settings;
|
||||
use punktfunk_core::config::GamepadPref;
|
||||
|
||||
fn pad(name: &str, rumble: bool) -> PadInfo {
|
||||
PadInfo {
|
||||
name: name.into(),
|
||||
key: format!("054c:0ce6:{name}"),
|
||||
pref: GamepadPref::DualSense,
|
||||
steam_virtual: false,
|
||||
battery: None,
|
||||
detail: "054C:0CE6 · gamepad".into(),
|
||||
forwarded: true,
|
||||
rumble,
|
||||
}
|
||||
}
|
||||
|
||||
fn drive(
|
||||
screen: &mut ControllersScreen,
|
||||
platform: Platform,
|
||||
pads: &[PadInfo],
|
||||
ev: MenuEvent,
|
||||
) -> (Outbox, Option<MenuPulse>) {
|
||||
let mut settings = Settings::default();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
store: crate::store::file_store(),
|
||||
platform,
|
||||
pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut fx = Outbox::default();
|
||||
let pulse = screen.menu(ev, &mut ctx, &mut fx);
|
||||
(fx, pulse)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_on_a_pad_asks_the_host_for_a_rumble_pulse() {
|
||||
let pads = [pad("DualSense", true)];
|
||||
let mut s = ControllersScreen::new();
|
||||
let (fx, _) = drive(&mut s, Platform::Android, &pads, MenuEvent::Confirm);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::PadAction {
|
||||
action: "rumble".into(),
|
||||
pad_key: "054c:0ce6:DualSense".into(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pad_with_no_motor_thuds_instead_of_sending_a_pulse() {
|
||||
let pads = [pad("Adapter", false)];
|
||||
let mut s = ControllersScreen::new();
|
||||
let (fx, pulse) = drive(&mut s, Platform::Android, &pads, MenuEvent::Confirm);
|
||||
assert!(fx.cmds.is_empty());
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grant_rows_are_androids_alone_and_carry_no_pad_key() {
|
||||
// Desktop: pads and nothing else — it asks for no grants and captures nothing raw.
|
||||
let pads = [pad("DualSense", true)];
|
||||
let mut settings = Settings::default();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let ctx = |platform, settings: &mut Settings| Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings,
|
||||
store: crate::store::file_store(),
|
||||
platform,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert_eq!(rows_for(&ctx(Platform::Desktop, &mut settings)).len(), 1);
|
||||
assert_eq!(
|
||||
rows_for(&ctx(Platform::Android, &mut settings)).len(),
|
||||
1 + PASSTHROUGH.len()
|
||||
);
|
||||
|
||||
// Down onto the first grant row, then A.
|
||||
let mut s = ControllersScreen::new();
|
||||
drive(
|
||||
&mut s,
|
||||
Platform::Android,
|
||||
&pads,
|
||||
MenuEvent::Move(pf_client_core::menu_nav::MenuDir::Down),
|
||||
);
|
||||
let (fx, _) = drive(&mut s, Platform::Android, &pads, MenuEvent::Confirm);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::PadAction {
|
||||
action: "sc2_bluetooth".into(),
|
||||
pad_key: String::new(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_no_pads_the_list_still_has_the_grants_under_an_inert_row() {
|
||||
let mut s = ControllersScreen::new();
|
||||
let (fx, pulse) = drive(&mut s, Platform::Android, &[], MenuEvent::Confirm);
|
||||
assert!(fx.cmds.is_empty(), "the empty-state row does nothing");
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! on the shell's stack. B pops back to the host list; A launches the focused title in
|
||||
//! the same window. The shell owns the aurora, chrome, and the connecting overlay.
|
||||
|
||||
use crate::anim::{entrances, Entrance, EntranceAt, Spring};
|
||||
use crate::anim::{approach, entrances, Entrance, EntranceAt, Spring};
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::library::{
|
||||
card_matrix, grid_col_hint, grid_step, initials, step_cursor, store_label, GridDir, GridShape,
|
||||
@@ -28,8 +28,10 @@ const GRID_MARGIN: f64 = 48.0;
|
||||
const GRID_LABEL: f64 = 10.0;
|
||||
/// The band a grid group heading occupies.
|
||||
const GRID_HEADING: f64 = 30.0;
|
||||
/// The band the focused title's name and store occupy under either arrangement.
|
||||
const DETAIL_BAND: f64 = 84.0;
|
||||
/// The band the focused title's name occupies under either arrangement. Shrunk from 84 when
|
||||
/// the store/platform subtitle left: the cover badge already carries that answer, and on a
|
||||
/// phone the 20 units bought most of a grid row back.
|
||||
const DETAIL_BAND: f64 = 64.0;
|
||||
/// The corner on the view/sort bar's own glass, once it has focus.
|
||||
const BAR_CORNER: f64 = 14.0;
|
||||
/// Air between the bar and the field under it. The shelf centres its cards and would never
|
||||
@@ -394,6 +396,13 @@ pub(super) fn strip_caption(
|
||||
/// the persisted settings are the state and these only draw it and hit-test a click.
|
||||
struct LibraryBar {
|
||||
focus: bool,
|
||||
/// How present the bar is, 0–1. The bar only APPEARS while it holds the pad (▲ from the
|
||||
/// field, "Sort & view" in the legend) — the Apple client's behaviour, adopted after the
|
||||
/// always-on band proved too expensive on a phone: it taxed every library visit a strip
|
||||
/// of field height to answer a question ("what is the sort") that only matters in the
|
||||
/// moment of changing it. Chased toward `focus` each frame; the field takes the band's
|
||||
/// room back as this falls.
|
||||
reveal: f64,
|
||||
sort_tabs: TabStrip,
|
||||
view_tabs: TabStrip,
|
||||
}
|
||||
@@ -402,6 +411,7 @@ impl LibraryBar {
|
||||
fn new() -> LibraryBar {
|
||||
LibraryBar {
|
||||
focus: false,
|
||||
reveal: 0.0,
|
||||
sort_tabs: TabStrip::new(),
|
||||
view_tabs: TabStrip::new(),
|
||||
}
|
||||
@@ -1229,7 +1239,11 @@ impl LibraryScreen {
|
||||
// reaches the bar (the shell turns that hint into a `Move(Up)`), but focus is
|
||||
// not a choice — a mouse picks a sort by pressing the pill it wants, which is
|
||||
// why both strips hit-test themselves rather than leaning on the legend.
|
||||
let (sort_hit, view_hit) = if self.bar_shown() {
|
||||
// …and only while the bar is actually PRESENT: it appears on focus now, so
|
||||
// an unfocused library has no pills on screen and none to hit. The `TabStrip`s
|
||||
// keep the geometry they last drew, and a press must not land on furniture
|
||||
// that has faded out.
|
||||
let (sort_hit, view_hit) = if self.bar_shown() && self.bar.focus {
|
||||
(
|
||||
self.bar
|
||||
.sort_tabs
|
||||
@@ -1424,9 +1438,21 @@ impl LibraryScreen {
|
||||
if self.entrance.is_some_and(|e| e.done(ctx.t)) {
|
||||
self.entrance = None;
|
||||
}
|
||||
// The bar takes its band off the TOP of the field. The detail band keeps the
|
||||
// full rect — it is anchored to the bottom — and so does the loading path
|
||||
// above, which is centred in a field the bar is not part of.
|
||||
// The bar only takes its band off the TOP of the field while it is present
|
||||
// (see [`LibraryBar::reveal`]) — hidden, the field keeps the whole rect. The
|
||||
// detail band keeps the full rect either way — it is anchored to the bottom —
|
||||
// and so does the loading path above, which is centred in a field the bar is
|
||||
// not part of.
|
||||
let bar_target = if self.bar.focus { 1.0 } else { 0.0 };
|
||||
self.bar.reveal = if crate::theme::reduce_motion() {
|
||||
bar_target
|
||||
} else {
|
||||
approach(self.bar.reveal, bar_target, dt, 0.10)
|
||||
};
|
||||
if (self.bar.reveal - bar_target).abs() < 0.005 {
|
||||
self.bar.reveal = bar_target;
|
||||
}
|
||||
let reveal = self.bar.reveal;
|
||||
let bar = Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top,
|
||||
@@ -1435,7 +1461,7 @@ impl LibraryScreen {
|
||||
);
|
||||
let field = Rect::from_ltrb(
|
||||
rect.left,
|
||||
bar.bottom + (BAR_GAP * k) as f32,
|
||||
rect.top + ((TAB_STRIP_H + BAR_GAP) * k * reveal) as f32,
|
||||
rect.right,
|
||||
rect.bottom,
|
||||
);
|
||||
@@ -1445,7 +1471,21 @@ impl LibraryScreen {
|
||||
}
|
||||
// After the cards, like the detail band: it is the screen's readout, and a
|
||||
// short window must not let an arriving cover paint over the answer.
|
||||
self.draw_bar(canvas, bar, k, fonts, dt);
|
||||
// Faded as a unit while arriving/leaving, with a small rise — the crate's
|
||||
// transition grammar. Bounded layer: unbounded would allocate a surface-sized
|
||||
// offscreen for a strip of pills (see the twin warning in home.rs).
|
||||
if reveal > 0.01 {
|
||||
let bounds = Rect::from_ltrb(
|
||||
bar.left,
|
||||
bar.top - (12.0 * k) as f32,
|
||||
bar.right,
|
||||
bar.bottom + (12.0 * k) as f32,
|
||||
);
|
||||
canvas.save_layer_alpha_f(bounds, reveal as f32);
|
||||
canvas.translate((0.0f32, (-(1.0 - reveal) * 10.0 * k) as f32));
|
||||
self.draw_bar(canvas, bar, k, fonts, dt);
|
||||
canvas.restore();
|
||||
}
|
||||
self.draw_detail_band(canvas, rect, k, fonts);
|
||||
self.evict_art();
|
||||
}
|
||||
@@ -1514,13 +1554,13 @@ impl LibraryScreen {
|
||||
/// The bar over the field: what this library is sorted by, what it is arranged as, and
|
||||
/// the control for both.
|
||||
///
|
||||
/// Drawn whether or not it has focus, because the SORT is the thing the field cannot
|
||||
/// say. A coverflow under `Platform` and one under `A–Z` are the same screen with the
|
||||
/// cards in a different order, and until this band existed the only place that answer
|
||||
/// lived was the Collections screen — which a single-store library is never offered at
|
||||
/// all ([`crate::collate::worth_browsing`]). The arrangement IS visible in the field, and
|
||||
/// is named here anyway: one strip that answers both questions the same way is a control
|
||||
/// the user finds once.
|
||||
/// Drawn only while it holds the pad ([`LibraryBar::reveal`]): the field's legend keeps
|
||||
/// "▲ Sort & view" up permanently, so the ANSWER is one press away instead of one strip
|
||||
/// of always-spent field height — the Apple client's behaviour, adopted for the small
|
||||
/// screens where that strip priced out a full grid row. A coverflow under `Platform` and
|
||||
/// one under `A–Z` are still the same screen with the cards in a different order; this
|
||||
/// band is still the only place that names it (the Collections screen is never offered
|
||||
/// to a single-store library at all — [`crate::collate::worth_browsing`]).
|
||||
fn draw_bar(&mut self, canvas: &Canvas, bar: Rect, k: f64, fonts: &Fonts, dt: f64) {
|
||||
// Focused, the WHOLE band takes an accent WASH — the two groups are one control here
|
||||
// (◀ ▶ step the sort, the shoulders pick the arrangement), so a ring around one pill
|
||||
@@ -1645,8 +1685,17 @@ impl LibraryScreen {
|
||||
// the cursor — is what put the focus ring in a different column from the cover the
|
||||
// scroll had just brought up.
|
||||
let shape = GridShape::new(self.len(), cols, self.launcher_count());
|
||||
let (cw, ch) = (GRID_W * k, GRID_H * k);
|
||||
let pitch_x = cw + GRID_GAP * k;
|
||||
// `grid_cols` clamps at two columns, so on a narrow-enough viewport (a high-density
|
||||
// phone in portrait, where the density floor raises `k` past what the panel width
|
||||
// covers) two full-size covers plus margins can overflow the rect and clip at the
|
||||
// edges. The covers shrink to fit instead — only ever downward, and only the CELLS:
|
||||
// headings and labels keep the design scale, and geometry stays self-consistent
|
||||
// because everything below draws and records from these same metrics.
|
||||
let fit = ((f64::from(rect.width()) - 2.0 * GRID_MARGIN * k)
|
||||
/ ((cols as f64 * (GRID_W + GRID_GAP) - GRID_GAP) * k))
|
||||
.clamp(0.25, 1.0);
|
||||
let (cw, ch) = (GRID_W * k * fit, GRID_H * k * fit);
|
||||
let pitch_x = cw + GRID_GAP * k * fit;
|
||||
let pitch_y = ch + GRID_GAP * k + GRID_LABEL * k;
|
||||
// The launcher prefix keeps its own band, which is how design D4 reads in two
|
||||
// dimensions: the shelf says it with a heading that changes as the cursor crosses,
|
||||
@@ -1704,7 +1753,7 @@ impl LibraryScreen {
|
||||
(bump, self.scroll.pos)
|
||||
};
|
||||
|
||||
let grid_w = cols as f64 * pitch_x - GRID_GAP * k;
|
||||
let grid_w = cols as f64 * pitch_x - GRID_GAP * k * fit;
|
||||
let x0 = f64::from(rect.left) + (f64::from(rect.width()) - grid_w) / 2.0 + bump_x;
|
||||
let y0 = f64::from(rect.top);
|
||||
let viewport = Rect::from_xywh(rect.left, rect.top, rect.width(), (view_h.max(0.0)) as f32);
|
||||
@@ -2072,7 +2121,7 @@ impl LibraryScreen {
|
||||
canvas,
|
||||
note,
|
||||
f64::from(rect.left) + EDGE_INSET * k,
|
||||
f64::from(rect.bottom) - 30.0 * k,
|
||||
f64::from(rect.bottom) - 12.0 * k,
|
||||
W::Regular,
|
||||
12.0 * k,
|
||||
fg(0.55),
|
||||
@@ -2081,6 +2130,9 @@ impl LibraryScreen {
|
||||
let Some(g) = self.focused() else { return };
|
||||
let w = f64::from(rect.width());
|
||||
let cx = f64::from(rect.left) + w / 2.0;
|
||||
// The title alone. The store/platform subtitle that sat under it is gone: the cover
|
||||
// badge already names the store, so the line said everything twice and cost the band
|
||||
// 20 units of field height on every library visit.
|
||||
fonts.centered(
|
||||
canvas,
|
||||
&g.title,
|
||||
@@ -2088,30 +2140,9 @@ impl LibraryScreen {
|
||||
27.0 * k,
|
||||
fg(1.0),
|
||||
cx,
|
||||
f64::from(rect.bottom) - 64.0 * k,
|
||||
f64::from(rect.bottom) - 34.0 * k,
|
||||
w * 0.8,
|
||||
);
|
||||
// Store, and the PLATFORM when the host named one — the reason `platform` was
|
||||
// plumbed at all is that "Shadow of the Colossus" means something rather different
|
||||
// with "PS2" under it.
|
||||
let store = store_label(&g.store).to_uppercase();
|
||||
let sub = match (&g.platform, g.launcher) {
|
||||
(_, true) => format!("{store} · LAUNCHER"),
|
||||
(Some(p), _) if !p.trim().is_empty() => format!("{store} · {}", p.to_uppercase()),
|
||||
_ => store,
|
||||
};
|
||||
fonts.centered(
|
||||
canvas,
|
||||
&sub,
|
||||
W::Regular,
|
||||
12.0 * k,
|
||||
// The subtitle rung of the 0.55 / 0.7 / 0.85 ladder every other detail line
|
||||
// in the crate already sits on.
|
||||
fg(0.55),
|
||||
cx,
|
||||
f64::from(rect.bottom) - 30.0 * k,
|
||||
w * 0.5,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -488,17 +488,27 @@ impl SettingsScreen {
|
||||
ListMsg::None => pulse,
|
||||
};
|
||||
}
|
||||
// The platform's own screens: A asks the host to open one; nothing here edits.
|
||||
RowId::Controllers | RowId::Licenses => {
|
||||
// Connected controllers is one of ours now — a shared Skia screen, so the console
|
||||
// keeps its own input on the page and only the grant dialogs go back to the host.
|
||||
RowId::Controllers => {
|
||||
return match msg {
|
||||
ListMsg::Activate => {
|
||||
fx.push(Screen::Controllers(
|
||||
super::controllers::ControllersScreen::new(),
|
||||
));
|
||||
pulse
|
||||
}
|
||||
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
};
|
||||
}
|
||||
// The one screen still the platform's: A asks the host to open it; nothing here
|
||||
// edits.
|
||||
RowId::Licenses => {
|
||||
return match msg {
|
||||
ListMsg::Activate => {
|
||||
let screen = if focused == RowId::Controllers {
|
||||
crate::platform::PlatformScreen::Controllers
|
||||
} else {
|
||||
crate::platform::PlatformScreen::Licenses
|
||||
};
|
||||
fx.cmds.push(crate::model::ConsoleCmd::OpenPlatformScreen {
|
||||
id: screen.id().to_string(),
|
||||
id: crate::platform::PlatformScreen::Licenses.id().to_string(),
|
||||
});
|
||||
pulse
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user