From 8545cc421cbbf6876e1f399f7567386ca4416723 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 13 Aug 2026 13:04:31 +0200 Subject: [PATCH] =?UTF-8?q?Blitz=20wei=C3=9F=20mit=20Rand=20=E2=80=94=20un?= =?UTF-8?q?d=20er=20fadet=20weiter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../MetricsProvider/BatteryGlyph.swift | 87 +++++++++++-------- .../BatteryGlyphTests.swift | 37 ++++++++ 2 files changed, 89 insertions(+), 35 deletions(-) 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) + } + } +}