diff --git a/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift b/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift new file mode 100644 index 0000000..98fd5c4 --- /dev/null +++ b/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift @@ -0,0 +1,147 @@ +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) + } + + 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, + 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() + } + + guard isCharging else { return } + + // Der Blitz wird **ausgestanzt**, nicht daraufgelegt. + // + // `destinationOut` nimmt weg, was vorher gezeichnet wurde — Füllung und + // Rahmen. Übrig bleibt ein Loch in Blitzform, 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 bolt = NSImage(systemSymbolName: "bolt.fill", + accessibilityDescription: nil) else { return } + let boltHeight = rect.height - 1 + let boltWidth = (bolt.size.width / bolt.size.height) * boltHeight + let boltRect = CGRect(x: body.midX - boltWidth / 2, + y: rect.midY - boltHeight / 2, + width: boltWidth, height: boltHeight) + NSGraphicsContext.saveGraphicsState() + bolt.draw(in: boltRect, 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 + + public init(charge: Double, isCharging: Bool, height: CGFloat = 14) { + self.charge = charge + self.isCharging = isCharging + 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 { + Image(systemName: "bolt.fill") + .font(.system(size: height * 0.72)) + .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 + } +} diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift index d1dd1e2..964a1c2 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift @@ -122,17 +122,14 @@ final class MetricStatusView: NSView, WidthReporting { /// jedes Prozent alle Symbole rechts davon hin und her. private var width: CGFloat { switch presentation { - case .symbol: max(MenuBarText.minimumWidth, - MenuBarText.symbolWidth(currentSymbol, height: 15) + 4) + case .symbol: max(MenuBarText.minimumWidth, symbolSlot + 4) case .graph: 28 case .bars: barsWidth case .value: MenuBarText.width(for: reference) case .valueAndGraph: MenuBarText.width(for: reference) + 2 + 28 case .labelAndValue: MenuBarText.width(for: "\(label) \(reference)") case .symbolAndValue: - MenuBarText.width(for: reference) - + MenuBarText.symbolWidth(currentSymbol, height: 13) - + MenuBarText.innerGap + MenuBarText.width(for: reference) + symbolSlot + MenuBarText.innerGap } } @@ -200,10 +197,15 @@ final class MetricStatusView: NSView, WidthReporting { drawText("\(label) \(displayValue)", color: color) case .symbolAndValue: - let glyph = MenuBarText.symbolWidth(currentSymbol, height: 13) - let symbolRect = NSRect(x: 0, y: bounds.midY - 6.5, width: glyph, height: 13) - MenuBarText.drawSymbol(currentSymbol, color: color, in: symbolRect) - drawChargingBolt(color: color, over: symbolRect) + let glyph = symbolSlot + if metric == .battery { + drawBattery(color: color, + at: NSPoint(x: 0, y: bounds.midY - BatteryGlyph.size.height / 2)) + } else { + MenuBarText.drawSymbol(currentSymbol, color: color, + in: NSRect(x: 0, y: bounds.midY - 6.5, + width: glyph, height: 13)) + } drawText(displayValue, color: color, in: NSRect(x: glyph + MenuBarText.innerGap, y: 0, width: bounds.width - glyph - MenuBarText.innerGap, @@ -224,12 +226,39 @@ final class MetricStatusView: NSView, WidthReporting { } private func drawSymbol(color: NSColor) { + if metric == .battery { + drawBattery(color: color, + at: NSPoint(x: bounds.midX - BatteryGlyph.size.width / 2, + y: bounds.midY - BatteryGlyph.size.height / 2)) + return + } let side: CGFloat = 15 let width = MenuBarText.symbolWidth(currentSymbol, height: side) - let rect = NSRect(x: bounds.midX - width / 2, y: bounds.midY - side / 2, - width: width, height: side) - MenuBarText.drawSymbol(currentSymbol, color: color, in: rect) - drawChargingBolt(color: color, over: rect) + MenuBarText.drawSymbol(currentSymbol, color: color, + in: NSRect(x: bounds.midX - width / 2, + y: bounds.midY - side / 2, + width: width, height: side)) + } + + /// Der Akku wird selbst gezeichnet — mit stufenloser Füllung und dem Blitz + /// mitten drin, so wie macOS es zeigt. + private func drawBattery(color: NSColor, at origin: NSPoint) { + guard let battery = snapshot.battery else { + MenuBarText.drawSymbol(BatterySymbol.unavailable, color: color, + in: NSRect(x: origin.x, y: origin.y, + width: 15, height: 15)) + return + } + BatteryGlyph.draw(charge: battery.charge, isCharging: battery.isCharging, + color: color, + in: CGRect(origin: origin, size: BatteryGlyph.size)) + } + + /// Wie breit das Glyph ist. Der Akku wird selbst gezeichnet und hat eigene + /// Maße; alles andere ist ein SF-Symbol. + private var symbolSlot: CGFloat { + metric == .battery ? BatteryGlyph.size.width + : MenuBarText.symbolWidth(currentSymbol, height: 13) } /// Das Symbol zum **Zustand**, nicht zur Metrik. @@ -244,17 +273,6 @@ final class MetricStatusView: NSView, WidthReporting { return BatterySymbol.name(charge: battery.charge) } - /// Beim Laden ein kleiner Blitz über dem Füllstand. Zwei Zeichnungen statt - /// eines Symbols, weil es die kombinierten Glyphen nur bei 100 % gibt. - private func drawChargingBolt(color: NSColor, over rect: NSRect) { - guard metric == .battery, snapshot.battery?.isCharging == true else { return } - let side: CGFloat = 8 - MenuBarText.drawSymbol(BatterySymbol.chargingBolt, color: color, - in: NSRect(x: rect.maxX - side * 0.75, - y: rect.maxY - side * 0.7, - width: side, height: side)) - } - private func drawGraph(color: NSColor, in rect: NSRect) { guard history.count > 1, let context = NSGraphicsContext.current?.cgContext else { return } let step = rect.width / CGFloat(history.count - 1) diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift index 5f3e981..cbb96ec 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift @@ -339,18 +339,15 @@ private struct BatteryTile: View { HStack(alignment: .firstTextBaseline, spacing: 7) { // Das Symbol folgt dem Ladestand — sonst zeigt es bei 5 % // dasselbe wie bei 95 % und trägt nichts bei. - Image(systemName: battery.map { BatterySymbol.name(charge: $0.charge) } - ?? BatterySymbol.unavailable) - .font(.system(size: 13)) - .foregroundStyle(tint(battery)) - .overlay(alignment: .topTrailing) { - if battery?.isCharging == true { - Image(systemName: BatterySymbol.chargingBolt) - .font(.system(size: 7)) - .foregroundStyle(Onyx.Color.positive) - .offset(x: 3, y: -3) - } - } + if let battery { + BatteryGlyphView(charge: battery.charge, + isCharging: battery.isCharging, height: 15) + .foregroundStyle(tint(battery)) + } else { + Image(systemName: BatterySymbol.unavailable) + .font(.system(size: 13)) + .foregroundStyle(Onyx.Color.textTertiary) + } Text(MetricFormat.percent(battery?.charge ?? 0)) .font(Onyx.Font.metric).monospacedDigit() .foregroundStyle(tint(battery)) diff --git a/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift b/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift new file mode 100644 index 0000000..fce184c --- /dev/null +++ b/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift @@ -0,0 +1,63 @@ +import Testing +import CoreGraphics +@testable import MetricsProvider + +// SF Symbols kennt fünf Füllstufen und keine Blitz-Varianten außer bei 100 %. +// Beides passt nicht zu dem, was macOS selbst zeigt: eine stufenlose Füllung +// und einen Blitz **im** Akku. Also selbst gezeichnet. + +@Suite("Akkuglyph") +struct BatteryGlyphTests { + + private let inner: CGFloat = 20 + + @Test("Die Füllung folgt dem Ladestand stufenlos") + func fillIsProportional() { + // Der eigentliche Gewinn gegenüber fünf festen Stufen: 60 % sieht aus + // wie 60 % und nicht wie 50 %. + #expect(BatteryGlyph.fillWidth(charge: 0.5, inner: inner) == 10) + #expect(BatteryGlyph.fillWidth(charge: 1.0, inner: inner) == 20) + } + + @Test("Ein leerer Akku bleibt leer") + func emptyStaysEmpty() { + #expect(BatteryGlyph.fillWidth(charge: 0, inner: inner) == 0) + } + + @Test("Ein fast leerer Akku ist trotzdem zu sehen") + func nearlyEmptyIsStillVisible() { + // Zwei Prozent ergäben 0,4 Punkte — unsichtbar, und damit + // ununterscheidbar von „leer". Ein Rest bleibt stehen. + let width = BatteryGlyph.fillWidth(charge: 0.02, inner: inner) + #expect(width >= BatteryGlyph.minimumFill) + } + + @Test("Über hundert Prozent läuft nichts über") + func neverOverflows() { + #expect(BatteryGlyph.fillWidth(charge: 2, inner: inner) == inner) + #expect(BatteryGlyph.fillWidth(charge: -1, inner: inner) == 0) + } + + @Test("Wenig Ladung gilt als kritisch") + func lowIsFlagged() { + // Dieselbe Schwelle wie in macOS: ab zwanzig Prozent abwärts wird die + // Farbe zum Signal. + #expect(BatteryGlyph.isLow(charge: 0.19)) + #expect(!BatteryGlyph.isLow(charge: 0.21)) + } + + @Test("Beim Laden gilt nichts als kritisch") + func chargingIsNeverLow() { + // Ein roter Akku, der gerade lädt, wäre eine Warnung vor etwas, das + // sich schon erledigt. + #expect(!BatteryGlyph.isLow(charge: 0.05, isCharging: true)) + } + + @Test("Das Glyph hat die Maße der Menüleiste") + func sizeFitsMenuBar() { + // Breiter als hoch, wie ein Akku aussieht — und niedrig genug, dass es + // in die 22 Punkte der Leiste passt. + #expect(BatteryGlyph.size.width > BatteryGlyph.size.height) + #expect(BatteryGlyph.size.height <= 14) + } +}