Alle Kacheln, Popovers und Fenster auf die Designschicht umgestellt: rund hundert gesetzte Schriftgrößen und 159 gesetzte Abstände und Radien sind jetzt Token. Übrig bleibt `spacing: 0` — das ist eine Aussage, keine geratene Zahl. Die Einstellungen bekommen eine Seitenleiste statt sieben Reitern, deren deutsche Beschriftungen zusammen 880 Punkte brauchten. Panel-Token stehen nicht mehr in Fenstern: dort gilt Systemsemantik, damit der Hellmodus nicht schwarz auf schwarz endet. Neu ist ein Schaufenster, das alle Bauteile nebeneinander zeigt — ohne das lässt sich eine Änderung an einem Token nur prüfen, indem man die App bedient, bis jede Kachel einmal zu sehen war. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
328 lines
14 KiB
Swift
328 lines
14 KiB
Swift
import AppKit
|
|
import OnyxDesign
|
|
import OnyxMenuBar
|
|
|
|
/// Der Akku, gezeichnet wie der von macOS.
|
|
///
|
|
/// Warum nicht als SF-Symbol: das kennt fünf Füllstufen und — außer bei
|
|
/// hundert Prozent — keine Blitz-Variante. Der Blitz musste deshalb **neben**
|
|
/// den Akku, und das sieht nach zwei Dingen aus statt nach einem. macOS
|
|
/// zeichnet ihn mitten hinein, und der Ladestand läuft stufenlos.
|
|
///
|
|
/// Selbst zu zeichnen ist hier die kleinere Lösung als die Symbolakrobatik:
|
|
/// ein Rahmen, ein Knubbel, eine Füllung und ein ausgestanzter Blitz.
|
|
public enum BatteryGlyph {
|
|
|
|
/// Maße wie in der Menüleiste von macOS.
|
|
public static let size = CGSize(width: 25, height: 12)
|
|
/// Damit zwei Prozent nicht wie null aussehen.
|
|
public static let minimumFill: CGFloat = 2
|
|
/// Ab hier wird die Farbe zum Signal — dieselbe Schwelle wie bei macOS.
|
|
public static let lowThreshold = 0.2
|
|
|
|
public static func fillWidth(charge: Double, inner: CGFloat) -> CGFloat {
|
|
let clamped = min(max(charge, 0), 1)
|
|
guard clamped > 0 else { return 0 }
|
|
return min(max(inner * clamped, minimumFill), inner)
|
|
}
|
|
|
|
/// Was mitten im Akku steht.
|
|
public enum Indicator: CaseIterable, Sendable {
|
|
case none
|
|
/// Lädt.
|
|
case bolt
|
|
/// Am Netz, aber es fließt nichts — meist, weil das Ladelimit erreicht
|
|
/// ist. Ohne eigenes Zeichen sieht das aus wie Akkubetrieb, und man
|
|
/// fragt sich, ob das Kabel wirklich steckt.
|
|
///
|
|
/// Als Pausenzeichen und nicht als Stecker: „Laden angehalten" ist die
|
|
/// Aussage, und ein Stecker sagt nur „Kabel steckt" — das sieht man
|
|
/// ohnehin am Kabel.
|
|
case paused
|
|
|
|
public var symbolName: String? {
|
|
switch self {
|
|
case .none: nil
|
|
case .bolt: "bolt.fill"
|
|
case .paused: "pause.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
public static func indicator(isCharging: Bool, isPluggedIn: Bool) -> Indicator {
|
|
if isCharging { return .bolt }
|
|
return isPluggedIn ? .paused : .none
|
|
}
|
|
|
|
/// Wo der Blitz auf der Füllung liegt und wo im Leerraum.
|
|
///
|
|
/// Er wird über der Füllung **ausgestanzt** und daneben **ausgemalt**. Nur
|
|
/// auszustanzen reicht nicht: über dem leeren Teil ist das Transparenz auf
|
|
/// Transparenz, und bei wenig Ladung sitzt der Blitz fast ganz dort.
|
|
public static func boltSplit(charge: Double,
|
|
inner: CGRect) -> (filled: CGRect, empty: CGRect) {
|
|
let width = fillWidth(charge: charge, inner: inner.width)
|
|
let filled = CGRect(x: inner.minX, y: inner.minY,
|
|
width: width, height: inner.height)
|
|
let empty = CGRect(x: inner.minX + width, y: inner.minY,
|
|
width: inner.width - width, height: inner.height)
|
|
return (filled, empty)
|
|
}
|
|
|
|
/// Der Blitz in zwei Fassungen, hell und dunkel.
|
|
///
|
|
/// **Warum zwei Dateien statt einer eingefärbten:** eine helle Fläche mit
|
|
/// dunklem Rand ließ sich aus einem einzelnen Symbol nur nachbauen, indem
|
|
/// dasselbe Zeichen zweimal in verschiedener Größe übereinander gezeichnet
|
|
/// wurde — und die Größenverhältnisse sahen nie stimmig aus. Zwei fertige
|
|
/// Fassungen ineinander zu blenden ist der geradere Weg: gezeichnet wird
|
|
/// genau das, was in der Datei steht.
|
|
public static let boltLight: NSImage? = load("bolt-light")
|
|
public static let boltDark: NSImage? = load("bolt-dark")
|
|
|
|
private static func load(_ name: String) -> NSImage? {
|
|
guard let url = Bundle.main.url(forResource: name, withExtension: "svg")
|
|
else { return nil }
|
|
return NSImage(contentsOf: url)
|
|
}
|
|
|
|
/// Wie dick der Rand um das Zeichen ist.
|
|
public static let outlineWidth: CGFloat = 1
|
|
|
|
/// Der Rahmen für den Rand — dasselbe Zeichen, etwas größer.
|
|
public static func outlineRect(_ glyph: CGRect) -> CGRect {
|
|
glyph.insetBy(dx: -outlineWidth, dy: -outlineWidth)
|
|
}
|
|
|
|
/// Wie hoch das Zeichen im Akku ist.
|
|
///
|
|
/// Bezugsgröße ist der **Innenraum**, nicht die Außenhöhe: der Rahmen
|
|
/// nimmt oben und unten je einen Punkt, die Füllung sitzt zwei Punkte
|
|
/// eingerückt, und um das Zeichen kommt noch sein eigener Rand. Wer von
|
|
/// der Außenhöhe ausgeht, landet bei einem Blitz, der den ganzen Akku
|
|
/// ausfüllt und an den Rahmen stößt.
|
|
public static func glyphHeight(for indicator: Indicator, in height: CGFloat) -> CGFloat {
|
|
let inner = max(height - 2 * fillInset, 1)
|
|
switch indicator {
|
|
// Das Pausenzeichen ist von Haus aus höher gebaut als der Blitz.
|
|
case .paused: return inner * 0.8
|
|
default: return inner
|
|
}
|
|
}
|
|
|
|
/// Wie weit die Füllung vom Rahmen abrückt.
|
|
public static let fillInset: CGFloat = 2
|
|
|
|
/// 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.
|
|
!isCharging && charge < lowThreshold
|
|
}
|
|
|
|
/// Ein Symbol in der gewünschten Farbe. `color.set()` genügt nicht:
|
|
/// Template-Bilder tragen ihre Farbe nicht aus dem Grafikzustand.
|
|
private static func tint(_ image: NSImage, with color: NSColor) -> NSImage? {
|
|
let configuration = NSImage.SymbolConfiguration(paletteColors: [color])
|
|
return image.withSymbolConfiguration(configuration)
|
|
}
|
|
|
|
/// Zeichnet den Akku in `rect`.
|
|
///
|
|
/// - Parameter color: die Vordergrundfarbe der Menüleiste. Nur bei
|
|
/// 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,
|
|
width: rect.width - capWidth - 1, height: rect.height)
|
|
|
|
// Rahmen — zurückgenommen, damit die Füllung die Aussage trägt.
|
|
let outline = NSBezierPath(roundedRect: body.insetBy(dx: 0.5, dy: 0.5),
|
|
xRadius: 3, yRadius: 3)
|
|
outline.lineWidth = 1
|
|
color.withAlphaComponent(0.45).setStroke()
|
|
outline.stroke()
|
|
|
|
// Der Knubbel am Pluspol.
|
|
let cap = CGRect(x: body.maxX + 1, y: rect.midY - 2.5, width: capWidth, height: 5)
|
|
color.withAlphaComponent(0.45).setFill()
|
|
NSBezierPath(roundedRect: cap, xRadius: 1, yRadius: 1).fill()
|
|
|
|
// Die Füllung.
|
|
let inner = body.insetBy(dx: 2, dy: 2)
|
|
let width = fillWidth(charge: charge, inner: inner.width)
|
|
if width > 0 {
|
|
let fill = CGRect(x: inner.minX, y: inner.minY,
|
|
width: width, height: inner.height)
|
|
(isLow(charge: charge, isCharging: isCharging)
|
|
? NSColor.systemRed : color).setFill()
|
|
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
|
|
}
|
|
|
|
// Das Zeichen wird **ausgestanzt**, nicht daraufgelegt.
|
|
//
|
|
// `destinationOut` nimmt weg, was vorher gezeichnet wurde — Füllung und
|
|
// Rahmen. Übrig bleibt ein Loch in Blitz- bzw. Steckerform, durch das
|
|
// die Menüleiste scheint. Genau so sieht der von macOS aus, und es
|
|
// funktioniert auf hellem wie dunklem Grund, ohne die Hintergrundfarbe
|
|
// zu kennen.
|
|
guard let name = indicator(isCharging: isCharging,
|
|
isPluggedIn: isPluggedIn).symbolName,
|
|
let glyph = NSImage(systemSymbolName: name, accessibilityDescription: nil)
|
|
else { return }
|
|
let indicator = self.indicator(isCharging: isCharging, isPluggedIn: isPluggedIn)
|
|
let glyphHeight = glyphHeight(for: indicator, in: rect.height)
|
|
let glyphWidth = (glyph.size.width / glyph.size.height) * glyphHeight
|
|
let glyphRect = CGRect(x: body.midX - glyphWidth / 2,
|
|
y: rect.midY - glyphHeight / 2,
|
|
width: glyphWidth, height: glyphHeight)
|
|
guard isCharging else {
|
|
// Das Pausenzeichen wird **ausgestanzt**: ein Loch in der Füllung.
|
|
//
|
|
// Weiß mit Rand funktioniert hier nicht — bei fast vollem Akku
|
|
// liegt es auf weißer Füllung, das Weiß verschwindet darin und
|
|
// übrig bleiben die dunklen Ränder als zwei Striche.
|
|
NSGraphicsContext.saveGraphicsState()
|
|
glyph.draw(in: glyphRect, from: .zero, operation: .destinationOut, fraction: 1)
|
|
NSGraphicsContext.restoreGraphicsState()
|
|
return
|
|
}
|
|
|
|
// Der Blitz blendet zwischen den beiden Fassungen über.
|
|
//
|
|
// Erst die dunkle voll deckend, dann die helle mit steigender
|
|
// Deckkraft darüber. So ist das Zeichen in jedem Moment vollständig
|
|
// gedeckt — zeichnete man beide anteilig, schiene dazwischen der
|
|
// Hintergrund durch und der Blitz wirkte blass.
|
|
let whiteness = boltWhiteness(phase: fadePhase)
|
|
if let dark = boltDark, let light = boltLight {
|
|
dark.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1)
|
|
light.draw(in: glyphRect, from: .zero, operation: .sourceOver,
|
|
fraction: whiteness)
|
|
return
|
|
}
|
|
if let filled = tint(glyph, with: NSColor(white: whiteness, alpha: 1)) {
|
|
filled.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1)
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: - SwiftUI
|
|
|
|
import SwiftUI
|
|
|
|
/// Derselbe Akku für die Kachel.
|
|
///
|
|
/// Dieselbe Form wie in der Menüleiste, damit nicht zwei verschiedene Akkus
|
|
/// in derselben App stehen. Der Blitz ist hier kein Ausstanzen, sondern eine
|
|
/// Maske — in SwiftUI ist das der geradere Weg zum selben Bild.
|
|
public struct BatteryGlyphView: View {
|
|
private let charge: Double
|
|
private let isCharging: Bool
|
|
private let height: CGFloat
|
|
|
|
private let isPluggedIn: Bool
|
|
|
|
public init(charge: Double, isCharging: Bool, isPluggedIn: Bool = false,
|
|
height: CGFloat = 14) {
|
|
self.charge = charge
|
|
self.isCharging = isCharging
|
|
self.isPluggedIn = isPluggedIn
|
|
self.height = height
|
|
}
|
|
|
|
private var scale: CGFloat { height / BatteryGlyph.size.height }
|
|
private var width: CGFloat { BatteryGlyph.size.width * scale }
|
|
|
|
public var body: some View {
|
|
HStack(spacing: Onyx.Space.xxs * scale) {
|
|
ZStack(alignment: .leading) {
|
|
RoundedRectangle(cornerRadius: Onyx.Radius.bar * scale, style: .continuous)
|
|
.strokeBorder(.primary.opacity(0.45), lineWidth: 1)
|
|
|
|
GeometryReader { geometry in
|
|
let inner = geometry.size.width - 4 * scale
|
|
RoundedRectangle(cornerRadius: 1.5 * scale, style: .continuous)
|
|
.fill(fillColor)
|
|
.frame(width: BatteryGlyph.fillWidth(charge: charge, inner: inner))
|
|
.padding(2 * scale)
|
|
}
|
|
}
|
|
.frame(width: (BatteryGlyph.size.width - 3) * scale, height: height)
|
|
.overlay {
|
|
let indicator = BatteryGlyph.indicator(isCharging: isCharging,
|
|
isPluggedIn: isPluggedIn)
|
|
if let name = indicator.symbolName {
|
|
// `TimelineView` gibt die Zeit her, ohne dass hier ein
|
|
// Zeitgeber laufen muss.
|
|
let size = BatteryGlyph.glyphHeight(for: indicator, in: height)
|
|
if isCharging {
|
|
TimelineView(.animation) { context in
|
|
let phase = context.date.timeIntervalSinceReferenceDate
|
|
/ BatteryGlyph.fadeDuration
|
|
let whiteness = BatteryGlyph.boltWhiteness(phase: phase)
|
|
bolt(side: size, whiteness: whiteness)
|
|
}
|
|
} else {
|
|
Image(systemName: name)
|
|
.font(.system(size: size))
|
|
.blendMode(.destinationOut)
|
|
}
|
|
}
|
|
}
|
|
.compositingGroup()
|
|
|
|
RoundedRectangle(cornerRadius: Onyx.Radius.bar * scale, style: .continuous)
|
|
.fill(.primary.opacity(0.45))
|
|
.frame(width: 2 * scale, height: 5 * scale)
|
|
}
|
|
.frame(width: width, height: height)
|
|
}
|
|
|
|
/// Dieselben zwei Fassungen wie in der Menüleiste, ineinander geblendet.
|
|
@ViewBuilder
|
|
private func bolt(side: CGFloat, whiteness: Double) -> some View {
|
|
if let dark = BatteryGlyph.boltDark, let light = BatteryGlyph.boltLight {
|
|
ZStack {
|
|
Image(nsImage: dark).resizable().aspectRatio(contentMode: .fit)
|
|
Image(nsImage: light).resizable().aspectRatio(contentMode: .fit)
|
|
.opacity(whiteness)
|
|
}
|
|
.frame(width: side, height: side)
|
|
} else {
|
|
Image(systemName: "bolt.fill")
|
|
.font(.system(size: side))
|
|
.foregroundStyle(Color(white: whiteness))
|
|
}
|
|
}
|
|
|
|
private var fillColor: Color {
|
|
BatteryGlyph.isLow(charge: charge, isCharging: isCharging) ? .red : .primary
|
|
}
|
|
}
|