SwiftUI zeichnet Symbole in Listen und Formularen auf macOS in der Akzentfarbe des Systems — nicht in der des Fensters. `.tint` an der Wurzel half deshalb nur der Reiterleiste; die Widget-Liste blieb systemgrün neben einer blauen Leiste, obwohl beides derselben Einstellung folgen soll. Ein eigener Label-Stil setzt die Farbe am Symbol. Er vererbt sich über die Umgebung, also genügt eine Zeile an denselben vier Wurzeln, an denen auch der Farbton gesetzt wird: Panel, Popovers, Einstellungen, Einrichtung. Ein Symbol, das seine Farbe selbst mitbringt, behält sie — die innere Angabe schlägt die äußere. Der grüne Haken für „erteilt" bringt sie jetzt am Symbol mit statt am ganzen Label: Grün heißt dort „erteilt" und ist keine Geschmacksfrage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
574 lines
22 KiB
Swift
574 lines
22 KiB
Swift
import SwiftUI
|
|
import OnyxDesign
|
|
import OnyxNotch
|
|
import OnyxWidgetKit
|
|
import CalendarProvider
|
|
import ShelfProvider
|
|
import OnyxMenuBar
|
|
import MetricsProvider
|
|
import OnyxHelperProtocol
|
|
import WeatherProvider
|
|
|
|
enum SettingsTab: Hashable {
|
|
case widgets, display, colors, sources, menubar, fans, permissions
|
|
}
|
|
|
|
struct SettingsView: View {
|
|
@Bindable var model: AppModel
|
|
let calendarModel: CalendarModel
|
|
let weatherModel: WeatherModel
|
|
let metricsModel: MetricsModel?
|
|
let shelfStore: ShelfStore?
|
|
let fanControl: FanControl
|
|
let launchAtLogin: LaunchAtLogin
|
|
let showOnboarding: () -> Void
|
|
/// Der gewählte Reiter liegt im Fenster-Controller, damit ein erneutes
|
|
/// Öffnen ihn setzen kann — etwa auf „Berechtigungen", wenn eine fehlt.
|
|
@Binding var selectedTab: SettingsTab
|
|
|
|
/// Die Reiter in der Reihenfolge, in der sie oben stehen.
|
|
private static let tabs: [(tab: SettingsTab, label: LocalizedStringKey)] = [
|
|
(.widgets, "settings.tab.widgets"),
|
|
(.display, "settings.tab.display"),
|
|
(.colors, "settings.tab.colors"),
|
|
(.sources, "settings.tab.options"),
|
|
(.menubar, "settings.tab.menubar"),
|
|
(.fans, "settings.tab.fans"),
|
|
(.permissions, "settings.tab.permissions"),
|
|
]
|
|
|
|
var body: some View {
|
|
// Eigene Reiterleiste statt `TabView`.
|
|
//
|
|
// `TabView` zieht unter seiner Leiste einen Trenner quer durchs
|
|
// Fenster — nicht abschaltbar, und die Leiste sitzt mittig darauf.
|
|
// Abstand nach oben half nicht: der Trenner wandert mit. Also die
|
|
// Leiste selbst gebaut, dann gibt es die Linie gar nicht erst.
|
|
VStack(spacing: 0) {
|
|
Picker("", selection: $selectedTab) {
|
|
ForEach(Self.tabs, id: \.tab) { entry in
|
|
Text(entry.label).tag(entry.tab)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
// Ohne feste Breite: sieben deutsche Beschriftungen brauchen rund
|
|
// 880 Punkte, und mit `fixedSize` nahm sich die Leiste die auch
|
|
// dann, wenn das Fenster schmaler war — sie ragte dann links und
|
|
// rechts über die Kante hinaus. Das Mindestmaß des Fensters sorgt
|
|
// dafür, dass hier trotzdem nichts gestaucht wird.
|
|
.frame(maxWidth: 1000)
|
|
.padding(.horizontal, 20)
|
|
.padding(.top, 10)
|
|
.padding(.bottom, 14)
|
|
|
|
content
|
|
}
|
|
// Eine Fläche für alles.
|
|
//
|
|
// Vorher hatte jeder Bereich seinen eigenen Untergrund: die
|
|
// Formulare bringen einen mit, der Rest des Fensters hat den des
|
|
// Systems, und die beiden liegen in Dunkelgrau ein, zwei Prozent
|
|
// auseinander. Sichtbar wurde das als Kante quer durchs Fenster.
|
|
.background(Color(nsColor: .windowBackgroundColor))
|
|
// Nur ein Mindestmaß, keine feste Größe: sonst kann das Fenster
|
|
// wachsen, der Inhalt aber nicht mit.
|
|
.frame(minWidth: 740, minHeight: 460)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
switch selectedTab {
|
|
case .widgets:
|
|
WidgetSettings(model: model, shelfStore: shelfStore)
|
|
case .display:
|
|
DisplaySettings(model: model)
|
|
case .colors:
|
|
ColorSettingsView(theme: OnyxTheme.shared)
|
|
case .sources:
|
|
WidgetOptionsView(calendarModel: calendarModel, weatherModel: weatherModel,
|
|
shelfStore: shelfStore)
|
|
case .menubar:
|
|
MenuBarSettings(model: model, metricsModel: metricsModel)
|
|
case .fans:
|
|
FanSettingsView(control: fanControl)
|
|
case .permissions:
|
|
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel,
|
|
launchAtLogin: launchAtLogin, showOnboarding: showOnboarding)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Berechtigungen
|
|
|
|
/// Zeigt den Zustand, statt ihn nur spürbar zu machen.
|
|
///
|
|
/// Eine verweigerte Berechtigung äußert sich sonst als leeres Widget, und der
|
|
/// Weg zurück ist nicht offensichtlich: hat macOS die Anfrage einmal
|
|
/// abgelehnt bekommen, fragt es nie wieder — es bleibt nur der Gang in die
|
|
/// Systemeinstellungen.
|
|
private struct PermissionSettings: View {
|
|
let calendarModel: CalendarModel
|
|
let weatherModel: WeatherModel
|
|
let launchAtLogin: LaunchAtLogin
|
|
let showOnboarding: () -> Void
|
|
@State private var calendarGranted = false
|
|
|
|
private var locationState: PermissionRow.State {
|
|
switch weatherModel.locationAuthorization {
|
|
case .granted: .granted
|
|
case .denied: .denied
|
|
case .undetermined: .undetermined
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
Form {
|
|
Section("settings.general") {
|
|
Toggle(isOn: Binding(
|
|
get: { launchAtLogin.isEnabled },
|
|
set: { _ = launchAtLogin.set($0) })) {
|
|
Text("settings.launchAtLogin")
|
|
}
|
|
.disabled(launchAtLogin.needsSystemSettings)
|
|
|
|
// Der Zustand, der sonst in eine Schleife führt: der Schalter
|
|
// steht auf aus, Drücken bewirkt nichts, weil das System den
|
|
// Eintrag blockiert. Also steht hier, wo es weitergeht.
|
|
if launchAtLogin.needsSystemSettings {
|
|
HStack {
|
|
Text("settings.launchAtLogin.blocked")
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
Spacer()
|
|
Button("settings.permissions.openSystem") {
|
|
launchAtLogin.openSystemSettings()
|
|
}
|
|
}
|
|
}
|
|
|
|
HStack {
|
|
Spacer()
|
|
Button("settings.onboarding.show", action: showOnboarding)
|
|
.buttonStyle(.borderless)
|
|
}
|
|
}
|
|
|
|
Section {
|
|
PermissionRow(
|
|
title: "settings.permissions.calendar",
|
|
symbol: "calendar",
|
|
state: calendarGranted ? .granted
|
|
: (calendarModel.authorization == .denied ? .denied : .undetermined),
|
|
explanation: "settings.permissions.calendar.why",
|
|
pane: .calendar,
|
|
request: { calendarGranted = await calendarModel.requestAccessNow() })
|
|
}
|
|
|
|
Section {
|
|
PermissionRow(
|
|
title: "settings.permissions.location",
|
|
symbol: "location",
|
|
// Direkt aus dem beobachtbaren Wert: der Zustand ändert
|
|
// sich, während dieses Fenster offen steht.
|
|
state: locationState,
|
|
explanation: "settings.permissions.location.why",
|
|
pane: .location,
|
|
request: { weatherModel.requestLocationAuthorization() })
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
|
|
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
|
|
.scrollContentBackground(.hidden)
|
|
.padding()
|
|
.task {
|
|
launchAtLogin.refresh()
|
|
calendarGranted = calendarModel.hasAccess
|
|
guard !calendarGranted, calendarModel.authorization == .undetermined else { return }
|
|
// Kurz warten, bis das Fenster wirklich vorne steht: TCC zeigt den
|
|
// Dialog nur, wenn eine App im Vordergrund ist, und Aktivierung
|
|
// wirkt asynchron.
|
|
try? await Task.sleep(for: .milliseconds(400))
|
|
calendarGranted = await calendarModel.requestAccessNow()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Eine Berechtigung mit ihrem Zustand und dem Weg, der von dort weiterführt.
|
|
///
|
|
/// Die drei Zustände brauchen drei verschiedene Antworten, und genau daran ist
|
|
/// die erste Fassung gescheitert: „noch nie gefragt" lässt sich mit einer
|
|
/// Anfrage lösen, „abgelehnt" nur über die Systemeinstellungen, und „erteilt"
|
|
/// braucht gar nichts.
|
|
private struct PermissionRow: View {
|
|
enum State { case granted, denied, undetermined }
|
|
enum Pane {
|
|
case calendar, location
|
|
|
|
/// Jede Berechtigung führt in ihren eigenen Bereich. Ein gemeinsamer
|
|
/// Knopf, der immer im Kalender landet, ist schlimmer als keiner.
|
|
var url: URL? {
|
|
switch self {
|
|
case .calendar:
|
|
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars")
|
|
case .location:
|
|
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices")
|
|
}
|
|
}
|
|
}
|
|
|
|
let title: LocalizedStringKey
|
|
let symbol: String
|
|
let state: State
|
|
let explanation: LocalizedStringKey
|
|
let pane: Pane
|
|
let request: () async -> Void
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Label(title, systemImage: symbol)
|
|
Spacer()
|
|
switch state {
|
|
case .granted:
|
|
// Die Farbe sitzt am Symbol, nicht am ganzen Label: der
|
|
// Akzentstil färbt sonst darüber. Grün heißt hier „erteilt"
|
|
// und ist keine Geschmacksfrage.
|
|
Label {
|
|
Text("settings.permissions.granted")
|
|
} icon: {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(Onyx.Color.positive)
|
|
}
|
|
.foregroundStyle(Onyx.Color.positive)
|
|
.labelStyle(.titleAndIcon)
|
|
case .undetermined:
|
|
Button("settings.permissions.request") { Task { await request() } }
|
|
case .denied:
|
|
// macOS fragt nach einer Ablehnung nicht mehr — ein
|
|
// Anfragen-Knopf wäre hier eine Lüge.
|
|
Button("settings.permissions.openSystem") {
|
|
if let url = pane.url { NSWorkspace.shared.open(url) }
|
|
}
|
|
}
|
|
}
|
|
Text(explanation)
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
|
|
// MARK: - Menüleiste
|
|
|
|
/// Jedes Modul einzeln an- und abschaltbar, mit eigener Darstellungsart.
|
|
///
|
|
/// Standardmäßig ist keines an: sechs neue Symbole beim ersten Start wären eine
|
|
/// Zumutung, und die Menüleiste ist ohnehin knapp — auf einem Notch-Display
|
|
/// blockiert die Kamera zusätzlich die Mitte.
|
|
private struct MenuBarSettings: View {
|
|
@Bindable var model: AppModel
|
|
let metricsModel: MetricsModel?
|
|
|
|
private static let modules: [(id: String, label: LocalizedStringKey, symbol: String)] = [
|
|
("metric.cpu", "metric.cpu", "cpu"),
|
|
("metric.gpu", "metric.gpu", "cpu.fill"),
|
|
("metric.memory", "metric.memory", "memorychip"),
|
|
("metric.battery", "metric.battery", "battery.100"),
|
|
("metric.temperature", "metric.sensors", "thermometer"),
|
|
("network", "network.name", "network"),
|
|
("audio", "mixer.menubar.name", "slider.horizontal.3"),
|
|
("fans", "widget.fans.name", "fan"),
|
|
]
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("settings.menubar.hint")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
List {
|
|
ForEach(Self.modules, id: \.id) { module in
|
|
ModuleRow(model: model, id: module.id,
|
|
label: module.label, symbol: module.symbol,
|
|
metricsModel: metricsModel)
|
|
}
|
|
}
|
|
.listStyle(.inset)
|
|
}
|
|
.padding()
|
|
// Der Zähler zwingt die Liste zum Neuzeichnen, wenn sich ein Modul
|
|
// ändert — sonst müsste jede Zeile ihren eigenen Zustand beobachten.
|
|
.id(model.menuBarRevision)
|
|
}
|
|
}
|
|
|
|
private struct ModuleRow: View {
|
|
@Bindable var model: AppModel
|
|
let id: String
|
|
let label: LocalizedStringKey
|
|
let symbol: String
|
|
let metricsModel: MetricsModel?
|
|
|
|
/// Welche Darstellungen dieses Modul beherrscht.
|
|
static func presentations(for id: String) -> [MenuBarPresentation] {
|
|
switch id {
|
|
// Ein Mischpult hat keinen einzelnen Messwert.
|
|
case "audio": []
|
|
// Drehzahl als Zahl oder nur das Symbol — ein Verlauf der Drehzahl
|
|
// sagt in 28 Punkten Breite nichts.
|
|
case "fans": [.value, .symbol]
|
|
// Der Akku kann alles, und gerade dort will man Symbol oder Kürzel:
|
|
// eine nackte Prozentzahl neben vier anderen sagt nichts.
|
|
case "metric.battery": [.symbolAndValue, .labelAndValue, .value, .symbol,
|
|
.graph, .valueAndGraph]
|
|
default: MenuBarPresentation.allCases
|
|
}
|
|
}
|
|
|
|
/// Zu welcher Hardwaregröße dieses Modul gehört — `nil` beim Netzwerk.
|
|
private var metricKind: MetricKind? {
|
|
guard id.hasPrefix("metric.") else { return nil }
|
|
return MetricKind(rawValue: String(id.dropFirst("metric.".count)))
|
|
}
|
|
|
|
var body: some View {
|
|
let settings = model.menuBarSettings(for: id)
|
|
|
|
HStack {
|
|
Toggle(isOn: Binding(
|
|
get: { settings.isEnabled },
|
|
set: { enabled in
|
|
var updated = settings
|
|
updated.isEnabled = enabled
|
|
model.setMenuBarSettings(updated, for: id)
|
|
})) {
|
|
Label(label, systemImage: symbol)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Bei den Sensoren: welcher Punkt. Ohne diese Wahl stand dort das
|
|
// stille Maximum über alle Sensoren — eine Zahl, von der niemand
|
|
// weiß, woher sie kommt.
|
|
if id == "metric.temperature", let metricsModel {
|
|
Picker("", selection: Binding(
|
|
get: { metricsModel.selectedSensorKey ?? "" },
|
|
set: { metricsModel.selectedSensorKey = $0.isEmpty ? nil : $0 })) {
|
|
Text("settings.menubar.sensor.hottest").tag("")
|
|
ForEach(metricsModel.snapshot.sensors) { sensor in
|
|
Text(sensor.name).tag(sensor.key)
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
.frame(width: 150)
|
|
.disabled(!settings.isEnabled)
|
|
}
|
|
|
|
// Bei CPU und GPU zusätzlich wählbar, was gezeigt wird. Beide Werte
|
|
// stammen aus derselben Messung — ein zweites Element in der
|
|
// ohnehin knappen Menüleiste wäre Verschwendung.
|
|
if let metric = metricKind, metric.supportsDisplayModes, let metricsModel {
|
|
Picker("", selection: Binding(
|
|
get: { metricsModel.displayMode(for: metric) },
|
|
set: { metricsModel.setDisplayMode($0, for: metric) })) {
|
|
Text("metric.mode.usage").tag(MetricDisplayMode.usage)
|
|
Text("metric.mode.temperature").tag(MetricDisplayMode.temperature)
|
|
}
|
|
.labelsHidden()
|
|
.frame(width: 110)
|
|
.disabled(!settings.isEnabled)
|
|
}
|
|
|
|
// Nur anbieten, was das Modul auch zeichnen kann. Der Mixer hat
|
|
// keinen einzelnen Messwert, den man als Zahl oder Verlauf zeigen
|
|
// könnte — eine Auswahl ohne Wirkung wäre eine Falle.
|
|
if !Self.presentations(for: id).isEmpty {
|
|
Picker("", selection: Binding(
|
|
get: { settings.presentation },
|
|
set: { presentation in
|
|
var updated = settings
|
|
updated.presentation = presentation
|
|
model.setMenuBarSettings(updated, for: id)
|
|
})) {
|
|
ForEach(Self.presentations(for: id)) { presentation in
|
|
Text(presentation.localizedName).tag(presentation)
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
.frame(width: 150)
|
|
.disabled(!settings.isEnabled)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension MenuBarPresentation {
|
|
var localizedName: LocalizedStringKey {
|
|
switch self {
|
|
case .value: "settings.menubar.value"
|
|
case .graph: "settings.menubar.graph"
|
|
case .bars: "settings.menubar.bars"
|
|
case .valueAndGraph: "settings.menubar.valueAndGraph"
|
|
case .symbol: "settings.menubar.symbol"
|
|
case .labelAndValue: "settings.menubar.labelAndValue"
|
|
case .symbolAndValue: "settings.menubar.symbolAndValue"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Widgets
|
|
|
|
private struct WidgetSettings: View {
|
|
@Bindable var model: AppModel
|
|
let shelfStore: ShelfStore?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("settings.widgets.hint")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
|
|
List {
|
|
Section("settings.widgets.inPanel") {
|
|
// Die Reihenfolge hier ist die Reihenfolge im Panel: die
|
|
// Layout-Engine setzt die Widgets von oben links der Reihe
|
|
// nach ein.
|
|
ForEach(model.layout) { placement in
|
|
if let widget = WidgetRegistry.shared.widget(id: placement.widgetID) {
|
|
PlacementRow(model: model, placement: placement, widget: widget)
|
|
}
|
|
}
|
|
.onMove { model.move(fromOffsets: $0, toOffset: $1) }
|
|
|
|
if model.layout.isEmpty {
|
|
Text("settings.widgets.empty")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
let available = WidgetRegistry.shared.all.filter { !model.isInPanel($0.id) }
|
|
if !available.isEmpty {
|
|
Section("settings.widgets.available") {
|
|
ForEach(available, id: \.id) { widget in
|
|
HStack {
|
|
Label(widget.displayName, systemImage: widget.symbolName)
|
|
Spacer()
|
|
Button("settings.widgets.add") { model.add(widget) }
|
|
.buttonStyle(.borderless)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
.listStyle(.inset)
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
/// Einstellungen der Ablage.
|
|
///
|
|
/// Automatisches Aufräumen ist ausdrücklich abschaltbar und standardmäßig aus:
|
|
/// eine Ablage, die ungefragt löscht, ist kein Zwischenlager, sondern ein
|
|
/// Papierkorb mit Zeitschaltuhr.
|
|
private struct ShelfSettingsRows: View {
|
|
@Bindable var store: ShelfStore
|
|
|
|
private static let choices: [Int?] = [nil, 1, 7, 30, 90]
|
|
|
|
var body: some View {
|
|
Picker(selection: $store.autoCleanupDays) {
|
|
ForEach(Self.choices, id: \.self) { days in
|
|
if let days {
|
|
Text("settings.shelf.days \(days)").tag(Optional(days))
|
|
} else {
|
|
Text("settings.shelf.never").tag(Int?.none)
|
|
}
|
|
}
|
|
} label: {
|
|
Text("settings.shelf.cleanup")
|
|
}
|
|
|
|
HStack {
|
|
Text("settings.shelf.count \(store.items.count)")
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Button("settings.shelf.reveal") { store.revealInFinder() }
|
|
.buttonStyle(.borderless)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PlacementRow: View {
|
|
@Bindable var model: AppModel
|
|
let placement: WidgetPlacement
|
|
let widget: any OnyxWidget
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Label(widget.displayName, systemImage: widget.symbolName)
|
|
|
|
Spacer()
|
|
|
|
Button {
|
|
model.remove(placement.widgetID)
|
|
} label: {
|
|
Image(systemName: "minus.circle")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.help("settings.widgets.remove")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Anzeige
|
|
|
|
private struct DisplaySettings: View {
|
|
@Bindable var model: AppModel
|
|
|
|
var body: some View {
|
|
Form {
|
|
Picker("settings.display.policy", selection: $model.displayPolicy) {
|
|
Text("settings.display.builtInOnly").tag(DisplayPolicy.builtInOnly)
|
|
Text("settings.display.all").tag(DisplayPolicy.allDisplays)
|
|
}
|
|
.pickerStyle(.radioGroup)
|
|
|
|
Text("settings.display.hint")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Divider()
|
|
|
|
// Die Auslösefläche mit Vorschau: zwei Zahlen in Punkten sagen
|
|
// niemandem etwas, ein nachgebildeter Bildschirm schon.
|
|
TriggerAreaSettings(trigger: $model.triggerArea)
|
|
|
|
Divider()
|
|
|
|
Toggle("settings.display.menuBarIcon", isOn: $model.showsMenuBarIcon)
|
|
Text("settings.display.menuBarIcon.hint")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Divider()
|
|
|
|
Toggle("settings.display.dockIcon", isOn: $model.showsDockIcon)
|
|
Text("settings.display.dockIcon.hint")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.formStyle(.grouped)
|
|
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
|
|
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
|
|
.scrollContentBackground(.hidden)
|
|
.padding()
|
|
}
|
|
}
|