Merge pull request #465 from Beingpax/better-powermode-ui

Better powermode UI
This commit is contained in:
Prakash Joshi Pax 2026-01-04 19:33:03 +05:45 committed by GitHub
commit f712652278
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 509 additions and 678 deletions

View File

@ -99,7 +99,6 @@ struct EmojiPickerView: View {
}
}
.padding()
.background(.regularMaterial)
.frame(minWidth: 260, idealWidth: 300, maxWidth: 320, minHeight: 150, idealHeight: 280, maxHeight: 350)
.alert("Emoji in Use", isPresented: $showingEmojiInUseAlert, presenting: emojiForAlert) { emojiStr in
Button("OK", role: .cancel) { }
@ -161,10 +160,6 @@ private struct EmojiButton: View {
Text(emoji)
.font(.largeTitle)
.frame(width: 44, height: 44)
.background(
Circle()
.fill(isSelected ? Color.accentColor.opacity(0.25) : Color.clear)
)
.overlay(
Circle()
.strokeBorder(isSelected ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: isSelected ? 2 : 1)
@ -197,10 +192,6 @@ private struct AddEmojiButton: View {
.labelStyle(.iconOnly)
.foregroundColor(.accentColor)
.frame(width: 44, height: 44)
.background(
Circle()
.fill(Color.secondary.opacity(0.1))
)
.overlay(
Circle()
.strokeBorder(Color.gray.opacity(0.3), lineWidth: 1)

View File

@ -39,9 +39,7 @@ struct ConfigurationView: View {
@State private var isAutoSendEnabled = false
@State private var isDefault = false
// State for prompt editing (similar to EnhancementSettingsView)
@State private var isEditingPrompt = false
@State private var selectedPromptForEdit: CustomPrompt?
@State private var isShowingDeleteConfirmation = false
// PowerMode hotkey configuration
@State private var powerModeConfigId: UUID = UUID()
@ -126,550 +124,371 @@ struct ConfigurationView: View {
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header with Title and Cancel button
HStack {
Text(mode.title)
.font(.largeTitle)
.fontWeight(.bold)
Spacer()
if case .edit(let config) = mode {
Button("Delete") {
let alert = NSAlert()
alert.messageText = "Delete Power Mode?"
alert.informativeText = "Are you sure you want to delete the '\(config.name)' power mode? This action cannot be undone."
alert.alertStyle = .warning
alert.addButton(withTitle: "Delete")
alert.addButton(withTitle: "Cancel")
// Style the Delete button as destructive
alert.buttons[0].hasDestructiveAction = true
let response = alert.runModal()
if response == .alertFirstButtonReturn {
powerModeManager.removeConfiguration(with: config.id)
presentationMode.wrappedValue.dismiss()
Form {
Section("General") {
HStack(spacing: 12) {
Button {
isShowingEmojiPicker.toggle()
} label: {
Text(selectedEmoji)
.font(.system(size: 22))
.frame(width: 32, height: 32)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color(NSColor.controlBackgroundColor))
)
}
.buttonStyle(.plain)
.popover(isPresented: $isShowingEmojiPicker, arrowEdge: .bottom) {
EmojiPickerView(
selectedEmoji: $selectedEmoji,
isPresented: $isShowingEmojiPicker
)
}
TextField("Name", text: $configName)
.textFieldStyle(.roundedBorder)
.focused($isNameFieldFocused)
}
}
Section("Trigger Scenarios") {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Applications")
Spacer()
AddIconButton(helpText: "Add application") {
loadInstalledApps()
isShowingAppPicker = true
}
}
.foregroundColor(.red)
.padding(.trailing, 8)
if selectedAppConfigs.isEmpty {
Text("No applications added")
.foregroundColor(.secondary)
.font(.subheadline)
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 44, maximum: 50), spacing: 10)], spacing: 10) {
ForEach(selectedAppConfigs) { appConfig in
ZStack(alignment: .topTrailing) {
if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: appConfig.bundleIdentifier) {
Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path))
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 44, height: 44)
.clipShape(RoundedRectangle(cornerRadius: 10))
} else {
Image(systemName: "app.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 26, height: 26)
.frame(width: 44, height: 44)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color(NSColor.controlBackgroundColor))
)
}
Button {
selectedAppConfigs.removeAll(where: { $0.id == appConfig.id })
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 14))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
.offset(x: 6, y: -6)
}
}
}
.padding(.vertical, 2)
}
}
Button("Cancel") {
.padding(.vertical, 2)
VStack(alignment: .leading, spacing: 10) {
Text("Websites")
HStack {
TextField("Enter website URL (e.g., google.com)", text: $newWebsiteURL)
.textFieldStyle(.roundedBorder)
.onSubmit { addWebsite() }
AddIconButton(helpText: "Add website", isDisabled: newWebsiteURL.isEmpty) {
addWebsite()
}
}
if websiteConfigs.isEmpty {
Text("No websites added")
.foregroundColor(.secondary)
.font(.subheadline)
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 140, maximum: 220), spacing: 10)], spacing: 10) {
ForEach(websiteConfigs) { urlConfig in
HStack(spacing: 6) {
Image(systemName: "globe")
.foregroundColor(.secondary)
Text(urlConfig.url)
.lineLimit(1)
Spacer(minLength: 0)
Button {
websiteConfigs.removeAll(where: { $0.id == urlConfig.id })
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color(NSColor.controlBackgroundColor))
)
}
}
.padding(.vertical, 2)
}
}
.padding(.vertical, 2)
}
Section("Transcription") {
if whisperState.usableModels.isEmpty {
Text("No transcription models available. Please connect to a cloud service or download a local model in the AI Models tab.")
.foregroundColor(.secondary)
} else {
let modelBinding = Binding<String?>(
get: { selectedTranscriptionModelName ?? whisperState.usableModels.first?.name },
set: { selectedTranscriptionModelName = $0 }
)
Picker("Model", selection: modelBinding) {
ForEach(whisperState.usableModels, id: \.name) { model in
Text(model.displayName).tag(model.name as String?)
}
}
}
if languageSelectionDisabled() {
LabeledContent("Language") {
Text("Autodetected")
.foregroundColor(.secondary)
}
} else if let selectedModel = effectiveModelName,
let modelInfo = whisperState.allAvailableModels.first(where: { $0.name == selectedModel }),
modelInfo.isMultilingualModel {
let languageBinding = Binding<String?>(
get: { selectedLanguage ?? UserDefaults.standard.string(forKey: "SelectedLanguage") ?? "auto" },
set: { selectedLanguage = $0 }
)
Picker("Language", selection: languageBinding) {
ForEach(modelInfo.supportedLanguages.sorted(by: {
if $0.key == "auto" { return true }
if $1.key == "auto" { return false }
return $0.value < $1.value
}), id: \.key) { key, value in
Text(value).tag(key as String?)
}
}
} else if let selectedModel = effectiveModelName,
let modelInfo = whisperState.allAvailableModels.first(where: { $0.name == selectedModel }),
!modelInfo.isMultilingualModel {
EmptyView()
.onAppear {
if selectedLanguage == nil {
selectedLanguage = "en"
}
}
}
}
Section("AI Enhancement") {
Toggle("Enable AI Enhancement", isOn: $isAIEnhancementEnabled)
.onChange(of: isAIEnhancementEnabled) { _, newValue in
if newValue {
if selectedAIProvider == nil {
selectedAIProvider = aiService.selectedProvider.rawValue
}
if selectedAIModel == nil {
selectedAIModel = aiService.currentModel
}
if selectedPromptId == nil {
selectedPromptId = enhancementService.allPrompts.first?.id
}
}
}
let providerBinding = Binding<AIProvider>(
get: {
if let providerName = selectedAIProvider,
let provider = AIProvider(rawValue: providerName) {
return provider
}
return aiService.selectedProvider
},
set: { newValue in
selectedAIProvider = newValue.rawValue
aiService.selectedProvider = newValue
selectedAIModel = nil
}
)
if isAIEnhancementEnabled {
if aiService.connectedProviders.isEmpty {
LabeledContent("AI Provider") {
Text("No providers connected")
.foregroundColor(.secondary)
.italic()
}
} else {
Picker("AI Provider", selection: providerBinding) {
ForEach(aiService.connectedProviders.filter { $0 != .elevenLabs && $0 != .deepgram }, id: \.self) { provider in
Text(provider.rawValue).tag(provider)
}
}
.onChange(of: selectedAIProvider) { _, newValue in
if let provider = newValue.flatMap({ AIProvider(rawValue: $0) }) {
selectedAIModel = provider.defaultModel
}
}
}
let providerName = selectedAIProvider ?? aiService.selectedProvider.rawValue
if let provider = AIProvider(rawValue: providerName),
provider != .custom {
if aiService.availableModels.isEmpty {
LabeledContent("AI Model") {
Text(provider == .openRouter ? "No models loaded" : "No models available")
.foregroundColor(.secondary)
.italic()
}
} else {
let modelBinding = Binding<String>(
get: {
if let model = selectedAIModel, !model.isEmpty { return model }
return aiService.currentModel
},
set: { newModelValue in
selectedAIModel = newModelValue
aiService.selectModel(newModelValue)
}
)
let models = provider == .openRouter ? aiService.availableModels : (provider == .ollama ? aiService.availableModels : provider.availableModels)
Picker("AI Model", selection: modelBinding) {
ForEach(models, id: \.self) { model in
Text(model).tag(model)
}
}
if provider == .openRouter {
Button("Refresh Models") {
Task { await aiService.fetchOpenRouterModels() }
}
.help("Refresh models")
}
}
}
if enhancementService.allPrompts.isEmpty {
LabeledContent("Enhancement Prompt") {
Text("No prompts available")
.foregroundColor(.secondary)
}
} else {
Picker("Enhancement Prompt", selection: $selectedPromptId) {
ForEach(enhancementService.allPrompts) { prompt in
Text(prompt.title).tag(prompt.id as UUID?)
}
}
}
Toggle("Context Awareness", isOn: $useScreenCapture)
}
}
Section("Advanced") {
Toggle(isOn: $isDefault) {
HStack(spacing: 6) {
Text("Set as default")
InfoTip(
title: "Default Power Mode",
message: "Default power mode is used when no specific app or website matches are found"
)
}
}
Toggle(isOn: $isAutoSendEnabled) {
HStack(spacing: 6) {
Text("Auto Send")
InfoTip(
title: "Auto Send",
message: "Automatically presses the Return/Enter key after pasting text. This is useful for chat applications or forms where its not necessary to to make changes to the transcribed text"
)
}
}
HStack {
Text("Keyboard Shortcut")
InfoTip(
title: "Power Mode Hotkey",
message: "Assign a unique keyboard shortcut to instantly activate this Power Mode and start recording"
)
Spacer()
KeyboardShortcuts.Recorder(for: .powerMode(id: powerModeConfigId))
.controlSize(.regular)
.frame(minHeight: 28)
}
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.background(Color(NSColor.controlBackgroundColor))
.navigationTitle(mode.title)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Save") {
saveConfiguration()
}
.keyboardShortcut(.defaultAction)
.disabled(!canSave)
.buttonStyle(.bordered)
.controlSize(.regular)
.padding(.horizontal, 4)
}
if case .edit = mode {
ToolbarItem {
Button("Delete", role: .destructive) {
isShowingDeleteConfirmation = true
}
.buttonStyle(.bordered)
.controlSize(.regular)
.padding(.horizontal, 4)
}
}
}
.confirmationDialog(
"Delete Power Mode?",
isPresented: $isShowingDeleteConfirmation,
titleVisibility: .visible
) {
if case .edit(let config) = mode {
Button("Delete", role: .destructive) {
powerModeManager.removeConfiguration(with: config.id)
presentationMode.wrappedValue.dismiss()
}
.keyboardShortcut(.escape, modifiers: [])
}
.padding(.horizontal)
.padding(.top)
.padding(.bottom, 10)
Divider()
ScrollView {
VStack(spacing: 20) {
// Main Input Section
VStack(spacing: 16) {
HStack(spacing: 16) {
Button(action: {
isShowingEmojiPicker.toggle()
}) {
ZStack {
Circle()
.fill(Color.accentColor.opacity(0.15))
.frame(width: 48, height: 48)
Text(selectedEmoji)
.font(.system(size: 24))
}
}
.buttonStyle(.plain)
.popover(isPresented: $isShowingEmojiPicker, arrowEdge: .bottom) {
EmojiPickerView(
selectedEmoji: $selectedEmoji,
isPresented: $isShowingEmojiPicker
)
}
TextField("Name your power mode", text: $configName)
.font(.system(size: 18, weight: .bold))
.textFieldStyle(.plain)
.foregroundColor(.primary)
.tint(.accentColor)
.focused($isNameFieldFocused)
}
// Default Power Mode Toggle
HStack {
Toggle("Set as default power mode", isOn: $isDefault)
.font(.system(size: 14))
InfoTip(
title: "Default Power Mode",
message: "Default power mode is used when no specific app or website matches are found"
)
Spacer()
}
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
.background(CardBackground(isSelected: false))
.padding(.horizontal)
.onAppear {
// Add a small delay to ensure the view is fully loaded
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isNameFieldFocused = true
}
}
VStack(spacing: 16) {
SectionHeader(title: "When to Trigger")
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Applications")
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
Button(action: {
loadInstalledApps()
isShowingAppPicker = true
}) {
Label("Add App", systemImage: "plus.circle.fill")
.font(.subheadline)
}
.buttonStyle(.plain)
}
if selectedAppConfigs.isEmpty {
HStack {
Spacer()
Text("No applications added")
.foregroundColor(.secondary)
.font(.subheadline)
Spacer()
}
.padding()
.background(CardBackground(isSelected: false))
} else {
// Grid of selected apps that wraps to next line
LazyVGrid(columns: [GridItem(.adaptive(minimum: 50, maximum: 55), spacing: 10)], spacing: 10) {
ForEach(selectedAppConfigs) { appConfig in
VStack {
ZStack(alignment: .topTrailing) {
// App icon - completely filling the container
if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: appConfig.bundleIdentifier) {
Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path))
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 50, height: 50)
.clipShape(RoundedRectangle(cornerRadius: 10))
} else {
Image(systemName: "app.fill")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 50, height: 50)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
// Remove button
Button(action: {
selectedAppConfigs.removeAll(where: { $0.id == appConfig.id })
}) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 14))
.foregroundColor(.white)
.background(Circle().fill(Color.black.opacity(0.6)))
}
.buttonStyle(.plain)
.offset(x: 6, y: -6)
}
}
.frame(width: 50, height: 50)
.background(CardBackground(isSelected: false, cornerRadius: 10))
}
}
}
}
Divider()
VStack(alignment: .leading, spacing: 12) {
Text("Websites")
.font(.subheadline)
.foregroundColor(.secondary)
// Add URL Field
HStack {
TextField("Enter website URL (e.g., google.com)", text: $newWebsiteURL)
.textFieldStyle(.roundedBorder)
.onSubmit {
addWebsite()
}
Button(action: addWebsite) {
Image(systemName: "plus.circle.fill")
.foregroundColor(.accentColor)
.font(.system(size: 18))
}
.buttonStyle(.plain)
.disabled(newWebsiteURL.isEmpty)
}
if websiteConfigs.isEmpty {
HStack {
Spacer()
Text("No websites added")
.foregroundColor(.secondary)
.font(.subheadline)
Spacer()
}
.padding()
.background(CardBackground(isSelected: false))
} else {
// Grid of website tags that wraps to next line
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100, maximum: 160), spacing: 10)], spacing: 10) {
ForEach(websiteConfigs) { urlConfig in
HStack(spacing: 4) {
Image(systemName: "globe")
.font(.system(size: 11))
.foregroundColor(.accentColor)
Text(urlConfig.url)
.font(.system(size: 11))
.lineLimit(1)
Spacer(minLength: 0)
Button(action: {
websiteConfigs.removeAll(where: { $0.id == urlConfig.id })
}) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 9))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.frame(height: 28)
.background(CardBackground(isSelected: false, cornerRadius: 10))
}
}
.padding(8)
}
}
}
.padding()
.background(CardBackground(isSelected: false))
.padding(.horizontal)
VStack(spacing: 16) {
SectionHeader(title: "Transcription")
if whisperState.usableModels.isEmpty {
Text("No transcription models available. Please connect to a cloud service or download a local model in the AI Models tab.")
.font(.subheadline)
.foregroundColor(.secondary)
.padding()
.frame(maxWidth: .infinity, alignment: .center)
.background(CardBackground(isSelected: false))
} else {
let modelBinding = Binding<String?>(
get: {
selectedTranscriptionModelName ?? whisperState.usableModels.first?.name
},
set: { selectedTranscriptionModelName = $0 }
)
HStack {
Text("Model")
.font(.subheadline)
.foregroundColor(.secondary)
Picker("", selection: modelBinding) {
ForEach(whisperState.usableModels, id: \.name) { model in
Text(model.displayName).tag(model.name as String?)
}
}
.labelsHidden()
Spacer()
}
}
if languageSelectionDisabled() {
HStack {
Text("Language")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Autodetected")
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
}
} else if let selectedModel = effectiveModelName,
let modelInfo = whisperState.allAvailableModels.first(where: { $0.name == selectedModel }),
modelInfo.isMultilingualModel {
let languageBinding = Binding<String?>(
get: {
selectedLanguage ?? UserDefaults.standard.string(forKey: "SelectedLanguage") ?? "auto"
},
set: { selectedLanguage = $0 }
)
HStack {
Text("Language")
.font(.subheadline)
.foregroundColor(.secondary)
Picker("", selection: languageBinding) {
ForEach(modelInfo.supportedLanguages.sorted(by: {
if $0.key == "auto" { return true }
if $1.key == "auto" { return false }
return $0.value < $1.value
}), id: \.key) { key, value in
Text(value).tag(key as String?)
}
}
.labelsHidden()
Spacer()
}
} else if let selectedModel = effectiveModelName,
let modelInfo = whisperState.allAvailableModels.first(where: { $0.name == selectedModel }),
!modelInfo.isMultilingualModel {
EmptyView()
.onAppear {
if selectedLanguage == nil {
selectedLanguage = "en"
}
}
}
}
.padding()
.background(CardBackground(isSelected: false))
.padding(.horizontal)
VStack(spacing: 16) {
SectionHeader(title: "AI Enhancement")
Toggle("Enable AI Enhancement", isOn: $isAIEnhancementEnabled)
.frame(maxWidth: .infinity, alignment: .leading)
.onChange(of: isAIEnhancementEnabled) { oldValue, newValue in
if newValue {
if selectedAIProvider == nil {
selectedAIProvider = aiService.selectedProvider.rawValue
}
if selectedAIModel == nil {
selectedAIModel = aiService.currentModel
}
}
}
Divider()
let providerBinding = Binding<AIProvider>(
get: {
if let providerName = selectedAIProvider,
let provider = AIProvider(rawValue: providerName) {
return provider
}
return aiService.selectedProvider
},
set: { newValue in
selectedAIProvider = newValue.rawValue
aiService.selectedProvider = newValue
selectedAIModel = nil
}
)
if isAIEnhancementEnabled {
HStack {
Text("AI Provider")
.font(.subheadline)
.foregroundColor(.secondary)
if aiService.connectedProviders.isEmpty {
Text("No providers connected")
.foregroundColor(.secondary)
.italic()
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Picker("", selection: providerBinding) {
ForEach(aiService.connectedProviders.filter { $0 != .elevenLabs && $0 != .deepgram }, id: \.self) { provider in
Text(provider.rawValue).tag(provider)
}
}
.labelsHidden()
.onChange(of: selectedAIProvider) { oldValue, newValue in
if let provider = newValue.flatMap({ AIProvider(rawValue: $0) }) {
selectedAIModel = provider.defaultModel
}
}
Spacer()
}
}
let providerName = selectedAIProvider ?? aiService.selectedProvider.rawValue
if let provider = AIProvider(rawValue: providerName),
provider != .custom {
HStack {
Text("AI Model")
.font(.subheadline)
.foregroundColor(.secondary)
if aiService.availableModels.isEmpty {
Text(provider == .openRouter ? "No models loaded" : "No models available")
.foregroundColor(.secondary)
.italic()
.frame(maxWidth: .infinity, alignment: .leading)
} else {
let modelBinding = Binding<String>(
get: {
if let model = selectedAIModel, !model.isEmpty {
return model
}
return aiService.currentModel
},
set: { newModelValue in
selectedAIModel = newModelValue
aiService.selectModel(newModelValue)
}
)
let models = provider == .openRouter ? aiService.availableModels : (provider == .ollama ? aiService.availableModels : provider.availableModels)
Picker("", selection: modelBinding) {
ForEach(models, id: \.self) { model in
Text(model).tag(model)
}
}
.labelsHidden()
if provider == .openRouter {
Button(action: {
Task {
await aiService.fetchOpenRouterModels()
}
}) {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
.help("Refresh models")
}
Spacer()
}
}
}
VStack(alignment: .leading, spacing: 12) {
Text("Enhancement Prompt")
.font(.headline)
.foregroundColor(.primary)
PromptSelectionGrid(
prompts: enhancementService.allPrompts,
selectedPromptId: selectedPromptId,
onPromptSelected: { prompt in
selectedPromptId = prompt.id
},
onEditPrompt: { prompt in
selectedPromptForEdit = prompt
},
onDeletePrompt: { prompt in
enhancementService.deletePrompt(prompt)
},
onAddNewPrompt: {
isEditingPrompt = true
}
)
}
Divider()
Toggle("Context Awareness", isOn: $useScreenCapture)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding()
.background(CardBackground(isSelected: false))
.padding(.horizontal)
VStack(spacing: 16) {
SectionHeader(title: "Advanced")
HStack {
Toggle("Auto Send", isOn: $isAutoSendEnabled)
InfoTip(
title: "Auto Send",
message: "Automatically presses the Return/Enter key after pasting text. This is useful for chat applications or forms where its not necessary to to make changes to the transcribed text"
)
Spacer()
}
Divider()
HStack {
Text("Keyboard Shortcut")
.font(.subheadline)
.foregroundColor(.secondary)
KeyboardShortcuts.Recorder(for: .powerMode(id: powerModeConfigId))
.controlSize(.small)
InfoTip(
title: "Power Mode Hotkey",
message: "Assign a unique keyboard shortcut to instantly activate this Power Mode and start recording"
)
Spacer()
}
}
.padding()
.background(CardBackground(isSelected: false))
.padding(.horizontal)
HStack {
Spacer()
Button(action: saveConfiguration) {
Text(mode.isAdding ? "Add New Power Mode" : "Save Changes")
.font(.headline)
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(canSave ? Color(red: 0.3, green: 0.7, blue: 0.4) : Color(red: 0.3, green: 0.7, blue: 0.4).opacity(0.5))
)
}
.buttonStyle(.plain)
.disabled(!canSave)
}
.padding(.horizontal)
}
.padding(.vertical)
Button("Cancel", role: .cancel) { }
} message: {
if case .edit(let config) = mode {
Text("Are you sure you want to delete the '\(config.name)' power mode? This action cannot be undone.")
}
}
.sheet(isPresented: $isShowingAppPicker) {
@ -680,15 +499,7 @@ struct ConfigurationView: View {
onDismiss: { isShowingAppPicker = false }
)
}
.sheet(isPresented: $isEditingPrompt) {
PromptEditorView(mode: .add)
}
.sheet(item: $selectedPromptForEdit) { prompt in
PromptEditorView(mode: .edit(prompt))
}
.powerModeValidationAlert(errors: validationErrors, isPresented: $showValidationAlert)
.navigationTitle("") // Explicitly set an empty title for this view
.toolbar(.hidden) // Attempt to hide the navigation bar area
.onAppear {
// Set AI provider and model for new power modes after environment objects are available
if case .add = mode {
@ -704,6 +515,11 @@ struct ConfigurationView: View {
if isAIEnhancementEnabled && selectedPromptId == nil {
selectedPromptId = enhancementService.allPrompts.first?.id
}
// Focus the name field for faster keyboard-driven setup
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isNameFieldFocused = true
}
}
}

