Files
onyx/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift
Scarriffle 20963f53e2 Am Netz ohne Laden: Pausenzeichen statt Stecker
„Laden angehalten" ist die Aussage. Ein Stecker sagt nur „Kabel steckt",
und das sieht man am Kabel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:00:44 +02:00

256 lines
11 KiB
Swift

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)
}
/// Was mitten im Akku steht.
public enum Indicator: CaseIterable, Sendable {
case none
/// Lädt.
case bolt
/// Am Netz, aber es fließt nichts meist, weil das Ladelimit erreicht
/// ist. Ohne eigenes Zeichen sieht das aus wie Akkubetrieb, und man
/// fragt sich, ob das Kabel wirklich steckt.
///
/// Als Pausenzeichen und nicht als Stecker: Laden angehalten" ist die
/// Aussage, und ein Stecker sagt nur Kabel steckt" das sieht man
/// ohnehin am Kabel.
case paused
public var symbolName: String? {
switch self {
case .none: nil
case .bolt: "bolt.fill"
case .paused: "pause.fill"
}
}
}
public static func indicator(isCharging: Bool, isPluggedIn: Bool) -> Indicator {
if isCharging { return .bolt }
return isPluggedIn ? .paused : .none
}
/// Wo der Blitz auf der Füllung liegt und wo im Leerraum.
///
/// Er wird über der Füllung **ausgestanzt** und daneben **ausgemalt**. Nur
/// auszustanzen reicht nicht: über dem leeren Teil ist das Transparenz auf
/// Transparenz, und bei wenig Ladung sitzt der Blitz fast ganz dort.
public static func boltSplit(charge: Double,
inner: CGRect) -> (filled: CGRect, empty: CGRect) {
let width = fillWidth(charge: charge, inner: inner.width)
let filled = CGRect(x: inner.minX, y: inner.minY,
width: width, height: inner.height)
let empty = CGRect(x: inner.minX + width, y: inner.minY,
width: inner.width - width, height: inner.height)
return (filled, empty)
}
/// Wie lange ein voller Hin- und Rückweg des Blitzes dauert.
public static let fadeDuration: TimeInterval = 2.2
/// Der Ping-Pong von 0 nach 1 und zurück.
///
/// Über den Kosinus statt linear: ein linearer Ping-Pong knickt an den
/// Umkehrpunkten sichtbar, der Kosinus wird dort flach. In der Menüleiste
/// fällt so ein Knick auf.
public static func fadeAmount(phase: Double) -> Double {
(1 - cos(phase.truncatingRemainder(dividingBy: 1) * 2 * .pi)) / 2
}
/// Wie weiß der Blitz gerade ist, 1 = weiß, 0 = schwarz.
///
/// Darum geht es: bei wenig Ladung sitzt der Blitz im leeren Teil des
/// Akkus und braucht Weiß, über der Füllung braucht er Schwarz. Ein Faden
/// dazwischen ist in beiden Fällen irgendwann gut lesbar und man sieht
/// am Wandern zusätzlich, dass geladen wird.
public static func boltWhiteness(phase: Double) -> Double {
1 - fadeAmount(phase: phase)
}
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
}
/// Ein Symbol in der gewünschten Farbe. `color.set()` genügt nicht:
/// Template-Bilder tragen ihre Farbe nicht aus dem Grafikzustand.
private static func tint(_ image: NSImage, with color: NSColor) -> NSImage? {
let configuration = NSImage.SymbolConfiguration(paletteColors: [color])
return image.withSymbolConfiguration(configuration)
}
/// 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, isPluggedIn: Bool = false,
fadePhase: Double = 0,
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()
}
// Das Zeichen wird **ausgestanzt**, nicht daraufgelegt.
//
// `destinationOut` nimmt weg, was vorher gezeichnet wurde Füllung und
// Rahmen. Übrig bleibt ein Loch in Blitz- bzw. Steckerform, 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 name = indicator(isCharging: isCharging,
isPluggedIn: isPluggedIn).symbolName,
let glyph = NSImage(systemSymbolName: name, accessibilityDescription: nil)
else { return }
let glyphHeight = rect.height - 1
let glyphWidth = (glyph.size.width / glyph.size.height) * glyphHeight
let glyphRect = CGRect(x: body.midX - glyphWidth / 2,
y: rect.midY - glyphHeight / 2,
width: glyphWidth, height: glyphHeight)
if isCharging {
// Der Blitz **fadet** zwischen Weiß und Schwarz.
//
// Bei wenig Ladung sitzt er im leeren Teil des Akkus und braucht
// Weiß, über der Füllung braucht er Schwarz. Ein fester Wert ist in
// einem der beiden Fälle immer unlesbar; ein Faden ist in beiden
// irgendwann gut zu sehen und die Bewegung zeigt zusätzlich, dass
// gerade geladen wird.
let whiteness = boltWhiteness(phase: fadePhase)
let boltColor = NSColor(white: whiteness, alpha: 1)
if let tinted = tint(glyph, with: boltColor) {
tinted.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1)
}
return
}
// Der Stecker wird ausgestanzt: er steht nur, wenn nicht geladen wird,
// und dann bewegt sich ohnehin nichts.
NSGraphicsContext.saveGraphicsState()
glyph.draw(in: glyphRect, 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
private let isPluggedIn: Bool
public init(charge: Double, isCharging: Bool, isPluggedIn: Bool = false,
height: CGFloat = 14) {
self.charge = charge
self.isCharging = isCharging
self.isPluggedIn = isPluggedIn
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 {
// Dasselbe Faden wie in der Menüleiste. `TimelineView` gibt
// die Zeit her, ohne dass hier ein Zeitgeber laufen muss.
TimelineView(.animation) { context in
let phase = context.date.timeIntervalSinceReferenceDate
/ BatteryGlyph.fadeDuration
Image(systemName: BatteryGlyph.Indicator.bolt.symbolName ?? "bolt.fill")
.font(.system(size: height * 0.68))
.foregroundStyle(
Color(white: BatteryGlyph.boltWhiteness(phase: phase)))
}
} else if let name = BatteryGlyph.indicator(isCharging: false,
isPluggedIn: isPluggedIn).symbolName {
Image(systemName: name)
.font(.system(size: height * 0.68))
.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
}
}