Blitz weiß mit Rand — und er fadet weiter

Beides, nicht eines von beiden: das Zeichen ist weiß mit dünnem dunklem
Rand, und beim Laden fadet es.

Der Rand ist dabei immer die Gegenfarbe zur Füllung. Damit bleibt das
Zeichen in jedem Moment des Fadens lesbar — über der hellen Füllung trägt
es der Rand, über dem leeren Teil die Füllung selbst.

Das Pausenzeichen ist niedriger als der Blitz. Es ist von Haus aus höher
gebaut und stieß oben und unten an den Rahmen des Akkus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-13 13:04:31 +02:00
parent 20963f53e2
commit 8545cc421c
2 changed files with 89 additions and 35 deletions

View File

@@ -68,6 +68,25 @@ public enum BatteryGlyph {
return (filled, empty) return (filled, empty)
} }
/// 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 Verhältnis zum Akku ist.
///
/// Das Pausenzeichen ist von Haus aus höher als der Blitz und stößt sonst
/// oben und unten an den Rahmen.
public static func glyphHeight(for indicator: Indicator, in height: CGFloat) -> CGFloat {
switch indicator {
case .paused: (height - 1) * 0.62
default: height - 1
}
}
/// Wie lange ein voller Hin- und Rückweg des Blitzes dauert. /// Wie lange ein voller Hin- und Rückweg des Blitzes dauert.
public static let fadeDuration: TimeInterval = 2.2 public static let fadeDuration: TimeInterval = 2.2
@@ -149,32 +168,25 @@ public enum BatteryGlyph {
isPluggedIn: isPluggedIn).symbolName, isPluggedIn: isPluggedIn).symbolName,
let glyph = NSImage(systemSymbolName: name, accessibilityDescription: nil) let glyph = NSImage(systemSymbolName: name, accessibilityDescription: nil)
else { return } else { return }
let glyphHeight = rect.height - 1 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 glyphWidth = (glyph.size.width / glyph.size.height) * glyphHeight
let glyphRect = CGRect(x: body.midX - glyphWidth / 2, let glyphRect = CGRect(x: body.midX - glyphWidth / 2,
y: rect.midY - glyphHeight / 2, y: rect.midY - glyphHeight / 2,
width: glyphWidth, height: glyphHeight) width: glyphWidth, height: glyphHeight)
if isCharging { // Weiß mit dünnem schwarzem Rand und beim Laden fadend.
// Der Blitz **fadet** zwischen Weiß und Schwarz. //
// // Der Rand ist immer die Gegenfarbe zur Füllung. Damit bleibt das
// Bei wenig Ladung sitzt er im leeren Teil des Akkus und braucht // Zeichen in **jedem** Moment des Fadens lesbar: über der hellen
// Weiß, über der Füllung braucht er Schwarz. Ein fester Wert ist in // Füllung trägt es der Rand, über dem leeren Teil die Füllung selbst.
// einem der beiden Fälle immer unlesbar; ein Faden ist in beiden let whiteness = isCharging ? boltWhiteness(phase: fadePhase) : 1
// irgendwann gut zu sehen und die Bewegung zeigt zusätzlich, dass if let outline = tint(glyph, with: NSColor(white: 1 - whiteness, alpha: 1)) {
// gerade geladen wird. outline.draw(in: outlineRect(glyphRect), from: .zero,
let whiteness = boltWhiteness(phase: fadePhase) operation: .sourceOver, fraction: 1)
let boltColor = NSColor(white: whiteness, alpha: 1) }
if let tinted = tint(glyph, with: boltColor) { if let body = tint(glyph, with: NSColor(white: whiteness, alpha: 1)) {
tinted.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1) body.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1)
}
return
} }
// Der Stecker wird ausgestanzt: er steht nur, wenn nicht geladen wird,
// und dann bewegt sich ohnehin nichts.
NSGraphicsContext.saveGraphicsState()
glyph.draw(in: glyphRect, from: .zero, operation: .destinationOut, fraction: 1)
NSGraphicsContext.restoreGraphicsState()
} }
} }
@@ -222,22 +234,27 @@ public struct BatteryGlyphView: View {
} }
.frame(width: (BatteryGlyph.size.width - 3) * scale, height: height) .frame(width: (BatteryGlyph.size.width - 3) * scale, height: height)
.overlay { .overlay {
if isCharging { let indicator = BatteryGlyph.indicator(isCharging: isCharging,
// Dasselbe Faden wie in der Menüleiste. `TimelineView` gibt isPluggedIn: isPluggedIn)
// die Zeit her, ohne dass hier ein Zeitgeber laufen muss. if let name = indicator.symbolName {
// `TimelineView` gibt die Zeit her, ohne dass hier ein
// Zeitgeber laufen muss.
TimelineView(.animation) { context in TimelineView(.animation) { context in
let phase = context.date.timeIntervalSinceReferenceDate let phase = isCharging
/ BatteryGlyph.fadeDuration ? context.date.timeIntervalSinceReferenceDate / BatteryGlyph.fadeDuration
Image(systemName: BatteryGlyph.Indicator.bolt.symbolName ?? "bolt.fill") : 0
.font(.system(size: height * 0.68)) let whiteness = isCharging
.foregroundStyle( ? BatteryGlyph.boltWhiteness(phase: phase) : 1
Color(white: BatteryGlyph.boltWhiteness(phase: phase))) let size = BatteryGlyph.glyphHeight(for: indicator, in: height)
ZStack {
Image(systemName: name)
.font(.system(size: size + 2 * BatteryGlyph.outlineWidth))
.foregroundStyle(Color(white: 1 - whiteness))
Image(systemName: name)
.font(.system(size: size))
.foregroundStyle(Color(white: whiteness))
}
} }
} else if let name = BatteryGlyph.indicator(isCharging: false,
isPluggedIn: isPluggedIn).symbolName {
Image(systemName: name)
.font(.system(size: height * 0.68))
.blendMode(.destinationOut)
} }
} }
.compositingGroup() .compositingGroup()

