Kacheln und Fenster sprechen dieselbe Sprache

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>
This commit is contained in:
Scarriffle
2026-09-05 21:05:51 +02:00
co-authored by Claude Opus 5
parent 9b600de9a9
commit edb327cabe
32 changed files with 3677 additions and 968 deletions
@@ -31,10 +31,12 @@ public struct MixerView: View {
// Mixer-Schalters: sie brauchen keinen Tap, keine Berechtigung und
// keinen laufenden Mixer. Wer nur die Lautstärke sucht, soll dafür
// nichts einschalten müssen.
SectionTitle("mixer.section.system", compact: compact)
OnyxSectionTitle(String(localized: "mixer.section.system", bundle: .module))
.padding(.top, compact ? 0 : Onyx.Space.xxs)
SystemSection(system: system, compact: compact)
SectionTitle("mixer.section.apps", compact: compact)
OnyxSectionTitle(String(localized: "mixer.section.apps", bundle: .module))
.padding(.top, compact ? 0 : Onyx.Space.xxs)
if mixer.isEnabled {
if mixer.groups.isEmpty {
@@ -42,24 +44,20 @@ public struct MixerView: View {
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
.padding(.vertical, Onyx.Space.md)
} else {
// Eine Karte um die Zeilen: sie fasst zusammen, was
// zusammengehört, und trennt es vom Ausschalter darunter.
VStack(spacing: 0) {
OnyxCard {
VStack(spacing: 0) {
ForEach(Array(mixer.groups.prefix(compact ? 4 : 12).enumerated()),
id: \.element.id) { index, group in
if index > 0 { Divider().overlay(Onyx.Color.hairline) }
if index > 0 { OnyxHairline() }
ProcessRow(mixer: mixer, group: group, compact: compact)
.padding(.horizontal, 10)
.padding(.vertical, compact ? 6 : 8)
.padding(.horizontal, Onyx.Space.row)
.padding(.vertical, compact ? Onyx.Space.sm : Onyx.Space.md)
}
}
.background(Onyx.Color.elevated.opacity(0.55),
in: .rect(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
}
}
}
@@ -78,7 +76,7 @@ public struct MixerView: View {
.foregroundStyle(Onyx.Color.textSecondary)
}
.buttonStyle(.plain)
.padding(.top, 2)
.padding(.top, Onyx.Space.xxs)
} else {
OffState(mixer: mixer, compact: compact)
autostartToggle
@@ -128,10 +126,10 @@ private struct OffState: View {
let compact: Bool
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: Onyx.Space.row) {
HStack(spacing: Onyx.Space.md) {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 15, weight: .light))
.font(.system(size: Onyx.Symbol.large, weight: .light))
.foregroundStyle(Onyx.Color.textTertiary)
Text("mixer.off.explain", bundle: .module)
.font(Onyx.Font.caption)
@@ -146,15 +144,7 @@ private struct OffState: View {
.fixedSize(horizontal: false, vertical: true)
}
Button { mixer.enable() } label: {
Text("mixer.on", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.surface)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Onyx.Color.accent, in: .capsule)
}
.buttonStyle(.plain)
OnyxPillButton(String(localized: "mixer.on", bundle: .module)) { mixer.enable() }
}
}
}
@@ -174,7 +164,7 @@ private struct ProcessRow: View {
// ist weg — bei einem erkennbaren Programmsymbol ist er redundant, und
// die gewonnene Zeile macht die Liste halb so hoch. Wer ihn braucht,
// hält den Zeiger auf das Symbol.
HStack(spacing: 10) {
HStack(spacing: Onyx.Space.row) {
icon
Button {
@@ -182,7 +172,7 @@ private struct ProcessRow: View {
mixer.setMuted(!muted, for: group.bundleID)
} label: {
Image(systemName: muted ? "speaker.slash.fill" : speakerSymbol)
.font(.system(size: 11))
.font(.system(size: Onyx.Symbol.small))
.frame(width: 15, alignment: .leading)
}
.buttonStyle(.plain)
@@ -190,8 +180,8 @@ private struct ProcessRow: View {
: (managed ? Onyx.Color.textSecondary
: Onyx.Color.textTertiary))
VolumeBar(
volume: volume,
OnyxSlider(
value: volume,
maximum: Gain.maximum(overdrive: overdriven),
level: mixer.level(for: group.bundleID),
isActive: managed && !muted,
@@ -238,7 +228,7 @@ private struct ProcessRow: View {
mixer.setOverdrive(!overdriven, for: group.bundleID)
} label: {
Image(systemName: "bolt.fill")
.font(.system(size: 9, weight: .medium))
.font(.system(size: Onyx.Symbol.caption, weight: .medium))
}
.buttonStyle(.plain)
.foregroundStyle(overdriven ? Onyx.Color.warning : Onyx.Color.textTertiary.opacity(0.55))
@@ -265,7 +255,7 @@ private struct ProcessRow: View {
mixer.setVolume(1, for: group.bundleID)
} label: {
Image(systemName: "arrow.counterclockwise")
.font(.system(size: 9, weight: .medium))
.font(.system(size: Onyx.Symbol.caption, weight: .medium))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textTertiary)
@@ -304,7 +294,7 @@ private struct ProcessRow: View {
.opacity(managed ? 1 : 0.65)
} else {
Image(systemName: "app.dashed")
.font(.system(size: 15))
.font(.system(size: Onyx.Symbol.large))
.foregroundStyle(Onyx.Color.textTertiary)
}
}
@@ -326,95 +316,6 @@ private struct ProcessRow: View {
return "\(group.name) (\(group.processes.count))"
}
}
/// Der Lautstärkeregler.
///
/// Selbst gezeichnet statt `Slider`: der Systemregler bringt sein eigenes
/// Erscheinungsbild mit, das auf der dunklen Fläche wie ein Fremdkörper sitzt.
/// Und er kann nicht zeigen, was hier den Unterschied macht — den Pegel und
/// die Grenze bei 100 %.
private struct VolumeBar: View {
let volume: Double
/// Der rechte Anschlag: 1 im Normalfall, 4 mit Overdrive.
let maximum: Double
let level: Double
let isActive: Bool
let onChange: (Double) -> Void
@State private var isDragging = false
private let trackHeight: CGFloat = 6
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
let fraction = min(max(volume / maximum, 0), 1)
let knob: CGFloat = isDragging ? 13 : 11
// Der Knopf soll an den Enden nicht überstehen.
let travel = max(width - knob, 1)
let fillWidth = travel * fraction + knob / 2
ZStack(alignment: .leading) {
Capsule()
.fill(Onyx.Color.surface)
.frame(height: trackHeight)
// Markierung bei 100 %: darüber wird verstärkt, und man soll
// sehen, wann man diese Grenze überschreitet. Ohne Overdrive
// liegt sie am rechten Anschlag und sagt dort nichts mehr.
if maximum > 1 {
Rectangle()
.fill(Onyx.Color.hairline)
.frame(width: 1, height: trackHeight + 4)
.offset(x: knob / 2 + travel / maximum)
}
Capsule()
.fill(volume > 1 ? Onyx.Color.warning : Onyx.Color.accent)
.frame(width: fillWidth, height: trackHeight)
.opacity(isActive ? 1 : 0.3)
// Der Pegel läuft **im** Regler mit, nicht daneben: eine
// zweite Leiste pro Zeile wäre doppelt so viel Grafik für
// dieselbe Auskunft.
if isActive, level > 0.001 {
Capsule()
.fill(.white.opacity(0.4))
.frame(width: fillWidth * meterFraction, height: trackHeight)
.blendMode(.plusLighter)
.allowsHitTesting(false)
}
Circle()
.fill(.white)
.frame(width: knob, height: knob)
.shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5)
.offset(x: travel * fraction)
.opacity(isActive ? 1 : 0.55)
}
.frame(height: 16)
.contentShape(.rect)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
isDragging = true
let position = min(max((value.location.x - knob / 2) / travel, 0), 1)
onChange(position * maximum)
}
.onEnded { _ in isDragging = false })
}
.frame(height: 16)
.animation(Onyx.Motion.value, value: isDragging)
}
/// Ausschläge sind linear in der Amplitude und damit optisch nutzlos:
/// halb so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren
/// Bereich so weit, dass man überhaupt etwas sieht.
private var meterFraction: Double {
min(1, sqrt(min(max(level, 0), 1)))
}
}
// MARK: - Widget
public struct AudioMixerWidget: OnyxWidget {
@@ -442,24 +343,6 @@ public struct AudioMixerWidget: OnyxWidget {
// MARK: - Systemzeilen
private struct SectionTitle: View {
let key: LocalizedStringKey
let compact: Bool
init(_ key: LocalizedStringKey, compact: Bool) {
self.key = key
self.compact = compact
}
var body: some View {
Text(key, bundle: .module)
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
.textCase(.uppercase)
.padding(.top, compact ? 0 : 2)
}
}
/// Ausgabe, Eingabe und Gerätewahl.
///
/// Bewusst dieselbe Zeilenform wie bei den Programmen: Symbol, Stummschalter,
@@ -471,7 +354,8 @@ private struct SystemSection: View {
let compact: Bool
var body: some View {
VStack(spacing: 0) {
OnyxCard {
VStack(spacing: 0) {
SystemRow(
symbol: system.outputMuted ? "speaker.slash.fill" : "speaker.wave.2.fill",
title: outputName,
@@ -486,7 +370,7 @@ private struct SystemSection: View {
onMute: { system.setOutputMuted(!system.outputMuted) },
onSelect: { system.selectOutput($0) })
Divider().overlay(Onyx.Color.hairline)
OnyxHairline()
SystemRow(
symbol: system.inputMuted ? "mic.slash.fill" : "mic.fill",
@@ -504,12 +388,7 @@ private struct SystemSection: View {
onVolume: { system.setInputVolume($0) },
onMute: { system.setInputMuted(!system.inputMuted) },
onSelect: { system.selectInput($0) })
}
.background(Onyx.Color.elevated.opacity(0.55),
in: .rect(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
}
}
}
@@ -555,14 +434,14 @@ private struct SystemRow: View {
}
}
} label: {
HStack(spacing: 4) {
HStack(spacing: Onyx.Space.xs) {
Text(title)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 7))
.font(.system(size: Onyx.Symbol.caption))
.foregroundStyle(Onyx.Color.textTertiary)
Spacer(minLength: 0)
}
@@ -571,18 +450,18 @@ private struct SystemRow: View {
.menuIndicator(.hidden)
.fixedSize()
HStack(spacing: 9) {
HStack(spacing: Onyx.Space.row) {
Button(action: onMute) {
Image(systemName: symbol)
.font(.system(size: 11))
.font(.system(size: Onyx.Symbol.small))
.frame(width: 15, alignment: .leading)
}
.buttonStyle(.plain)
.foregroundStyle(muted ? Onyx.Color.critical : Onyx.Color.textSecondary)
if adjustable {
SystemVolumeBar(volume: volume, level: level,
isActive: !muted, onChange: onVolume)
OnyxSlider(value: volume, level: level,
isActive: !muted, onChange: onVolume)
if let metering { metering }
Text("\(Int(volume * 100)) %")
.font(Onyx.Font.metricSmall).monospacedDigit()
@@ -599,7 +478,7 @@ private struct SystemRow: View {
}
}
}
.padding(.horizontal, 10)
.padding(.horizontal, Onyx.Space.row)
.padding(.vertical, compact ? 7 : 9)
}
}
@@ -617,7 +496,7 @@ struct MeteringButton: View {
var body: some View {
Button(action: toggle) {
Image(systemName: isOn ? "waveform.circle.fill" : "waveform.circle")
.font(.system(size: 12))
.font(.system(size: Onyx.Symbol.medium))
.frame(width: 14, height: 14)
}
.buttonStyle(.plain)
@@ -625,68 +504,3 @@ struct MeteringButton: View {
.help(Text(isOn ? "mixer.input.meterOff" : "mixer.input.meterOn", bundle: .module))
}
}
/// Wie der Programmregler, nur ohne Verstärkung und ohne Pegel: die
/// Systemlautstärke geht von null bis hundert, mehr gibt Core Audio nicht her.
private struct SystemVolumeBar: View {
let volume: Double
let level: Double
let isActive: Bool
let onChange: (Double) -> Void
@State private var isDragging = false
var body: some View {
GeometryReader { geometry in
let knob: CGFloat = isDragging ? 13 : 11
let travel = max(geometry.size.width - knob, 1)
let fraction = min(max(volume, 0), 1)
ZStack(alignment: .leading) {
Capsule().fill(Onyx.Color.surface).frame(height: 6)
let fillWidth = travel * fraction + knob / 2
Capsule()
.fill(Onyx.Color.accent)
.frame(width: fillWidth, height: 6)
.opacity(isActive ? 1 : 0.3)
// Derselbe mitlaufende Pegel wie in den Programmzeilen. Er
// sitzt **im** Regler, weil eine zweite Leiste daneben doppelt
// so viel Grafik für dieselbe Auskunft wäre.
if isActive, level > 0.001 {
Capsule()
.fill(.white.opacity(0.4))
.frame(width: fillWidth * meterFraction, height: 6)
.blendMode(.plusLighter)
.allowsHitTesting(false)
}
Circle()
.fill(.white)
.frame(width: knob, height: knob)
.shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5)
.offset(x: travel * fraction)
.opacity(isActive ? 1 : 0.55)
}
.frame(height: 16)
.contentShape(.rect)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
isDragging = true
onChange(min(max((value.location.x - knob / 2) / travel, 0), 1))
}
.onEnded { _ in isDragging = false })
}
.frame(height: 16)
.animation(Onyx.Motion.value, value: isDragging)
}
/// Ausschläge sind linear in der Amplitude und damit optisch nutzlos:
/// halb so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren
/// Bereich so weit, dass man überhaupt etwas sieht.
private var meterFraction: Double {
min(1, sqrt(min(max(level, 0), 1)))
}
}
@@ -56,6 +56,15 @@ final class ProcessTap {
private var procID: AudioDeviceIOProcID?
private let tapUUID = UUID()
/// Der erste Eingangspuffer, der dem Tap gehört.
///
/// **Nicht immer null.** Bringt das Ausgabegerät selbst einen Eingang mit —
/// jedes Headset tut das, jedes USB-Interface auch —, dann reiht das
/// Aggregate-Gerät dessen Puffer vor die des Taps. Wird in `start()`
/// bestimmt und im Echtzeitpfad nur gelesen; geschrieben wird ausschließlich
/// vor `AudioDeviceStart` und nach `AudioDeviceStop`, also nie nebenläufig.
private var tapBufferOffset = 0
/// Wann zuletzt ein Puffer mit Inhalt kam — für den Wachhund.
private(set) var isRunning = false
@@ -131,6 +140,24 @@ final class ProcessTap {
}
aggregateID = device
// Wo der Tap im Eingang liegt, steht erst fest, wenn das Gerät gebaut
// ist. Danach fragen, vorher nichts starten: liegt er nicht dort, wo
// wir ihn erwarten, wäre die Folge Stille bei stummgeschaltetem
// Original — genau der Fehler, der am Headset auftrat.
guard let subDeviceID = Self.device(forUID: outputDeviceUID),
let subDeviceInputs = Self.inputChannelCounts(subDeviceID),
let aggregateInputs = Self.inputChannelCounts(aggregateID),
let tapChannels = Self.tapChannels(tapID),
let offset = TapBufferLayout.offset(
subDeviceInputBuffers: subDeviceInputs.count,
aggregateInputChannels: aggregateInputs,
tapChannels: tapChannels)
else {
cleanUp()
throw AudioMixerError.unexpectedLayout
}
tapBufferOffset = offset
var proc: AudioDeviceIOProcID?
let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&proc, aggregateID, nil) {
[weak self] _, inputData, _, outputData, _ in
@@ -149,7 +176,7 @@ final class ProcessTap {
isRunning = true
lastAudioUptime.store(UInt64(Self.uptime() * 1000), ordering: .relaxed)
log.notice("Tap aktiv für \(self.process.name, privacy: .public)")
log.notice("Tap aktiv für \(self.process.name, privacy: .public), Eingangspuffer \(self.tapBufferOffset, privacy: .public)")
}
// MARK: - Echtzeitpfad
@@ -166,11 +193,14 @@ final class ProcessTap {
var peak: Float = 0
for (index, outputBuffer) in outputList.enumerated() {
guard index < inputList.count,
let source = inputList[index].mData,
// Der Ausgang zählt ab null, der Eingang ab dem Puffer des Taps.
// Ohne den Versatz stünde hier bei einem Headset das Mikrofon.
let sourceIndex = tapBufferOffset + index
guard sourceIndex < inputList.count,
let source = inputList[sourceIndex].mData,
let destination = outputBuffer.mData else { continue }
let count = Int(min(inputList[index].mDataByteSize, outputBuffer.mDataByteSize))
let count = Int(min(inputList[sourceIndex].mDataByteSize, outputBuffer.mDataByteSize))
/ MemoryLayout<Float>.size
let inputSamples = source.assumingMemoryBound(to: Float.self)
let outputSamples = destination.assumingMemoryBound(to: Float.self)
@@ -224,6 +254,89 @@ final class ProcessTap {
AudioHardwareDestroyProcessTap(tapID)
tapID = AudioObjectID(kAudioObjectUnknown)
}
tapBufferOffset = 0
}
// MARK: - Core Audio
private static func device(forUID uid: String) -> AudioObjectID? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyTranslateUIDToDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var qualifier = uid as CFString
var device = AudioObjectID(kAudioObjectUnknown)
var size = UInt32(MemoryLayout<AudioObjectID>.size)
let status = withUnsafeMutablePointer(to: &qualifier) {
AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address,
UInt32(MemoryLayout<CFString>.size), $0, &size, &device)
}
guard status == noErr, device != AudioObjectID(kAudioObjectUnknown) else { return nil }
return device
}
/// Die Kanäle je Eingangspuffer eines Geräts. Leer heißt: kein Eingang —
/// das ist ein gültiges Ergebnis, `nil` heißt, es ließ sich nicht lesen.
private static func inputChannelCounts(_ device: AudioObjectID) -> [UInt32]? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: kAudioObjectPropertyScopeInput,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr
else { return nil }
guard size > 0 else { return [] }
let raw = UnsafeMutableRawPointer.allocate(
byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
defer { raw.deallocate() }
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, raw) == noErr
else { return nil }
let list = UnsafeMutableAudioBufferListPointer(
raw.assumingMemoryBound(to: AudioBufferList.self))
return list.map(\.mNumberChannels)
}
private static func tapChannels(_ tap: AudioObjectID) -> UInt32? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioTapPropertyFormat,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var format = AudioStreamBasicDescription()
var size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size)
guard AudioObjectGetPropertyData(tap, &address, 0, nil, &size, &format) == noErr
else { return nil }
return format.mChannelsPerFrame
}
}
/// Wo im Eingang eines Aggregate-Geräts die Puffer des Taps liegen.
///
/// Ein Aggregate-Gerät reiht seine Eingangspuffer in fester Ordnung: erst die
/// des Untergeräts, dann die des Taps. Bei den eingebauten Lautsprechern gibt
/// es keine eigenen — dort liegt der Tap an null, und genau daran hat sich der
/// erste Entwurf gewöhnt. Ein Headset bringt sein Mikrofon mit und schiebt den
/// Tap eine Stelle weiter.
///
/// Als reine Funktion, weil sie sonst nur mit angestecktem Headset prüfbar wäre.
enum TapBufferLayout {
/// - Returns: der Index des ersten Tap-Puffers, oder `nil`, wenn die
/// Aufteilung nicht der erwarteten entspricht. Dann wird **nicht geraten**:
/// ein falsch geratener Puffer heißt Stille bei stummgeschaltetem
/// Original, ein abgewiesener Tap heißt nur, dass dieses Programm
/// ungeregelt bleibt — hörbar.
static func offset(subDeviceInputBuffers: Int,
aggregateInputChannels: [UInt32],
tapChannels: UInt32) -> Int? {
guard subDeviceInputBuffers >= 0, tapChannels > 0 else { return nil }
guard subDeviceInputBuffers < aggregateInputChannels.count else { return nil }
// Die Gegenprobe: an der erwarteten Stelle muss das Format des Taps
// stehen. Läge die Ordnung anders, fiele das hier auf, statt still zu
// bleiben.
guard aggregateInputChannels[subDeviceInputBuffers] == tapChannels else { return nil }
return subDeviceInputBuffers
}
}
@@ -233,6 +346,7 @@ public enum AudioMixerError: Error, LocalizedError {
case ioProcFailed(OSStatus)
case startFailed
case noOutputDevice
case unexpectedLayout
public var errorDescription: String? {
switch self {
@@ -246,6 +360,8 @@ public enum AudioMixerError: Error, LocalizedError {
"Das Mischgerät startet nicht."
case .noOutputDevice:
"Kein Ausgabegerät gefunden."
case .unexpectedLayout:
"Das Mischgerät reicht den Ton an unerwarteter Stelle durch."
}
}
}
@@ -44,6 +44,9 @@ public final class SystemAudio {
public private(set) var inputIsAdjustable = false
private var listeners: [PropertyListener] = []
/// Zuhörer, die am gewählten Gerät hängen und beim Wechsel neu gesetzt
/// werden müssen.
private var deviceListeners: [PropertyListener] = []
/// Die Pegel von Ausgabe und Eingabe.
///
@@ -59,6 +62,9 @@ public final class SystemAudio {
// MARK: - Lesen
public func refresh() {
let previousOutput = outputDeviceID
let previousInput = inputDeviceID
outputDevices = Self.devices().filter(\.isOutput)
inputDevices = Self.devices().filter(\.isInput)
@@ -79,6 +85,13 @@ public final class SystemAudio {
alertVolume = alertDevice == outputDeviceID
? nil // Dasselbe Gerät, derselbe Regler — keine zweite Zeile dafür.
: Self.volume(alertDevice, scope: kAudioDevicePropertyScopeOutput)
// Nur bei einem echten Wechsel neu anmelden. Ein Abmelden mitten im
// Rückruf, der gerade läuft, ist eine Einladung zum Absturz.
if outputDeviceID != previousOutput || inputDeviceID != previousInput
|| deviceListeners.isEmpty {
observeCurrentDevices()
}
}
// MARK: - Setzen
@@ -135,6 +148,48 @@ public final class SystemAudio {
}
}
/// Zuhörer an Lautstärke und Stummschaltung der **gerade gewählten** Geräte.
///
/// **Das fehlte.** Beobachtet wurden nur Gerätewechsel; wer die Lautstärke
/// per Tastatur änderte, bekam davon nichts mit — der Regler im Mixer stand
/// dann auf einem Wert, den es nicht mehr gab, bis irgendetwas anderes ein
/// `refresh()` auslöste.
///
/// Die Zuhörer hängen am Gerät, nicht am Systemobjekt, und müssen deshalb
/// bei jedem Gerätewechsel neu gesetzt werden.
private func observeCurrentDevices() {
deviceListeners = []
for (device, scope) in [(outputDeviceID, kAudioDevicePropertyScopeOutput),
(inputDeviceID, kAudioDevicePropertyScopeInput)] {
guard device != AudioObjectID(kAudioObjectUnknown) else { continue }
for selector in [kAudioDevicePropertyVolumeScalar, kAudioDevicePropertyMute] {
// Manche Geräte führen keine Hauptlautstärke, sondern nur
// Kanäle. Dort scheitert das Anmelden — das ist kein Fehler,
// dort gibt es schlicht nichts zu hören.
if let listener = PropertyListener(object: device,
selector: selector,
scope: scope,
handler: { [weak self] in
Task { @MainActor in self?.readLevels() }
}) {
deviceListeners.append(listener)
}
}
}
}
/// Nur die Pegel und Schalter neu lesen — ohne Geräteliste.
///
/// Getrennt von `refresh()`, weil dieser Weg aus einem Rückruf kommt, der
/// **während** einer Änderung feuert: dort auch noch die Geräte neu
/// aufzuzählen wäre Arbeit für nichts.
private func readLevels() {
outputVolume = Self.volume(outputDeviceID, scope: kAudioDevicePropertyScopeOutput) ?? 0
outputMuted = Self.muted(outputDeviceID, scope: kAudioDevicePropertyScopeOutput)
inputVolume = Self.volume(inputDeviceID, scope: kAudioDevicePropertyScopeInput) ?? 0
inputMuted = Self.muted(inputDeviceID, scope: kAudioDevicePropertyScopeInput)
}
private static func devices() -> [AudioDevice] {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
@@ -325,14 +380,23 @@ public final class SystemAudio {
/// geschrieben.
private final class PropertyListener: @unchecked Sendable {
private let object = AudioObjectID(kAudioObjectSystemObject)
private let object: AudioObjectID
private var address: AudioObjectPropertyAddress
private let block: AudioObjectPropertyListenerBlock
init?(selector: AudioObjectPropertySelector, handler: @escaping @Sendable () -> Void) {
/// - Parameters:
/// - object: Standardmäßig das Systemobjekt. Für Lautstärke und
/// Stummschaltung muss es das **Gerät** sein — die sind keine
/// Eigenschaften der Hardware im Allgemeinen, sondern dieser Kiste.
/// - scope: Ein- oder Ausgabe. Global genügt nur bei Geräteeigenschaften.
init?(object: AudioObjectID = AudioObjectID(kAudioObjectSystemObject),
selector: AudioObjectPropertySelector,
scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal,
handler: @escaping @Sendable () -> Void) {
self.object = object
address = AudioObjectPropertyAddress(
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mScope: scope,
mElement: kAudioObjectPropertyElementMain)
block = { _, _ in handler() }
guard AudioObjectAddPropertyListenerBlock(object, &address, .main, block) == noErr
@@ -32,7 +32,7 @@ public struct CalendarSourceSettings: View {
if model.kind != .appleCalendar {
Section {
LabeledContent {
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
statusText
Text("calendar.source.readOnly", bundle: .module)
.font(.caption)
@@ -53,7 +53,7 @@ public struct CalendarSourceSettings: View {
private var statusText: some View {
switch calendarrState {
case .ready(let window):
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
Text("calendar.source.status.ready \(window.events.count.formatted()) \(asOfText(window.asOf))",
bundle: .module)
if let coverage = window.coverage {
@@ -45,6 +45,18 @@ public final class CalendarModel {
/// Die Kalender, die zur Auswahl stehen.
public private(set) var availableCalendars: [OnyxCalendar] = []
/// Der Zustand der Calendarr-Quelle **allein**, für die Einstellungen.
///
/// Getrennt von `state`, weil das bei „beide" auch Apple-Termine enthält —
/// eine Zahl daraus sagt über Calendarr nichts.
///
/// Und getrennt geführt statt in den Einstellungen nachgeladen: dort stand
/// bisher ein zweiter, eigener Aufruf von `CalendarrSource`, also immer der
/// **Schnappschuss** — auch wenn längst der Server gefragt wurde. Der
/// Zeitstempel daneben war deshalb das Alter einer Datei, die niemand mehr
/// liest, und sah aus wie ein Kalender, der seit Wochen stillsteht.
public private(set) var calendarrState: CalendarSourceState?
private static let kindKey = "calendar.source"
private static let hiddenKey = "calendar.hiddenCalendars"
private static let gridKey = "calendar.showsMonthGrid"
@@ -194,6 +206,7 @@ public final class CalendarModel {
let interval = DateInterval(start: start, end: end)
var states: [CalendarSourceState] = []
let calendarr = kind == .appleCalendar ? nil : calendarrChoice as AnyObject
for source in activeSources {
guard !Task.isCancelled else { return }
// Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer
@@ -201,7 +214,9 @@ public final class CalendarModel {
// passiert — die Anfrage muss aus dem Einstellungsfenster kommen.
// Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht
// eine leere Terminliste, die "nichts mehr heute" behaupten würde.
states.append(source.isAuthorized ? await source.load(interval) : .denied)
let loaded = source.isAuthorized ? await source.load(interval) : .denied
if let calendarr, source as AnyObject === calendarr { calendarrState = loaded }
states.append(loaded)
}
guard !Task.isCancelled else { return }
state = CalendarSourceKind.merge(states).filteringHidden(hiddenCalendarIDs)
@@ -266,41 +281,26 @@ private struct CalendarWidgetView: View {
AgendaList(events: model.upcoming, coverage: window.coverage)
}
case .denied:
CalendarNotice(symbol: "lock", text: "widget.calendar.denied")
OnyxNotice(symbol: "lock",
text: String(localized: "widget.calendar.denied", bundle: .module))
case .neverWritten:
CalendarNotice(symbol: "clock.arrow.circlepath", text: "widget.calendar.neverWritten")
OnyxNotice(symbol: "clock.arrow.circlepath",
text: String(localized: "widget.calendar.neverWritten", bundle: .module))
case .loggedOut:
CalendarNotice(symbol: "person.crop.circle.badge.xmark", text: "widget.calendar.loggedOut")
OnyxNotice(symbol: "person.crop.circle.badge.xmark",
text: String(localized: "widget.calendar.loggedOut", bundle: .module))
case .incompatible:
CalendarNotice(symbol: "exclamationmark.triangle", text: "widget.calendar.incompatible")
OnyxNotice(symbol: "exclamationmark.triangle",
text: String(localized: "widget.calendar.incompatible", bundle: .module))
case .unreadable:
CalendarNotice(symbol: "questionmark.folder", text: "widget.calendar.unreadable")
OnyxNotice(symbol: "questionmark.folder",
text: String(localized: "widget.calendar.unreadable", bundle: .module))
}
}
.task { model.refresh() }
.onDisappear { model.stop() }
}
}
/// Kein Platzhalter, sondern eine Antwort: jeder Zustand sagt, was zu tun ist.
private struct CalendarNotice: View {
let symbol: String
let text: LocalizedStringKey
var body: some View {
VStack(spacing: 6) {
Image(systemName: symbol)
.font(.system(size: 16))
.foregroundStyle(Onyx.Color.textTertiary)
Text(text, bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: - Mini-Monat
private struct LargeCalendar: View {
@@ -310,9 +310,9 @@ private struct LargeCalendar: View {
private var calendar: Calendar { .autoupdatingCurrent }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
MiniMonth(window: window)
Divider().overlay(Onyx.Color.hairline)
OnyxHairline()
AgendaList(events: Array(upcoming.prefix(3)), coverage: window.coverage)
}
}
@@ -328,11 +328,11 @@ private struct MiniMonth: View {
let busy = window.events.busyDays(calendar: calendar)
let today = calendar.startOfDay(for: Date())
VStack(spacing: 3) {
VStack(spacing: Onyx.Space.xs) {
HStack(spacing: 0) {
ForEach(weekdaySymbols, id: \.self) { symbol in
Text(symbol)
.font(.system(size: 8, weight: .medium))
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(maxWidth: .infinity)
}
@@ -370,7 +370,7 @@ private struct DayCell: View {
Circle().fill(Onyx.Color.accent.opacity(0.9)).frame(width: 15, height: 15)
}
Text("\(Calendar.autoupdatingCurrent.component(.day, from: day.date))")
.font(.system(size: 9, weight: isToday ? .semibold : .regular))
.font(Onyx.Font.micro.weight(isToday ? .semibold : .regular))
.foregroundStyle(textColor)
}
.frame(maxWidth: .infinity)
@@ -413,10 +413,10 @@ private struct AgendaList: View {
} else {
// Nach Tagen getrennt: „8:00" allein sagt nicht, an welchem.
let days = AgendaGrouping.group(events)
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
ForEach(Array(days.enumerated()), id: \.element.id) { index, day in
Text(AgendaGrouping.label(for: day.date))
.font(.system(size: 9, weight: .semibold))
.font(Onyx.Font.micro.weight(.semibold))
.foregroundStyle(Onyx.Color.textSecondary)
.textCase(.uppercase)
// Luft nur zwischen den Tagen, nicht über dem ersten.
@@ -435,18 +435,18 @@ private struct EventRow: View {
let event: OnyxEvent
var body: some View {
HStack(spacing: 6) {
HStack(spacing: Onyx.Space.sm) {
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
.fill(Color(hex: event.colorHex) ?? Onyx.Color.accent)
.frame(width: 3)
VStack(alignment: .leading, spacing: 1) {
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
Text(event.title)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
Text(timeText)
.font(.system(size: 9))
.font(Onyx.Font.micro)
.monospacedDigit()
.foregroundStyle(Onyx.Color.textTertiary)
.lineLimit(1)
@@ -41,7 +41,7 @@ private struct Idle: View {
let model: MediaModel
var body: some View {
VStack(spacing: 8) {
VStack(spacing: Onyx.Space.md) {
Text("widget.media.nothing", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textTertiary)
@@ -59,12 +59,12 @@ private struct Playing: View {
// Übereinander, nicht nebeneinander. Die Kachel ist hochkant: neben
// einem Cover blieben für den Titel noch gut hundert Punkte, und dann
// steht dort „Bernhard H…" statt eines Titels.
VStack(alignment: .leading, spacing: 7) {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
Artwork(data: state.artwork)
.frame(maxWidth: .infinity)
.frame(height: 88)
VStack(alignment: .leading, spacing: 1) {
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
Text(state.title)
.font(Onyx.Font.caption.weight(.medium))
.foregroundStyle(Onyx.Color.textPrimary)
@@ -91,7 +91,7 @@ private struct Playing: View {
// greift: dann fehlen Cover und Browser-Wiedergaben,
// und das soll erklärbar sein statt rätselhaft.
Image(systemName: "applescript")
.font(.system(size: 9))
.font(.system(size: Onyx.Symbol.caption))
.foregroundStyle(Onyx.Color.textTertiary)
.help(Text("widget.media.viaAppleScript", bundle: .module))
}
@@ -107,7 +107,7 @@ private struct Artwork: View {
let data: Data?
var body: some View {
let shape = RoundedRectangle(cornerRadius: 8, style: .continuous)
let shape = RoundedRectangle(cornerRadius: Onyx.Radius.control, style: .continuous)
// Reihenfolge zählt: erst der Rahmen, dann das Beschneiden. Andersherum
// beschneidet man das Bild auf seine eigene, viel größere Größe — und es
@@ -122,7 +122,7 @@ private struct Artwork: View {
shape.fill(Onyx.Color.elevated)
.overlay {
Image(systemName: "music.note")
.font(.system(size: 18))
.font(.system(size: Onyx.Symbol.xlarge))
.foregroundStyle(Onyx.Color.textTertiary)
}
}
@@ -143,7 +143,7 @@ private struct Scrubber: View {
let now = model.tick
let progress = state.progress(at: now)
VStack(spacing: 2) {
VStack(spacing: Onyx.Space.xxs) {
GeometryReader { geometry in
ZStack(alignment: .leading) {
Capsule().fill(Onyx.Color.hairline)
@@ -166,7 +166,7 @@ private struct Scrubber: View {
Spacer(minLength: 0)
Text(format(state.duration ?? 0))
}
.font(.system(size: 8))
.font(Onyx.Font.micro)
.monospacedDigit()
.foregroundStyle(Onyx.Color.textTertiary)
}
@@ -185,7 +185,7 @@ private struct TransportControls: View {
let size: CGFloat
var body: some View {
HStack(spacing: 12) {
HStack(spacing: Onyx.Space.lg) {
button("backward.fill") { model.send(.previous) }
button(model.nowPlaying?.isPlaying == true ? "pause.fill" : "play.fill") {
model.send(.playPause)
@@ -1,4 +1,5 @@
import AppKit
import OnyxDesign
import OnyxMenuBar
/// Der Akku, gezeichnet wie der von macOS.
@@ -259,9 +260,9 @@ public struct BatteryGlyphView: View {
private var width: CGFloat { BatteryGlyph.size.width * scale }
public var body: some View {
HStack(spacing: 1 * scale) {
HStack(spacing: Onyx.Space.xxs * scale) {
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 3 * scale, style: .continuous)
RoundedRectangle(cornerRadius: Onyx.Radius.bar * scale, style: .continuous)
.strokeBorder(.primary.opacity(0.45), lineWidth: 1)
GeometryReader { geometry in
@@ -296,7 +297,7 @@ public struct BatteryGlyphView: View {
}
.compositingGroup()
RoundedRectangle(cornerRadius: 1 * scale, style: .continuous)
RoundedRectangle(cornerRadius: Onyx.Radius.bar * scale, style: .continuous)
.fill(.primary.opacity(0.45))
.frame(width: 2 * scale, height: 5 * scale)
}
@@ -34,7 +34,7 @@ public struct ChargeLimitControl: View {
}
public var body: some View {
HStack(spacing: 6) {
HStack(spacing: Onyx.Space.sm) {
Text("charge.limit", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
@@ -62,7 +62,7 @@ public struct ChargeLimitControl: View {
URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension")!)
} label: {
Image(systemName: "arrow.up.forward.app")
.font(.system(size: 10))
.font(.system(size: Onyx.Symbol.small))
.foregroundStyle(Onyx.Color.textTertiary)
}
.buttonStyle(.plain)
@@ -12,7 +12,7 @@ struct MetricPopoverContent: View {
let metric: MetricKind
var body: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
// Ein Titel oben: bei drei gleich aussehenden Ringen sieht man
// sonst nicht, ob man vor dem CPU- oder dem GPU-Popover steht.
Text(String(localized: .init(metric.localizationKey), bundle: .module))
@@ -25,7 +25,7 @@ struct MetricPopoverContent: View {
case .temperature: SensorDetail(snapshot: model.snapshot)
}
}
.padding(14)
.padding(Onyx.Space.xl)
.frame(width: 280)
}
}
@@ -34,17 +34,6 @@ struct MetricPopoverContent: View {
/// Überschrift eines Abschnitts. Ohne sie ist eine lange Werteliste nur eine
/// lange Werteliste.
private struct SectionTitle: View {
let text: LocalizedStringKey
var body: some View {
Text(text, bundle: .module)
.font(.caption.weight(.semibold))
.foregroundStyle(Onyx.Color.accent)
.textCase(.uppercase)
}
}
private struct Row: View {
let label: String
let value: String
@@ -88,7 +77,7 @@ private struct Dial: View {
var body: some View {
// Sechs Punkte Abstand statt zwei: die Beschriftung klebte am Ring und
// sah aus, als gehörte sie noch hinein.
VStack(spacing: 6) {
VStack(spacing: Onyx.Space.sm) {
ZStack {
Circle().stroke(.quaternary, lineWidth: 5)
Circle()
@@ -96,13 +85,13 @@ private struct Dial: View {
.stroke(tint ?? Onyx.Color.accent,
style: .init(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
Text(text).font(.system(size: 15, weight: .medium).monospacedDigit())
Text(text).font(Onyx.Font.headline.monospacedDigit())
}
.frame(width: 62, height: 62)
.alignmentGuide(.dialCenter) { $0[VerticalAlignment.center] }
Text(caption).font(.caption2).foregroundStyle(.secondary)
}
.padding(.bottom, 2)
.padding(.bottom, Onyx.Space.xxs)
}
}
@@ -115,7 +104,7 @@ private struct ProcessorDetail: View {
var body: some View {
let snapshot = model.snapshot
HStack(spacing: 14) {
HStack(spacing: Onyx.Space.xl) {
Dial(value: metric == .cpu ? snapshot.cpu.total : (snapshot.gpu ?? 0),
caption: metric == .cpu ? "CPU" : "GPU",
text: MetricFormat.percent(metric == .cpu ? snapshot.cpu.total
@@ -135,11 +124,11 @@ private struct ProcessorDetail: View {
.frame(maxWidth: .infinity)
if metric == .cpu, !model.snapshot.cpu.perCore.isEmpty {
SectionTitle(text: "popover.perCore")
OnyxPopoverTitle(String(localized: "popover.perCore", bundle: .module))
CoreGrid(values: model.snapshot.cpu.perCore)
}
SectionTitle(text: "popover.power")
OnyxPopoverTitle(String(localized: "popover.power", bundle: .module))
if let cpu = snapshot.power.cpu {
Row(label: String(localized: "popover.power.cpu", bundle: .module),
value: MetricFormat.watts(cpu))
@@ -154,7 +143,7 @@ private struct ProcessorDetail: View {
}
if !snapshot.fans.isEmpty {
SectionTitle(text: "popover.fans")
OnyxPopoverTitle(String(localized: "popover.fans", bundle: .module))
ForEach(snapshot.fans) { fan in
Row(label: fan.name, value: "\(Int(fan.rpm)) U/min")
}
@@ -181,13 +170,13 @@ private struct CoreGrid: View {
let values: [Double]
var body: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 3), count: 8),
spacing: 3) {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: Onyx.Space.xs), count: 8),
spacing: Onyx.Space.xs) {
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
RoundedRectangle(cornerRadius: 2, style: .continuous)
RoundedRectangle(cornerRadius: Onyx.Radius.bar, style: .continuous)
.fill(Onyx.Color.accent.opacity(0.18))
.overlay(alignment: .bottom) {
RoundedRectangle(cornerRadius: 2, style: .continuous)
RoundedRectangle(cornerRadius: Onyx.Radius.bar, style: .continuous)
.fill(Onyx.Color.accent)
.frame(height: max(14 * value, 1.5))
}
@@ -205,12 +194,12 @@ private struct MemoryDetail: View {
var body: some View {
let memory = snapshot.memory
HStack(spacing: 14) {
HStack(spacing: Onyx.Space.xl) {
Dial(value: memory.usedFraction,
caption: String(localized: "popover.memory.used", bundle: .module),
text: MetricFormat.percent(memory.usedFraction),
tint: pressureColor)
VStack(alignment: .leading, spacing: 3) {
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
legend(Onyx.Color.accent, "popover.memory.app", memory.app)
legend(.purple, "popover.memory.wired", memory.wired)
legend(.teal, "popover.memory.compressed", memory.compressed)
@@ -218,7 +207,7 @@ private struct MemoryDetail: View {
}
}
SectionTitle(text: "popover.memory.swap")
OnyxPopoverTitle(String(localized: "popover.memory.swap", bundle: .module))
// Auslagerung ist der Wert, der zählt: solange nicht ausgelagert wird,
// ist ein voller Speicher kein Problem — macOS füllt ihn absichtlich.
Row(label: String(localized: "popover.memory.swapUsed", bundle: .module),
@@ -226,7 +215,7 @@ private struct MemoryDetail: View {
emphasis: memory.swapUsed > 512 * 1024 * 1024 ? Onyx.Color.warning : nil)
if !snapshot.topMemoryProcesses.isEmpty {
SectionTitle(text: "popover.processes")
OnyxPopoverTitle(String(localized: "popover.processes", bundle: .module))
ForEach(snapshot.topMemoryProcesses) { process in
Row(label: process.name, value: bytes(process.bytes))
}
@@ -242,7 +231,7 @@ private struct MemoryDetail: View {
}
private func legend(_ color: Color, _ key: LocalizedStringKey, _ value: UInt64) -> some View {
HStack(spacing: 5) {
HStack(spacing: Onyx.Space.sm) {
Circle().fill(color).frame(width: 7, height: 7)
Text(key, bundle: .module).font(.caption)
Spacer(minLength: 8)
@@ -263,7 +252,7 @@ private struct BatteryDetail: View {
var body: some View {
if let battery = snapshot.battery {
HStack(alignment: .dialCenter, spacing: 14) {
HStack(alignment: .dialCenter, spacing: Onyx.Space.xl) {
Dial(value: battery.charge,
caption: String(localized: "metric.battery", bundle: .module),
text: MetricFormat.percent(battery.charge),
@@ -275,7 +264,7 @@ private struct BatteryDetail: View {
// darunter waren zweimal dieselbe Auskunft an zwei Stellen —
// und die zweite stand allein unter dem Diagramm, wo sie
// aussah, als gehörte sie zu dessen Beschriftung.
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
if let remaining = battery.timeRemaining {
Text(duration(remaining)).font(.title3.monospacedDigit())
Text(battery.isCharging ? "popover.battery.untilFull"
@@ -302,11 +291,11 @@ private struct BatteryDetail: View {
// eine Akkusache, und man greift danach, während man auf den
// Ladestand schaut.
if let control {
Divider().overlay(Onyx.Color.hairline)
OnyxHairline()
ChargeLimitControl(control: control, roomy: true)
}
SectionTitle(text: "popover.battery.condition")
OnyxPopoverTitle(String(localized: "popover.battery.condition", bundle: .module))
if let health = battery.health {
Row(label: String(localized: "popover.battery.health", bundle: .module),
value: MetricFormat.percent(health))
@@ -320,7 +309,7 @@ private struct BatteryDetail: View {
value: MetricFormat.temperature(sensor.celsius))
}
SectionTitle(text: "popover.power")
OnyxPopoverTitle(String(localized: "popover.power", bundle: .module))
if let watts = battery.watts {
Row(label: String(localized: battery.isCharging ? "popover.battery.charging"
: "popover.battery.draining",
@@ -354,7 +343,7 @@ private struct BatteryDetail: View {
isPluggedIn: battery.isPluggedIn,
charge: battery.charge,
limit: control?.chargeLimit)
HStack(spacing: 6) {
HStack(spacing: Onyx.Space.sm) {
Image(systemName: symbol(for: activity))
.font(.caption)
.foregroundStyle(tint(for: activity))
@@ -401,7 +390,7 @@ private struct SensorDetail: View {
let snapshot: MetricsSnapshot
var body: some View {
SectionTitle(text: "popover.temperature")
OnyxPopoverTitle(String(localized: "popover.temperature", bundle: .module))
ForEach(snapshot.sensors) { sensor in
HStack {
Text(sensor.name).font(.callout)
@@ -421,9 +410,9 @@ private struct SensorDetail: View {
}
if !snapshot.fans.isEmpty {
SectionTitle(text: "popover.fans")
OnyxPopoverTitle(String(localized: "popover.fans", bundle: .module))
ForEach(snapshot.fans) { fan in
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
HStack {
Text(fan.name).font(.callout)
Spacer(minLength: 12)
@@ -8,7 +8,7 @@ struct NetworkPopoverContent: View {
let model: NetworkModel
var body: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
HStack(alignment: .top) {
rate(model.snapshot.downloadRate, "network.download", Onyx.Color.download)
Spacer()
@@ -43,17 +43,17 @@ struct NetworkPopoverContent: View {
.foregroundStyle(.secondary)
}
section("network.addresses")
OnyxPopoverTitle(String(localized: "network.addresses", bundle: .module))
field("network.field.interface", model.snapshot.interfaceName)
field("network.field.ipv4", model.snapshot.ipv4)
field("network.field.ipv6", model.snapshot.ipv6)
field("network.field.router", model.snapshot.router)
section("network.transferred")
OnyxPopoverTitle(String(localized: "network.transferred", bundle: .module))
field("network.total.received", bytes(model.snapshot.totalReceived))
field("network.total.sent", bytes(model.snapshot.totalSent))
section("network.public")
OnyxPopoverTitle(String(localized: "network.public", bundle: .module))
if model.showsPublicAddress {
HStack {
if model.publicAddress.isLoading {
@@ -86,7 +86,7 @@ struct NetworkPopoverContent: View {
}
if !model.processes.isEmpty {
section("network.processes")
OnyxPopoverTitle(String(localized: "network.processes", bundle: .module))
ForEach(model.processes) { process in
HStack {
Text(process.name).font(.callout).lineLimit(1)
@@ -101,7 +101,7 @@ struct NetworkPopoverContent: View {
}
}
}
.padding(14)
.padding(Onyx.Space.xl)
.frame(width: 300)
.onAppear { model.addDetailConsumer() }
.onDisappear { model.removeDetailConsumer() }
@@ -117,10 +117,10 @@ struct NetworkPopoverContent: View {
}
private func rate(_ value: Double, _ key: LocalizedStringKey, _ tint: Color) -> some View {
VStack(alignment: .leading, spacing: 1) {
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
Text(Throughput.formatted(value))
.font(.system(size: 18, weight: .medium).monospacedDigit())
HStack(spacing: 4) {
.font(Onyx.Font.display.monospacedDigit())
HStack(spacing: Onyx.Space.xs) {
Circle().fill(tint).frame(width: 6, height: 6)
Text(key, bundle: .module).font(.caption).foregroundStyle(.secondary)
}
@@ -128,19 +128,12 @@ struct NetworkPopoverContent: View {
}
private func peak(_ value: Double, _ key: LocalizedStringKey) -> some View {
HStack(spacing: 4) {
HStack(spacing: Onyx.Space.xs) {
Text(key, bundle: .module).font(.caption2).foregroundStyle(.secondary)
Text(Throughput.formatted(value)).font(.caption2.monospacedDigit())
}
}
private func section(_ key: LocalizedStringKey) -> some View {
Text(key, bundle: .module)
.font(.caption.weight(.semibold))
.foregroundStyle(Onyx.Color.accent)
.textCase(.uppercase)
}
@ViewBuilder
private func field(_ key: LocalizedStringKey, _ value: String?) -> some View {
if let value {
@@ -175,16 +168,16 @@ private struct TrafficChart: View {
// Fünftel je Balken, und mit jedem weiteren wurden sie schmaler.
// Frisch gestartet sah das Diagramm dadurch aus wie ein Ausschlag.
let width = GraphLayout.barWidth(capacity: NetworkModel.historyLength,
totalWidth: geometry.size.width, spacing: 1)
totalWidth: geometry.size.width, spacing: Onyx.Space.xxs)
let half = geometry.size.height / 2
HStack(alignment: .center, spacing: 1) {
HStack(alignment: .center, spacing: Onyx.Space.xxs) {
// Fehlende Messwerte bleiben als freie Fläche links stehen; der
// Verlauf wächst nach links weg, wie bei jedem Zeitdiagramm.
Spacer(minLength: 0)
ForEach(Array(points.enumerated()), id: \.offset) { _, snapshot in
VStack(spacing: 1) {
VStack(spacing: Onyx.Space.xxs) {
Rectangle()
.fill(Onyx.Color.upload)
.frame(height: max(half * CGFloat(snapshot.uploadRate / peak), 0.5))
@@ -141,10 +141,10 @@ private struct NetworkWidgetView: View {
@State private var token: UUID?
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
VStack(alignment: .leading, spacing: Onyx.Space.sm) {
HStack(spacing: Onyx.Space.sm) {
Image(systemName: model.snapshot.interfaceKind.symbolName)
.font(.system(size: 11))
.font(.system(size: Onyx.Symbol.small))
.foregroundStyle(Onyx.Color.download)
Text(connectionName)
.font(Onyx.Font.caption)
@@ -158,7 +158,7 @@ private struct NetworkWidgetView: View {
capacity: NetworkModel.historyLength)
.frame(height: 18)
Divider().overlay(Onyx.Color.hairline)
OnyxHairline()
Details(snapshot: model.snapshot)
Spacer(minLength: 0)
@@ -189,16 +189,16 @@ private struct Rates: View {
let compact: Bool
var body: some View {
VStack(alignment: .leading, spacing: 1) {
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
row("arrow.down", Throughput.formatted(snapshot.downloadRate), Onyx.Color.download)
row("arrow.up", Throughput.formatted(snapshot.uploadRate), Onyx.Color.upload)
}
}
private func row(_ symbol: String, _ text: String, _ tint: Color) -> some View {
HStack(spacing: 3) {
HStack(spacing: Onyx.Space.xs) {
Image(systemName: symbol)
.font(.system(size: 8, weight: .bold))
.font(.system(size: Onyx.Symbol.caption, weight: .bold))
.foregroundStyle(tint)
Text(text)
.font(compact ? Onyx.Font.metricSmall : Onyx.Font.metricSmall)
@@ -212,7 +212,7 @@ private struct Details: View {
let snapshot: NetworkSnapshot
var body: some View {
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
field("network.field.interface", snapshot.interfaceName)
field("network.field.ipv4", snapshot.ipv4)
field("network.field.router", snapshot.router)
@@ -222,12 +222,12 @@ private struct Details: View {
@ViewBuilder
private func field(_ label: LocalizedStringKey, _ value: String?) -> some View {
if let value {
HStack(spacing: 4) {
HStack(spacing: Onyx.Space.xs) {
Text(label, bundle: .module)
.font(.system(size: 9))
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
Text(value)
.font(.system(size: 9))
.font(Onyx.Font.micro)
.monospacedDigit()
.foregroundStyle(Onyx.Color.textSecondary)
.textSelection(.enabled)
@@ -41,7 +41,7 @@ public struct WidgetGrid: View {
// Sollte nach dem Filtern in LayoutStore nicht vorkommen. Falls doch,
// ist eine sichtbare Lücke mit Namen besser als ein stiller Ausfall.
OnyxTile {
VStack(spacing: 4) {
VStack(spacing: Onyx.Space.xs) {
Image(systemName: "questionmark.square.dashed")
Text(item.placement.widgetID)
.font(Onyx.Font.caption)
@@ -45,9 +45,9 @@ public struct AirDropView: View {
if compact {
// Nebeneinander statt übereinander: in 52 Punkten Höhe ist für
// Symbol über Text kein Platz.
HStack(spacing: 8) {
HStack(spacing: Onyx.Space.md) {
Image(systemName: "shareplay")
.font(.system(size: 15, weight: .light))
.font(.system(size: Onyx.Symbol.large, weight: .light))
.foregroundStyle(isTargeted ? Onyx.Color.accent
: Onyx.Color.textTertiary)
Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module)
@@ -57,11 +57,11 @@ public struct AirDropView: View {
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.padding(.horizontal, Onyx.Space.md)
} else {
VStack(spacing: 6) {
VStack(spacing: Onyx.Space.sm) {
Image(systemName: "shareplay")
.font(.system(size: 20, weight: .light))
.font(.system(size: Onyx.Symbol.xlarge, weight: .light))
.foregroundStyle(isTargeted ? Onyx.Color.accent
: Onyx.Color.textTertiary)
Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module)
@@ -74,15 +74,12 @@ public struct AirDropView: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(isTargeted ? Onyx.Color.accent : Onyx.Color.hairline,
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
}
// Diese Fläche ist **nur** zum Ablegen da. Ihre Kante steht deshalb
// immer — ohne sie wüsste niemand, dass sie eine ist.
.onyxDropZone(isTargeted: isTargeted, style: .always)
// Derselbe Weg wie in der Ablage: auch hier kommen Anhänge als
// Versprechen an, nicht als fertige Datei.
.contentShape(.rect)
.onFileDrop(isTargeted: $isTargeted) { urls in failed = !AirDrop.send(urls) }
.animation(Onyx.Motion.value, value: isTargeted)
}
}
@@ -50,12 +50,12 @@ public struct ShelfView: View {
}
public var body: some View {
VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: Onyx.Space.sm) {
if store.items.isEmpty {
EmptyShelf()
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 4) {
LazyVStack(alignment: .leading, spacing: Onyx.Space.xs) {
ForEach(store.items.prefix(compact ? 4 : 20)) { item in
ShelfRow(item: item, store: store, thumbnails: thumbnails)
}
@@ -63,7 +63,7 @@ public struct ShelfView: View {
}
.scrollIndicators(.never)
HStack(spacing: 10) {
HStack(spacing: Onyx.Space.row) {
Button { store.removeAll() } label: {
Text("shelf.clear", bundle: .module).font(Onyx.Font.caption)
}
@@ -82,24 +82,17 @@ public struct ShelfView: View {
// Die ganze Kachel nimmt Dateien an, nicht nur ein Streifen darin.
.contentShape(.rect)
.onFileDrop(isTargeted: $isTargeted) { urls in store.add(urls) }
.overlay {
if isTargeted {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Onyx.Color.accent, style: StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
.background(Onyx.Color.accent.opacity(0.08),
in: .rect(cornerRadius: 10, style: .continuous))
.allowsHitTesting(false)
}
}
.animation(Onyx.Motion.value, value: isTargeted)
// Die Kachel nimmt nebenbei Dateien an — die Kante erscheint deshalb
// erst, wenn wirklich etwas darüber schwebt.
.onyxDropZone(isTargeted: isTargeted, style: .whenTargeted)
}
}
private struct EmptyShelf: View {
var body: some View {
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
Image(systemName: "tray")
.font(.system(size: 16))
.font(.system(size: Onyx.Symbol.large))
.foregroundStyle(Onyx.Color.textTertiary)
Text("shelf.empty", bundle: .module)
.font(Onyx.Font.caption)
@@ -117,9 +110,9 @@ private struct ShelfRow: View {
@State private var isHovered = false
var body: some View {
HStack(spacing: 8) {
HStack(spacing: Onyx.Space.md) {
thumbnail
VStack(alignment: .leading, spacing: 1) {
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
Text(item.name)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textPrimary)
@@ -133,19 +126,19 @@ private struct ShelfRow: View {
if isHovered {
Button { AirDrop.send([item.url]) } label: {
Image(systemName: "shareplay").font(.system(size: 10))
Image(systemName: "shareplay").font(.system(size: Onyx.Symbol.small))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textTertiary)
Button { store.remove(item) } label: {
Image(systemName: "xmark.circle.fill").font(.system(size: 11))
Image(systemName: "xmark.circle.fill").font(.system(size: Onyx.Symbol.small))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textTertiary)
}
}
.padding(.vertical, 2)
.padding(.vertical, Onyx.Space.xxs)
.contentShape(.rect)
.onHover { isHovered = $0 }
// `NSItemProvider(contentsOf:)` statt `.draggable(item.url)`: letzteres
@@ -176,9 +169,9 @@ private struct ShelfRow: View {
Image(nsImage: image)
.resizable().aspectRatio(contentMode: .fit)
.frame(width: 22, height: 22)
.clipShape(.rect(cornerRadius: 3, style: .continuous))
.clipShape(.rect(cornerRadius: Onyx.Radius.bar, style: .continuous))
} else {
RoundedRectangle(cornerRadius: 3, style: .continuous)
RoundedRectangle(cornerRadius: Onyx.Radius.bar, style: .continuous)
.fill(Onyx.Color.elevated)
.frame(width: 22, height: 22)
}
@@ -204,13 +197,13 @@ public struct ShelfWidget: OnyxWidget {
public func makeView() -> AnyView {
AnyView(
VStack(spacing: 8) {
VStack(spacing: Onyx.Space.md) {
// AirDrop oben und schmal: es ist eine reine Ablegefläche ohne
// Zustand, die Ablage darunter braucht den Platz für ihre Liste.
AirDropView(compact: true)
.frame(height: 52)
Divider().overlay(Onyx.Color.hairline)
OnyxHairline()
ShelfView(store: store)
}
@@ -195,12 +195,15 @@ private struct WeatherWidgetView: View {
if let lastKnown {
content(lastKnown, isStale: true)
} else {
Notice(symbol: "wifi.slash", text: "widget.weather.unavailable")
OnyxNotice(symbol: "wifi.slash",
text: String(localized: "widget.weather.unavailable", bundle: .module))
}
case .locationDenied:
Notice(symbol: "location.slash", text: "widget.weather.locationDenied")
OnyxNotice(symbol: "location.slash",
text: String(localized: "widget.weather.locationDenied", bundle: .module))
case .locationUndetermined:
Notice(symbol: "location.circle", text: "widget.weather.locationUndetermined")
OnyxNotice(symbol: "location.circle",
text: String(localized: "widget.weather.locationUndetermined", bundle: .module))
case .loading, .idle:
ProgressView()
.controlSize(.small)
@@ -217,25 +220,6 @@ private struct WeatherWidgetView: View {
WideWeather(snapshot: snapshot, isStale: isStale)
}
}
private struct Notice: View {
let symbol: String
let text: LocalizedStringKey
var body: some View {
VStack(spacing: 6) {
Image(systemName: symbol)
.font(.system(size: 15))
.foregroundStyle(Onyx.Color.textTertiary)
Text(text, bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct WideWeather: View {
let snapshot: WeatherSnapshot
let isStale: Bool
@@ -246,15 +230,15 @@ private struct WideWeather: View {
// die untere Hälfte war leer — bei Schriftgrößen von acht Punkten, die
// man auf Armlänge nicht entziffert.
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .center, spacing: 10) {
HStack(alignment: .center, spacing: Onyx.Space.row) {
Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight))
.font(.system(size: 34))
.font(.system(size: Onyx.Symbol.hero))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(Onyx.Color.textPrimary)
VStack(alignment: .leading, spacing: 0) {
Text(snapshot.temperature.onyxFormatted)
.font(.system(size: 30, weight: .medium))
.font(Onyx.Font.displayLarge)
.monospacedDigit()
.foregroundStyle(Onyx.Color.textPrimary)
if let high = snapshot.high, let low = snapshot.low {
@@ -271,7 +255,7 @@ private struct WideWeather: View {
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.lineLimit(1)
.padding(.top, 2)
.padding(.top, Onyx.Space.xxs)
if snapshot.apparentTemperature != snapshot.temperature {
Text("weather.feelsLike \(snapshot.apparentTemperature.onyxFormatted)",
@@ -283,19 +267,19 @@ private struct WideWeather: View {
Spacer(minLength: 6)
if !snapshot.hourly.isEmpty {
Divider().overlay(Onyx.Color.hairline)
OnyxHairline()
// Vier statt fünf Stunden: bei 180 Punkten Breite bleiben so
// gut vierzig je Spalte, und die Zahlen lassen sich in einer
// lesbaren Größe setzen.
HStack(spacing: 0) {
ForEach(snapshot.hourly.prefix(4)) { point in
VStack(spacing: 3) {
VStack(spacing: Onyx.Space.xs) {
Text(point.date.formatted(.dateTime.hour()))
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
Image(systemName: point.condition.symbolName(isDaylight: true))
.font(.system(size: 13))
.font(.system(size: Onyx.Symbol.medium))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(Onyx.Color.textSecondary)
Text(point.temperature.onyxFormatted)
@@ -306,15 +290,15 @@ private struct WideWeather: View {
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 8)
.padding(.vertical, Onyx.Space.md)
}
Spacer(minLength: 0)
HStack(spacing: 4) {
HStack(spacing: Onyx.Space.xs) {
if isStale {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 9))
.font(.system(size: Onyx.Symbol.caption))
.foregroundStyle(Onyx.Color.warning)
}
Text(stampText)
@@ -338,10 +322,13 @@ private struct WideWeather: View {
}
}
extension Measurement where UnitType == UnitTemperature {
public extension Measurement where UnitType == UnitTemperature {
/// Ganzzahlig und in der Einheit, die zur Region des Nutzers passt.
/// Nachkommastellen bei der Außentemperatur täuschen eine Genauigkeit vor,
/// die die Vorhersage nicht hat.
///
/// Öffentlich, seit die Temperatur auch neben der Notch steht: dieselbe
/// Zahl zweimal verschieden zu runden wäre der Anfang zweier Wahrheiten.
var onyxFormatted: String {
formatted(.measurement(width: .narrow,
usage: .weather,