Files
onyx/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift
Scarriffle 2bf76eb060 Fehlendes return nachgetragen
Der vorige Commit war nicht übersetzbar: mit einer Zeile vor dem `switch`
ist er kein Ausdruck mehr und braucht ein ausdrückliches `return`. Meine
Prüfung lief in dem Moment gegen ein anderes Verzeichnis und hat den Fehler
nicht gemeldet.

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

323 lines
14 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)
}
/// Der Blitz als eigene Vorlage.
///
/// SF Symbols' `bolt.fill` ist eine massive Fläche; die mitgelieferte
/// Kontur trifft die Form besser und bringt schon einen kräftigen Strich
/// mit. Fehlt die Datei, bleibt das Systemsymbol.
public static let chargingGlyph: NSImage? = {
guard let url = Bundle.main.url(forResource: "thunder", withExtension: "svg")
else { return nil }
return NSImage(contentsOf: url)
}()
/// Wie dick der Rand um das Zeichen ist.
public static let outlineWidth: CGFloat = 1
/// Der Rahmen für den Rand dasselbe Zeichen, etwas größer.
public static func outlineRect(_ glyph: CGRect) -> CGRect {
glyph.insetBy(dx: -outlineWidth, dy: -outlineWidth)
}
/// Wie hoch das Zeichen im Verhältnis zum Akku ist.
///
/// Das Pausenzeichen ist von Haus aus höher als der Blitz und stößt sonst
/// oben und unten an den Rahmen.
public static func glyphHeight(for indicator: Indicator, in height: CGFloat) -> CGFloat {
// Der Rand kommt außen noch dazu bei voller Höhe stößt das Zeichen
// deshalb oben und unten an den Rahmen des Akkus.
let usable = height - 1 - 2 * outlineWidth
switch indicator {
case .paused: return usable * 0.7
default: return usable
}
}
/// 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 indicator = self.indicator(isCharging: isCharging, isPluggedIn: isPluggedIn)
let glyphHeight = glyphHeight(for: indicator, in: rect.height)
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)
guard isCharging else {
// Das Pausenzeichen wird **ausgestanzt**: ein Loch in der Füllung.
//
// Weiß mit Rand funktioniert hier nicht bei fast vollem Akku
// liegt es auf weißer Füllung, das Weiß verschwindet darin und
// übrig bleiben die dunklen Ränder als zwei Striche.
NSGraphicsContext.saveGraphicsState()
glyph.draw(in: glyphRect, from: .zero, operation: .destinationOut, fraction: 1)
NSGraphicsContext.restoreGraphicsState()
return
}
// Der Blitz: mit dunklem Rand, und fadend.
//
// Der Rand ist immer die Gegenfarbe zum Blitz selbst. Damit bleibt er
// in **jedem** Moment des Fadens lesbar: über der hellen Füllung trägt
// ihn der Rand, über dem leeren Teil der Blitz.
let whiteness = boltWhiteness(phase: fadePhase)
let inverseColor = NSColor(white: 1 - whiteness, alpha: 1)
let boltColor = NSColor(white: whiteness, alpha: 1)
if let custom = chargingGlyph {
MenuBarText.drawTemplate(custom, color: inverseColor, in: outlineRect(glyphRect))
MenuBarText.drawTemplate(custom, color: boltColor, in: glyphRect)
return
}
if let outline = tint(glyph, with: inverseColor) {
outline.draw(in: outlineRect(glyphRect), from: .zero,
operation: .sourceOver, fraction: 1)
}
if let filled = tint(glyph, with: boltColor) {
filled.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1)
}
}
}
// 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 {
let indicator = BatteryGlyph.indicator(isCharging: isCharging,
isPluggedIn: isPluggedIn)
if let name = indicator.symbolName {
// `TimelineView` gibt die Zeit her, ohne dass hier ein
// Zeitgeber laufen muss.
let size = BatteryGlyph.glyphHeight(for: indicator, in: height)
if isCharging {
TimelineView(.animation) { context in
let phase = context.date.timeIntervalSinceReferenceDate
/ BatteryGlyph.fadeDuration
let whiteness = BatteryGlyph.boltWhiteness(phase: phase)
ZStack {
bolt(name: name, side: size + 2 * BatteryGlyph.outlineWidth)
.foregroundStyle(Color(white: 1 - whiteness))
bolt(name: name, side: size)
.foregroundStyle(Color(white: whiteness))
}
}
} else {
Image(systemName: name)
.font(.system(size: size))
.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)
}
/// Dieselbe Vorlage wie in der Menüleiste, sonst das Systemsymbol.
@ViewBuilder
private func bolt(name: String, side: CGFloat) -> some View {
if let custom = BatteryGlyph.chargingGlyph {
Image(nsImage: custom)
.renderingMode(.template)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: side, height: side)
} else {
Image(systemName: name).font(.system(size: side))
}
}
private var fillColor: Color {
BatteryGlyph.isLow(charge: charge, isCharging: isCharging) ? .red : .primary
}
}