Der Blitz war ein Loch in der Füllung — über dem gefüllten Teil liest man ihn, über dem leeren ist es Transparenz auf Transparenz. Bei wenig Ladung sitzt er fast ganz im Leeren und verschwindet. Statt zwischen Schwarz und Weiß zu faden geht er den Weg von macOS: geteilt gezeichnet, über der Füllung ausgestanzt, daneben ausgemalt. Damit ist er bei jedem Ladestand lesbar, ohne Bewegung in der Menüleiste. Ein Test prüft, dass sich die beiden Teile an der Kante treffen statt zu überlappen — sonst wäre der Blitz an der Nahtstelle dicker. Die Auslösefläche der Notch lässt sich jetzt einstellen, mit Vorschau. Wie genau jemand die Notch trifft, hängt an Hand und Zeigegerät; feste Werte im Code waren dafür die falsche Antwort. Die Vorschau zeigt einen nachgebildeten Bildschirm mit Notch und eingefärbter Fläche — „24 Punkte unter der Notch" ist keine Vorstellung, die jemand im Kopf hat. Die Menüleistenbreiten folgen jetzt dem, was dasteht. „Temp 53°" stand in einer Box für „Temp 100 °C", und in der Menüleiste ist Platz das knappste Gut. Dieselbe Hysterese wie beim Netzwerk: sofort wachsen, zögernd schrumpfen. Das Mixer-Symbol war ausgeschaltet auf 55 % abgeblendet. Das war meine Idee und ein Fehlgriff: über einem hellen Schreibtischhintergrund ist es damit praktisch weg. Ob der Mixer läuft, erfährt man beim Klick — dafür muss man das Symbol aber erst finden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
231 lines
10 KiB
Swift
231 lines
10 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.
|
|
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
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
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,
|
|
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)
|
|
// Geteilt zeichnen: über der Füllung ein Loch, daneben ein Glyph.
|
|
let split = boltSplit(charge: charge, inner: inner)
|
|
|
|
NSGraphicsContext.saveGraphicsState()
|
|
NSBezierPath(rect: split.filled).setClip()
|
|
glyph.draw(in: glyphRect, from: .zero, operation: .destinationOut, fraction: 1)
|
|
NSGraphicsContext.restoreGraphicsState()
|
|
|
|
NSGraphicsContext.saveGraphicsState()
|
|
NSBezierPath(rect: split.empty).setClip()
|
|
if let tinted = tint(glyph, with: color) {
|
|
tinted.draw(in: glyphRect, from: .zero, operation: .sourceOver, 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 {
|
|
// Geteilt wie in der Menüleiste: über der Füllung ein Loch,
|
|
// daneben ein ausgemaltes Glyph. Nur auszustanzen hieße, dass
|
|
// der Blitz bei wenig Ladung im Leeren verschwindet.
|
|
if let name = BatteryGlyph.indicator(isCharging: isCharging,
|
|
isPluggedIn: isPluggedIn).symbolName {
|
|
GeometryReader { geometry in
|
|
let inner = CGRect(origin: .zero, size: geometry.size)
|
|
.insetBy(dx: 2 * scale, dy: 2 * scale)
|
|
let split = BatteryGlyph.boltSplit(charge: charge, inner: inner)
|
|
let glyph = Image(systemName: name)
|
|
.font(.system(size: height * 0.68))
|
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
|
|
|
glyph.blendMode(.destinationOut)
|
|
.mask(alignment: .topLeading) {
|
|
Rectangle().frame(width: split.filled.width,
|
|
height: geometry.size.height)
|
|
.offset(x: split.filled.minX)
|
|
}
|
|
glyph.mask(alignment: .topLeading) {
|
|
Rectangle().frame(width: split.empty.width,
|
|
height: geometry.size.height)
|
|
.offset(x: split.empty.minX)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.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
|
|
}
|
|
}
|