diff --git a/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift b/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift index 3ab758a..2a675d8 100644 --- a/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift @@ -8,22 +8,32 @@ struct AddHostSheet: View { @State private var name = "" @State private var address = "" @State private var port = 9777 + #if os(tvOS) + private enum EditField: String, Identifiable { + case name, address, port + var id: String { rawValue } + } + @State private var editing: EditField? + #endif let onAdd: (StoredHost) -> Void var body: some View { #if os(tvOS) - // No Form here: tvOS list rows add a full-width focus fill + row platter - // behind the field's own pill. Standalone fields have exactly one pill. - VStack(spacing: 28) { + // No inline text editing on tvOS — Settings-style value rows; pressing one + // raises the SYSTEM fullscreen keyboard (TVTextEntry). + VStack(spacing: 24) { Text("Add Host") .font(.title3.weight(.semibold)) - TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room")) - .labelsHidden() - TextField("Address", text: $address, prompt: Text("IP or hostname")) - .labelsHidden() - TextField("Port", value: $port, format: .number.grouping(.never)) - .labelsHidden() + TVFieldRow( + label: "Name", value: name, placeholder: "Optional" + ) { editing = .name } + TVFieldRow( + label: "Address", value: address, placeholder: "IP or hostname" + ) { editing = .address } + TVFieldRow( + label: "Port", value: String(port), placeholder: "" + ) { editing = .port } HStack(spacing: 32) { Button("Cancel", role: .cancel) { dismiss() } Button("Add Host") { add() } @@ -33,6 +43,29 @@ struct AddHostSheet: View { } .frame(maxWidth: 1000) .padding(60) + .fullScreenCover(item: $editing) { field in + switch field { + case .name: + TVTextEntry(title: "Name (optional, e.g. Living Room)", text: name) { + name = $0 + editing = nil + } + case .address: + TVTextEntry(title: "IP or hostname", text: address) { + address = $0.trimmingCharacters(in: .whitespaces) + editing = nil + } + case .port: + TVTextEntry( + title: "Port", text: String(port), keyboardType: .numberPad + ) { + if let value = Int($0), (1...65535).contains(value) { + port = value + } + editing = nil + } + } + } #else VStack(spacing: 0) { Form { diff --git a/clients/apple/Sources/PunktfunkClient/PairSheet.swift b/clients/apple/Sources/PunktfunkClient/PairSheet.swift index 7b0cfa0..8e7aa67 100644 --- a/clients/apple/Sources/PunktfunkClient/PairSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/PairSheet.swift @@ -33,6 +33,13 @@ struct PairSheet: View { @State private var busy = false @State private var errorText: String? @State private var token = CeremonyToken() + #if os(tvOS) + private enum EditField: String, Identifiable { + case pin, clientName + var id: String { rawValue } + } + @State private var editing: EditField? + #endif var body: some View { #if os(tvOS) @@ -47,12 +54,12 @@ struct PairSheet: View { .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - TextField("PIN", text: $pin, prompt: Text("Shown in the host's log")) - .labelsHidden() - TextField( - "Client name", text: $clientName, - prompt: Text("How the host lists this device")) - .labelsHidden() + TVFieldRow( + label: "PIN", value: pin, placeholder: "Shown in the host's log" + ) { editing = .pin } + TVFieldRow( + label: "Device name", value: clientName, placeholder: "Apple TV" + ) { editing = .clientName } if let errorText { Text(errorText) .font(.callout) @@ -74,6 +81,23 @@ struct PairSheet: View { .frame(maxWidth: 1000) .padding(60) .onDisappear { token.cancelled = true } + .fullScreenCover(item: $editing) { field in + switch field { + case .pin: + TVTextEntry( + title: "PIN (shown in the host's log)", text: pin, + keyboardType: .numberPad + ) { + pin = $0.trimmingCharacters(in: .whitespaces) + editing = nil + } + case .clientName: + TVTextEntry(title: "Device name", text: clientName) { + clientName = $0 + editing = nil + } + } + } #else VStack(spacing: 0) { Form { diff --git a/clients/apple/Sources/PunktfunkClient/TVTextEntry.swift b/clients/apple/Sources/PunktfunkClient/TVTextEntry.swift new file mode 100644 index 0000000..a4b96b0 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/TVTextEntry.swift @@ -0,0 +1,90 @@ +// The native tvOS text-entry experience: real tvOS apps never edit text inline — +// selecting a field presents the SYSTEM full-screen keyboard (Apple's "Designing the +// Keyboard Input Experience"). UIKit gives that for free: a UITextField that becomes +// first responder presents the fullscreen keyboard UI with the field's placeholder as +// the prompt. SwiftUI's inline TextField on tvOS is an expanding pill with stray +// chrome — this bridge replaces it everywhere on tvOS. + +#if os(tvOS) +import SwiftUI +import UIKit + +/// Present inside a fullScreenCover: immediately raises the system keyboard for one +/// value, then calls `onDone` with the result (also on Menu-button dismissal, with +/// whatever was typed so far — match the system apps' "edits stick" behavior). +struct TVTextEntry: UIViewControllerRepresentable { + let title: String + let text: String + var keyboardType: UIKeyboardType = .default + let onDone: (String) -> Void + + func makeUIViewController(context: Context) -> TVTextEntryController { + let controller = TVTextEntryController() + controller.configure( + title: title, text: text, keyboardType: keyboardType, onDone: onDone) + return controller + } + + func updateUIViewController(_ controller: TVTextEntryController, context: Context) {} +} + +final class TVTextEntryController: UIViewController, UITextFieldDelegate { + private let field = UITextField() + private var onDone: ((String) -> Void)? + private var finished = false + + func configure( + title: String, text: String, keyboardType: UIKeyboardType, + onDone: @escaping (String) -> Void + ) { + field.placeholder = title + field.text = text + field.keyboardType = keyboardType + field.returnKeyType = .done + self.onDone = onDone + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + field.delegate = self + view.addSubview(field) // must be in a window to become first responder + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + field.becomeFirstResponder() // presents the tvOS fullscreen keyboard + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } + + func textFieldDidEndEditing(_ textField: UITextField) { + guard !finished else { return } + finished = true + onDone?(textField.text ?? "") + } +} + +/// A Settings-app-style value row: label leading, current value trailing — the whole +/// row is one system lozenge, and pressing it opens the fullscreen keyboard. +struct TVFieldRow: View { + let label: String + let value: String + let placeholder: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + Text(label) + Spacer() + Text(value.isEmpty ? placeholder : value) + .foregroundStyle(.secondary) + } + } + } +} +#endif