diff --git a/VoiceInk/Models/PredefinedModels.swift b/VoiceInk/Models/PredefinedModels.swift index 00f234c..d3d8b7a 100644 --- a/VoiceInk/Models/PredefinedModels.swift +++ b/VoiceInk/Models/PredefinedModels.swift @@ -92,7 +92,7 @@ import Foundation name: "parakeet-tdt-0.6b", displayName: "Parakeet V3", description: "NVIDIA's ASR model V3 for lightning-fast transcription with multi-lingual(English + European) support.", - size: "500 MB", + size: "630 MB", speed: 0.99, accuracy: 0.94, ramUsage: 0.8, diff --git a/VoiceInk/PowerMode/PowerModePopover.swift b/VoiceInk/PowerMode/PowerModePopover.swift index ff552b9..61e1fed 100644 --- a/VoiceInk/PowerMode/PowerModePopover.swift +++ b/VoiceInk/PowerMode/PowerModePopover.swift @@ -77,12 +77,12 @@ struct PowerModeRow: View { HStack(spacing: 8) { Text(config.emoji) .font(.system(size: 14)) - + Text(config.name) .foregroundColor(.white.opacity(0.9)) .font(.system(size: 13)) .lineLimit(1) - + if isSelected { Spacer() Image(systemName: "checkmark") @@ -90,9 +90,10 @@ struct PowerModeRow: View { .font(.system(size: 10)) } } - .contentShape(Rectangle()) + .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 4) .padding(.horizontal, 8) + .contentShape(Rectangle()) } .buttonStyle(.plain) .background(isSelected ? Color.white.opacity(0.1) : Color.clear) diff --git a/VoiceInk/Views/Recorder/EnhancementPromptPopover.swift b/VoiceInk/Views/Recorder/EnhancementPromptPopover.swift index 3aefce5..7ff4ae0 100644 --- a/VoiceInk/Views/Recorder/EnhancementPromptPopover.swift +++ b/VoiceInk/Views/Recorder/EnhancementPromptPopover.swift @@ -73,12 +73,12 @@ struct EnhancementPromptRow: View { Image(systemName: prompt.icon.rawValue) .font(.system(size: 14)) .foregroundColor(isDisabled ? .white.opacity(0.4) : .white.opacity(0.7)) - + Text(prompt.title) .foregroundColor(isDisabled ? .white.opacity(0.4) : .white.opacity(0.9)) .font(.system(size: 13)) .lineLimit(1) - + if isSelected { Spacer() Image(systemName: "checkmark") @@ -86,9 +86,10 @@ struct EnhancementPromptRow: View { .font(.system(size: 10)) } } - .contentShape(Rectangle()) + .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 4) .padding(.horizontal, 8) + .contentShape(Rectangle()) } .buttonStyle(.plain) .background(isSelected ? Color.white.opacity(0.1) : Color.clear) diff --git a/VoiceInk/Views/Recorder/MiniRecorderView.swift b/VoiceInk/Views/Recorder/MiniRecorderView.swift index 99aaa5b..6a6e888 100644 --- a/VoiceInk/Views/Recorder/MiniRecorderView.swift +++ b/VoiceInk/Views/Recorder/MiniRecorderView.swift @@ -6,8 +6,7 @@ struct MiniRecorderView: View { @EnvironmentObject var windowManager: MiniWindowManager @EnvironmentObject private var enhancementService: AIEnhancementService - @State private var showPowerModePopover = false - @State private var showEnhancementPromptPopover = false + @State private var activePopover: ActivePopoverState = .none private var backgroundView: some View { ZStack { @@ -36,19 +35,19 @@ struct MiniRecorderView: View { private var contentLayout: some View { HStack(spacing: 0) { // Left button zone - always visible - RecorderPromptButton(showPopover: $showEnhancementPromptPopover) + RecorderPromptButton(activePopover: $activePopover) .padding(.leading, 7) - + Spacer() - + // Fixed visualizer zone statusView .frame(maxWidth: .infinity) - + Spacer() - + // Right button zone - always visible - RecorderPowerModeButton(showPopover: $showPowerModePopover) + RecorderPowerModeButton(activePopover: $activePopover) .padding(.trailing, 7) } .padding(.vertical, 9) diff --git a/VoiceInk/Views/Recorder/NotchRecorderView.swift b/VoiceInk/Views/Recorder/NotchRecorderView.swift index 356b605..8a20436 100644 --- a/VoiceInk/Views/Recorder/NotchRecorderView.swift +++ b/VoiceInk/Views/Recorder/NotchRecorderView.swift @@ -5,8 +5,7 @@ struct NotchRecorderView: View { @ObservedObject var recorder: Recorder @EnvironmentObject var windowManager: NotchWindowManager @State private var isHovering = false - @State private var showPowerModePopover = false - @State private var showEnhancementPromptPopover = false + @State private var activePopover: ActivePopoverState = .none @ObservedObject private var powerModeManager = PowerModeManager.shared @EnvironmentObject private var enhancementService: AIEnhancementService @@ -32,19 +31,19 @@ struct NotchRecorderView: View { } private var leftSection: some View { - HStack(spacing: 8) { + HStack(spacing: 12) { RecorderPromptButton( - showPopover: $showEnhancementPromptPopover, + activePopover: $activePopover, buttonSize: 22, padding: EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) ) - + RecorderPowerModeButton( - showPopover: $showPowerModePopover, + activePopover: $activePopover, buttonSize: 22, padding: EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) ) - + Spacer() } .frame(width: 84) diff --git a/VoiceInk/Views/Recorder/RecorderComponents.swift b/VoiceInk/Views/Recorder/RecorderComponents.swift index a793b6c..6cc2706 100644 --- a/VoiceInk/Views/Recorder/RecorderComponents.swift +++ b/VoiceInk/Views/Recorder/RecorderComponents.swift @@ -1,5 +1,12 @@ import SwiftUI +// MARK: - Shared Popover State +enum ActivePopoverState { + case none + case enhancement + case power +} + // MARK: - Hover Interaction Manager class HoverInteraction: ObservableObject { @Published var isHovered: Bool = false @@ -144,13 +151,15 @@ struct ProgressAnimation: View { // MARK: - Prompt Button Component struct RecorderPromptButton: View { @EnvironmentObject private var enhancementService: AIEnhancementService - @Binding var showPopover: Bool + @Binding var activePopover: ActivePopoverState let buttonSize: CGFloat let padding: EdgeInsets - @StateObject private var hoverInteraction = HoverInteraction() + @State private var isHoveringEnhancement: Bool = false + @State private var isHoveringEnhancementPopover: Bool = false + @State private var enhancementDismissWorkItem: DispatchWorkItem? - init(showPopover: Binding, buttonSize: CGFloat = 28, padding: EdgeInsets = EdgeInsets(top: 0, leading: 7, bottom: 0, trailing: 0)) { - self._showPopover = showPopover + init(activePopover: Binding, buttonSize: CGFloat = 28, padding: EdgeInsets = EdgeInsets(top: 0, leading: 7, bottom: 0, trailing: 0)) { + self._activePopover = activePopover self.buttonSize = buttonSize self.padding = padding } @@ -163,23 +172,42 @@ struct RecorderPromptButton: View { disabled: false ) { if enhancementService.isEnhancementEnabled { - showPopover.toggle() + activePopover = activePopover == .enhancement ? .none : .enhancement } else { enhancementService.isEnhancementEnabled = true } } .frame(width: buttonSize) .padding(padding) - .onHover { hoverInteraction.setHover(on: $0) } - .popover(isPresented: $showPopover, arrowEdge: .bottom) { + .onHover { + isHoveringEnhancement = $0 + syncEnhancementPopoverVisibility() + } + .popover(isPresented: .constant(activePopover == .enhancement), arrowEdge: .bottom) { EnhancementPromptPopover() .environmentObject(enhancementService) - .onHover { hoverInteraction.setHover(on: $0) } + .onHover { + isHoveringEnhancementPopover = $0 + syncEnhancementPopoverVisibility() + } } - .onChange(of: hoverInteraction.isHovered) { isHovered in - if isHovered != showPopover { - showPopover = isHovered + } + + private func syncEnhancementPopoverVisibility() { + let shouldShow = isHoveringEnhancement || isHoveringEnhancementPopover + if shouldShow { + enhancementDismissWorkItem?.cancel() + enhancementDismissWorkItem = nil + activePopover = .enhancement + } else { + enhancementDismissWorkItem?.cancel() + let work = DispatchWorkItem { [activePopoverBinding = $activePopover] in + if activePopoverBinding.wrappedValue == .enhancement { + activePopoverBinding.wrappedValue = .none + } } + enhancementDismissWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work) } } } @@ -187,13 +215,15 @@ struct RecorderPromptButton: View { // MARK: - Power Mode Button Component struct RecorderPowerModeButton: View { @ObservedObject private var powerModeManager = PowerModeManager.shared - @Binding var showPopover: Bool + @Binding var activePopover: ActivePopoverState let buttonSize: CGFloat let padding: EdgeInsets - @StateObject private var hoverInteraction = HoverInteraction() + @State private var isHoveringPower: Bool = false + @State private var isHoveringPowerPopover: Bool = false + @State private var powerDismissWorkItem: DispatchWorkItem? - init(showPopover: Binding, buttonSize: CGFloat = 28, padding: EdgeInsets = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 7)) { - self._showPopover = showPopover + init(activePopover: Binding, buttonSize: CGFloat = 28, padding: EdgeInsets = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 7)) { + self._activePopover = activePopover self.buttonSize = buttonSize self.padding = padding } @@ -205,19 +235,38 @@ struct RecorderPowerModeButton: View { color: .orange, disabled: powerModeManager.enabledConfigurations.isEmpty ) { - showPopover.toggle() + activePopover = activePopover == .power ? .none : .power } .frame(width: buttonSize) .padding(padding) - .onHover { hoverInteraction.setHover(on: $0) } - .popover(isPresented: $showPopover, arrowEdge: .bottom) { - PowerModePopover() - .onHover { hoverInteraction.setHover(on: $0) } + .onHover { + isHoveringPower = $0 + syncPowerPopoverVisibility() } - .onChange(of: hoverInteraction.isHovered) { isHovered in - if isHovered != showPopover { - showPopover = isHovered + .popover(isPresented: .constant(activePopover == .power), arrowEdge: .bottom) { + PowerModePopover() + .onHover { + isHoveringPowerPopover = $0 + syncPowerPopoverVisibility() + } + } + } + + private func syncPowerPopoverVisibility() { + let shouldShow = isHoveringPower || isHoveringPowerPopover + if shouldShow { + powerDismissWorkItem?.cancel() + powerDismissWorkItem = nil + activePopover = .power + } else { + powerDismissWorkItem?.cancel() + let work = DispatchWorkItem { [activePopoverBinding = $activePopover] in + if activePopoverBinding.wrappedValue == .power { + activePopoverBinding.wrappedValue = .none + } } + powerDismissWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work) } } }