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:
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user