diff --git a/Packages/OnyxKit/Sources/MetricsProvider/BatteryHistory.swift b/Packages/OnyxKit/Sources/MetricsProvider/BatteryHistory.swift new file mode 100644 index 0000000..d57a896 --- /dev/null +++ b/Packages/OnyxKit/Sources/MetricsProvider/BatteryHistory.swift @@ -0,0 +1,186 @@ +import Foundation +import Observation +import os + +private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "BatteryHistory") + +/// Ein Messpunkt des Akkus. +/// +/// Vier Zahlen, mehr nicht. Was sich nicht zeichnen lässt, gehört nicht in +/// einen Verlauf — Zyklen und Gesundheit ändern sich in Monaten und stehen +/// besser als aktueller Wert da. +public struct BatterySample: Equatable, Sendable, Codable { + public let at: Date + /// 0 bis 1. + public let charge: Double + /// Zellentemperatur, falls der SMC sie hergibt. + public let celsius: Double? + /// Negativ heißt Entladung. + public let watts: Double? + + public init(at: Date, charge: Double, celsius: Double?, watts: Double?) { + self.at = at + self.charge = charge + self.celsius = celsius + self.watts = watts + } +} + +/// Über welchen Zeitraum der Verlauf gezeigt wird. +public enum HistoryWindow: String, CaseIterable, Sendable { + case day, week, month + + public var duration: TimeInterval { + switch self { + case .day: 86_400 + case .week: 7 * 86_400 + case .month: 30 * 86_400 + } + } + + /// Wie viele Säulen die Anzeige bekommt. + /// + /// Nicht ein Punkt je Messung: über einen Monat wären das über achttausend, + /// und mehr Punkte als Bildpunkte zu zeichnen kostet Zeit, ohne etwas zu + /// zeigen. + public var buckets: Int { + switch self { + case .day: 48 // halbstündlich + case .week: 56 // dreistündlich + case .month: 60 // halbtägig + } + } +} + +/// Die Regeln des Verlaufs — ohne Uhr, ohne Dateisystem. +public enum BatteryHistoryRules { + + /// Wie oft gemessen wird. + /// + /// Fünf Minuten reichen: der Ladestand eines Akkus ändert sich nicht + /// sprunghaft, und ein Punkt je Minute wäre die zwölffache Datei für + /// dieselbe Kurve. + public static let interval: TimeInterval = 300 + + /// Wie lange Messpunkte aufgehoben werden. + /// + /// Genau so lang wie das längste Fenster. Alles darüber hinaus wäre eine + /// Datei, die wächst, ohne dass es je jemand ansieht. + public static let maximumAge: TimeInterval = HistoryWindow.month.duration + + /// Ob jetzt ein neuer Messpunkt fällig ist. + public static func shouldRecord(last: Date?, now: Date, + interval: TimeInterval = interval) -> Bool { + guard let last else { return true } + return now.timeIntervalSince(last) >= interval + } + + /// Was zu alt geworden ist, fällt heraus. + public static func pruned(_ samples: [BatterySample], now: Date, + maximumAge: TimeInterval = maximumAge) -> [BatterySample] { + let cutoff = now.addingTimeInterval(-maximumAge) + return samples.filter { $0.at >= cutoff } + } + + /// Fasst die Messpunkte eines Fensters zu Säulen zusammen. + /// + /// Der Mittelwert je Zeitabschnitt, nicht der erste oder letzte Wert: ein + /// einzelner Ausreißer soll die Säule nicht bestimmen. Abschnitte ohne + /// Messung bleiben `nil` — ein zugeklappter Rechner hat dort keinen + /// Ladestand gehabt, und eine durchgezogene Linie darüber wäre erfunden. + public static func buckets(_ samples: [BatterySample], + window: HistoryWindow, + now: Date) -> [Double?] { + let start = now.addingTimeInterval(-window.duration) + let width = window.duration / Double(window.buckets) + + var sums = [Double](repeating: 0, count: window.buckets) + var counts = [Int](repeating: 0, count: window.buckets) + + for sample in samples where sample.at >= start && sample.at <= now { + let offset = sample.at.timeIntervalSince(start) + let index = min(Int(offset / width), window.buckets - 1) + sums[index] += sample.charge + counts[index] += 1 + } + + return (0.. (low: Double, high: Double, average: Double)? { + let start = now.addingTimeInterval(-window.duration) + let inWindow = samples.filter { $0.at >= start && $0.at <= now } + guard !inWindow.isEmpty else { return nil } + let charges = inWindow.map(\.charge) + return (charges.min()!, charges.max()!, + charges.reduce(0, +) / Double(charges.count)) + } +} + +/// Der Verlauf, aufgezeichnet und abgelegt. +@MainActor +@Observable +public final class BatteryHistoryStore { + + public private(set) var samples: [BatterySample] = [] + + @ObservationIgnored private let fileURL: URL + @ObservationIgnored private var lastRecorded: Date? + + private struct Archive: Codable { + var version = 1 + var samples: [BatterySample] + } + + public init(directory: URL? = nil) { + let folder = directory ?? Self.defaultDirectory + try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + fileURL = folder.appendingPathComponent("battery.json") + load() + lastRecorded = samples.last?.at + } + + public static var defaultDirectory: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Onyx/Metrics", isDirectory: true) + } + + /// Nimmt eine Messung entgegen — und legt sie nur ab, wenn sie fällig ist. + public func record(_ reading: BatteryReading, now: Date = Date()) { + guard BatteryHistoryRules.shouldRecord(last: lastRecorded, now: now) else { return } + lastRecorded = now + samples.append(BatterySample(at: now, charge: reading.charge, + celsius: reading.celsius, watts: reading.watts)) + samples = BatteryHistoryRules.pruned(samples, now: now) + save() + } + + public func buckets(_ window: HistoryWindow, now: Date = Date()) -> [Double?] { + BatteryHistoryRules.buckets(samples, window: window, now: now) + } + + public func summary(_ window: HistoryWindow, + now: Date = Date()) -> (low: Double, high: Double, average: Double)? { + BatteryHistoryRules.summary(samples, window: window, now: now) + } + + private func load() { + guard let data = try? Data(contentsOf: fileURL) else { return } + guard let archive = try? JSONDecoder().decode(Archive.self, from: data), + archive.version == 1 else { + log.notice("Unbekannte Fassung des Akkuverlaufs — er beginnt neu") + return + } + samples = archive.samples.sorted { $0.at < $1.at } + } + + private func save() { + do { + try JSONEncoder().encode(Archive(samples: samples)).write(to: fileURL, options: .atomic) + } catch { + log.error("Verlauf nicht schreibbar: \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/Packages/OnyxKit/Sources/MetricsProvider/BatteryHistoryView.swift b/Packages/OnyxKit/Sources/MetricsProvider/BatteryHistoryView.swift new file mode 100644 index 0000000..f7f9da6 --- /dev/null +++ b/Packages/OnyxKit/Sources/MetricsProvider/BatteryHistoryView.swift @@ -0,0 +1,90 @@ +import SwiftUI +import OnyxDesign + +/// Der Ladeverlauf als Säulen. +/// +/// **Säulen und keine Linie.** Eine Linie verbindet auch dort, wo nichts +/// gemessen wurde — ein zugeklappter Rechner hat keinen Ladestand gehabt, und +/// eine Linie darüber behauptet einen. Säulen können einfach fehlen. +public struct BatteryHistoryView: View { + + private let store: BatteryHistoryStore + @State private var window: HistoryWindow = .day + + public init(store: BatteryHistoryStore) { self.store = store } + + public var body: some View { + VStack(alignment: .leading, spacing: Onyx.Space.sm) { + header + chart + } + } + + private var header: some View { + HStack(spacing: Onyx.Space.sm) { + if let summary = store.summary(window) { + Text(verbatim: "\(percent(summary.low)) – \(percent(summary.high)) %") + .font(Onyx.Font.metricSmall) + .foregroundStyle(Onyx.Color.textSecondary) + } else { + Text("battery.history.empty", bundle: .module) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textTertiary) + } + + Spacer(minLength: Onyx.Space.xs) + + // Drei kurze Wörter statt eines Menüs: die Auswahl hat drei + // Möglichkeiten, und ein Menü dafür verlangt zwei Klicks für das, + // was ein Klick kann. + ForEach(HistoryWindow.allCases, id: \.self) { candidate in + Button { window = candidate } label: { + Text(candidate.localizedName, bundle: .module) + .font(Onyx.Font.micro) + .foregroundStyle(candidate == window + ? Onyx.Color.accent : Onyx.Color.textTertiary) + } + .buttonStyle(.plain) + } + } + } + + private var chart: some View { + GeometryReader { geometry in + let buckets = store.buckets(window) + let gap: CGFloat = 1 + let width = max((geometry.size.width - gap * CGFloat(buckets.count - 1)) + / CGFloat(buckets.count), 1) + + HStack(alignment: .bottom, spacing: gap) { + ForEach(Array(buckets.enumerated()), id: \.offset) { _, value in + if let value { + RoundedRectangle(cornerRadius: Onyx.Radius.bar, style: .continuous) + .fill(Onyx.Color.accent) + .frame(width: width, + height: max(geometry.size.height * value, 1)) + } else { + // Die Lücke bleibt eine Lücke. + Color.clear.frame(width: width, height: 1) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + .frame(height: 44) + } + + private func percent(_ value: Double) -> Int { + Int((min(max(value, 0), 1) * 100).rounded()) + } +} + +extension HistoryWindow { + var localizedName: LocalizedStringKey { + switch self { + case .day: "battery.history.day" + case .week: "battery.history.week" + case .month: "battery.history.month" + } + } +} diff --git a/Packages/OnyxKit/Sources/MetricsProvider/ChargeActivity.swift b/Packages/OnyxKit/Sources/MetricsProvider/ChargeActivity.swift index c67ef64..8ca6782 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/ChargeActivity.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/ChargeActivity.swift @@ -35,3 +35,58 @@ public enum ChargeActivity: Equatable, Sendable { return .pluggedNotCharging } } + +/// Was am Strom geschehen ist — und ob es überhaupt etwas war. +/// +/// ``ChargeActivity`` sagt, welcher Zustand *gilt*. Das ist nicht dasselbe wie +/// „es ist etwas passiert": der Zustand gilt die ganze Zeit, gemeldet werden +/// soll er nur beim Wechsel. +public enum PowerEvent: Equatable, Sendable { + case charging + case plugged + case unplugged + case atLimit(Int) + /// Am Akku und fast leer. Schlägt `unplugged`: beides trifft zu, aber nur + /// eines davon muss man sehen. + case low +} + +/// Merkt sich den letzten Zustand und meldet nur Wechsel. +/// +/// **Der erste gelesene Zustand meldet nichts.** Sonst begrüßte Onyx jeden +/// Start mit „am Netz" — einer Nachricht über nichts, das gerade geschehen ist. +/// +/// Eigener Typ und nicht zwei Zeilen im Programmstart, weil hier drei Regeln +/// zusammenkommen, die man einzeln prüfen können muss: die Schwelle für „fast +/// leer", der Vorrang vor „am Akku" und das Schweigen beim ersten Mal. +public struct PowerWatcher: Equatable, Sendable { + + /// Ab hier heißt „am Akku" nicht mehr nur „am Akku". + public static let lowThreshold = 0.2 + + private var last: PowerEvent? + + public init() {} + + /// Der zuletzt gesehene Zustand. `nil`, solange nichts gelesen wurde. + public var current: PowerEvent? { last } + + /// Nimmt eine Messung entgegen und gibt zurück, was zu melden ist — oder + /// `nil`, wenn sich nichts geändert hat. + public mutating func observe(isCharging: Bool, isPluggedIn: Bool, + charge: Double, limit: Int?) -> PowerEvent? { + let state = ChargeActivity.resolve(isCharging: isCharging, + isPluggedIn: isPluggedIn, + charge: charge, limit: limit) + let event: PowerEvent = switch state { + case .charging: .charging + case .pausedAtLimit(let value): .atLimit(value) + case .pluggedNotCharging: .plugged + case .onBattery: charge <= Self.lowThreshold ? .low : .unplugged + } + + defer { last = event } + guard let previous = last else { return nil } + return event == previous ? nil : event + } +} diff --git a/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings index f031b04..d48183a 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings @@ -1,1064 +1,1126 @@ { - "sourceLanguage" : "en", - "strings" : { - "" : { - - }, - "%lld" : { - "comment" : "A label displaying the speed of a fan. The argument is the speed of the fan, in revolutions per minute.", - "isCommentAutoGenerated" : true, - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld" + "sourceLanguage": "en", + "strings": { + "": {}, + "%lld": { + "comment": "A label displaying the speed of a fan. The argument is the speed of the fan, in revolutions per minute.", + "isCommentAutoGenerated": true, + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld" + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld" } } } }, - "%lld U/min" : { - "comment" : "A label that shows the fan speed in revolutions per minute. The value is the fan speed in revolutions per minute.", - "isCommentAutoGenerated" : true, - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld U/min" + "%lld U/min": { + "comment": "A label that shows the fan speed in revolutions per minute. The value is the fan speed in revolutions per minute.", + "isCommentAutoGenerated": true, + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld U/min" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld U/min" + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld U/min" } } } }, - "battery.charging" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lädt" + "Lüfter %lld": { + "comment": "A label that shows the name of a fan. The argument is the index of the fan.", + "isCommentAutoGenerated": true, + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter %lld" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Charging" + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan %lld" } } } }, - "battery.cycles" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zyklen" + "battery.charging": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lädt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cycles" + "en": { + "stringUnit": { + "state": "translated", + "value": "Charging" } } } }, - "battery.draining" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verbrauch" + "battery.cycles": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zyklen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Draw" + "en": { + "stringUnit": { + "state": "translated", + "value": "Cycles" } } } }, - "battery.health" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zustand" + "battery.draining": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbrauch" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Health" + "en": { + "stringUnit": { + "state": "translated", + "value": "Draw" } } } }, - "battery.lowPower" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Energiesparmodus" + "battery.health": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zustand" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Low Power Mode" + "en": { + "stringUnit": { + "state": "translated", + "value": "Health" } } } }, - "battery.pluggedIn" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Am Netz" + "battery.history.day": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "24 h" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Plugged in" + "en": { + "stringUnit": { + "state": "translated", + "value": "24 h" } } } }, - "battery.remaining %@" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "noch %@" + "battery.history.empty": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch kein Verlauf." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ remaining" + "en": { + "stringUnit": { + "state": "translated", + "value": "No history yet." } } } }, - "battery.untilFull %@" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ bis voll" + "battery.history.month": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "30 T" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ until full" + "en": { + "stringUnit": { + "state": "translated", + "value": "30 d" } } } }, - "charge.limit" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ladelimit" + "battery.history.week": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "7 T" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Charge limit" + "en": { + "stringUnit": { + "state": "translated", + "value": "7 d" } } } }, - "charge.limit.hint" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "80 bis 100 % — der Bereich, den macOS selbst anbietet." + "battery.lowPower": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Energiesparmodus" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "80 to 100 % — the range macOS itself offers." + "en": { + "stringUnit": { + "state": "translated", + "value": "Low Power Mode" } } } }, - "charge.limit.inSystemSettings" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "In den Systemeinstellungen einstellbar" + "battery.pluggedIn": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Am Netz" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Set in System Settings" + "en": { + "stringUnit": { + "state": "translated", + "value": "Plugged in" } } } }, - "charge.limit.needsHelper" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Braucht den privilegierten Dienst aus Einstellungen → Lüfter." + "battery.remaining %@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "noch %@" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Needs the privileged service from Settings → Fans." + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ remaining" } } } }, - "charge.limit.off" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aus" + "battery.untilFull %@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ bis voll" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Off" + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ until full" } } } }, - "Lüfter %lld" : { - "comment" : "A label that shows the name of a fan. The argument is the index of the fan.", - "isCommentAutoGenerated" : true, - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lüfter %lld" + "charge.limit": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladelimit" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fan %lld" + "en": { + "stringUnit": { + "state": "translated", + "value": "Charge limit" } } } }, - "menubar.short.battery" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Akku" + "charge.limit.hint": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "80 bis 100 % — der Bereich, den macOS selbst anbietet." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Batt" + "en": { + "stringUnit": { + "state": "translated", + "value": "80 to 100 % — the range macOS itself offers." } } } }, - "menubar.short.temperature" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temp" + "charge.limit.inSystemSettings": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "In den Systemeinstellungen einstellbar" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temp" + "en": { + "stringUnit": { + "state": "translated", + "value": "Set in System Settings" } } } }, - "metric.battery" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Akku" + "charge.limit.needsHelper": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Braucht den privilegierten Dienst aus Einstellungen → Lüfter." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Battery" + "en": { + "stringUnit": { + "state": "translated", + "value": "Needs the privileged service from Settings → Fans." } } } }, - "metric.battery.charging" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lädt" + "charge.limit.off": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Charging" + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" } } } }, - "metric.cpu" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "CPU" + "menubar.short.battery": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Akku" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "CPU" + "en": { + "stringUnit": { + "state": "translated", + "value": "Batt" } } } }, - "metric.cpu.perCore" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je Kern" + "menubar.short.temperature": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Temp" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Per core" + "en": { + "stringUnit": { + "state": "translated", + "value": "Temp" } } } }, - "metric.fan %lld" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lüfter %lld" + "metric.battery": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Akku" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fan %lld" + "en": { + "stringUnit": { + "state": "translated", + "value": "Battery" } } } }, - "metric.fan.controlComing" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Die Lüftersteuerung kommt mit dem privilegierten Helfer — Schreibzugriff auf den SMC verlangt root." + "metric.battery.charging": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lädt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fan control arrives with the privileged helper — writing to the SMC requires root." + "en": { + "stringUnit": { + "state": "translated", + "value": "Charging" } } } }, - "metric.gpu" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "GPU" + "metric.cpu": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "CPU" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "GPU" + "en": { + "stringUnit": { + "state": "translated", + "value": "CPU" } } } }, - "metric.memory" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Speicher" + "metric.cpu.perCore": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Je Kern" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Memory" + "en": { + "stringUnit": { + "state": "translated", + "value": "Per core" } } } }, - "metric.mode.temperature" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temperatur" + "metric.fan %lld": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter %lld" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temperature" + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan %lld" } } } }, - "metric.mode.usage" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auslastung" + "metric.fan.controlComing": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Lüftersteuerung kommt mit dem privilegierten Helfer — Schreibzugriff auf den SMC verlangt root." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Usage" + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan control arrives with the privileged helper — writing to the SMC requires root." } } } }, - "metric.rpm" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "U/min" + "metric.gpu": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "GPU" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "rpm" + "en": { + "stringUnit": { + "state": "translated", + "value": "GPU" } } } }, - "metric.sensors" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sensoren" + "metric.memory": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Speicher" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sensors" + "en": { + "stringUnit": { + "state": "translated", + "value": "Memory" } } } }, - "metric.sensors.none" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Keine Sensoren lesbar" + "metric.mode.temperature": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Temperatur" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No sensors readable" + "en": { + "stringUnit": { + "state": "translated", + "value": "Temperature" } } } }, - "metric.show" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anzeigen" + "metric.mode.usage": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auslastung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show" + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage" } } } }, - "popover.battery.calculating" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wird berechnet …" + "metric.rpm": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "U/min" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Calculating…" + "en": { + "stringUnit": { + "state": "translated", + "value": "rpm" } } } }, - "popover.battery.charging" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lädt" + "metric.sensors": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sensoren" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Charging" + "en": { + "stringUnit": { + "state": "translated", + "value": "Sensors" } } } }, - "popover.battery.condition" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zustand" + "metric.sensors.none": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Sensoren lesbar" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Condition" + "en": { + "stringUnit": { + "state": "translated", + "value": "No sensors readable" } } } }, - "popover.battery.cycles" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ladezyklen" + "metric.show": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cycles" + "en": { + "stringUnit": { + "state": "translated", + "value": "Show" } } } }, - "popover.battery.draining" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verbrauch" + "popover.battery.calculating": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird berechnet …" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Draw" + "en": { + "stringUnit": { + "state": "translated", + "value": "Calculating…" } } } }, - "popover.battery.health" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kapazität" + "popover.battery.charging": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lädt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Capacity" + "en": { + "stringUnit": { + "state": "translated", + "value": "Charging" } } } }, - "popover.battery.limitComing" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Das Ladelimit kommt mit dem privilegierten Helfer. Der zuständige SMC-Key ist auf diesem Modell noch nicht identifiziert — siehe docs/spikes/A-smc.md." + "popover.battery.condition": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zustand" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The charge limit arrives with the privileged helper. The relevant SMC key is not yet identified on this model — see docs/spikes/A-smc.md." + "en": { + "stringUnit": { + "state": "translated", + "value": "Condition" } } } }, - "popover.battery.none" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kein Akku gefunden" + "popover.battery.cycles": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladezyklen" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No battery found" + "en": { + "stringUnit": { + "state": "translated", + "value": "Cycles" } } } }, - "popover.battery.notCharging" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Am Netz, lädt nicht" + "popover.battery.draining": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbrauch" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Plugged in, not charging" + "en": { + "stringUnit": { + "state": "translated", + "value": "Draw" } } } }, - "popover.battery.onBattery" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Akkubetrieb" + "popover.battery.health": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kapazität" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Running on battery" + "en": { + "stringUnit": { + "state": "translated", + "value": "Capacity" } } } }, - "popover.battery.pausedAtLimit %lld" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Angehalten — Ladelimit von %lld %% erreicht" + "popover.battery.limitComing": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Ladelimit kommt mit dem privilegierten Helfer. Der zuständige SMC-Key ist auf diesem Modell noch nicht identifiziert — siehe docs/spikes/A-smc.md." } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Paused — charge limit of %lld %% reached" + "en": { + "stringUnit": { + "state": "translated", + "value": "The charge limit arrives with the privileged helper. The relevant SMC key is not yet identified on this model — see docs/spikes/A-smc.md." } } } }, - "popover.battery.pluggedIn" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Am Netzteil" + "popover.battery.none": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Akku gefunden" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "On adapter" + "en": { + "stringUnit": { + "state": "translated", + "value": "No battery found" } } } }, - "popover.battery.remaining" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "verbleibend" + "popover.battery.notCharging": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Am Netz, lädt nicht" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "remaining" + "en": { + "stringUnit": { + "state": "translated", + "value": "Plugged in, not charging" } } } }, - "popover.battery.untilFull" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "bis voll" + "popover.battery.onBattery": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Akkubetrieb" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "until full" + "en": { + "stringUnit": { + "state": "translated", + "value": "Running on battery" } } } }, - "popover.battery.voltage" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Spannung" + "popover.battery.pausedAtLimit %lld": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Angehalten — Ladelimit von %lld %% erreicht" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Voltage" + "en": { + "stringUnit": { + "state": "translated", + "value": "Paused — charge limit of %lld %% reached" } } } }, - "popover.fans" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lüfter" + "popover.battery.pluggedIn": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Am Netzteil" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fans" + "en": { + "stringUnit": { + "state": "translated", + "value": "On adapter" } } } }, - "popover.memory.app" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Programme" + "popover.battery.remaining": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "verbleibend" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Apps" + "en": { + "stringUnit": { + "state": "translated", + "value": "remaining" } } } }, - "popover.memory.compressed" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Komprimiert" + "popover.battery.untilFull": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "bis voll" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Compressed" + "en": { + "stringUnit": { + "state": "translated", + "value": "until full" } } } }, - "popover.memory.free" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Frei" + "popover.battery.voltage": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Spannung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Free" + "en": { + "stringUnit": { + "state": "translated", + "value": "Voltage" } } } }, - "popover.memory.swap" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auslagerung" + "popover.fans": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Swap" + "en": { + "stringUnit": { + "state": "translated", + "value": "Fans" } } } }, - "popover.memory.swapUsed" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Benutzt" + "popover.memory.app": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Programme" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Used" + "en": { + "stringUnit": { + "state": "translated", + "value": "Apps" } } } }, - "popover.memory.used" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Belegt" + "popover.memory.compressed": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Komprimiert" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Used" + "en": { + "stringUnit": { + "state": "translated", + "value": "Compressed" } } } }, - "popover.memory.wired" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reserviert" + "popover.memory.free": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Frei" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wired" + "en": { + "stringUnit": { + "state": "translated", + "value": "Free" } } } }, - "popover.perCore" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je Kern" + "popover.memory.swap": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auslagerung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Per core" + "en": { + "stringUnit": { + "state": "translated", + "value": "Swap" } } } }, - "popover.power" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Leistung" + "popover.memory.swapUsed": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Power" + "en": { + "stringUnit": { + "state": "translated", + "value": "Used" } } } }, - "popover.power.adapter" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Netzteil" + "popover.memory.used": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Belegt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Adapter" + "en": { + "stringUnit": { + "state": "translated", + "value": "Used" } } } }, - "popover.power.cpu" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "CPU" + "popover.memory.wired": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Reserviert" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "CPU" + "en": { + "stringUnit": { + "state": "translated", + "value": "Wired" } } } }, - "popover.power.display" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anzeige" + "popover.perCore": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Je Kern" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Display" + "en": { + "stringUnit": { + "state": "translated", + "value": "Per core" } } } }, - "popover.power.total" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Gesamt" + "popover.power": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Leistung" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Total" + "en": { + "stringUnit": { + "state": "translated", + "value": "Power" } } } }, - "popover.processes" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Größte Verbraucher" + "popover.power.adapter": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzteil" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Top consumers" + "en": { + "stringUnit": { + "state": "translated", + "value": "Adapter" } } } }, - "popover.temperature" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temperatur" + "popover.power.cpu": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "CPU" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temperature" + "en": { + "stringUnit": { + "state": "translated", + "value": "CPU" } } } }, - "settings.menubar.labelAndValue" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kürzel + Wert" + "popover.power.display": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeige" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Label + value" + "en": { + "stringUnit": { + "state": "translated", + "value": "Display" } } } }, - "settings.menubar.symbolAndValue" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Symbol + Wert" + "popover.power.total": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gesamt" } }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Symbol + value" + "en": { + "stringUnit": { + "state": "translated", + "value": "Total" + } + } + } + }, + "popover.processes": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Größte Verbraucher" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Top consumers" + } + } + } + }, + "popover.temperature": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Temperatur" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Temperature" + } + } + } + }, + "settings.menubar.labelAndValue": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kürzel + Wert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Label + value" + } + } + } + }, + "settings.menubar.symbolAndValue": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Symbol + Wert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Symbol + value" } } } } }, - "version" : "1.1" -} \ No newline at end of file + "version": "1.1" +} diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift index e92680f..5d01ab6 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift @@ -82,14 +82,14 @@ private struct CompactMetric: View { private var mode: MetricDisplayMode { model.displayMode(for: metric) } var body: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: Onyx.Space.xs) { Label { Text(String(localized: .init(metric.localizationKey), bundle: .module)) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) } icon: { Image(systemName: metric.symbolName) - .font(.system(size: 11)) + .font(.system(size: Onyx.Symbol.small)) .foregroundStyle(Onyx.Color.accent) } @@ -103,7 +103,7 @@ private struct CompactMetric: View { if let detail = MetricSummary.detail(metric, model.snapshot) { Text(detail) - .font(.system(size: 9)) + .font(Onyx.Font.micro) .monospacedDigit() .foregroundStyle(Onyx.Color.textTertiary) .lineLimit(1) @@ -121,7 +121,7 @@ private struct WideMetric: View { // Übereinander, nicht nebeneinander: die Kachel ist hochkant, und ein // Verlauf über die volle Breite liest sich besser als einer, der sich // die Zeile mit der Zahl teilt. - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: Onyx.Space.md) { CompactMetric(model: model, metric: metric) Spacer(minLength: 0) Sparkline(values: model.series(metric), @@ -200,11 +200,11 @@ private struct SensorList: View { let detailed: Bool var body: some View { - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: Onyx.Space.xs) { ForEach(snapshot.sensors.prefix(detailed ? 8 : 3)) { sensor in - HStack(spacing: 6) { + HStack(spacing: Onyx.Space.sm) { Text(sensor.name) - .font(.system(size: 10)) + .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) .lineLimit(1) Spacer(minLength: 0) @@ -215,17 +215,17 @@ private struct SensorList: View { } if detailed, !snapshot.fans.isEmpty { - Divider().overlay(Onyx.Color.hairline).padding(.vertical, 2) + OnyxHairline().padding(.vertical, Onyx.Space.xxs) ForEach(snapshot.fans) { fan in - HStack(spacing: 6) { + HStack(spacing: Onyx.Space.sm) { Image(systemName: "fan") - .font(.system(size: 9)) + .font(.system(size: Onyx.Symbol.caption)) .foregroundStyle(Onyx.Color.textTertiary) Text("\(Int(fan.rpm))") .font(Onyx.Font.metricSmall) .foregroundStyle(Onyx.Color.textSecondary) Text(verbatim: "U/min") - .font(.system(size: 9)) + .font(Onyx.Font.micro) .foregroundStyle(Onyx.Color.textTertiary) Spacer(minLength: 0) } @@ -351,8 +351,10 @@ private struct BatteryTile: View { var body: some View { let battery = model.snapshot.battery - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .firstTextBaseline, spacing: 7) { + VStack(alignment: .leading, spacing: Onyx.Space.md) { + // Der Verlauf steht unter den Zahlen, nicht über ihnen: die Frage + // „wie voll ist er jetzt" kommt vor „wie war es gestern". + HStack(alignment: .firstTextBaseline, spacing: Onyx.Space.md) { // Das Symbol folgt dem Ladestand — sonst zeigt es bei 5 % // dasselbe wie bei 95 % und trägt nichts bei. if let battery { @@ -362,7 +364,7 @@ private struct BatteryTile: View { .foregroundStyle(tint(battery)) } else { Image(systemName: BatterySymbol.unavailable) - .font(.system(size: 13)) + .font(.system(size: Onyx.Symbol.medium)) .foregroundStyle(Onyx.Color.textTertiary) } Text(MetricFormat.percent(battery?.charge ?? 0)) @@ -386,7 +388,7 @@ private struct BatteryTile: View { Spacer(minLength: 0) - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: Onyx.Space.xs) { if let watts = battery.watts, abs(watts) > 0.1 { row(watts > 0 ? "battery.charging" : "battery.draining", value: String(format: "%.1f W", abs(watts))) @@ -405,19 +407,23 @@ private struct BatteryTile: View { // Der Energiesparmodus erklärt, warum die Maschine langsamer // wirkt. Ohne diesen Hinweis sucht man den Grund woanders. if battery.isLowPower { - HStack(spacing: 4) { - Image(systemName: "leaf.fill").font(.system(size: 9)) + HStack(spacing: Onyx.Space.xs) { + Image(systemName: "leaf.fill").font(.system(size: Onyx.Symbol.caption)) Text("battery.lowPower", bundle: .module).font(Onyx.Font.metricSmall) } .foregroundStyle(Onyx.Color.warning) } + + // Der Verlauf steht unten: „wie voll ist er jetzt" kommt vor + // „wie war es gestern". + BatteryHistoryView(store: model.batteryHistory) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private func row(_ key: String.LocalizationValue, value: String) -> some View { - HStack(spacing: 4) { + HStack(spacing: Onyx.Space.xs) { Text(String(localized: key, bundle: .module)) .foregroundStyle(Onyx.Color.textTertiary) Spacer(minLength: 4) diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift index d45e26e..23a9406 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift @@ -49,6 +49,14 @@ public final class MetricsModel { } public private(set) var snapshot = MetricsSnapshot() + + /// Der Akkuverlauf. + /// + /// Gehört hierher und nicht in den Programmstart: die Messwerte entstehen + /// hier, und ein zweiter Beobachter für dieselben Zahlen wäre ein zweiter + /// Weg zu ihnen. Wie oft wirklich aufgezeichnet wird, entscheidet der + /// Speicher selbst — hier wird nur jede Messung angeboten. + public let batteryHistory = BatteryHistoryStore() /// Verlauf für die Graphen, jüngster Wert zuletzt. public private(set) var history: [MetricsSnapshot] = [] @@ -132,6 +140,7 @@ public final class MetricsModel { private func sample() { let value = source.sample() snapshot = value + if let battery = value.battery { batteryHistory.record(battery) } history.append(value) if history.count > Self.historyLength { history.removeFirst(history.count - Self.historyLength) } } diff --git a/Packages/OnyxKit/Sources/OnyxHelperProtocol/ChargeSafety.swift b/Packages/OnyxKit/Sources/OnyxHelperProtocol/ChargeSafety.swift index 937d2d6..702be9a 100644 --- a/Packages/OnyxKit/Sources/OnyxHelperProtocol/ChargeSafety.swift +++ b/Packages/OnyxKit/Sources/OnyxHelperProtocol/ChargeSafety.swift @@ -66,3 +66,81 @@ public enum ChargeSafety { return percent } } + +/// Segelmodus und Hitzeschutz — die beiden Regeln, die *über* dem Limit liegen. +/// +/// **Warum im Helfer und nicht in der App.** Beide greifen in das Laden ein, +/// und ein falscher Eingriff dort ist die eine Operation in diesem Projekt, die +/// den Akku dauerhaft beschädigen kann. Was der Helfer tut, ist von außen nicht +/// abschaltbar — dieselbe Begründung wie bei `FanSafety`. +public enum ChargePolicy { + + /// Wie weit der Ladestand unter das Limit fallen muss, bevor wieder geladen + /// wird. + /// + /// **Der eigentliche Zweck des Segelns.** Ohne Abstand hängt der Akku + /// dauerhaft am Limit und wird bei jedem verlorenen Prozent nachgeladen — + /// hunderte winzige Ladevorgänge am Tag, und genau die altern ihn. Mit + /// Abstand lädt er einmal und segelt dann von selbst hinunter. + public static let sailingHysteresis = 5 + + /// Ab dieser Zellentemperatur wird nicht geladen. + /// + /// Hitze schadet einem Lithium-Akku mehr als jeder Ladestand. Der Wert ist + /// bewusst konservativ: macOS selbst drosselt später, aber macOS kennt auch + /// die Ladehistorie des Geräts, und wir kennen sie nicht. + public static let maximumChargingCelsius = 40.0 + + /// Unter dieser Temperatur wird wieder geladen. + /// + /// Auch hier ein Abstand, aus demselben Grund wie beim Segeln: genau an der + /// Schwelle schaltete es sonst im Sekundentakt an und aus. + public static let resumeChargingCelsius = 36.0 + + /// Ob jetzt geladen werden soll. + /// + /// - Parameters: + /// - charge: Ladestand, 0 bis 1. + /// - limit: Das eingestellte Limit in Prozent. + /// - celsius: Zellentemperatur, falls bekannt. Unbekannt heißt: kein + /// Hitzeschutz — eine Temperatur zu erfinden wäre schlimmer als keine. + /// - wasCharging: Ob zuletzt geladen wurde. Daran hängt der Abstand: + /// beide Regeln haben zwei Schwellen, eine zum Anhalten und eine zum + /// Weitermachen. + public static func shouldCharge(charge: Double, limit: Int, celsius: Double?, + wasCharging: Bool) -> Bool { + if let celsius { + if wasCharging, celsius >= maximumChargingCelsius { return false } + if !wasCharging, celsius >= resumeChargingCelsius { return false } + } + + let percent = Int((min(max(charge, 0), 1) * 100).rounded()) + // Ein Limit von hundert ist keines — dann gilt nur der Hitzeschutz. + guard limit < ChargeSafety.noLimit else { return true } + + if wasCharging { return percent < limit } + return percent <= limit - sailingHysteresis + } + + /// Warum gerade nicht geladen wird — für die Anzeige. + public enum Reason: Equatable, Sendable { + case charging + case atLimit(Int) + case sailing(Int) + case tooHot(Double) + } + + public static func reason(charge: Double, limit: Int, celsius: Double?, + wasCharging: Bool) -> Reason { + if let celsius { + let threshold = wasCharging ? maximumChargingCelsius : resumeChargingCelsius + if celsius >= threshold { return .tooHot(celsius) } + } + guard shouldCharge(charge: charge, limit: limit, celsius: celsius, + wasCharging: wasCharging) else { + let percent = Int((min(max(charge, 0), 1) * 100).rounded()) + return percent >= limit ? .atLimit(limit) : .sailing(limit) + } + return .charging + } +} diff --git a/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryHistoryTests.swift b/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryHistoryTests.swift new file mode 100644 index 0000000..d058fcc --- /dev/null +++ b/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryHistoryTests.swift @@ -0,0 +1,92 @@ +import Testing +import Foundation +@testable import MetricsProvider + +@Suite("Akkuverlauf") +struct BatteryHistoryTests { + + private func sample(_ seconds: TimeInterval, charge: Double) -> BatterySample { + BatterySample(at: Date(timeIntervalSince1970: seconds), charge: charge, + celsius: nil, watts: nil) + } + + private let now = Date(timeIntervalSince1970: 100 * 86_400) + + @Test("Ohne vorherige Messung wird sofort aufgezeichnet") + func firstSampleIsRecorded() { + #expect(BatteryHistoryRules.shouldRecord(last: nil, now: now)) + } + + @Test("Vor Ablauf des Abstands nicht noch einmal") + func waitsForTheInterval() { + let recent = now.addingTimeInterval(-60) + #expect(!BatteryHistoryRules.shouldRecord(last: recent, now: now)) + } + + @Test("Nach dem Abstand wieder") + func recordsAfterTheInterval() { + let old = now.addingTimeInterval(-BatteryHistoryRules.interval) + #expect(BatteryHistoryRules.shouldRecord(last: old, now: now)) + } + + @Test("Zu alte Messpunkte fallen heraus") + func oldSamplesArePruned() { + let samples = [ + sample(now.timeIntervalSince1970 - 40 * 86_400, charge: 0.5), + sample(now.timeIntervalSince1970 - 1 * 86_400, charge: 0.6), + ] + let kept = BatteryHistoryRules.pruned(samples, now: now) + #expect(kept.count == 1) + #expect(kept.first?.charge == 0.6) + } + + @Test("Die Säulen bekommen den Mittelwert ihres Abschnitts") + func bucketsAverage() { + // Zwei Messungen in derselben halben Stunde ergeben eine Säule mit + // ihrem Mittel — nicht die erste und nicht die letzte. + let base = now.timeIntervalSince1970 - 600 + let samples = [sample(base, charge: 0.4), sample(base + 60, charge: 0.6)] + let buckets = BatteryHistoryRules.buckets(samples, window: .day, now: now) + let filled = buckets.compactMap { $0 } + #expect(filled.count == 1) + #expect(abs(filled[0] - 0.5) < 0.0001) + } + + @Test("Abschnitte ohne Messung bleiben leer") + func gapsStayEmpty() { + // Ein zugeklappter Rechner hat dort keinen Ladestand gehabt. Eine + // durchgezogene Linie darüber wäre erfunden. + let samples = [sample(now.timeIntervalSince1970 - 600, charge: 0.5)] + let buckets = BatteryHistoryRules.buckets(samples, window: .day, now: now) + #expect(buckets.count == HistoryWindow.day.buckets) + #expect(buckets.filter { $0 == nil }.count == HistoryWindow.day.buckets - 1) + } + + @Test("Messpunkte außerhalb des Fensters zählen nicht mit") + func outOfWindowIsIgnored() { + let samples = [sample(now.timeIntervalSince1970 - 10 * 86_400, charge: 0.9)] + #expect(BatteryHistoryRules.buckets(samples, window: .day, now: now).allSatisfy { $0 == nil }) + #expect(BatteryHistoryRules.summary(samples, window: .day, now: now) == nil) + } + + @Test("Die Zusammenfassung nennt Tief, Hoch und Mittel") + func summaryReportsRange() { + let base = now.timeIntervalSince1970 - 3600 + let samples = [sample(base, charge: 0.2), sample(base + 60, charge: 0.8)] + let summary = BatteryHistoryRules.summary(samples, window: .day, now: now) + #expect(summary?.low == 0.2) + #expect(summary?.high == 0.8) + #expect(abs((summary?.average ?? 0) - 0.5) < 0.0001) + } + + @Test("Jedes Fenster hat weniger Säulen als Messpunkte") + func bucketsAreFewerThanSamples() { + // Über einen Monat wären es bei fünf Minuten Abstand über achttausend + // Punkte — mehr Punkte als Bildpunkte zu zeichnen kostet Zeit, ohne + // etwas zu zeigen. + for window in HistoryWindow.allCases { + let possible = Int(window.duration / BatteryHistoryRules.interval) + #expect(window.buckets < possible) + } + } +} diff --git a/Packages/OnyxKit/Tests/OnyxHelperProtocolTests/ChargePolicyTests.swift b/Packages/OnyxKit/Tests/OnyxHelperProtocolTests/ChargePolicyTests.swift new file mode 100644 index 0000000..fed6cf3 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxHelperProtocolTests/ChargePolicyTests.swift @@ -0,0 +1,89 @@ +import Testing +@testable import OnyxHelperProtocol + +@Suite("Segelmodus und Hitzeschutz") +struct ChargePolicyTests { + + // MARK: - Segeln + + @Test("Unter dem Limit wird geladen") + func chargesBelowLimit() { + #expect(ChargePolicy.shouldCharge(charge: 0.5, limit: 80, celsius: nil, + wasCharging: true)) + } + + @Test("Am Limit hört es auf") + func stopsAtLimit() { + #expect(!ChargePolicy.shouldCharge(charge: 0.80, limit: 80, celsius: nil, + wasCharging: true)) + } + + @Test("Nach dem Anhalten wird nicht sofort wieder geladen") + func doesNotResumeImmediately() { + // Der Kern des Segelns: ohne Abstand hinge der Akku am Limit und würde + // bei jedem verlorenen Prozent nachgeladen — hunderte winzige + // Ladevorgänge am Tag, und genau die altern ihn. + #expect(!ChargePolicy.shouldCharge(charge: 0.79, limit: 80, celsius: nil, + wasCharging: false)) + #expect(!ChargePolicy.shouldCharge(charge: 0.76, limit: 80, celsius: nil, + wasCharging: false)) + } + + @Test("Erst unter dem Abstand geht es wieder los") + func resumesBelowHysteresis() { + #expect(ChargePolicy.shouldCharge(charge: 0.75, limit: 80, celsius: nil, + wasCharging: false)) + } + + @Test("Ohne Limit gilt nur der Hitzeschutz") + func noLimitMeansAlwaysCharge() { + #expect(ChargePolicy.shouldCharge(charge: 0.99, limit: 100, celsius: nil, + wasCharging: false)) + } + + // MARK: - Hitze + + @Test("Zu heiß heißt: nicht laden") + func heatStopsCharging() { + #expect(!ChargePolicy.shouldCharge(charge: 0.3, limit: 100, celsius: 41, + wasCharging: true)) + } + + @Test("Nach dem Abkühlen nicht gleich an der Schwelle wieder los") + func heatHasHysteresisToo() { + // Genau an der Schwelle schaltete es sonst im Sekundentakt an und aus. + #expect(!ChargePolicy.shouldCharge(charge: 0.3, limit: 100, celsius: 37, + wasCharging: false)) + #expect(ChargePolicy.shouldCharge(charge: 0.3, limit: 100, celsius: 35, + wasCharging: false)) + } + + @Test("Ohne Temperaturmessung kein Hitzeschutz") + func unknownTemperatureIsNotAnExcuse() { + // Eine Temperatur zu erfinden wäre schlimmer als keine zu haben. + #expect(ChargePolicy.shouldCharge(charge: 0.3, limit: 100, celsius: nil, + wasCharging: true)) + } + + // MARK: - Der Grund, den die Anzeige nennt + + @Test("Der Grund unterscheidet Limit, Segeln und Hitze") + func reasonsAreDistinct() { + #expect(ChargePolicy.reason(charge: 0.5, limit: 80, celsius: nil, + wasCharging: true) == .charging) + #expect(ChargePolicy.reason(charge: 0.80, limit: 80, celsius: nil, + wasCharging: true) == .atLimit(80)) + #expect(ChargePolicy.reason(charge: 0.78, limit: 80, celsius: nil, + wasCharging: false) == .sailing(80)) + #expect(ChargePolicy.reason(charge: 0.3, limit: 100, celsius: 42, + wasCharging: true) == .tooHot(42)) + } + + @Test("Hitze schlägt jeden anderen Grund") + func heatWinsOverLimit() { + // Wer bei 42 Grad „am Limit" liest, sucht den Fehler an der falschen + // Stelle. + #expect(ChargePolicy.reason(charge: 0.95, limit: 80, celsius: 42, + wasCharging: true) == .tooHot(42)) + } +}