From 8f099cf70188eafdfa58d899618525ab8467b25a Mon Sep 17 00:00:00 2001 From: Beingpax Date: Mon, 5 Jan 2026 14:48:22 +0545 Subject: [PATCH] Improve prompt editor --- VoiceInk/Views/EnhancementSettingsView.swift | 274 +++++----- VoiceInk/Views/PromptEditorView.swift | 508 ++++++++++++------- 2 files changed, 461 insertions(+), 321 deletions(-) diff --git a/VoiceInk/Views/EnhancementSettingsView.swift b/VoiceInk/Views/EnhancementSettingsView.swift index c3dfd67..60cb4dc 100644 --- a/VoiceInk/Views/EnhancementSettingsView.swift +++ b/VoiceInk/Views/EnhancementSettingsView.swift @@ -7,110 +7,173 @@ struct EnhancementSettingsView: View { @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 { - 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" - ) - } - } - .toggleStyle(.switch) - - // Context Toggles in the same row - HStack(spacing: 24) { - Toggle(isOn: $enhancementService.useClipboardContext) { + ZStack(alignment: .topLeading) { + Form { + Section { + Toggle(isOn: $enhancementService.isEnhancementEnabled) { HStack(spacing: 4) { - Text("Clipboard Context") + Text("Enable Enhancement") InfoTip( - title: "Clipboard Context", - message: "Use text from clipboard to understand the context" + 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" ) } } .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" - ) + 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") + } + + 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) + } + ) + .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") } - .toggleStyle(.switch) } .opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8) - } header: { - Text("General") - } - - // 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 { - DisclosureGroup(isExpanded: $isShortcutsExpanded) { - EnhancementShortcutsView() - .padding(.vertical, 8) - } label: { - HStack { - Text("Shortcuts") + + Section { + DisclosureGroup(isExpanded: $isShortcutsExpanded) { + EnhancementShortcutsView() + .padding(.vertical, 8) + } label: { + HStack { + Text("Shortcuts") .font(.headline) .foregroundColor(.primary) - Spacer() - } - .contentShape(Rectangle()) - .onTapGesture { - withAnimation { - isShortcutsExpanded.toggle() + 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) } - .opacity(enhancementService.isEnhancementEnabled ? 1.0 : 0.8) } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - .background(Color(NSColor.controlBackgroundColor)) .frame(minWidth: 500, minHeight: 400) - .sheet(isPresented: $isEditingPrompt) { - PromptEditorView(mode: .add) - } - .sheet(item: $selectedPromptForEdit) { prompt in - PromptEditorView(mode: .edit(prompt)) - } } } -// MARK: - Drag & Drop Reorderable Grid +// MARK: - Reorderable Grid private struct ReorderablePromptGrid: View { @EnvironmentObject private var enhancementService: AIEnhancementService @@ -118,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? @@ -170,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) @@ -204,7 +252,7 @@ private struct ReorderablePromptGrid: View { } } -// MARK: - Drop Delegates +// MARK: - Drop Delegate private struct PromptDropDelegate: DropDelegate { let item: CustomPrompt @Binding var prompts: [CustomPrompt] @@ -215,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 @@ -234,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