diff --git a/Onyx/FanWidget.swift b/Onyx/FanWidget.swift index 94599a5..fd3a4fe 100644 --- a/Onyx/FanWidget.swift +++ b/Onyx/FanWidget.swift @@ -93,7 +93,7 @@ private struct FanRow: View { .foregroundStyle(Onyx.Color.textSecondary) .lineLimit(1) Spacer(minLength: 4) - Text("\(Int(fan.currentRPM)) U/min") + Text("fans.rpm \(Int(fan.currentRPM))") .font(Onyx.Font.metricSmall).monospacedDigit() .foregroundStyle(Onyx.Color.textPrimary) } diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings index 4c4274e..9c29b0a 100644 --- a/Onyx/Localizable.xcstrings +++ b/Onyx/Localizable.xcstrings @@ -345,6 +345,7 @@ } }, "fans.menubar.short": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -544,6 +545,22 @@ } } }, + "fans.rpm %lld": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld U/min" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld rpm" + } + } + } + }, "fans.safety": { "localizations": { "de": { @@ -796,6 +813,7 @@ } }, "mixer.menubar.short": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1294,6 +1312,7 @@ } }, "settings.menubar.labelAndValue": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -1309,6 +1328,38 @@ } } }, + "settings.menubar.sensor": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sensor" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sensor" + } + } + } + }, + "settings.menubar.sensor.hottest": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wärmster Punkt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Hottest point" + } + } + } + }, "settings.menubar.symbol": { "extractionState": "stale", "localizations": { @@ -1327,6 +1378,7 @@ } }, "settings.menubar.symbolAndValue": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { diff --git a/Onyx/SettingsView.swift b/Onyx/SettingsView.swift index 8ab6d23..5f58a0f 100644 --- a/Onyx/SettingsView.swift +++ b/Onyx/SettingsView.swift @@ -289,6 +289,23 @@ private struct ModuleRow: View { Spacer() + // Bei den Sensoren: welcher Punkt. Ohne diese Wahl stand dort das + // stille Maximum über alle Sensoren — eine Zahl, von der niemand + // weiß, woher sie kommt. + if id == "metric.temperature", let metricsModel { + Picker("", selection: Binding( + get: { metricsModel.selectedSensorKey ?? "" }, + set: { metricsModel.selectedSensorKey = $0.isEmpty ? nil : $0 })) { + Text("settings.menubar.sensor.hottest").tag("") + ForEach(metricsModel.snapshot.sensors) { sensor in + Text(sensor.name).tag(sensor.key) + } + } + .labelsHidden() + .frame(width: 150) + .disabled(!settings.isEnabled) + } + // Bei CPU und GPU zusätzlich wählbar, was gezeigt wird. Beide Werte // stammen aus derselben Messung — ein zweites Element in der // ohnehin knappen Menüleiste wäre Verschwendung. diff --git a/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings index c9e42e1..9a20224 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/MetricsProvider/Localizable.xcstrings @@ -1,1009 +1,1015 @@ { - "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" } } } }, - "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" + "battery.charging" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lädt" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Fan %lld" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charging" } } } }, - "battery.charging": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Lädt" + "battery.cycles" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zyklen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Charging" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cycles" } } } }, - "battery.cycles": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Zyklen" + "battery.draining" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbrauch" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Cycles" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Draw" } } } }, - "battery.draining": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Verbrauch" + "battery.health" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zustand" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Draw" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Health" } } } }, - "battery.health": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Zustand" + "battery.lowPower" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Energiesparmodus" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Health" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Low Power Mode" } } } }, - "battery.lowPower": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Energiesparmodus" + "battery.pluggedIn" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Am Netz" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Low Power Mode" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plugged in" } } } }, - "battery.pluggedIn": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Am Netz" + "battery.remaining %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "noch %@" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Plugged in" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ remaining" } } } }, - "battery.remaining %@": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "noch %@" + "battery.untilFull %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ bis voll" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ remaining" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ until full" } } } }, - "battery.untilFull %@": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ bis voll" + "charge.limit" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ladelimit" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ until full" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charge limit" } } } }, - "charge.limit": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Ladelimit" + "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": "Charge limit" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "80 to 100 % — the range macOS itself offers." } } } }, - "charge.limit.hint": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "80 bis 100 % — der Bereich, den macOS selbst anbietet." + "charge.limit.inSystemSettings" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In den Systemeinstellungen einstellbar" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "80 to 100 % — the range macOS itself offers." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set in System Settings" } } } }, - "charge.limit.inSystemSettings": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "In den Systemeinstellungen einstellbar" + "charge.limit.needsHelper" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Braucht den privilegierten Dienst aus Einstellungen → Lüfter." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Set in System Settings" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Needs the privileged service from Settings → Fans." } } } }, - "charge.limit.needsHelper": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Braucht den privilegierten Dienst aus Einstellungen → Lüfter." + "charge.limit.off" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aus" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Needs the privileged service from Settings → Fans." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Off" } } } }, - "charge.limit.off": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Aus" + "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": "Off" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fan %lld" } } } }, - "menubar.short.battery": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Akku" + "menubar.short.battery" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akku" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Batt" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batt" } } } }, - "menubar.short.temperature": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Temp" + "menubar.short.temperature" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temp" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Temp" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temp" } } } }, - "metric.battery": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Akku" + "metric.battery" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akku" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Battery" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Battery" } } } }, - "metric.battery.charging": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Lädt" + "metric.battery.charging" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lädt" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Charging" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charging" } } } }, - "metric.cpu": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "CPU" + "metric.cpu" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "CPU" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU" } } } }, - "metric.cpu.perCore": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Je Kern" + "metric.cpu.perCore" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je Kern" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Per core" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Per core" } } } }, - "metric.fan %lld": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Lüfter %lld" + "metric.fan %lld" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lüfter %lld" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Fan %lld" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fan %lld" } } } }, - "metric.fan.controlComing": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Die Lüftersteuerung kommt mit dem privilegierten Helfer — Schreibzugriff auf den SMC verlangt root." + "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": "Fan control arrives with the privileged helper — writing to the SMC requires root." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fan control arrives with the privileged helper — writing to the SMC requires root." } } } }, - "metric.gpu": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "GPU" + "metric.gpu" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "GPU" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU" } } } }, - "metric.memory": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Speicher" + "metric.memory" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speicher" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Memory" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memory" } } } }, - "metric.mode.temperature": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Temperatur" + "metric.mode.temperature" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temperatur" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Temperature" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temperature" } } } }, - "metric.mode.usage": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Auslastung" + "metric.mode.usage" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auslastung" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Usage" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usage" } } } }, - "metric.rpm": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "U/min" + "metric.rpm" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "U/min" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "rpm" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "rpm" } } } }, - "metric.sensors": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Sensoren" + "metric.sensors" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensoren" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Sensors" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensors" } } } }, - "metric.sensors.none": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Keine Sensoren lesbar" + "metric.sensors.none" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Sensoren lesbar" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "No sensors readable" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No sensors readable" } } } }, - "metric.show": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Anzeigen" + "metric.show" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anzeigen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Show" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show" } } } }, - "popover.battery.calculating": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Wird berechnet …" + "popover.battery.calculating" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wird berechnet …" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Calculating…" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calculating…" } } } }, - "popover.battery.charging": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Lädt mit" + "popover.battery.charging" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lädt mit" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Charging at" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charging at" } } } }, - "popover.battery.condition": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Zustand" + "popover.battery.condition" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zustand" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Condition" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Condition" } } } }, - "popover.battery.cycles": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Ladezyklen" + "popover.battery.cycles" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ladezyklen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Cycles" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cycles" } } } }, - "popover.battery.draining": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Verbrauch" + "popover.battery.draining" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbrauch" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Draw" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Draw" } } } }, - "popover.battery.health": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Kapazität" + "popover.battery.health" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kapazität" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Capacity" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capacity" } } } }, - "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.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": "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" : "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.none": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Kein Akku gefunden" + "popover.battery.none" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Akku gefunden" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "No battery found" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No battery found" } } } }, - "popover.battery.pluggedIn": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Am Netzteil" + "popover.battery.pluggedIn" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Am Netzteil" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "On adapter" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On adapter" } } } }, - "popover.battery.remaining": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "verbleibend" + "popover.battery.remaining" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "verbleibend" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "remaining" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "remaining" } } } }, - "popover.battery.untilFull": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "bis voll" + "popover.battery.untilFull" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "bis voll" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "until full" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "until full" } } } }, - "popover.battery.voltage": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Spannung" + "popover.battery.voltage" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spannung" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Voltage" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voltage" } } } }, - "popover.fans": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Lüfter" + "popover.fans" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lüfter" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Fans" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fans" } } } }, - "popover.memory.app": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Programme" + "popover.memory.app" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programme" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Apps" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apps" } } } }, - "popover.memory.compressed": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Komprimiert" + "popover.memory.compressed" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Komprimiert" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Compressed" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compressed" } } } }, - "popover.memory.free": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Frei" + "popover.memory.free" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frei" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Free" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Free" } } } }, - "popover.memory.swap": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Auslagerung" + "popover.memory.swap" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auslagerung" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Swap" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Swap" } } } }, - "popover.memory.swapUsed": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Benutzt" + "popover.memory.swapUsed" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Benutzt" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Used" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Used" } } } }, - "popover.memory.used": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Belegt" + "popover.memory.used" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Belegt" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Used" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Used" } } } }, - "popover.memory.wired": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Reserviert" + "popover.memory.wired" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reserviert" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Wired" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wired" } } } }, - "popover.perCore": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Je Kern" + "popover.perCore" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je Kern" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Per core" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Per core" } } } }, - "popover.power": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Leistung" + "popover.power" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leistung" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Power" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Power" } } } }, - "popover.power.adapter": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Netzteil" + "popover.power.adapter" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Netzteil" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Adapter" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adapter" } } } }, - "popover.power.cpu": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "CPU" + "popover.power.cpu" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "CPU" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU" } } } }, - "popover.power.display": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Anzeige" + "popover.power.display" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anzeige" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Display" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Display" } } } }, - "popover.power.total": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Gesamt" + "popover.power.total" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesamt" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Total" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total" } } } }, - "popover.processes": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Größte Verbraucher" + "popover.processes" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Größte Verbraucher" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Top consumers" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Top consumers" } } } }, - "popover.temperature": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Temperatur" + "popover.temperature" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temperatur" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Temperature" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temperature" } } } }, - "settings.menubar.labelAndValue": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Kürzel + Wert" + "settings.menubar.labelAndValue" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kürzel + Wert" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Label + value" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label + value" } } } }, - "settings.menubar.symbolAndValue": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Symbol + Wert" + "settings.menubar.symbolAndValue" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Symbol + Wert" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Symbol + value" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Symbol + value" } } } } }, - "version": "1.1" -} + "version" : "1.1" +} \ No newline at end of file diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift index dbf17b5..08b5c8b 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift @@ -35,13 +35,18 @@ public final class MetricMenuBarModule: MenuBarModule { case .gpu: "GPU" case .memory: "RAM" case .battery: String(localized: "menubar.short.battery", bundle: .module) - case .temperature: String(localized: "menubar.short.temperature", bundle: .module) + // Der gewählte Sensor gibt sein eigenes Kürzel — „Temp" neben einer + // Zahl sagt nicht, welcher Punkt gemeint ist. + case .temperature: model.selectedSensorName + ?? String(localized: "menubar.short.temperature", bundle: .module) } } public func makeStatusView(presentation: MenuBarPresentation) -> NSView { let view = MetricStatusView(metric: metric, presentation: presentation, label: shortLabel) + view.sensorCelsius = model.selectedSensorCelsius + view.sensorCelsius = model.selectedSensorCelsius view.update(snapshot: model.snapshot, history: model.series(metric), mode: model.displayMode(for: metric)) self.view = view @@ -117,13 +122,17 @@ final class MetricStatusView: NSView, WidthReporting { /// jedes Prozent alle Symbole rechts davon hin und her. private var width: CGFloat { switch presentation { - case .symbol: MenuBarText.minimumWidth + case .symbol: max(MenuBarText.minimumWidth, + MenuBarText.symbolWidth(metric.symbolName, height: 15) + 4) case .graph: 28 case .bars: barsWidth case .value: MenuBarText.width(for: reference) case .valueAndGraph: MenuBarText.width(for: reference) + 2 + 28 case .labelAndValue: MenuBarText.width(for: "\(label) \(reference)") - case .symbolAndValue: MenuBarText.width(for: reference, includesSymbol: true) + case .symbolAndValue: + MenuBarText.width(for: reference) + + MenuBarText.symbolWidth(metric.symbolName, height: 13) + + MenuBarText.innerGap } } @@ -135,6 +144,15 @@ final class MetricStatusView: NSView, WidthReporting { return min(CGFloat(count) * 2 + CGFloat(count - 1) * 1 + 4, 60) } + /// Was tatsächlich dasteht. Bei den Sensoren der gewählte Punkt statt des + /// stillen Maximums über alle. + private var displayValue: String { + if metric == .temperature, let celsius = sensorCelsius { + return MetricFormat.temperature(celsius) + } + return MetricSummary.value(metric, snapshot, mode: mode) + } + /// Die breiteste Zeichenfolge dieser Metrik. private var reference: String { switch (metric, mode) { @@ -147,6 +165,9 @@ final class MetricStatusView: NSView, WidthReporting { /// das Element danach neu ein. var onWidthChange: ((CGFloat) -> Void)? + /// Der gewählte Sensor, falls einer gewählt ist. + var sensorCelsius: Double? + func update(snapshot: MetricsSnapshot, history: [Double], mode: MetricDisplayMode) { let before = width self.snapshot = snapshot @@ -168,7 +189,7 @@ final class MetricStatusView: NSView, WidthReporting { switch presentation { case .value: - drawText(MetricSummary.value(metric, snapshot, mode: mode), color: color) + drawText(displayValue, color: color) case .symbol: drawSymbol(color: color) case .graph: @@ -176,18 +197,21 @@ final class MetricStatusView: NSView, WidthReporting { case .bars: drawBars(color: color) case .labelAndValue: - drawText("\(label) \(MetricSummary.value(metric, snapshot, mode: mode))", color: color) + drawText("\(label) \(displayValue)", color: color) case .symbolAndValue: + let glyph = MenuBarText.symbolWidth(metric.symbolName, height: 13) MenuBarText.drawSymbol(metric.symbolName, color: color, in: NSRect(x: 0, y: bounds.midY - 6.5, - width: 13, height: 13)) - drawText(MetricSummary.value(metric, snapshot, mode: mode), color: color, - in: NSRect(x: 15, y: 0, width: bounds.width - 15, height: bounds.height)) + width: glyph, height: 13)) + drawText(displayValue, color: color, + in: NSRect(x: glyph + MenuBarText.innerGap, y: 0, + width: bounds.width - glyph - MenuBarText.innerGap, + height: bounds.height)) case .valueAndGraph: let split = bounds.width * 0.55 - drawText(MetricSummary.value(metric, snapshot, mode: mode), color: color, + drawText(displayValue, color: color, in: NSRect(x: 0, y: 0, width: split, height: bounds.height)) drawGraph(color: color, in: NSRect(x: split + 2, y: 5, diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift index b446000..d45e26e 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricsModel.swift @@ -18,6 +18,36 @@ public final class MetricsModel { /// `nil` heißt: kein Regler, weil es nichts zu regeln gibt. public weak var chargeControl: (any ChargeLimitReading)? + /// Welcher Sensor in der Menüleiste steht. `nil` = der wärmste. + /// + /// Ohne diese Wahl zeigte das Modul stumm das Maximum über alle Sensoren — + /// eine Zahl, von der niemand weiß, woher sie kommt. „Der wärmste Punkt" + /// bleibt die Voreinstellung, weil das die Frage ist, die man meistens + /// hat; aber wer den Akku oder die GPU im Blick behalten will, soll das + /// sagen können. + public var selectedSensorKey: String? { + didSet { + guard selectedSensorKey != oldValue else { return } + defaults.set(selectedSensorKey, forKey: "onyx.metric.sensorKey") + } + } + + /// Der Wert des gewählten Sensors — oder der wärmste, wenn keiner gewählt + /// ist oder der gewählte gerade nicht meldet. + public var selectedSensorCelsius: Double? { + if let key = selectedSensorKey, + let sensor = snapshot.sensors.first(where: { $0.key == key }) { + return sensor.celsius + } + return snapshot.sensors.map(\.celsius).max() + } + + /// Wie der gewählte Sensor heißt — fürs Kürzel in der Menüleiste. + public var selectedSensorName: String? { + guard let key = selectedSensorKey else { return nil } + return snapshot.sensors.first(where: { $0.key == key })?.name + } + public private(set) var snapshot = MetricsSnapshot() /// Verlauf für die Graphen, jüngster Wert zuletzt. public private(set) var history: [MetricsSnapshot] = [] @@ -37,6 +67,7 @@ public final class MetricsModel { private var demands: [UUID: TimeInterval] = [:] public init(defaults: UserDefaults = .standard) { + self.selectedSensorKey = defaults.string(forKey: "onyx.metric.sensorKey") self.defaults = defaults for metric in MetricKind.allCases where metric.supportsDisplayModes { let stored = defaults.string(forKey: Self.key(metric)) diff --git a/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift index ffae423..54efee7 100644 --- a/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift +++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift @@ -59,7 +59,7 @@ public final class NetworkMenuBarModule: MenuBarModule { } } -final class NetworkStatusView: NSView { +final class NetworkStatusView: NSView, WidthReporting { private let presentation: MenuBarPresentation private var snapshot = NetworkSnapshot() @@ -67,7 +67,7 @@ final class NetworkStatusView: NSView { init(presentation: MenuBarPresentation) { self.presentation = presentation - super.init(frame: NSRect(x: 0, y: 0, width: Self.width(presentation), height: 22)) + super.init(frame: NSRect(x: 0, y: 0, width: MenuBarText.minimumWidth, height: 22)) } @available(*, unavailable) @@ -75,38 +75,60 @@ final class NetworkStatusView: NSView { /// Breiter als die Hardwaremodule: „↓ 2,4 MB/s" braucht schlicht mehr Platz /// als „46 %". - /// Gemessen an der breitesten Zeichenfolge, die vorkommen kann. + /// Der Platz für die **aktuellen** Raten. /// - /// „↓ 999,9 MB/s" ist der Extremfall — meistens steht dort „↓ 12 KB/s". - /// Vorher waren 76 Punkte fest verdrahtet, und daneben blieb dauerhaft - /// Platz für ein weiteres Symbol frei, in dem nichts stand. - static func width(_ presentation: MenuBarPresentation) -> CGFloat { + /// Auf „↓ 999,9 MB/s" zu dimensionieren hieße, neben „↓ 13 KB/s" dauerhaft + /// die halbe Breite leer zu lassen — und genau so sah es aus. Gemessen wird + /// deshalb, was gerade dasteht; `AdaptiveWidth` sorgt dafür, dass die + /// Leiste dabei nicht zappelt. + private static func ratesWidth(_ snapshot: NetworkSnapshot) -> CGFloat { + let font = NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular) + let lines = ["↓ " + Throughput.formatted(snapshot.downloadRate), + "↑ " + Throughput.formatted(snapshot.uploadRate)] + let widest = lines + .map { NSAttributedString(string: $0, attributes: [.font: font]).size().width } + .max() ?? 0 + return ceil(widest) + 2 * MenuBarText.sidePadding + } + + private func neededWidth() -> CGFloat { switch presentation { - case .symbol: MenuBarText.minimumWidth + case .symbol: + max(MenuBarText.minimumWidth, + MenuBarText.symbolWidth(snapshot.interfaceKind.symbolName, height: 15) + 4) case .graph, .bars: 28 - case .value: ratesWidth - case .valueAndGraph: ratesWidth + 26 - // Die Raten tragen mit ↓ und ↑ ihre Beschriftung schon in sich. - case .labelAndValue, .symbolAndValue: ratesWidth + MenuBarText.symbolWidth + 2 + case .value: Self.ratesWidth(snapshot) + case .valueAndGraph: Self.ratesWidth(snapshot) + 26 + // Die Raten tragen mit ↓ und ↑ ihre Beschriftung schon in sich; ein + // Kürzel davor wäre doppelt gemoppelt, ein Symbol dagegen nützlich — + // es zeigt WLAN, Ethernet oder VPN. + case .labelAndValue, .symbolAndValue: + Self.ratesWidth(snapshot) + symbolSlot + MenuBarText.innerGap } } - /// Die Raten stehen zweizeilig in 9 pt — dafür misst `MenuBarText` mit - /// seiner 11-pt-Schrift zu breit, also hier eigens gemessen. - private static var ratesWidth: CGFloat { - let font = NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular) - let widest = NSAttributedString(string: "↓ 999,9 MB/s", attributes: [.font: font]) - return ceil(widest.size().width) + 2 * MenuBarText.sidePadding + private var symbolSlot: CGFloat { + MenuBarText.symbolWidth(snapshot.interfaceKind.symbolName, height: 13) } + private var adaptive = AdaptiveWidth(minimum: MenuBarText.minimumWidth) + private var width: CGFloat = MenuBarText.minimumWidth + var onWidthChange: ((CGFloat) -> Void)? + override var intrinsicContentSize: NSSize { - NSSize(width: Self.width(presentation), height: 22) + NSSize(width: width, height: 22) } func update(_ snapshot: NetworkSnapshot, history: [Double]) { self.snapshot = snapshot self.history = history needsDisplay = true + + let next = adaptive.update(needed: neededWidth()) + guard next != width else { return } + width = next + invalidateIntrinsicContentSize() + onWidthChange?(next) } override func draw(_ dirtyRect: NSRect) { @@ -122,10 +144,14 @@ final class NetworkStatusView: NSView { drawGraph(color: color, in: bounds.insetBy(dx: 2, dy: 5)) case .labelAndValue, .symbolAndValue: + let glyph = symbolSlot MenuBarText.drawSymbol(snapshot.interfaceKind.symbolName, color: color, - in: NSRect(x: 0, y: bounds.midY - 6.5, width: 13, height: 13)) + in: NSRect(x: 0, y: bounds.midY - 6.5, + width: glyph, height: 13)) drawRates(color: color, - in: NSRect(x: 15, y: 0, width: bounds.width - 15, height: bounds.height)) + in: NSRect(x: glyph + MenuBarText.innerGap, y: 0, + width: bounds.width - glyph - MenuBarText.innerGap, + height: bounds.height)) case .value, .valueAndGraph: drawRates(color: color, diff --git a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift index 78a61e5..b8e41c1 100644 --- a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift +++ b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift @@ -56,13 +56,38 @@ public enum MenuBarText { /// in einer selbst gezeichneten Ansicht muss die Farbe in die /// Symbolkonfiguration. public static func drawSymbol(_ name: String, color: NSColor, in rect: NSRect) { + guard let tinted = symbol(name, color: color, height: rect.height) else { return } + // Seitenverhältnis wahren. Ein Batteriesymbol ist doppelt so breit wie + // hoch; in ein Quadrat gezeichnet sieht es zerquetscht aus. + let size = fittedSize(of: tinted, height: rect.height) + tinted.draw(in: NSRect(x: rect.midX - size.width / 2, + y: rect.midY - size.height / 2, + width: size.width, height: size.height)) + } + + /// Wie breit ein Symbol bei dieser Höhe tatsächlich ist. + /// + /// Ohne diese Messung bekommt ein breites Symbol denselben Platz wie ein + /// schmales — entweder gequetscht oder mit einer Lücke daneben. + public static func symbolWidth(_ name: String, height: CGFloat) -> CGFloat { + guard let image = symbol(name, color: .labelColor, height: height) else { + return height + } + return fittedSize(of: image, height: height).width + } + + private static func symbol(_ name: String, color: NSColor, height: CGFloat) -> NSImage? { guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil) - else { return } - let configuration = NSImage.SymbolConfiguration(pointSize: rect.height, - weight: .regular) + else { return nil } + let configuration = NSImage.SymbolConfiguration(pointSize: height, weight: .regular) .applying(NSImage.SymbolConfiguration(paletteColors: [color])) - let tinted = image.withSymbolConfiguration(configuration) ?? image - tinted.draw(in: rect) + return image.withSymbolConfiguration(configuration) ?? image + } + + private static func fittedSize(of image: NSImage, height: CGFloat) -> NSSize { + let natural = image.size + guard natural.height > 0 else { return NSSize(width: height, height: height) } + return NSSize(width: (natural.width / natural.height) * height, height: height) } public static func draw(_ text: String, color: NSColor, in rect: NSRect) { @@ -83,3 +108,47 @@ public enum MenuBarText { public protocol WidthReporting: AnyObject { var onWidthChange: ((CGFloat) -> Void)? { get set } } + +/// Eine Breite, die mitwächst — aber nur ungern schrumpft. +/// +/// Eine feste Breite für den schlimmsten Fall lässt neben jedem gewöhnlichen +/// Wert Platz stehen: „↓ 13 KB/s" braucht gut halb so viel wie „↓ 999,9 MB/s". +/// Eine Breite, die jedem Messwert folgt, schiebt dagegen bei jeder Messung +/// alle Symbole rechts davon hin und her. +/// +/// Der Kompromiss ist unsymmetrisch, und das mit Absicht: **sofort wachsen**, +/// denn abgeschnittene Messwerte sind falsche Messwerte. **Zögernd +/// schrumpfen**, damit ein kurzer Lastausschlag die Leiste nicht zum Pumpen +/// bringt. +public struct AdaptiveWidth: Sendable { + + /// Wie viele ruhigere Messungen es braucht, bevor Platz abgegeben wird. + /// Bei zwei Sekunden Abtastung sind das gut zwanzig Sekunden Geduld. + public static let shrinkAfter = 10 + + public let minimum: CGFloat + private var current: CGFloat = 0 + private var calmSamples = 0 + + public init(minimum: CGFloat) { + self.minimum = minimum + } + + public mutating func update(needed: CGFloat) -> CGFloat { + let wanted = max(needed, minimum) + + if wanted > current { + current = wanted + calmSamples = 0 + } else if wanted < current { + calmSamples += 1 + if calmSamples >= Self.shrinkAfter { + current = wanted + calmSamples = 0 + } + } else { + calmSamples = 0 + } + return current + } +} diff --git a/Packages/OnyxKit/Tests/OnyxMenuBarTests/AdaptiveWidthTests.swift b/Packages/OnyxKit/Tests/OnyxMenuBarTests/AdaptiveWidthTests.swift new file mode 100644 index 0000000..f137d97 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxMenuBarTests/AdaptiveWidthTests.swift @@ -0,0 +1,73 @@ +import Testing +import CoreGraphics +@testable import OnyxMenuBar + +// Eine feste Breite für den schlimmsten Fall lässt neben jedem gewöhnlichen +// Wert Platz stehen: „↓ 13 KB/s" braucht gut halb so viel wie „↓ 999,9 MB/s". +// Eine Breite, die jedem Wert folgt, schiebt dagegen bei jeder Messung alle +// Symbole rechts davon hin und her. +// +// Also: sofort wachsen, aber erst nach einer Weile schrumpfen. + +@Suite("Mitwachsende Breite") +struct AdaptiveWidthTests { + + @Test("Am Anfang gilt, was gebraucht wird") + func startsAtNeeded() { + var width = AdaptiveWidth(minimum: 20) + #expect(width.update(needed: 60) == 60) + } + + @Test("Mehr Platz wird sofort genommen") + func growsImmediately() { + // Sonst wird abgeschnitten, und abgeschnittene Messwerte sind falsche + // Messwerte. + var width = AdaptiveWidth(minimum: 20) + _ = width.update(needed: 40) + #expect(width.update(needed: 80) == 80) + } + + @Test("Weniger Platz wird nicht sofort abgegeben") + func shrinksOnlyAfterPatience() { + var width = AdaptiveWidth(minimum: 20) + _ = width.update(needed: 80) + for _ in 0..<(AdaptiveWidth.shrinkAfter - 1) { + #expect(width.update(needed: 40) == 80) + } + } + + @Test("Nach genügend ruhigen Messungen schrumpft es") + func shrinksEventually() { + var width = AdaptiveWidth(minimum: 20) + _ = width.update(needed: 80) + var last: CGFloat = 0 + for _ in 0..