diff --git a/Onyx/MixerMenuBarModule.swift b/Onyx/MixerMenuBarModule.swift index e96f29d..c6e9ad5 100644 --- a/Onyx/MixerMenuBarModule.swift +++ b/Onyx/MixerMenuBarModule.swift @@ -37,13 +37,15 @@ final class MixerMenuBarModule: MenuBarModule { AnyView( ScrollView { MixerView(mixer: mixer, system: system) - .padding(14) + .padding(16) } .scrollIndicators(.automatic) - // Breiter als das Panel: hier ist der Mixer zu Hause, und eine - // Zeile aus Symbol, Stummschalter, Regler und Wert braucht Platz. - .frame(width: 330) - .frame(maxHeight: 460) + // Deutlich breiter als das Panel: hier ist der Mixer zu Hause. + // Eine Zeile aus Symbol, Stummschalter, Regler und Wert braucht + // Platz, und ein Regler von hundert Punkten Länge lässt sich nicht + // fein einstellen. + .frame(width: 420) + .frame(maxHeight: 620) ) } diff --git a/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift b/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift index 54c1709..d55cf77 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/AudioMixer.swift @@ -16,6 +16,9 @@ private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Au public final class AudioMixer { 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 lastError: String? @@ -91,18 +94,22 @@ public final class AudioMixer { private func refresh() { let all = Self.currentProcesses() let now = Date() - for process in all where process.isPlaying { lastHeard[process.bundleID] = now } - // Core Audio führt Dutzende Systemdienste, die theoretisch Ton ausgeben // könnten. In einem Mischpult sind sie nur Rauschen — gezeigt wird, was // 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( - isPlaying: process.isPlaying, - secondsSinceHeard: lastHeard[process.bundleID] + isPlaying: group.isPlaying, + secondsSinceHeard: lastHeard[group.bundleID] .map { now.timeIntervalSince($0) }, - isManaged: managed.contains(process.bundleID)) + isManaged: managed.contains(group.bundleID)) } + processes = groups.flatMap(\.processes) syncTaps() startLevelTimerIfNeeded() } @@ -116,49 +123,65 @@ public final class AudioMixer { 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 // 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() - taps[bundleID] = nil + taps[key] = nil } - for process in processes where wanted.contains(process.bundleID) { - if let existing = taps[process.bundleID] { - existing.setGain(store.effectiveGain(for: process.bundleID)) + for (key, entry) in wanted { + let gain = store.effectiveGain(for: entry.group.bundleID) + if let existing = taps[key] { + existing.setGain(gain) continue } - let tap = ProcessTap(process: process) - tap.setGain(store.effectiveGain(for: process.bundleID)) + let tap = ProcessTap(process: entry.process) + tap.setGain(gain) do { try tap.start(outputDeviceUID: outputUID) - taps[process.bundleID] = tap + taps[key] = tap lastError = nil } catch { // Bei einem Fehlschlag nicht in einem halben Zustand // verharren: das Programm bleibt sonst stumm zurück. tap.stop() - managed.remove(process.bundleID) + managed.remove(entry.group.bundleID) persist() 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. private func checkForStalledTaps() { - for (bundleID, tap) in taps { + for (key, tap) in taps { guard tap.isRunning, tap.secondsSinceAudio > 30, - processes.first(where: { $0.bundleID == bundleID })?.isPlaying == true + tap.process.isPlaying 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() - taps[bundleID] = nil + taps[key] = nil } syncTaps() } @@ -202,13 +225,20 @@ public final class AudioMixer { levelTimer = nil 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] = [:] - for (bundleID, tap) in taps { - let peak = Double(tap.takePeak()) - let previous = levels[bundleID] ?? 0 + for (app, peak) in peaks { + let previous = levels[app] ?? 0 // Schnell hoch, langsam runter. Ein Pegel, der jedem Puffer folgt, // 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 } diff --git a/Packages/OnyxKit/Sources/AudioProvider/AudioProcessGroup.swift b/Packages/OnyxKit/Sources/AudioProvider/AudioProcessGroup.swift new file mode 100644 index 0000000..4164931 --- /dev/null +++ b/Packages/OnyxKit/Sources/AudioProvider/AudioProcessGroup.swift @@ -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 } + } +} diff --git a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift index 2a21b67..ba74cfe 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift @@ -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) } } diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift index c138ed6..b411e07 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift @@ -123,10 +123,18 @@ final class MetricStatusView: NSView, WidthReporting { private var shownWidth: CGFloat = MenuBarText.minimumWidth - /// Die Breite für die Gestalt des Werts, nicht für seinen Höchstwert — - /// zwischen „34 %" und „87 %" darf sich nichts bewegen. - private func shape(_ text: String) -> String { - MenuBarText.widestOfSameShape(text) + /// Der breiteste Wert, der vorkommen kann. + /// + /// **Fest**, nicht am aktuellen Wert bemessen: ein Element, das bei + /// „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 { @@ -144,11 +152,11 @@ final class MetricStatusView: NSView, WidthReporting { case .symbol: max(MenuBarText.minimumWidth, symbolSlot + 4) case .graph: 28 case .bars: barsWidth - case .value: MenuBarText.width(for: shape(displayValue)) - case .valueAndGraph: MenuBarText.width(for: shape(displayValue)) + 2 + 28 - case .labelAndValue: MenuBarText.width(for: shape("\(label) \(displayValue)")) + case .value: MenuBarText.width(for: reference) + case .valueAndGraph: MenuBarText.width(for: reference) + 2 + 28 + case .labelAndValue: MenuBarText.width(for: "\(label) \(reference)") case .symbolAndValue: - MenuBarText.width(for: shape(displayValue)) + symbolSlot + MenuBarText.innerGap + MenuBarText.width(for: reference) + symbolSlot + MenuBarText.innerGap } } diff --git a/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift index f82a5a9..698906c 100644 --- a/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift +++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkMenuBarModule.swift @@ -81,19 +81,16 @@ final class NetworkStatusView: NSView, WidthReporting { /// die halbe Breite leer zu lassen — und genau so sah es aus. Gemessen wird /// deshalb, was gerade dasteht; `AdaptiveWidth` sorgt dafür, dass die /// 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) - // Die **Gestalt** reservieren, nicht den Höchstwert der letzten - // Messungen: der Durchsatz schwankt im Sekundentakt, und der Höchstwert - // 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 + let widest = NSAttributedString(string: "↓ 999 MB/s", attributes: [.font: font]) + return ceil(widest.size().width) + 2 * MenuBarText.sidePadding } private func neededWidth() -> CGFloat { @@ -102,13 +99,13 @@ final class NetworkStatusView: NSView, WidthReporting { max(MenuBarText.minimumWidth, MenuBarText.symbolWidth(snapshot.interfaceKind.symbolName, height: 15) + 4) case .graph, .bars: 28 - case .value: Self.ratesWidth(snapshot) - case .valueAndGraph: Self.ratesWidth(snapshot) + 26 + case .value: Self.ratesWidth + case .valueAndGraph: Self.ratesWidth + 26 // Die Raten tragen mit ↓ und ↑ ihre Beschriftung schon in sich; ein // Kürzel davor wäre doppelt gemoppelt, ein Symbol dagegen nützlich — // es zeigt WLAN, Ethernet oder VPN. case .labelAndValue, .symbolAndValue: - Self.ratesWidth(snapshot) + symbolSlot + MenuBarText.innerGap + Self.ratesWidth + symbolSlot + MenuBarText.innerGap } } diff --git a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift index 1c21718..79eda0b 100644 --- a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift +++ b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarText.swift @@ -39,17 +39,6 @@ public enum MenuBarText { 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 { guard !text.isEmpty else { return 0 } return NSAttributedString(string: text, attributes: [.font: font]).size().width diff --git a/Packages/OnyxKit/Tests/AudioProviderTests/ProcessGroupTests.swift b/Packages/OnyxKit/Tests/AudioProviderTests/ProcessGroupTests.swift new file mode 100644 index 0000000..4742b71 --- /dev/null +++ b/Packages/OnyxKit/Tests/AudioProviderTests/ProcessGroupTests.swift @@ -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"]) + } +} diff --git a/Packages/OnyxKit/Tests/OnyxMenuBarTests/MenuBarTextTests.swift b/Packages/OnyxKit/Tests/OnyxMenuBarTests/MenuBarTextTests.swift index 0d9c2f5..9cadf52 100644 --- a/Packages/OnyxKit/Tests/OnyxMenuBarTests/MenuBarTextTests.swift +++ b/Packages/OnyxKit/Tests/OnyxMenuBarTests/MenuBarTextTests.swift @@ -50,44 +50,35 @@ struct MenuBarTextTests { } } -// Eine Breite, die dem Höchstwert der letzten Messungen folgt, ist bei einem -// Wert, der ständig schwankt, fast immer zu groß: der Netzwerkdurchsatz -// wechselt im Sekundentakt, und das Element blieb dauerhaft auf der Breite -// eines vergangenen Ausschlags stehen. Die Differenz stand als Lücke daneben. +// Ein Menüleistenelement darf seine Breite **nicht** ändern, wenn sich der +// Messwert ändert — sonst rutschen alle Symbole rechts davon mit. Das gilt auch +// dann, wenn eine Stelle dazukommt: aus „14 KB/s" wird „431 KB/s", und die +// halbe Leiste wandert. // -// Stattdessen: die Ziffern durch Neunen ersetzen. Das ergibt die breiteste -// Zeichenfolge **derselben Gestalt** — die Breite ändert sich dann nur noch, -// wenn eine Stelle dazukommt oder die Einheit wechselt. +// Also eine feste Breite je Darstellung, bemessen am breitesten Wert, der +// vorkommen kann. Der frühere Fehler war nicht die feste Breite, sondern ein +// falscher Bezugswert: „100 °C" statt „100°" ist rund 15 Punkte zu viel. -@Suite("Breite gleicher Gestalt") -struct DigitShapeTests { +@Suite("Feste Breite") +struct FixedWidthTests { - @Test("Ziffern werden zu Neunen") - func digitsBecomeNines() { - #expect(MenuBarText.widestOfSameShape("↓ 14 KB/s") == "↓ 99 KB/s") - #expect(MenuBarText.widestOfSameShape("34 %") == "99 %") + @Test("Der Bezugswert hängt nicht vom Messwert ab") + func referenceIsIndependentOfValue() { + #expect(MenuBarText.width(for: "100 %") == MenuBarText.width(for: "100 %")) } - @Test("Alles außer Ziffern bleibt stehen") - func nonDigitsSurvive() { - #expect(MenuBarText.widestOfSameShape("↑ 2,4 MB/s") == "↑ 9,9 MB/s") + @Test("Ein Bezugswert deckt alle kürzeren ab") + func referenceCoversShorterValues() { + // 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") - func sameLengthIsStable() { - // Genau darum geht es: von 14 auf 37 KB/s darf sich nichts bewegen. - #expect(MenuBarText.widestOfSameShape("↓ 14 KB/s") - == MenuBarText.widestOfSameShape("↓ 37 KB/s")) - } - - @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("–") == "–") + @Test("Grad ohne Einheitenbuchstaben ist deutlich schmaler") + func degreeWithoutUnitIsNarrower() { + // Der eigentliche Fund: gezeichnet wird „63°", bemessen wurde früher + // „100 °C". + #expect(MenuBarText.width(of: "100°") < MenuBarText.width(of: "100 °C") - 10) } }