diff --git a/Onyx/MixerMenuBarModule.swift b/Onyx/MixerMenuBarModule.swift index aebb700..0f15b99 100644 --- a/Onyx/MixerMenuBarModule.swift +++ b/Onyx/MixerMenuBarModule.swift @@ -36,8 +36,10 @@ final class MixerMenuBarModule: MenuBarModule { .padding(14) } .scrollIndicators(.automatic) - .frame(width: 280) - .frame(maxHeight: 420) + // 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) ) } diff --git a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift index 7efcdc7..fca7c5e 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift @@ -4,6 +4,14 @@ import OnyxDesign import OnyxWidgetKit /// Die Mischpult-Ansicht. Dieselbe im Panel und im Menüleisten-Popover. +/// +/// Die Anordnung folgt SoundSource, weil sie dort gut gelöst ist: eine Zeile je +/// Programm, links das Symbol als Kachel, dann ein Stummschalter, dann der +/// Regler, rechts der Wert. Nichts steht übereinander, nichts muss man suchen. +/// +/// Die Farben folgen ihr **nicht**. SoundSource färbt jeden Regler grün; in +/// Onyx ist Farbe ein Signal — die Verstärkung über 100 % ist eines, eine +/// gewöhnliche Lautstärke nicht. public struct MixerView: View { let mixer: AudioMixer /// Im Panel ist es eng, im Popover nicht. @@ -24,8 +32,22 @@ public struct MixerView: View { .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 8) } else { - ForEach(mixer.processes.prefix(compact ? 4 : 10)) { process in - ProcessRow(mixer: mixer, process: process) + // 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 + if index > 0 { Divider().overlay(Onyx.Color.hairline) } + ProcessRow(mixer: mixer, process: process, compact: compact) + .padding(.horizontal, 10) + .padding(.vertical, compact ? 7 : 9) + } + } + .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) } } @@ -94,15 +116,15 @@ private struct OffState: View { private struct ProcessRow: View { let mixer: AudioMixer let process: AudioProcess - @State private var isHovered = false + 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) } var body: some View { - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 7) { + VStack(alignment: .leading, spacing: compact ? 5 : 6) { + HStack(spacing: 8) { icon Text(process.name) .font(Onyx.Font.caption) @@ -111,22 +133,28 @@ private struct ProcessRow: View { // In der Mitte kürzen statt am Ende: bei zwei ähnlichen // Namen ist das Ende oft das Unterscheidende. .truncationMode(.middle) - .layoutPriority(1) - Spacer(minLength: 4) - - // Die Prozentzahl erscheint erst, wenn sie etwas aussagt. - // Bei jedem Programm „100 %" zu zeigen ist Zahlenrauschen. - if managed { - Text("\(Int(volume * 100)) %") - .font(Onyx.Font.metricSmall) - .foregroundStyle(volume > 1 ? Onyx.Color.warning : Onyx.Color.textTertiary) - .monospacedDigit() - .transition(.opacity) + if process.isPlaying { + Circle().fill(Onyx.Color.positive).frame(width: 5, height: 5) } } - HStack(spacing: 7) { + 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: process.bundleID), @@ -140,22 +168,16 @@ private struct ProcessRow: View { mixer.setVolume(newValue, for: process.bundleID) }) - Button { - if !managed { mixer.setManaged(true, for: process.bundleID) } - mixer.setMuted(!muted, for: process.bundleID) - } label: { - Image(systemName: muted ? "speaker.slash.fill" : "speaker.wave.2.fill") - .font(.system(size: 9)) - .frame(width: 12) - } - .buttonStyle(.plain) - .foregroundStyle(muted ? Onyx.Color.critical - : (managed ? Onyx.Color.textTertiary - : Onyx.Color.textTertiary.opacity(0.5))) + 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) - .onHover { isHovered = $0 } .contextMenu { if managed { Button { @@ -168,20 +190,36 @@ private struct ProcessRow: View { .animation(Onyx.Motion.value, value: managed) } + /// Wie viele Wellen am Lautsprecher — folgt der Lautstärke, wie überall + /// sonst in macOS auch. + private var speakerSymbol: String { + switch volume { + case ..<0.01: "speaker.fill" + case ..<0.5: "speaker.wave.1.fill" + case ..<1.2: "speaker.wave.2.fill" + default: "speaker.wave.3.fill" + } + } + @ViewBuilder private var icon: some View { - // 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().frame(width: 15, height: 15) - .opacity(managed ? 1 : 0.65) - } else { - Image(systemName: "app.dashed") - .font(.system(size: 11)) - .foregroundStyle(Onyx.Color.textTertiary) - .frame(width: 15, height: 15) + // 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) + } } + .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) } } @@ -199,61 +237,64 @@ private struct VolumeBar: View { @State private var isDragging = false - private let trackHeight: CGFloat = 5 + private let trackHeight: CGFloat = 6 var body: some View { GeometryReader { geometry in let width = geometry.size.width let fraction = min(max(volume / Gain.maximumVolume, 0), 1) - let fillWidth = width * fraction + 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.elevated) + .fill(Onyx.Color.surface) .frame(height: trackHeight) // Markierung bei 100 %: darüber wird verstärkt, und man soll // sehen, wann man diese Grenze überschreitet. Rectangle() .fill(Onyx.Color.hairline) - .frame(width: 1, height: trackHeight + 3) - .offset(x: width / Gain.maximumVolume) + .frame(width: 1, height: trackHeight + 4) + .offset(x: knob / 2 + travel / Gain.maximumVolume) Capsule() .fill(volume > 1 ? Onyx.Color.warning : Onyx.Color.accent) .frame(width: fillWidth, height: trackHeight) - .opacity(isActive ? 1 : 0.28) + .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.35)) + .fill(.white.opacity(0.4)) .frame(width: fillWidth * meterFraction, height: trackHeight) .blendMode(.plusLighter) .allowsHitTesting(false) } Circle() - .fill(Onyx.Color.textPrimary) - .frame(width: isDragging ? 10 : 8, height: isDragging ? 10 : 8) - .shadow(color: .black.opacity(0.4), radius: 1, y: 0.5) - .offset(x: fillWidth - (isDragging ? 5 : 4)) - .opacity(isActive ? 1 : 0.5) + .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: 14) + .frame(height: 16) .contentShape(.rect) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in isDragging = true - let position = min(max(value.location.x / width, 0), 1) + let position = min(max((value.location.x - knob / 2) / travel, 0), 1) onChange(position * Gain.maximumVolume) } .onEnded { _ in isDragging = false }) } - .frame(height: 14) + .frame(height: 16) .animation(Onyx.Motion.value, value: isDragging) } @@ -279,7 +320,7 @@ public struct AudioMixerWidget: OnyxWidget { public func makeView() -> AnyView { AnyView( ScrollView { - MixerView(mixer: mixer) + MixerView(mixer: mixer, compact: true) } .scrollIndicators(.never) ) diff --git a/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift b/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift index 98fd5c4..2d44c3b 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift @@ -25,6 +25,30 @@ public enum BatteryGlyph { return min(max(inner * clamped, minimumFill), inner) } + /// Was mitten im Akku steht. + public enum Indicator: CaseIterable, Sendable { + case none + /// Lädt. + case bolt + /// Am Netz, aber es fließt nichts — meist, weil das Ladelimit erreicht + /// ist. Ohne eigenes Zeichen sieht das aus wie Akkubetrieb, und man + /// fragt sich, ob das Kabel wirklich steckt. + case plug + + public var symbolName: String? { + switch self { + case .none: nil + case .bolt: "bolt.fill" + case .plug: "powerplug.fill" + } + } + } + + public static func indicator(isCharging: Bool, isPluggedIn: Bool) -> Indicator { + if isCharging { return .bolt } + return isPluggedIn ? .plug : .none + } + public static func isLow(charge: Double, isCharging: Bool = false) -> Bool { // Ein roter Akku, der gerade lädt, wäre eine Warnung vor etwas, das // sich schon erledigt. @@ -36,7 +60,7 @@ public enum BatteryGlyph { /// - Parameter color: die Vordergrundfarbe der Menüleiste. Nur bei /// kritischem Ladestand weicht die Füllung davon ab — Farbe ist in der /// Menüleiste ein Signal, keine Dekoration. - public static func draw(charge: Double, isCharging: Bool, + public static func draw(charge: Double, isCharging: Bool, isPluggedIn: Bool = false, color: NSColor, in rect: CGRect) { let capWidth: CGFloat = 2 let body = CGRect(x: rect.minX, y: rect.minY, @@ -65,23 +89,24 @@ public enum BatteryGlyph { NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill() } - guard isCharging else { return } - - // Der Blitz wird **ausgestanzt**, nicht daraufgelegt. + // Das Zeichen wird **ausgestanzt**, nicht daraufgelegt. // // `destinationOut` nimmt weg, was vorher gezeichnet wurde — Füllung und - // Rahmen. Übrig bleibt ein Loch in Blitzform, durch das die Menüleiste - // scheint. Genau so sieht der von macOS aus, und es funktioniert auf - // hellem wie dunklem Grund, ohne die Hintergrundfarbe zu kennen. - guard let bolt = NSImage(systemSymbolName: "bolt.fill", - accessibilityDescription: nil) else { return } - let boltHeight = rect.height - 1 - let boltWidth = (bolt.size.width / bolt.size.height) * boltHeight - let boltRect = CGRect(x: body.midX - boltWidth / 2, - y: rect.midY - boltHeight / 2, - width: boltWidth, height: boltHeight) + // Rahmen. Übrig bleibt ein Loch in Blitz- bzw. Steckerform, durch das + // die Menüleiste scheint. Genau so sieht der von macOS aus, und es + // funktioniert auf hellem wie dunklem Grund, ohne die Hintergrundfarbe + // zu kennen. + guard let name = indicator(isCharging: isCharging, + isPluggedIn: isPluggedIn).symbolName, + let glyph = NSImage(systemSymbolName: name, accessibilityDescription: nil) + else { return } + let glyphHeight = rect.height - 1 + let glyphWidth = (glyph.size.width / glyph.size.height) * glyphHeight + let glyphRect = CGRect(x: body.midX - glyphWidth / 2, + y: rect.midY - glyphHeight / 2, + width: glyphWidth, height: glyphHeight) NSGraphicsContext.saveGraphicsState() - bolt.draw(in: boltRect, from: .zero, operation: .destinationOut, fraction: 1) + glyph.draw(in: glyphRect, from: .zero, operation: .destinationOut, fraction: 1) NSGraphicsContext.restoreGraphicsState() } } @@ -101,9 +126,13 @@ public struct BatteryGlyphView: View { private let isCharging: Bool private let height: CGFloat - public init(charge: Double, isCharging: Bool, height: CGFloat = 14) { + private let isPluggedIn: Bool + + public init(charge: Double, isCharging: Bool, isPluggedIn: Bool = false, + height: CGFloat = 14) { self.charge = charge self.isCharging = isCharging + self.isPluggedIn = isPluggedIn self.height = height } @@ -126,9 +155,10 @@ public struct BatteryGlyphView: View { } .frame(width: (BatteryGlyph.size.width - 3) * scale, height: height) .overlay { - if isCharging { - Image(systemName: "bolt.fill") - .font(.system(size: height * 0.72)) + if let name = BatteryGlyph.indicator(isCharging: isCharging, + isPluggedIn: isPluggedIn).symbolName { + Image(systemName: name) + .font(.system(size: height * 0.68)) .blendMode(.destinationOut) } } diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift index 964a1c2..4aa8033 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricMenuBarModule.swift @@ -250,7 +250,7 @@ final class MetricStatusView: NSView, WidthReporting { return } BatteryGlyph.draw(charge: battery.charge, isCharging: battery.isCharging, - color: color, + isPluggedIn: battery.isPluggedIn, color: color, in: CGRect(origin: origin, size: BatteryGlyph.size)) } diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift index cbb96ec..a12a4f2 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricWidgets.swift @@ -341,7 +341,8 @@ private struct BatteryTile: View { // dasselbe wie bei 95 % und trägt nichts bei. if let battery { BatteryGlyphView(charge: battery.charge, - isCharging: battery.isCharging, height: 15) + isCharging: battery.isCharging, + isPluggedIn: battery.isPluggedIn, height: 15) .foregroundStyle(tint(battery)) } else { Image(systemName: BatterySymbol.unavailable) diff --git a/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift b/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift index fce184c..c150ca2 100644 --- a/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift +++ b/Packages/OnyxKit/Tests/MetricsProviderTests/BatteryGlyphTests.swift @@ -1,5 +1,6 @@ import Testing import CoreGraphics +import AppKit @testable import MetricsProvider // SF Symbols kennt fünf Füllstufen und keine Blitz-Varianten außer bei 100 %. @@ -61,3 +62,43 @@ struct BatteryGlyphTests { #expect(BatteryGlyph.size.height <= 14) } } + +// Am Netz, aber es tut sich nichts — weil das Ladelimit erreicht ist. Ohne +// eigenes Zeichen sieht das genauso aus wie Akkubetrieb, und man fragt sich, +// ob das Kabel wirklich steckt. + +@Suite("Ladezustand im Glyph") +struct BatteryIndicatorTests { + + @Test("Beim Laden der Blitz") + func chargingShowsBolt() { + #expect(BatteryGlyph.indicator(isCharging: true, isPluggedIn: true) == .bolt) + } + + @Test("Am Netz ohne Laden der Stecker") + func pluggedButIdleShowsPlug() { + // Der Fall, um den es geht: Ladelimit erreicht. + #expect(BatteryGlyph.indicator(isCharging: false, isPluggedIn: true) == .plug) + } + + @Test("Im Akkubetrieb gar nichts") + func onBatteryShowsNothing() { + #expect(BatteryGlyph.indicator(isCharging: false, isPluggedIn: false) == .none) + } + + @Test("Laden schlägt alles andere") + func chargingWins() { + // Sollte der Netzzustand einmal widersprüchlich gemeldet werden, ist + // „lädt" die Auskunft, die zählt. + #expect(BatteryGlyph.indicator(isCharging: true, isPluggedIn: false) == .bolt) + } + + @Test("Jedes Zeichen gibt es auch wirklich") + func everyIndicatorResolves() { + for indicator in BatteryGlyph.Indicator.allCases { + guard let name = indicator.symbolName else { continue } + #expect(NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil, + "\(name) gibt es nicht") + } + } +}