Files
onyx/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift
Scarriffle 8f0831391e Blitz fadet, Einstellungsfenster mittig und veränderbar
Der Blitz fadet beim Laden zwischen Weiß und Schwarz — so gewünscht, und
ich hätte vorher fragen sollen statt eine eigene Lösung zu bauen und sie
hinterher zu begründen.

Über den Kosinus statt linear: ein linearer Ping-Pong knickt an den
Umkehrpunkten sichtbar. Der Zeitgeber läuft mit 30 Bildern je Sekunde und
nur, solange geladen wird — steht das Kabel nicht drin, läuft gar nichts.
Der Stecker für „am Netz, lädt nicht" bleibt ausgestanzt; dort bewegt sich
ohnehin nichts.

Das Einstellungsfenster lässt sich jetzt vergrößern und startet mit
620 × 620 statt 520 × 420. Der Bereich „Displays" passt mit der Vorschau
der Auslösefläche nicht mehr in die alte Höhe, und ein Fenster, in dem man
scrollt, obwohl der Bildschirm frei ist, ist eine Zumutung. Die feste Größe
in der View musste dafür weichen — sonst wächst das Fenster und der Inhalt
bleibt stehen.

Und es öffnet jetzt wirklich mittig. Mein erster Versuch mit 60 Punkten
Abstand von oben war zu zaghaft: der Titelbalken lag weiter so hoch, dass
man auf dem Weg zu seinen Knöpfen durch die Auslösefläche fuhr.

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

252 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.
case plug
public var symbolName: String? {
switch self {
case .none: nil
case .bolt: "bolt.fill"
case .plug: "powerplug.fill"
}
}
}
public static func indicator(isCharging: Bool, isPluggedIn: Bool) -> Indicator {
if isCharging { return .bolt }
return isPluggedIn ? .plug : .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
}
}