Files
onyx/Onyx/FanWidget.swift
Scarriffle 5ba9e551a4 Ladelimit gehört zum Akku, nicht zu den Lüftern
Der Regler stand in der Lüfterkachel, weil dort der Helfer schon angebunden
war. Das ist die Sicht des Programmierers, nicht die des Nutzers: gesucht
wird er beim Akku, und zwar genau dann, wenn man auf den Ladestand schaut.

Er steht jetzt in der Akku-Kachel des Panels und im Akku-Popover der
Menüleiste. Angebunden über ein Protokoll statt über einen direkten Zugriff:
geschrieben wird vom privilegierten Helfer, und der gehört zur App, nicht zu
MetricsProvider — die Akku-Ansicht soll ihn bedienen können, ohne ihn zu
kennen. Ohne laufenden Helfer ist der Regler sichtbar, aber gesperrt, mit dem
Hinweis, wo er herkommt. Ein Regler, der still nichts tut, ist schlimmer als
einer, der sagt warum.

Dazu Protokollierung, warum das Panel schließt: Zeigerposition,
Auslösefläche, Fensterrahmen. „Es ging zu, obwohl ich noch drin war" ist
ohne diese drei Zahlen eine Behauptung gegen eine andere — und meine
automatisierten Proben halten den Zeiger perfekt still, treffen den Fall
also nie.

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

261 lines
9.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: 8) {
// Automatik ist der Ruhezustand und bleibt einen Klick entfernt:
// wer manuell geregelt hat, muss ohne Nachdenken zurückkönnen.
// Als gefüllte Pille, wenn sie greift ein blauer Text sieht
// aus wie ein Link und nicht wie ein Zustand.
Button { control.setAutomatic(index: index) } label: {
Text("fans.auto")
.font(Onyx.Font.metricSmall)
.foregroundStyle(fan.isManual ? Onyx.Color.textTertiary
: Onyx.Color.surface)
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background {
Capsule()
.fill(fan.isManual ? Color.clear : Onyx.Color.accent)
.overlay {
Capsule().strokeBorder(
fan.isManual ? Onyx.Color.hairline : .clear,
lineWidth: 1)
}
}
}
.buttonStyle(.plain)
.disabled(!fan.isManual)
// Ein Regler über einen leeren Bereich stürzt ab. Meldet der
// Helfer keine brauchbaren Grenzen etwa weil der Lüfter steht
// und der SMC nichts hergibt , gibt es nichts zu ziehen.
if fan.limits.maximum > fan.limits.minimum {
Slider(value: Binding(
get: { min(max(fan.targetRPM, fan.limits.minimum), fan.limits.maximum) },
set: { control.setTarget(index: index, rpm: $0) }),
in: fan.limits.minimum...fan.limits.maximum)
.controlSize(.mini)
} else {
Text("fans.noRange")
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
Spacer(minLength: 0)
}
}
}
}
}
// 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
if presentation == .symbol {
MenuBarText.drawSymbol("fan", color: color,
in: NSRect(x: bounds.midX - 7.5, y: bounds.midY - 7.5,
width: 15, height: 15))
return
}
MenuBarText.drawSymbol("fan", color: color,
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))
}
}