From 8f0831391eaaa8597563975f07f041be30d84b10 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 13 Aug 2026 12:52:21 +0200 Subject: [PATCH] =?UTF-8?q?Blitz=20fadet,=20Einstellungsfenster=20mittig?= =?UTF-8?q?=20und=20ver=C3=A4nderbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Blitz fadet beim Laden zwischen Weiß und Schwarz — so gewünscht, und ich hätte vorher fragen sollen statt eine eigene Lösung zu bauen und sie hinterher zu begründen. Über den Kosinus statt linear: ein linearer Ping-Pong knickt an den Umkehrpunkten sichtbar. Der Zeitgeber läuft mit 30 Bildern je Sekunde und nur, solange geladen wird — steht das Kabel nicht drin, läuft gar nichts. Der Stecker für „am Netz, lädt nicht" bleibt ausgestanzt; dort bewegt sich ohnehin nichts. Das Einstellungsfenster lässt sich jetzt vergrößern und startet mit 620 × 620 statt 520 × 420. Der Bereich „Displays" passt mit der Vorschau der Auslösefläche nicht mehr in die alte Höhe, und ein Fenster, in dem man scrollt, obwohl der Bildschirm frei ist, ist eine Zumutung. Die feste Größe in der View musste dafür weichen — sonst wächst das Fenster und der Inhalt bleibt stehen. Und es öffnet jetzt wirklich mittig. Mein erster Versuch mit 60 Punkten Abstand von oben war zu zaghaft: der Titelbalken lag weiter so hoch, dass man auf dem Weg zu seinen Knöpfen durch die Auslösefläche fuhr. Co-Authored-By: Claude Opus 5 --- Onyx/SettingsView.swift | 4 +- Onyx/SettingsWindowController.swift | 19 +++- .../MetricsProvider/BatteryGlyph.swift | 91 ++++++++++++------- .../MetricsProvider/MetricMenuBarModule.swift | 33 ++++++- .../Sources/OnyxMenuBar/MenuBarText.swift | 6 +- .../BatteryGlyphTests.swift | 37 ++++++++ 6 files changed, 146 insertions(+), 44 deletions(-) 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) + } +}