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) // 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() .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" } } }