import SwiftUI import AppKit import OnyxDesign import OnyxWidgetKit import OnyxMenuBar import OnyxHelperProtocol /// Die Lüfter — als Panel-Kachel und als Menüleistenmodul. /// /// Beide zeigen dieselbe Ansicht auf derselben Datenquelle. Die Steuerung nur /// in den Einstellungen zu verstecken war falsch: Lüfter regelt man, wenn es /// gerade laut ist, und dann will man nicht erst ein Fenster suchen. struct FanView: View { let control: FanControl /// Im Popover ist Platz für die Erklärung, in der Kachel nicht. let roomy: Bool var body: some View { VStack(alignment: .leading, spacing: 10) { switch control.installState { case .installed: if let report = control.report, !report.fans.isEmpty { ForEach(Array(report.fans.enumerated()), id: \.offset) { index, fan in FanRow(control: control, index: index, fan: fan, roomy: roomy) } if let hottest = report.hottestCelsius { Divider().overlay(Onyx.Color.hairline) HStack(spacing: 5) { Image(systemName: "thermometer.medium") .font(.system(size: 10)) Text("\(Int(hottest.rounded())) °C") .font(Onyx.Font.metricSmall).monospacedDigit() Spacer(minLength: 0) } .foregroundStyle(Onyx.Color.textTertiary) } } else { notice("fans.noData", symbol: "fan") } case .requiresApproval: notice("fans.approval.needed", symbol: "hand.raised") { control.openApprovalSettings() } case .notInstalled: notice("fans.notInstalled", symbol: "fan") { control.install() } case .failed(let reason): Text(reason) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.critical) .fixedSize(horizontal: false, vertical: true) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .onAppear { control.refreshInstallState() } } @ViewBuilder private func notice(_ key: LocalizedStringKey, symbol: String, action: (() -> Void)? = nil) -> some View { VStack(alignment: .leading, spacing: 6) { Image(systemName: symbol) .font(.system(size: 15, weight: .light)) .foregroundStyle(Onyx.Color.textTertiary) Text(key) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) .fixedSize(horizontal: false, vertical: true) if let action { Button(action: action) { Text("fans.install") .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.surface) .padding(.horizontal, 10).padding(.vertical, 4) .background(Onyx.Color.accent, in: .capsule) } .buttonStyle(.plain) } } } } private struct FanRow: View { let control: FanControl let index: Int let fan: FanStatus let roomy: Bool var body: some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { Text("fans.fan \(fan.index + 1)") .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) .lineLimit(1) Spacer(minLength: 4) Text("\(Int(fan.currentRPM)) U/min") .font(Onyx.Font.metricSmall).monospacedDigit() .foregroundStyle(Onyx.Color.textPrimary) } HStack(spacing: 6) { // Automatik ist der Ruhezustand und bleibt einen Klick entfernt: // wer manuell geregelt hat, muss ohne Nachdenken zurückkönnen. Button { control.setAutomatic(index: index) } label: { Text("fans.auto") .font(Onyx.Font.caption) .foregroundStyle(fan.isManual ? Onyx.Color.textTertiary : Onyx.Color.accent) } .buttonStyle(.plain) Slider(value: Binding( get: { fan.targetRPM }, set: { control.setTarget(index: index, rpm: $0) }), in: fan.limits.minimum...fan.limits.maximum) .controlSize(.mini) } } } } // MARK: - Panel-Kachel struct FanWidget: OnyxWidget { let id = "fans" var displayName: String { String(localized: "widget.fans.name") } let symbolName = "fan" private let control: FanControl init(control: FanControl) { self.control = control } func makeView() -> AnyView { AnyView(ScrollView { FanView(control: control, roomy: false) } .scrollIndicators(.never)) } } // MARK: - Menüleistenmodul @MainActor final class FanMenuBarModule: MenuBarModule { let id = "fans" var displayName: String { String(localized: "widget.fans.name") } private let control: FanControl private var view: FanStatusView? private var isActive = false init(control: FanControl) { self.control = control } func makeStatusView(presentation: MenuBarPresentation) -> NSView { let view = FanStatusView(presentation: presentation) view.update(rpm: control.report?.fans.map(\.currentRPM).max()) self.view = view return view } func makePopoverView() -> AnyView { AnyView(FanView(control: control, roomy: true) .padding(14) .frame(width: 260)) } func activate() { isActive = true control.start() startObserving() } func deactivate() { isActive = false view = nil } /// Keine eigene Messschleife: `FanControl` fragt den Helfer ohnehin ab, hier /// wird nur zugehört. `withObservationTracking` meldet sich **einmal** und /// muss danach neu eingerichtet werden. private func startObserving() { withObservationTracking { _ = control.report } onChange: { Task { @MainActor [weak self] in guard let self, isActive else { return } view?.update(rpm: control.report?.fans.map(\.currentRPM).max()) startObserving() } } } } /// Die Drehzahl in der Menüleiste. private final class FanStatusView: NSView, WidthReporting { private let presentation: MenuBarPresentation private var rpm: Double? init(presentation: MenuBarPresentation) { self.presentation = presentation super.init(frame: NSRect(x: 0, y: 0, width: 22, height: 22)) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } override var intrinsicContentSize: NSSize { NSSize(width: MenuBarText.width(for: reference, includesSymbol: true), height: 22) } /// Die breiteste Zahl, die vorkommen kann — nicht die aktuelle. Sonst /// änderte das Element bei jeder Messung seine Breite. private var reference: String { presentation == .symbol ? "" : "9999" } var onWidthChange: ((CGFloat) -> Void)? func update(rpm: Double?) { self.rpm = rpm needsDisplay = true } override func draw(_ dirtyRect: NSRect) { let color = NSColor.labelColor guard let image = NSImage(systemSymbolName: "fan", accessibilityDescription: nil) else { return } image.isTemplate = true if presentation == .symbol { color.set() image.draw(in: NSRect(x: bounds.midX - 7.5, y: bounds.midY - 7.5, width: 15, height: 15)) return } color.set() image.draw(in: NSRect(x: 0, y: bounds.midY - 6.5, width: 13, height: 13)) MenuBarText.draw(rpm.map { String(Int($0.rounded())) } ?? "–", color: color, in: NSRect(x: 15, y: 0, width: bounds.width - 15, height: bounds.height)) } }