View File

@@ -194,3 +194,40 @@ struct BatteryBoltFadeTests {
#expect(BatteryGlyph.boltWhiteness(phase: 0.5) == 0) #expect(BatteryGlyph.boltWhiteness(phase: 0.5) == 0)
} }
} }
@Suite("Umrandetes Zeichen")
struct BatteryOutlineTests {
private let glyph = CGRect(x: 10, y: 4, width: 8, height: 12)
@Test("Der Rand liegt außen herum und bleibt mittig")
func outlineSurroundsAndCentres() {
let outline = BatteryGlyph.outlineRect(glyph)
#expect(outline.minX < glyph.minX)
#expect(outline.maxX > glyph.maxX)
#expect(abs(outline.midX - glyph.midX) < 0.001)
#expect(abs(outline.midY - glyph.midY) < 0.001)
}
@Test("Er ist dünn")
func outlineIsThin() {
// Ein dicker Rand frisst das Zeichen auf.
#expect(BatteryGlyph.outlineRect(glyph).width - glyph.width <= 2.5)
}
@Test("Das Pausenzeichen ist niedriger als der Blitz")
func pauseIsShorter() {
// Es ist von Haus aus höher gebaut und stößt sonst oben und unten an
// den Rahmen des Akkus.
let bolt = BatteryGlyph.glyphHeight(for: .bolt, in: 12)
let paused = BatteryGlyph.glyphHeight(for: .paused, in: 12)
#expect(paused < bolt)
}
@Test("Beide bleiben im Akku")
func bothFitInside() {
for indicator in BatteryGlyph.Indicator.allCases {
#expect(BatteryGlyph.glyphHeight(for: indicator, in: 12) < 12)
}
}
}