diff --git a/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift b/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift index 327adc1..88fb33b 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift @@ -68,6 +68,25 @@ public enum BatteryGlyph { 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. public static let fadeDuration: TimeInterval = 2.2 @@ -149,32 +168,25 @@ public enum BatteryGlyph { isPluggedIn: isPluggedIn).symbolName, let glyph = NSImage(systemSymbolName: name, accessibilityDescription: nil) 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 glyphRect = CGRect(x: body.midX - glyphWidth / 2, y: rect.midY - glyphHeight / 2, width: glyphWidth, height: glyphHeight) - if isCharging { - // Der Blitz **fadet** zwischen Weiß und Schwarz. - // - // Bei wenig Ladung sitzt er im leeren Teil des Akkus und braucht - // Weiß, über der Füllung braucht er Schwarz. Ein fester Wert ist in - // einem der beiden Fälle immer unlesbar; ein Faden ist in beiden - // irgendwann gut zu sehen — und die Bewegung zeigt zusätzlich, dass - // gerade geladen wird. - let whiteness = boltWhiteness(phase: fadePhase) - let boltColor = NSColor(white: whiteness, alpha: 1) - if let tinted = tint(glyph, with: boltColor) { - tinted.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1) - } - return + // Weiß mit dünnem schwarzem Rand — und beim Laden fadend. + // + // Der Rand ist 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. + let whiteness = isCharging ? boltWhiteness(phase: fadePhase) : 1 + if let outline = tint(glyph, with: NSColor(white: 1 - whiteness, alpha: 1)) { + outline.draw(in: outlineRect(glyphRect), from: .zero, + operation: .sourceOver, fraction: 1) + } + if let body = tint(glyph, with: NSColor(white: whiteness, alpha: 1)) { + body.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1) } - - // 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) .overlay { - if isCharging { - // Dasselbe Faden wie in der Menüleiste. `TimelineView` gibt - // die Zeit her, ohne dass hier ein Zeitgeber laufen muss. + 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. TimelineView(.animation) { context in - let phase = context.date.timeIntervalSinceReferenceDate - / BatteryGlyph.fadeDuration - Image(systemName: BatteryGlyph.Indicator.bolt.symbolName ?? "bolt.fill") - .font(.system(size: height * 0.68)) - .foregroundStyle( - Color(white: BatteryGlyph.boltWhiteness(phase: phase))) + let phase = isCharging + ? context.date.timeIntervalSinceReferenceDate / BatteryGlyph.fadeDuration + : 0 + let whiteness = isCharging + ? BatteryGlyph.boltWhiteness(phase: phase) : 1 + 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() diff --git a/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift b/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift index 554f7a9..faccd02 100644 --- a/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift +++ b/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift @@ -194,3 +194,40 @@ struct BatteryBoltFadeTests { #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) + } + } +}