fix(apple/tvOS): focus-native home grid, separated actions, Form-free dialogs
ci / rust (push) Has been cancelled
ci / rust (push) Has been cancelled
Three more tvOS-isms, all the same lesson — let the focus engine own the chrome: - Host cards drew their own material platter + accent ring INSIDE the .card button style, muting the native grow/tilt focus motion. On tvOS the card style now owns the platter outright (material/ring stay on the pointer platforms), and the grid gets 48 pt spacing so the focused card swells without overlapping siblings. - Add Host and Settings no longer sit in the hosts row: they're a compact button row below the grid (and the empty state gains a Settings button, since tvOS has no toolbar). - The Add Host and pairing dialogs drop Form entirely on tvOS — list rows added a full-width focus fill plus a row platter behind every field's own pill (the "second outer pill"). As standalone fields in a centered dialog over the dimmed home, each input is exactly one pill with vertically centered text. Verified by screenshot in the Apple TV simulator (home grid + Add Host dialog). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,28 @@ struct AddHostSheet: View {
|
|||||||
let onAdd: (StoredHost) -> Void
|
let onAdd: (StoredHost) -> Void
|
||||||
|
|
||||||
var body: some View {
|
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) {
|
||||||
|
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()
|
||||||
|
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)
|
||||||
|
#else
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Form {
|
Form {
|
||||||
TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room"))
|
TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room"))
|
||||||
@@ -33,13 +55,7 @@ struct AddHostSheet: View {
|
|||||||
.keyboardShortcut(.cancelAction)
|
.keyboardShortcut(.cancelAction)
|
||||||
#endif
|
#endif
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Add Host") {
|
Button("Add Host") { add() }
|
||||||
onAdd(StoredHost(
|
|
||||||
name: name.trimmingCharacters(in: .whitespaces),
|
|
||||||
address: address.trimmingCharacters(in: .whitespaces),
|
|
||||||
port: UInt16(clamping: port)))
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
@@ -55,5 +71,14 @@ struct AddHostSheet: View {
|
|||||||
.frame(width: 380)
|
.frame(width: 380)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
#endif
|
#endif
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func add() {
|
||||||
|
onAdd(StoredHost(
|
||||||
|
name: name.trimmingCharacters(in: .whitespaces),
|
||||||
|
address: address.trimmingCharacters(in: .whitespaces),
|
||||||
|
port: UInt16(clamping: port)))
|
||||||
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,20 +123,28 @@ struct ContentView: View {
|
|||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVGrid(columns: gridColumns, spacing: 16) {
|
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||||
ForEach(store.hosts) { host in
|
ForEach(store.hosts) { host in
|
||||||
hostCard(host)
|
hostCard(host)
|
||||||
}
|
}
|
||||||
#if os(tvOS)
|
|
||||||
actionTile("Add Host", systemImage: "plus") {
|
|
||||||
showAddHost = true
|
|
||||||
}
|
|
||||||
actionTile("Settings", systemImage: "gearshape") {
|
|
||||||
showSettings = true
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.padding()
|
.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,11 +221,21 @@ struct ContentView: View {
|
|||||||
private var gridColumns: [GridItem] {
|
private var gridColumns: [GridItem] {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
[GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)]
|
[GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)]
|
||||||
|
#elseif os(tvOS)
|
||||||
|
[GridItem(.adaptive(minimum: 320), spacing: 48)]
|
||||||
#else
|
#else
|
||||||
[GridItem(.adaptive(minimum: 280), spacing: 16)]
|
[GridItem(.adaptive(minimum: 280), spacing: 16)]
|
||||||
#endif
|
#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 {
|
private var addHostButton: some View {
|
||||||
Button {
|
Button {
|
||||||
showAddHost = true
|
showAddHost = true
|
||||||
@@ -247,6 +265,9 @@ struct ContentView: View {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
#endif
|
#endif
|
||||||
|
#if os(tvOS)
|
||||||
|
Button("Settings") { showSettings = true }
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,6 +324,9 @@ struct ContentView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, cardPadding)
|
.padding(.vertical, cardPadding)
|
||||||
.padding(.horizontal, 12)
|
.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))
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||||
.overlay {
|
.overlay {
|
||||||
if host.id == mostRecentHostID {
|
if host.id == mostRecentHostID {
|
||||||
@@ -310,6 +334,7 @@ struct ContentView: View {
|
|||||||
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
.buttonStyle(.card)
|
.buttonStyle(.card)
|
||||||
@@ -344,30 +369,6 @@ struct ContentView: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(tvOS)
|
|
||||||
/// Grid-resident replacement for the toolbar (whose items are neither sized nor
|
|
||||||
/// focusable on tvOS): a full-size, focus-native tile per action.
|
|
||||||
private func actionTile(
|
|
||||||
_ label: String, systemImage: String, action: @escaping () -> Void
|
|
||||||
) -> some View {
|
|
||||||
Button(action: action) {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
Image(systemName: systemImage)
|
|
||||||
.font(.system(size: 56, weight: .light))
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
.frame(height: 76)
|
|
||||||
Text(label)
|
|
||||||
.font(.title3.weight(.semibold))
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 28)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
|
||||||
}
|
|
||||||
.buttonStyle(.card)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// The host of the most recent session — its card carries the accent ring.
|
/// The host of the most recent session — its card carries the accent ring.
|
||||||
private var mostRecentHostID: UUID? {
|
private var mostRecentHostID: UUID? {
|
||||||
store.hosts
|
store.hosts
|
||||||
|
|||||||
@@ -35,6 +35,46 @@ struct PairSheet: View {
|
|||||||
@State private var token = CeremonyToken()
|
@State private var token = CeremonyToken()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
#if os(tvOS)
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Label("Pair with \(host.displayName)", systemImage: "lock.shield")
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
Text("The host prints the PIN when pairing is armed "
|
||||||
|
+ "(--allow-pairing, \u{201C}PAIRING ARMED\u{201D} in its log). "
|
||||||
|
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
||||||
|
+ "needed.")
|
||||||
|
.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()
|
||||||
|
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)
|
||||||
|
.onDisappear { token.cancelled = true }
|
||||||
|
#else
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Form {
|
Form {
|
||||||
Section {
|
Section {
|
||||||
@@ -103,6 +143,7 @@ struct PairSheet: View {
|
|||||||
#endif
|
#endif
|
||||||
.interactiveDismissDisabled(busy)
|
.interactiveDismissDisabled(busy)
|
||||||
.onDisappear { token.cancelled = true } // any other dismissal path
|
.onDisappear { token.cancelled = true } // any other dismissal path
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func runCeremony() {
|
private func runCeremony() {
|
||||||
|
|||||||
Reference in New Issue
Block a user