From 8a8dd7809f6e0de97e5266cb61a3c9d48008abff Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 11 Jun 2026 13:38:37 +0200 Subject: [PATCH] =?UTF-8?q?fix(apple/tvOS):=20native=20form=20controls=20?= =?UTF-8?q?=E2=80=94=20pushed=20pickers,=20single-pill=20fields,=20centere?= =?UTF-8?q?d=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline iOS form widgets fought the tvOS focus system at every turn: focused fields showed nested pills, rows darkened oddly and grew on activation, the Compositor picker was not even focusable, and prefilled fields (port, client name) floated their label inside the pill, shoving the value off-center. - Settings is now a fully tv-native screen: NO inline text entry — the stream mode is a preset picker (This TV native / 720p / 1080p / 4K, plus a Custom entry preserving a mode set on another platform) and both pickers use .navigationLink style (pushed selection lists, exactly like the system Settings app — and properly focusable; the cover wraps in a NavigationStack for the pushes). - Where text entry is unavoidable (Add Host, PIN pairing), the fields keep their stock single-pill chrome (the grouped form style stays off tvOS — its row platters were one of the nested pills) and prefilled fields hide their floating label so values center vertically. - All earlier row-clearing experiments reverted. Verified by screenshot in the Apple TV simulator: Settings rows render as single focus lozenges with chevrons; the Add Host pills are uniform with centered text. Co-Authored-By: Claude Fable 5 --- .../apple/Punktfunk.xcodeproj/project.pbxproj | 4 +- .../PunktfunkClient/AddHostSheet.swift | 10 ++- .../Sources/PunktfunkClient/ContentView.swift | 6 +- .../Sources/PunktfunkClient/PairSheet.swift | 7 +- .../PunktfunkClient/SettingsView.swift | 78 +++++++++++++++++-- 5 files changed, 94 insertions(+), 11 deletions(-) diff --git a/clients/apple/Punktfunk.xcodeproj/project.pbxproj b/clients/apple/Punktfunk.xcodeproj/project.pbxproj index d21e31f9..b4fd44b7 100644 --- a/clients/apple/Punktfunk.xcodeproj/project.pbxproj +++ b/clients/apple/Punktfunk.xcodeproj/project.pbxproj @@ -487,6 +487,7 @@ CC0000000000000000000012 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = F4H37KF6WC; @@ -502,7 +503,6 @@ MARKETING_VERSION = 0.1; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.tvos; PRODUCT_NAME = "$(TARGET_NAME)"; - ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; SDKROOT = appletvos; SUPPORTED_PLATFORMS = "appletvos appletvsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -515,6 +515,7 @@ CC0000000000000000000013 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = F4H37KF6WC; @@ -530,7 +531,6 @@ MARKETING_VERSION = 0.1; PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.tvos; PRODUCT_NAME = "$(TARGET_NAME)"; - ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; SDKROOT = appletvos; SUPPORTED_PLATFORMS = "appletvos appletvsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift b/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift index 16fe4a31..9003b09c 100644 --- a/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift @@ -17,8 +17,16 @@ struct AddHostSheet: View { TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room")) TextField("Address", text: $address, prompt: Text("IP or hostname")) TextField("Port", value: $port, format: .number.grouping(.never)) + #if os(tvOS) + // tvOS floats the label above a non-empty field INSIDE the pill, + // shoving the value off-center — the field is always prefilled + // here, so drop the label there. + .labelsHidden() + #endif } - .formStyle(.grouped) + #if !os(tvOS) + .formStyle(.grouped) + #endif HStack { Button("Cancel", role: .cancel) { dismiss() } #if !os(tvOS) diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index 13ae2fa1..cfc7f60f 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -173,8 +173,10 @@ struct ContentView: View { .background(.thickMaterial, ignoresSafeAreaEdges: .all) } .fullScreenCover(isPresented: $showSettings) { - SettingsView() - .background(.thickMaterial, ignoresSafeAreaEdges: .all) + NavigationStack { + SettingsView() + } + .background(.thickMaterial, ignoresSafeAreaEdges: .all) } #else .sheet(isPresented: $showAddHost) { diff --git a/clients/apple/Sources/PunktfunkClient/PairSheet.swift b/clients/apple/Sources/PunktfunkClient/PairSheet.swift index 3a37dd01..20a3003a 100644 --- a/clients/apple/Sources/PunktfunkClient/PairSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/PairSheet.swift @@ -46,6 +46,9 @@ struct PairSheet: View { TextField( "Client name", text: $clientName, prompt: Text("How the host lists this Mac")) + #if os(tvOS) + .labelsHidden() // prefilled → tvOS floats the label off-center + #endif } header: { Label("Pair with \(host.displayName)", systemImage: "lock.shield") .foregroundStyle(.tint) @@ -65,7 +68,9 @@ struct PairSheet: View { } } } - .formStyle(.grouped) + #if !os(tvOS) + .formStyle(.grouped) + #endif HStack { Button("Cancel", role: .cancel) { token.cancelled = true diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift index 2bcff049..e358f436 100644 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -23,6 +23,79 @@ struct SettingsView: View { #endif var body: some View { + #if os(tvOS) + // Native tv pattern: no inline text entry (typing numbers with a remote is + // miserable and the inline field chrome fights the focus system). The mode is + // a preset picker; pickers push selection lists like the system Settings app. + tvBody + #else + sharedBody + #endif + } + + #if os(tvOS) + private static let presets: [(label: String, tag: String)] = [ + ("720p @ 60", "1280x720x60"), + ("1080p @ 60", "1920x1080x60"), + ("4K @ 60", "3840x2160x60"), + ] + + private var modeTag: Binding { + Binding( + get: { "\(width)x\(height)x\(hz)" }, + set: { tag in + let parts = tag.split(separator: "x").compactMap { Int($0) } + guard parts.count == 3 else { return } + width = parts[0] + height = parts[1] + hz = parts[2] + }) + } + + private var tvBody: some View { + let currentTag = "\(width)x\(height)x\(hz)" + let bounds = UIScreen.main.nativeBounds + let nativeTag = "\(Int(max(bounds.width, bounds.height)))x" + + "\(Int(min(bounds.width, bounds.height)))x\(UIScreen.main.maximumFramesPerSecond)" + var options = Self.presets + if !options.contains(where: { $0.tag == nativeTag }) { + options.insert(("This TV (native)", nativeTag), at: 0) + } + if !options.contains(where: { $0.tag == currentTag }) { + options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0) + } + return Form { + Section { + Picker("Stream mode", selection: modeTag) { + ForEach(options, id: \.tag) { option in + Text(option.label).tag(option.tag) + } + } + .pickerStyle(.navigationLink) + Picker("Compositor", selection: $compositor) { + Text("Automatic").tag(0) + Text("KWin (KDE Plasma)").tag(1) + Text("wlroots (Sway / Hyprland)").tag(2) + Text("Mutter (GNOME)").tag(3) + Text("gamescope").tag(4) + } + .pickerStyle(.navigationLink) + } footer: { + Text("The host creates a virtual output at exactly this mode — native " + + "resolution, no scaling. A specific compositor is honored only if " + + "available on the host.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section { + Button("Done") { dismiss() } + } + } + .navigationTitle("Settings") + } + #endif + + private var sharedBody: some View { Form { Section { HStack { @@ -100,11 +173,6 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) } - #if os(tvOS) - Section { - Button("Done") { dismiss() } - } - #endif } .formStyle(.grouped) #if os(macOS)