Die Mediensteuerung stand nebeneinander: Cover links, alles andere in den verbleibenden gut hundert Punkten. Daher „Bernhard H…" statt eines Titels. Jetzt übereinander, wie es zu einer hochkanten Kachel passt. Das Cover ragte zusätzlich über die Kachel hinaus, weil `clipShape` vor dem Rahmen stand — beschnitten wurde damit auf die Eigengröße des Bildes, und die ist bei einem Podcast-Cover ein Vielfaches. Erst Rahmen, dann beschneiden. Menüleistensymbole waren kaum zu sehen. `color.set()` vor `image.draw(in:)` färbt ein Template-Bild nicht: es wird schwarz gezeichnet, auf einer dunklen Menüleiste also unsichtbar. AppKit färbt Template-Bilder nur ein, wenn sie als `image` eines Buttons gesetzt sind — in einer selbst gezeichneten Ansicht muss die Farbe in die Symbolkonfiguration. Betraf alle fünf Zeichenstellen, nicht nur die Lüfter. „fans.auto" stand als roher Schlüssel da: Xcode hatte ihn extrahiert, gefüllt wurde er nie. Beim Nachsehen waren es 35 solche Einträge über alle Kataloge hinweg — die meisten reine Formatschlüssel, die als Durchreicher trotzdem einen Wert brauchen. Der Automatik-Knopf ist jetzt eine gefüllte Pille, wenn sie greift. Blauer Text sieht aus wie ein Link und nicht wie ein Zustand. Und ein Absturz, der noch niemandem passiert ist: meldet der Helfer für einen stehenden Lüfter keine Grenzen, hätte `Slider(in: 0...0)` die App beendet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
261 lines
9.6 KiB
Swift
261 lines
9.6 KiB
Swift
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))
|
||
}
|
||
}
|