feat(apple): size the console UI for the iPad

GamepadFormMetrics had exactly two tiers, tvOS and everything-else, so a 13"
iPad Pro drew its settings rows, section tabs and legend at iPhone scale in
the middle of a very large display — the field verdict was that the sizing
"does not adapt to larger screens". There is a third tier now, between the
in-hand and 10-foot sets.

Chosen from the SIZE CLASSES, not the device idiom: an iPad in a narrow Stage
Manager or Split View window keeps the in-hand numbers, because what is being
read is the window, not the panel behind it. Phone sizing is untouched.

Published from ContentView — the app root — rather than only from
`gamepadPaletteInk`. A screen that applies that modifier itself sits ABOVE its
own copy of the environment, so its `@Environment` resolves against its parent
and would read the bare phone default no matter what the modifier published.
The screenshot harness publishes it too, for the same reason: it mounts these
screens with no ContentView above them, so an iPad capture used to render the
whole gamepad UI at iPhone scale.

macOS + tvOS typecheck; the console UI verified in the iPad simulator opening
Settings at the tablet tier.
This commit is contained in:
2026-08-10 07:44:36 +02:00
parent 08e462fee9
commit ffb1ecfebe
5 changed files with 147 additions and 53 deletions
@@ -115,6 +115,23 @@ struct ContentView: View {
/// scenePhase drives the keep-alive: use THIS, not the willResignActive observers resign-active
/// also fires for Control Center / app-switcher peeks, where the disconnect timer must not start.
@Environment(\.scenePhase) private var scenePhase
#if os(iOS)
@Environment(\.horizontalSizeClass) private var hSizeClass
@Environment(\.verticalSizeClass) private var vSizeClass
#endif
/// The gamepad UI's form-metric tier for this window, published from HERE the app's root.
/// A screen that applies `gamepadPaletteInk` itself sits ABOVE its own copy of the environment,
/// so its `@Environment` resolves against its parent; publishing at the root is what makes
/// every one of them (including the ones presented as sheets and covers, which inherit the
/// environment) read its own window's tier instead of the bare default.
private var gamepadMetrics: GamepadFormMetrics {
#if os(iOS)
.forWindow(h: hSizeClass, v: vSizeClass)
#else
.platformDefault
#endif
}
private var gamepadUIActive: Bool {
GamepadUIEnvironment.isActive(
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled,
@@ -181,6 +198,11 @@ struct ContentView: View {
}
private var driven: some View {
drivenBase
.environment(\.gamepadMetrics, gamepadMetrics)
}
private var drivenBase: some View {
Group {
// The stream view's structural identity MUST be stable across the
// awaiting-trust streaming transition: recreating it restarts the pump,
@@ -13,6 +13,7 @@ import SwiftUI
struct GamepadAddHostView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.dismiss) private var dismiss
@Environment(\.gamepadHostedInShell) private var hostedInShell
let onAdd: (StoredHost) -> Void
@@ -48,7 +49,7 @@ struct GamepadAddHostView: View {
isActive: controllerActive && editing == nil
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
.frame(maxWidth: metrics.rowMaxWidth)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity)
@@ -61,10 +62,10 @@ struct GamepadAddHostView: View {
if !compact {
Text("Hosts on this network appear automatically — add one by address "
+ "for everything else.")
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.font(.geist(metrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.multilineTextAlignment(.leading)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72, alignment: .leading)
.frame(maxWidth: metrics.rowMaxWidth * 0.72, alignment: .leading)
}
}
.padding(.horizontal, 24)
@@ -189,7 +190,7 @@ struct GamepadAddHostView: View {
}
private func rowView(_ row: Row, focused: Bool) -> some View {
let m = GamepadFormMetrics.self
let m = metrics
return HStack(spacing: 14) {
if row.isAction {
Label("Add Host", systemImage: "plus.circle.fill")
@@ -82,39 +82,100 @@ func gamepadTitleSize(compact: Bool) -> CGFloat {
}
/// Metrics shared by the gamepad form screens' glass rows (GamepadSettingsView,
/// GamepadAddHostView) one set of numbers so the two screens read as the same surface,
/// sized for the couch on tvOS and for the hand elsewhere.
enum GamepadFormMetrics {
#if os(tvOS)
static let headerFont: CGFloat = 17
static let labelFont: CGFloat = 23
static let valueFont: CGFloat = 21
static let iconFont: CGFloat = 24
static let iconWidth: CGFloat = 40
static let chevronFont: CGFloat = 16
static let rowHPad: CGFloat = 24
static let rowVPad: CGFloat = 19
static let rowCorner: CGFloat = 18
static let rowMaxWidth: CGFloat = 920
static let detailFont: CGFloat = 19
static let bandWidth: CGFloat = 380
#else
static let headerFont: CGFloat = 12
static let labelFont: CGFloat = 16
static let valueFont: CGFloat = 15
static let iconFont: CGFloat = 17
static let iconWidth: CGFloat = 28
static let chevronFont: CGFloat = 12
static let rowHPad: CGFloat = 16
static let rowVPad: CGFloat = 13
static let rowCorner: CGFloat = 14
static let rowMaxWidth: CGFloat = 620
static let detailFont: CGFloat = 13
/// GamepadAddHostView) one set of numbers so the screens read as the same surface, at the size
/// the screen they are on calls for.
///
/// Three tiers, not two. The phone numbers used to serve every non-TV device, so an iPad Pro drew
/// a settings list at iPhone scale in the middle of a 13" display the field verdict was that the
/// sizing "does not adapt to larger screens". `pad` sits between the in-hand and 10-foot sets.
///
/// Chosen from the SIZE CLASSES rather than the device idiom, so an iPad running a narrow Stage
/// Manager or Split View window correctly gets the in-hand numbers the window is what the user
/// is reading, not the panel it sits on.
struct GamepadFormMetrics {
/// Which set this is, for the few things that are a KIND of layout rather than a number.
enum Tier { case phone, pad, tv }
let tier: Tier
let headerFont: CGFloat
let labelFont: CGFloat
let valueFont: CGFloat
let iconFont: CGFloat
let iconWidth: CGFloat
let chevronFont: CGFloat
let rowHPad: CGFloat
let rowVPad: CGFloat
let rowCorner: CGFloat
let rowMaxWidth: CGFloat
let detailFont: CGFloat
/// The option band's (GamepadOptionBand) fixed stage inside a choice row.
static let bandWidth: CGFloat = 240
let bandWidth: CGFloat
/// The settings screen's section-tab pills.
let tabFont: CGFloat
/// The pinned controls legend (GamepadHintBar).
let hintGlyphFont: CGFloat
let hintTextFont: CGFloat
let hintPad: CGFloat
/// In-hand: a phone, or any window narrow enough to read like one.
static let phone = GamepadFormMetrics(
tier: .phone,
headerFont: 12, labelFont: 16, valueFont: 15, iconFont: 17, iconWidth: 28,
chevronFont: 12, rowHPad: 16, rowVPad: 13, rowCorner: 14, rowMaxWidth: 620,
detailFont: 13, bandWidth: 240,
tabFont: 13, hintGlyphFont: 19, hintTextFont: 14, hintPad: 13)
/// A tablet-sized window an arm's length away rather than in the palm.
static let pad = GamepadFormMetrics(
tier: .pad,
headerFont: 14, labelFont: 20, valueFont: 19, iconFont: 21, iconWidth: 34,
chevronFont: 14, rowHPad: 20, rowVPad: 16, rowCorner: 16, rowMaxWidth: 820,
detailFont: 16, bandWidth: 320,
tabFont: 16, hintGlyphFont: 23, hintTextFont: 17, hintPad: 15)
/// 10-foot.
static let tv = GamepadFormMetrics(
tier: .tv,
headerFont: 17, labelFont: 23, valueFont: 21, iconFont: 24, iconWidth: 40,
chevronFont: 16, rowHPad: 24, rowVPad: 19, rowCorner: 18, rowMaxWidth: 920,
detailFont: 19, bandWidth: 380,
tabFont: 17, hintGlyphFont: 27, hintTextFont: 20, hintPad: 18)
/// What a screen gets before anything publishes a tier and the only tier tvOS and macOS ever
/// use (an Apple TV is always 10-foot; a Mac window is read at desk distance).
static var platformDefault: GamepadFormMetrics {
#if os(tvOS)
tv
#else
phone
#endif
}
#if os(iOS)
/// The tier a window's size classes call for. REGULAR on both axes is the tablet case.
static func forWindow(
h: UserInterfaceSizeClass?, v: UserInterfaceSizeClass?
) -> GamepadFormMetrics {
h == .regular && v == .regular ? .pad : .phone
}
#endif
}
private struct GamepadMetricsKey: EnvironmentKey {
static let defaultValue = GamepadFormMetrics.platformDefault
}
extension EnvironmentValues {
/// The form metrics for the screen currently drawing. Published from ContentView the app
/// ROOT rather than only from `gamepadPaletteInk`, because a screen that applies that
/// modifier itself sits ABOVE its own copy: its `@Environment` resolves against its parent, so
/// it would read the bare default instead of its own window's tier.
var gamepadMetrics: GamepadFormMetrics {
get { self[GamepadMetricsKey.self] }
set { self[GamepadMetricsKey.self] = newValue }
}
}
/// One glyph + label cell in a hint bar.
struct GamepadHint: Identifiable {
let glyph: String
@@ -128,34 +189,26 @@ struct GamepadHint: Identifiable {
/// the backdrop instead of dissolving into it.
struct GamepadHintBar: View {
@Environment(\.gamepadInk) private var ink
/// Sized with the screen it pins to a legend at phone scale on a 13" iPad is the same
/// mismatch the form rows had (see GamepadFormMetrics).
@Environment(\.gamepadMetrics) private var metrics
let hints: [GamepadHint]
// 10-foot legend on tvOS, in-hand sizes elsewhere.
#if os(tvOS)
private static let glyphFont: CGFloat = 27
private static let textFont: CGFloat = 20
private static let pad: CGFloat = 18
#else
private static let glyphFont: CGFloat = 19
private static let textFont: CGFloat = 14
private static let pad: CGFloat = 13
#endif
var body: some View {
HStack(spacing: 18) {
ForEach(hints) { hint in
HStack(spacing: 7) {
Image(systemName: hint.glyph)
.font(.system(size: Self.glyphFont))
.font(.system(size: metrics.hintGlyphFont))
.foregroundStyle(ink.fg)
Text(hint.text)
}
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
}
}
.font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
.font(.geist(metrics.hintTextFont, .semibold, relativeTo: .subheadline))
.foregroundStyle(ink.fg(0.85))
.padding(Self.pad)
.padding(metrics.hintPad)
.consoleGlass(Capsule())
.overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1))
}
@@ -43,10 +43,27 @@ enum ScreenshotMode {
/// readiness ping for the capture script.
struct ScreenshotHostView: View {
let scene: ShotScene
#if os(iOS)
@Environment(\.horizontalSizeClass) private var hSizeClass
@Environment(\.verticalSizeClass) private var vSizeClass
#endif
/// The gamepad UI's form-metric tier, published here for the same reason ContentView does it:
/// this harness mounts those screens DIRECTLY, with no ContentView in the tree, so without it
/// an iPad capture renders every gamepad screen at iPhone scale a capture that doesn't look
/// like the app.
private var gamepadMetrics: GamepadFormMetrics {
#if os(iOS)
.forWindow(h: hSizeClass, v: vSizeClass)
#else
.platformDefault
#endif
}
var body: some View {
scene.make()
.environment(\.colorScheme, scene.colorScheme)
.environment(\.gamepadMetrics, gamepadMetrics)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Black fills the display, but the SCENE keeps its safe area. Ignoring it wholesale
// here pushed the stream hero's HUD under the Dynamic Island (the resolution/bitrate
@@ -46,6 +46,7 @@ enum GpSettingsTab: String, CaseIterable, Hashable {
struct GamepadSettingsView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.gamepadMetrics) private var metrics
@Environment(\.dismiss) private var dismiss
@Environment(\.gamepadHostedInShell) private var hostedInShell
/// The saved-host store the pin picker writes `setPinned` through it and the profile rows
@@ -142,7 +143,7 @@ struct GamepadSettingsView: View {
isActive: controllerActive
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
.frame(maxWidth: metrics.rowMaxWidth)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity)
@@ -165,7 +166,7 @@ struct GamepadSettingsView: View {
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text(focusedDetail)
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
.font(.geist(metrics.detailFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.lineLimit(2, reservesSpace: true)
.animation(.smooth(duration: 0.2), value: focusID)
@@ -252,7 +253,7 @@ struct GamepadSettingsView: View {
private func pill(_ t: GpSettingsTab) -> some View {
let selected = t == tab
return Text(t.rawValue)
.font(.geist(compact ? 12 : 13, .semibold, relativeTo: .footnote))
.font(.geist(compact ? 12 : metrics.tabFont, .semibold, relativeTo: .footnote))
// `onAccent`, not `fg` the selected pill is FILLED with the palette accent, and
// `onAccent` is the colour picked (by the accent's own luminance) to read on top of
// it; its doc calls out "a filled pill's label" for exactly this surface. Using the
@@ -368,7 +369,7 @@ struct GamepadSettingsView: View {
// MARK: - Row rendering
private func rowView(_ row: Row, focused: Bool) -> some View {
let m = GamepadFormMetrics.self
let m = metrics
// No section header: the tab strip names the section now, and repeating it above the
// first row of every tab was just a second label saying the same word.
return VStack(alignment: .leading, spacing: 6) {
@@ -446,9 +447,9 @@ struct GamepadSettingsView: View {
/// narrows the stage.
private var bandWidth: CGFloat {
#if os(iOS)
hSizeClass == .compact && vSizeClass == .regular ? 170 : GamepadFormMetrics.bandWidth
hSizeClass == .compact && vSizeClass == .regular ? 170 : metrics.bandWidth
#else
GamepadFormMetrics.bandWidth
metrics.bandWidth
#endif
}