diff --git a/VoiceInk/Views/AI Models/APIKeyManagementView.swift b/VoiceInk/Views/AI Models/APIKeyManagementView.swift index d502b9a..20f3cc4 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,97 @@ 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() - } - } 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) + if !ollamaModels.isEmpty { + Divider() + + Picker("Model", selection: $selectedOllamaModel) { + ForEach(ollamaModels) { model in + Text(model.name).tag(model.name) + } } + .onChange(of: selectedOllamaModel) { oldValue, newValue in + aiService.updateSelectedOllamaModel(newValue) + } + } + + } else if aiService.selectedProvider == .custom { + // Custom Configuration inline + TextField("API Endpoint URL", text: $aiService.customBaseURL) + .textFieldStyle(.roundedBorder) + + Divider() + + TextField("Model Name", text: $aiService.customModel) + .textFieldStyle(.roundedBorder) + + Divider() + + if aiService.isAPIKeyValid { + HStack { + Text("API Key Set") + Spacer() + Button("Remove Key", role: .destructive) { + aiService.clearAPIKey() + } + } + } else { + SecureField("API Key", text: $apiKey) + .textFieldStyle(.roundedBorder) + + 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) } - // 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() - } - } - } - } - .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) - - 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 { - // API Key Input for other providers - VStack(alignment: .leading, spacing: 8) { - Text("Enter your API Key") - .font(.subheadline) - .foregroundColor(.secondary) - - SecureField("API Key", text: $apiKey) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .font(.system(.body, design: .monospaced)) - + // 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 +243,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 +283,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/ContentView.swift b/VoiceInk/Views/ContentView.swift index 017502a..f189abe 100644 --- a/VoiceInk/Views/ContentView.swift +++ b/VoiceInk/Views/ContentView.swift @@ -142,7 +142,8 @@ struct ContentView: View { } } .navigationSplitViewStyle(.balanced) - .frame(minWidth: 940, minHeight: 730) + .frame(width: 950) + .frame(minHeight: 730) .onReceive(NotificationCenter.default.publisher(for: .navigateToDestination)) { notification in if let destination = notification.userInfo?["destination"] as? String { switch destination { diff --git a/VoiceInk/Views/EnhancementSettingsView.swift b/VoiceInk/Views/EnhancementSettingsView.swift index a1826ca..60cb4dc 100644 --- a/VoiceInk/Views/EnhancementSettingsView.swift +++ b/VoiceInk/Views/EnhancementSettingsView.swift @@ -4,117 +4,176 @@ import UniformTypeIdentifiers struct EnhancementSettingsView: View { @EnvironmentObject private var enhancementService: AIEnhancementService @State private var isEditingPrompt = false - @State private var isSettingsExpanded = true + @State private var isShortcutsExpanded = false @State private var selectedPromptForEdit: CustomPrompt? + private var isPanelOpen: Bool { + isEditingPrompt || selectedPromptForEdit != nil + } + + private func closePanel() { + withAnimation(.spring(response: 0.4, dampingFraction: 0.9)) { + isEditingPrompt = false + selectedPromptForEdit = nil + } + } + 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)) - } + ZStack(alignment: .topLeading) { + 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)) + .toggleStyle(.switch) - // 1. AI Provider Integration Section - VStack(alignment: .leading, spacing: 16) { - Text("AI Provider Integration") - .font(.headline) + 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) - APIKeyManagementView() + 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) } - .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 + .opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8) + } header: { + Text("General") + } + + APIKeyManagementView() + .opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8) + + Section { + ReorderablePromptGrid( + selectedPromptId: enhancementService.selectedPromptId, + onPromptSelected: { prompt in + enhancementService.setActivePrompt(prompt) + }, + onEditPrompt: { prompt in + withAnimation(.spring(response: 0.4, dampingFraction: 0.9)) { selectedPromptForEdit = prompt - }, - onDeletePrompt: { prompt in - enhancementService.deletePrompt(prompt) - }, - onAddNewPrompt: { + } + }, + onDeletePrompt: { prompt in + enhancementService.deletePrompt(prompt) + } + ) + .padding(.vertical, 8) + } header: { + HStack { + Text("Enhancement Prompts") + Spacer() + Button { + withAnimation(.spring(response: 0.4, dampingFraction: 0.9)) { isEditingPrompt = true } - ) + } label: { + Image(systemName: "plus.circle.fill") + .font(.system(size: 18)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Add new prompt") } - .padding() - .background(CardBackground(isSelected: false)) - - EnhancementShortcutsSection() } + .opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8) + + Section { + DisclosureGroup(isExpanded: $isShortcutsExpanded) { + EnhancementShortcutsView() + .padding(.vertical, 8) + } label: { + HStack { + Text("Shortcuts") + .font(.headline) + .foregroundColor(.primary) + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation { + isShortcutsExpanded.toggle() + } + } + } + } + .opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8) + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + .background(Color(NSColor.controlBackgroundColor)) + .disabled(isPanelOpen) + .blur(radius: isPanelOpen ? 2 : 0) + .animation(.spring(response: 0.4, dampingFraction: 0.9), value: isPanelOpen) + + if isPanelOpen { + Color.black.opacity(0.2) + .ignoresSafeArea() + .onTapGesture { + closePanel() + } + .transition(.opacity) + .zIndex(1) + } + + if isPanelOpen { + HStack(spacing: 0) { + Spacer() + + Group { + if let prompt = selectedPromptForEdit { + PromptEditorView(mode: .edit(prompt)) { + closePanel() + } + } else if isEditingPrompt { + PromptEditorView(mode: .add) { + closePanel() + } + } + } + .frame(width: 450) + .frame(maxHeight: .infinity) + .background( + Color(NSColor.windowBackgroundColor) + ) + .overlay( + Divider(), alignment: .leading + ) + .shadow(color: .black.opacity(0.15), radius: 12, x: -4, y: 0) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + .ignoresSafeArea() + .zIndex(2) } - .padding(24) - } - .frame(minWidth: 600, minHeight: 500) - .background(Color(NSColor.controlBackgroundColor)) - .sheet(isPresented: $isEditingPrompt) { - PromptEditorView(mode: .add) - } - .sheet(item: $selectedPromptForEdit) { prompt in - PromptEditorView(mode: .edit(prompt)) } + .frame(minWidth: 500, minHeight: 400) } } -// MARK: - Drag & Drop Reorderable Grid +// MARK: - Reorderable Grid private struct ReorderablePromptGrid: View { @EnvironmentObject private var enhancementService: AIEnhancementService @@ -122,7 +181,6 @@ private struct ReorderablePromptGrid: View { let onPromptSelected: (CustomPrompt) -> Void let onEditPrompt: ((CustomPrompt) -> Void)? let onDeletePrompt: ((CustomPrompt) -> Void)? - let onAddNewPrompt: (() -> Void)? @State private var draggingItem: CustomPrompt? @@ -174,32 +232,18 @@ private struct ReorderablePromptGrid: View { ) ) } - - if let onAddNewPrompt = onAddNewPrompt { - CustomPrompt.addNewButton { - onAddNewPrompt() - } - .help("Add new prompt") - .onDrop( - of: [UTType.text], - delegate: PromptEndDropDelegate( - prompts: $enhancementService.customPrompts, - draggingItem: $draggingItem - ) - ) - } } .padding(.vertical, 12) .padding(.horizontal, 16) HStack { Image(systemName: "info.circle") - .font(.caption) - .foregroundColor(.secondary) + .font(.caption) + .foregroundColor(.secondary) Text("Double-click to edit • Right-click for more options") - .font(.caption) - .foregroundColor(.secondary) + .font(.caption) + .foregroundColor(.secondary) } .padding(.top, 8) .padding(.horizontal, 16) @@ -208,7 +252,7 @@ private struct ReorderablePromptGrid: View { } } -// MARK: - Drop Delegates +// MARK: - Drop Delegate private struct PromptDropDelegate: DropDelegate { let item: CustomPrompt @Binding var prompts: [CustomPrompt] @@ -219,7 +263,6 @@ private struct PromptDropDelegate: DropDelegate { guard let fromIndex = prompts.firstIndex(of: draggingItem), let toIndex = prompts.firstIndex(of: item) else { return } - // Move item as you hover for immediate visual update if prompts[toIndex].id != draggingItem.id { withAnimation(.easeInOut(duration: 0.12)) { let from = fromIndex @@ -238,26 +281,3 @@ private struct PromptDropDelegate: DropDelegate { return true } } - -private struct PromptEndDropDelegate: DropDelegate { - @Binding var prompts: [CustomPrompt] - @Binding var draggingItem: CustomPrompt? - - func validateDrop(info: DropInfo) -> Bool { true } - func dropUpdated(info: DropInfo) -> DropProposal? { DropProposal(operation: .move) } - - func performDrop(info: DropInfo) -> Bool { - guard let draggingItem = draggingItem, - let currentIndex = prompts.firstIndex(of: draggingItem) else { - self.draggingItem = nil - return false - } - - // Move to end if dropped on the trailing "Add New" tile - withAnimation(.easeInOut(duration: 0.12)) { - prompts.move(fromOffsets: IndexSet(integer: currentIndex), toOffset: prompts.endIndex) - } - self.draggingItem = nil - return true - } -} diff --git a/VoiceInk/Views/PromptEditorView.swift b/VoiceInk/Views/PromptEditorView.swift index 0a005d6..bace524 100644 --- a/VoiceInk/Views/PromptEditorView.swift +++ b/VoiceInk/Views/PromptEditorView.swift @@ -20,12 +20,12 @@ struct PromptEditorView: View { let mode: Mode @Environment(\.dismiss) private var dismiss @EnvironmentObject private var enhancementService: AIEnhancementService + var onDismiss: (() -> Void)? @State private var title: String @State private var promptText: String @State private var selectedIcon: PromptIcon @State private var description: String @State private var triggerWords: [String] - @State private var showingPredefinedPrompts = false @State private var useSystemInstructions: Bool @State private var showingIconPicker = false @@ -36,8 +36,9 @@ struct PromptEditorView: View { return false } - init(mode: Mode) { + init(mode: Mode, onDismiss: (() -> Void)? = nil) { self.mode = mode + self.onDismiss = onDismiss switch mode { case .add: _title = State(initialValue: "") @@ -58,196 +59,251 @@ struct PromptEditorView: View { var body: some View { VStack(spacing: 0) { - // Header with modern styling - HStack { + HStack(spacing: 12) { Text(isEditingPredefinedPrompt ? "Edit Trigger Words" : (mode == .add ? "New Prompt" : "Edit Prompt")) - .font(.title2) - .fontWeight(.bold) + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(.primary) + Spacer() - HStack(spacing: 12) { - Button("Cancel") { + + Button(action: { + if let onDismiss = onDismiss { + onDismiss() + } else { dismiss() } - .buttonStyle(.plain) - .foregroundColor(.secondary) - - Button { - save() - dismiss() - } label: { - Text("Save") - .fontWeight(.medium) - } - .buttonStyle(.borderedProminent) - .disabled(isEditingPredefinedPrompt ? false : (title.isEmpty || promptText.isEmpty)) - .keyboardShortcut(.return, modifiers: .command) + }) { + Image(systemName: "xmark") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.secondary) + .padding(6) + .background(Color.secondary.opacity(0.1)) + .clipShape(Circle()) } + .buttonStyle(.plain) + .help("Close") } - .padding() - .background( - Color(NSColor.windowBackgroundColor) - .shadow(color: .black.opacity(0.1), radius: 8, y: 2) + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(Color(NSColor.windowBackgroundColor)) + .overlay( + Divider().opacity(0.5), alignment: .bottom ) ScrollView { VStack(spacing: 24) { if isEditingPredefinedPrompt { - // Simplified view for predefined prompts - only trigger word editing VStack(alignment: .leading, spacing: 16) { Text("Editing: \(title)") - .font(.title2) - .fontWeight(.semibold) + .font(.title3) + .fontWeight(.medium) .foregroundColor(.primary) - .padding(.horizontal) - .padding(.top, 8) Text("You can only customize the trigger words for system prompts.") .font(.subheadline) .foregroundColor(.secondary) - .padding(.horizontal) - // Trigger Words Field using reusable component TriggerWordsEditor(triggerWords: $triggerWords) - .padding(.horizontal) } + .padding(.horizontal, 20) .padding(.vertical, 20) } else { - // Full editing interface for custom prompts - // Title and Icon Section with improved layout - HStack(spacing: 20) { - // Title Field - VStack(alignment: .leading, spacing: 8) { - Text("Title") - .font(.headline) - .foregroundColor(.secondary) - TextField("Enter a short, descriptive title", text: $title) - .textFieldStyle(.roundedBorder) - .font(.body) - } - .frame(maxWidth: .infinity) - - // Icon Selector with preview - VStack(alignment: .leading, spacing: 8) { - Text("Icon") - .font(.headline) - .foregroundColor(.secondary) - - // Preview of selected icon - clickable to open popover (square button) - Button(action: { - showingIconPicker = true - }) { + VStack(spacing: 24) { + HStack(alignment: .top, spacing: 16) { + Button(action: { showingIconPicker = true }) { Image(systemName: selectedIcon) - .font(.system(size: 20)) + .font(.system(size: 24)) .foregroundColor(.primary) - .frame(width: 48, height: 48) + .frame(width: 56, height: 56) .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(8) + .cornerRadius(10) .overlay( - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: 10) .stroke(Color.secondary.opacity(0.2), lineWidth: 1) ) } .buttonStyle(.plain) + .popover(isPresented: $showingIconPicker, arrowEdge: .bottom) { + IconPickerPopover(selectedIcon: $selectedIcon, isPresented: $showingIconPicker) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Title") + .font(.subheadline) + .foregroundColor(.secondary) + + TextField("Prompt Name", text: $title) + .textFieldStyle(.plain) + .font(.system(size: 14)) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.secondary.opacity(0.2), lineWidth: 1) + .background(Color(NSColor.controlBackgroundColor).cornerRadius(6)) + ) + } } - .popover(isPresented: $showingIconPicker, arrowEdge: .bottom) { - IconPickerPopover(selectedIcon: $selectedIcon, isPresented: $showingIconPicker) + + VStack(alignment: .leading, spacing: 6) { + Text("Description") + .font(.subheadline) + .foregroundColor(.secondary) + + TextField("Brief description of what this prompt does", text: $description) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.secondary.opacity(0.2), lineWidth: 1) + .background(Color(NSColor.controlBackgroundColor).cornerRadius(6)) + ) } - } - .padding(.horizontal) - .padding(.top, 8) - - // Description Field - VStack(alignment: .leading, spacing: 8) { - Text("Description") - .font(.headline) - .foregroundColor(.secondary) - Text("Add a brief description of what this prompt does") - .font(.subheadline) - .foregroundColor(.secondary) + Divider().padding(.vertical, 4) - TextField("Enter a description", text: $description) - .textFieldStyle(.roundedBorder) - .font(.body) - } - .padding(.horizontal) - - // Prompt Text Section with improved styling - VStack(alignment: .leading, spacing: 8) { - Text("Prompt Instructions") - .font(.headline) - .foregroundColor(.secondary) - - Text("Define how AI should enhance your transcriptions") - .font(.subheadline) - .foregroundColor(.secondary) - - if !isEditingPredefinedPrompt { - HStack(spacing: 8) { - Toggle("Use System Instructions", isOn: $useSystemInstructions) + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Text("Instructions") + .font(.headline) + .foregroundColor(.primary) InfoTip( - title: "System Instructions", - message: "If enabled, your instructions are combined with a general-purpose template to improve transcription quality.\n\nDisable for full control over the AI's system prompt (for advanced users)." + title: "Instructions", + message: "Define how AI should process the text." ) } - .padding(.bottom, 4) + + ZStack(alignment: .topLeading) { + TextEditor(text: $promptText) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 180) + .padding(8) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.secondary.opacity(0.2), lineWidth: 1) + ) + + if promptText.isEmpty { + Text("Enter your custom prompt instructions here...") + .font(.system(.body, design: .monospaced)) + .foregroundColor(.secondary.opacity(0.5)) + .padding(.horizontal, 12) + .padding(.vertical, 12) + .allowsHitTesting(false ) + } + } + + if !isEditingPredefinedPrompt { + HStack(spacing: 8) { + Toggle("Use System Template", isOn: $useSystemInstructions) + .toggleStyle(.switch) + .controlSize(.small) + + InfoTip( + title: "System Instructions", + message: "If enabled, your instructions are combined with a general-purpose template to improve transcription quality.\n\nDisable for full control over the AI's system prompt (for advanced users)." + ) + } + .padding(.top, 4) + } } - - TextEditor(text: $promptText) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 200) - .padding(12) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(Color(NSColor.textBackgroundColor)) - ) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.secondary.opacity(0.2), lineWidth: 1) - ) - } - .padding(.horizontal) - - // Trigger Words Field using reusable component - TriggerWordsEditor(triggerWords: $triggerWords) - .padding(.horizontal) - - if case .add = mode { - // Popover keeps templates accessible without taking space in the layout - Button("Start with a Predefined Template") { - showingPredefinedPrompts.toggle() - } - .font(.headline) - .padding(.horizontal, 24) - .padding(.vertical, 12) - .background( - Capsule() - .fill(Color(.windowBackgroundColor).opacity(0.9)) - ) - .overlay( - Capsule() - .stroke(Color.secondary.opacity(0.2), lineWidth: 1) - ) - .buttonStyle(.plain) - .padding(.horizontal) - .popover(isPresented: $showingPredefinedPrompts, arrowEdge: .bottom) { - PredefinedPromptsView { template in - title = template.title - promptText = template.promptText - selectedIcon = template.icon - description = template.description - showingPredefinedPrompts = false + + Divider().padding(.vertical, 4) + + TriggerWordsEditor(triggerWords: $triggerWords) + + if case .add = mode, !isEditingPredefinedPrompt { + HStack { + Menu { + ForEach(PromptTemplates.all, id: \.title) { template in + Button { + title = template.title + promptText = template.promptText + selectedIcon = template.icon + description = template.description + } label: { + HStack { + Text(template.title) + Spacer() + Image(systemName: template.icon) + } + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: "sparkles") + .foregroundColor(.accentColor) + Text("Start with Template") + .foregroundColor(.primary) + Image(systemName: "chevron.down") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.vertical, 6) + .padding(.horizontal, 10) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.secondary.opacity(0.15), lineWidth: 1) + ) + } + .menuStyle(.borderlessButton) + .fixedSize() + + Spacer() } } } + .padding(.horizontal, 20) + .padding(.vertical, 20) } } - .padding(.vertical, 20) + } + + VStack(spacing: 0) { + Divider() + HStack { + Button("Cancel") { + if let onDismiss = onDismiss { + onDismiss() + } else { + dismiss() + } + } + .keyboardShortcut(.escape, modifiers: []) + .buttonStyle(.plain) + .foregroundColor(.secondary) + + Spacer() + + Button { + save() + if let onDismiss = onDismiss { + onDismiss() + } else { + dismiss() + } + } label: { + Text("Save Changes") + .frame(minWidth: 100) + } + .buttonStyle(.borderedProminent) + .disabled(isEditingPredefinedPrompt ? false : (title.isEmpty || promptText.isEmpty)) + .keyboardShortcut(.return, modifiers: .command) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .background(Color(NSColor.windowBackgroundColor)) } } - .frame(minWidth: 700, minHeight: 500) + .frame(minWidth: 400, minHeight: 500) + .background(Color(NSColor.windowBackgroundColor)) } private func save() { @@ -278,45 +334,65 @@ struct PromptEditorView: View { } } -// Reusable Trigger Words Editor Component +// MARK: - Trigger Words Editor struct TriggerWordsEditor: View { @Binding var triggerWords: [String] @State private var newTriggerWord: String = "" var body: some View { VStack(alignment: .leading, spacing: 8) { - Text("Trigger Words") - .font(.headline) - .foregroundColor(.secondary) + HStack(spacing: 6) { + Text("Trigger Words") + .font(.subheadline) + .foregroundColor(.secondary) + + InfoTip( + title: "Trigger Words", + message: "Add multiple words that can activate this prompt." + ) + } - Text("Add multiple words that can activate this prompt") - .font(.subheadline) - .foregroundColor(.secondary) + HStack { + TextField("Add trigger word (e.g. 'summarize')", text: $newTriggerWord) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .padding(6) + .background( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.secondary.opacity(0.2), lineWidth: 1) + .background(Color(NSColor.controlBackgroundColor).cornerRadius(6)) + ) + .onSubmit { + addTriggerWord() + } + + Button(action: { addTriggerWord() }) { + Image(systemName: "plus") + .font(.system(size: 12, weight: .bold)) + .frame(width: 26, height: 26) + .background(Color.accentColor.opacity(0.1)) + .foregroundColor(.accentColor) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + .disabled(newTriggerWord.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } - // Display existing trigger words as tags if !triggerWords.isEmpty { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 140, maximum: 220))], spacing: 8) { + TagLayout(alignment: .leading, spacing: 6) { ForEach(triggerWords, id: \.self) { word in TriggerWordItemView(word: word) { triggerWords.removeAll { $0 == word } } } } - } - - // Input for new trigger word - HStack { - TextField("Add trigger word", text: $newTriggerWord) - .textFieldStyle(.roundedBorder) - .font(.body) - .onSubmit { - addTriggerWord() - } - - Button("Add") { - addTriggerWord() - } - .disabled(newTriggerWord.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .padding(.top, 4) + } else { + Text("No trigger words added") + .font(.caption) + .foregroundColor(.secondary.opacity(0.7)) + .italic() + .padding(.top, 2) } } } @@ -325,7 +401,6 @@ struct TriggerWordsEditor: View { let trimmedWord = newTriggerWord.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedWord.isEmpty else { return } - // Check for duplicates (case insensitive) let lowerCaseWord = trimmedWord.lowercased() guard !triggerWords.contains(where: { $0.lowercased() == lowerCaseWord }) else { return } @@ -334,49 +409,90 @@ struct TriggerWordsEditor: View { } } - +// MARK: - Trigger Word Item struct TriggerWordItemView: View { let word: String let onDelete: () -> Void @State private var isHovered = false var body: some View { - HStack(spacing: 6) { - Text(word) - .font(.system(size: 13)) - .lineLimit(1) - .foregroundColor(.primary) - - Spacer(minLength: 8) + HStack(spacing: 4) { + Text(word) + .font(.system(size: 12)) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: 120, alignment: .leading) + .foregroundColor(.primary) Button(action: onDelete) { - Image(systemName: "xmark.circle.fill") - .symbolRenderingMode(.hierarchical) - .foregroundStyle(isHovered ? .red : .secondary) - .contentTransition(.symbolEffect(.replace)) - } - .buttonStyle(.borderless) - .help("Remove word") - .onHover { hover in - withAnimation(.easeInOut(duration: 0.2)) { - isHovered = hover - } + Image(systemName: "xmark") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.secondary) } + .buttonStyle(.plain) + .padding(.leading, 2) } .padding(.horizontal, 8) - .padding(.vertical, 6) - .background { - RoundedRectangle(cornerRadius: 6) - .fill(Color(.windowBackgroundColor).opacity(0.4)) - } - .overlay { - RoundedRectangle(cornerRadius: 6) + .padding(.vertical, 4) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(4) + .overlay( + RoundedRectangle(cornerRadius: 4) .stroke(Color.secondary.opacity(0.2), lineWidth: 1) + ) + } +} + +// MARK: - Tag Layout +struct TagLayout: Layout { + var alignment: Alignment = .leading + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var height: CGFloat = 0 + var currentRowWidth: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + + if currentRowWidth + size.width > maxWidth { + // New row + height += size.height + spacing + currentRowWidth = size.width + spacing + } else { + // Same row + currentRowWidth += size.width + spacing + } + + if height == 0 { + height = size.height + } + } + + return CGSize(width: maxWidth, height: height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var x = bounds.minX + var y = bounds.minY + let maxHeight = subviews.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + + if x + size.width > bounds.maxX { + x = bounds.minX + y += maxHeight + spacing + } + + subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing } } } -// Icon Picker Popover - shows icons in a grid format without category labels +// MARK: - Icon Picker struct IconPickerPopover: View { @Binding var selectedIcon: PromptIcon @Binding var isPresented: Bool diff --git a/VoiceInk/Views/Settings/EnhancementShortcutsView.swift b/VoiceInk/Views/Settings/EnhancementShortcutsView.swift index 4c5be1f..a34acb1 100644 --- a/VoiceInk/Views/Settings/EnhancementShortcutsView.swift +++ b/VoiceInk/Views/Settings/EnhancementShortcutsView.swift @@ -3,180 +3,81 @@ 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) } } -struct EnhancementShortcutsSection: View { - @State private var isExpanded = false - - var body: some View { - VStack(spacing: 0) { - Button { - withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { - isExpanded.toggle() - } - } 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) - } - - Spacer() - - Image(systemName: "chevron.down") - .rotationEffect(.degrees(isExpanded ? 0 : -90)) - .foregroundColor(.secondary) - .font(.system(size: 14, weight: .semibold)) - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - 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 - ) - ) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .background(CardBackground(isSelected: false)) - } -} - // 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) } } diff --git a/VoiceInk/VoiceInk.swift b/VoiceInk/VoiceInk.swift index ff32b05..2526e6a 100644 --- a/VoiceInk/VoiceInk.swift +++ b/VoiceInk/VoiceInk.swift @@ -276,6 +276,8 @@ struct VoiceInkApp: App { } } .windowStyle(.hiddenTitleBar) + .defaultSize(width: 950, height: 730) + .windowResizability(.contentSize) .commands { CommandGroup(replacing: .newItem) { }