fix(apple/tvOS): native form controls — pushed pickers, single-pill fields, centered values
ci / rust (push) Has been cancelled
ci / rust (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user