Files
onyx/Onyx/FanSettingsView.swift
Scarriffle 2aa60eedf9 Farben einstellbar — und erst mal überhaupt eine Farbe
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>
2026-08-13 23:24:40 +02:00

191 lines
6.9 KiB
Swift

import SwiftUI
import OnyxDesign
import MetricsProvider
import OnyxHelperProtocol
/// Die Lüftersteuerung in den Einstellungen.
///
/// Bewusst hier und nicht im Notch-Panel: ein Regler, der die Kühlung des
/// Rechners verstellt, gehört nicht dorthin, wo man mit dem Mauszeiger
/// versehentlich hinkommt.
struct FanSettingsView: View {
@Bindable var control: FanControl
var body: some View {
Form {
switch control.installState {
case .notInstalled, .failed:
installSection
case .requiresApproval:
approvalSection
case .installed:
controlSection
// Das Ladelimit hängt am selben Helfer wie die Lüfter beides
// ist Hardwaresteuerung über den SMC, und beides ist erst
// erreichbar, wenn der Helfer läuft.
Section("charge.section") {
ChargeLimitControl(control: control, roomy: true)
}
}
}
.formStyle(.grouped)
.padding()
.onAppear {
control.refreshInstallState()
control.start()
}
.onDisappear { control.stop() }
}
// MARK: - Einrichtung
private var installSection: some View {
Section {
Text("fans.intro")
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
// Das Risiko benennen, bevor jemand zustimmt nicht danach.
Label("fans.warning", systemImage: "exclamationmark.triangle.fill")
.font(.callout)
.foregroundStyle(Onyx.Color.warning)
.fixedSize(horizontal: false, vertical: true)
Text("fans.safety")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if case .failed(let message) = control.installState {
Text(message).font(.callout).foregroundStyle(Onyx.Color.critical)
}
Button("fans.install") { control.install() }
}
}
private var approvalSection: some View {
Section {
Label("fans.approval.needed", systemImage: "hand.raised")
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
Button("fans.approval.open") { control.openApprovalSettings() }
Button("fans.approval.recheck") { control.refreshInstallState() }
}
}
// MARK: - Steuerung
@ViewBuilder
private var controlSection: some View {
if let report = control.report {
Section {
if let hottest = report.hottestCelsius {
HStack {
Label("fans.hottest", systemImage: "thermometer")
Spacer()
Text("\(Int(hottest))°").monospacedDigit()
.foregroundStyle(hottest >= FanSafety.criticalCelsius
? Onyx.Color.critical : .secondary)
}
}
ForEach(report.fans, id: \.index) { fan in
FanRow(fan: fan, control: control)
}
}
Section {
Text("fans.safety.active")
.font(.callout).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button("fans.uninstall", role: .destructive) {
Task { await control.uninstall() }
}
}
} else {
Section {
HStack {
ProgressView().controlSize(.small)
Text("fans.connecting").foregroundStyle(.secondary)
}
if let error = control.lastError {
Text(error).font(.callout).foregroundStyle(Onyx.Color.critical)
}
}
}
}
}
private struct FanRow: View {
let fan: FanStatus
let control: FanControl
@State private var target: Double = 0
@State private var editing = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Label(name, systemImage: "fan")
Spacer()
Text("\(Int(fan.currentRPM)) U/min").monospacedDigit().foregroundStyle(.secondary)
}
Picker("", selection: Binding(
get: { fan.isManual },
set: { manual in
if manual {
control.setTarget(index: fan.index, rpm: max(target, fan.limits.minimum))
} else {
control.setAutomatic(index: fan.index)
}
})) {
Text("fans.mode.auto").tag(false)
Text("fans.mode.manual").tag(true)
}
.pickerStyle(.segmented)
.labelsHidden()
if fan.isManual, fan.limits.isUsable {
HStack {
Text("\(Int(fan.limits.minimum))").font(.caption2).foregroundStyle(.secondary)
Slider(value: $target,
in: fan.limits.minimum...fan.limits.maximum,
onEditingChanged: { active in
editing = active
// Erst beim Loslassen setzen: jede Bewegung des
// Reglers wäre sonst ein SMC-Schreibvorgang.
if !active { control.setTarget(index: fan.index, rpm: target) }
})
Text("\(Int(fan.limits.maximum))").font(.caption2).foregroundStyle(.secondary)
}
Text("\(Int(target)) U/min").font(.caption).monospacedDigit()
}
// Ein erzwungener Rückfall wird erklärt, nicht verschwiegen.
if let reason = fan.automaticReason, reason != "noRequest" {
Label(explanation(for: reason), systemImage: "shield.lefthalf.filled")
.font(.caption)
.foregroundStyle(Onyx.Color.warning)
.fixedSize(horizontal: false, vertical: true)
}
}
.onAppear { if !editing { target = max(fan.targetRPM, fan.limits.minimum) } }
.onChange(of: fan.targetRPM) { _, new in if !editing { target = new } }
}
private var name: String {
fan.index == 0 ? String(localized: "fans.left") : String(localized: "fans.right")
}
private func explanation(for reason: String) -> LocalizedStringKey {
switch reason {
case "watchdog": "fans.reason.watchdog"
case "temperature": "fans.reason.temperature"
case "noTemperature": "fans.reason.noTemperature"
case "unknownLimits": "fans.reason.unknownLimits"
default: "fans.reason.other"
}
}
}