Merge pull request 'feat(gamepad-ui): profiles integration — pinned cards, pin management, settings section on all three gamepad UIs' (#42) from worktree-gamepad-ui-profiles into main
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 13s
deb / build-publish-host (push) Successful in 4m30s
android / android (push) Successful in 7m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m50s
deb / build-publish (push) Successful in 5m31s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m5s
docker / builders-arm64cross (push) Successful in 5s
apple / screenshots (push) Successful in 5m57s
arch / build-publish (push) Successful in 8m37s
release / apple (push) Successful in 9m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m55s
ci / rust (push) Successful in 8m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m19s
docker / deploy-docs (push) Failing after 9m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m30s
apple / swift (push) Successful in 1m32s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 52s
ci / web (push) Successful in 1m8s
flatpak / build-publish (push) Successful in 27m16s
ci / rust-arm64 (push) Successful in 1m48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
ci / docs-site (push) Successful in 1m51s
deb / build-publish-client-arm64 (push) Successful in 1m21s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 13s
deb / build-publish-host (push) Successful in 4m30s
android / android (push) Successful in 7m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m50s
deb / build-publish (push) Successful in 5m31s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m5s
docker / builders-arm64cross (push) Successful in 5s
apple / screenshots (push) Successful in 5m57s
arch / build-publish (push) Successful in 8m37s
release / apple (push) Successful in 9m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m55s
ci / rust (push) Successful in 8m50s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m19s
docker / deploy-docs (push) Failing after 9m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m30s
apple / swift (push) Successful in 1m32s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 52s
ci / web (push) Successful in 1m8s
flatpak / build-publish (push) Successful in 27m16s
ci / rust-arm64 (push) Successful in 1m48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
ci / docs-site (push) Successful in 1m51s
deb / build-publish-client-arm64 (push) Successful in 1m21s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
Reviewed-on: #42
This commit was merged in pull request #42.
This commit is contained in:
@@ -50,10 +50,12 @@ import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.unom.punktfunk.kit.NativeBridge
|
||||
import io.unom.punktfunk.kit.security.ClientIdentity
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.models.PendingTrust
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -250,6 +252,139 @@ fun GamepadHostOptionsDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pin-to-hosts picker the settings screen's Profiles section opens — the Android mirror of the
|
||||
* desktop console's PinHostsScreen (design §5.2a): one toggle row per SAVED host, D-pad up/down
|
||||
* moves, A flips the focused pin, left/right unpins/pins (the settings-toggle semantics), B closes.
|
||||
* A toggle is presentation only: it edits the host's pinned cards through the same store write the
|
||||
* carousel's unpin uses, never the profile itself and never the host's default binding.
|
||||
*
|
||||
* Pin state is read live from [pinned] (backed by the host records), so what a switch shows is
|
||||
* always what the store holds — the row can't disagree with the carousel it feeds.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun GamepadPinHostsDialog(
|
||||
profileName: String,
|
||||
hosts: List<KnownHost>,
|
||||
pinned: (KnownHost) -> Boolean,
|
||||
onToggle: (KnownHost) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS
|
||||
// Done, so it starts focused).
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
BackHandler(onBack = onDismiss)
|
||||
GamepadNavEffect2D(
|
||||
active = true,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
NavDir.DOWN -> if (focus < hosts.size) focus++
|
||||
// Directional = state-targeted (left → unpinned, right → pinned), so holding a
|
||||
// direction can't oscillate; asking for the state it's already in is a no-op.
|
||||
NavDir.LEFT -> hosts.getOrNull(focus)?.let { if (pinned(it)) onToggle(it) }
|
||||
NavDir.RIGHT -> hosts.getOrNull(focus)?.let { if (!pinned(it)) onToggle(it) }
|
||||
}
|
||||
},
|
||||
onActivate = {
|
||||
val kh = hosts.getOrNull(focus)
|
||||
if (kh != null) onToggle(kh) else onDismiss()
|
||||
},
|
||||
)
|
||||
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(24.dp)
|
||||
.widthIn(max = 520.dp)
|
||||
.heightIn(max = maxCardHeight)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF01A1730))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
|
||||
.padding(28.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(
|
||||
"Pin “$profileName”",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Column(
|
||||
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (hosts.isEmpty()) {
|
||||
DialogText("No saved hosts yet — pair with a host first, then pin this profile to it.")
|
||||
} else {
|
||||
DialogText("A pinned profile appears as its own card on the host — one press connects with it.")
|
||||
hosts.forEachIndexed { i, kh ->
|
||||
PinHostRow(
|
||||
label = kh.name,
|
||||
on = pinned(kh),
|
||||
focused = i == focus,
|
||||
onClick = { onToggle(kh) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
DialogButton(
|
||||
"Done",
|
||||
focused = focus == hosts.size,
|
||||
primary = true,
|
||||
enabled = true,
|
||||
onClick = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One host's pin toggle: name + a [ConsoleSwitch], with the shared console focus visuals. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) {
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
// Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short
|
||||
// landscape window pulls itself into view.
|
||||
val intoView = remember { BringIntoViewRequester() }
|
||||
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.bringIntoViewRequester(intoView)
|
||||
.graphicsLayer { scaleX = visuals.scale; scaleY = visuals.scale }
|
||||
.clip(shape)
|
||||
.background(visuals.background)
|
||||
.border(1.dp, visuals.border, shape)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
ConsoleSwitch(on = on, focused = focused)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Console counterpart of [SpeedTestDialog]. Same measurement, same targeting rule — a TV box on a
|
||||
* powerline adapter is exactly the machine whose link is worth measuring, so this belongs on the
|
||||
|
||||
@@ -57,6 +57,8 @@ import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import io.unom.punktfunk.kit.deviceBodyVibrator
|
||||
import io.unom.punktfunk.kit.security.KnownHost
|
||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||
|
||||
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
|
||||
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
|
||||
@@ -72,6 +74,8 @@ private class GpRow(
|
||||
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
|
||||
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
|
||||
val toggled: Boolean? = null, // non-null = a toggle row, drawn as a ConsoleSwitch (not text)
|
||||
val adjustable: Boolean = true, // false = the row navigates/acts instead of stepping — no chevrons
|
||||
val enabled: Boolean = true, // dimmed + inert when false (still focusable, for its detail)
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -89,7 +93,39 @@ fun GamepadSettingsScreen(
|
||||
val hasBodyVibrator = remember { deviceBodyVibrator(context) != null }
|
||||
// Gates the AV1 codec row the same way the touch settings do (see `codecOptionsFor`).
|
||||
val av1Capable = remember { io.unom.punktfunk.kit.VideoDecoders.pickDecoder("video/av01") != null }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update)
|
||||
|
||||
// The Profiles section's stores, constructed here the way ConnectScreen constructs its own.
|
||||
// The catalog is read once per screen entry: this screen can't create or edit profiles
|
||||
// (design §5.4 — the touch interface does), so the list is stable for its lifetime. The saved
|
||||
// hosts DO change under it — every pin toggle writes one — so they live in state and refresh
|
||||
// on each toggle, keeping the "Pinned to N hosts" counts honest.
|
||||
val knownHostStore = remember { KnownHostStore(context) }
|
||||
val profileStore = remember { ProfileStore(context) }
|
||||
val profiles = remember { profileStore.all() }
|
||||
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
|
||||
// The profile whose pin-to-hosts picker is up, or null. While it's showing, it owns the pad
|
||||
// (this screen's nav gates on it, the ConnectScreen-dialog pattern).
|
||||
var pinProfile by remember { mutableStateOf<StreamProfile?>(null) }
|
||||
|
||||
// Toggle a host+profile pin — the same store write ConnectScreen's togglePin does. Presentation
|
||||
// only: pin appends at the end (card order), unpin removes, and the host's default binding
|
||||
// (profileId) is never touched.
|
||||
fun togglePin(kh: KnownHost, profile: StreamProfile) {
|
||||
val pins = if (profile.id in kh.pinnedProfileIds) {
|
||||
kh.pinnedProfileIds - profile.id
|
||||
} else {
|
||||
kh.pinnedProfileIds + profile.id
|
||||
}
|
||||
knownHostStore.save(kh.copy(pinnedProfileIds = pins))
|
||||
savedHosts = knownHostStore.all()
|
||||
}
|
||||
|
||||
// On a TV "the touch interface" is confusing advice (no touch to reach it with) — the honest
|
||||
// path there is this screen's own Controller-optimized UI toggle, which swaps in the standard
|
||||
// interface remote-navigably. The strings branch on it.
|
||||
val tv = remember { isTvDevice(context) }
|
||||
val rows = buildSettingsRows(s, hasBodyVibrator, av1Capable, ::update) +
|
||||
buildProfileRows(profiles, savedHosts, tv) { pinProfile = it }
|
||||
var focus by remember { mutableIntStateOf(0) }
|
||||
if (focus > rows.lastIndex) focus = rows.lastIndex
|
||||
// The direction the focused value last stepped (+1 forward / -1 back) — drives which way the
|
||||
@@ -101,7 +137,9 @@ fun GamepadSettingsScreen(
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
GamepadNavEffect2D(
|
||||
active = navActive,
|
||||
// The pin picker owns the pad while it's up (its own nav + BackHandler), so this screen
|
||||
// drops its probes — the pattern ConnectScreen's dialogs use.
|
||||
active = navActive && pinProfile == null,
|
||||
onDirection = { dir ->
|
||||
when (dir) {
|
||||
NavDir.UP -> if (focus > 0) focus--
|
||||
@@ -162,16 +200,41 @@ fun GamepadSettingsScreen(
|
||||
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
|
||||
.padding(ConsoleLegendInset),
|
||||
) {
|
||||
// The legend follows the focused row (the desktop console's hints() does the same):
|
||||
// a profile row doesn't adjust, it opens the pin picker, and the "No profiles yet"
|
||||
// placeholder does nothing at all — advertising ↔/A on those would be a lie.
|
||||
val focused = rows.getOrNull(focus)
|
||||
GamepadHintBar(
|
||||
listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
),
|
||||
when {
|
||||
focused != null && !focused.enabled -> listOf(
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
focused != null && !focused.adjustable -> listOf(
|
||||
PadGlyph.hint('A', "Pin to hosts") { focused.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
else -> listOf(
|
||||
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
|
||||
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
|
||||
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
|
||||
PadGlyph.hint('B', "Done", onClick = onBack),
|
||||
)
|
||||
},
|
||||
hazeState = hazeState,
|
||||
)
|
||||
}
|
||||
|
||||
// The pin-to-hosts picker for the activated profile row — the console counterpart of the
|
||||
// touch UI's per-profile pin toggles in the host edit sheet.
|
||||
pinProfile?.let { p ->
|
||||
GamepadPinHostsDialog(
|
||||
profileName = p.name,
|
||||
hosts = savedHosts,
|
||||
pinned = { kh -> p.id in kh.pinnedProfileIds },
|
||||
onToggle = { kh -> togglePin(kh, p) },
|
||||
onDismiss = { pinProfile = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,8 +243,13 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
val visuals = animateConsoleFocus(active = focused)
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
// The chevrons keep their layout slot and only fade, so the value never jumps sideways when
|
||||
// focus arrives; the value colour cross-fades with them.
|
||||
val chevronAlpha by animateFloatAsState(if (focused) 0.6f else 0f, tween(160), label = "chevrons")
|
||||
// focus arrives; the value colour cross-fades with them. A non-adjustable row (a profile row
|
||||
// navigates, the empty-catalog placeholder does nothing) never shows them at all.
|
||||
val chevronAlpha by animateFloatAsState(
|
||||
if (focused && row.adjustable) 0.6f else 0f,
|
||||
tween(160),
|
||||
label = "chevrons",
|
||||
)
|
||||
val valueColor by animateColorAsState(
|
||||
Color.White.copy(alpha = if (focused) 1f else 0.6f),
|
||||
tween(160),
|
||||
@@ -216,7 +284,9 @@ private fun SettingRowView(row: GpRow, focused: Boolean, adjustDir: Int, onClick
|
||||
row.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
// A disabled row (the "No profiles yet" placeholder) dims but stays focusable,
|
||||
// so its detail line can still explain what would go here.
|
||||
color = Color.White.copy(alpha = if (row.enabled) 1f else 0.45f),
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
@@ -435,3 +505,62 @@ private fun buildSettingsRows(
|
||||
) { update(s.copy(sc2Capture = it)) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The trailing Profiles section — the Android mirror of the desktop console's (design §5.2a, §5.4):
|
||||
* one row per catalog profile, valued with how many saved hosts pin it, activating into the
|
||||
* pin-to-hosts picker. Read-only beyond pinning: profiles are created and edited in the standard
|
||||
* interface, so an empty catalog shows one dimmed placeholder explaining where they come from
|
||||
* instead of a dead-looking empty header. On a TV that phrasing changes: "touch interface" points
|
||||
* nowhere useful on a touchless device, so the strings name the actual route — the
|
||||
* Controller-optimized UI toggle a few rows up, which swaps the standard interface in
|
||||
* (d-pad-navigable; the profile editor lives there on every device, unlike tvOS where none exists).
|
||||
*/
|
||||
private fun buildProfileRows(
|
||||
profiles: List<StreamProfile>,
|
||||
savedHosts: List<KnownHost>,
|
||||
tv: Boolean,
|
||||
openPinPicker: (StreamProfile) -> Unit,
|
||||
): List<GpRow> {
|
||||
val createHint = if (tv) {
|
||||
"To create or edit profiles on this device, turn off Controller-optimized UI above " +
|
||||
"and use the standard interface."
|
||||
} else {
|
||||
"Profiles are created and edited in the touch interface."
|
||||
}
|
||||
if (profiles.isEmpty()) {
|
||||
return listOf(
|
||||
GpRow(
|
||||
id = "noProfiles",
|
||||
header = "Profiles",
|
||||
label = "No profiles yet",
|
||||
value = "",
|
||||
detail = "Profiles bundle stream settings for different uses — pinned ones become " +
|
||||
"one-press connect cards here. " + createHint,
|
||||
adjust = { false },
|
||||
activate = {},
|
||||
adjustable = false,
|
||||
enabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
return profiles.mapIndexed { i, p ->
|
||||
// Counted straight off the host records, so it agrees with what the carousel renders.
|
||||
val pins = savedHosts.count { p.id in it.pinnedProfileIds }
|
||||
GpRow(
|
||||
id = "profile:${p.id}",
|
||||
header = if (i == 0) "Profiles" else null,
|
||||
label = p.name,
|
||||
value = when (pins) {
|
||||
0 -> "Not pinned"
|
||||
1 -> "Pinned to 1 host"
|
||||
else -> "Pinned to $pins hosts"
|
||||
},
|
||||
detail = "Pin this profile to a host and it appears as its own card — one press " +
|
||||
"connects with it. " + createHint,
|
||||
adjust = { false },
|
||||
activate = { openPinPicker(p) },
|
||||
adjustable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ struct GamepadHomeView: View {
|
||||
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
|
||||
#if os(macOS)
|
||||
.sheet(isPresented: $showSettings) {
|
||||
GamepadSettingsView()
|
||||
GamepadSettingsView(store: store)
|
||||
.frame(width: 720, height: 640)
|
||||
}
|
||||
.sheet(isPresented: $showAddHost) {
|
||||
@@ -144,7 +144,7 @@ struct GamepadHomeView: View {
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 420)
|
||||
#else
|
||||
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView() }
|
||||
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
|
||||
.fullScreenCover(isPresented: $showAddHost) {
|
||||
GamepadAddHostView { store.add($0) }
|
||||
}
|
||||
|
||||
@@ -146,7 +146,9 @@ private struct ShotGamepadHome: View {
|
||||
}
|
||||
|
||||
private struct ShotGamepadSettings: View {
|
||||
var body: some View { GamepadSettingsView() }
|
||||
@StateObject private var store = ShotMock.hostStore()
|
||||
|
||||
var body: some View { GamepadSettingsView(store: store) }
|
||||
}
|
||||
|
||||
private struct ShotGamepadAddHost: View {
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
// on stale captured state. Left/right CLAMPS at a choice list's ends (the dull boundary thud tells
|
||||
// the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable
|
||||
// with one button. Toggles read left = off, right = on — refusing a no-op with the same thud.
|
||||
//
|
||||
// The trailing Profiles section (design/client-settings-profiles.md §5.2a/§5.4) is the pin manager
|
||||
// for this controller-first surface: a row per catalog profile opens the pin-to-hosts picker — an
|
||||
// in-place swap of the row list (B peels back, the "one layer" rule GamepadAddHostView set) with
|
||||
// one toggle row per saved host, writing `StoredHost.pinnedProfileIDs` via HostStore.setPinned.
|
||||
// Pins are presentation only: never the host's default binding, never the profile itself —
|
||||
// profiles are created and edited in the standard interface (and can't be on tvOS, whose
|
||||
// per-device catalog the detail strings are honest about).
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
@@ -21,6 +29,10 @@ import CoreHaptics
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
/// The saved-host store — the pin picker writes `setPinned` through it and the profile rows
|
||||
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
|
||||
/// itself (ContentView owns the instance).
|
||||
@ObservedObject var store: HostStore
|
||||
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||
@@ -52,6 +64,10 @@ struct GamepadSettingsView: View {
|
||||
@AppStorage(DefaultsKey.rumbleOnDevice) private var rumbleOnDevice = false
|
||||
#endif
|
||||
@ObservedObject private var gamepads = GamepadManager.shared
|
||||
/// The profile catalog (ProfileStore.shared, like every other surface that reads it) — the
|
||||
/// Profiles rows re-derive from it each render, so a rename/delete made in the standard
|
||||
/// interface shows up live.
|
||||
@ObservedObject private var profiles = ProfileStore.shared
|
||||
|
||||
#if os(iOS)
|
||||
/// `.compact` in a landscape phone window — tighter chrome so more rows fit.
|
||||
@@ -62,6 +78,9 @@ struct GamepadSettingsView: View {
|
||||
private let compact = false // no size classes on macOS; the sheet is sized generously
|
||||
#endif
|
||||
@State private var focusID: String?
|
||||
/// The pin-to-hosts picker's profile — non-nil swaps the row list for one toggle row per
|
||||
/// saved host (§5.2a); B (Menu on tvOS) peels back to the settings rows.
|
||||
@State private var pinTarget: StreamProfile?
|
||||
/// The direction of the last value step (+1 right/forward, -1 left) — picks which edge the
|
||||
/// changed value slides in from, so the animation follows the user's motion.
|
||||
@State private var lastAdjustDelta = 1
|
||||
@@ -72,7 +91,7 @@ struct GamepadSettingsView: View {
|
||||
focusID: $focusID,
|
||||
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
|
||||
onActivate: { activate(id: $0.id) },
|
||||
onBack: { dismiss() }
|
||||
onBack: { back() }
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||
@@ -80,7 +99,7 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
Text("Settings")
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
@@ -96,11 +115,7 @@ struct GamepadSettingsView: View {
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
.animation(.smooth(duration: 0.2), value: focusID)
|
||||
GamepadHintBar(hints: [
|
||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
])
|
||||
GamepadHintBar(hints: hints)
|
||||
}
|
||||
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
@@ -138,6 +153,43 @@ struct GamepadSettingsView: View {
|
||||
.accessibilityLabel("Close settings")
|
||||
}
|
||||
|
||||
/// "Settings", or "Pin “Work”" while the pin picker is up — the title is what says which
|
||||
/// layer the row list currently is.
|
||||
private var title: String {
|
||||
pinTarget.map { "Pin “\($0.name)”" } ?? "Settings"
|
||||
}
|
||||
|
||||
/// The legend follows the layer: value-editing hints on the settings rows, pin/unpin on the
|
||||
/// picker — where B reads "Back" (it peels to the settings rows, GamepadAddHostView's "one
|
||||
/// layer" rule), and a hostless picker has nothing to pin, so only Back remains.
|
||||
private var hints: [GamepadHint] {
|
||||
guard pinTarget != nil else {
|
||||
return [
|
||||
.init(glyph: "arrow.left.and.right", text: "Adjust"),
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
|
||||
]
|
||||
}
|
||||
guard !store.hosts.isEmpty else {
|
||||
return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back")]
|
||||
}
|
||||
return [
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"),
|
||||
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"),
|
||||
]
|
||||
}
|
||||
|
||||
/// B peels one layer: the pin picker back to the settings rows — focus returning to the
|
||||
/// profile row it came from — then the screen itself.
|
||||
private func back() {
|
||||
if let profile = pinTarget {
|
||||
pinTarget = nil
|
||||
focusID = "profile-\(profile.id)"
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row rendering
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
@@ -164,7 +216,7 @@ struct GamepadSettingsView: View {
|
||||
HStack(spacing: 9) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
|
||||
// Keyed by the value so a change slides the new option in instead of
|
||||
// hard-swapping the string — a QUIET horizontal slip following the user's
|
||||
// motion (a right-step enters from the right), crossfading over ~14 pt.
|
||||
@@ -185,7 +237,7 @@ struct GamepadSettingsView: View {
|
||||
.animation(.smooth(duration: 0.22), value: row.value)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||
.foregroundStyle(.white.opacity(focused && row.adjustable ? 0.6 : 0))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, m.rowHPad)
|
||||
@@ -219,6 +271,9 @@ struct GamepadSettingsView: View {
|
||||
let value: String
|
||||
/// One-line explanation shown near the hint bar while this row is focused.
|
||||
let detail: String
|
||||
/// Whether left/right means anything here — false hides the value's chevrons (the
|
||||
/// Profiles rows navigate, and the placeholder rows do nothing at all).
|
||||
var adjustable = true
|
||||
/// Left/right step; returns whether the value actually changed (false ⇒ boundary thud).
|
||||
let adjust: (Int) -> Bool
|
||||
/// A — cycle forward (wrapping) / flip.
|
||||
@@ -238,6 +293,9 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
|
||||
private var rows: [Row] {
|
||||
// The pin picker replaces the whole list while it's up — same screen, one layer deeper,
|
||||
// so the focus list's controller wiring (and the tvOS focus engine) carries over as is.
|
||||
if let profile = pinTarget { return pinRows(for: profile) }
|
||||
let resolution = resolutionOptions
|
||||
let refresh = SettingsOptions.refreshRates(including: hz)
|
||||
.map { (label: "\($0) Hz", tag: $0) }
|
||||
@@ -394,7 +452,98 @@ struct GamepadSettingsView: View {
|
||||
at: at + 1)
|
||||
}
|
||||
#endif
|
||||
return list
|
||||
return list + profileRows
|
||||
}
|
||||
|
||||
// MARK: - Profiles (§5.2a)
|
||||
|
||||
/// The trailing Profiles section: one row per catalog profile, its value how many saved
|
||||
/// hosts pin it, A opening the pin-to-hosts picker. Read-only beyond that — this surface
|
||||
/// pins and unpins, but profiles are created and edited elsewhere (design §5.4), so
|
||||
/// left/right is a boundary thud, not an editor.
|
||||
private var profileRows: [Row] {
|
||||
guard !profiles.profiles.isEmpty else {
|
||||
return [Row(
|
||||
id: "noProfiles", header: "Profiles", icon: "slider.horizontal.3",
|
||||
label: "No profiles yet", value: "",
|
||||
detail: emptyCatalogDetail,
|
||||
adjustable: false,
|
||||
adjust: { _ in false }, activate: {})]
|
||||
}
|
||||
return profiles.profiles.enumerated().map { i, profile in
|
||||
let pins = store.hosts
|
||||
.filter { ($0.pinnedProfileIDs ?? []).contains(profile.id) }.count
|
||||
return Row(
|
||||
id: "profile-\(profile.id)", header: i == 0 ? "Profiles" : nil,
|
||||
icon: "slider.horizontal.3", label: profile.name,
|
||||
value: pins == 0 ? "Not pinned" : "Pinned to \(pins) host\(pins == 1 ? "" : "s")",
|
||||
detail: profileDetail,
|
||||
adjustable: false,
|
||||
adjust: { _ in false },
|
||||
activate: {
|
||||
// Focus lands on the picker's first row — the focus list's reconcile
|
||||
// follows this id when the row set swaps underneath it.
|
||||
focusID = store.hosts.first.map { "pinHost-\($0.id.uuidString)" } ?? "noHosts"
|
||||
pinTarget = profile
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The pin-to-hosts picker: one toggle row per SAVED host, sharing the settings rows'
|
||||
/// toggle semantics (left = unpin, right = pin, A flips; asking for the state it's in is a
|
||||
/// boundary thud). Writes ride `HostStore.setPinned` — pin appends, unpin removes — and
|
||||
/// NEVER the host's default binding (`profileID`): a pin is presentation only (§5.2a).
|
||||
private func pinRows(for profile: StreamProfile) -> [Row] {
|
||||
guard !store.hosts.isEmpty else {
|
||||
return [Row(
|
||||
id: "noHosts", icon: "desktopcomputer", label: "No saved hosts yet",
|
||||
value: "",
|
||||
detail: "Pair with a host first, then pin this profile to it.",
|
||||
adjustable: false,
|
||||
adjust: { _ in false }, activate: {})]
|
||||
}
|
||||
return store.hosts.map { host in
|
||||
let hostID = host.id
|
||||
let pinned = (host.pinnedProfileIDs ?? []).contains(profile.id)
|
||||
return Row(
|
||||
id: "pinHost-\(hostID.uuidString)", icon: "desktopcomputer",
|
||||
label: host.displayName,
|
||||
value: pinned ? "Pinned" : "Off",
|
||||
detail: "A pinned profile appears as its own card on the host — one press "
|
||||
+ "connects with it.",
|
||||
adjust: { delta in
|
||||
let target = delta > 0
|
||||
guard pinned != target else { return false }
|
||||
store.setPinned(hostID, profileID: profile.id, pinned: target)
|
||||
return true
|
||||
},
|
||||
activate: { store.setPinned(hostID, profileID: profile.id, pinned: !pinned) })
|
||||
}
|
||||
}
|
||||
|
||||
/// The profile rows' explainer. tvOS gets its own: the catalog is per-device (the App Group
|
||||
/// suite — nothing syncs it) and tvOS has no profile editor at all (§5.4), so pointing a TV
|
||||
/// user at a "standard interface" would promise profiles that can never arrive there.
|
||||
private var profileDetail: String {
|
||||
#if os(tvOS)
|
||||
return "Pin this profile to a host and it appears as its own card on the home screen — "
|
||||
+ "one press connects with it."
|
||||
#else
|
||||
return "Pin this profile to a host and it appears as its own card — one press connects "
|
||||
+ "with it. Profiles are created and edited in Punktfunk's standard interface."
|
||||
#endif
|
||||
}
|
||||
|
||||
/// What the empty catalog's placeholder explains — again honest on tvOS, where profiles
|
||||
/// cannot be created (on the device or anywhere that would reach its per-device catalog).
|
||||
private var emptyCatalogDetail: String {
|
||||
#if os(tvOS)
|
||||
return "Profiles bundle stream settings for different uses. Creating them isn't "
|
||||
+ "available on Apple TV yet."
|
||||
#else
|
||||
return "Profiles bundle stream settings for different uses. Create them in Punktfunk's "
|
||||
+ "standard interface, then pin them here as one-press connect cards."
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode
|
||||
|
||||
@@ -79,20 +79,22 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
can_wake: false,
|
||||
last_used: k.and_then(|h| h.last_used),
|
||||
os: k.map(|h| h.os.clone()).unwrap_or_default(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
};
|
||||
let label = row.name.clone();
|
||||
if k.is_none() {
|
||||
seed = Some(row.clone());
|
||||
}
|
||||
if row.paired {
|
||||
(ConsoleEntry::Library(row), Some(label))
|
||||
(ConsoleEntry::Library(Box::new(row)), Some(label))
|
||||
} else {
|
||||
(ConsoleEntry::Home, Some(label))
|
||||
}
|
||||
}
|
||||
None if fake => {
|
||||
let row = fake_host_row();
|
||||
(ConsoleEntry::Library(row), None)
|
||||
(ConsoleEntry::Library(Box::new(row)), None)
|
||||
}
|
||||
None => (ConsoleEntry::Home, None),
|
||||
};
|
||||
@@ -207,6 +209,7 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
launch,
|
||||
title,
|
||||
request_access,
|
||||
profile,
|
||||
} => {
|
||||
let Some(pin) = trust::parse_hex32(&fp_hex) else {
|
||||
// Connect (and request-access) pin the host's advertised fingerprint;
|
||||
@@ -221,9 +224,11 @@ pub fn run(target: Option<&str>) -> u8 {
|
||||
// have changed the defaults since the last stream, and the host may carry
|
||||
// a profile binding. Console (and therefore Decky, which spawns this
|
||||
// binary) honors bindings with no console-side work — the resolver is the
|
||||
// same one `--connect` goes through. No one-off here: picking a profile is
|
||||
// a desktop-shell affordance in v1, pinned cards are the console's.
|
||||
let (settings, profile) = trust::effective_settings(&addr, port, None);
|
||||
// same one `--connect` goes through. A pinned card's connect arrives as a
|
||||
// one-off profile id; the resolver prefers it over the binding, and a
|
||||
// dangling id falls back to the defaults without blocking the connect.
|
||||
let (settings, profile) =
|
||||
trust::effective_settings(&addr, port, profile.as_deref());
|
||||
let mut params = session_params(
|
||||
&settings,
|
||||
profile.map(|p| p.name),
|
||||
@@ -303,6 +308,8 @@ fn fake_host_row() -> HostRow {
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: "linux/arch/steamos".into(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,6 +513,38 @@ impl ServiceState {
|
||||
ConsoleCmd::Probe => {
|
||||
self.last_probe = Instant::now() - Duration::from_secs(60);
|
||||
}
|
||||
ConsoleCmd::SetPin {
|
||||
key,
|
||||
profile_id,
|
||||
pin,
|
||||
} => {
|
||||
// Presentation only (design §5.2a): order = card order, appended at the
|
||||
// end; never touches `profile_id` (the default binding). Idempotent, so
|
||||
// a repeated press inside one refresh window can't double-pin.
|
||||
let mut known = trust::KnownHosts::load();
|
||||
let idx = known
|
||||
.hosts
|
||||
.iter()
|
||||
.position(|h| !h.fp_hex.is_empty() && h.fp_hex == key)
|
||||
.or_else(|| {
|
||||
let (addr, port) = key.rsplit_once(':')?;
|
||||
known.index_by_addr(addr, port.parse().ok()?)
|
||||
});
|
||||
let Some(h) = idx.and_then(|i| known.hosts.get_mut(i)) else {
|
||||
tracing::warn!(%key, "pin toggle for an unknown host — ignoring");
|
||||
return;
|
||||
};
|
||||
if pin && !h.pinned_profiles.contains(&profile_id) {
|
||||
h.pinned_profiles.push(profile_id);
|
||||
} else if !pin {
|
||||
h.pinned_profiles.retain(|id| *id != profile_id);
|
||||
}
|
||||
if let Err(e) = known.save() {
|
||||
tracing::warn!(error = %format!("{e:#}"), "saving known hosts");
|
||||
}
|
||||
// `run` refreshes the rows right after this drain, so the carousel and
|
||||
// the pin screen reflect the new card within the same service pass.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,12 +583,21 @@ impl ServiceState {
|
||||
})
|
||||
}
|
||||
|
||||
/// The console home's rows: saved hosts (most recent first), then
|
||||
/// discovered-but-unsaved ones, then a still-uncovered `--browse` seed.
|
||||
/// The console home's rows: saved hosts (most recent first) — each followed by its
|
||||
/// pinned profile cards (design §5.2a) — then discovered-but-unsaved ones, then a
|
||||
/// still-uncovered `--browse` seed.
|
||||
fn rows(&self) -> Vec<HostRow> {
|
||||
let known = trust::KnownHosts::load();
|
||||
let catalog = pf_client_core::profiles::ProfilesFile::load();
|
||||
let probed = self.probed.lock().unwrap();
|
||||
let mut rows: Vec<HostRow> = known
|
||||
let chip = |p: &pf_client_core::profiles::StreamProfile| pf_console_ui::ProfileChip {
|
||||
id: p.id.clone(),
|
||||
name: p.name.clone(),
|
||||
accent: p.accent.clone(),
|
||||
};
|
||||
// Primary rows paired with their pinned cards, so the sort below can order hosts
|
||||
// while every host's cards stay glued behind its primary tile.
|
||||
let mut saved: Vec<(HostRow, Vec<HostRow>)> = known
|
||||
.hosts
|
||||
.iter()
|
||||
.map(|h| {
|
||||
@@ -563,8 +611,8 @@ impl ServiceState {
|
||||
|| (d.addr == h.addr && d.port == h.port)
|
||||
});
|
||||
let online = advert.is_some() || probed.get(&key).copied().unwrap_or(false);
|
||||
HostRow {
|
||||
key,
|
||||
let row = HostRow {
|
||||
key: key.clone(),
|
||||
name: host_display_name(&h.name, &h.addr),
|
||||
addr: h.addr.clone(),
|
||||
port: h.port,
|
||||
@@ -581,10 +629,34 @@ impl ServiceState {
|
||||
.filter(|d| !d.os.is_empty())
|
||||
.map(|d| d.os.clone())
|
||||
.unwrap_or_else(|| h.os.clone()),
|
||||
}
|
||||
pin: None,
|
||||
bound_profile: h
|
||||
.profile_id
|
||||
.as_deref()
|
||||
.and_then(|id| catalog.find_by_id(id))
|
||||
.map(chip),
|
||||
};
|
||||
// A pinned card shares the primary tile's live state; its key rides the
|
||||
// profile id behind a NUL (impossible in a fingerprint or `addr:port`),
|
||||
// so cursor-follow and the wake path address the card itself.
|
||||
let pins = h
|
||||
.resolved_pins(&catalog)
|
||||
.into_iter()
|
||||
.map(|p| HostRow {
|
||||
key: format!("{key}\0{}", p.id),
|
||||
pin: Some(chip(p)),
|
||||
bound_profile: None,
|
||||
..row.clone()
|
||||
})
|
||||
.collect();
|
||||
(row, pins)
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by(|a, b| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name)));
|
||||
saved.sort_by(|(a, _), (b, _)| b.last_used.cmp(&a.last_used).then(a.name.cmp(&b.name)));
|
||||
let mut rows: Vec<HostRow> = saved
|
||||
.into_iter()
|
||||
.flat_map(|(row, pins)| std::iter::once(row).chain(pins))
|
||||
.collect();
|
||||
|
||||
let mut extra: Vec<HostRow> = self
|
||||
.discovered
|
||||
@@ -612,6 +684,8 @@ impl ServiceState {
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: d.os.clone(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
})
|
||||
.collect();
|
||||
extra.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
@@ -35,7 +35,9 @@ mod widgets;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub use library::{LibraryGame, LibraryPhase, LibraryShared};
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub use model::{ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, WakeStatus};
|
||||
pub use model::{
|
||||
ConsoleBus, ConsoleCmd, ConsoleShared, HostRow, PairPhase, ProfileChip, WakeStatus,
|
||||
};
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
pub use shell::ConsoleOptions;
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
|
||||
@@ -7,9 +7,20 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// A settings profile as the console shows it (design client-settings-profiles.md §5.2a):
|
||||
/// the resolved name and accent of a catalog entry, keyed by its stable id. The service
|
||||
/// thread resolves these against the catalog; the shell never opens the profiles file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ProfileChip {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// `#RRGGBB`, the catalog's optional tint for pinned cards.
|
||||
pub accent: Option<String>,
|
||||
}
|
||||
|
||||
/// One row on the console home carousel — a saved host, a discovered-but-unsaved one,
|
||||
/// or (client-side) the trailing Add Host tile. Fully resolved by the service thread;
|
||||
/// the shell renders it verbatim.
|
||||
/// a pinned profile card, or (client-side) the trailing Add Host tile. Fully resolved by
|
||||
/// the service thread; the shell renders it verbatim.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct HostRow {
|
||||
/// Stable identity across refreshes: the pinned fingerprint when known, else
|
||||
@@ -35,6 +46,14 @@ pub struct HostRow {
|
||||
/// future tile OS glyph. Empty = unknown (older host). Plumbed now; drawing is a
|
||||
/// follow-up — the Skia glyph set doesn't exist yet.
|
||||
pub os: String,
|
||||
/// `Some` = this row is a pinned profile card (§5.2a): a shortcut tile rendered right
|
||||
/// after its host's primary tile, sharing its live state, that connects with THIS
|
||||
/// profile. `None` = the host's primary tile.
|
||||
pub pin: Option<ProfileChip>,
|
||||
/// The primary tile's default-profile chip: the profile bound as this host's default
|
||||
/// (`KnownHost::profile_id`), resolved, so the tile can say what a plain A-press uses.
|
||||
/// Always `None` on pinned rows — there the profile IS `pin`.
|
||||
pub bound_profile: Option<ProfileChip>,
|
||||
}
|
||||
|
||||
/// The pairing ceremony's observable state (one at a time — the ceremony is modal).
|
||||
@@ -143,6 +162,16 @@ pub enum ConsoleCmd {
|
||||
CancelWake,
|
||||
/// Sweep reachability now (the home screen refreshes its presence pips).
|
||||
Probe,
|
||||
/// Pin (or unpin) a profile as an extra connect card on a saved host
|
||||
/// (`KnownHost::pinned_profiles`, design §5.2a). `key` is the HOST row's key
|
||||
/// (fingerprint or `addr:port`); presentation only — never touches the host's
|
||||
/// default binding or the profile itself. Idempotent: re-pinning a pinned profile
|
||||
/// (or unpinning an absent one) is a no-op.
|
||||
SetPin {
|
||||
key: String,
|
||||
profile_id: String,
|
||||
pin: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// The overlay→binary command queue. A plain deque under the same locking discipline as
|
||||
@@ -184,6 +213,8 @@ mod tests {
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
};
|
||||
shared.set_hosts(vec![row.clone()]);
|
||||
let g1 = shared.hosts_gen();
|
||||
|
||||
@@ -7,6 +7,7 @@ pub(crate) mod add_host;
|
||||
pub(crate) mod home;
|
||||
pub(crate) mod library;
|
||||
pub(crate) mod pair;
|
||||
pub(crate) mod pin_hosts;
|
||||
pub(crate) mod settings;
|
||||
|
||||
use crate::glyphs::Hint;
|
||||
@@ -57,6 +58,9 @@ pub(crate) struct ConnectIntent {
|
||||
/// shell shows a "waiting for approval" takeover instead of "connecting", and the
|
||||
/// binary parks on a long budget and persists the host as paired once let in.
|
||||
pub request_access: bool,
|
||||
/// One-off settings-profile id for this launch (a pinned card's connect); `None`
|
||||
/// keeps the host's default binding.
|
||||
pub profile: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) enum Nav {
|
||||
@@ -91,6 +95,7 @@ pub(crate) enum Screen {
|
||||
Settings(settings::SettingsScreen),
|
||||
AddHost(add_host::AddHostScreen),
|
||||
Pair(pair::PairScreen),
|
||||
PinHosts(pin_hosts::PinHostsScreen),
|
||||
}
|
||||
|
||||
impl Screen {
|
||||
@@ -106,6 +111,7 @@ impl Screen {
|
||||
Screen::Settings(s) => s.menu(ev, ctx, fx),
|
||||
Screen::AddHost(s) => s.menu(ev, ctx, fx),
|
||||
Screen::Pair(s) => s.menu(ev, ctx, fx),
|
||||
Screen::PinHosts(s) => s.menu(ev, ctx, fx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +158,7 @@ impl Screen {
|
||||
Screen::Settings(_) => "Settings".into(),
|
||||
Screen::AddHost(_) => "Add Host".into(),
|
||||
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
|
||||
Screen::PinHosts(s) => format!("Pin \u{201c}{}\u{201d}", s.profile_name()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +169,7 @@ impl Screen {
|
||||
Screen::Settings(s) => s.hints(ctx),
|
||||
Screen::AddHost(s) => s.hints(ctx),
|
||||
Screen::Pair(s) => s.hints(ctx),
|
||||
Screen::PinHosts(s) => s.hints(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +191,7 @@ impl Screen {
|
||||
Screen::Settings(s) => s.render(canvas, rect, k, dt, fonts, ctx),
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,13 +94,19 @@ impl HomeScreen {
|
||||
Some(h) => {
|
||||
// Dial-first even when the presence pips say offline — a
|
||||
// routed/VPN host is mDNS-blind and probe-shy but dials fine.
|
||||
// A pinned card connects with ITS profile (one-off, §5.2a);
|
||||
// the primary tile keeps the host's default binding.
|
||||
fx.connect = Some(ConnectIntent {
|
||||
addr: h.addr.clone(),
|
||||
port: h.port,
|
||||
fp_hex: h.fp_hex.clone(),
|
||||
launch: None,
|
||||
title: h.name.clone(),
|
||||
title: match &h.pin {
|
||||
Some(p) => format!("{} · {}", h.name, p.name),
|
||||
None => h.name.clone(),
|
||||
},
|
||||
request_access: false,
|
||||
profile: h.pin.as_ref().map(|p| p.id.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -295,16 +301,62 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
|
||||
|
||||
let max_w = f64::from(rect.width()) - 2.0 * pad;
|
||||
let sub_base = f64::from(rect.bottom) - pad;
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&format!("{}:{}", h.addr, h.port),
|
||||
l,
|
||||
sub_base,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
white(0.55),
|
||||
max_w,
|
||||
);
|
||||
match (&h.pin, &h.bound_profile) {
|
||||
// A pinned card: the profile name IS the subtitle, tinted with its accent —
|
||||
// the card's whole point is "this host, with these settings" (§5.2a).
|
||||
(Some(p), _) => {
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&p.name,
|
||||
l,
|
||||
sub_base,
|
||||
W::SemiBold,
|
||||
13.0 * k,
|
||||
accent_color(p.accent.as_deref()),
|
||||
max_w,
|
||||
);
|
||||
}
|
||||
// The primary tile says which profile a plain press uses, after the address.
|
||||
(None, Some(b)) => {
|
||||
let addr = format!("{}:{}", h.addr, h.port);
|
||||
let addr_w = f64::from(fonts.measure(&addr, W::Regular, 13.0 * k));
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&addr,
|
||||
l,
|
||||
sub_base,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
white(0.55),
|
||||
max_w,
|
||||
);
|
||||
let x = l + addr_w + 8.0 * k;
|
||||
if x < l + max_w {
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&format!("· {}", b.name),
|
||||
x,
|
||||
sub_base,
|
||||
W::SemiBold,
|
||||
13.0 * k,
|
||||
accent_color(b.accent.as_deref()),
|
||||
l + max_w - x,
|
||||
);
|
||||
}
|
||||
}
|
||||
(None, None) => {
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&format!("{}:{}", h.addr, h.port),
|
||||
l,
|
||||
sub_base,
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
white(0.55),
|
||||
max_w,
|
||||
);
|
||||
}
|
||||
}
|
||||
fonts.draw_clipped(
|
||||
canvas,
|
||||
&h.name,
|
||||
@@ -317,6 +369,26 @@ fn draw_host_tile(canvas: &Canvas, fonts: &Fonts, h: &HostRow, rect: Rect, k: f6
|
||||
);
|
||||
}
|
||||
|
||||
/// A profile's `#RRGGBB` accent as a color, defaulting to the brand tint. Parsed
|
||||
/// leniently — a malformed accent (hand-edited catalog) falls back rather than erroring.
|
||||
fn accent_color(accent: Option<&str>) -> skia_safe::Color4f {
|
||||
let Some(hex) = accent
|
||||
.and_then(|a| a.strip_prefix('#'))
|
||||
.filter(|h| h.len() == 6)
|
||||
else {
|
||||
return BRAND;
|
||||
};
|
||||
let Ok(v) = u32::from_str_radix(hex, 16) else {
|
||||
return BRAND;
|
||||
};
|
||||
skia_safe::Color4f::new(
|
||||
((v >> 16) & 0xff) as f32 / 255.0,
|
||||
((v >> 8) & 0xff) as f32 / 255.0,
|
||||
(v & 0xff) as f32 / 255.0,
|
||||
1.0,
|
||||
)
|
||||
}
|
||||
|
||||
fn draw_add_tile(canvas: &Canvas, fonts: &Fonts, rect: Rect, k: f64) {
|
||||
crate::theme::panel(
|
||||
canvas,
|
||||
@@ -484,6 +556,8 @@ mod tests {
|
||||
can_wake,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,6 +625,38 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// A pinned card's A-press is a connect WITH its profile (one-off), titled so the
|
||||
/// connecting takeover says which settings are coming (§5.2a).
|
||||
#[test]
|
||||
fn pinned_card_connects_with_its_profile() {
|
||||
let mut settings = ctx_settings();
|
||||
let mut pinned = host("ab\0p1", true, true, false);
|
||||
pinned.name = "Tower".into();
|
||||
pinned.pin = Some(crate::model::ProfileChip {
|
||||
id: "p1".into(),
|
||||
name: "Work".into(),
|
||||
accent: None,
|
||||
});
|
||||
let hosts = [pinned];
|
||||
let pads: Vec<pf_client_core::gamepad::PadInfo> = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "test",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = HomeScreen::new();
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
let intent = fx.connect.expect("a pinned card connects");
|
||||
assert_eq!(intent.profile.as_deref(), Some("p1"));
|
||||
assert_eq!(intent.title, "Tower · Work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_tile_is_always_last() {
|
||||
let mut settings = ctx_settings();
|
||||
|
||||
@@ -120,6 +120,8 @@ impl LibraryScreen {
|
||||
launch: Some(g.id.clone()),
|
||||
title: g.title.clone(),
|
||||
request_access: false,
|
||||
// Game launches follow the host's default binding.
|
||||
profile: None,
|
||||
});
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@ impl PairScreen {
|
||||
launch: None,
|
||||
title: self.host_name.clone(),
|
||||
request_access: true,
|
||||
profile: None,
|
||||
});
|
||||
fx.pop();
|
||||
}
|
||||
@@ -430,6 +431,8 @@ mod tests {
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
//! "Pin “Work”" — choose which saved hosts show a profile as an extra connect card
|
||||
//! (design/client-settings-profiles.md §5.2a), reached from the settings screen's
|
||||
//! Profiles section. One toggle row per saved host; a toggle rides
|
||||
//! [`ConsoleCmd::SetPin`] to the binary, which persists `KnownHost::pinned_profiles`
|
||||
//! and refreshes the rows — the row's shown state follows the model, so what the list
|
||||
//! says is always what the store holds (and what Decky's host list will render).
|
||||
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::model::ConsoleCmd;
|
||||
use crate::screens::{Ctx, Outbox};
|
||||
use crate::theme::{Fonts, DIM, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
use skia_safe::{Canvas, Rect};
|
||||
|
||||
pub(crate) struct PinHostsScreen {
|
||||
profile_id: String,
|
||||
profile_name: String,
|
||||
list: MenuList,
|
||||
}
|
||||
|
||||
/// The toggle rows' domain: every SAVED host, primary tiles only (a pinned card is the
|
||||
/// OUTPUT of this screen, not a row in it), in the model's carousel order.
|
||||
fn host_indices(ctx: &Ctx) -> Vec<usize> {
|
||||
ctx.hosts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, h)| h.saved && h.pin.is_none())
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl PinHostsScreen {
|
||||
pub(crate) fn new(profile_id: String, profile_name: String) -> PinHostsScreen {
|
||||
PinHostsScreen {
|
||||
profile_id,
|
||||
profile_name,
|
||||
list: MenuList::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn profile_name(&self) -> &str {
|
||||
&self.profile_name
|
||||
}
|
||||
|
||||
/// Is this profile currently pinned on the host at `ctx.hosts[host_idx]`? Read from
|
||||
/// the model — the pinned card's row IS the state, so the toggle can never disagree
|
||||
/// with what the carousel shows.
|
||||
fn pinned(&self, ctx: &Ctx, host_idx: usize) -> bool {
|
||||
let host = &ctx.hosts[host_idx];
|
||||
ctx.hosts.iter().any(|r| {
|
||||
r.addr == host.addr
|
||||
&& r.port == host.port
|
||||
&& r.pin.as_ref().is_some_and(|p| p.id == self.profile_id)
|
||||
})
|
||||
}
|
||||
|
||||
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 indices = host_indices(ctx);
|
||||
let (msg, pulse) = self.list.menu(ev, indices.len());
|
||||
let Some(&host_idx) = indices.get(self.list.cursor) else {
|
||||
return pulse;
|
||||
};
|
||||
// Toggle semantics shared with the settings rows: left = unpin, right = pin,
|
||||
// A flips; asking for the state it's already in is a boundary thud.
|
||||
let target = match msg {
|
||||
ListMsg::Adjust(delta) => delta > 0,
|
||||
ListMsg::Activate => !self.pinned(ctx, host_idx),
|
||||
ListMsg::None => return pulse,
|
||||
};
|
||||
if self.pinned(ctx, host_idx) == target {
|
||||
return Some(MenuPulse::Boundary);
|
||||
}
|
||||
fx.cmds.push(ConsoleCmd::SetPin {
|
||||
key: ctx.hosts[host_idx].key.clone(),
|
||||
profile_id: self.profile_id.clone(),
|
||||
pin: target,
|
||||
});
|
||||
Some(MenuPulse::Move)
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, ctx: &Ctx) -> Vec<Hint> {
|
||||
if host_indices(ctx).is_empty() {
|
||||
return vec![Hint::new(HintKey::Back, "Done")];
|
||||
}
|
||||
vec![
|
||||
Hint::new(HintKey::Confirm, "Pin / Unpin"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
canvas: &Canvas,
|
||||
rect: Rect,
|
||||
k: f64,
|
||||
dt: f64,
|
||||
fonts: &Fonts,
|
||||
ctx: &mut Ctx,
|
||||
) {
|
||||
let indices = host_indices(ctx);
|
||||
let cx = f64::from(rect.left) + f64::from(rect.width()) / 2.0;
|
||||
if indices.is_empty() {
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"No saved hosts yet — pair with a host first, then pin this profile to it.",
|
||||
W::Regular,
|
||||
14.0 * k,
|
||||
DIM,
|
||||
cx,
|
||||
f64::from(rect.top) + f64::from(rect.height()) / 2.0,
|
||||
f64::from(rect.width()) * 0.7,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The explainer band under the list, like the settings screen's detail text.
|
||||
let detail_h = 34.0 * k;
|
||||
let list_rect = Rect::from_ltrb(
|
||||
rect.left,
|
||||
rect.top,
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
);
|
||||
let rows: Vec<RowSpec> = indices
|
||||
.iter()
|
||||
.map(|&i| {
|
||||
let h = &ctx.hosts[i];
|
||||
let pinned = self.pinned(ctx, i);
|
||||
RowSpec {
|
||||
header: None,
|
||||
label: h.name.clone(),
|
||||
value: Some(if pinned {
|
||||
"Pinned".into()
|
||||
} else {
|
||||
"Off".into()
|
||||
}),
|
||||
value_dim: !pinned,
|
||||
caret: false,
|
||||
adjustable: true,
|
||||
enabled: true,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.list
|
||||
.render(canvas, list_rect, &rows, fonts, k, dt, true);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
"A pinned profile appears as its own card on the host — one press connects with it.",
|
||||
W::Regular,
|
||||
13.0 * k,
|
||||
DIM,
|
||||
cx,
|
||||
f64::from(rect.bottom) - detail_h + 6.0 * k,
|
||||
f64::from(rect.width()) * 0.8,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{HostRow, ProfileChip};
|
||||
use crate::screens::Outbox;
|
||||
use pf_client_core::trust::Settings;
|
||||
|
||||
fn host(key: &str, saved: bool, pin: Option<&str>) -> HostRow {
|
||||
HostRow {
|
||||
key: key.into(),
|
||||
name: key.into(),
|
||||
addr: "10.0.0.9".into(),
|
||||
port: 9777,
|
||||
fp_hex: key.into(),
|
||||
paired: true,
|
||||
saved,
|
||||
online: true,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: pin.map(|id| ProfileChip {
|
||||
id: id.into(),
|
||||
name: "Work".into(),
|
||||
accent: None,
|
||||
}),
|
||||
bound_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggling_sends_set_pin_for_the_focused_host() {
|
||||
let mut settings = Settings::default();
|
||||
let pads = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let hosts = [host("aa", true, None), host("bb", true, None)];
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = PinHostsScreen::new("p1".into(), "Work".into());
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::SetPin {
|
||||
key: "aa".into(),
|
||||
profile_id: "p1".into(),
|
||||
pin: true,
|
||||
}]
|
||||
);
|
||||
|
||||
// Left on an unpinned host = already off = boundary, no command.
|
||||
let mut fx = Outbox::default();
|
||||
let pulse = s.menu(
|
||||
MenuEvent::Move(pf_client_core::gamepad::MenuDir::Left),
|
||||
&mut ctx,
|
||||
&mut fx,
|
||||
);
|
||||
assert!(fx.cmds.is_empty());
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_reads_from_the_models_pinned_rows() {
|
||||
let mut settings = Settings::default();
|
||||
let pads = Vec::new();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
// Host "aa" already carries a pinned card for p1; its primary row toggles OFF.
|
||||
let hosts = [host("aa", true, None), host("aa\0p1", true, Some("p1"))];
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = PinHostsScreen::new("p1".into(), "Work".into());
|
||||
// Only the primary row is a toggle row.
|
||||
assert_eq!(host_indices(&ctx).len(), 1);
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert_eq!(
|
||||
fx.cmds,
|
||||
vec![ConsoleCmd::SetPin {
|
||||
key: "aa".into(),
|
||||
profile_id: "p1".into(),
|
||||
pin: false,
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
//! read the same file, so values round-trip freely.
|
||||
|
||||
use crate::glyphs::{Hint, HintKey};
|
||||
use crate::screens::{Ctx, Outbox};
|
||||
use crate::screens::{Ctx, Outbox, Screen};
|
||||
use crate::theme::{Fonts, DIM, W};
|
||||
use crate::widgets::{ListMsg, MenuList, RowSpec};
|
||||
use pf_client_core::gamepad::{MenuEvent, MenuPulse};
|
||||
@@ -15,8 +15,13 @@ use skia_safe::{Canvas, Rect};
|
||||
|
||||
/// Stable row identity — adjust/activate dispatch by id so nothing acts on a stale
|
||||
/// index when the pad list under the "Use controller" row churns.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum RowId {
|
||||
/// A catalog profile (index into [`SettingsScreen::profiles`]) — activating opens
|
||||
/// the pin-to-hosts screen. The console never edits profiles (design §5.4).
|
||||
Profile(usize),
|
||||
/// The Profiles section's placeholder while the catalog is empty.
|
||||
NoProfiles,
|
||||
Resolution,
|
||||
Refresh,
|
||||
RenderScale,
|
||||
@@ -50,7 +55,8 @@ enum RowId {
|
||||
// Gaming Mode, so a field it omits is simply unreachable there (render scale, 4:4:4,
|
||||
// scroll/shortcut behavior, fullscreen-on-stream, auto-wake, the library toggle and echo
|
||||
// cancellation all were). Still deliberately smaller than the desktop dialogs — device
|
||||
// pickers (GPU/speaker/mic) and the profile catalog stay desktop-only.
|
||||
// pickers (GPU/speaker/mic) stay desktop-only, and profiles are pinnable here (the
|
||||
// trailing Profiles section) but created and edited only in the desktop app (design §5.4).
|
||||
const ROWS: [RowId; 27] = [
|
||||
RowId::Resolution,
|
||||
RowId::Refresh,
|
||||
@@ -149,15 +155,42 @@ const PAD_TYPES: [(&str, &str); 6] = [
|
||||
|
||||
pub(crate) struct SettingsScreen {
|
||||
list: MenuList,
|
||||
/// The profile catalog's `(id, name)` pairs, loaded once at construction — the console
|
||||
/// can't create profiles (design §5.4: the desktop app does), so the list is stable
|
||||
/// for the screen's lifetime.
|
||||
profiles: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl SettingsScreen {
|
||||
pub(crate) fn new() -> SettingsScreen {
|
||||
Self::with_profiles(
|
||||
pf_client_core::profiles::ProfilesFile::load()
|
||||
.profiles
|
||||
.into_iter()
|
||||
.map(|p| (p.id, p.name))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn with_profiles(profiles: Vec<(String, String)>) -> SettingsScreen {
|
||||
SettingsScreen {
|
||||
list: MenuList::new(),
|
||||
profiles,
|
||||
}
|
||||
}
|
||||
|
||||
/// The full row list: the fixed settings rows, then the Profiles section — one row
|
||||
/// per catalog profile, or the explainer placeholder while there are none.
|
||||
fn row_ids(&self) -> Vec<RowId> {
|
||||
let mut ids = ROWS.to_vec();
|
||||
if self.profiles.is_empty() {
|
||||
ids.push(RowId::NoProfiles);
|
||||
} else {
|
||||
ids.extend((0..self.profiles.len()).map(RowId::Profile));
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
pub(crate) fn menu(
|
||||
&mut self,
|
||||
ev: MenuEvent,
|
||||
@@ -168,7 +201,31 @@ impl SettingsScreen {
|
||||
fx.pop();
|
||||
return None;
|
||||
}
|
||||
let (msg, pulse) = self.list.menu(ev, ROWS.len());
|
||||
let ids = self.row_ids();
|
||||
let (msg, pulse) = self.list.menu(ev, ids.len());
|
||||
// The Profiles rows navigate instead of editing the settings file.
|
||||
match ids[self.list.cursor] {
|
||||
RowId::Profile(i) => {
|
||||
return match msg {
|
||||
ListMsg::Activate => {
|
||||
let (id, name) = self.profiles[i].clone();
|
||||
fx.push(Screen::PinHosts(super::pin_hosts::PinHostsScreen::new(
|
||||
id, name,
|
||||
)));
|
||||
pulse
|
||||
}
|
||||
ListMsg::Adjust(_) => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
}
|
||||
}
|
||||
RowId::NoProfiles => {
|
||||
return match msg {
|
||||
ListMsg::Adjust(_) | ListMsg::Activate => Some(MenuPulse::Boundary),
|
||||
ListMsg::None => pulse,
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Rebase the shell-lifetime snapshot on the file before an adjust-then-save: this
|
||||
// screen is one of the settings file's several whole-file writers (profiles.rs
|
||||
// documents the no-merge debt), and adjusting a stale snapshot would silently
|
||||
@@ -180,7 +237,7 @@ impl SettingsScreen {
|
||||
}
|
||||
match msg {
|
||||
ListMsg::Adjust(delta) => {
|
||||
let changed = adjust(ROWS[self.list.cursor], delta, false, ctx);
|
||||
let changed = adjust(ids[self.list.cursor], delta, false, ctx);
|
||||
if changed {
|
||||
ctx.settings.save();
|
||||
Some(MenuPulse::Move)
|
||||
@@ -190,7 +247,7 @@ impl SettingsScreen {
|
||||
}
|
||||
ListMsg::Activate => {
|
||||
// A cycles forward WRAPPING, so every option is reachable one-handed.
|
||||
if adjust(ROWS[self.list.cursor], 1, true, ctx) {
|
||||
if adjust(ids[self.list.cursor], 1, true, ctx) {
|
||||
ctx.settings.save();
|
||||
}
|
||||
pulse
|
||||
@@ -200,11 +257,18 @@ impl SettingsScreen {
|
||||
}
|
||||
|
||||
pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec<Hint> {
|
||||
vec![
|
||||
Hint::new(HintKey::Adjust, "Adjust"),
|
||||
Hint::new(HintKey::Confirm, "Change"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
]
|
||||
match self.row_ids()[self.list.cursor] {
|
||||
RowId::Profile(_) => vec![
|
||||
Hint::new(HintKey::Confirm, "Pin to hosts…"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
],
|
||||
RowId::NoProfiles => vec![Hint::new(HintKey::Back, "Done")],
|
||||
_ => vec![
|
||||
Hint::new(HintKey::Adjust, "Adjust"),
|
||||
Hint::new(HintKey::Confirm, "Change"),
|
||||
Hint::new(HintKey::Back, "Done"),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
@@ -224,10 +288,14 @@ impl SettingsScreen {
|
||||
rect.right,
|
||||
rect.bottom - detail_h as f32,
|
||||
);
|
||||
let rows: Vec<RowSpec> = ROWS.iter().map(|id| row_spec(*id, ctx)).collect();
|
||||
let ids = self.row_ids();
|
||||
let rows: Vec<RowSpec> = ids
|
||||
.iter()
|
||||
.map(|id| row_spec(*id, ctx, &self.profiles))
|
||||
.collect();
|
||||
self.list
|
||||
.render(canvas, list_rect, &rows, fonts, k, dt, true);
|
||||
let detail = detail(ROWS[self.list.cursor]);
|
||||
let detail = detail(ids[self.list.cursor]);
|
||||
fonts.centered(
|
||||
canvas,
|
||||
detail,
|
||||
@@ -241,7 +309,38 @@ impl SettingsScreen {
|
||||
}
|
||||
}
|
||||
|
||||
fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
fn row_spec(id: RowId, ctx: &Ctx, profiles: &[(String, String)]) -> RowSpec {
|
||||
// The Profiles section: name + how many hosts pin it (counted from the live rows, so
|
||||
// it reflects what the carousel shows). Read-only here beyond opening the pin screen.
|
||||
match id {
|
||||
RowId::Profile(i) => {
|
||||
let (pid, name) = &profiles[i];
|
||||
let pins = ctx
|
||||
.hosts
|
||||
.iter()
|
||||
.filter(|h| h.pin.as_ref().is_some_and(|p| &p.id == pid))
|
||||
.count();
|
||||
return RowSpec {
|
||||
header: (i == 0).then_some("Profiles"),
|
||||
label: name.clone(),
|
||||
value: Some(match pins {
|
||||
0 => "Not pinned".into(),
|
||||
1 => "Pinned to 1 host".into(),
|
||||
n => format!("Pinned to {n} hosts"),
|
||||
}),
|
||||
value_dim: pins == 0,
|
||||
caret: false,
|
||||
adjustable: false,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
RowId::NoProfiles => {
|
||||
let mut row = RowSpec::action("No profiles yet", false);
|
||||
row.header = Some("Profiles");
|
||||
return row;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let s = &ctx.settings;
|
||||
// Several rows follow another: echo cancellation only means anything while the mic
|
||||
// streams, the pad rows only while any controller is forwarded at all, and the
|
||||
@@ -382,6 +481,7 @@ fn row_spec(id: RowId, ctx: &Ctx) -> RowSpec {
|
||||
),
|
||||
RowId::AutoWake => (None, "Wake hosts automatically", on_off(s.auto_wake).into()),
|
||||
RowId::Library => (None, "Game library", on_off(s.library_enabled).into()),
|
||||
RowId::Profile(_) | RowId::NoProfiles => unreachable!("returned above"),
|
||||
};
|
||||
RowSpec {
|
||||
header,
|
||||
@@ -477,6 +577,16 @@ fn detail(id: RowId) -> &'static str {
|
||||
reached over a VPN, where the wake wait only adds delay."
|
||||
}
|
||||
RowId::Library => "Show paired hosts' game libraries (tap a title to stream it).",
|
||||
RowId::Profile(_) => {
|
||||
"Pin this profile to a host and it appears as its own card — one press \
|
||||
connects with these settings. Profiles are created and edited in the \
|
||||
Punktfunk desktop app."
|
||||
}
|
||||
RowId::NoProfiles => {
|
||||
"Profiles bundle stream settings for different uses (a low-latency one, a \
|
||||
quality one…). Create them in the Punktfunk desktop app, then pin them \
|
||||
here as one-press connect cards."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,6 +721,8 @@ fn adjust(id: RowId, delta: i32, wrap: bool, ctx: &mut Ctx) -> bool {
|
||||
RowId::Fullscreen => toggle(&mut s.fullscreen_on_stream, delta, wrap),
|
||||
RowId::AutoWake => toggle(&mut s.auto_wake, delta, wrap),
|
||||
RowId::Library => toggle(&mut s.library_enabled, delta, wrap),
|
||||
// Navigation rows, handled before the settings path in `menu` — never a value edit.
|
||||
RowId::Profile(_) | RowId::NoProfiles => None,
|
||||
}
|
||||
.is_some()
|
||||
}
|
||||
@@ -736,7 +848,7 @@ mod tests {
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(!row_spec(RowId::EchoCancel, &ctx).enabled);
|
||||
assert!(!row_spec(RowId::EchoCancel, &ctx, &[]).enabled);
|
||||
assert!(
|
||||
!adjust(RowId::EchoCancel, -1, false, &mut ctx),
|
||||
"mic off = thud"
|
||||
@@ -745,7 +857,7 @@ mod tests {
|
||||
assert!(ctx.settings.echo_cancel, "and nothing was written");
|
||||
|
||||
ctx.settings.mic_enabled = true;
|
||||
assert!(row_spec(RowId::EchoCancel, &ctx).enabled);
|
||||
assert!(row_spec(RowId::EchoCancel, &ctx, &[]).enabled);
|
||||
assert!(adjust(RowId::EchoCancel, -1, false, &mut ctx));
|
||||
assert!(!ctx.settings.echo_cancel);
|
||||
assert!(adjust(RowId::EchoCancel, 1, true, &mut ctx));
|
||||
@@ -771,7 +883,7 @@ mod tests {
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled);
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
||||
assert!(
|
||||
!adjust(RowId::SmoothBuffer, 1, false, &mut ctx),
|
||||
"latency intent = thud"
|
||||
@@ -781,14 +893,14 @@ mod tests {
|
||||
// Stepping the intent to Smoothness brings the buffer row to life.
|
||||
assert!(adjust(RowId::PresentPriority, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.present_priority, "smooth");
|
||||
assert!(row_spec(RowId::SmoothBuffer, &ctx).enabled);
|
||||
assert!(row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
||||
assert!(adjust(RowId::SmoothBuffer, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.smooth_buffer, 1);
|
||||
|
||||
// The intent wraps back and the row goes inert again.
|
||||
assert!(adjust(RowId::PresentPriority, -1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.present_priority, "latency");
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx).enabled);
|
||||
assert!(!row_spec(RowId::SmoothBuffer, &ctx, &[]).enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -864,4 +976,110 @@ mod tests {
|
||||
assert!(adjust(RowId::Bitrate, 1, false, &mut ctx));
|
||||
assert_eq!(ctx.settings.bitrate_kbps, 0, "snapped to Automatic");
|
||||
}
|
||||
|
||||
/// The Profiles section trails the settings rows: one row per catalog profile whose
|
||||
/// value counts the pinned cards in the live model, activating opens the pin screen,
|
||||
/// and left/right (which edits every other row) is a boundary — a profile row
|
||||
/// navigates, it must never fall into the settings save path.
|
||||
#[test]
|
||||
fn profile_rows_navigate_instead_of_editing() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut pinned = crate::model::HostRow {
|
||||
key: "aa\0p1".into(),
|
||||
name: "Tower".into(),
|
||||
addr: "10.0.0.9".into(),
|
||||
port: 9777,
|
||||
fp_hex: "aa".into(),
|
||||
paired: true,
|
||||
saved: true,
|
||||
online: true,
|
||||
mgmt_port: 47990,
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: Some(crate::model::ProfileChip {
|
||||
id: "p1".into(),
|
||||
name: "Work".into(),
|
||||
accent: None,
|
||||
}),
|
||||
bound_profile: None,
|
||||
};
|
||||
let hosts = [pinned.clone(), {
|
||||
pinned.key = "aa".into();
|
||||
pinned.pin = None;
|
||||
pinned
|
||||
}];
|
||||
let mut ctx = Ctx {
|
||||
hosts: &hosts,
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(vec![
|
||||
("p1".into(), "Work".into()),
|
||||
("p2".into(), "Game".into()),
|
||||
]);
|
||||
let ids = s.row_ids();
|
||||
assert_eq!(ids.len(), ROWS.len() + 2);
|
||||
assert_eq!(ids[ROWS.len()], RowId::Profile(0));
|
||||
|
||||
let spec = row_spec(RowId::Profile(0), &ctx, &s.profiles);
|
||||
assert_eq!(spec.header, Some("Profiles"));
|
||||
assert_eq!(spec.label, "Work");
|
||||
assert_eq!(spec.value.as_deref(), Some("Pinned to 1 host"));
|
||||
let spec = row_spec(RowId::Profile(1), &ctx, &s.profiles);
|
||||
assert_eq!(spec.header, None, "only the first row carries the header");
|
||||
assert_eq!(spec.value.as_deref(), Some("Not pinned"));
|
||||
|
||||
s.list.cursor = ROWS.len(); // onto "Work"
|
||||
let mut fx = Outbox::default();
|
||||
s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(
|
||||
matches!(fx.nav, Some(crate::screens::Nav::Push(b))
|
||||
if matches!(*b, Screen::PinHosts(ref p) if p.profile_name() == "Work")),
|
||||
"A on a profile row opens its pin screen"
|
||||
);
|
||||
|
||||
let mut fx = Outbox::default();
|
||||
let pulse = s.menu(
|
||||
MenuEvent::Move(pf_client_core::gamepad::MenuDir::Right),
|
||||
&mut ctx,
|
||||
&mut fx,
|
||||
);
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
assert!(fx.nav.is_none() && fx.cmds.is_empty());
|
||||
}
|
||||
|
||||
/// An empty catalog shows the explainer placeholder — present, inert, and dimmed —
|
||||
/// so the section still tells the user where profiles come from.
|
||||
#[test]
|
||||
fn empty_catalog_shows_the_placeholder() {
|
||||
let (mut settings, pads) = ctx_parts();
|
||||
let library = crate::library::LibraryShared::default();
|
||||
let mut ctx = Ctx {
|
||||
hosts: &[],
|
||||
library: &library,
|
||||
settings: &mut settings,
|
||||
pads: &pads,
|
||||
deck: false,
|
||||
device_name: "t",
|
||||
t: 0.0,
|
||||
};
|
||||
let mut s = SettingsScreen::with_profiles(Vec::new());
|
||||
let ids = s.row_ids();
|
||||
assert_eq!(*ids.last().unwrap(), RowId::NoProfiles);
|
||||
let spec = row_spec(RowId::NoProfiles, &ctx, &s.profiles);
|
||||
assert_eq!(spec.header, Some("Profiles"));
|
||||
assert!(!spec.enabled);
|
||||
|
||||
s.list.cursor = ids.len() - 1;
|
||||
let mut fx = Outbox::default();
|
||||
let pulse = s.menu(MenuEvent::Confirm, &mut ctx, &mut fx);
|
||||
assert!(matches!(pulse, Some(MenuPulse::Boundary)));
|
||||
assert!(fx.nav.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,8 +239,14 @@ impl Shell {
|
||||
port: h.port,
|
||||
fp_hex: h.fp_hex.clone(),
|
||||
launch: None,
|
||||
title: h.name.clone(),
|
||||
// A wake started from a pinned card carries its profile
|
||||
// through to the connect (the row's key found it again).
|
||||
title: match &h.pin {
|
||||
Some(p) => format!("{} · {}", h.name, p.name),
|
||||
None => h.name.clone(),
|
||||
},
|
||||
request_access: false,
|
||||
profile: h.pin.as_ref().map(|p| p.id.clone()),
|
||||
})
|
||||
});
|
||||
self.bus.send(ConsoleCmd::CancelWake);
|
||||
@@ -269,6 +275,7 @@ impl Shell {
|
||||
launch: intent.launch,
|
||||
title: intent.title,
|
||||
request_access: intent.request_access,
|
||||
profile: intent.profile,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ fn hosts() -> Vec<HostRow> {
|
||||
can_wake: false,
|
||||
last_used: None,
|
||||
os: String::new(),
|
||||
pin: None,
|
||||
bound_profile: None,
|
||||
};
|
||||
vec![
|
||||
HostRow {
|
||||
|
||||
@@ -88,8 +88,9 @@ pub enum ConsoleEntry {
|
||||
/// The host list (bare `--browse`).
|
||||
Home,
|
||||
/// Home with this host's library already pushed (`--browse host` — the Decky
|
||||
/// per-host launch; B backs out to Home).
|
||||
Library(HostRow),
|
||||
/// per-host launch; B backs out to Home). Boxed: `HostRow` outgrew the dataless
|
||||
/// `Home` variant when it learned its profile chips.
|
||||
Library(Box<HostRow>),
|
||||
}
|
||||
|
||||
/// The binary's ends of the console: models to write, commands to serve.
|
||||
|
||||
@@ -84,6 +84,11 @@ pub enum OverlayAction {
|
||||
fp_hex: String,
|
||||
launch: Option<String>,
|
||||
title: String,
|
||||
/// One-off settings-profile override for THIS launch (a profile id — a pinned
|
||||
/// card's connect). `None` resolves the host's default binding as before; the
|
||||
/// binary feeds it to `trust::effective_settings`, so a dangling id quietly
|
||||
/// falls back to the defaults and never blocks the connect.
|
||||
profile: Option<String>,
|
||||
/// The no-PIN delegated-approval path: pin the host's advertised fingerprint and
|
||||
/// open a connect the host PARKS until the operator approves this device in its
|
||||
/// console (a long connect budget), then persist it as paired. `false` = an
|
||||
|
||||
Reference in New Issue
Block a user