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:
Scarriffle
2026-08-13 13:15:53 +02:00
parent e79191d033
commit c44bb3af30
9 changed files with 306 additions and 175 deletions

View File

@@ -37,7 +37,7 @@ public struct MixerView: View {
SectionTitle("mixer.section.apps", compact: compact)
if mixer.isEnabled {
if mixer.processes.isEmpty {
if mixer.groups.isEmpty {
Text("mixer.noProcesses", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textTertiary)
@@ -47,12 +47,12 @@ public struct MixerView: View {
// Eine Karte um die Zeilen: sie fasst zusammen, was
// zusammengehört, und trennt es vom Ausschalter darunter.
VStack(spacing: 0) {
ForEach(Array(mixer.processes.prefix(compact ? 4 : 12).enumerated()),
id: \.element.id) { index, process in
ForEach(Array(mixer.groups.prefix(compact ? 4 : 12).enumerated()),
id: \.element.id) { index, group in
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(.vertical, compact ? 7 : 9)
.padding(.vertical, compact ? 6 : 8)
}
}
.background(Onyx.Color.elevated.opacity(0.55),
@@ -82,7 +82,7 @@ public struct MixerView: View {
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.animation(Onyx.Motion.value, value: mixer.processes.count)
.animation(Onyx.Motion.value, value: mixer.groups.count)
}
}
@@ -127,74 +127,60 @@ private struct OffState: View {
private struct ProcessRow: View {
let mixer: AudioMixer
let process: AudioProcess
let group: AudioProcessGroup
let compact: Bool
private var managed: Bool { mixer.isManaged(process.bundleID) }
private var muted: Bool { mixer.isMuted(process.bundleID) }
private var volume: Double { mixer.volume(for: process.bundleID) }
private var managed: Bool { mixer.isManaged(group.bundleID) }
private var muted: Bool { mixer.isMuted(group.bundleID) }
private var volume: Double { mixer.volume(for: group.bundleID) }
var body: some View {
VStack(alignment: .leading, spacing: compact ? 5 : 6) {
HStack(spacing: 8) {
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)
}
// Eine Zeile statt zwei: 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. Wer ihn braucht,
// hält den Zeiger auf das Symbol.
HStack(spacing: 10) {
icon
Button {
if !managed { mixer.setManaged(true, for: group.bundleID) }
mixer.setMuted(!muted, for: group.bundleID)
} label: {
Image(systemName: muted ? "speaker.slash.fill" : speakerSymbol)
.font(.system(size: 11))
.frame(width: 15, alignment: .leading)
}
.buttonStyle(.plain)
.foregroundStyle(muted ? Onyx.Color.critical
: (managed ? Onyx.Color.textSecondary
: Onyx.Color.textTertiary))
HStack(spacing: 9) {
// Der Stummschalter links vom Regler, wie in SoundSource: er
// gehört zur Lautstärke und nicht zu den Zusatzfunktionen.
Button {
if !managed { mixer.setManaged(true, for: process.bundleID) }
mixer.setMuted(!muted, for: process.bundleID)
} label: {
Image(systemName: muted ? "speaker.slash.fill" : speakerSymbol)
.font(.system(size: 11))
.frame(width: 15, alignment: .leading)
}
.buttonStyle(.plain)
.foregroundStyle(muted ? Onyx.Color.critical
: (managed ? Onyx.Color.textSecondary
: Onyx.Color.textTertiary))
VolumeBar(
volume: volume,
level: mixer.level(for: group.bundleID),
isActive: managed && !muted,
onChange: { newValue in
// Am Regler zu ziehen **ist** die Entscheidung, dieses
// Programm zu regeln. Ein zusätzlicher Schalter davor wäre
// eine Hürde, die nichts schützt der große Ausschalter
// unten stellt ohnehin alles zurück.
if !managed { mixer.setManaged(true, for: group.bundleID) }
mixer.setVolume(newValue, for: group.bundleID)
})
VolumeBar(
volume: volume,
level: mixer.level(for: process.bundleID),
isActive: managed && !muted,
onChange: { newValue in
// Am Regler zu ziehen **ist** die Entscheidung, dieses
// Programm zu regeln. Ein zusätzlicher Schalter davor
// wäre eine Hürde, die nichts schützt der große
// Ausschalter unten stellt ohnehin alles zurück.
if !managed { mixer.setManaged(true, for: process.bundleID) }
mixer.setVolume(newValue, for: process.bundleID)
})
Text(managed ? "\(Int(volume * 100)) %" : "")
.font(Onyx.Font.metricSmall)
.monospacedDigit()
.foregroundStyle(volume > 1 && managed ? Onyx.Color.warning
: Onyx.Color.textTertiary)
// Feste Breite, sonst wandert der Regler beim Ziehen.
.frame(width: 36, alignment: .trailing)
}
Text(managed ? "\(Int(volume * 100)) %" : "")
.font(Onyx.Font.metricSmall)
.monospacedDigit()
.foregroundStyle(volume > 1 && managed ? Onyx.Color.warning
: Onyx.Color.textTertiary)
// Feste Breite, sonst wandert der Regler beim Ziehen.
.frame(width: 36, alignment: .trailing)
}
.contentShape(.rect)
.help(group.name)
.contextMenu {
if managed {
Button {
mixer.setManaged(false, for: process.bundleID)
} label: {
Button { mixer.setManaged(false, for: group.bundleID) } label: {
Text("mixer.release", bundle: .module)
}
}
@@ -213,25 +199,23 @@ 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
private var icon: some View {
// Als Kachel wie in SoundSource: ein freigestelltes Programmsymbol
// wirkt neben Text verloren, mit Hintergrund steht es für sich.
Group {
// Auch Hilfsprozesse bekommen das Symbol ihres Programms sonst
// steht neben Google Chrome" ein leerer Platzhalter.
if let image = ProcessNaming.icon(bundleID: process.bundleID, pid: process.pid) {
Image(nsImage: image).resizable().padding(2)
} else {
Image(systemName: "app.dashed")
.font(.system(size: 10))
.foregroundStyle(Onyx.Color.textTertiary)
}
// Auch Hilfsprozesse bekommen das Symbol ihres Programms.
if let image = ProcessNaming.icon(bundleID: group.bundleID,
pid: group.processes.first?.pid ?? 0) {
Image(nsImage: image)
.resizable()
.frame(width: 26, height: 26)
.opacity(managed ? 1 : 0.65)
} else {
Image(systemName: "app.dashed")
.font(.system(size: 15))
.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)
}
}