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

Reviewed-on: #317
This commit was merged in pull request #317.
This commit is contained in:
2026-08-18 21:50:54 +00:00
21 changed files with 896 additions and 317 deletions
@@ -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) }
}
@@ -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
@@ -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() }
}
+31 -4
View File
@@ -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));
+13 -2
View File
@@ -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 {
+5 -2
View File
@@ -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,
+8
View File
@@ -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,
})
}
+14
View File
@@ -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.
+11
View File
@@ -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
+7 -8
View File
@@ -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",
}
}
+10
View File
@@ -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)));
}
}
+73 -42
View File
@@ -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, 01. 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 `AZ` 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 `AZ` 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,
);
}
}
+18 -8
View File
@@ -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
}