Blitz fadet, Einstellungsfenster mittig und veränderbar

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-13 12:52:21 +02:00
parent a84e5a0c8e
commit 8f0831391e
6 changed files with 146 additions and 44 deletions

View File

@@ -46,7 +46,9 @@ struct SettingsView: View {
.tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") } .tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") }
.tag(SettingsTab.permissions) .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)
} }
} }

View File

@@ -57,7 +57,13 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
let window = NSWindow(contentViewController: hosting) let window = NSWindow(contentViewController: hosting)
window.title = "Onyx" 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.isReleasedWhenClosed = false
window.delegate = self window.delegate = self
Self.place(window) Self.place(window)
@@ -84,11 +90,14 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
guard let screen = window.screen ?? NSScreen.main else { window.center(); return } guard let screen = window.screen ?? NSScreen.main else { window.center(); return }
let visible = screen.visibleFrame let visible = screen.visibleFrame
let size = window.frame.size let size = window.frame.size
// Ein Fingerbreit Abstand nach oben genügt: die Auslösefläche reicht // Wirklich mittig, nicht etwas unterhalb der Notch".
// 14 Punkte unter die Notch, der Titelbalken ist 28 hoch. //
let top = visible.maxY - 60 // `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, 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. /// Der rote Knopf blendet aus, statt zu schließen.

View File

@@ -64,6 +64,28 @@ public enum BatteryGlyph {
return (filled, empty) 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 { public static func isLow(charge: Double, isCharging: Bool = false) -> Bool {
// Ein roter Akku, der gerade lädt, wäre eine Warnung vor etwas, das // Ein roter Akku, der gerade lädt, wäre eine Warnung vor etwas, das
// sich schon erledigt. // sich schon erledigt.
@@ -83,6 +105,7 @@ public enum BatteryGlyph {
/// kritischem Ladestand weicht die Füllung davon ab Farbe ist in der /// kritischem Ladestand weicht die Füllung davon ab Farbe ist in der
/// Menüleiste ein Signal, keine Dekoration. /// Menüleiste ein Signal, keine Dekoration.
public static func draw(charge: Double, isCharging: Bool, isPluggedIn: Bool = false, public static func draw(charge: Double, isCharging: Bool, isPluggedIn: Bool = false,
fadePhase: Double = 0,
color: NSColor, in rect: CGRect) { color: NSColor, in rect: CGRect) {
let capWidth: CGFloat = 2 let capWidth: CGFloat = 2
let body = CGRect(x: rect.minX, y: rect.minY, let body = CGRect(x: rect.minX, y: rect.minY,
@@ -127,19 +150,26 @@ public enum BatteryGlyph {
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)
// Geteilt zeichnen: über der Füllung ein Loch, daneben ein Glyph. if isCharging {
let split = boltSplit(charge: charge, inner: inner) // Der Blitz **fadet** zwischen Weiß und Schwarz.
//
NSGraphicsContext.saveGraphicsState() // Bei wenig Ladung sitzt er im leeren Teil des Akkus und braucht
NSBezierPath(rect: split.filled).setClip() // Weiß, über der Füllung braucht er Schwarz. Ein fester Wert ist in
glyph.draw(in: glyphRect, from: .zero, operation: .destinationOut, fraction: 1) // einem der beiden Fälle immer unlesbar; ein Faden ist in beiden
NSGraphicsContext.restoreGraphicsState() // irgendwann gut zu sehen und die Bewegung zeigt zusätzlich, dass
// gerade geladen wird.
NSGraphicsContext.saveGraphicsState() let whiteness = boltWhiteness(phase: fadePhase)
NSBezierPath(rect: split.empty).setClip() let boltColor = NSColor(white: whiteness, alpha: 1)
if let tinted = tint(glyph, with: color) { if let tinted = tint(glyph, with: boltColor) {
tinted.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1) 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() NSGraphicsContext.restoreGraphicsState()
} }
} }
@@ -188,31 +218,22 @@ public struct BatteryGlyphView: View {
} }
.frame(width: (BatteryGlyph.size.width - 3) * scale, height: height) .frame(width: (BatteryGlyph.size.width - 3) * scale, height: height)
.overlay { .overlay {
// Geteilt wie in der Menüleiste: über der Füllung ein Loch, if isCharging {
// daneben ein ausgemaltes Glyph. Nur auszustanzen hieße, dass // Dasselbe Faden wie in der Menüleiste. `TimelineView` gibt
// der Blitz bei wenig Ladung im Leeren verschwindet. // die Zeit her, ohne dass hier ein Zeitgeber laufen muss.
if let name = BatteryGlyph.indicator(isCharging: isCharging, TimelineView(.animation) { context in
isPluggedIn: isPluggedIn).symbolName { let phase = context.date.timeIntervalSinceReferenceDate
GeometryReader { geometry in / BatteryGlyph.fadeDuration
let inner = CGRect(origin: .zero, size: geometry.size) Image(systemName: BatteryGlyph.Indicator.bolt.symbolName ?? "bolt.fill")
.insetBy(dx: 2 * scale, dy: 2 * scale)
let split = BatteryGlyph.boltSplit(charge: charge, inner: inner)
let glyph = Image(systemName: name)
.font(.system(size: height * 0.68)) .font(.system(size: height * 0.68))
.frame(width: geometry.size.width, height: geometry.size.height) .foregroundStyle(
Color(white: BatteryGlyph.boltWhiteness(phase: phase)))
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)
}
} }
} 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

@@ -113,6 +113,14 @@ final class MetricStatusView: NSView, WidthReporting {
@available(*, unavailable) @available(*, unavailable)
required init?(coder: NSCoder) { fatalError() } 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 adaptive = AdaptiveWidth(minimum: MenuBarText.minimumWidth)
private var shownWidth: CGFloat = MenuBarText.minimumWidth private var shownWidth: CGFloat = MenuBarText.minimumWidth
@@ -170,6 +178,8 @@ final class MetricStatusView: NSView, WidthReporting {
self.mode = mode self.mode = mode
needsDisplay = true needsDisplay = true
updateFadeTimer()
let next = adaptive.update(needed: width) let next = adaptive.update(needed: width)
guard next != shownWidth else { return } guard next != shownWidth else { return }
shownWidth = next shownWidth = next
@@ -248,10 +258,31 @@ final class MetricStatusView: NSView, WidthReporting {
return return
} }
BatteryGlyph.draw(charge: battery.charge, isCharging: battery.isCharging, 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)) 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 /// Wie breit das Glyph ist. Der Akku wird selbst gezeichnet und hat eigene
/// Maße; alles andere ist ein SF-Symbol. /// Maße; alles andere ist ein SF-Symbol.
private var symbolSlot: CGFloat { private var symbolSlot: CGFloat {

View File

@@ -22,12 +22,14 @@ public enum MenuBarText {
NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) 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 public static let symbolWidth: CGFloat = 13
/// Abstand zwischen Symbol und Zahl. /// Abstand zwischen Symbol und Zahl.
public static let innerGap: CGFloat = 2 public static let innerGap: CGFloat = 2
/// Schmaler geht nicht, ohne dass ein Glyph anstößt. /// 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 /// Die Breite eines Elements aus gemessener Textbreite. Rein, damit die
/// Rechnung prüfbar bleibt. /// Rechnung prüfbar bleibt.

View File

@@ -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)
}
}