Akku wird gezeichnet wie der von macOS
Der Blitz stand neben dem Akku statt darin, und das sah nach zwei Dingen aus statt nach einem. Mit SF Symbols ging es nicht anders: fünf feste Füllstufen, und Blitz-Varianten gibt es nur bei hundert Prozent. Also selbst gezeichnet — ein Rahmen, ein Knubbel, eine Füllung, ein Blitz. Das ist am Ende die kleinere Lösung als die Symbolakrobatik und gibt obendrein, was vorher fehlte: die Füllung läuft stufenlos, 60 % sieht aus wie 60 % und nicht wie 50 %. Der Blitz wird ausgestanzt, nicht daraufgelegt. `destinationOut` nimmt weg, was vorher gezeichnet wurde; übrig bleibt ein Loch in Blitzform, durch das die Menüleiste scheint. Das funktioniert auf hellem wie dunklem Grund, ohne die Hintergrundfarbe zu kennen — und genau so sieht der von macOS aus. Unter zwanzig Prozent wird die Füllung rot, beim Laden nie: eine Warnung vor etwas, das sich schon erledigt, ist keine. Dieselbe Form auch in der Akku-Kachel. Zwei verschiedene Akkus in einer App wären schlechter als gar keiner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
147
Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift
Normal file
147
Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import AppKit
|
||||||
|
import OnyxMenuBar
|
||||||
|
|
||||||
|
/// Der Akku, gezeichnet wie der von macOS.
|
||||||
|
///
|
||||||
|
/// Warum nicht als SF-Symbol: das kennt fünf Füllstufen und — außer bei
|
||||||
|
/// hundert Prozent — keine Blitz-Variante. Der Blitz musste deshalb **neben**
|
||||||
|
/// den Akku, und das sieht nach zwei Dingen aus statt nach einem. macOS
|
||||||
|
/// zeichnet ihn mitten hinein, und der Ladestand läuft stufenlos.
|
||||||
|
///
|
||||||
|
/// Selbst zu zeichnen ist hier die kleinere Lösung als die Symbolakrobatik:
|
||||||
|
/// ein Rahmen, ein Knubbel, eine Füllung und ein ausgestanzter Blitz.
|
||||||
|
public enum BatteryGlyph {
|
||||||
|
|
||||||
|
/// Maße wie in der Menüleiste von macOS.
|
||||||
|
public static let size = CGSize(width: 25, height: 12)
|
||||||
|
/// Damit zwei Prozent nicht wie null aussehen.
|
||||||
|
public static let minimumFill: CGFloat = 2
|
||||||
|
/// Ab hier wird die Farbe zum Signal — dieselbe Schwelle wie bei macOS.
|
||||||
|
public static let lowThreshold = 0.2
|
||||||
|
|
||||||
|
public static func fillWidth(charge: Double, inner: CGFloat) -> CGFloat {
|
||||||
|
let clamped = min(max(charge, 0), 1)
|
||||||
|
guard clamped > 0 else { return 0 }
|
||||||
|
return min(max(inner * clamped, minimumFill), inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func isLow(charge: Double, isCharging: Bool = false) -> Bool {
|
||||||
|
// Ein roter Akku, der gerade lädt, wäre eine Warnung vor etwas, das
|
||||||
|
// sich schon erledigt.
|
||||||
|
!isCharging && charge < lowThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zeichnet den Akku in `rect`.
|
||||||
|
///
|
||||||
|
/// - Parameter color: die Vordergrundfarbe der Menüleiste. Nur bei
|
||||||
|
/// kritischem Ladestand weicht die Füllung davon ab — Farbe ist in der
|
||||||
|
/// Menüleiste ein Signal, keine Dekoration.
|
||||||
|
public static func draw(charge: Double, isCharging: Bool,
|
||||||
|
color: NSColor, in rect: CGRect) {
|
||||||
|
let capWidth: CGFloat = 2
|
||||||
|
let body = CGRect(x: rect.minX, y: rect.minY,
|
||||||
|
width: rect.width - capWidth - 1, height: rect.height)
|
||||||
|
|
||||||
|
// Rahmen — zurückgenommen, damit die Füllung die Aussage trägt.
|
||||||
|
let outline = NSBezierPath(roundedRect: body.insetBy(dx: 0.5, dy: 0.5),
|
||||||
|
xRadius: 3, yRadius: 3)
|
||||||
|
outline.lineWidth = 1
|
||||||
|
color.withAlphaComponent(0.45).setStroke()
|
||||||
|
outline.stroke()
|
||||||
|
|
||||||
|
// Der Knubbel am Pluspol.
|
||||||
|
let cap = CGRect(x: body.maxX + 1, y: rect.midY - 2.5, width: capWidth, height: 5)
|
||||||
|
color.withAlphaComponent(0.45).setFill()
|
||||||
|
NSBezierPath(roundedRect: cap, xRadius: 1, yRadius: 1).fill()
|
||||||
|
|
||||||
|
// Die Füllung.
|
||||||
|
let inner = body.insetBy(dx: 2, dy: 2)
|
||||||
|
let width = fillWidth(charge: charge, inner: inner.width)
|
||||||
|
if width > 0 {
|
||||||
|
let fill = CGRect(x: inner.minX, y: inner.minY,
|
||||||
|
width: width, height: inner.height)
|
||||||
|
(isLow(charge: charge, isCharging: isCharging)
|
||||||
|
? NSColor.systemRed : color).setFill()
|
||||||
|
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard isCharging else { return }
|
||||||
|
|
||||||
|
// Der Blitz wird **ausgestanzt**, nicht daraufgelegt.
|
||||||
|
//
|
||||||
|
// `destinationOut` nimmt weg, was vorher gezeichnet wurde — Füllung und
|
||||||
|
// Rahmen. Übrig bleibt ein Loch in Blitzform, durch das die Menüleiste
|
||||||
|
// scheint. Genau so sieht der von macOS aus, und es funktioniert auf
|
||||||
|
// hellem wie dunklem Grund, ohne die Hintergrundfarbe zu kennen.
|
||||||
|
guard let bolt = NSImage(systemSymbolName: "bolt.fill",
|
||||||
|
accessibilityDescription: nil) else { return }
|
||||||
|
let boltHeight = rect.height - 1
|
||||||
|
let boltWidth = (bolt.size.width / bolt.size.height) * boltHeight
|
||||||
|
let boltRect = CGRect(x: body.midX - boltWidth / 2,
|
||||||
|
y: rect.midY - boltHeight / 2,
|
||||||
|
width: boltWidth, height: boltHeight)
|
||||||
|
NSGraphicsContext.saveGraphicsState()
|
||||||
|
bolt.draw(in: boltRect, from: .zero, operation: .destinationOut, fraction: 1)
|
||||||
|
NSGraphicsContext.restoreGraphicsState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - SwiftUI
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Derselbe Akku für die Kachel.
|
||||||
|
///
|
||||||
|
/// Dieselbe Form wie in der Menüleiste, damit nicht zwei verschiedene Akkus
|
||||||
|
/// in derselben App stehen. Der Blitz ist hier kein Ausstanzen, sondern eine
|
||||||
|
/// Maske — in SwiftUI ist das der geradere Weg zum selben Bild.
|
||||||
|
public struct BatteryGlyphView: View {
|
||||||
|
private let charge: Double
|
||||||
|
private let isCharging: Bool
|
||||||
|
private let height: CGFloat
|
||||||
|
|
||||||
|
public init(charge: Double, isCharging: Bool, height: CGFloat = 14) {
|
||||||
|
self.charge = charge
|
||||||
|
self.isCharging = isCharging
|
||||||
|
self.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
private var scale: CGFloat { height / BatteryGlyph.size.height }
|
||||||
|
private var width: CGFloat { BatteryGlyph.size.width * scale }
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
HStack(spacing: 1 * scale) {
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 3 * scale, style: .continuous)
|
||||||
|
.strokeBorder(.primary.opacity(0.45), lineWidth: 1)
|
||||||
|
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let inner = geometry.size.width - 4 * scale
|
||||||
|
RoundedRectangle(cornerRadius: 1.5 * scale, style: .continuous)
|
||||||
|
.fill(fillColor)
|
||||||
|
.frame(width: BatteryGlyph.fillWidth(charge: charge, inner: inner))
|
||||||
|
.padding(2 * scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: (BatteryGlyph.size.width - 3) * scale, height: height)
|
||||||
|
.overlay {
|
||||||
|
if isCharging {
|
||||||
|
Image(systemName: "bolt.fill")
|
||||||
|
.font(.system(size: height * 0.72))
|
||||||
|
.blendMode(.destinationOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.compositingGroup()
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 1 * scale, style: .continuous)
|
||||||
|
.fill(.primary.opacity(0.45))
|
||||||
|
.frame(width: 2 * scale, height: 5 * scale)
|
||||||
|
}
|
||||||
|
.frame(width: width, height: height)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fillColor: Color {
|
||||||
|
BatteryGlyph.isLow(charge: charge, isCharging: isCharging) ? .red : .primary
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,17 +122,14 @@ final class MetricStatusView: NSView, WidthReporting {
|
|||||||
/// jedes Prozent alle Symbole rechts davon hin und her.
|
/// jedes Prozent alle Symbole rechts davon hin und her.
|
||||||
private var width: CGFloat {
|
private var width: CGFloat {
|
||||||
switch presentation {
|
switch presentation {
|
||||||
case .symbol: max(MenuBarText.minimumWidth,
|
case .symbol: max(MenuBarText.minimumWidth, symbolSlot + 4)
|
||||||
MenuBarText.symbolWidth(currentSymbol, height: 15) + 4)
|
|
||||||
case .graph: 28
|
case .graph: 28
|
||||||
case .bars: barsWidth
|
case .bars: barsWidth
|
||||||
case .value: MenuBarText.width(for: reference)
|
case .value: MenuBarText.width(for: reference)
|
||||||
case .valueAndGraph: MenuBarText.width(for: reference) + 2 + 28
|
case .valueAndGraph: MenuBarText.width(for: reference) + 2 + 28
|
||||||
case .labelAndValue: MenuBarText.width(for: "\(label) \(reference)")
|
case .labelAndValue: MenuBarText.width(for: "\(label) \(reference)")
|
||||||
case .symbolAndValue:
|
case .symbolAndValue:
|
||||||
MenuBarText.width(for: reference)
|
MenuBarText.width(for: reference) + symbolSlot + MenuBarText.innerGap
|
||||||
+ MenuBarText.symbolWidth(currentSymbol, height: 13)
|
|
||||||
+ MenuBarText.innerGap
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,10 +197,15 @@ final class MetricStatusView: NSView, WidthReporting {
|
|||||||
drawText("\(label) \(displayValue)", color: color)
|
drawText("\(label) \(displayValue)", color: color)
|
||||||
|
|
||||||
case .symbolAndValue:
|
case .symbolAndValue:
|
||||||
let glyph = MenuBarText.symbolWidth(currentSymbol, height: 13)
|
let glyph = symbolSlot
|
||||||
let symbolRect = NSRect(x: 0, y: bounds.midY - 6.5, width: glyph, height: 13)
|
if metric == .battery {
|
||||||
MenuBarText.drawSymbol(currentSymbol, color: color, in: symbolRect)
|
drawBattery(color: color,
|
||||||
drawChargingBolt(color: color, over: symbolRect)
|
at: NSPoint(x: 0, y: bounds.midY - BatteryGlyph.size.height / 2))
|
||||||
|
} else {
|
||||||
|
MenuBarText.drawSymbol(currentSymbol, color: color,
|
||||||
|
in: NSRect(x: 0, y: bounds.midY - 6.5,
|
||||||
|
width: glyph, height: 13))
|
||||||
|
}
|
||||||
drawText(displayValue, color: color,
|
drawText(displayValue, color: color,
|
||||||
in: NSRect(x: glyph + MenuBarText.innerGap, y: 0,
|
in: NSRect(x: glyph + MenuBarText.innerGap, y: 0,
|
||||||
width: bounds.width - glyph - MenuBarText.innerGap,
|
width: bounds.width - glyph - MenuBarText.innerGap,
|
||||||
@@ -224,12 +226,39 @@ final class MetricStatusView: NSView, WidthReporting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func drawSymbol(color: NSColor) {
|
private func drawSymbol(color: NSColor) {
|
||||||
|
if metric == .battery {
|
||||||
|
drawBattery(color: color,
|
||||||
|
at: NSPoint(x: bounds.midX - BatteryGlyph.size.width / 2,
|
||||||
|
y: bounds.midY - BatteryGlyph.size.height / 2))
|
||||||
|
return
|
||||||
|
}
|
||||||
let side: CGFloat = 15
|
let side: CGFloat = 15
|
||||||
let width = MenuBarText.symbolWidth(currentSymbol, height: side)
|
let width = MenuBarText.symbolWidth(currentSymbol, height: side)
|
||||||
let rect = NSRect(x: bounds.midX - width / 2, y: bounds.midY - side / 2,
|
MenuBarText.drawSymbol(currentSymbol, color: color,
|
||||||
width: width, height: side)
|
in: NSRect(x: bounds.midX - width / 2,
|
||||||
MenuBarText.drawSymbol(currentSymbol, color: color, in: rect)
|
y: bounds.midY - side / 2,
|
||||||
drawChargingBolt(color: color, over: rect)
|
width: width, height: side))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Akku wird selbst gezeichnet — mit stufenloser Füllung und dem Blitz
|
||||||
|
/// mitten drin, so wie macOS es zeigt.
|
||||||
|
private func drawBattery(color: NSColor, at origin: NSPoint) {
|
||||||
|
guard let battery = snapshot.battery else {
|
||||||
|
MenuBarText.drawSymbol(BatterySymbol.unavailable, color: color,
|
||||||
|
in: NSRect(x: origin.x, y: origin.y,
|
||||||
|
width: 15, height: 15))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
BatteryGlyph.draw(charge: battery.charge, isCharging: battery.isCharging,
|
||||||
|
color: color,
|
||||||
|
in: CGRect(origin: origin, size: BatteryGlyph.size))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie breit das Glyph ist. Der Akku wird selbst gezeichnet und hat eigene
|
||||||
|
/// Maße; alles andere ist ein SF-Symbol.
|
||||||
|
private var symbolSlot: CGFloat {
|
||||||
|
metric == .battery ? BatteryGlyph.size.width
|
||||||
|
: MenuBarText.symbolWidth(currentSymbol, height: 13)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Das Symbol zum **Zustand**, nicht zur Metrik.
|
/// Das Symbol zum **Zustand**, nicht zur Metrik.
|
||||||
@@ -244,17 +273,6 @@ final class MetricStatusView: NSView, WidthReporting {
|
|||||||
return BatterySymbol.name(charge: battery.charge)
|
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func drawGraph(color: NSColor, in rect: NSRect) {
|
private func drawGraph(color: NSColor, in rect: NSRect) {
|
||||||
guard history.count > 1, let context = NSGraphicsContext.current?.cgContext else { return }
|
guard history.count > 1, let context = NSGraphicsContext.current?.cgContext else { return }
|
||||||
let step = rect.width / CGFloat(history.count - 1)
|
let step = rect.width / CGFloat(history.count - 1)
|
||||||
|
|||||||
@@ -339,18 +339,15 @@ private struct BatteryTile: View {
|
|||||||
HStack(alignment: .firstTextBaseline, spacing: 7) {
|
HStack(alignment: .firstTextBaseline, spacing: 7) {
|
||||||
// Das Symbol folgt dem Ladestand — sonst zeigt es bei 5 %
|
// Das Symbol folgt dem Ladestand — sonst zeigt es bei 5 %
|
||||||
// dasselbe wie bei 95 % und trägt nichts bei.
|
// dasselbe wie bei 95 % und trägt nichts bei.
|
||||||
Image(systemName: battery.map { BatterySymbol.name(charge: $0.charge) }
|
if let battery {
|
||||||
?? BatterySymbol.unavailable)
|
BatteryGlyphView(charge: battery.charge,
|
||||||
.font(.system(size: 13))
|
isCharging: battery.isCharging, height: 15)
|
||||||
.foregroundStyle(tint(battery))
|
.foregroundStyle(tint(battery))
|
||||||
.overlay(alignment: .topTrailing) {
|
} else {
|
||||||
if battery?.isCharging == true {
|
Image(systemName: BatterySymbol.unavailable)
|
||||||
Image(systemName: BatterySymbol.chargingBolt)
|
.font(.system(size: 13))
|
||||||
.font(.system(size: 7))
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
.foregroundStyle(Onyx.Color.positive)
|
}
|
||||||
.offset(x: 3, y: -3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(MetricFormat.percent(battery?.charge ?? 0))
|
Text(MetricFormat.percent(battery?.charge ?? 0))
|
||||||
.font(Onyx.Font.metric).monospacedDigit()
|
.font(Onyx.Font.metric).monospacedDigit()
|
||||||
.foregroundStyle(tint(battery))
|
.foregroundStyle(tint(battery))
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import Testing
|
||||||
|
import CoreGraphics
|
||||||
|
@testable import MetricsProvider
|
||||||
|
|
||||||
|
// SF Symbols kennt fünf Füllstufen und keine Blitz-Varianten außer bei 100 %.
|
||||||
|
// Beides passt nicht zu dem, was macOS selbst zeigt: eine stufenlose Füllung
|
||||||
|
// und einen Blitz **im** Akku. Also selbst gezeichnet.
|
||||||
|
|
||||||
|
@Suite("Akkuglyph")
|
||||||
|
struct BatteryGlyphTests {
|
||||||
|
|
||||||
|
private let inner: CGFloat = 20
|
||||||
|
|
||||||
|
@Test("Die Füllung folgt dem Ladestand stufenlos")
|
||||||
|
func fillIsProportional() {
|
||||||
|
// Der eigentliche Gewinn gegenüber fünf festen Stufen: 60 % sieht aus
|
||||||
|
// wie 60 % und nicht wie 50 %.
|
||||||
|
#expect(BatteryGlyph.fillWidth(charge: 0.5, inner: inner) == 10)
|
||||||
|
#expect(BatteryGlyph.fillWidth(charge: 1.0, inner: inner) == 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein leerer Akku bleibt leer")
|
||||||
|
func emptyStaysEmpty() {
|
||||||
|
#expect(BatteryGlyph.fillWidth(charge: 0, inner: inner) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein fast leerer Akku ist trotzdem zu sehen")
|
||||||
|
func nearlyEmptyIsStillVisible() {
|
||||||
|
// Zwei Prozent ergäben 0,4 Punkte — unsichtbar, und damit
|
||||||
|
// ununterscheidbar von „leer". Ein Rest bleibt stehen.
|
||||||
|
let width = BatteryGlyph.fillWidth(charge: 0.02, inner: inner)
|
||||||
|
#expect(width >= BatteryGlyph.minimumFill)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Über hundert Prozent läuft nichts über")
|
||||||
|
func neverOverflows() {
|
||||||
|
#expect(BatteryGlyph.fillWidth(charge: 2, inner: inner) == inner)
|
||||||
|
#expect(BatteryGlyph.fillWidth(charge: -1, inner: inner) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Wenig Ladung gilt als kritisch")
|
||||||
|
func lowIsFlagged() {
|
||||||
|
// Dieselbe Schwelle wie in macOS: ab zwanzig Prozent abwärts wird die
|
||||||
|
// Farbe zum Signal.
|
||||||
|
#expect(BatteryGlyph.isLow(charge: 0.19))
|
||||||
|
#expect(!BatteryGlyph.isLow(charge: 0.21))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Beim Laden gilt nichts als kritisch")
|
||||||
|
func chargingIsNeverLow() {
|
||||||
|
// Ein roter Akku, der gerade lädt, wäre eine Warnung vor etwas, das
|
||||||
|
// sich schon erledigt.
|
||||||
|
#expect(!BatteryGlyph.isLow(charge: 0.05, isCharging: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Das Glyph hat die Maße der Menüleiste")
|
||||||
|
func sizeFitsMenuBar() {
|
||||||
|
// Breiter als hoch, wie ein Akku aussieht — und niedrig genug, dass es
|
||||||
|
// in die 22 Punkte der Leiste passt.
|
||||||
|
#expect(BatteryGlyph.size.width > BatteryGlyph.size.height)
|
||||||
|
#expect(BatteryGlyph.size.height <= 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user