„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>
256 lines
11 KiB
Swift
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
|
|
}
|
|
}
|