diff --git a/VoiceInk/Views/AI Models/APIKeyManagementView.swift b/VoiceInk/Views/AI Models/APIKeyManagementView.swift index d502b9a..a02aeb1 100644 --- a/VoiceInk/Views/AI Models/APIKeyManagementView.swift +++ b/VoiceInk/Views/AI Models/APIKeyManagementView.swift @@ -13,74 +13,95 @@ struct APIKeyManagementView: View { @State private var isEditingURL = false var body: some View { - VStack(alignment: .leading, spacing: 16) { - // Provider Selection + Section("AI Provider Integration") { HStack { - Picker("AI Provider", selection: $aiService.selectedProvider) { + Picker("Provider", selection: $aiService.selectedProvider) { ForEach(AIProvider.allCases.filter { $0 != .elevenLabs && $0 != .deepgram && $0 != .soniox }, id: \.self) { provider in Text(provider.rawValue).tag(provider) } } + .pickerStyle(.automatic) + .tint(.blue) - Spacer() - + // Show connected status for all providers if aiService.isAPIKeyValid && aiService.selectedProvider != .ollama { - HStack(spacing: 6) { + Spacer() + Circle() + .fill(Color.green) + .frame(width: 8, height: 8) + Text("Connected") + .font(.subheadline) + .foregroundColor(.secondary) + } else if aiService.selectedProvider == .ollama { + Spacer() + if isCheckingOllama { + ProgressView() + .controlSize(.small) + } else if !ollamaModels.isEmpty { Circle() .fill(Color.green) .frame(width: 8, height: 8) - Text("Connected to") - .font(.caption) - Text(aiService.selectedProvider.rawValue) - .font(.caption.bold()) + Text("Connected") + .font(.subheadline) + .foregroundColor(.secondary) + } else { + Circle() + .fill(Color.red) + .frame(width: 8, height: 8) + Text("Disconnected") + .font(.subheadline) + .foregroundColor(.secondary) } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.secondary.opacity(0.1)) - .foregroundColor(.secondary) - .cornerRadius(6) } } - .onChange(of: aiService.selectedProvider) { oldValue, newValue in if aiService.selectedProvider == .ollama { checkOllamaConnection() } } - - // Model Selection - if aiService.selectedProvider == .openRouter { - HStack { + + VStack(alignment: .leading, spacing: 12) { + // Model Selection + if aiService.selectedProvider == .openRouter { if aiService.availableModels.isEmpty { - Text("No models loaded") - .foregroundColor(.secondary) + HStack { + Text("No models loaded") + .foregroundColor(.secondary) + Spacer() + Button(action: { + Task { + await aiService.fetchOpenRouterModels() + } + }) { + Label("Refresh", systemImage: "arrow.clockwise") + } + } } else { - Picker("Model", selection: Binding( - get: { aiService.currentModel }, - set: { aiService.selectModel($0) } - )) { - ForEach(aiService.availableModels, id: \.self) { model in - Text(model).tag(model) + HStack { + Picker("Model", selection: Binding( + get: { aiService.currentModel }, + set: { aiService.selectModel($0) } + )) { + ForEach(aiService.availableModels, id: \.self) { model in + Text(model).tag(model) + } + } + + Spacer() + + Button(action: { + Task { + await aiService.fetchOpenRouterModels() + } + }) { + Label("Refresh", systemImage: "arrow.clockwise") } } } - - - Button(action: { - Task { - await aiService.fetchOpenRouterModels() - } - }) { - Image(systemName: "arrow.clockwise") - } - .buttonStyle(.borderless) - .help("Refresh models") - } - } else if !aiService.availableModels.isEmpty && - aiService.selectedProvider != .ollama && - aiService.selectedProvider != .custom { - HStack { + } else if !aiService.availableModels.isEmpty && + aiService.selectedProvider != .ollama && + aiService.selectedProvider != .custom { Picker("Model", selection: Binding( get: { aiService.currentModel }, set: { aiService.selectModel($0) } @@ -90,62 +111,27 @@ struct APIKeyManagementView: View { } } } - } - - if aiService.selectedProvider == .ollama { - VStack(alignment: .leading, spacing: 16) { - // Header with status - HStack { - Label("Ollama Configuration", systemImage: "server.rack") - .font(.headline) - - Spacer() - - HStack(spacing: 6) { - Circle() - .fill(isCheckingOllama ? Color.orange : (ollamaModels.isEmpty ? Color.red : Color.green)) - .frame(width: 8, height: 8) - Text(isCheckingOllama ? "Checking..." : (ollamaModels.isEmpty ? "Disconnected" : "Connected")) - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.secondary.opacity(0.1)) - .cornerRadius(6) - } - - // Server URL - HStack { - Label("Server URL", systemImage: "link") - .font(.subheadline) - .foregroundColor(.secondary) - - Spacer() - - if isEditingURL { + + Divider() + + if aiService.selectedProvider == .ollama { + // Ollama Configuration inline + if isEditingURL { + HStack { TextField("Base URL", text: $ollamaBaseURL) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .frame(maxWidth: 200) + .textFieldStyle(.roundedBorder) Button("Save") { aiService.updateOllamaBaseURL(ollamaBaseURL) checkOllamaConnection() isEditingURL = false } - .buttonStyle(.bordered) - .controlSize(.small) - } else { - Text(ollamaBaseURL) - .font(.system(.body, design: .monospaced)) - .foregroundColor(.primary) - - Button(action: { isEditingURL = true }) { - Image(systemName: "pencil") - } - .buttonStyle(.borderless) - .controlSize(.small) - + } + } else { + HStack { + Text("Server: \(ollamaBaseURL)") + Spacer() + Button("Edit") { isEditingURL = true } Button(action: { ollamaBaseURL = "http://localhost:11434" aiService.updateOllamaBaseURL(ollamaBaseURL) @@ -153,190 +139,90 @@ struct APIKeyManagementView: View { }) { Image(systemName: "arrow.counterclockwise") } - .buttonStyle(.borderless) - .foregroundColor(.secondary) - .controlSize(.small) + .help("Reset to default") } } - // Model selection and refresh - HStack { - Label("Model", systemImage: "cpu") - .font(.subheadline) - .foregroundColor(.secondary) - - Spacer() - - if ollamaModels.isEmpty { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - Text("No models available") - .foregroundColor(.secondary) - .italic() + if !ollamaModels.isEmpty { + Picker("Model", selection: $selectedOllamaModel) { + ForEach(ollamaModels) { model in + Text(model.name).tag(model.name) } - } else { - Picker("", selection: $selectedOllamaModel) { - ForEach(ollamaModels) { model in - Text(model.name).tag(model.name) - } - } - .onChange(of: selectedOllamaModel) { oldValue, newValue in - aiService.updateSelectedOllamaModel(newValue) - } - .labelsHidden() - .frame(maxWidth: 150) } - - Button(action: { checkOllamaConnection() }) { - Label(isCheckingOllama ? "Refreshing..." : "Refresh", systemImage: isCheckingOllama ? "arrow.triangle.2.circlepath" : "arrow.clockwise") - .font(.caption) - } - .disabled(isCheckingOllama) - .buttonStyle(.bordered) - .controlSize(.small) - } - } - .padding() - .background(Color.secondary.opacity(0.03)) - .cornerRadius(12) - - } else if aiService.selectedProvider == .custom { - VStack(alignment: .leading, spacing: 16) { - // Header - VStack(alignment: .leading, spacing: 4) { - Text("Custom Provider Configuration") - .font(.headline) - HStack(spacing: 4) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - .font(.caption) - Text("Requires OpenAI-compatible API endpoint") - .font(.caption) - .foregroundColor(.secondary) + .onChange(of: selectedOllamaModel) { oldValue, newValue in + aiService.updateSelectedOllamaModel(newValue) } } - // Configuration Fields - VStack(alignment: .leading, spacing: 8) { - if !aiService.isAPIKeyValid { - TextField("API Endpoint URL (e.g., https://api.example.com/v1/chat/completions)", text: $aiService.customBaseURL) - .textFieldStyle(.roundedBorder) - - TextField("Model Name (e.g., gpt-4o-mini, claude-3-5-sonnet-20240620)", text: $aiService.customModel) - .textFieldStyle(.roundedBorder) - } else { - VStack(alignment: .leading, spacing: 8) { - Text("API Endpoint URL") - .font(.subheadline) - .foregroundColor(.secondary) - Text(aiService.customBaseURL) - .font(.system(.body, design: .monospaced)) - - Text("Model") - .font(.subheadline) - .foregroundColor(.secondary) - Text(aiService.customModel) - .font(.system(.body, design: .monospaced)) - } - } - - if aiService.isAPIKeyValid { - Text("API Key") - .font(.subheadline) - .foregroundColor(.secondary) - - HStack { - Text(String(repeating: "•", count: 40)) - .font(.system(.body, design: .monospaced)) - - Spacer() - - Button(action: { - aiService.clearAPIKey() - }) { - Label("Remove Key", systemImage: "trash") - .foregroundColor(.red) - } - .buttonStyle(.borderless) - } - } else { - Text("Enter your API Key") - .font(.subheadline) - .foregroundColor(.secondary) - - SecureField("API Key", text: $apiKey) - .textFieldStyle(.roundedBorder) - .font(.system(.body, design: .monospaced)) - - HStack { - Button(action: { - isVerifying = true - aiService.saveAPIKey(apiKey) { success, errorMessage in - isVerifying = false - if !success { - alertMessage = errorMessage ?? "Verification failed" - showAlert = true - } - apiKey = "" - } - }) { - HStack { - if isVerifying { - ProgressView() - .scaleEffect(0.5) - .frame(width: 16, height: 16) - } else { - Image(systemName: "checkmark.circle.fill") - } - Text("Verify and Save") - } - } - .disabled(aiService.customBaseURL.isEmpty || aiService.customModel.isEmpty || apiKey.isEmpty) - - Spacer() - } - } + Button("Refresh Connection") { + checkOllamaConnection() } - } - .padding() - .background(Color.secondary.opacity(0.03)) - .cornerRadius(12) - } else { - // API Key Display for other providers if valid - if aiService.isAPIKeyValid { - VStack(alignment: .leading, spacing: 8) { - Text("API Key") - .font(.subheadline) - .foregroundColor(.secondary) - + + } else if aiService.selectedProvider == .custom { + // Custom Configuration inline + TextField("API Endpoint URL", text: $aiService.customBaseURL) + TextField("Model Name", text: $aiService.customModel) + + if aiService.isAPIKeyValid { HStack { - Text(String(repeating: "•", count: 40)) - .font(.system(.body, design: .monospaced)) - + Text("API Key Set") Spacer() - - Button(action: { + Button("Remove Key", role: .destructive) { aiService.clearAPIKey() - }) { - Label("Remove Key", systemImage: "trash") - .foregroundColor(.red) } - .buttonStyle(.borderless) } - } - } else { - // API Key Input for other providers - VStack(alignment: .leading, spacing: 8) { - Text("Enter your API Key") - .font(.subheadline) - .foregroundColor(.secondary) - + } else { SecureField("API Key", text: $apiKey) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .font(.system(.body, design: .monospaced)) - + Button("Verify and Save") { + isVerifying = true + aiService.saveAPIKey(apiKey) { success, errorMessage in + isVerifying = false + if !success { + alertMessage = errorMessage ?? "Verification failed" + showAlert = true + } + apiKey = "" + } + } + .disabled(aiService.customBaseURL.isEmpty || aiService.customModel.isEmpty || apiKey.isEmpty) + } + + } else { + // API Key Display for other providers + if aiService.isAPIKeyValid { HStack { + Text("API Key") + Spacer() + Text("••••••••") + .foregroundColor(.secondary) + Button("Remove", role: .destructive) { + aiService.clearAPIKey() + } + } + } else { + SecureField("API Key", text: $apiKey) + .textFieldStyle(.roundedBorder) + + HStack { + // Get API Key Link + if let url = getAPIKeyURL() { + Link(destination: url) { + HStack { + Image(systemName: "key.fill") + Text("Get API Key") + } + .font(.caption) + .foregroundColor(.blue) + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background(Color.blue.opacity(0.1)) + .cornerRadius(6) + } + .buttonStyle(.plain) + } + + Spacer() + Button(action: { isVerifying = true aiService.saveAPIKey(apiKey) { success, errorMessage in @@ -350,66 +236,12 @@ struct APIKeyManagementView: View { }) { HStack { if isVerifying { - ProgressView() - .scaleEffect(0.5) - .frame(width: 16, height: 16) - } else { - Image(systemName: "checkmark.circle.fill") + ProgressView().controlSize(.small) } Text("Verify and Save") } } - - Spacer() - - HStack(spacing: 8) { - Text((aiService.selectedProvider == .groq || aiService.selectedProvider == .gemini || aiService.selectedProvider == .cerebras) ? "Free" : "Paid") - .font(.caption2) - .foregroundColor(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.secondary.opacity(0.1)) - .cornerRadius(4) - - if aiService.selectedProvider != .ollama && aiService.selectedProvider != .custom { - Button { - let url = switch aiService.selectedProvider { - case .groq: - URL(string: "https://console.groq.com/keys")! - case .openAI: - URL(string: "https://platform.openai.com/api-keys")! - case .gemini: - URL(string: "https://makersuite.google.com/app/apikey")! - case .anthropic: - URL(string: "https://console.anthropic.com/settings/keys")! - case .mistral: - URL(string: "https://console.mistral.ai/api-keys")! - case .elevenLabs: - URL(string: "https://elevenlabs.io/speech-synthesis")! - case .deepgram: - URL(string: "https://console.deepgram.com/api-keys")! - case .soniox: - URL(string: "https://console.soniox.com/")! - case .ollama, .custom: - URL(string: "")! // This case should never be reached - case .openRouter: - URL(string: "https://openrouter.ai/keys")! - case .cerebras: - URL(string: "https://cloud.cerebras.ai/")! - } - NSWorkspace.shared.open(url) - } label: { - HStack(spacing: 4) { - Text("Get API Key") - .foregroundColor(.accentColor) - Image(systemName: "arrow.up.right") - .font(.caption) - .foregroundColor(.accentColor) - } - } - .buttonStyle(.plain) - } - } + .disabled(apiKey.isEmpty) } } } @@ -444,8 +276,19 @@ struct APIKeyManagementView: View { } } - private func formatSize(_ bytes: Int64) -> String { - let gigabytes = Double(bytes) / 1_000_000_000 - return String(format: "%.1f GB", gigabytes) + private func getAPIKeyURL() -> URL? { + switch aiService.selectedProvider { + case .groq: return URL(string: "https://console.groq.com/keys") + case .openAI: return URL(string: "https://platform.openai.com/api-keys") + case .gemini: return URL(string: "https://makersuite.google.com/app/apikey") + case .anthropic: return URL(string: "https://console.anthropic.com/settings/keys") + case .mistral: return URL(string: "https://console.mistral.ai/api-keys") + case .elevenLabs: return URL(string: "https://elevenlabs.io/speech-synthesis") + case .deepgram: return URL(string: "https://console.deepgram.com/api-keys") + case .soniox: return URL(string: "https://console.soniox.com/") + case .openRouter: return URL(string: "https://openrouter.ai/keys") + case .cerebras: return URL(string: "https://cloud.cerebras.ai/") + default: return nil + } } } diff --git a/VoiceInk/Views/EnhancementSettingsView.swift b/VoiceInk/Views/EnhancementSettingsView.swift index a1826ca..31f56a3 100644 --- a/VoiceInk/Views/EnhancementSettingsView.swift +++ b/VoiceInk/Views/EnhancementSettingsView.swift @@ -8,103 +8,84 @@ struct EnhancementSettingsView: View { @State private var selectedPromptForEdit: CustomPrompt? var body: some View { - ScrollView { - VStack(spacing: 32) { - // Main Settings Sections - VStack(spacing: 24) { - // Enable/Disable Toggle Section - VStack(alignment: .leading, spacing: 12) { - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Enable Enhancement") - .font(.headline) - - InfoTip( - title: "AI Enhancement", - message: "AI enhancement lets you pass the transcribed audio through LLMS to post-process using different prompts suitable for different use cases like e-mails, summary, writing, etc.", - learnMoreURL: "https://www.youtube.com/@tryvoiceink/videos" - ) - } - - Text("Turn on AI-powered enhancement features") - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - - Toggle("", isOn: $enhancementService.isEnhancementEnabled) - .toggleStyle(SwitchToggleStyle(tint: .blue)) - .labelsHidden() - .scaleEffect(1.2) - } - - HStack(spacing: 20) { - VStack(alignment: .leading, spacing: 4) { - Toggle("Clipboard Context", isOn: $enhancementService.useClipboardContext) - .toggleStyle(.switch) - .disabled(!enhancementService.isEnhancementEnabled) - Text("Use text from clipboard to understand the context") - .font(.caption) - .foregroundColor(enhancementService.isEnhancementEnabled ? .secondary : .secondary.opacity(0.5)) - } - - VStack(alignment: .leading, spacing: 4) { - Toggle("Context Awareness", isOn: $enhancementService.useScreenCaptureContext) - .toggleStyle(.switch) - .disabled(!enhancementService.isEnhancementEnabled) - Text("Learn what is on the screen to understand the context") - .font(.caption) - .foregroundColor(enhancementService.isEnhancementEnabled ? .secondary : .secondary.opacity(0.5)) - } - } - } - .padding() - .background(CardBackground(isSelected: false)) - - // 1. AI Provider Integration Section - VStack(alignment: .leading, spacing: 16) { - Text("AI Provider Integration") - .font(.headline) - - APIKeyManagementView() - } - .padding() - .background(CardBackground(isSelected: false)) - - // 3. Enhancement Modes & Assistant Section - VStack(alignment: .leading, spacing: 16) { - Text("Enhancement Prompt") - .font(.headline) - - // Reorderable prompts grid with drag-and-drop - ReorderablePromptGrid( - selectedPromptId: enhancementService.selectedPromptId, - onPromptSelected: { prompt in - enhancementService.setActivePrompt(prompt) - }, - onEditPrompt: { prompt in - selectedPromptForEdit = prompt - }, - onDeletePrompt: { prompt in - enhancementService.deletePrompt(prompt) - }, - onAddNewPrompt: { - isEditingPrompt = true - } + Form { + Section { + Toggle(isOn: $enhancementService.isEnhancementEnabled) { + HStack(spacing: 4) { + Text("Enable Enhancement") + InfoTip( + title: "AI Enhancement", + message: "AI enhancement lets you pass the transcribed audio through LLMs to post-process using different prompts suitable for different use cases like e-mails, summary, writing, etc.", + learnMoreURL: "https://tryvoiceink.com/docs/enhancements-configuring-models" ) } - .padding() - .background(CardBackground(isSelected: false)) - - EnhancementShortcutsSection() } + .toggleStyle(.switch) + + // Context Toggles in the same row + HStack(spacing: 24) { + Toggle(isOn: $enhancementService.useClipboardContext) { + HStack(spacing: 4) { + Text("Clipboard Context") + InfoTip( + title: "Clipboard Context", + message: "Use text from clipboard to understand the context" + ) + } + } + .toggleStyle(.switch) + + Toggle(isOn: $enhancementService.useScreenCaptureContext) { + HStack(spacing: 4) { + Text("Screen Context") + InfoTip( + title: "Context Awareness", + message: "Learn what is on the screen to understand the context" + ) + } + } + .toggleStyle(.switch) + } + .opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8) + } header: { + Text("General") } - .padding(24) + + // API Key Management (Consolidated Section) + APIKeyManagementView() + .opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8) + + Section("Enhancement Prompts") { + // Reorderable prompts grid with drag-and-drop + ReorderablePromptGrid( + selectedPromptId: enhancementService.selectedPromptId, + onPromptSelected: { prompt in + enhancementService.setActivePrompt(prompt) + }, + onEditPrompt: { prompt in + selectedPromptForEdit = prompt + }, + onDeletePrompt: { prompt in + enhancementService.deletePrompt(prompt) + }, + onAddNewPrompt: { + isEditingPrompt = true + } + ) + .padding(.vertical, 8) + } + .opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8) + + Section("Shortcuts") { + EnhancementShortcutsView() + .padding(.vertical, 8) + } + .opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8) } - .frame(minWidth: 600, minHeight: 500) + .formStyle(.grouped) + .scrollContentBackground(.hidden) .background(Color(NSColor.controlBackgroundColor)) + .frame(minWidth: 500, minHeight: 400) .sheet(isPresented: $isEditingPrompt) { PromptEditorView(mode: .add) } diff --git a/VoiceInk/Views/Settings/EnhancementShortcutsView.swift b/VoiceInk/Views/Settings/EnhancementShortcutsView.swift index 4c5be1f..4e85ffd 100644 --- a/VoiceInk/Views/Settings/EnhancementShortcutsView.swift +++ b/VoiceInk/Views/Settings/EnhancementShortcutsView.swift @@ -3,20 +3,56 @@ import KeyboardShortcuts struct EnhancementShortcutsView: View { @ObservedObject private var shortcutSettings = EnhancementShortcutSettings.shared - + var body: some View { - VStack(spacing: 12) { - ShortcutRow( - title: "Toggle AI Enhancement", - description: "Quickly enable or disable enhancement while recording.", - keyDisplay: ["⌘", "E"], - isOn: $shortcutSettings.isToggleEnhancementShortcutEnabled - ) - ShortcutRow( - title: "Switch Enhancement Prompt", - description: "Switch between your saved prompts without touching the UI. Use ⌘1–⌘0 to activate the corresponding prompt in the order they are saved.", - keyDisplay: ["⌘", "1 – 0"] - ) + VStack(spacing: 8) { + // Toggle AI Enhancement + HStack(alignment: .center, spacing: 12) { + HStack(spacing: 4) { + Text("Toggle AI Enhancement") + .font(.system(size: 13)) + + InfoTip( + title: "Toggle AI Enhancement", + message: "Quickly enable or disable AI enhancement while recording. Available only when VoiceInk is running and the recorder is visible.", + learnMoreURL: "https://tryvoiceink.com/docs/enhancement-shortcuts" + ) + } + + Spacer() + + HStack(spacing: 10) { + HStack(spacing: 4) { + KeyChip(label: "⌘") + KeyChip(label: "E") + } + + Toggle("", isOn: $shortcutSettings.isToggleEnhancementShortcutEnabled) + .toggleStyle(.switch) + .labelsHidden() + } + } + + // Switch Enhancement Prompt + HStack(alignment: .center, spacing: 12) { + HStack(spacing: 4) { + Text("Switch Enhancement Prompt") + .font(.system(size: 13)) + + InfoTip( + title: "Switch Enhancement Prompt", + message: "Switch between your saved prompts using ⌘1 through ⌘0 to activate the corresponding prompt in the order they are saved. Available only when VoiceInk is running and the recorder is visible.", + learnMoreURL: "https://tryvoiceink.com/docs/enhancement-shortcuts" + ) + } + + Spacer() + + HStack(spacing: 4) { + KeyChip(label: "⌘") + KeyChip(label: "1 – 0") + } + } } .background(Color.clear) } @@ -33,29 +69,19 @@ struct EnhancementShortcutsSection: View { } } label: { HStack(spacing: 12) { - Image(systemName: "command") - .font(.system(size: 20)) - .foregroundColor(.accentColor) - .frame(width: 24, height: 24) - - VStack(alignment: .leading, spacing: 2) { - Text("Enhancement Shortcuts") - .font(.headline) - .foregroundColor(.primary) - Text("Keep enhancement prompts handy") - .font(.subheadline) - .foregroundColor(.secondary) - } - + Text("Enhancement Shortcuts") + .font(.headline) + .foregroundColor(.primary) + Spacer() - + Image(systemName: "chevron.down") .rotationEffect(.degrees(isExpanded ? 0 : -90)) .foregroundColor(.secondary) - .font(.system(size: 14, weight: .semibold)) + .font(.system(size: 13, weight: .medium)) } .padding(.horizontal, 16) - .padding(.vertical, 14) + .padding(.vertical, 12) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -63,21 +89,16 @@ struct EnhancementShortcutsSection: View { if isExpanded { Divider() .transition(.opacity) - - VStack(alignment: .leading, spacing: 16) { - EnhancementShortcutsView() - - Text("Enhancement shortcuts are available only when the recorder is visible and VoiceInk is running.") - .font(.caption) - .foregroundColor(.secondary) - } - .padding(16) - .transition( - .asymmetric( - insertion: .opacity.combined(with: .scale(scale: 0.98, anchor: .top)), - removal: .opacity + + EnhancementShortcutsView() + .padding(.horizontal, 16) + .padding(.vertical, 12) + .transition( + .asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.98, anchor: .top)), + removal: .opacity + ) ) - ) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -86,97 +107,25 @@ struct EnhancementShortcutsSection: View { } // MARK: - Supporting Views -private struct ShortcutRow: View { - let title: String - let description: String - let keyDisplay: [String] - private var isOn: Binding? - - init(title: String, description: String, keyDisplay: [String], isOn: Binding? = nil) { - self.title = title - self.description = description - self.keyDisplay = keyDisplay - self.isOn = isOn - } - - var body: some View { - HStack(alignment: .center, spacing: 16) { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - Text(title) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.primary) - InfoTip(title: title, message: description, learnMoreURL: "https://tryvoiceink.com/docs/switching-enhancement-prompts") - } - - Text(description) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(2) - } - - Spacer(minLength: 12) - - if let isOn = isOn { - keyDisplayView(isActive: isOn.wrappedValue) - .onTapGesture { - withAnimation(.bouncy) { - isOn.wrappedValue.toggle() - } - } - .contentShape(Rectangle()) - } else { - keyDisplayView() - } - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .background(CardBackground(isSelected: false)) - } - - @ViewBuilder - private func keyDisplayView(isActive: Bool? = nil) -> some View { - HStack(spacing: 8) { - ForEach(keyDisplay, id: \.self) { key in - KeyChip(label: key, isActive: isActive) - } - } - } -} - private struct KeyChip: View { let label: String - var isActive: Bool? = nil var body: some View { - let active = isActive ?? true - Text(label) - .font(.system(size: 13, weight: .semibold, design: .rounded)) - .foregroundColor(active ? .primary : .secondary) - .padding(.horizontal, 10) - .padding(.vertical, 6) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundColor(.primary) + .padding(.horizontal, 8) + .padding(.vertical, 4) .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill( - LinearGradient( - gradient: Gradient(colors: [ - Color(NSColor.controlBackgroundColor).opacity(active ? 0.9 : 0.6), - Color(NSColor.controlBackgroundColor).opacity(active ? 0.7 : 0.5) - ]), - startPoint: .top, - endPoint: .bottom - ) - ) + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor)) ) .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) + RoundedRectangle(cornerRadius: 4, style: .continuous) .strokeBorder( - Color(NSColor.separatorColor).opacity(active ? 0.4 : 0.2), - lineWidth: 1 + Color(NSColor.separatorColor).opacity(0.5), + lineWidth: 0.5 ) ) - .shadow(color: Color(NSColor.shadowColor).opacity(active ? 0.15 : 0.05), radius: 2, x: 0, y: 1) - .opacity(active ? 1.0 : 0.6) } }