From 70a665f5329b7e157df089c3c246161033346310 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 11 Aug 2026 12:46:06 +0200 Subject: [PATCH] =?UTF-8?q?Men=C3=BCleiste:=20Breite=20mitwachsend,=20Symb?= =?UTF-8?q?ole=20unverzerrt,=20Sensor=20w=C3=A4hlbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Netzwerkkachel war auf „↓ 999,9 MB/s" dimensioniert und stand damit neben „↓ 13 KB/s" zur Hälfte leer. Auf den schlimmsten Fall auszulegen ist für einen Wert, der sich um den Faktor tausend ändert, die falsche Antwort. Gemessen wird jetzt, was dasteht — mit unsymmetrischer Hysterese: sofort wachsen, denn abgeschnittene Messwerte sind falsche Messwerte, und erst nach zehn ruhigeren Messungen schrumpfen, damit ein Lastausschlag die Leiste nicht zum Pumpen bringt. Das Akkusymbol war zerquetscht. Ein Batteriesymbol ist doppelt so breit wie hoch und wurde in ein Quadrat gezeichnet. Symbole behalten jetzt ihr Seitenverhältnis, und die Breite des Elements richtet sich nach dem, was das Symbol tatsächlich braucht — sonst hätte ein breites Glyph dieselbe Spalte wie ein schmales. Bei den Sensoren stand stumm das Maximum über alle Punkte: eine Zahl, von der niemand weiß, woher sie kommt. Jetzt wählbar, und der gewählte Sensor gibt sein eigenes Kürzel — „Temp 46°" sagt weniger als „Heatpipe 46°". Voreinstellung bleibt der wärmste Punkt, weil das die häufigste Frage ist. Und die Lüfterkachel zeigte „2500" ohne Einheit. Co-Authored-By: Claude Opus 5 --- Onyx/FanWidget.swift | 2 +- Onyx/Localizable.xcstrings | 52 + Onyx/SettingsView.swift | 17 + .../MetricsProvider/Localizable.xcstrings | 1276 +++++++++-------- .../MetricsProvider/MetricMenuBarModule.swift | 42 +- .../MetricsProvider/MetricsModel.swift | 31 + .../NetworkMenuBarModule.swift | 68 +- .../Sources/OnyxMenuBar/MenuBarText.swift | 79 +- .../OnyxMenuBarTests/AdaptiveWidthTests.swift | 73 + 9 files changed, 969 insertions(+), 671 deletions(-) create mode 100644 Packages/OnyxKit/Tests/OnyxMenuBarTests/AdaptiveWidthTests.swift 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..