Files
punktfunk/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift
T
enricobuehlerandClaude Opus 5 cf55a11174 fix(client/settings): captions ran under the switch on iPhone
The `described` idiom capped its caption at 360pt, which was only ever about
READING — past roughly 46 characters a line stops scanning well on a wide Mac window
or an iPad detail pane. On an iPhone the cell is narrower than the cap in the first
place, so the cap did nothing and every caption laid out to the full cell width,
running its last line straight under the row's switch.

`CaptionWidth` now carries both limits: the reading cap, plus an iOS trailing inset
that reserves the control column (a `UISwitch` is 51pt, so 60 with room). Order
matters — the inset shrinks what the text is offered, then the cap applies, so a
narrow phone cell clears the switch and a wide pane still caps.

One rule in one place: the override marker and the iOS resolution wheel's caption
each carried their own copy of the 360, and neither would have grown the inset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00

212 lines
9.4 KiB
Swift

// SettingsView's footers and stateful helpers, used by both the section builders
// (SettingsView+Sections.swift) and the per-platform bodies (SettingsView.swift). The option
// LISTS live in SettingsOptions — they're shared with the gamepad settings screen too.
#if os(macOS)
import AppKit
#endif
import PunktfunkKit
import SwiftUI
/// How wide a `described` row's caption (and its override marker) may run.
///
/// Two limits, because they solve different problems. The 360pt CAP is about reading: past roughly
/// 46 characters a line stops scanning well, and on a wide Mac window or an iPad detail pane an
/// uncapped caption runs the full cell. The trailing INSET is about collision: an iOS grouped row
/// puts its switch or value about 60pt in from the trailing edge, and a caption laid out to the
/// full cell width runs its last line straight under that control — which is what the cap alone
/// never fixed, because an iPhone cell is narrower than the cap in the first place.
struct CaptionWidth: ViewModifier {
#if os(iOS)
/// Reserve the control column. Sized to a `UISwitch` (51pt) plus breathing room — the widest
/// of the trailing accessories these rows use.
private static let trailingInset: CGFloat = 60
#else
// macOS lays the control out inline and its cells are wider than the cap; nothing to clear.
private static let trailingInset: CGFloat = 0
#endif
func body(content: Content) -> some View {
content
// Order matters: the padding shrinks what the text is offered, THEN the cap applies —
// so a narrow phone cell reserves the control column and a wide pane still caps.
.padding(.trailing, Self.trailingInset)
.frame(maxWidth: 360, alignment: .leading)
}
}
extension SettingsView {
// MARK: - Described rows (the 2026-07 revamp's field idiom)
/// A control with its explanation attached to the SAME cell: the field, then a tight caption
/// directly under it. This replaced the per-section footer paragraphs — a description the eye
/// can't match to its field is one nobody reads. Keep captions to one or two sentences; when
/// a picker's meaning depends on the selection, pass a DYNAMIC string describing the current
/// choice.
/// `field` is the overlay's name for this row (see `SettingsField`). Passing it puts the
/// override marker + Reset in the caption line while a profile is being edited — with the row
/// it belongs to, which is the only place the state is legible.
@ViewBuilder
func described<Content: View>(
_ caption: String, field: String? = nil, @ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 5) {
content()
VStack(alignment: .leading, spacing: 5) {
Text(caption)
.font(.geist(13, relativeTo: .footnote))
.foregroundStyle(.secondary)
// Wrap, never truncate, in Form cells.
.fixedSize(horizontal: false, vertical: true)
if let field {
overrideMarker(field)
}
}
.modifier(CaptionWidth())
}
.padding(.vertical, 2)
}
// MARK: - Bitrate
/// Slider domain, log-scale: the useful range spans three orders of magnitude
/// (a few Mbps … 3 Gbps) — linear would cram everything below 100 Mbps into the
/// first pixels.
private static let minSliderKbps = 2_000.0
private static let maxSliderKbps = 3_000_000.0
/// tvOS's cluster caption (the touch/desktop forms describe bitrate per-row instead).
static let bitrateFooter =
"Automatic uses the host's default bitrate (20 Mbps); the host clamps any choice "
+ "to its supported range. Run a speed test from a host card's context menu to "
+ "pick an informed value. Applies from the next session."
static let gigabitWarning =
"Above 1 Gbps — test the network speed first (a host card's context menu → "
+ "Test Network Speed…). A bitrate beyond what the link sustains causes loss "
+ "and stutter."
/// `bitrateKbps == 0` is Automatic; switching to manual lands on the host default. Scoped, so
/// flipping it in a profile records the override there rather than moving the global.
var automaticBitrate: Binding<Bool> {
let bitrate = scoped(SettingsFields.bitrateKbps)
return Binding(
get: { bitrate.wrappedValue == 0 },
set: { bitrate.wrappedValue = $0 ? 0 : 20_000 })
}
/// Slider position 0...1 ↔ kbps on the log scale, snapped to two significant figures
/// so the readout shows round numbers instead of 47_322.
var bitrateSlider: Binding<Double> {
let bitrate = scoped(SettingsFields.bitrateKbps)
return Binding(
get: {
let v = Double(bitrate.wrappedValue)
.clamped(Self.minSliderKbps, Self.maxSliderKbps)
return log(v / Self.minSliderKbps)
/ log(Self.maxSliderKbps / Self.minSliderKbps)
},
set: { pos in
let raw = Self.minSliderKbps
* pow(Self.maxSliderKbps / Self.minSliderKbps, pos)
let mag = pow(10, floor(log10(raw)) - 1)
bitrate.wrappedValue = Int((raw / mag).rounded() * mag)
})
}
// MARK: - Statistics
static var statisticsDescription: String {
let base = "Live session stats in a corner overlay — Compact is a one-line pill, "
+ "Detailed adds the latency stage breakdown."
#if os(macOS)
return base + " ⌃⌥⇧S cycles the tiers any time."
#elseif os(iOS)
return base + " ⌃⌥⇧S or a three-finger tap cycles the tiers any time."
#else
return base
#endif
}
// MARK: - Controllers
/// tvOS's cluster caption (the touch/desktop form describes each row inline instead).
static let controllersFooter =
"One controller is forwarded as player 1 — Automatic picks the most recently "
+ "connected. Type is the virtual pad the host creates; Automatic matches your "
+ "controller (a DualSense keeps adaptive triggers, lightbar, touchpad and motion). "
+ "Applies from the next session."
/// "Use controller" choices for this view's manager (see `SettingsOptions.controllerOptions`).
var controllerOptions: [(label: String, tag: String)] {
SettingsOptions.controllerOptions(gamepads)
}
func controllerRow(_ controller: GamepadManager.DiscoveredController) -> some View {
HStack(spacing: 10) {
Image(systemName: controller.hasTouchpadAndMotion ? "playstation.logo" : "gamecontroller.fill")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
Text(controller.name)
HStack(spacing: 8) {
if !controller.isExtended {
Text(controller.productCategory)
}
if controller.hasAdaptiveTriggers {
Image(systemName: "r2.button.roundedtop.horizontal")
}
if controller.hasLight {
Image(systemName: "lightbulb.fill")
}
if controller.hasMotion {
Image(systemName: "gyroscope")
}
if controller.hasHaptics {
Image(systemName: "waveform")
}
if let level = controller.batteryLevel {
Text("\(Int(level * 100))%")
if controller.isCharging {
Image(systemName: "bolt.fill")
}
}
}
.font(.geist(11, relativeTo: .caption2))
.foregroundStyle(.secondary)
}
Spacer()
// Every forwarded controller is surfaced (not just the primary `active`) with its
// wire pad index as a player number — a pin forwards only one, Automatic forwards all.
if let pad = gamepads.padIndex(for: controller) {
Text("Player \(pad + 1)")
.font(.geist(11, .semibold, relativeTo: .caption2))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Capsule().fill(.green.opacity(0.2)))
.foregroundStyle(.green)
}
}
}
func fillFromMainScreen() {
#if os(macOS)
guard let screen = NSScreen.main else { return }
let scale = screen.backingScaleFactor
setResolution(
width: Int(screen.frame.width * scale), height: Int(screen.frame.height * scale))
scoped(SettingsFields.refreshHz).wrappedValue = screen.maximumFramesPerSecond
#else
// nativeBounds is portrait-oriented pixels — streams are landscape.
let bounds = UIScreen.main.nativeBounds
setResolution(
width: Int(max(bounds.width, bounds.height)),
height: Int(min(bounds.width, bounds.height)))
scoped(SettingsFields.refreshHz).wrappedValue = UIScreen.main.maximumFramesPerSecond
#if os(iOS)
// The native mode is the "This device" wheel row, so leave Custom mode if it was on.
customMode = false
#endif
#endif
}
}