Merge origin/main (tvOS client work) with host EIS/attach + macOS-input fixes
ci / rust (push) Has been cancelled
ci / rust (push) Has been cancelled
This commit is contained in:
@@ -8,30 +8,86 @@ 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 inline text editing on tvOS — Settings-style value rows; pressing one
|
||||
// raises the SYSTEM fullscreen keyboard (TVTextEntry).
|
||||
VStack(spacing: 24) {
|
||||
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() }
|
||||
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
.frame(maxWidth: 1000)
|
||||
.padding(60)
|
||||
.navigationTitle("Add Host")
|
||||
.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 {
|
||||
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)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
#endif
|
||||
Spacer()
|
||||
Button("Add Host") {
|
||||
onAdd(StoredHost(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
address: address.trimmingCharacters(in: .whitespaces),
|
||||
port: UInt16(clamping: port)))
|
||||
dismiss()
|
||||
}
|
||||
Button("Add Host") { add() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
@@ -47,5 +103,14 @@ struct AddHostSheet: View {
|
||||
.frame(width: 380)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
private func add() {
|
||||
onAdd(StoredHost(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
address: address.trimmingCharacters(in: .whitespaces),
|
||||
port: UInt16(clamping: port)))
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import AppKit
|
||||
#endif
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(tvOS)
|
||||
import SwiftUINavigationTransitions
|
||||
#endif
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var model = SessionModel()
|
||||
@@ -55,6 +58,7 @@ struct ContentView: View {
|
||||
// On the outer Group so the sheet survives the trust-prompt → home transition
|
||||
// (the "Pair with PIN instead" path disconnects first — the host's accept loop
|
||||
// is sequential, a pairing connection would queue behind the live session).
|
||||
#if !os(tvOS)
|
||||
.sheet(item: $pairingTarget) { host in
|
||||
PairSheet(host: host) { fingerprint in
|
||||
// Backstop against a stale ceremony surfacing after dismissal (PairSheet
|
||||
@@ -66,6 +70,7 @@ struct ContentView: View {
|
||||
connect(pinned)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var sessionView: some View {
|
||||
@@ -110,18 +115,54 @@ struct ContentView: View {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: gridColumns, spacing: 16) {
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
ForEach(store.hosts) { host in
|
||||
hostCard(host)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
#if os(tvOS)
|
||||
// Actions live below the hosts, not between them.
|
||||
HStack(spacing: 32) {
|
||||
Button {
|
||||
showAddHost = true
|
||||
} label: {
|
||||
Label("Add Host", systemImage: "plus")
|
||||
}
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
}
|
||||
.padding(.top, 24)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Punktfunkempfänger")
|
||||
#if os(tvOS)
|
||||
// Pushed routes — the Settings-app navigation feel (push animation, Menu
|
||||
// pops) instead of modal overlays.
|
||||
.navigationDestination(isPresented: $showAddHost) {
|
||||
AddHostSheet { store.add($0) }
|
||||
}
|
||||
.navigationDestination(isPresented: $showSettings) {
|
||||
SettingsView()
|
||||
}
|
||||
.navigationDestination(item: $pairingTarget) { host in
|
||||
PairSheet(host: host) { fingerprint in
|
||||
guard pairingTarget?.id == host.id else { return }
|
||||
store.pin(host.id, fingerprint: fingerprint)
|
||||
var pinned = host
|
||||
pinned.pinnedSHA256 = fingerprint
|
||||
connect(pinned)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
.toolbar {
|
||||
#if !os(macOS)
|
||||
#if os(iOS)
|
||||
// Adjacent trailing items share one glass pill (the system default).
|
||||
ToolbarItem(placement: .topBarTrailing) { settingsButton }
|
||||
ToolbarItem(placement: .topBarTrailing) { addHostButton }
|
||||
@@ -138,14 +179,24 @@ struct ContentView: View {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 480, minHeight: 360)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
// The Settings-app slide for every push in this stack (top-level routes AND
|
||||
// the pickers' drill-ins) — SwiftUI's default on tvOS is a bare crossfade.
|
||||
// Spring-driven (UISpringTimingParameters): ~0.87 damping ratio — settles fast
|
||||
// with just a hint of life, no visible overshoot ping-pong.
|
||||
.customNavigationTransition(
|
||||
.slide.animation(.interpolatingSpring(stiffness: 300, damping: 30)))
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
.sheet(isPresented: $showAddHost) {
|
||||
AddHostSheet { store.add($0) }
|
||||
}
|
||||
#if !os(macOS)
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: $showSettings) {
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
@@ -156,6 +207,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
.alert(
|
||||
"Connection failed",
|
||||
isPresented: Binding(
|
||||
@@ -175,11 +227,21 @@ struct ContentView: View {
|
||||
private var gridColumns: [GridItem] {
|
||||
#if os(macOS)
|
||||
[GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)]
|
||||
#elseif os(tvOS)
|
||||
[GridItem(.adaptive(minimum: 320), spacing: 48)]
|
||||
#else
|
||||
[GridItem(.adaptive(minimum: 280), spacing: 16)]
|
||||
#endif
|
||||
}
|
||||
|
||||
private var gridSpacing: CGFloat {
|
||||
#if os(tvOS)
|
||||
48 // the focused card scales up — give it room instead of overlapping siblings
|
||||
#else
|
||||
16
|
||||
#endif
|
||||
}
|
||||
|
||||
private var addHostButton: some View {
|
||||
Button {
|
||||
showAddHost = true
|
||||
@@ -209,6 +271,9 @@ struct ContentView: View {
|
||||
#if os(iOS)
|
||||
.controlSize(.large)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
Button("Settings") { showSettings = true }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +330,9 @@ struct ContentView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, cardPadding)
|
||||
.padding(.horizontal, 12)
|
||||
#if !os(tvOS)
|
||||
// tvOS: the .card button style owns platter + focus motion — extra chrome
|
||||
// inside it mutes the grow/tilt. Material + accent ring are for pointer UIs.
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
.overlay {
|
||||
if host.id == mostRecentHostID {
|
||||
@@ -272,6 +340,7 @@ struct ContentView: View {
|
||||
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.card)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// PIN pairing sheet. The host, started with --allow-pairing (or --require-pairing),
|
||||
// prints a short PIN at startup ("PAIRING ARMED — enter this PIN on the client to
|
||||
// pair"); the user types it here. The ceremony is SPAKE2, so a wrong PIN buys an
|
||||
// PIN pairing sheet. The host shows the pairing PIN in its web console (port 3000 →
|
||||
// Pairing; also printed in the host's log when armed via --allow-pairing); the user
|
||||
// types it here. The ceremony is SPAKE2, so a wrong PIN buys an
|
||||
// attacker exactly one online guess — for the user a typo just means "try again" (the
|
||||
// host rate-limits ceremonies to one per 2 s). Success returns the host's now-VERIFIED
|
||||
// fingerprint: the caller pins it, no manual comparison needed, and the host stores this
|
||||
@@ -33,12 +33,76 @@ 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)
|
||||
VStack(spacing: 24) {
|
||||
Text("The PIN is shown in the host's web console "
|
||||
+ "(http://<host>:3000 → Pairing). "
|
||||
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
||||
+ "needed.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
TVFieldRow(
|
||||
label: "PIN", value: pin, placeholder: "Shown in the host's web console"
|
||||
) { editing = .pin }
|
||||
TVFieldRow(
|
||||
label: "Device name", value: clientName, placeholder: "Apple TV"
|
||||
) { editing = .clientName }
|
||||
if let errorText {
|
||||
Text(errorText)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
HStack(spacing: 32) {
|
||||
Button("Cancel", role: .cancel) {
|
||||
token.cancelled = true
|
||||
dismiss()
|
||||
}
|
||||
if busy {
|
||||
ProgressView()
|
||||
}
|
||||
Button("Pair & Connect") { runCeremony() }
|
||||
.disabled(busy || pin.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
.frame(maxWidth: 1000)
|
||||
.padding(60)
|
||||
.navigationTitle("Pair with \(host.displayName)")
|
||||
.onDisappear { token.cancelled = true }
|
||||
.fullScreenCover(item: $editing) { field in
|
||||
switch field {
|
||||
case .pin:
|
||||
TVTextEntry(
|
||||
title: "PIN (shown in the host's web console)", 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 {
|
||||
Section {
|
||||
TextField("PIN", text: $pin, prompt: Text("Shown in the host's log"))
|
||||
TextField(
|
||||
"PIN", text: $pin,
|
||||
prompt: Text("Shown in the host's web console"))
|
||||
.font(.system(.title3, design: .monospaced))
|
||||
#if os(iOS)
|
||||
.keyboardType(.numberPad)
|
||||
@@ -46,12 +110,15 @@ 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)
|
||||
} footer: {
|
||||
Text("The host prints the PIN when pairing is armed "
|
||||
+ "(--allow-pairing, \u{201C}PAIRING ARMED\u{201D} in its log). "
|
||||
Text("The PIN is shown in the host's web console "
|
||||
+ "(http://<host>:3000 → Pairing). "
|
||||
+ "Pairing verifies both sides at once — no fingerprint "
|
||||
+ "comparison needed.")
|
||||
.font(.caption)
|
||||
@@ -65,7 +132,9 @@ struct PairSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
#if !os(tvOS)
|
||||
.formStyle(.grouped)
|
||||
#endif
|
||||
HStack {
|
||||
Button("Cancel", role: .cancel) {
|
||||
token.cancelled = true
|
||||
@@ -98,6 +167,7 @@ struct PairSheet: View {
|
||||
#endif
|
||||
.interactiveDismissDisabled(busy)
|
||||
.onDisappear { token.cancelled = true } // any other dismissal path
|
||||
#endif
|
||||
}
|
||||
|
||||
private func runCeremony() {
|
||||
@@ -126,16 +196,16 @@ struct PairSheet: View {
|
||||
onPaired(fingerprint)
|
||||
dismiss()
|
||||
case .failure(PunktfunkClientError.wrongPIN):
|
||||
errorText = "Wrong PIN — check the host's \u{201C}PAIRING ARMED\u{201D} "
|
||||
+ "line and try again."
|
||||
errorText = "Wrong PIN — check the host's web console (port 3000) "
|
||||
+ "and try again."
|
||||
case .failure(is ClientIdentityStore.IdentityError):
|
||||
errorText = "Can't store this Mac's identity in the Keychain, so the "
|
||||
+ "pairing would not survive a relaunch. Unlock the login "
|
||||
+ "keychain and try again."
|
||||
case .failure:
|
||||
errorText = "Pairing failed. Is the host reachable, armed with "
|
||||
+ "--allow-pairing, and not mid-session? Retries are rate-limited "
|
||||
+ "to one per 2 seconds."
|
||||
errorText = "Pairing failed. Is the host reachable, pairing armed "
|
||||
+ "(web console → Pairing), and not mid-session? Retries are "
|
||||
+ "rate-limited to one per 2 seconds."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import PunktfunkKit
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage("punktfunk.width") private var width = 1920
|
||||
@AppStorage("punktfunk.height") private var height = 1080
|
||||
@AppStorage("punktfunk.hz") private var hz = 60
|
||||
@@ -22,6 +23,76 @@ 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)
|
||||
}
|
||||
let compositors: [(label: String, tag: Int)] = [
|
||||
("Automatic", 0),
|
||||
("KWin (KDE Plasma)", 1),
|
||||
("wlroots (Sway / Hyprland)", 2),
|
||||
("Mutter (GNOME)", 3),
|
||||
("gamescope", 4),
|
||||
]
|
||||
return ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
|
||||
TVSelectionRow(
|
||||
title: "Compositor", options: compositors, selection: $compositor)
|
||||
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)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.frame(maxWidth: 1000)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(60)
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
}
|
||||
#endif
|
||||
|
||||
private var sharedBody: some View {
|
||||
Form {
|
||||
Section {
|
||||
HStack {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A Settings-app-style selection screen: pushed list of option rows, checkmark on the
|
||||
/// current value, selecting pops back. Replaces Picker(.navigationLink), whose internal
|
||||
/// list renders rows in the focused (dark-text) style while the push animates.
|
||||
struct TVSelectionList<Tag: Hashable>: View {
|
||||
let title: String
|
||||
let options: [(label: String, tag: Tag)]
|
||||
@Binding var selection: Tag
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
ForEach(options, id: \.tag) { option in
|
||||
Button {
|
||||
selection = option.tag
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Text(option.label)
|
||||
Spacer()
|
||||
if option.tag == selection {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 900)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(60)
|
||||
}
|
||||
.navigationTitle(title)
|
||||
}
|
||||
}
|
||||
|
||||
/// The pushing row for a TVSelectionList: label leading, current value trailing.
|
||||
struct TVSelectionRow<Tag: Hashable>: View {
|
||||
let title: String
|
||||
let options: [(label: String, tag: Tag)]
|
||||
@Binding var selection: Tag
|
||||
|
||||
var body: some View {
|
||||
NavigationLink {
|
||||
TVSelectionList(title: title, options: options, selection: $selection)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
Text(options.first { $0.tag == selection }?.label ?? "—")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user