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") }
.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)
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.

View File

@@ -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) {
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()

View File

@@ -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 {

View File

@@ -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.

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