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:
co-authored by
Claude Opus 5
parent
9b600de9a9
commit
edb327cabe
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user