From 9e57a5a1ff281112cee8578169ea65c0ef47715a 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 d21e31f..b4fd44b 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 16fe4a3..9003b09 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 13ae2fa..cfc7f60 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 3a37dd0..20a3003 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 2bcff04..e358f43 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)