- Rename application from MenuWhisper to Tell me with new domain com.fmartingr.tellme - Implement comprehensive preferences window with 6 tabs (General, Models, Text Insertion, Interface, Advanced, Permissions) - Add full English/Spanish localization for all UI elements - Create functional onboarding flow with model download capability - Implement preview dialog for transcription editing - Add settings export/import functionality - Fix HUD content display issues and add comprehensive permission checking - Enhance build scripts and app bundle creation for proper localization support
993 lines
No EOL
39 KiB
Swift
993 lines
No EOL
39 KiB
Swift
import SwiftUI
|
|
import CoreModels
|
|
import CoreSTT
|
|
import CoreUtils
|
|
import CorePermissions
|
|
import CoreSettings
|
|
|
|
class PreferencesWindowController: NSWindowController {
|
|
private let modelManager: ModelManager
|
|
private let whisperEngine: WhisperCPPEngine
|
|
private let permissionManager: PermissionManager
|
|
private let settings: CoreSettings.Settings
|
|
private var preferencesView: PreferencesView?
|
|
|
|
init(modelManager: ModelManager, whisperEngine: WhisperCPPEngine, permissionManager: PermissionManager, settings: CoreSettings.Settings, initialTab: Int = 0) {
|
|
self.modelManager = modelManager
|
|
self.whisperEngine = whisperEngine
|
|
self.permissionManager = permissionManager
|
|
self.settings = settings
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
|
|
super.init(window: window)
|
|
|
|
window.title = NSLocalizedString("preferences.title", comment: "Tell me Preferences")
|
|
window.center()
|
|
window.minSize = NSSize(width: 750, height: 600)
|
|
window.maxSize = NSSize(width: 1200, height: 800)
|
|
|
|
preferencesView = PreferencesView(
|
|
modelManager: modelManager,
|
|
whisperEngine: whisperEngine,
|
|
permissionManager: permissionManager,
|
|
settings: settings,
|
|
initialTab: initialTab,
|
|
onClose: { [weak self] in
|
|
self?.close()
|
|
}
|
|
)
|
|
|
|
window.contentView = NSHostingView(rootView: preferencesView!)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func setSelectedTab(_ tabIndex: Int) {
|
|
preferencesView?.setSelectedTab(tabIndex)
|
|
}
|
|
}
|
|
|
|
struct PreferencesView: View {
|
|
@ObservedObject var modelManager: ModelManager
|
|
let whisperEngine: WhisperCPPEngine
|
|
@ObservedObject var permissionManager: PermissionManager
|
|
@ObservedObject var settings: CoreSettings.Settings
|
|
let onClose: () -> Void
|
|
|
|
@State private var selectedTab: Int
|
|
@State private var isDownloading: [String: Bool] = [:]
|
|
@State private var downloadProgress: [String: Double] = [:]
|
|
@State private var showingDeleteAlert = false
|
|
@State private var modelToDelete: ModelInfo?
|
|
|
|
init(modelManager: ModelManager, whisperEngine: WhisperCPPEngine, permissionManager: PermissionManager, settings: CoreSettings.Settings, initialTab: Int = 0, onClose: @escaping () -> Void) {
|
|
self.modelManager = modelManager
|
|
self.whisperEngine = whisperEngine
|
|
self.permissionManager = permissionManager
|
|
self.settings = settings
|
|
self.onClose = onClose
|
|
self._selectedTab = State(initialValue: initialTab)
|
|
}
|
|
|
|
func setSelectedTab(_ tabIndex: Int) {
|
|
selectedTab = tabIndex
|
|
}
|
|
|
|
var body: some View {
|
|
TabView(selection: $selectedTab) {
|
|
GeneralTab(settings: settings)
|
|
.tabItem {
|
|
Label(NSLocalizedString("preferences.general", comment: "General"), systemImage: "gearshape")
|
|
}
|
|
.tag(0)
|
|
|
|
ModelsTab(
|
|
modelManager: modelManager,
|
|
whisperEngine: whisperEngine,
|
|
settings: settings,
|
|
isDownloading: $isDownloading,
|
|
downloadProgress: $downloadProgress,
|
|
showingDeleteAlert: $showingDeleteAlert,
|
|
modelToDelete: $modelToDelete
|
|
)
|
|
.tabItem {
|
|
Label(NSLocalizedString("preferences.models", comment: "Models"), systemImage: "brain.head.profile")
|
|
}
|
|
.tag(1)
|
|
|
|
InsertionTab(settings: settings)
|
|
.tabItem {
|
|
Label(NSLocalizedString("preferences.insertion", comment: "Text Insertion"), systemImage: "text.cursor")
|
|
}
|
|
.tag(2)
|
|
|
|
HUDTab(settings: settings)
|
|
.tabItem {
|
|
Label(NSLocalizedString("preferences.interface", comment: "Interface"), systemImage: "rectangle.on.rectangle")
|
|
}
|
|
.tag(3)
|
|
|
|
AdvancedTab(settings: settings)
|
|
.tabItem {
|
|
Label(NSLocalizedString("preferences.advanced", comment: "Advanced"), systemImage: "slider.horizontal.3")
|
|
}
|
|
.tag(4)
|
|
|
|
PermissionsTab(permissionManager: permissionManager)
|
|
.tabItem {
|
|
Label(NSLocalizedString("preferences.permissions", comment: "Permissions"), systemImage: "lock.shield")
|
|
}
|
|
.tag(5)
|
|
}
|
|
.frame(minWidth: 750, idealWidth: 800, maxWidth: 1200, minHeight: 600, idealHeight: 600, maxHeight: 800)
|
|
.alert(NSLocalizedString("alert.delete_model", comment: "Delete Model"), isPresented: $showingDeleteAlert) {
|
|
Button(NSLocalizedString("general.cancel", comment: "Cancel"), role: .cancel) {
|
|
modelToDelete = nil
|
|
}
|
|
Button(NSLocalizedString("preferences.models.delete", comment: "Delete"), role: .destructive) {
|
|
if let model = modelToDelete {
|
|
deleteModel(model)
|
|
}
|
|
modelToDelete = nil
|
|
}
|
|
} message: {
|
|
if let model = modelToDelete {
|
|
Text(String(format: NSLocalizedString("preferences.models.delete_confirm", comment: "Delete confirmation"), model.name))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func deleteModel(_ model: ModelInfo) {
|
|
do {
|
|
try modelManager.deleteModel(model)
|
|
} catch {
|
|
print("Failed to delete model: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ModelsTab: View {
|
|
@ObservedObject var modelManager: ModelManager
|
|
let whisperEngine: WhisperCPPEngine
|
|
@ObservedObject var settings: CoreSettings.Settings
|
|
|
|
@Binding var isDownloading: [String: Bool]
|
|
@Binding var downloadProgress: [String: Double]
|
|
@Binding var showingDeleteAlert: Bool
|
|
@Binding var modelToDelete: ModelInfo?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text(NSLocalizedString("preferences.models.title", comment: "Speech Recognition Models"))
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
|
|
Text(NSLocalizedString("preferences.models.description", comment: "Model description"))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
// Current Model Status
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(NSLocalizedString("preferences.models.current_model", comment: "Current Model"))
|
|
.font(.headline)
|
|
|
|
if let activeModel = modelManager.activeModel {
|
|
HStack {
|
|
VStack(alignment: .leading) {
|
|
Text(activeModel.name)
|
|
.font(.body)
|
|
.fontWeight(.medium)
|
|
Text("\(activeModel.sizeMB) MB • \(activeModel.qualityTier) quality • \(activeModel.estimatedRAM)")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Circle()
|
|
.fill(whisperEngine.isModelLoaded() ? Color.green : Color.orange)
|
|
.frame(width: 8, height: 8)
|
|
|
|
Text(whisperEngine.isModelLoaded() ? NSLocalizedString("status.loaded", comment: "Loaded") : NSLocalizedString("status.loading", comment: "Loading..."))
|
|
.font(.caption)
|
|
.foregroundColor(whisperEngine.isModelLoaded() ? .green : .orange)
|
|
}
|
|
.padding(12)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(8)
|
|
} else {
|
|
Text(NSLocalizedString("preferences.models.no_model", comment: "No model selected"))
|
|
.foregroundColor(.secondary)
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(8)
|
|
}
|
|
}
|
|
|
|
// Language Settings
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(NSLocalizedString("preferences.models.language", comment: "Language"))
|
|
.font(.headline)
|
|
|
|
HStack {
|
|
Text(NSLocalizedString("preferences.models.recognition_language", comment: "Recognition Language:"))
|
|
.font(.body)
|
|
|
|
Picker(NSLocalizedString("general.language", comment: "Language"), selection: Binding(
|
|
get: { settings.forcedLanguage ?? "auto" },
|
|
set: { newValue in
|
|
settings.forcedLanguage = newValue == "auto" ? nil : newValue
|
|
}
|
|
)) {
|
|
Text(NSLocalizedString("language.auto_detect", comment: "Auto-detect")).tag("auto")
|
|
Divider()
|
|
Text(NSLocalizedString("language.english", comment: "English")).tag("en")
|
|
Text(NSLocalizedString("language.spanish", comment: "Spanish")).tag("es")
|
|
Text(NSLocalizedString("language.french", comment: "French")).tag("fr")
|
|
Text(NSLocalizedString("language.german", comment: "German")).tag("de")
|
|
Text(NSLocalizedString("language.italian", comment: "Italian")).tag("it")
|
|
Text(NSLocalizedString("language.portuguese", comment: "Portuguese")).tag("pt")
|
|
Text(NSLocalizedString("language.russian", comment: "Russian")).tag("ru")
|
|
Text(NSLocalizedString("language.chinese", comment: "Chinese")).tag("zh")
|
|
Text(NSLocalizedString("language.japanese", comment: "Japanese")).tag("ja")
|
|
Text(NSLocalizedString("language.korean", comment: "Korean")).tag("ko")
|
|
}
|
|
.pickerStyle(.menu)
|
|
.frame(width: 150)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(12)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(8)
|
|
}
|
|
|
|
// Available Models
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(NSLocalizedString("preferences.models.available_models", comment: "Available Models"))
|
|
.font(.headline)
|
|
|
|
ScrollView {
|
|
LazyVStack(spacing: 8) {
|
|
ForEach(modelManager.availableModels) { model in
|
|
ModelRow(
|
|
model: model,
|
|
modelManager: modelManager,
|
|
whisperEngine: whisperEngine,
|
|
isDownloading: isDownloading[model.name] ?? false,
|
|
downloadProgress: downloadProgress[model.name] ?? 0.0,
|
|
onDownload: {
|
|
downloadModel(model)
|
|
},
|
|
onSelect: {
|
|
selectModel(model)
|
|
},
|
|
onDelete: {
|
|
modelToDelete = model
|
|
showingDeleteAlert = true
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxHeight: 200)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(20)
|
|
}
|
|
|
|
private func downloadModel(_ model: ModelInfo) {
|
|
isDownloading[model.name] = true
|
|
downloadProgress[model.name] = 0.0
|
|
|
|
Task {
|
|
do {
|
|
try await modelManager.downloadModel(model) { progress in
|
|
DispatchQueue.main.async {
|
|
downloadProgress[model.name] = progress.progress
|
|
}
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
isDownloading[model.name] = false
|
|
downloadProgress[model.name] = 1.0
|
|
}
|
|
} catch {
|
|
DispatchQueue.main.async {
|
|
isDownloading[model.name] = false
|
|
downloadProgress[model.name] = 0.0
|
|
}
|
|
print("Download failed: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func selectModel(_ model: ModelInfo) {
|
|
modelManager.setActiveModel(model)
|
|
|
|
Task {
|
|
do {
|
|
if let modelPath = modelManager.getModelPath(for: model) {
|
|
try await whisperEngine.loadModel(at: modelPath)
|
|
}
|
|
} catch {
|
|
print("Failed to load model: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ModelRow: View {
|
|
let model: ModelInfo
|
|
@ObservedObject var modelManager: ModelManager
|
|
let whisperEngine: WhisperCPPEngine
|
|
|
|
let isDownloading: Bool
|
|
let downloadProgress: Double
|
|
let onDownload: () -> Void
|
|
let onSelect: () -> Void
|
|
let onDelete: () -> Void
|
|
|
|
private var isActive: Bool {
|
|
modelManager.activeModel?.name == model.name
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(model.name)
|
|
.font(.body)
|
|
.fontWeight(.medium)
|
|
|
|
if isActive {
|
|
Text(NSLocalizedString("preferences.models.active_badge", comment: "ACTIVE"))
|
|
.font(.caption)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.blue)
|
|
.cornerRadius(4)
|
|
}
|
|
}
|
|
|
|
Text("\(model.sizeMB) MB • \(model.qualityTier) quality • \(model.estimatedRAM)")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
if !model.notes.isEmpty {
|
|
Text(model.notes)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
VStack(spacing: 8) {
|
|
if model.isDownloaded {
|
|
HStack(spacing: 8) {
|
|
if !isActive {
|
|
Button(NSLocalizedString("general.select", comment: "Select")) {
|
|
onSelect()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
|
|
Button(NSLocalizedString("preferences.models.delete", comment: "Delete")) {
|
|
onDelete()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.foregroundColor(.red)
|
|
}
|
|
} else {
|
|
if isDownloading {
|
|
HStack {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
Text(NSLocalizedString("preferences.models.downloading", comment: "Downloading..."))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
} else {
|
|
Button(NSLocalizedString("preferences.models.download", comment: "Download")) {
|
|
onDownload()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.accessibilityLabel("Download \(model.name) model")
|
|
.accessibilityHint("Downloads the speech recognition model for offline use")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(12)
|
|
.background(isActive ? Color.blue.opacity(0.1) : Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(8)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(isActive ? Color.blue : Color.clear, lineWidth: 2)
|
|
)
|
|
}
|
|
}
|
|
|
|
struct PermissionsTab: View {
|
|
@ObservedObject var permissionManager: PermissionManager
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text(NSLocalizedString("preferences.permissions", comment: "Permissions"))
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
|
|
Text(NSLocalizedString("preferences.permissions.description", comment: "Permissions description"))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
// Microphone Permission
|
|
PermissionRow(
|
|
title: NSLocalizedString("permissions.microphone.title_short", comment: "Microphone"),
|
|
description: NSLocalizedString("permissions.microphone.description_short", comment: "Microphone description"),
|
|
status: permissionManager.microphoneStatus,
|
|
onOpenSettings: {
|
|
permissionManager.openSystemSettings(for: .microphone)
|
|
},
|
|
onRefresh: {
|
|
permissionManager.checkAllPermissions()
|
|
}
|
|
)
|
|
|
|
Divider()
|
|
|
|
// Accessibility Permission
|
|
PermissionRow(
|
|
title: NSLocalizedString("permissions.accessibility.title_short", comment: "Accessibility"),
|
|
description: NSLocalizedString("permissions.accessibility.description_short", comment: "Accessibility description"),
|
|
status: permissionManager.accessibilityStatus,
|
|
onOpenSettings: {
|
|
permissionManager.openSystemSettings(for: .accessibility)
|
|
},
|
|
onRefresh: {
|
|
permissionManager.checkAllPermissions()
|
|
}
|
|
)
|
|
|
|
Divider()
|
|
|
|
// Input Monitoring Permission
|
|
PermissionRow(
|
|
title: NSLocalizedString("permissions.input_monitoring.title_short", comment: "Input Monitoring"),
|
|
description: NSLocalizedString("permissions.input_monitoring.description_short", comment: "Input Monitoring description"),
|
|
status: permissionManager.inputMonitoringStatus,
|
|
onOpenSettings: {
|
|
permissionManager.openSystemSettings(for: .inputMonitoring)
|
|
},
|
|
onRefresh: {
|
|
permissionManager.checkAllPermissions()
|
|
}
|
|
)
|
|
}
|
|
.padding(16)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(12)
|
|
|
|
// Help text
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(NSLocalizedString("preferences.permissions.need_help", comment: "Need Help?"))
|
|
.font(.headline)
|
|
|
|
Text(NSLocalizedString("preferences.permissions.after_granting", comment: "After granting permissions"))
|
|
.font(.body)
|
|
.foregroundColor(.secondary)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(NSLocalizedString("preferences.permissions.step1", comment: "Step 1"))
|
|
Text(NSLocalizedString("preferences.permissions.step2", comment: "Step 2"))
|
|
Text(NSLocalizedString("preferences.permissions.step3", comment: "Step 3"))
|
|
}
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.padding(.leading, 8)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(20)
|
|
}
|
|
}
|
|
|
|
struct PermissionRow: View {
|
|
let title: String
|
|
let description: String
|
|
let status: PermissionStatus
|
|
let onOpenSettings: () -> Void
|
|
let onRefresh: () -> Void
|
|
|
|
var body: some View {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(title)
|
|
.font(.body)
|
|
.fontWeight(.medium)
|
|
|
|
Spacer()
|
|
|
|
// Status indicator
|
|
HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(statusColor)
|
|
.frame(width: 8, height: 8)
|
|
|
|
Text(statusText)
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(statusColor)
|
|
}
|
|
}
|
|
|
|
Text(description)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
VStack(spacing: 6) {
|
|
if status != .granted {
|
|
Button(NSLocalizedString("permissions.open_settings", comment: "Open System Settings")) {
|
|
onOpenSettings()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
|
|
Button(NSLocalizedString("permissions.refresh_status", comment: "Refresh Status")) {
|
|
onRefresh()
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.controlSize(.small)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var statusColor: Color {
|
|
switch status {
|
|
case .granted:
|
|
return .green
|
|
case .denied:
|
|
return .red
|
|
case .notDetermined, .restricted:
|
|
return .orange
|
|
}
|
|
}
|
|
|
|
private var statusText: String {
|
|
switch status {
|
|
case .granted:
|
|
return NSLocalizedString("status.granted", comment: "Granted")
|
|
case .denied:
|
|
return NSLocalizedString("status.denied", comment: "Denied")
|
|
case .notDetermined:
|
|
return NSLocalizedString("status.not_set", comment: "Not Set")
|
|
case .restricted:
|
|
return NSLocalizedString("status.restricted", comment: "Restricted")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct GeneralTab: View {
|
|
@ObservedObject var settings: CoreSettings.Settings
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
Text(NSLocalizedString("preferences.general.title", comment: "General Settings"))
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
|
|
// Hotkey Configuration
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(NSLocalizedString("preferences.general.global_hotkey", comment: "Global Hotkey"))
|
|
.font(.headline)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text(NSLocalizedString("preferences.general.hotkey_combination", comment: "Hotkey Combination:"))
|
|
.frame(width: 140, alignment: .leading)
|
|
|
|
HotkeyRecorder(hotkey: $settings.hotkey)
|
|
|
|
Spacer()
|
|
}
|
|
|
|
HStack {
|
|
Text(NSLocalizedString("preferences.general.mode", comment: "Activation Mode:"))
|
|
.frame(width: 140, alignment: .leading)
|
|
|
|
Picker(NSLocalizedString("general.mode", comment: "Mode"), selection: $settings.hotkeyMode) {
|
|
ForEach(HotkeyMode.allCases, id: \.self) { mode in
|
|
Text(mode.displayName).tag(mode)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.frame(width: 200)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(12)
|
|
}
|
|
|
|
// Audio and Timing
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(NSLocalizedString("preferences.general.audio_timing", comment: "Audio & Timing"))
|
|
.font(.headline)
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack {
|
|
Toggle(NSLocalizedString("preferences.general.sounds", comment: "Play sounds"), isOn: $settings.playSounds)
|
|
Spacer()
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(NSLocalizedString("preferences.general.limit", comment: "Dictation time limit:"))
|
|
Spacer()
|
|
Text("\(Int(settings.dictationTimeLimit / 60)) \(NSLocalizedString("preferences.general.minutes", comment: "minutes"))")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Slider(
|
|
value: Binding(
|
|
get: { settings.dictationTimeLimit / 60 },
|
|
set: { settings.dictationTimeLimit = $0 * 60 }
|
|
),
|
|
in: 1...30,
|
|
step: 1
|
|
) {
|
|
Text(NSLocalizedString("preferences.general.time_limit_slider", comment: "Time Limit"))
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(12)
|
|
}
|
|
|
|
// Settings Management
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(NSLocalizedString("preferences.general.settings_management", comment: "Settings Management"))
|
|
.font(.headline)
|
|
|
|
HStack(spacing: 12) {
|
|
Button(NSLocalizedString("preferences.general.export_settings", comment: "Export Settings...")) {
|
|
exportSettings()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Button(NSLocalizedString("preferences.general.import_settings", comment: "Import Settings...")) {
|
|
importSettings()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(16)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(12)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(20)
|
|
}
|
|
|
|
private func exportSettings() {
|
|
let panel = NSSavePanel()
|
|
panel.allowedContentTypes = [.json]
|
|
panel.nameFieldStringValue = "\(NSLocalizedString("app.name", comment: "App name")) Settings.json"
|
|
|
|
if panel.runModal() == .OK, let url = panel.url {
|
|
do {
|
|
let data = try settings.exportSettings()
|
|
try data.write(to: url)
|
|
// Show success message
|
|
showAlert(title: NSLocalizedString("alert.success", comment: "Success"), message: NSLocalizedString("success.settings.exported", comment: "Settings exported"))
|
|
} catch {
|
|
showAlert(title: NSLocalizedString("alert.error", comment: "Error"), message: "Failed to export settings: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func importSettings() {
|
|
let panel = NSOpenPanel()
|
|
panel.allowedContentTypes = [.json]
|
|
panel.allowsMultipleSelection = false
|
|
|
|
if panel.runModal() == .OK, let url = panel.url {
|
|
do {
|
|
let data = try Data(contentsOf: url)
|
|
try settings.importSettings(from: data)
|
|
showAlert(title: NSLocalizedString("alert.success", comment: "Success"), message: NSLocalizedString("success.settings.imported", comment: "Settings imported"))
|
|
} catch {
|
|
showAlert(title: NSLocalizedString("alert.error", comment: "Error"), message: "Failed to import settings: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func showAlert(title: String, message: String) {
|
|
let alert = NSAlert()
|
|
alert.messageText = title
|
|
alert.informativeText = message
|
|
alert.runModal()
|
|
}
|
|
}
|
|
|
|
// MARK: - New Tab Views
|
|
|
|
struct InsertionTab: View {
|
|
@ObservedObject var settings: CoreSettings.Settings
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
Text(NSLocalizedString("preferences.insertion.title", comment: "Text Insertion"))
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
|
|
// Insertion Method
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(NSLocalizedString("preferences.insertion.method", comment: "Insertion Method:"))
|
|
.font(.headline)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Picker(NSLocalizedString("general.method", comment: "Method"), selection: $settings.insertionMethod) {
|
|
ForEach(InsertionMethod.allCases, id: \.self) { method in
|
|
Text(method.displayName).tag(method)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
|
|
Text(NSLocalizedString("preferences.insertion.method_description", comment: "Insertion method description"))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(16)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(12)
|
|
}
|
|
|
|
// Preview Settings
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(NSLocalizedString("preferences.insertion.preview", comment: "Preview"))
|
|
.font(.headline)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Toggle(NSLocalizedString("preferences.insertion.preview", comment: "Show preview"), isOn: $settings.showPreview)
|
|
|
|
Text(NSLocalizedString("preferences.insertion.preview_description", comment: "Preview description"))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(16)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(12)
|
|
}
|
|
|
|
// Secure Input Information
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(NSLocalizedString("preferences.insertion.secure_input_title", comment: "Secure Input Handling"))
|
|
.font(.headline)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Image(systemName: "info.circle")
|
|
.foregroundColor(.blue)
|
|
Text(NSLocalizedString("preferences.insertion.secure_input_description", comment: "Secure input description"))
|
|
.font(.body)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(Color.blue.opacity(0.1))
|
|
.cornerRadius(12)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(20)
|
|
}
|
|
}
|
|
|
|
struct HUDTab: View {
|
|
@ObservedObject var settings: CoreSettings.Settings
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
Text(NSLocalizedString("preferences.hud.title", comment: "Interface Settings"))
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
|
|
// HUD Appearance
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(NSLocalizedString("preferences.hud.appearance", comment: "HUD Appearance"))
|
|
.font(.headline)
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(NSLocalizedString("preferences.hud.opacity", comment: "Opacity:"))
|
|
Spacer()
|
|
Text("\(Int(settings.hudOpacity * 100))%")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Slider(value: $settings.hudOpacity, in: 0.3...1.0, step: 0.1) {
|
|
Text(NSLocalizedString("preferences.hud.opacity_slider", comment: "HUD Opacity"))
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(NSLocalizedString("preferences.hud.size", comment: "Size:"))
|
|
Spacer()
|
|
Text("\(Int(settings.hudSize * 100))%")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Slider(value: $settings.hudSize, in: 0.8...1.5, step: 0.1) {
|
|
Text(NSLocalizedString("preferences.hud.size_slider", comment: "HUD Size"))
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(12)
|
|
}
|
|
|
|
// Audio Feedback
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(NSLocalizedString("preferences.hud.audio_feedback", comment: "Audio Feedback"))
|
|
.font(.headline)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Toggle(NSLocalizedString("preferences.general.sounds", comment: "Play sounds for dictation"), isOn: $settings.playSounds)
|
|
|
|
Text(NSLocalizedString("preferences.hud.sounds_description", comment: "Sounds description"))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(16)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(12)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(20)
|
|
}
|
|
}
|
|
|
|
struct AdvancedTab: View {
|
|
@ObservedObject var settings: CoreSettings.Settings
|
|
@State private var showingResetAlert = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
Text(NSLocalizedString("preferences.advanced.title", comment: "Advanced Settings"))
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
|
|
// Processing Settings
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(NSLocalizedString("preferences.advanced.processing", comment: "Processing"))
|
|
.font(.headline)
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(NSLocalizedString("preferences.advanced.threads", comment: "Processing threads:"))
|
|
Spacer()
|
|
Text("\(settings.processingThreads)")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Slider(
|
|
value: Binding(
|
|
get: { Double(settings.processingThreads) },
|
|
set: { settings.processingThreads = Int($0) }
|
|
),
|
|
in: 1...8,
|
|
step: 1
|
|
) {
|
|
Text(NSLocalizedString("preferences.advanced.threads_slider", comment: "Processing Threads"))
|
|
}
|
|
|
|
Text(NSLocalizedString("preferences.advanced.threads_description", comment: "Threads description"))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(12)
|
|
}
|
|
|
|
// Logging Settings
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(NSLocalizedString("preferences.advanced.logging", comment: "Logging"))
|
|
.font(.headline)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Toggle(NSLocalizedString("preferences.advanced.enable_logging", comment: "Enable logging"), isOn: $settings.enableLogging)
|
|
|
|
Text(NSLocalizedString("preferences.advanced.logging_description", comment: "Logging description"))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(16)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(12)
|
|
}
|
|
|
|
// Reset Settings
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(NSLocalizedString("preferences.advanced.reset", comment: "Reset"))
|
|
.font(.headline)
|
|
|
|
HStack {
|
|
Button(NSLocalizedString("preferences.advanced.reset_button", comment: "Reset All Settings")) {
|
|
showingResetAlert = true
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.foregroundColor(.red)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(16)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.cornerRadius(12)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(20)
|
|
.alert(NSLocalizedString("preferences.advanced.reset_title", comment: "Reset Settings"), isPresented: $showingResetAlert) {
|
|
Button(NSLocalizedString("general.cancel", comment: "Cancel"), role: .cancel) { }
|
|
Button(NSLocalizedString("general.reset", comment: "Reset"), role: .destructive) {
|
|
resetSettings()
|
|
}
|
|
} message: {
|
|
Text(NSLocalizedString("preferences.advanced.reset_message", comment: "Reset confirmation"))
|
|
}
|
|
}
|
|
|
|
private func resetSettings() {
|
|
// Reset all settings to defaults
|
|
settings.hotkey = HotkeyConfig.default
|
|
settings.hotkeyMode = .pushToTalk
|
|
settings.playSounds = false
|
|
settings.dictationTimeLimit = 600
|
|
settings.hudOpacity = 0.9
|
|
settings.hudSize = 1.0
|
|
settings.forcedLanguage = nil
|
|
settings.insertionMethod = .paste
|
|
settings.showPreview = false
|
|
settings.enableLogging = false
|
|
settings.processingThreads = 4
|
|
}
|
|
} |