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 } }