Akkusymbol folgt dem Ladestand
Ein Symbol, das bei 5 % genauso aussieht wie bei 95 %, ist Dekoration und keine Anzeige. Wer in der Menüleiste nur das Glyph stehen hat, will es ohne die Zahl daneben lesen können. Fünf Füllstufen, gerundet zur nächsten: 60 % soll nicht halbleer aussehen und 40 % nicht dreiviertel voll. Beim Bauen fiel auf, dass die naheliegende Lösung nicht geht. Die erste Fassung hängte beim Laden ein „.bolt" an den Füllstand — von diesen kombinierten Symbolen existiert aber allein `battery.100percent.bolt`, und `battery.slash` gibt es auch nicht. Die übrigen wären leere Felder in der Menüleiste gewesen. Nachgemessen statt angenommen, und ein Test prüft jetzt für jeden erzeugten Namen, dass es ihn wirklich gibt. Den Füllstand beim Laden gegen einen Blitz einzutauschen wäre die falsche Wahl gewesen: dass geladen wird, sieht man am Netzstecker; wie weit es ist, sieht man nirgends sonst. Also bleibt der Füllstand, und der Blitz kommt als kleines Zeichen darüber. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,24 +19,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ U/min": {
|
||||
"comment": "A label that shows the current speed of a fan. The argument is the current speed of the fan.",
|
||||
"isCommentAutoGenerated": true,
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%@ U/min"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%@ U/min"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ °C" : {
|
||||
"comment" : "A label showing the temperature of the hottest fan in the report, in degrees Celsius. The argument is the temperature in degrees Celsius.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@@ -55,6 +37,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ U/min" : {
|
||||
"comment" : "A label that shows the current speed of a fan. The argument is the current speed of the fan.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%@ U/min"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%@ U/min"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@°" : {
|
||||
"comment" : "A temperature in °C.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@@ -545,7 +545,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fans.rpm %@" : {
|
||||
"comment" : "A label displaying the current RPM of a fan. The argument is the current RPM.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"fans.rpm %lld" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -1329,6 +1334,7 @@
|
||||
}
|
||||
},
|
||||
"settings.menubar.sensor" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
|
||||
35
Packages/OnyxKit/Sources/MetricsProvider/BatterySymbol.swift
Normal file
35
Packages/OnyxKit/Sources/MetricsProvider/BatterySymbol.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
/// Das Akkusymbol zum jeweiligen Zustand.
|
||||
///
|
||||
/// Ein Symbol, das bei 5 % genauso aussieht wie bei 95 %, ist Dekoration und
|
||||
/// keine Anzeige — es beantwortet die eine Frage nicht, für die es da ist. Wer
|
||||
/// in der Menüleiste nur das Symbol stehen hat, will es auf einen Blick lesen
|
||||
/// können, ohne die Zahl daneben.
|
||||
public enum BatterySymbol {
|
||||
|
||||
/// Kein Akku da — oder keiner zu lesen.
|
||||
public static let unavailable = "batteryblock.slash"
|
||||
|
||||
/// Der Blitz beim Laden — **getrennt** vom Füllstand.
|
||||
///
|
||||
/// Nachgemessen, nicht angenommen: von den kombinierten Symbolen existiert
|
||||
/// allein `battery.100percent.bolt`. Für 0, 25, 50 und 75 Prozent gibt es
|
||||
/// keine Blitz-Variante, und `battery.slash` gibt es auch nicht.
|
||||
///
|
||||
/// Den Füllstand beim Laden gegen einen Blitz einzutauschen wäre die
|
||||
/// falsche Wahl: dass geladen wird, sieht man am Netzstecker; **wie weit**
|
||||
/// es ist, sieht man nirgends sonst. Also bleibt der Füllstand, und der
|
||||
/// Blitz kommt als kleines Zeichen darüber.
|
||||
public static let chargingBolt = "bolt.fill"
|
||||
|
||||
/// SF Symbols kennt fünf Füllstufen. Gerundet wird zur nächsten: 60 % soll
|
||||
/// nicht halbleer aussehen und 40 % nicht dreiviertel voll.
|
||||
private static let steps = [0, 25, 50, 75, 100]
|
||||
|
||||
public static func name(charge: Double) -> String {
|
||||
let percent = Int((min(max(charge, 0), 1) * 100).rounded())
|
||||
let step = steps.min { abs($0 - percent) < abs($1 - percent) } ?? 0
|
||||
return "battery.\(step)percent"
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ final class MetricStatusView: NSView, WidthReporting {
|
||||
private var width: CGFloat {
|
||||
switch presentation {
|
||||
case .symbol: max(MenuBarText.minimumWidth,
|
||||
MenuBarText.symbolWidth(metric.symbolName, height: 15) + 4)
|
||||
MenuBarText.symbolWidth(currentSymbol, height: 15) + 4)
|
||||
case .graph: 28
|
||||
case .bars: barsWidth
|
||||
case .value: MenuBarText.width(for: reference)
|
||||
@@ -131,7 +131,7 @@ final class MetricStatusView: NSView, WidthReporting {
|
||||
case .labelAndValue: MenuBarText.width(for: "\(label) \(reference)")
|
||||
case .symbolAndValue:
|
||||
MenuBarText.width(for: reference)
|
||||
+ MenuBarText.symbolWidth(metric.symbolName, height: 13)
|
||||
+ MenuBarText.symbolWidth(currentSymbol, height: 13)
|
||||
+ MenuBarText.innerGap
|
||||
}
|
||||
}
|
||||
@@ -200,10 +200,10 @@ final class MetricStatusView: NSView, WidthReporting {
|
||||
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: glyph, height: 13))
|
||||
let glyph = MenuBarText.symbolWidth(currentSymbol, height: 13)
|
||||
let symbolRect = NSRect(x: 0, y: bounds.midY - 6.5, width: glyph, height: 13)
|
||||
MenuBarText.drawSymbol(currentSymbol, color: color, in: symbolRect)
|
||||
drawChargingBolt(color: color, over: symbolRect)
|
||||
drawText(displayValue, color: color,
|
||||
in: NSRect(x: glyph + MenuBarText.innerGap, y: 0,
|
||||
width: bounds.width - glyph - MenuBarText.innerGap,
|
||||
@@ -225,9 +225,33 @@ final class MetricStatusView: NSView, WidthReporting {
|
||||
|
||||
private func drawSymbol(color: NSColor) {
|
||||
let side: CGFloat = 15
|
||||
MenuBarText.drawSymbol(metric.symbolName, color: color,
|
||||
in: NSRect(x: bounds.midX - side / 2,
|
||||
y: bounds.midY - side / 2,
|
||||
let width = MenuBarText.symbolWidth(currentSymbol, height: side)
|
||||
let rect = NSRect(x: bounds.midX - width / 2, y: bounds.midY - side / 2,
|
||||
width: width, height: side)
|
||||
MenuBarText.drawSymbol(currentSymbol, color: color, in: rect)
|
||||
drawChargingBolt(color: color, over: rect)
|
||||
}
|
||||
|
||||
/// Das Symbol zum **Zustand**, nicht zur Metrik.
|
||||
///
|
||||
/// Beim Akku ändert es sich mit dem Ladestand und zeigt beim Laden einen
|
||||
/// Blitz — ein Symbol, das bei 5 % genauso aussieht wie bei 95 %, ist
|
||||
/// Dekoration. Die übrigen Messgrößen haben nichts, was sich sinnvoll im
|
||||
/// Glyph abbilden ließe.
|
||||
private var currentSymbol: String {
|
||||
guard metric == .battery else { return metric.symbolName }
|
||||
guard let battery = snapshot.battery else { return BatterySymbol.unavailable }
|
||||
return BatterySymbol.name(charge: battery.charge)
|
||||
}
|
||||
|
||||
/// Beim Laden ein kleiner Blitz über dem Füllstand. Zwei Zeichnungen statt
|
||||
/// eines Symbols, weil es die kombinierten Glyphen nur bei 100 % gibt.
|
||||
private func drawChargingBolt(color: NSColor, over rect: NSRect) {
|
||||
guard metric == .battery, snapshot.battery?.isCharging == true else { return }
|
||||
let side: CGFloat = 8
|
||||
MenuBarText.drawSymbol(BatterySymbol.chargingBolt, color: color,
|
||||
in: NSRect(x: rect.maxX - side * 0.75,
|
||||
y: rect.maxY - side * 0.7,
|
||||
width: side, height: side))
|
||||
}
|
||||
|
||||
|
||||
@@ -336,15 +336,24 @@ private struct BatteryTile: View {
|
||||
let battery = model.snapshot.battery
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 7) {
|
||||
// Das Symbol folgt dem Ladestand — sonst zeigt es bei 5 %
|
||||
// dasselbe wie bei 95 % und trägt nichts bei.
|
||||
Image(systemName: battery.map { BatterySymbol.name(charge: $0.charge) }
|
||||
?? BatterySymbol.unavailable)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(tint(battery))
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if battery?.isCharging == true {
|
||||
Image(systemName: BatterySymbol.chargingBolt)
|
||||
.font(.system(size: 7))
|
||||
.foregroundStyle(Onyx.Color.positive)
|
||||
.offset(x: 3, y: -3)
|
||||
}
|
||||
}
|
||||
Text(MetricFormat.percent(battery?.charge ?? 0))
|
||||
.font(Onyx.Font.metric).monospacedDigit()
|
||||
.foregroundStyle(tint(battery))
|
||||
if let battery, battery.isCharging {
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Onyx.Color.positive)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import Testing
|
||||
import AppKit
|
||||
@testable import MetricsProvider
|
||||
|
||||
// Ein Akkusymbol, das immer gleich aussieht, ist Dekoration und keine
|
||||
// Anzeige — es beantwortet die eine Frage nicht, für die es da ist.
|
||||
|
||||
@Suite("Akkusymbol")
|
||||
struct BatterySymbolTests {
|
||||
|
||||
@Test("Der Füllstand steht im Symbol")
|
||||
func fillFollowsCharge() {
|
||||
#expect(BatterySymbol.name(charge: 0.05) == "battery.0percent")
|
||||
#expect(BatterySymbol.name(charge: 0.25) == "battery.25percent")
|
||||
#expect(BatterySymbol.name(charge: 0.50) == "battery.50percent")
|
||||
#expect(BatterySymbol.name(charge: 0.75) == "battery.75percent")
|
||||
#expect(BatterySymbol.name(charge: 1.00) == "battery.100percent")
|
||||
}
|
||||
|
||||
@Test("Die Stufen liegen zwischen den Marken, nicht darauf")
|
||||
func stepsRoundToNearest() {
|
||||
// 60 % soll nicht als halbleer erscheinen, 40 % nicht als dreiviertel
|
||||
// voll. Gerundet wird zur nächsten Stufe.
|
||||
#expect(BatterySymbol.name(charge: 0.60) == "battery.50percent")
|
||||
#expect(BatterySymbol.name(charge: 0.64) == "battery.75percent")
|
||||
#expect(BatterySymbol.name(charge: 0.40) == "battery.50percent")
|
||||
}
|
||||
|
||||
@Test("Leer und voll bleiben in ihren Grenzen")
|
||||
func extremesAreClamped() {
|
||||
// Ein Messfehler darf keinen Symbolnamen erzeugen, den es nicht gibt.
|
||||
#expect(BatterySymbol.name(charge: -1) == "battery.0percent")
|
||||
#expect(BatterySymbol.name(charge: 5) == "battery.100percent")
|
||||
}
|
||||
|
||||
@Test("Jeder erzeugte Name existiert auch wirklich")
|
||||
func everyNameResolves() {
|
||||
// Der Grund für diesen Test: die erste Fassung setzte beim Laden ein
|
||||
// „.bolt" an den Füllstand an. Von diesen kombinierten Symbolen gibt es
|
||||
// aber nur `battery.100percent.bolt` — der Rest wäre ein leeres Feld
|
||||
// in der Menüleiste gewesen.
|
||||
for percent in stride(from: 0.0, through: 1.0, by: 0.05) {
|
||||
let name = BatterySymbol.name(charge: percent)
|
||||
#expect(NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil,
|
||||
"\(name) gibt es nicht")
|
||||
}
|
||||
#expect(NSImage(systemSymbolName: BatterySymbol.unavailable,
|
||||
accessibilityDescription: nil) != nil)
|
||||
#expect(NSImage(systemSymbolName: BatterySymbol.chargingBolt,
|
||||
accessibilityDescription: nil) != nil)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user