From bd82e36170d9604f27ab09c6072e7d085ada2978 Mon Sep 17 00:00:00 2001 From: majroth Date: Wed, 31 Dec 2025 20:24:29 +0100 Subject: [PATCH] style(ui): unify selection styling and polish layout - Extract reusable SegmentButton component from ModeToggleView - Redesign footer with standard menu item styling (Edit, Login, Quit) - Unify selection styling with controlBackgroundColor across components - Update device row with green checkmark indicator and refined spacing - Fix volume icon layout jumping with fixed frame - Adjust ScrollView height (min: 280, max: 480) with subtle background - Use full-width dividers for cleaner section separation --- AudioPriorityBar/Views/DeviceListView.swift | 413 +++++++++++--------- AudioPriorityBar/Views/MenuBarView.swift | 282 +++++++------ 2 files changed, 389 insertions(+), 306 deletions(-) diff --git a/AudioPriorityBar/Views/DeviceListView.swift b/AudioPriorityBar/Views/DeviceListView.swift index 4077372..5deac6e 100644 --- a/AudioPriorityBar/Views/DeviceListView.swift +++ b/AudioPriorityBar/Views/DeviceListView.swift @@ -104,6 +104,7 @@ struct DraggableDeviceRow: View { @State private var isHovering = false @State private var lastReportedTarget: Int? = nil + @State private var isTransitioning = false var isDisconnected: Bool { !device.isConnected @@ -156,235 +157,259 @@ struct DraggableDeviceRow: View { } var body: some View { - HStack(spacing: 8) { - // Drag handle + priority label area - if !isHiddenSection { - ZStack { - // Drag handle icon - Image(systemName: "line.3.horizontal") - .font(.system(size: 11, weight: .medium)) - .foregroundColor(.secondary) - .frame(width: 36, height: rowHeight) - .opacity(isHovering || isDragging ? 1 : 0) - .scaleEffect(isHovering || isDragging ? 1 : 0.8) - - // Priority number or "Active" label when not hovering - Group { - if isSelected && !isDisconnected { - Text("Active") - .font(.system(size: 9, weight: .bold)) - .foregroundColor(.accentColor) - } else { - Text("\(index + 1)") - .font(.system(size: 11, weight: .semibold, design: .monospaced)) - .foregroundColor(.secondary.opacity(0.8)) + if #available(macOS 14.0, *) { + HStack(spacing: 0) { + // Drag handle + checkmark/priority area + if !isHiddenSection { + ZStack { + // Drag handle icon (shown on hover) + Image(systemName: "line.3.horizontal") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + .frame(width: 36, height: rowHeight) + .opacity(isHovering || isDragging ? 1 : 0) + .scaleEffect(isHovering || isDragging ? 1 : (isTransitioning ? 0.9 : 0.8)) + .blur(radius: isTransitioning ? 1 : 0) + + // Checkmark for selected, priority number for others + Group { + if isSelected && !isDisconnected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 14)) + .foregroundColor(.green) + } else { + Text("\(index + 1)") + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundColor(.secondary.opacity(0.8)) + } } + .opacity(isHovering || isDragging ? 0 : 1) + .scaleEffect(isHovering || isDragging ? 0.8 : (isTransitioning ? 0.9 : 1)) + .blur(radius: isTransitioning ? 1 : 0) } - .opacity(isHovering || isDragging ? 0 : 1) - .scaleEffect(isHovering || isDragging ? 0.8 : 1) - } - .frame(width: 36) - .animation(.easeInOut(duration: 0.12), value: isHovering) - .animation(.easeInOut(duration: 0.12), value: isDragging) - } - - // Device name - use HStack with tap gesture instead of Button to not interfere with drag - HStack(spacing: 8) { - Text(device.name) - .font(.system(size: 13, weight: .regular)) - .strikethrough(isNeverUse, color: .secondary) - .lineLimit(1) - .truncationMode(.tail) - .foregroundColor(isGrayed || isNeverUse ? .secondary : .primary) - - if let icon = statusIcon { - Image(systemName: icon) - .font(.system(size: 10)) - .foregroundColor(.secondary.opacity(0.7)) - } - - if let lastSeen = lastSeenText { - Text(lastSeen) - .font(.system(size: 10)) - .foregroundColor(.secondary.opacity(0.6)) + .frame(width: 36) + .animation(.easeInOut(duration: 0.12), value: isHovering) + .animation(.easeInOut(duration: 0.12), value: isDragging) + .animation(.easeInOut(duration: 0.25), value: isTransitioning) } - - if isMuted { - Text("Muted") - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(.white) + + // Device name - use HStack with tap gesture instead of Button to not interfere with drag + HStack(spacing: 8) { + Text(device.name) + .font(.system(size: 13, weight: .regular)) + .strikethrough(isNeverUse, color: .secondary) + .lineLimit(1) + .truncationMode(.tail) + .foregroundColor(isGrayed || isNeverUse ? .secondary : .primary) + + if let icon = statusIcon { + Image(systemName: icon) + .font(.system(size: 10)) + .foregroundColor(.secondary.opacity(0.7)) + } + + if let lastSeen = lastSeenText { + Text(lastSeen) + .font(.system(size: 10)) + .foregroundColor(.secondary.opacity(0.6)) + } + + if isMuted { + HStack(spacing: 4) { + Image(systemName: "speaker.slash.fill") + .font(.system(size: 9)) + Text("Muted") + .font(.system(size: 9, weight: .semibold)) + } + .foregroundColor(.secondary) .padding(.horizontal, 7) .padding(.vertical, 3) - .background(Capsule().fill(Color.red)) - } - - Spacer(minLength: 12) - - if isSelected && !isDisconnected { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.accentColor) - .font(.system(size: 15)) - .transition(.scale.combined(with: .opacity)) + .background( + Capsule() + .fill(Color(NSColor.windowBackgroundColor)) + .overlay(Capsule().stroke(Color.secondary.opacity(0.3), lineWidth: 1)) + ) + } + + Spacer(minLength: 12) } - } - .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isSelected) - - // Actions menu - always reserve space to prevent layout shifts - ZStack { - // Invisible placeholder to reserve space - Image(systemName: "ellipsis.circle") - .font(.system(size: 14)) - .frame(width: 28, height: 28) - .opacity(0) - // Actual menu (shown on hover) - if isHovering && !isDragging { - Group { + // Actions menu - always reserve space to prevent layout shifts + ZStack { + // Invisible placeholder to reserve space + Image(systemName: "ellipsis") + .font(.system(size: 14)) + .frame(width: 28, height: 28) + .opacity(0) + + // Actual menu (shown on hover) Menu { - if showCategoryPicker { - Button { - audioManager.setCategory(.speaker, for: device) - } label: { - Label("Move to Speakers", systemImage: "speaker.wave.2.fill") - } - Button { - audioManager.setCategory(.headphone, for: device) - } label: { - Label("Move to Headphones", systemImage: "headphones") - } - Divider() - } - - if isHiddenSection || isIgnored { - Button { - audioManager.unhideDevice(device) - } label: { - Label("Stop Ignoring", systemImage: "eye") + if showCategoryPicker { + Button { + audioManager.setCategory(.speaker, for: device) + } label: { + Label("Move to Speakers", systemImage: "speaker.wave.2.fill") + } + Button { + audioManager.setCategory(.headphone, for: device) + } label: { + Label("Move to Headphones", systemImage: "headphones") + } + Divider() } - } else { - if let onHide { + + if isHiddenSection || isIgnored { Button { - onHide(device) + audioManager.unhideDevice(device) } label: { - let categoryLabel = device.type == .input ? "microphone" : - (category == .headphone ? "headphone" : "speaker") - Label("Ignore as \(categoryLabel)", systemImage: "eye.slash") + Label("Stop Ignoring", systemImage: "eye") } - - if device.type == .output { + } else { + if let onHide { Button { - audioManager.hideDeviceEntirely(device) + onHide(device) } label: { - Label("Ignore entirely", systemImage: "eye.slash.fill") + let categoryLabel = device.type == .input ? "microphone" : + (category == .headphone ? "headphone" : "speaker") + Label("Ignore as \(categoryLabel)", systemImage: "eye.slash") + } + + if device.type == .output { + Button { + audioManager.hideDeviceEntirely(device) + } label: { + Label("Ignore entirely", systemImage: "eye.slash.fill") + } } } } - } - - if isDisconnected { - Divider() - Button(role: .destructive) { - audioManager.priorityManager.forgetDevice(device.uid) - audioManager.refreshDevices() - } label: { - Label("Forget Device", systemImage: "trash") + + if isDisconnected { + Divider() + Button(role: .destructive) { + audioManager.priorityManager.forgetDevice(device.uid) + audioManager.refreshDevices() + } label: { + Label("Forget Device", systemImage: "trash") + } } - } - - if device.isConnected { - Divider() - Button { - audioManager.setNeverUse(device, neverUse: !audioManager.isNeverUse(device)) - } label: { - if audioManager.isNeverUse(device) { - Label("Allow Use", systemImage: "checkmark.circle") - } else { - Label("Never Use", systemImage: "nosign") + + if device.isConnected { + Divider() + Button { + audioManager.setNeverUse(device, neverUse: !audioManager.isNeverUse(device)) + } label: { + if audioManager.isNeverUse(device) { + Label("Allow Use", systemImage: "checkmark.circle") + } else { + Label("Never Use", systemImage: "nosign") + } } } - } } label: { - Image(systemName: "ellipsis.circle") + Image(systemName: "ellipsis") .font(.system(size: 14)) .foregroundColor(.secondary) .frame(width: 28, height: 28) .contentShape(Rectangle()) } .menuStyle(.borderlessButton) - } - .transition(.opacity.combined(with: .scale(scale: 0.8))) + .menuIndicator(.hidden) + .opacity(isHovering && !isDragging ? 1 : 0) + .scaleEffect(isHovering && !isDragging ? 1 : (isTransitioning ? 0.9 : 0.8)) + .blur(radius: isTransitioning ? 1 : 0) + .allowsHitTesting(isHovering && !isDragging) } + .frame(width: 32) + .animation(.easeInOut(duration: 0.12), value: isHovering) + .animation(.easeInOut(duration: 0.12), value: isDragging) + .animation(.easeInOut(duration: 0.25), value: isTransitioning) } - .frame(width: 32) - .animation(.easeInOut(duration: 0.12), value: isHovering) - } - .padding(.leading, 8) - .padding(.trailing, 10) - .padding(.vertical, 5) - .opacity(isDragging ? 0.5 : (isGrayed ? 0.6 : 1.0)) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(isSelected && !isDisconnected ? Color.accentColor.opacity(0.12) : (isHovering ? Color.primary.opacity(0.06) : Color.clear)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(isSelected && !isDisconnected ? Color.accentColor.opacity(0.8) : Color.clear, lineWidth: 1.5) - ) - // Drop indicator above this row - .overlay(alignment: .top) { - if isDropTarget { - DropIndicatorLine() - .offset(y: -5) - .transition(.opacity.combined(with: .scale(scale: 0.8))) + .padding(.leading, 4) + .padding(.trailing, 4) + .padding(.vertical, 4) + .opacity(isDragging ? 0.5 : (isGrayed ? 0.6 : 1.0)) + .background( + RoundedRectangle(cornerRadius: 10) + .fill( + isSelected && !isDisconnected + ? Color(NSColor.controlBackgroundColor).opacity(0.4) + : (isHovering ? Color.primary.opacity(0.1) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke( + isSelected && !isDisconnected + ? Color.secondary.opacity(0.2) + : Color.clear, + lineWidth: 1 + ) + ) + ) + // Drop indicator above this row + .overlay(alignment: .top) { + if isDropTarget { + DropIndicatorLine() + .offset(y: -5) + .transition(.opacity.combined(with: .scale(scale: 0.8))) + } } - } - // Drop indicator below this row (for last position) - .overlay(alignment: .bottom) { - if isDropTargetBelow { - DropIndicatorLine() - .offset(y: 5) - .transition(.opacity.combined(with: .scale(scale: 0.8))) + // Drop indicator below this row (for last position) + .overlay(alignment: .bottom) { + if isDropTargetBelow { + DropIndicatorLine() + .offset(y: 5) + .transition(.opacity.combined(with: .scale(scale: 0.8))) + } } - } - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.12)) { - isHovering = hovering + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.12)) { + isHovering = hovering + } } - } - // Highlight the dragged row with a border instead of moving it - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(isDragging ? Color.accentColor : Color.clear, lineWidth: 2) - ) - .scaleEffect(isDragging ? 1.02 : 1.0) - .animation(.easeInOut(duration: 0.15), value: isHovering) - .animation(.easeInOut(duration: 0.15), value: isSelected) - .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isDragging) - .animation(.easeInOut(duration: 0.1), value: isDropTarget) - .animation(.easeInOut(duration: 0.1), value: isDropTargetBelow) - .contentShape(Rectangle()) - .onTapGesture { - if !isDisconnected && audioManager.isCustomMode { - onSelect() + .onChange(of: isHovering) { _, _ in + isTransitioning = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isTransitioning = false + } } - } - .gesture( - DragGesture(minimumDistance: 5) - .onChanged { value in - if !isDragging { - onDragStarted() + // Highlight the dragged row with a border instead of moving it + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(isDragging ? Color.accentColor : Color.clear, lineWidth: 2) + ) + .scaleEffect(isDragging ? 1.02 : 1.0) + .animation(.easeInOut(duration: 0.15), value: isHovering) + .animation(.easeInOut(duration: 0.15), value: isSelected) + .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isDragging) + .animation(.easeInOut(duration: 0.1), value: isDropTarget) + .animation(.easeInOut(duration: 0.1), value: isDropTargetBelow) + .contentShape(Rectangle()) + .onTapGesture { + if !isDisconnected && audioManager.isCustomMode { + onSelect() + } + } + .gesture( + DragGesture(minimumDistance: 5) + .onChanged { value in + if !isDragging { + onDragStarted() + } + let newTarget = calculateTarget(offset: value.translation.height) + if newTarget != lastReportedTarget { + lastReportedTarget = newTarget + onTargetChanged(newTarget) + } } - let newTarget = calculateTarget(offset: value.translation.height) - if newTarget != lastReportedTarget { - lastReportedTarget = newTarget - onTargetChanged(newTarget) + .onEnded { _ in + lastReportedTarget = nil + onDragEnded() } - } - .onEnded { _ in - lastReportedTarget = nil - onDragEnded() - } - ) + ) + } else { + // Fallback on earlier versions + } } } diff --git a/AudioPriorityBar/Views/MenuBarView.swift b/AudioPriorityBar/Views/MenuBarView.swift index 3ad55c2..2843856 100644 --- a/AudioPriorityBar/Views/MenuBarView.swift +++ b/AudioPriorityBar/Views/MenuBarView.swift @@ -14,10 +14,9 @@ struct MenuBarView: View { } .padding(.horizontal, 16) .padding(.vertical, 14) - .background(Color.primary.opacity(0.02)) + .padding(.vertical, 8) Divider() - .padding(.horizontal, 12) ScrollView { VStack(spacing: 20) { @@ -39,7 +38,7 @@ struct MenuBarView: View { onUnhide: { audioManager.unhideDevice($0, category: .speaker) }, category: .speaker, showCategoryPicker: true, - isActiveCategory: audioManager.currentMode == .speaker || audioManager.isCustomMode + isActiveCategory: false ) } @@ -61,7 +60,7 @@ struct MenuBarView: View { onUnhide: { audioManager.unhideDevice($0, category: .headphone) }, category: .headphone, showCategoryPicker: true, - isActiveCategory: audioManager.currentMode == .headphone || audioManager.isCustomMode + isActiveCategory: false ) } @@ -76,120 +75,114 @@ struct MenuBarView: View { onHide: { audioManager.hideDevice($0, category: nil) }, onUnhide: { audioManager.unhideDevice($0, category: nil) }, category: nil, - showCategoryPicker: false + showCategoryPicker: false, + isActiveCategory: false ) } - .padding(.horizontal, 16) - .padding(.vertical, 14) + .padding(.horizontal, 8) + .padding(.vertical, 8) } - .frame(maxHeight: 420) + .frame(minHeight: 280, maxHeight: 480) + .padding(.vertical, 8) + .background(Color.primary.opacity(0.03)) Divider() - .padding(.horizontal, 12) // Footer - HStack(spacing: 16) { - // Hidden devices toggle (only in normal mode) - if !audioManager.isEditMode { - HiddenDevicesToggleView() - .transition(.opacity.combined(with: .scale(scale: 0.9))) - } + VStack(spacing: 4) { + // Standard menu items + EditModeToggle() - Spacer() - - // Launch at login toggle LaunchAtLoginToggle() - // Edit mode toggle - Button { - withAnimation(.easeInOut(duration: 0.2)) { - audioManager.toggleEditMode() - } - } label: { - HStack(spacing: 4) { - Image(systemName: audioManager.isEditMode ? "checkmark.circle.fill" : "pencil.circle") - .font(.system(size: 12)) - Text(audioManager.isEditMode ? "Done" : "Edit") - .font(.system(size: 12, weight: .medium)) - } - .foregroundColor(audioManager.isEditMode ? .accentColor : .secondary) - } - .buttonStyle(.plain) - .animation(.easeInOut(duration: 0.2), value: audioManager.isEditMode) + Divider() - // Quit button Button { NSApplication.shared.terminate(nil) } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 14)) - .foregroundColor(.secondary.opacity(0.6)) + Text("Quit") + .frame(maxWidth: .infinity, alignment: .leading) } - .buttonStyle(.plain) - .help("Quit") + .buttonStyle(MenuItemButtonStyle()) } - .padding(.horizontal, 16) - .padding(.vertical, 10) + .padding(.vertical, 8) + .padding(.horizontal, 8) .animation(.easeInOut(duration: 0.2), value: audioManager.isEditMode) } .frame(width: 340) } } +private struct SegmentButton: View { + let icon: String + let label: String + let isSelected: Bool + let namespace: Namespace.ID + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 14)) + .frame(width: 14, height: 14) + Text(label) + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + } + .padding(.horizontal, 8) + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .foregroundColor(isSelected ? .primary : .secondary) + .background { + if isSelected { + RoundedRectangle(cornerRadius: 8) + .fill(Color(NSColor.controlBackgroundColor).opacity(0.7)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary.opacity(0.2), lineWidth: 1) + ) + .matchedGeometryEffect(id: "segment", in: namespace) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + struct ModeToggleView: View { @EnvironmentObject var audioManager: AudioManager + @Namespace private var segmentAnimation var body: some View { - HStack(spacing: 4) { + HStack(spacing: 0) { ForEach(OutputCategory.allCases, id: \.self) { mode in - let isSelected = audioManager.currentMode == mode && !audioManager.isCustomMode - Button { - withAnimation(.easeInOut(duration: 0.2)) { + SegmentButton( + icon: mode.icon, + label: mode.label, + isSelected: !audioManager.isCustomMode && audioManager.currentMode == mode, + namespace: segmentAnimation + ) { + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { if audioManager.isCustomMode { audioManager.setCustomMode(false) } audioManager.setMode(mode) } - } label: { - HStack(spacing: 5) { - Image(systemName: mode.icon) - .font(.system(size: 11)) - Text(mode.label) - .font(.system(size: 12, weight: .medium)) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(isSelected ? Color.accentColor : Color.clear) - ) - .foregroundColor(isSelected ? .white : .secondary) } - .buttonStyle(.plain) } - // Custom mode toggle - Button { - withAnimation(.easeInOut(duration: 0.2)) { + SegmentButton( + icon: "hand.raised.fill", + label: "Manual", + isSelected: audioManager.isCustomMode, + namespace: segmentAnimation + ) { + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { audioManager.setCustomMode(!audioManager.isCustomMode) } - } label: { - Image(systemName: "hand.raised.fill") - .font(.system(size: 12)) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .contentShape(Rectangle()) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(audioManager.isCustomMode ? Color.orange : Color.clear) - ) - .foregroundColor(audioManager.isCustomMode ? .white : .secondary) } - .buttonStyle(.plain) .help("Manual mode - disable auto-switching") } .padding(4) @@ -197,13 +190,13 @@ struct ModeToggleView: View { RoundedRectangle(cornerRadius: 12) .fill(Color.primary.opacity(0.05)) ) - .animation(.easeInOut(duration: 0.2), value: audioManager.currentMode) - .animation(.easeInOut(duration: 0.2), value: audioManager.isCustomMode) } } struct VolumeSliderView: View { @EnvironmentObject var audioManager: AudioManager + @State private var isEditing = false + @State private var isTransitioning = false var volumeIcon: String { if audioManager.currentMode == .headphone { @@ -223,18 +216,35 @@ struct VolumeSliderView: View { var body: some View { HStack(spacing: 10) { - Image(systemName: volumeIcon) - .font(.system(size: 13)) - .foregroundColor(.accentColor) - .frame(width: 20) - .animation(.easeInOut(duration: 0.15), value: volumeIcon) + if #available(macOS 14.0, *) { + Image(systemName: volumeIcon) + .font(.system(size: 13)) + .frame(width: 20, height: 14) + .foregroundColor(isEditing ? .accentColor : .primary) + .scaleEffect(isEditing ? 1.05 : (isTransitioning ? 0.9 : 1)) + .blur(radius: isTransitioning ? 1 : 0) + .opacity(isTransitioning ? 0.5 : 1) + .animation(.easeInOut(duration: 0.2), value: isEditing) + .animation(.easeInOut(duration: 0.25), value: isTransitioning) + .onChange(of: isEditing) { _, _ in + isTransitioning = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isTransitioning = false + } + } + } else { + // Fallback on earlier versions + } Slider( value: Binding( get: { Double(audioManager.volume) }, set: { audioManager.setVolume(Float($0)) } ), - in: 0...1 + in: 0...1, + onEditingChanged: { editing in + isEditing = editing + } ) .controlSize(.small) @@ -243,6 +253,7 @@ struct VolumeSliderView: View { .foregroundColor(.secondary) .frame(width: 36, alignment: .trailing) } + .padding(.horizontal, 4) .onScrollWheel { delta in let newVolume = audioManager.volume + Float(delta * 0.02) audioManager.setVolume(max(0, min(1, newVolume))) @@ -303,17 +314,13 @@ struct DeviceSectionView: View { var isActiveCategory: Bool = true var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 6) { - Image(systemName: icon) - .font(.system(size: 11)) - .foregroundColor(isActiveCategory ? .accentColor : .secondary) - Text(title) - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(.secondary) - .textCase(.uppercase) - .tracking(0.5) - } + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.secondary) + .textCase(.uppercase) + .tracking(0.5) + .padding(.horizontal, 12) if devices.isEmpty { Text("No devices") @@ -429,7 +436,7 @@ struct HiddenDeviceRow: View { .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 10) - .fill(isHovering ? Color.primary.opacity(0.06) : Color.clear) + .fill(isHovering ? Color.primary.opacity(0.1) : Color.clear) ) .animation(.easeInOut(duration: 0.15), value: isHovering) .onHover { hovering in @@ -440,24 +447,75 @@ struct HiddenDeviceRow: View { } } +struct EditModeToggle: View { + @EnvironmentObject var audioManager: AudioManager + + var hiddenCount: Int { + audioManager.hiddenInputDevices.count + + audioManager.hiddenSpeakerDevices.count + + audioManager.hiddenHeadphoneDevices.count + } + + var body: some View { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + audioManager.toggleEditMode() + } + } label: { + HStack(spacing: 8) { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .semibold)) + .opacity(audioManager.isEditMode ? 1 : 0) + .frame(width: 14) + Text(audioManager.isEditMode ? "Done Editing" : "Edit Devices...") + Spacer() + if !audioManager.isEditMode && hiddenCount > 0 { + Text("\(hiddenCount) ignored") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + } + .buttonStyle(MenuItemButtonStyle()) + } +} + struct LaunchAtLoginToggle: View { @StateObject private var launchManager = LaunchAtLoginManager.shared - + var body: some View { Button { - withAnimation(.easeInOut(duration: 0.15)) { - launchManager.isEnabled.toggle() - } + launchManager.isEnabled.toggle() } label: { - HStack(spacing: 4) { - Image(systemName: launchManager.isEnabled ? "power.circle.fill" : "power.circle") - .font(.system(size: 12)) - Text("Login") - .font(.system(size: 11, weight: .medium)) + HStack(spacing: 8) { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .semibold)) + .opacity(launchManager.isEnabled ? 1 : 0) + .frame(width: 14) + Text("Launch at Login") + Spacer() } - .foregroundColor(launchManager.isEnabled ? .accentColor : .secondary) } - .buttonStyle(.plain) - .help(launchManager.isEnabled ? "Disable launch at login" : "Enable launch at login") + .buttonStyle(MenuItemButtonStyle()) + } +} + +struct MenuItemButtonStyle: ButtonStyle { + @State private var isHovering = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 13)) + .foregroundColor(.primary) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isHovering ? Color.primary.opacity(0.1) : Color.clear) + ) + .onHover { hovering in + isHovering = hovering + } } }