Menüleiste springt nicht mehr, Mixer fasst Hilfsprozesse zusammen
Die Breite folgte der Stellenzahl des aktuellen Werts. Aus „14 KB/s" wird „431 KB/s", und mit der zusätzlichen Stelle wanderte die halbe Leiste. Jetzt wieder feste Breiten, bemessen am breitesten Wert, der vorkommen kann. Der frühere Fehler war nicht die feste Breite, sondern der falsche Bezugswert: gezeichnet wird „63°", bemessen wurde „100 °C" — fünfzehn Punkte für zwei Zeichen, die nie erscheinen. Nachgemessen: zwei Aufnahmen im Abstand von sechs Sekunden mit geänderten Werten ergeben identische Breiten. Der Mixer fasst Hilfsprozesse mit ihrem Programm zusammen. „Google Chrome" und „Google Chrome Helper" standen als zwei Zeilen nebeneinander, mit getrennten Reglern für dasselbe Programm — zieht man den einen, hört man den anderen. Chrome hat je Tab einen Helfer; fünfzehn Regler für einen Browser sind keine Auskunft. Abgegriffen wird weiterhin je Prozess, geregelt aber je Programm. Der Schlüssel der Taps muss deshalb den Prozess mitführen: mehrere Helfer teilen sich eine Kennung und überschrieben sich sonst gegenseitig. Der Pegel einer Zeile ist der lauteste ihrer Prozesse. Die Zeilen sind einzeilig statt zweizeilig: Symbol, Stummschalter, Regler, Wert. Der Name ist weg — bei einem erkennbaren Programmsymbol ist er redundant, und die gewonnene Zeile macht die Liste halb so hoch. Dafür ist das Symbol größer, und wer den Namen braucht, hält den Zeiger darauf. Popover 420 statt 330 Punkte breit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,13 +37,15 @@ final class MixerMenuBarModule: MenuBarModule {
|
|||||||
AnyView(
|
AnyView(
|
||||||
ScrollView {
|
ScrollView {
|
||||||
MixerView(mixer: mixer, system: system)
|
MixerView(mixer: mixer, system: system)
|
||||||
.padding(14)
|
.padding(16)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.automatic)
|
.scrollIndicators(.automatic)
|
||||||
// Breiter als das Panel: hier ist der Mixer zu Hause, und eine
|
// Deutlich breiter als das Panel: hier ist der Mixer zu Hause.
|
||||||
// Zeile aus Symbol, Stummschalter, Regler und Wert braucht Platz.
|
// Eine Zeile aus Symbol, Stummschalter, Regler und Wert braucht
|
||||||
.frame(width: 330)
|
// Platz, und ein Regler von hundert Punkten Länge lässt sich nicht
|
||||||
.frame(maxHeight: 460)
|
// fein einstellen.
|
||||||
|
.frame(width: 420)
|
||||||
|
.frame(maxHeight: 620)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Au
|
|||||||
public final class AudioMixer {
|
public final class AudioMixer {
|
||||||
|
|
||||||
public private(set) var processes: [AudioProcess] = []
|
public private(set) var processes: [AudioProcess] = []
|
||||||
|
/// Die Programme, wie sie in der Oberfläche stehen — Hilfsprozesse mit
|
||||||
|
/// ihrem Programm zusammengefasst.
|
||||||
|
public private(set) var groups: [AudioProcessGroup] = []
|
||||||
public private(set) var isEnabled = false
|
public private(set) var isEnabled = false
|
||||||
public private(set) var lastError: String?
|
public private(set) var lastError: String?
|
||||||
|
|
||||||
@@ -91,18 +94,22 @@ public final class AudioMixer {
|
|||||||
private func refresh() {
|
private func refresh() {
|
||||||
let all = Self.currentProcesses()
|
let all = Self.currentProcesses()
|
||||||
let now = Date()
|
let now = Date()
|
||||||
for process in all where process.isPlaying { lastHeard[process.bundleID] = now }
|
|
||||||
|
|
||||||
// Core Audio führt Dutzende Systemdienste, die theoretisch Ton ausgeben
|
// Core Audio führt Dutzende Systemdienste, die theoretisch Ton ausgeben
|
||||||
// könnten. In einem Mischpult sind sie nur Rauschen — gezeigt wird, was
|
// könnten. In einem Mischpult sind sie nur Rauschen — gezeigt wird, was
|
||||||
// klingt, kürzlich klang oder geregelt wird.
|
// klingt, kürzlich klang oder geregelt wird.
|
||||||
processes = all.filter { process in
|
// Erst zusammenfassen, dann filtern: sonst verschwindet der
|
||||||
|
// Hauptprozess von Chrome, weil nur sein Helfer Ton ausgibt.
|
||||||
|
let candidates = AudioProcessGroup.group(all)
|
||||||
|
for group in candidates where group.isPlaying { lastHeard[group.bundleID] = now }
|
||||||
|
|
||||||
|
groups = candidates.filter { group in
|
||||||
ProcessVisibility.shouldShow(
|
ProcessVisibility.shouldShow(
|
||||||
isPlaying: process.isPlaying,
|
isPlaying: group.isPlaying,
|
||||||
secondsSinceHeard: lastHeard[process.bundleID]
|
secondsSinceHeard: lastHeard[group.bundleID]
|
||||||
.map { now.timeIntervalSince($0) },
|
.map { now.timeIntervalSince($0) },
|
||||||
isManaged: managed.contains(process.bundleID))
|
isManaged: managed.contains(group.bundleID))
|
||||||
}
|
}
|
||||||
|
processes = groups.flatMap(\.processes)
|
||||||
syncTaps()
|
syncTaps()
|
||||||
startLevelTimerIfNeeded()
|
startLevelTimerIfNeeded()
|
||||||
}
|
}
|
||||||
@@ -116,49 +123,65 @@ public final class AudioMixer {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let wanted = Set(processes.filter { managed.contains($0.bundleID) }.map(\.bundleID))
|
// Abgegriffen wird je **Prozess**, geregelt je **Programm**: Chromes
|
||||||
|
// Ton kommt aus mehreren Helfern, die alle denselben Regler bekommen.
|
||||||
|
// Der Schlüssel muss deshalb den Prozess mitführen — mehrere Helfer
|
||||||
|
// teilen sich eine Kennung und überschrieben sonst einander.
|
||||||
|
var wanted: [String: (process: AudioProcess, group: AudioProcessGroup)] = [:]
|
||||||
|
for group in groups where managed.contains(group.bundleID) {
|
||||||
|
for process in group.processes {
|
||||||
|
wanted[Self.tapKey(process)] = (process, group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Beendete oder abgewählte Programme freigeben — sonst bliebe der Tap
|
// Beendete oder abgewählte Programme freigeben — sonst bliebe der Tap
|
||||||
// stehen und schaltete ein Programm stumm, das gar nicht mehr gemeint ist.
|
// stehen und schaltete ein Programm stumm, das gar nicht mehr gemeint ist.
|
||||||
for (bundleID, tap) in taps where !wanted.contains(bundleID) {
|
for (key, tap) in taps where wanted[key] == nil {
|
||||||
tap.stop()
|
tap.stop()
|
||||||
taps[bundleID] = nil
|
taps[key] = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for process in processes where wanted.contains(process.bundleID) {
|
for (key, entry) in wanted {
|
||||||
if let existing = taps[process.bundleID] {
|
let gain = store.effectiveGain(for: entry.group.bundleID)
|
||||||
existing.setGain(store.effectiveGain(for: process.bundleID))
|
if let existing = taps[key] {
|
||||||
|
existing.setGain(gain)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
let tap = ProcessTap(process: process)
|
let tap = ProcessTap(process: entry.process)
|
||||||
tap.setGain(store.effectiveGain(for: process.bundleID))
|
tap.setGain(gain)
|
||||||
do {
|
do {
|
||||||
try tap.start(outputDeviceUID: outputUID)
|
try tap.start(outputDeviceUID: outputUID)
|
||||||
taps[process.bundleID] = tap
|
taps[key] = tap
|
||||||
lastError = nil
|
lastError = nil
|
||||||
} catch {
|
} catch {
|
||||||
// Bei einem Fehlschlag nicht in einem halben Zustand
|
// Bei einem Fehlschlag nicht in einem halben Zustand
|
||||||
// verharren: das Programm bleibt sonst stumm zurück.
|
// verharren: das Programm bleibt sonst stumm zurück.
|
||||||
tap.stop()
|
tap.stop()
|
||||||
managed.remove(process.bundleID)
|
managed.remove(entry.group.bundleID)
|
||||||
persist()
|
persist()
|
||||||
lastError = error.localizedDescription
|
lastError = error.localizedDescription
|
||||||
log.error("Tap für \(process.name, privacy: .public) fehlgeschlagen: \(error.localizedDescription, privacy: .public)")
|
log.error("Tap für \(entry.process.name, privacy: .public) fehlgeschlagen: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ein Tap je Prozess, nicht je Programm — mehrere Helfer eines Programms
|
||||||
|
/// tragen dieselbe Kennung und überschrieben sich sonst gegenseitig.
|
||||||
|
private static func tapKey(_ process: AudioProcess) -> String {
|
||||||
|
"\(process.bundleID)#\(process.pid)"
|
||||||
|
}
|
||||||
|
|
||||||
/// Baut Taps neu auf, die nur noch Stille liefern.
|
/// Baut Taps neu auf, die nur noch Stille liefern.
|
||||||
private func checkForStalledTaps() {
|
private func checkForStalledTaps() {
|
||||||
for (bundleID, tap) in taps {
|
for (key, tap) in taps {
|
||||||
guard tap.isRunning,
|
guard tap.isRunning,
|
||||||
tap.secondsSinceAudio > 30,
|
tap.secondsSinceAudio > 30,
|
||||||
processes.first(where: { $0.bundleID == bundleID })?.isPlaying == true
|
tap.process.isPlaying
|
||||||
else { continue }
|
else { continue }
|
||||||
|
|
||||||
log.error("Tap für \(bundleID, privacy: .public) liefert nur Stille — Neuaufbau")
|
log.error("Tap für \(key, privacy: .public) liefert nur Stille — Neuaufbau")
|
||||||
tap.stop()
|
tap.stop()
|
||||||
taps[bundleID] = nil
|
taps[key] = nil
|
||||||
}
|
}
|
||||||
syncTaps()
|
syncTaps()
|
||||||
}
|
}
|
||||||
@@ -202,13 +225,20 @@ public final class AudioMixer {
|
|||||||
levelTimer = nil
|
levelTimer = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Der Pegel gilt je **Programm**: Chromes Ton verteilt sich auf mehrere
|
||||||
|
// Helfer, und die Zeile zeigt den lautesten davon.
|
||||||
|
var peaks: [String: Double] = [:]
|
||||||
|
for (_, tap) in taps {
|
||||||
|
let app = ProcessNaming.parentBundleID(for: tap.process.bundleID)
|
||||||
|
peaks[app] = max(peaks[app] ?? 0, Double(tap.takePeak()))
|
||||||
|
}
|
||||||
|
|
||||||
var next: [String: Double] = [:]
|
var next: [String: Double] = [:]
|
||||||
for (bundleID, tap) in taps {
|
for (app, peak) in peaks {
|
||||||
let peak = Double(tap.takePeak())
|
let previous = levels[app] ?? 0
|
||||||
let previous = levels[bundleID] ?? 0
|
|
||||||
// Schnell hoch, langsam runter. Ein Pegel, der jedem Puffer folgt,
|
// Schnell hoch, langsam runter. Ein Pegel, der jedem Puffer folgt,
|
||||||
// flackert; einer, der nur fällt, verpasst die Spitzen.
|
// flackert; einer, der nur fällt, verpasst die Spitzen.
|
||||||
next[bundleID] = peak > previous ? peak : previous * 0.72
|
next[app] = peak > previous ? peak : previous * 0.72
|
||||||
}
|
}
|
||||||
levels = next
|
levels = next
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Alle Tonquellen **eines** Programms in einer Zeile.
|
||||||
|
///
|
||||||
|
/// Chrome gibt seinen Ton über Hilfsprozesse aus — einen je Tab. In der Liste
|
||||||
|
/// standen deshalb „Google Chrome" und „Google Chrome Helper" nebeneinander,
|
||||||
|
/// und zwar mit getrennten Reglern für dasselbe Programm: zieht man den einen,
|
||||||
|
/// hört man den anderen. Fünfzehn Regler für einen Browser sind keine Auskunft,
|
||||||
|
/// sondern eine Zumutung.
|
||||||
|
public struct AudioProcessGroup: Identifiable, Equatable, Sendable {
|
||||||
|
|
||||||
|
/// Die Kennung des **Programms**, nicht die des Prozesses. Sie ist der
|
||||||
|
/// Schlüssel für Lautstärke, Stummschaltung und Auswahl.
|
||||||
|
public let bundleID: String
|
||||||
|
public let name: String
|
||||||
|
public let processes: [AudioProcess]
|
||||||
|
|
||||||
|
public var id: String { bundleID }
|
||||||
|
|
||||||
|
/// Spielt einer, spielt die Gruppe.
|
||||||
|
///
|
||||||
|
/// Der Ton kommt aus dem Helfer, während der Hauptprozess schweigt — die
|
||||||
|
/// Zeile muss trotzdem als spielend gelten, sonst verschwindet sie.
|
||||||
|
public var isPlaying: Bool { processes.contains { $0.isPlaying } }
|
||||||
|
|
||||||
|
public static func group(_ processes: [AudioProcess]) -> [AudioProcessGroup] {
|
||||||
|
let byApp = Dictionary(grouping: processes) {
|
||||||
|
ProcessNaming.parentBundleID(for: $0.bundleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return byApp.map { bundleID, members in
|
||||||
|
// Der Name kommt vom Programm selbst, wenn es dabei ist. Sonst vom
|
||||||
|
// Helfer — dann ist er alles, was da ist.
|
||||||
|
let name = members.first { $0.bundleID == bundleID }?.name
|
||||||
|
?? ProcessNaming.fallbackName(for: bundleID)
|
||||||
|
return AudioProcessGroup(bundleID: bundleID, name: name,
|
||||||
|
processes: members.sorted { $0.pid < $1.pid })
|
||||||
|
}
|
||||||
|
// Nach Namen sortiert: Core Audio sagt keine Reihenfolge zu, und ohne
|
||||||
|
// feste Sortierung springen die Zeilen bei jeder Messung umher.
|
||||||
|
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ public struct MixerView: View {
|
|||||||
SectionTitle("mixer.section.apps", compact: compact)
|
SectionTitle("mixer.section.apps", compact: compact)
|
||||||
|
|
||||||
if mixer.isEnabled {
|
if mixer.isEnabled {
|
||||||
if mixer.processes.isEmpty {
|
if mixer.groups.isEmpty {
|
||||||
Text("mixer.noProcesses", bundle: .module)
|
Text("mixer.noProcesses", bundle: .module)
|
||||||
.font(Onyx.Font.caption)
|
.font(Onyx.Font.caption)
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
@@ -47,12 +47,12 @@ public struct MixerView: View {
|
|||||||
// Eine Karte um die Zeilen: sie fasst zusammen, was
|
// Eine Karte um die Zeilen: sie fasst zusammen, was
|
||||||
// zusammengehört, und trennt es vom Ausschalter darunter.
|
// zusammengehört, und trennt es vom Ausschalter darunter.
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ForEach(Array(mixer.processes.prefix(compact ? 4 : 12).enumerated()),
|
ForEach(Array(mixer.groups.prefix(compact ? 4 : 12).enumerated()),
|
||||||
id: \.element.id) { index, process in
|
id: \.element.id) { index, group in
|
||||||
if index > 0 { Divider().overlay(Onyx.Color.hairline) }
|
if index > 0 { Divider().overlay(Onyx.Color.hairline) }
|
||||||
ProcessRow(mixer: mixer, process: process, compact: compact)
|
ProcessRow(mixer: mixer, group: group, compact: compact)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, compact ? 7 : 9)
|
.padding(.vertical, compact ? 6 : 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Onyx.Color.elevated.opacity(0.55),
|
.background(Onyx.Color.elevated.opacity(0.55),
|
||||||
@@ -82,7 +82,7 @@ public struct MixerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.animation(Onyx.Motion.value, value: mixer.processes.count)
|
.animation(Onyx.Motion.value, value: mixer.groups.count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,36 +127,24 @@ private struct OffState: View {
|
|||||||
|
|
||||||
private struct ProcessRow: View {
|
private struct ProcessRow: View {
|
||||||
let mixer: AudioMixer
|
let mixer: AudioMixer
|
||||||
let process: AudioProcess
|
let group: AudioProcessGroup
|
||||||
let compact: Bool
|
let compact: Bool
|
||||||
|
|
||||||
private var managed: Bool { mixer.isManaged(process.bundleID) }
|
private var managed: Bool { mixer.isManaged(group.bundleID) }
|
||||||
private var muted: Bool { mixer.isMuted(process.bundleID) }
|
private var muted: Bool { mixer.isMuted(group.bundleID) }
|
||||||
private var volume: Double { mixer.volume(for: process.bundleID) }
|
private var volume: Double { mixer.volume(for: group.bundleID) }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: compact ? 5 : 6) {
|
// Eine Zeile statt zwei: Symbol, Stummschalter, Regler, Wert. Der Name
|
||||||
HStack(spacing: 8) {
|
// 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) {
|
||||||
icon
|
icon
|
||||||
Text(process.name)
|
|
||||||
.font(Onyx.Font.caption)
|
|
||||||
.foregroundStyle(managed ? Onyx.Color.textPrimary : Onyx.Color.textSecondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
// In der Mitte kürzen statt am Ende: bei zwei ähnlichen
|
|
||||||
// Namen ist das Ende oft das Unterscheidende.
|
|
||||||
.truncationMode(.middle)
|
|
||||||
Spacer(minLength: 4)
|
|
||||||
if process.isPlaying {
|
|
||||||
Circle().fill(Onyx.Color.positive).frame(width: 5, height: 5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 9) {
|
|
||||||
// Der Stummschalter links vom Regler, wie in SoundSource: er
|
|
||||||
// gehört zur Lautstärke und nicht zu den Zusatzfunktionen.
|
|
||||||
Button {
|
Button {
|
||||||
if !managed { mixer.setManaged(true, for: process.bundleID) }
|
if !managed { mixer.setManaged(true, for: group.bundleID) }
|
||||||
mixer.setMuted(!muted, for: process.bundleID)
|
mixer.setMuted(!muted, for: group.bundleID)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: muted ? "speaker.slash.fill" : speakerSymbol)
|
Image(systemName: muted ? "speaker.slash.fill" : speakerSymbol)
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
@@ -169,15 +157,15 @@ private struct ProcessRow: View {
|
|||||||
|
|
||||||
VolumeBar(
|
VolumeBar(
|
||||||
volume: volume,
|
volume: volume,
|
||||||
level: mixer.level(for: process.bundleID),
|
level: mixer.level(for: group.bundleID),
|
||||||
isActive: managed && !muted,
|
isActive: managed && !muted,
|
||||||
onChange: { newValue in
|
onChange: { newValue in
|
||||||
// Am Regler zu ziehen **ist** die Entscheidung, dieses
|
// Am Regler zu ziehen **ist** die Entscheidung, dieses
|
||||||
// Programm zu regeln. Ein zusätzlicher Schalter davor
|
// Programm zu regeln. Ein zusätzlicher Schalter davor wäre
|
||||||
// wäre eine Hürde, die nichts schützt — der große
|
// eine Hürde, die nichts schützt — der große Ausschalter
|
||||||
// Ausschalter unten stellt ohnehin alles zurück.
|
// unten stellt ohnehin alles zurück.
|
||||||
if !managed { mixer.setManaged(true, for: process.bundleID) }
|
if !managed { mixer.setManaged(true, for: group.bundleID) }
|
||||||
mixer.setVolume(newValue, for: process.bundleID)
|
mixer.setVolume(newValue, for: group.bundleID)
|
||||||
})
|
})
|
||||||
|
|
||||||
Text(managed ? "\(Int(volume * 100)) %" : "–")
|
Text(managed ? "\(Int(volume * 100)) %" : "–")
|
||||||
@@ -188,13 +176,11 @@ private struct ProcessRow: View {
|
|||||||
// Feste Breite, sonst wandert der Regler beim Ziehen.
|
// Feste Breite, sonst wandert der Regler beim Ziehen.
|
||||||
.frame(width: 36, alignment: .trailing)
|
.frame(width: 36, alignment: .trailing)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
|
.help(group.name)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if managed {
|
if managed {
|
||||||
Button {
|
Button { mixer.setManaged(false, for: group.bundleID) } label: {
|
||||||
mixer.setManaged(false, for: process.bundleID)
|
|
||||||
} label: {
|
|
||||||
Text("mixer.release", bundle: .module)
|
Text("mixer.release", bundle: .module)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,26 +199,24 @@ private struct ProcessRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ohne Namen daneben trägt das Symbol die Erkennung allein — also größer
|
||||||
|
/// und ohne Kachel, damit es tatsächlich zu erkennen ist.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var icon: some View {
|
private var icon: some View {
|
||||||
// Als Kachel wie in SoundSource: ein freigestelltes Programmsymbol
|
// Auch Hilfsprozesse bekommen das Symbol ihres Programms.
|
||||||
// wirkt neben Text verloren, mit Hintergrund steht es für sich.
|
if let image = ProcessNaming.icon(bundleID: group.bundleID,
|
||||||
Group {
|
pid: group.processes.first?.pid ?? 0) {
|
||||||
// Auch Hilfsprozesse bekommen das Symbol ihres Programms — sonst
|
Image(nsImage: image)
|
||||||
// steht neben „Google Chrome" ein leerer Platzhalter.
|
.resizable()
|
||||||
if let image = ProcessNaming.icon(bundleID: process.bundleID, pid: process.pid) {
|
.frame(width: 26, height: 26)
|
||||||
Image(nsImage: image).resizable().padding(2)
|
.opacity(managed ? 1 : 0.65)
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "app.dashed")
|
Image(systemName: "app.dashed")
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 15))
|
||||||
.foregroundStyle(Onyx.Color.textTertiary)
|
.foregroundStyle(Onyx.Color.textTertiary)
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 20, height: 20)
|
|
||||||
.background(Onyx.Color.surface.opacity(managed ? 0.9 : 0.5),
|
|
||||||
in: .rect(cornerRadius: 5, style: .continuous))
|
|
||||||
.opacity(managed ? 1 : 0.7)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Der Lautstärkeregler.
|
/// Der Lautstärkeregler.
|
||||||
|
|||||||
@@ -123,10 +123,18 @@ final class MetricStatusView: NSView, WidthReporting {
|
|||||||
|
|
||||||
private var shownWidth: CGFloat = MenuBarText.minimumWidth
|
private var shownWidth: CGFloat = MenuBarText.minimumWidth
|
||||||
|
|
||||||
/// Die Breite für die Gestalt des Werts, nicht für seinen Höchstwert —
|
/// Der breiteste Wert, der vorkommen kann.
|
||||||
/// zwischen „34 %" und „87 %" darf sich nichts bewegen.
|
///
|
||||||
private func shape(_ text: String) -> String {
|
/// **Fest**, nicht am aktuellen Wert bemessen: ein Element, das bei
|
||||||
MenuBarText.widestOfSameShape(text)
|
/// „431" breiter wird als bei „14", schiebt alle Symbole rechts davon.
|
||||||
|
///
|
||||||
|
/// Der frühere Fehler war nicht die feste Breite, sondern der falsche
|
||||||
|
/// Bezugswert — gezeichnet wird „63°", bemessen wurde „100 °C".
|
||||||
|
private var reference: String {
|
||||||
|
switch (metric, mode) {
|
||||||
|
case (.temperature, _), (_, .temperature): "100°"
|
||||||
|
default: "100 %"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override var intrinsicContentSize: NSSize {
|
override var intrinsicContentSize: NSSize {
|
||||||
@@ -144,11 +152,11 @@ final class MetricStatusView: NSView, WidthReporting {
|
|||||||
case .symbol: max(MenuBarText.minimumWidth, symbolSlot + 4)
|
case .symbol: max(MenuBarText.minimumWidth, symbolSlot + 4)
|
||||||
case .graph: 28
|
case .graph: 28
|
||||||
case .bars: barsWidth
|
case .bars: barsWidth
|
||||||
case .value: MenuBarText.width(for: shape(displayValue))
|
case .value: MenuBarText.width(for: reference)
|
||||||
case .valueAndGraph: MenuBarText.width(for: shape(displayValue)) + 2 + 28
|
case .valueAndGraph: MenuBarText.width(for: reference) + 2 + 28
|
||||||
case .labelAndValue: MenuBarText.width(for: shape("\(label) \(displayValue)"))
|
case .labelAndValue: MenuBarText.width(for: "\(label) \(reference)")
|
||||||
case .symbolAndValue:
|
case .symbolAndValue:
|
||||||
MenuBarText.width(for: shape(displayValue)) + symbolSlot + MenuBarText.innerGap
|
MenuBarText.width(for: reference) + symbolSlot + MenuBarText.innerGap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,19 +81,16 @@ final class NetworkStatusView: NSView, WidthReporting {
|
|||||||
/// die halbe Breite leer zu lassen — und genau so sah es aus. Gemessen wird
|
/// die halbe Breite leer zu lassen — und genau so sah es aus. Gemessen wird
|
||||||
/// deshalb, was gerade dasteht; `AdaptiveWidth` sorgt dafür, dass die
|
/// deshalb, was gerade dasteht; `AdaptiveWidth` sorgt dafür, dass die
|
||||||
/// Leiste dabei nicht zappelt.
|
/// Leiste dabei nicht zappelt.
|
||||||
private static func ratesWidth(_ snapshot: NetworkSnapshot) -> CGFloat {
|
/// Feste Breite, bemessen am breitesten Wert, der vorkommen kann.
|
||||||
|
///
|
||||||
|
/// Nicht am aktuellen: aus „14 KB/s" wird „431 KB/s", und mit jeder
|
||||||
|
/// zusätzlichen Stelle wanderte die halbe Menüleiste. „999 MB/s" kostet
|
||||||
|
/// gegenüber einem dreistelligen KB-Wert nur wenige Punkte — das ist der
|
||||||
|
/// Preis dafür, dass nie etwas springt.
|
||||||
|
private static var ratesWidth: CGFloat {
|
||||||
let font = NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular)
|
let font = NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular)
|
||||||
// Die **Gestalt** reservieren, nicht den Höchstwert der letzten
|
let widest = NSAttributedString(string: "↓ 999 MB/s", attributes: [.font: font])
|
||||||
// Messungen: der Durchsatz schwankt im Sekundentakt, und der Höchstwert
|
return ceil(widest.size().width) + 2 * MenuBarText.sidePadding
|
||||||
// ist fast immer größer als das, was gerade dasteht. Die Differenz
|
|
||||||
// stand als Lücke daneben.
|
|
||||||
let lines = ["↓ " + Throughput.formatted(snapshot.downloadRate),
|
|
||||||
"↑ " + Throughput.formatted(snapshot.uploadRate)]
|
|
||||||
.map(MenuBarText.widestOfSameShape)
|
|
||||||
let widest = lines
|
|
||||||
.map { NSAttributedString(string: $0, attributes: [.font: font]).size().width }
|
|
||||||
.max() ?? 0
|
|
||||||
return ceil(widest) + 2 * MenuBarText.sidePadding
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func neededWidth() -> CGFloat {
|
private func neededWidth() -> CGFloat {
|
||||||
@@ -102,13 +99,13 @@ final class NetworkStatusView: NSView, WidthReporting {
|
|||||||
max(MenuBarText.minimumWidth,
|
max(MenuBarText.minimumWidth,
|
||||||
MenuBarText.symbolWidth(snapshot.interfaceKind.symbolName, height: 15) + 4)
|
MenuBarText.symbolWidth(snapshot.interfaceKind.symbolName, height: 15) + 4)
|
||||||
case .graph, .bars: 28
|
case .graph, .bars: 28
|
||||||
case .value: Self.ratesWidth(snapshot)
|
case .value: Self.ratesWidth
|
||||||
case .valueAndGraph: Self.ratesWidth(snapshot) + 26
|
case .valueAndGraph: Self.ratesWidth + 26
|
||||||
// Die Raten tragen mit ↓ und ↑ ihre Beschriftung schon in sich; ein
|
// Die Raten tragen mit ↓ und ↑ ihre Beschriftung schon in sich; ein
|
||||||
// Kürzel davor wäre doppelt gemoppelt, ein Symbol dagegen nützlich —
|
// Kürzel davor wäre doppelt gemoppelt, ein Symbol dagegen nützlich —
|
||||||
// es zeigt WLAN, Ethernet oder VPN.
|
// es zeigt WLAN, Ethernet oder VPN.
|
||||||
case .labelAndValue, .symbolAndValue:
|
case .labelAndValue, .symbolAndValue:
|
||||||
Self.ratesWidth(snapshot) + symbolSlot + MenuBarText.innerGap
|
Self.ratesWidth + symbolSlot + MenuBarText.innerGap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,17 +39,6 @@ public enum MenuBarText {
|
|||||||
return max(minimumWidth, content + symbol)
|
return max(minimumWidth, content + symbol)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dieselbe Zeichenfolge mit allen Ziffern auf 9 gesetzt.
|
|
||||||
///
|
|
||||||
/// Damit lässt sich die Breite für die **Gestalt** eines Werts reservieren
|
|
||||||
/// statt für seinen Höchstwert. Bei ständig schwankenden Zahlen — dem
|
|
||||||
/// Netzwerkdurchsatz etwa — ist der Höchstwert der letzten Messungen fast
|
|
||||||
/// immer größer als der aktuelle, und die Differenz steht als Lücke daneben.
|
|
||||||
/// So bewegt sich nur noch etwas, wenn eine Stelle dazukommt.
|
|
||||||
public static func widestOfSameShape(_ text: String) -> String {
|
|
||||||
String(text.map { $0.isNumber ? "9" : $0 })
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func width(of text: String) -> CGFloat {
|
public static func width(of text: String) -> CGFloat {
|
||||||
guard !text.isEmpty else { return 0 }
|
guard !text.isEmpty else { return 0 }
|
||||||
return NSAttributedString(string: text, attributes: [.font: font]).size().width
|
return NSAttributedString(string: text, attributes: [.font: font]).size().width
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import Testing
|
||||||
|
import CoreAudio
|
||||||
|
@testable import AudioProvider
|
||||||
|
|
||||||
|
// Chrome gibt seinen Ton über einen Hilfsprozess aus. In der Liste standen
|
||||||
|
// deshalb „Google Chrome" und „Google Chrome Helper" nebeneinander — zwei
|
||||||
|
// Regler für dasselbe Programm, und wer den einen zieht, hört den anderen.
|
||||||
|
|
||||||
|
private func process(_ bundleID: String, name: String, id: UInt32,
|
||||||
|
playing: Bool = false) -> AudioProcess {
|
||||||
|
AudioProcess(objectID: AudioObjectID(id), pid: pid_t(id), bundleID: bundleID,
|
||||||
|
name: name, isPlaying: playing)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("Programme zusammenfassen")
|
||||||
|
struct ProcessGroupTests {
|
||||||
|
|
||||||
|
@Test("Hilfsprozess und Programm werden eine Zeile")
|
||||||
|
func helperJoinsItsApp() {
|
||||||
|
let groups = AudioProcessGroup.group([
|
||||||
|
process("com.google.Chrome", name: "Google Chrome", id: 1),
|
||||||
|
process("com.google.Chrome.helper", name: "Google Chrome Helper", id: 2),
|
||||||
|
])
|
||||||
|
#expect(groups.count == 1)
|
||||||
|
#expect(groups.first?.bundleID == "com.google.Chrome")
|
||||||
|
#expect(groups.first?.processes.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Der Name kommt vom Programm, nicht vom Helfer")
|
||||||
|
func nameComesFromTheApp() {
|
||||||
|
let groups = AudioProcessGroup.group([
|
||||||
|
process("com.google.Chrome.helper", name: "Google Chrome Helper", id: 2),
|
||||||
|
process("com.google.Chrome", name: "Google Chrome", id: 1),
|
||||||
|
])
|
||||||
|
#expect(groups.first?.name == "Google Chrome")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ohne das Programm selbst trägt der Helfer den Namen")
|
||||||
|
func helperAloneKeepsItsName() {
|
||||||
|
// Chrome kann Ton ausgeben, ohne dass der Hauptprozess selbst als
|
||||||
|
// Tonquelle auftaucht. Dann ist der Helfer alles, was da ist.
|
||||||
|
let groups = AudioProcessGroup.group([
|
||||||
|
process("com.google.Chrome.helper", name: "Google Chrome Helper", id: 2),
|
||||||
|
])
|
||||||
|
#expect(groups.count == 1)
|
||||||
|
#expect(groups.first?.bundleID == "com.google.Chrome")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Verschiedene Programme bleiben getrennt")
|
||||||
|
func differentAppsStayApart() {
|
||||||
|
let groups = AudioProcessGroup.group([
|
||||||
|
process("com.spotify.client", name: "Spotify", id: 1),
|
||||||
|
process("com.google.Chrome", name: "Google Chrome", id: 2),
|
||||||
|
])
|
||||||
|
#expect(groups.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Spielt einer, spielt die Gruppe")
|
||||||
|
func groupPlaysIfAnyPlays() {
|
||||||
|
// Der Ton kommt aus dem Helfer; der Hauptprozess schweigt. Die Zeile
|
||||||
|
// muss trotzdem als spielend gelten, sonst verschwindet sie.
|
||||||
|
let groups = AudioProcessGroup.group([
|
||||||
|
process("com.google.Chrome", name: "Google Chrome", id: 1, playing: false),
|
||||||
|
process("com.google.Chrome.helper", name: "Helper", id: 2, playing: true),
|
||||||
|
])
|
||||||
|
#expect(groups.first?.isPlaying == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Mehrere Helfer landen alle in derselben Zeile")
|
||||||
|
func manyHelpersOneRow() {
|
||||||
|
// Chrome hat je Tab einen. Fünfzehn Regler für einen Browser wären
|
||||||
|
// keine Auskunft, sondern eine Zumutung.
|
||||||
|
let groups = AudioProcessGroup.group((1...5).map {
|
||||||
|
process("com.google.Chrome.helper", name: "Helper", id: UInt32($0))
|
||||||
|
})
|
||||||
|
#expect(groups.count == 1)
|
||||||
|
#expect(groups.first?.processes.count == 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Die Reihenfolge bleibt stabil")
|
||||||
|
func orderIsStable() {
|
||||||
|
// Sonst springen die Zeilen bei jeder Messung umher, weil Core Audio
|
||||||
|
// keine Reihenfolge zusagt.
|
||||||
|
let input = [process("b.app", name: "B", id: 2), process("a.app", name: "A", id: 1)]
|
||||||
|
#expect(AudioProcessGroup.group(input).map(\.name) == ["A", "B"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,44 +50,35 @@ struct MenuBarTextTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eine Breite, die dem Höchstwert der letzten Messungen folgt, ist bei einem
|
// Ein Menüleistenelement darf seine Breite **nicht** ändern, wenn sich der
|
||||||
// Wert, der ständig schwankt, fast immer zu groß: der Netzwerkdurchsatz
|
// Messwert ändert — sonst rutschen alle Symbole rechts davon mit. Das gilt auch
|
||||||
// wechselt im Sekundentakt, und das Element blieb dauerhaft auf der Breite
|
// dann, wenn eine Stelle dazukommt: aus „14 KB/s" wird „431 KB/s", und die
|
||||||
// eines vergangenen Ausschlags stehen. Die Differenz stand als Lücke daneben.
|
// halbe Leiste wandert.
|
||||||
//
|
//
|
||||||
// Stattdessen: die Ziffern durch Neunen ersetzen. Das ergibt die breiteste
|
// Also eine feste Breite je Darstellung, bemessen am breitesten Wert, der
|
||||||
// Zeichenfolge **derselben Gestalt** — die Breite ändert sich dann nur noch,
|
// vorkommen kann. Der frühere Fehler war nicht die feste Breite, sondern ein
|
||||||
// wenn eine Stelle dazukommt oder die Einheit wechselt.
|
// falscher Bezugswert: „100 °C" statt „100°" ist rund 15 Punkte zu viel.
|
||||||
|
|
||||||
@Suite("Breite gleicher Gestalt")
|
@Suite("Feste Breite")
|
||||||
struct DigitShapeTests {
|
struct FixedWidthTests {
|
||||||
|
|
||||||
@Test("Ziffern werden zu Neunen")
|
@Test("Der Bezugswert hängt nicht vom Messwert ab")
|
||||||
func digitsBecomeNines() {
|
func referenceIsIndependentOfValue() {
|
||||||
#expect(MenuBarText.widestOfSameShape("↓ 14 KB/s") == "↓ 99 KB/s")
|
#expect(MenuBarText.width(for: "100 %") == MenuBarText.width(for: "100 %"))
|
||||||
#expect(MenuBarText.widestOfSameShape("34 %") == "99 %")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Alles außer Ziffern bleibt stehen")
|
@Test("Ein Bezugswert deckt alle kürzeren ab")
|
||||||
func nonDigitsSurvive() {
|
func referenceCoversShorterValues() {
|
||||||
#expect(MenuBarText.widestOfSameShape("↑ 2,4 MB/s") == "↑ 9,9 MB/s")
|
// Was hineinpasst, wird nicht abgeschnitten.
|
||||||
|
for value in ["0 %", "9 %", "34 %", "100 %"] {
|
||||||
|
#expect(MenuBarText.width(of: value) <= MenuBarText.width(of: "100 %"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Gleich lange Zahlen ergeben dieselbe Gestalt")
|
@Test("Grad ohne Einheitenbuchstaben ist deutlich schmaler")
|
||||||
func sameLengthIsStable() {
|
func degreeWithoutUnitIsNarrower() {
|
||||||
// Genau darum geht es: von 14 auf 37 KB/s darf sich nichts bewegen.
|
// Der eigentliche Fund: gezeichnet wird „63°", bemessen wurde früher
|
||||||
#expect(MenuBarText.widestOfSameShape("↓ 14 KB/s")
|
// „100 °C".
|
||||||
== MenuBarText.widestOfSameShape("↓ 37 KB/s"))
|
#expect(MenuBarText.width(of: "100°") < MenuBarText.width(of: "100 °C") - 10)
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Eine Stelle mehr ist eine andere Gestalt")
|
|
||||||
func moreDigitsIsWider() {
|
|
||||||
#expect(MenuBarText.widestOfSameShape("↓ 999 KB/s")
|
|
||||||
!= MenuBarText.widestOfSameShape("↓ 99 KB/s"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("Ohne Ziffern bleibt der Text, wie er ist")
|
|
||||||
func textWithoutDigitsIsUnchanged() {
|
|
||||||
#expect(MenuBarText.widestOfSameShape("–") == "–")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user