diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt index a7d04cfe..eb0166b9 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt @@ -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, ) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt index 940fa338..a6e8ca5d 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt @@ -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? = 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? = 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? = 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 diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt index 73782564..142d89d6 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadNav.kt @@ -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() } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt index 42cea36c..4f9a20c9 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -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() + + 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 diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/ConsoleJson.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/ConsoleJson.kt index dbd9675c..1f06054b 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/ConsoleJson.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/ConsoleJson.kt @@ -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, 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) { diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/SkiaConsole.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/SkiaConsole.kt index eb68beb6..ec51c044 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/SkiaConsole.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/SkiaConsole.kt @@ -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) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/SkiaConsoleShell.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/SkiaConsoleShell.kt index fa12cb28..f2f59e18 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/SkiaConsoleShell.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/console/SkiaConsoleShell.kt @@ -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 diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt index bda63bc2..f97554c9 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -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() diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index 246bd199..37351e5e 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -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() = diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/TvScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/TvScreenshotTest.kt index 9aa8d2b0..39565bcb 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/TvScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/TvScreenshotTest.kt @@ -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() } } diff --git a/clients/android/native/src/console/host.rs b/clients/android/native/src/console/host.rs index deb9749a..5fcb525f 100644 --- a/clients/android/native/src/console/host.rs +++ b/clients/android/native/src/console/host.rs @@ -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, store: Arc = 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, store: Arc 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, store: Arc 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, store: Arc= 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)); diff --git a/clients/android/native/src/console/mod.rs b/clients/android/native/src/console/mod.rs index 959dc78c..a76d0ab3 100644 --- a/clients/android/native/src/console/mod.rs +++ b/clients/android/native/src/console/mod.rs @@ -83,6 +83,13 @@ struct PadJson { steam_virtual: bool, #[serde(default)] battery: Option, + /// `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 { diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index bfc55ac7..d3f173a5 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -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, diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 87250a15..697e9cc2 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -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, }) } diff --git a/crates/pf-client-core/src/menu_nav.rs b/crates/pf-client-core/src/menu_nav.rs index e4052620..5ef6f774 100644 --- a/crates/pf-client-core/src/menu_nav.rs +++ b/crates/pf-client-core/src/menu_nav.rs @@ -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, + /// 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. diff --git a/crates/pf-console-ui/src/model.rs b/crates/pf-console-ui/src/model.rs index 718d85ef..fd8e21d7 100644 --- a/crates/pf-console-ui/src/model.rs +++ b/crates/pf-console-ui/src/model.rs @@ -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 diff --git a/crates/pf-console-ui/src/platform.rs b/crates/pf-console-ui/src/platform.rs index 495a73cb..5d0e14cb 100644 --- a/crates/pf-console-ui/src/platform.rs +++ b/crates/pf-console-ui/src/platform.rs @@ -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", } } diff --git a/crates/pf-console-ui/src/screens.rs b/crates/pf-console-ui/src/screens.rs index 37623613..9384696f 100644 --- a/crates/pf-console-ui/src/screens.rs +++ b/crates/pf-console-ui/src/screens.rs @@ -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), } } diff --git a/crates/pf-console-ui/src/screens/controllers.rs b/crates/pf-console-ui/src/screens/controllers.rs new file mode 100644 index 00000000..074d53ef --- /dev/null +++ b/crates/pf-console-ui/src/screens/controllers.rs @@ -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 { + let mut rows: Vec = 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 { + 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, + rows: &[Row], + ctx: &mut Ctx, + fx: &mut Outbox, + ) -> Option { + 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 { + 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 = 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 = 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) { + 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))); + } +} diff --git a/crates/pf-console-ui/src/screens/library.rs b/crates/pf-console-ui/src/screens/library.rs index e0a88a55..38ca606e 100644 --- a/crates/pf-console-ui/src/screens/library.rs +++ b/crates/pf-console-ui/src/screens/library.rs @@ -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, - ); } } diff --git a/crates/pf-console-ui/src/screens/settings.rs b/crates/pf-console-ui/src/screens/settings.rs index 70beedfc..cec3f63a 100644 --- a/crates/pf-console-ui/src/screens/settings.rs +++ b/crates/pf-console-ui/src/screens/settings.rs @@ -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 }