`bolt.fill` aus SF Symbols ist eine massive Fläche; die neue Datei ist eine Kontur mit kräftigem Strich und trifft die Form besser. Gezeichnet wird sie zweifach wie zuvor: einmal etwas größer in der Gegenfarbe als Rand, einmal in der fadenden Farbe darüber. Damit bleibt der Blitz in jedem Moment des Fadens lesbar — über der hellen Füllung trägt ihn der Rand, über dem leeren Teil er sich selbst. Fehlt die Datei, bleibt das Systemsymbol. Dieselbe Vorlage auch in der Akku-Kachel, damit nicht zwei verschiedene Blitze in einer App stehen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
320 lines
14 KiB
Swift
320 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 {
|
|
switch indicator {
|
|
case .paused: (height - 1) * 0.62
|
|
default: height - 1
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|