Files
onyx/Onyx/SettingsView.swift
Scarriffle 8f0831391e Blitz fadet, Einstellungsfenster mittig und veränderbar
Der Blitz fadet beim Laden zwischen Weiß und Schwarz — so gewünscht, und
ich hätte vorher fragen sollen statt eine eigene Lösung zu bauen und sie
hinterher zu begründen.

Über den Kosinus statt linear: ein linearer Ping-Pong knickt an den
Umkehrpunkten sichtbar. Der Zeitgeber läuft mit 30 Bildern je Sekunde und
nur, solange geladen wird — steht das Kabel nicht drin, läuft gar nichts.
Der Stecker für „am Netz, lädt nicht" bleibt ausgestanzt; dort bewegt sich
ohnehin nichts.

Das Einstellungsfenster lässt sich jetzt vergrößern und startet mit
620 × 620 statt 520 × 420. Der Bereich „Displays" passt mit der Vorschau
der Auslösefläche nicht mehr in die alte Höhe, und ein Fenster, in dem man
scrollt, obwohl der Bildschirm frei ist, ist eine Zumutung. Die feste Größe
in der View musste dafür weichen — sonst wächst das Fenster und der Inhalt
bleibt stehen.

Und es öffnet jetzt wirklich mittig. Mein erster Versuch mit 60 Punkten
Abstand von oben war zu zaghaft: der Titelbalken lag weiter so hoch, dass
man auf dem Weg zu seinen Knöpfen durch die Auslösefläche fuhr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:52:21 +02:00

518 lines
19 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, 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
@Binding var selectedTab: SettingsTab
var body: some View {
TabView(selection: $selectedTab) {
WidgetSettings(model: model, shelfStore: shelfStore)
.tabItem { Label("settings.tab.widgets", systemImage: "square.grid.2x2") }
.tag(SettingsTab.widgets)
DisplaySettings(model: model)
.tabItem { Label("settings.tab.display", systemImage: "macbook") }
.tag(SettingsTab.display)
CalendarSourceSettings(model: calendarModel)
.tabItem { Label("settings.tab.sources", systemImage: "calendar.badge.clock") }
.tag(SettingsTab.sources)
MenuBarSettings(model: model, metricsModel: metricsModel)
.tabItem { Label("settings.tab.menubar", systemImage: "menubar.rectangle") }
.tag(SettingsTab.menubar)
FanSettingsView(control: fanControl)
.tabItem { Label("settings.tab.fans", systemImage: "fan") }
.tag(SettingsTab.fans)
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel,
launchAtLogin: launchAtLogin, showOnboarding: showOnboarding)
.tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") }
.tag(SettingsTab.permissions)
}
// Nur ein Mindestmaß, keine feste Größe: sonst kann das Fenster
// wachsen, der Inhalt aber nicht mit.
.frame(minWidth: 520, minHeight: 420)
}
}
// 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)
.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:
Label("settings.permissions.granted", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.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)
}
}
}
}
if let shelfStore {
Section("settings.shelf.title") {
ShelfSettingsRows(store: shelfStore)
}
}
}
.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)
.padding()
}
}