diff --git a/Onyx/SettingsView.swift b/Onyx/SettingsView.swift index 74b52da..aa278a5 100644 --- a/Onyx/SettingsView.swift +++ b/Onyx/SettingsView.swift @@ -46,7 +46,9 @@ struct SettingsView: View { .tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") } .tag(SettingsTab.permissions) } - .frame(width: 520, height: 420) + // Nur ein Mindestmaß, keine feste Größe: sonst kann das Fenster + // wachsen, der Inhalt aber nicht mit. + .frame(minWidth: 520, minHeight: 420) } } diff --git a/Onyx/SettingsWindowController.swift b/Onyx/SettingsWindowController.swift index 4dbd9d0..ee66ded 100644 --- a/Onyx/SettingsWindowController.swift +++ b/Onyx/SettingsWindowController.swift @@ -57,7 +57,13 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { let window = NSWindow(contentViewController: hosting) window.title = "Onyx" - window.styleMask = [.titled, .closable, .miniaturizable] + // Veränderbar, und das ist keine Kür: der Bereich „Displays" mit + // der Vorschau der Auslösefläche passt in 420 Punkte Höhe nicht + // mehr, und ein Fenster, in dem man scrollen muss, obwohl der + // Bildschirm frei ist, ist eine Zumutung. + window.styleMask = [.titled, .closable, .miniaturizable, .resizable] + window.setContentSize(NSSize(width: 620, height: 620)) + window.contentMinSize = NSSize(width: 520, height: 420) window.isReleasedWhenClosed = false window.delegate = self Self.place(window) @@ -84,11 +90,14 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { guard let screen = window.screen ?? NSScreen.main else { window.center(); return } let visible = screen.visibleFrame let size = window.frame.size - // Ein Fingerbreit Abstand nach oben genügt: die Auslösefläche reicht - // 14 Punkte unter die Notch, der Titelbalken ist 28 hoch. - let top = visible.maxY - 60 + // Wirklich mittig, nicht „etwas unterhalb der Notch". + // + // `center()` setzt oberhalb der Bildschirmmitte, und mein erster + // Versuch mit 60 Punkten Abstand war zu zaghaft: der Titelbalken lag + // weiter so hoch, dass man auf dem Weg zu seinen Knöpfen durch die + // Auslösefläche der Notch fuhr und aus Versehen das Panel öffnete. window.setFrameOrigin(NSPoint(x: visible.midX - size.width / 2, - y: top - size.height)) + y: visible.midY - size.height / 2)) } /// Der rote Knopf blendet aus, statt zu schließen. diff --git a/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift b/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift index 5f6ceb4..1b56704 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift @@ -64,6 +64,28 @@ public enum BatteryGlyph { return (filled, empty) } + /// Wie lange ein voller Hin- und Rückweg des Blitzes dauert. + public static let fadeDuration: TimeInterval = 2.2 + + /// Der Ping-Pong von 0 nach 1 und zurück. + /// + /// Über den Kosinus statt linear: ein linearer Ping-Pong knickt an den + /// Umkehrpunkten sichtbar, der Kosinus wird dort flach. In der Menüleiste + /// fällt so ein Knick auf. + public static func fadeAmount(phase: Double) -> Double { + (1 - cos(phase.truncatingRemainder(dividingBy: 1) * 2 * .pi)) / 2 + } + + /// Wie weiß der Blitz gerade ist, 1 = weiß, 0 = schwarz. + /// + /// Darum geht es: bei wenig Ladung sitzt der Blitz im leeren Teil des + /// Akkus und braucht Weiß, über der Füllung braucht er Schwarz. Ein Faden + /// dazwischen ist in beiden Fällen irgendwann gut lesbar — und man sieht + /// am Wandern zusätzlich, dass geladen wird. + public static func boltWhiteness(phase: Double) -> Double { + 1 - fadeAmount(phase: phase) + } + 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. @@ -83,6 +105,7 @@ public enum BatteryGlyph { /// 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, + fadePhase: Double = 0, color: NSColor, in rect: CGRect) { let capWidth: CGFloat = 2 let body = CGRect(x: rect.minX, y: rect.minY, @@ -127,19 +150,26 @@ public enum BatteryGlyph { 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) + 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 } + + // 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() } } @@ -188,31 +218,22 @@ public struct BatteryGlyphView: View { } .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) + if isCharging { + // Dasselbe Faden wie in der Menüleiste. `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)) - .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) - } + .foregroundStyle( + Color(white: BatteryGlyph.boltWhiteness(phase: phase))) } + } 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/Sources/MetricsProvider/MetricMenuBarModule.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift index 8bb252b..325fb71 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift @@ -113,6 +113,14 @@ final class MetricStatusView: NSView, WidthReporting { @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + /// Läuft nur, solange geladen wird — nur dann bewegt sich der Blitz. + /// + /// 30 Bilder je Sekunde reichen für ein weiches Faden und kosten deutlich + /// weniger als die 60, die man reflexhaft nähme. Steht das Ladekabel nicht + /// drin, läuft gar nichts. + private var fadeTimer: Timer? + private var fadePhase: Double = 0 + private var adaptive = AdaptiveWidth(minimum: MenuBarText.minimumWidth) private var shownWidth: CGFloat = MenuBarText.minimumWidth @@ -170,6 +178,8 @@ final class MetricStatusView: NSView, WidthReporting { self.mode = mode needsDisplay = true + updateFadeTimer() + let next = adaptive.update(needed: width) guard next != shownWidth else { return } shownWidth = next @@ -248,10 +258,31 @@ final class MetricStatusView: NSView, WidthReporting { return } BatteryGlyph.draw(charge: battery.charge, isCharging: battery.isCharging, - isPluggedIn: battery.isPluggedIn, color: color, + isPluggedIn: battery.isPluggedIn, fadePhase: fadePhase, + color: color, in: CGRect(origin: origin, size: BatteryGlyph.size)) } + /// Der Zeitgeber fürs Faden — an und aus mit dem Ladevorgang. + private func updateFadeTimer() { + let shouldFade = metric == .battery && snapshot.battery?.isCharging == true + if shouldFade, fadeTimer == nil { + let timer = Timer(timeInterval: 1.0 / 30, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.fadePhase += (1.0 / 30) / BatteryGlyph.fadeDuration + self.needsDisplay = true + } + } + RunLoop.main.add(timer, forMode: .common) + fadeTimer = timer + } else if !shouldFade, fadeTimer != nil { + fadeTimer?.invalidate() + fadeTimer = nil + needsDisplay = true + } + } + /// 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 { diff --git a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift index b8e41c1..79eda0b 100644 --- a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift +++ b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift @@ -22,12 +22,14 @@ public enum MenuBarText { NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) } - public static let sidePadding: CGFloat = 2 + /// Ein Punkt je Seite. macOS legt um jedes Statuselement ohnehin rund + /// acht Punkte eigene Polsterung — was hier draufkommt, kommt oben drauf. + public static let sidePadding: CGFloat = 1 public static let symbolWidth: CGFloat = 13 /// Abstand zwischen Symbol und Zahl. public static let innerGap: CGFloat = 2 /// Schmaler geht nicht, ohne dass ein Glyph anstößt. - public static let minimumWidth: CGFloat = 20 + public static let minimumWidth: CGFloat = 18 /// Die Breite eines Elements aus gemessener Textbreite. Rein, damit die /// Rechnung prüfbar bleibt. diff --git a/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift b/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift index 951a6b8..a8ee0df 100644 --- a/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift +++ b/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift @@ -155,3 +155,40 @@ struct BatteryBoltSplitTests { } } } + +@Suite("Blitz beim Laden") +struct BatteryBoltFadeTests { + + @Test("Die Kurve läuft von Weiß nach Schwarz und zurück") + func fadeIsAPingPong() { + #expect(BatteryGlyph.fadeAmount(phase: 0) == 0) + #expect(BatteryGlyph.fadeAmount(phase: 0.5) == 1) + #expect(abs(BatteryGlyph.fadeAmount(phase: 1) - 0) < 0.0001) + } + + @Test("Sie ist an den Umkehrpunkten weich") + func endsAreSmooth() { + // Ein linearer Ping-Pong knickt an den Enden sichtbar. Der Kosinus + // wird dort flach — das Faden wirkt dadurch ruhig statt gehetzt. + let nearStart = BatteryGlyph.fadeAmount(phase: 0.02) + let nearMiddle = BatteryGlyph.fadeAmount(phase: 0.5) + - BatteryGlyph.fadeAmount(phase: 0.48) + #expect(nearStart < nearMiddle) + } + + @Test("Die Phase läuft rundherum weiter") + func phaseWrapsAround() { + // Sonst gäbe es nach einer Umdrehung einen Sprung. + #expect(abs(BatteryGlyph.fadeAmount(phase: 1.25) + - BatteryGlyph.fadeAmount(phase: 0.25)) < 0.0001) + } + + @Test("Die Enden sind wirklich Schwarz und Weiß") + func extremesAreBlackAndWhite() { + // Genau darum geht es: bei wenig Ladung sitzt der Blitz im leeren Teil + // und braucht Weiß, über der Füllung braucht er Schwarz. Ein Faden + // dazwischen ist in beiden Fällen irgendwann lesbar. + #expect(BatteryGlyph.boltWhiteness(phase: 0) == 1) + #expect(BatteryGlyph.boltWhiteness(phase: 0.5) == 0) + } +}