Deine Idee war die bessere. 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, wie man an den drei Anläufen sieht. Zwei fertige Fassungen ineinander zu blenden ist der geradere Weg: gezeichnet wird genau das, was in der Datei steht. Erst die dunkle voll deckend, dann die helle mit steigender Deckkraft darüber. Zeichnete man beide anteilig, schiene dazwischen der Hintergrund durch und der Blitz wirkte blass. Ohne eigenen Rand bekommt der Blitz den vollen Innenraum des Akkus statt Innenraum minus Rand — er ist damit wieder etwas größer, bleibt aber innerhalb des Rahmens. Der Test hält die neue Grenze fest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
327 lines
14 KiB
Swift
327 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 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
|
|
}
|
|
}
|