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 } 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 } /// 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, 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) 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 let name = BatteryGlyph.indicator(isCharging: isCharging, 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 } }