Das Grün, das überall auftauchte, war die System-Akzentfarbe: `.tint` und `.accentColor` werden ohne eigene Angabe dorthin durchgereicht, und das betraf alle Popovers und das ganze Einstellungsfenster. Die Notch-Widgets dagegen benutzten Onyx' eigenes Blau. Zwei Farben, ungewollt. Jetzt eine, und die ist einstellbar. Ein neuer Reiter „Farben": Akzentfarbe mit sechs Vorschlägen und einem Knopf, der die Systemfarbe übernimmt, dazu das Paar für Herunter- und Hochladen samt Tauschknopf. Die Signalfarben bleiben fest und stehen nur zur Ansicht dort. Gelb heißt Warnung, Rot heißt kritisch, Grün heißt in Ordnung — bei Temperatur, Akku, Lüfter und Fehlern. Wer sie umstellen kann, kann ihre Bedeutung zerstören; ein rotes „alles gut" liest niemand richtig. Sichtbar sind sie trotzdem, sonst sucht man die Warnfarbe im Reiter und findet sie nirgends. Umgesetzt über die Tokens statt über vierzig Fundstellen: `Onyx.Color.accent` ist keine Konstante mehr, sondern liest bei jedem Zugriff aus `OnyxTheme`. Damit merkt SwiftUI die Abhängigkeit und zeichnet neu, sobald die Farbe sich ändert. Dazu ein `.tint` an den vier Wurzeln — Panel, Popovers, Einstellungen, Einrichtung —, damit auch Haken, Regler und Auswahlfelder mitziehen. Nebenbei die Beschriftung: „Je Programm" heißt jetzt „Programme". Großgesetzt las sich das als englisches „J-E". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
523 lines
20 KiB
Swift
523 lines
20 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
|
|
@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)
|
|
ColorSettingsView(theme: OnyxTheme.shared)
|
|
.tabItem { Label("settings.tab.colors", systemImage: "paintpalette") }
|
|
.tag(SettingsTab.colors)
|
|
WidgetOptionsView(calendarModel: calendarModel, weatherModel: weatherModel,
|
|
shelfStore: shelfStore)
|
|
.tabItem { Label("settings.tab.options", systemImage: "slider.horizontal.3") }
|
|
.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)
|
|
}
|
|
// Abstand nach oben: ohne ihn sitzt die Reiterleiste genau auf der
|
|
// Trennlinie unter dem Titelbalken und sieht aus, als läge sie auf
|
|
// einer Kante. `TabView` in einem gewöhnlichen Fenster bekommt diesen
|
|
// Abstand nicht von selbst — in Apples `Settings`-Szene schon, und die
|
|
// benutzt Onyx bewusst nicht.
|
|
.padding(.top, 12)
|
|
// Nur ein Mindestmaß, keine feste Größe: sonst kann das Fenster
|
|
// wachsen, der Inhalt aber nicht mit.
|
|
.frame(minWidth: 740, minHeight: 460)
|
|
}
|
|
}
|
|
|
|
// 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(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)
|
|
.padding()
|
|
}
|
|
}
|