import SwiftUI import OnyxDesign import OnyxNotch import LockProvider import OnyxWidgetKit import CalendarProvider import ShelfProvider import OnyxMenuBar import MetricsProvider import OnyxHelperProtocol import WeatherProvider struct SettingsView: View { @Bindable var model: AppModel let calendarModel: CalendarModel let weatherModel: WeatherModel let metricsModel: MetricsModel? let shelfStore: ShelfStore? let fanControl: FanControl let lockController: LockController let launchAtLogin: LaunchAtLogin let showOnboarding: () -> Void /// Der gewählte Bereich liegt im Fenster-Controller, damit ein erneutes /// Öffnen ihn setzen kann — etwa auf „Berechtigungen", wenn eine fehlt. @Binding var section: SettingsSection var body: some View { // **Zweiteilung von Hand, kein `NavigationSplitView`.** // // Der malt Seitenleiste und Inhaltsfläche selbst deckend zu. Die // Vibrancy-Ebene des Fensters kam deshalb nur unter dem Titelbalken // durch — oben durchsichtig, darunter eine Platte. // `.scrollContentBackground(.hidden)` erreicht sie nicht: es gilt für // Listen und Scrollflächen, nicht für den Rahmen darum. // // Gebraucht wird hier ohnehin keine Navigation, sondern eine Auswahl // und zwei Spalten. HStack(spacing: 0) { sidebar .frame(width: 200) Divider() content .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } .tint(Onyx.Color.accent) .labelStyle(.onyx) // Außerhalb eines `Form` zeichnet macOS jeden `Toggle` als // Ankreuzfeld. In einer Karte mit Schaltern sieht das aus wie ein // Formular von 2009 — und schlimmer: die beiden Formen bedeuten // dasselbe und sehen verschieden aus. .toggleStyle(.switch) // Nur ein Mindestmaß, keine feste Größe: sonst kann das Fenster // wachsen, der Inhalt aber nicht mit. .frame(minWidth: 760, minHeight: 480) } private var sidebar: some View { ScrollView { VStack(spacing: Onyx.Space.xxs) { ForEach(SettingsSection.allCases) { entry in Button { section = entry } label: { OnyxSidebarLabel(entry.title, symbol: entry.symbol, tint: entry.tint) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, Onyx.Space.md) .padding(.vertical, Onyx.Space.sm) .background { RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous) .fill(section == entry ? AnyShapeStyle(Onyx.Color.accent) : AnyShapeStyle(.clear)) } // Auf der Akzentfarbe steht Weiß, nicht die // Textfarbe des Systems — die wäre im Hellmodus // schwarz auf kräftigem Blau. .foregroundStyle(section == entry ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) .contentShape(.rect) } .buttonStyle(.plain) } } .padding(.horizontal, Onyx.Space.md) .padding(.vertical, Onyx.Space.row) } .scrollContentBackground(.hidden) } @ViewBuilder private var content: some View { switch section { case .general: GeneralSettings(model: model, launchAtLogin: launchAtLogin, showOnboarding: showOnboarding) case .widgets: WidgetSettings(model: model) case .widgetOptions: WidgetOptionsView(calendarModel: calendarModel, weatherModel: weatherModel, shelfStore: shelfStore) case .notch: NotchSettings(model: model) case .appearance: AppearanceSettingsView(theme: OnyxTheme.shared) case .menubar: MenuBarSettings(model: model, metricsModel: metricsModel) case .fans: FanSettingsView(control: fanControl) case .lockScreen: LockSettingsView(model: model, controller: lockController) case .permissions: PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel) } } } // MARK: - Allgemein private struct GeneralSettings: View { @Bindable var model: AppModel let launchAtLogin: LaunchAtLogin let showOnboarding: () -> Void var body: some View { SettingsPage { OnyxFormCard(String(localized: "settings.general.behaviour")) { OnyxFormRow(String(localized: "settings.launchAtLogin")) { Toggle("", isOn: Binding( get: { launchAtLogin.isEnabled }, set: { _ = launchAtLogin.set($0) })) .labelsHidden() .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 { OnyxFormRow(String(localized: "settings.launchAtLogin.blocked")) { Button("settings.action.open") { launchAtLogin.openSystemSettings() } } } OnyxFormRow(String(localized: "settings.onboarding.title"), hint: String(localized: "settings.onboarding.hint")) { Button("settings.action.show", action: showOnboarding) } } OnyxFormCard(String(localized: "settings.general.appearanceGroup")) { OnyxFormRow(String(localized: "settings.display.menuBarIcon"), hint: String(localized: "settings.display.menuBarIcon.hint")) { Toggle("", isOn: $model.showsMenuBarIcon).labelsHidden() } OnyxFormRow(String(localized: "settings.display.dockIcon"), hint: String(localized: "settings.display.dockIcon.hint")) { Toggle("", isOn: $model.showsDockIcon).labelsHidden() } } } .task { launchAtLogin.refresh() } } } // MARK: - Notch private struct NotchSettings: View { @Bindable var model: AppModel var body: some View { SettingsPage { OnyxFormCard(String(localized: "settings.notch.displays")) { OnyxFormRow(String(localized: "settings.display.policy"), hint: String(localized: "settings.display.hint")) { Picker("", selection: $model.displayPolicy) { Text("settings.display.builtInOnly").tag(DisplayPolicy.builtInOnly) Text("settings.display.mainDisplay").tag(DisplayPolicy.mainDisplay) Text("settings.display.all").tag(DisplayPolicy.allDisplays) } .labelsHidden() .frame(width: 200) } OnyxFormRow(String(localized: "settings.notch.style"), hint: String(localized: "settings.notch.style.hint")) { Picker("", selection: $model.notchStyle) { Text("settings.notch.style.automatic").tag(NotchStyle.automatic) Text("settings.notch.style.notch").tag(NotchStyle.notch) Text("settings.notch.style.island").tag(NotchStyle.island) } .labelsHidden() .frame(width: 200) } OnyxFormRow(String(localized: "settings.island.show"), hint: String(localized: "settings.island.hint")) { Toggle("", isOn: $model.showsIdleIsland).labelsHidden() } } OnyxFormCard(String(localized: "settings.band"), hint: String(localized: "settings.band.hint")) { OnyxFormRow(String(localized: "settings.band.leading")) { BandEditor(items: $model.bandLeading) } OnyxFormRow(String(localized: "settings.band.trailing")) { BandEditor(items: $model.bandTrailing) } } NotchWidthSettings(model: model) OnyxFormCard(String(localized: "settings.notch.triggerArea")) { // Die Auslösefläche mit Vorschau: zwei Zahlen in Punkten sagen // niemandem etwas, ein nachgebildeter Bildschirm schon. TriggerAreaSettings(trigger: $model.triggerArea) .padding(.horizontal, OnyxForm.rowPadding) .padding(.bottom, OnyxForm.rowPadding) } } } } // 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 @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 { SettingsPage { OnyxFormCard(String(localized: "settings.permissions.group")) { PermissionRow( title: String(localized: "settings.permissions.calendar"), symbol: "calendar", state: calendarGranted ? .granted : (calendarModel.authorization == .denied ? .denied : .undetermined), explanation: String(localized: "settings.permissions.calendar.why"), pane: .calendar, request: { calendarGranted = await calendarModel.requestAccessNow() }) PermissionRow( title: String(localized: "settings.permissions.location"), symbol: "location", // Direkt aus dem beobachtbaren Wert: der Zustand ändert // sich, während dieses Fenster offen steht. state: locationState, explanation: String(localized: "settings.permissions.location.why"), pane: .location, request: { weatherModel.requestLocationAuthorization() }) } } .task { 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: String let symbol: String let state: State let explanation: String let pane: Pane let request: () async -> Void var body: some View { OnyxFormRow(title, hint: explanation, symbol: symbol) { 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) } } } } } } // 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: String.LocalizationValue, 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 { SettingsPage { Text("settings.menubar.hint") .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) OnyxFormCard { ForEach(Self.modules, id: \.id) { module in ModuleRow(model: model, id: module.id, label: module.label, symbol: module.symbol, metricsModel: metricsModel) } } } // 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: String.LocalizationValue 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) OnyxFormRow(String(localized: label), symbol: symbol) { HStack(spacing: Onyx.Space.md) { // 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) } Toggle("", isOn: Binding( get: { settings.isEnabled }, set: { enabled in var updated = settings updated.isEnabled = enabled model.setMenuBarSettings(updated, for: id) })) .labelsHidden() } } } } 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 /// Welches Widget rechts gezeigt wird. /// /// Der Name allein sagt nicht, was eine Kachel zeigt — „Sensoren" kann /// vieles heißen. Und ausprobieren hieß bisher: hinzufügen, Notch /// anfahren, ansehen, wieder entfernen. @State private var previewID: String? var body: some View { HStack(spacing: 0) { // Eine Liste, keine Karte: das Ziehen zum Umsortieren kommt von // `List.onMove`. Und **nur** die Liste, ohne Rahmen darum — eine // Liste in einer Spalte mit eigenem Rand lief unter den // Fensterknöpfen hindurch, weil sie ihre eigene Scrollfläche // mitbringt und die dann zwei Ränder gegeneinander schieben. List(selection: $previewID) { Section { // 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) .tag(placement.widgetID) } } .onMove { model.move(fromOffsets: $0, toOffset: $1) } if model.layout.isEmpty { Text("settings.widgets.empty") .foregroundStyle(.secondary) } } header: { Text("settings.widgets.inPanel") } footer: { Text("settings.widgets.hintShort") .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } 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) } .tag(widget.id) } } } } .listStyle(.inset) .scrollContentBackground(.hidden) Divider() WidgetPreview(widgetID: previewID) .frame(width: 252) } .task { // Beim Öffnen gleich etwas zeigen: eine leere Vorschau neben einer // vollen Liste sieht aus, als wäre sie kaputt. guard previewID == nil else { return } previewID = model.layout.first?.widgetID ?? WidgetRegistry.shared.all.first?.id } } } /// Die gewählte Kachel, so wie sie in der Notch aussieht. /// /// Echte Größe (180 × 200) auf echter Paneloberfläche und mit den echten /// Modellen — eine verkleinerte oder erfundene Vorschau wäre eine Behauptung. /// Dieselben Schritte wie in `WidgetGrid`: Kachel, Rahmen, beschneiden. private struct WidgetPreview: View { let widgetID: String? var body: some View { VStack(spacing: Onyx.Space.row) { if let widgetID, let widget = WidgetRegistry.shared.widget(id: widgetID) { OnyxTile { widget.makeView() } .frame(width: LayoutEngine.tileSize.width, height: LayoutEngine.tileSize.height) .clipped() .padding(Onyx.Metric.panelPadding) // Die Fläche bleibt schwarzer Stein, auch wenn das Fenster // hell ist — sie zeigt die Notch, nicht das Fenster. .background(OnyxSurface(cornerRadius: Onyx.Radius.tile)) .tint(Onyx.Color.accent) VStack(spacing: Onyx.Space.xxs) { Text(widget.displayName) .font(.callout) Text("settings.widgets.preview.hint") .font(.caption) .foregroundStyle(.secondary) } .multilineTextAlignment(.center) } else { Text("settings.widgets.preview.none") .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 0) } .frame(maxWidth: .infinity, alignment: .top) .padding(OnyxForm.pagePadding) } } 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: - Seite /// Der Rahmen eines Bereichs, der aus Karten besteht. /// /// Die `ScrollView` sitzt hier und nicht im Fenster: Bereiche mit einer `List` /// oder einem `Form` bringen ihre eigene mit, und zwei ineinander ergeben eine /// Fläche, die beim Ziehen mal die eine und mal die andere bewegt. struct SettingsPage: View { private let content: Content init(@ViewBuilder content: () -> Content) { self.content = content() } var body: some View { ScrollView { VStack(alignment: .leading, spacing: OnyxForm.cardSpacing) { content } .frame(maxWidth: .infinity, alignment: .leading) .padding(OnyxForm.pagePadding) } .scrollContentBackground(.hidden) } } /// Die Inselbreite, je Bildschirm. /// /// **Nur Breite, keine Höhe.** Die Höhe ist nicht mehr frei: bei verschmolzener /// Notch gibt sie die Hardware vor, bei schwebender Insel die Menüleiste, damit /// die Insel darin liegt statt darunter herauszuhängen. Ein Höhenregler wäre /// ein Bedienelement, das nichts tut. /// /// Bildschirme mit echter Notch bekommen keinen Regler, sondern die Auskunft, /// warum es keinen gibt — sonst sucht man den Fehler bei sich. private struct NotchWidthSettings: View { @Bindable var model: AppModel var body: some View { OnyxFormCard(String(localized: "settings.notch.width"), hint: String(localized: "settings.notch.width.hint")) { ForEach(NSScreen.screens, id: \.displayID) { screen in row(for: screen) } } } @ViewBuilder private func row(for screen: NSScreen) -> some View { let id = screen.displayID // Dieselbe Frage, die auch die Geometrie stellt — und dieselbe Antwort. let hasHardware = NotchGeometry.hardwareRect(screen.onyxMetrics) != nil let merged = model.notchStyle.merges(withHardware: hasHardware) OnyxFormRow(screen.localizedName, hint: merged ? String(localized: "settings.notch.width.hardware") : nil) { if merged { Text("settings.notch.width.fixed") .foregroundStyle(.secondary) } else { HStack(spacing: Onyx.Space.lg) { Slider(value: binding(for: id), in: NotchCoordinator.widthRange, step: 2) .frame(width: 180) Text(verbatim: "\(Int(model.notchWidth(for: id) ?? NotchCoordinator.defaultVirtualSize.width))") .font(.body.monospacedDigit()) .foregroundStyle(.secondary) .frame(width: 32, alignment: .trailing) Button { model.setNotchWidth(nil, for: id) } label: { Image(systemName: "arrow.counterclockwise") } .buttonStyle(.borderless) .disabled(model.notchWidth(for: id) == nil) .help(Text("settings.notch.width.reset")) } } } } private func binding(for id: CGDirectDisplayID) -> Binding { Binding(get: { model.notchWidth(for: id) ?? NotchCoordinator.defaultVirtualSize.width }, set: { model.setNotchWidth($0, for: id) }) } } /// Die Elemente einer Bandseite: was drin ist, in welcher Reihenfolge, und was /// sich hinzufügen lässt. /// /// Kein Ziehen zum Umsortieren. Die Liste hat höchstens vier Einträge, und für /// vier Einträge ist Herausnehmen und wieder Hinzufügen schneller als jede /// Ziehgeste — die man außerdem erst finden muss. private struct BandEditor: View { @Binding var items: [BandAccessory] private var available: [BandAccessory] { BandAccessory.allCases.filter { !items.contains($0) } } var body: some View { HStack(spacing: Onyx.Space.sm) { ForEach(items, id: \.self) { item in HStack(spacing: Onyx.Space.xs) { Text(item.localizedName) .font(Onyx.Font.footnote) Button { items.removeAll { $0 == item } } label: { Image(systemName: "xmark") .font(.system(size: Onyx.Symbol.caption)) } .buttonStyle(.plain) .foregroundStyle(.secondary) } .padding(.horizontal, Onyx.Space.sm) .padding(.vertical, Onyx.Space.xs) .background(.quaternary, in: .capsule) } if items.count < AppModel.bandLimit, !available.isEmpty { Menu { ForEach(available, id: \.self) { candidate in Button { items.append(candidate) } label: { Text(candidate.localizedName) } } } label: { Image(systemName: "plus") } .menuStyle(.borderlessButton) .menuIndicator(.hidden) .fixedSize() } } } }