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 in zwei Fassungen, hell und dunkel. /// /// **Warum zwei Dateien statt einer eingefärbten:** eine helle Fläche mit /// dunklem Rand ließ sich aus einem einzelnen Symbol nur nachbauen, indem /// dasselbe Zeichen zweimal in verschiedener Größe übereinander gezeichnet /// wurde — und die Größenverhältnisse sahen nie stimmig aus. Zwei fertige /// Fassungen ineinander zu blenden ist der geradere Weg: gezeichnet wird /// genau das, was in der Datei steht. public static let boltLight: NSImage? = load("bolt-light") public static let boltDark: NSImage? = load("bolt-dark") private static func load(_ name: String) -> NSImage? { guard let url = Bundle.main.url(forResource: name, 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 Akku ist. /// /// Bezugsgröße ist der **Innenraum**, nicht die Außenhöhe: der Rahmen /// nimmt oben und unten je einen Punkt, die Füllung sitzt zwei Punkte /// eingerückt, und um das Zeichen kommt noch sein eigener Rand. Wer von /// der Außenhöhe ausgeht, landet bei einem Blitz, der den ganzen Akku /// ausfüllt und an den Rahmen stößt. public static func glyphHeight(for indicator: Indicator, in height: CGFloat) -> CGFloat { let inner = max(height - 2 * fillInset, 1) switch indicator { // Das Pausenzeichen ist von Haus aus höher gebaut als der Blitz. case .paused: return inner * 0.8 default: return inner } } /// Wie weit die Füllung vom Rahmen abrückt. public static let fillInset: CGFloat = 2 /// 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 blendet zwischen den beiden Fassungen über. // // Erst die dunkle voll deckend, dann die helle mit steigender // Deckkraft darüber. So ist das Zeichen in jedem Moment vollständig // gedeckt — zeichnete man beide anteilig, schiene dazwischen der // Hintergrund durch und der Blitz wirkte blass. let whiteness = boltWhiteness(phase: fadePhase) if let dark = boltDark, let light = boltLight { dark.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1) light.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: whiteness) return } if let filled = tint(glyph, with: NSColor(white: whiteness, alpha: 1)) { 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) bolt(side: size, whiteness: 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) } /// Dieselben zwei Fassungen wie in der Menüleiste, ineinander geblendet. @ViewBuilder private func bolt(side: CGFloat, whiteness: Double) -> some View { if let dark = BatteryGlyph.boltDark, let light = BatteryGlyph.boltLight { ZStack { Image(nsImage: dark).resizable().aspectRatio(contentMode: .fit) Image(nsImage: light).resizable().aspectRatio(contentMode: .fit) .opacity(whiteness) } .frame(width: side, height: side) } else { Image(systemName: "bolt.fill") .font(.system(size: side)) .foregroundStyle(Color(white: whiteness)) } } private var fillColor: Color { BatteryGlyph.isLow(charge: charge, isCharging: isCharging) ? .red : .primary } }