View File

@ -1,4 +1,5 @@
import SwiftUI
import SwiftData
extension View {
func placeholder<Content: View>(
@ -69,6 +70,7 @@ struct PowerModeView: View {
var body: some View {
NavigationStack(path: $navigationPath) {
VStack(spacing: 0) {
// Header Section
VStack(spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
@ -80,7 +82,7 @@ struct PowerModeView: View {
InfoTip(
title: "What is Power Mode?",
message: "Automatically apply custom configurations based on the app/website you are using",
learnMoreURL: "https://www.youtube.com/@tryvoiceink/videos"
learnMoreURL: "https://tryvoiceink.com/docs/power-mode"
)
}
@ -135,120 +137,122 @@ struct PowerModeView: View {
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 16)
.frame(maxWidth: .infinity)
.background(Color(NSColor.windowBackgroundColor))
Rectangle()
.fill(Color(NSColor.separatorColor))
.frame(height: 1)
.padding(.horizontal, 24)
if isReorderMode {
VStack(spacing: 12) {
List {
ForEach(powerModeManager.configurations) { config in
HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color(NSColor.controlBackgroundColor))
.frame(width: 40, height: 40)
Text(config.emoji)
.font(.system(size: 20))
}
Text(config.name)
.font(.system(size: 15, weight: .semibold))
Spacer()
HStack(spacing: 6) {
if config.isDefault {
Text("Default")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.accentColor))
.foregroundColor(.white)
// Content Section
Group {
if isReorderMode {
VStack(spacing: 12) {
List {
ForEach(powerModeManager.configurations) { config in
HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color(NSColor.controlBackgroundColor))
.frame(width: 40, height: 40)
Text(config.emoji)
.font(.system(size: 20))
}
if !config.isEnabled {
Text("Disabled")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Color(NSColor.controlBackgroundColor)))
.overlay(
Capsule().stroke(Color(NSColor.separatorColor), lineWidth: 0.5)
)
.foregroundColor(.secondary)
}
}
}
.padding(.vertical, 12)
.padding(.horizontal, 14)
.background(CardBackground(isSelected: false))
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.padding(.vertical, 6)
}
.onMove(perform: powerModeManager.moveConfigurations)
}
.listStyle(.plain)
.listRowSeparator(.hidden)
.scrollContentBackground(.hidden)
.background(Color(NSColor.controlBackgroundColor))
}
.padding(.horizontal, 24)
.padding(.vertical, 20)
} else {
GeometryReader { geometry in
ScrollView {
VStack(spacing: 0) {
if powerModeManager.configurations.isEmpty {
VStack(spacing: 24) {
Text(config.name)
.font(.system(size: 15, weight: .semibold))
Spacer()
.frame(height: geometry.size.height * 0.2)
VStack(spacing: 16) {
Image(systemName: "square.grid.2x2.fill")
.font(.system(size: 48, weight: .regular))
.foregroundColor(.secondary.opacity(0.6))
VStack(spacing: 8) {
Text("No Power Modes Yet")
.font(.system(size: 20, weight: .medium))
.foregroundColor(.primary)
Text("Create first power mode to automate your VoiceInk workflow based on apps/website you are using")
.font(.system(size: 14))
HStack(spacing: 6) {
if config.isDefault {
Text("Default")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.accentColor))
.foregroundColor(.white)
}
if !config.isEnabled {
Text("Disabled")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(Color(NSColor.controlBackgroundColor)))
.overlay(
Capsule().stroke(Color(NSColor.separatorColor), lineWidth: 0.5)
)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.lineSpacing(2)
}
}
Spacer()
}
.frame(maxWidth: .infinity)
.frame(minHeight: geometry.size.height)
} else {
VStack(spacing: 0) {
PowerModeConfigurationsGrid(
powerModeManager: powerModeManager,
onEditConfig: { config in
configurationMode = .edit(config)
navigationPath.append(configurationMode!)
.padding(.vertical, 12)
.padding(.horizontal, 14)
.background(CardBackground(isSelected: false))
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.padding(.vertical, 6)
}
.onMove(perform: powerModeManager.moveConfigurations)
}
.listStyle(.plain)
.listRowSeparator(.hidden)
.scrollContentBackground(.hidden)
.background(Color(NSColor.controlBackgroundColor))
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
} else {
GeometryReader { geometry in
ScrollView {
VStack(spacing: 0) {
if powerModeManager.configurations.isEmpty {
VStack(spacing: 24) {
Spacer()
.frame(height: geometry.size.height * 0.2)
VStack(spacing: 16) {
Image(systemName: "square.grid.2x2.fill")
.font(.system(size: 48, weight: .regular))
.foregroundColor(.secondary.opacity(0.6))
VStack(spacing: 8) {
Text("No Power Modes Yet")
.font(.system(size: 20, weight: .medium))
.foregroundColor(.primary)
Text("Create first power mode to automate your VoiceInk workflow based on apps/website you are using")
.font(.system(size: 14))
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.lineSpacing(2)
}
}
)
.padding(.horizontal, 24)
.padding(.vertical, 20)
Spacer()
.frame(height: 40)
Spacer()
}
.frame(maxWidth: .infinity)
.frame(minHeight: geometry.size.height)
} else {
VStack(spacing: 0) {
PowerModeConfigurationsGrid(
powerModeManager: powerModeManager,
onEditConfig: { config in
configurationMode = .edit(config)
navigationPath.append(configurationMode!)
}
)
.padding(.horizontal, 24)
.padding(.vertical, 20)
Spacer()
.frame(height: 40)
}
}
}
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(NSColor.controlBackgroundColor))
}
.background(Color(NSColor.controlBackgroundColor))
.navigationDestination(for: ConfigurationMode.self) { mode in
@ -259,11 +263,9 @@ struct PowerModeView: View {
}
// New component for section headers
struct SectionHeader: View {
let title: String
var body: some View {
Text(title)
.font(.system(size: 16, weight: .bold))

View File

@ -65,7 +65,26 @@ struct PowerModeConfigurationsGrid: View {
)
}
}
.padding(.horizontal)
}
}
/// Small, consistent icon-only add button used across Power Mode configuration rows.
struct AddIconButton: View {
let helpText: String
var isDisabled: Bool = false
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 18))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help(helpText)
.accessibilityLabel(helpText)
.disabled(isDisabled)
}
}
@ -166,7 +185,7 @@ struct ConfigurationRow: View {
.font(.caption2)
}
}
if websiteCount > 0 {
HStack(spacing: 4) {
Image(systemName: "globe")
@ -176,6 +195,7 @@ struct ConfigurationRow: View {
}
}
}
.padding(.top, 2)
.foregroundColor(.secondary)
}
@ -193,7 +213,6 @@ struct ConfigurationRow: View {
if selectedModel != nil || selectedLanguage != nil || config.isAIEnhancementEnabled || config.isAutoSendEnabled {
Divider()
.padding(.horizontal, 16)
HStack(spacing: 8) {
if let model = selectedModel, model != "Default" {
@ -203,8 +222,8 @@ struct ConfigurationRow: View {
Text(model)
.font(.caption)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule()
.fill(Color(NSColor.controlBackgroundColor)))
.overlay(
@ -220,8 +239,8 @@ struct ConfigurationRow: View {
Text(language)
.font(.caption)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule()
.fill(Color(NSColor.controlBackgroundColor)))
.overlay(
@ -237,8 +256,8 @@ struct ConfigurationRow: View {
Text(modelName.count > 20 ? String(modelName.prefix(18)) + "..." : modelName)
.font(.caption)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule()
.fill(Color(NSColor.controlBackgroundColor)))
.overlay(
@ -254,8 +273,8 @@ struct ConfigurationRow: View {
Text("Auto Send")
.font(.caption)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule()
.fill(Color(NSColor.controlBackgroundColor)))
.overlay(
@ -271,8 +290,8 @@ struct ConfigurationRow: View {
Text("Context Awareness")
.font(.caption)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule()
.fill(Color(NSColor.controlBackgroundColor)))
.overlay(
@ -287,8 +306,8 @@ struct ConfigurationRow: View {
Text(selectedPrompt?.title ?? "AI")
.font(.caption)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule()
.fill(Color.accentColor.opacity(0.1)))
.foregroundColor(.accentColor)
@ -296,10 +315,13 @@ struct ConfigurationRow: View {
Spacer()
}
.padding(.vertical, 10)
.padding(.vertical, 6)
.padding(.horizontal, 16)
.background(Color.secondary.opacity(0.1))
}
}
.clipShape(RoundedRectangle(cornerRadius: 16))
.background(CardBackground(isSelected: isEditing))
.opacity(config.isEnabled ? 1.0 : 0.5)