Complete Phase 4: Comprehensive preferences, localization, and UX polish
- 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
This commit is contained in:
parent
7ba5895406
commit
54c3b65d4a
25 changed files with 3086 additions and 235 deletions
|
|
@ -1,10 +1,11 @@
|
|||
import SwiftUI
|
||||
import CoreUtils
|
||||
import MenuWhisperAudio
|
||||
import TellMeAudio
|
||||
import CorePermissions
|
||||
import CoreSTT
|
||||
import CoreModels
|
||||
import CoreInjection
|
||||
import CoreSettings
|
||||
import AVFoundation
|
||||
|
||||
public class AppController: ObservableObject {
|
||||
|
|
@ -14,16 +15,21 @@ public class AppController: ObservableObject {
|
|||
private let hotkeyManager = HotkeyManager()
|
||||
private let audioEngine = AudioEngine()
|
||||
private let permissionManager = PermissionManager()
|
||||
private let soundManager = SoundManager()
|
||||
private let soundManager: SoundManager
|
||||
private let textInjector: TextInjector
|
||||
|
||||
// Settings
|
||||
public let settings = Settings()
|
||||
|
||||
// STT components
|
||||
public let whisperEngine = WhisperCPPEngine(numThreads: 4, useGPU: true)
|
||||
public let whisperEngine: WhisperCPPEngine
|
||||
public var modelManager: ModelManager!
|
||||
|
||||
// UI components
|
||||
private var hudWindow: HUDWindow?
|
||||
private var preferencesWindow: PreferencesWindowController?
|
||||
private var onboardingWindow: OnboardingWindowController?
|
||||
private var helpWindow: HelpWindowController?
|
||||
private var statusItem: NSStatusItem?
|
||||
|
||||
// State management
|
||||
|
|
@ -32,9 +38,13 @@ public class AppController: ObservableObject {
|
|||
|
||||
// Dictation timer
|
||||
private var dictationTimer: Timer?
|
||||
private let maxDictationDuration: TimeInterval = 600 // 10 minutes default
|
||||
|
||||
public init() {
|
||||
whisperEngine = WhisperCPPEngine(
|
||||
numThreads: settings.processingThreads,
|
||||
useGPU: true
|
||||
)
|
||||
soundManager = SoundManager(settings: settings)
|
||||
textInjector = TextInjector(permissionManager: permissionManager)
|
||||
setupDelegates()
|
||||
setupNotifications()
|
||||
|
|
@ -89,6 +99,10 @@ public class AppController: ObservableObject {
|
|||
public func start() {
|
||||
logger.info("Starting app controller")
|
||||
|
||||
// Debug: Check localization
|
||||
let testString = NSLocalizedString("preferences.title", comment: "Test")
|
||||
logger.info("Localization test - preferences.title: '\(testString)'")
|
||||
|
||||
// Setup status item menu on main actor
|
||||
Task { @MainActor in
|
||||
setupStatusItemMenu()
|
||||
|
|
@ -110,14 +124,14 @@ public class AppController: ObservableObject {
|
|||
@MainActor
|
||||
private func setupStatusItemMenu() {
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||
statusItem?.button?.image = NSImage(systemSymbolName: "mic", accessibilityDescription: "MenuWhisper")
|
||||
statusItem?.button?.image = NSImage(systemSymbolName: "mic", accessibilityDescription: "Tell me")
|
||||
statusItem?.button?.imagePosition = .imageOnly
|
||||
|
||||
let menu = NSMenu()
|
||||
|
||||
// Status item
|
||||
let statusMenuItem = NSMenuItem()
|
||||
statusMenuItem.title = "MenuWhisper"
|
||||
statusMenuItem.title = NSLocalizedString("app.name", comment: "App name")
|
||||
statusMenuItem.isEnabled = false
|
||||
menu.addItem(statusMenuItem)
|
||||
|
||||
|
|
@ -125,19 +139,33 @@ public class AppController: ObservableObject {
|
|||
|
||||
// Model status
|
||||
let modelMenuItem = NSMenuItem()
|
||||
modelMenuItem.title = "Loading model..."
|
||||
modelMenuItem.title = NSLocalizedString("menubar.loading_model", comment: "Loading model...")
|
||||
modelMenuItem.isEnabled = false
|
||||
menu.addItem(modelMenuItem)
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
// Preferences
|
||||
let preferencesMenuItem = NSMenuItem(title: "Preferences...", action: #selector(openPreferences), keyEquivalent: ",")
|
||||
let preferencesMenuItem = NSMenuItem(title: NSLocalizedString("menubar.preferences", comment: "Preferences..."), action: #selector(openPreferences), keyEquivalent: ",")
|
||||
preferencesMenuItem.target = self
|
||||
menu.addItem(preferencesMenuItem)
|
||||
|
||||
// Help
|
||||
let helpMenuItem = NSMenuItem(title: NSLocalizedString("menubar.help", comment: "Help"), action: #selector(openHelp), keyEquivalent: "?")
|
||||
helpMenuItem.target = self
|
||||
menu.addItem(helpMenuItem)
|
||||
|
||||
// Debug: Reset Onboarding (only in development)
|
||||
#if DEBUG
|
||||
let resetOnboardingMenuItem = NSMenuItem(title: NSLocalizedString("menubar.reset_onboarding", comment: "Reset Onboarding"), action: #selector(resetOnboarding), keyEquivalent: "")
|
||||
resetOnboardingMenuItem.target = self
|
||||
menu.addItem(resetOnboardingMenuItem)
|
||||
#endif
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
// Quit
|
||||
let quitMenuItem = NSMenuItem(title: "Quit MenuWhisper", action: #selector(quitApp), keyEquivalent: "q")
|
||||
let quitMenuItem = NSMenuItem(title: NSLocalizedString("menubar.quit", comment: "Quit Tell me"), action: #selector(quitApp), keyEquivalent: "q")
|
||||
quitMenuItem.target = self
|
||||
menu.addItem(quitMenuItem)
|
||||
|
||||
|
|
@ -153,6 +181,24 @@ public class AppController: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
@objc private func openHelp() {
|
||||
Task { @MainActor in
|
||||
showHelp()
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@objc private func resetOnboarding() {
|
||||
UserDefaults.standard.removeObject(forKey: "hasShownPermissionOnboarding")
|
||||
logger.info("Onboarding status reset - will show on next startup or permission check")
|
||||
|
||||
// Optionally show onboarding immediately
|
||||
Task { @MainActor in
|
||||
showPermissionOnboarding()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@objc private func quitApp() {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
|
|
@ -165,11 +211,11 @@ public class AppController: ObservableObject {
|
|||
let modelMenuItem = menu.items[2] // Model status item
|
||||
|
||||
if let activeModel = modelManager?.activeModel, whisperEngine.isModelLoaded() {
|
||||
modelMenuItem.title = "Model: \(activeModel.name)"
|
||||
modelMenuItem.title = String(format: NSLocalizedString("menubar.model_status", comment: "Model status"), activeModel.name)
|
||||
} else if modelManager?.activeModel != nil {
|
||||
modelMenuItem.title = "Model: Loading..."
|
||||
modelMenuItem.title = NSLocalizedString("menubar.model_loading", comment: "Model loading")
|
||||
} else {
|
||||
modelMenuItem.title = "No model - click Preferences"
|
||||
modelMenuItem.title = NSLocalizedString("menubar.no_model", comment: "No model")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -222,21 +268,28 @@ public class AppController: ObservableObject {
|
|||
|
||||
@MainActor
|
||||
private func showPermissionOnboarding() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Welcome to MenuWhisper"
|
||||
alert.informativeText = "MenuWhisper needs some permissions to work properly:\n\n• Microphone: To capture your speech\n• Accessibility: To insert transcribed text\n• Input Monitoring: To send keyboard events\n\nWould you like to set up permissions now?"
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "Set Up Permissions")
|
||||
alert.addButton(withTitle: "Later")
|
||||
|
||||
let response = alert.runModal()
|
||||
|
||||
// Mark that we've shown the onboarding
|
||||
UserDefaults.standard.set(true, forKey: "hasShownPermissionOnboarding")
|
||||
|
||||
if response == .alertFirstButtonReturn {
|
||||
showPreferences(initialTab: 1) // Open Permissions tab
|
||||
guard let modelManager = modelManager else {
|
||||
logger.error("ModelManager not initialized for onboarding")
|
||||
return
|
||||
}
|
||||
|
||||
onboardingWindow = OnboardingWindowController(
|
||||
permissionManager: permissionManager,
|
||||
modelManager: modelManager,
|
||||
whisperEngine: whisperEngine,
|
||||
onComplete: { [weak self] in
|
||||
// Mark that we've shown the onboarding
|
||||
UserDefaults.standard.set(true, forKey: "hasShownPermissionOnboarding")
|
||||
|
||||
// Clear the reference after a brief delay to allow window to close
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
self?.onboardingWindow = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
onboardingWindow?.showWindow(nil)
|
||||
onboardingWindow?.window?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
private func checkMicrophonePermission(completion: @escaping (Bool) -> Void) {
|
||||
|
|
@ -252,12 +305,53 @@ public class AppController: ObservableObject {
|
|||
cancelDictation()
|
||||
}
|
||||
|
||||
private func checkAllRequiredPermissions() -> Bool {
|
||||
// Refresh permission status first (they might have been granted since startup)
|
||||
permissionManager.checkAllPermissions()
|
||||
|
||||
// Check microphone permission (critical for audio capture)
|
||||
if permissionManager.microphoneStatus != .granted {
|
||||
logger.warning("Microphone permission not granted")
|
||||
Task { @MainActor in
|
||||
showMicrophonePermissionAlert()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Check accessibility permission (required for text insertion)
|
||||
if permissionManager.accessibilityStatus != .granted {
|
||||
logger.warning("Accessibility permission not granted")
|
||||
Task { @MainActor in
|
||||
showAccessibilityPermissionAlert()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Input monitoring (required for hotkeys and text insertion)
|
||||
if permissionManager.inputMonitoringStatus != .granted {
|
||||
logger.warning("Input monitoring permission not granted")
|
||||
Task { @MainActor in
|
||||
showInputMonitoringPermissionAlert()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
logger.info("All required permissions granted")
|
||||
return true
|
||||
}
|
||||
|
||||
private func startListening() {
|
||||
guard currentState == .idle else {
|
||||
logger.warning("Cannot start listening from state: \(currentState)")
|
||||
return
|
||||
}
|
||||
|
||||
// Check all required permissions before starting
|
||||
let allPermissionsGranted = checkAllRequiredPermissions()
|
||||
guard allPermissionsGranted else {
|
||||
return // Permission alerts will be shown by checkAllRequiredPermissions
|
||||
}
|
||||
|
||||
// Check if a model is loaded before starting
|
||||
guard whisperEngine.isModelLoaded() else {
|
||||
logger.warning("No model loaded - showing setup alert")
|
||||
|
|
@ -324,7 +418,7 @@ public class AppController: ObservableObject {
|
|||
}
|
||||
|
||||
let startTime = Date()
|
||||
let transcription = try await whisperEngine.transcribe(audioData: audioData, language: "auto")
|
||||
let transcription = try await whisperEngine.transcribe(audioData: audioData, language: settings.forcedLanguage ?? "auto")
|
||||
let duration = Date().timeIntervalSince(startTime)
|
||||
|
||||
logger.info("Transcription completed in \(String(format: "%.2f", duration))s: \"\(transcription)\"")
|
||||
|
|
@ -345,10 +439,37 @@ public class AppController: ObservableObject {
|
|||
private func injectTranscriptionResult(_ text: String) {
|
||||
logger.info("Attempting to inject transcription result: \(text)")
|
||||
|
||||
// Check if preview is enabled
|
||||
if settings.showPreview {
|
||||
showPreviewDialog(text: text)
|
||||
} else {
|
||||
performTextInjection(text)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func showPreviewDialog(text: String) {
|
||||
let previewDialog = PreviewDialogController(
|
||||
text: text,
|
||||
onInsert: { [weak self] editedText in
|
||||
self?.performTextInjection(editedText)
|
||||
},
|
||||
onCancel: { [weak self] in
|
||||
self?.finishProcessing()
|
||||
}
|
||||
)
|
||||
previewDialog.showWindow(nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func performTextInjection(_ text: String) {
|
||||
do {
|
||||
// Attempt to inject the text using paste method with fallback enabled
|
||||
try textInjector.injectText(text, method: .paste, enableFallback: true)
|
||||
logger.info("Text injection successful")
|
||||
// Determine injection method from settings
|
||||
let injectionMethod: InjectionMethod = settings.insertionMethod == .paste ? .paste : .typing
|
||||
|
||||
// Attempt to inject the text using the configured method
|
||||
try textInjector.injectText(text, method: injectionMethod, enableFallback: true)
|
||||
logger.info("Text injection successful using \(injectionMethod) method")
|
||||
|
||||
// Show success and finish processing
|
||||
finishProcessing()
|
||||
|
|
@ -403,7 +524,7 @@ public class AppController: ObservableObject {
|
|||
private func startDictationTimer() {
|
||||
stopDictationTimer() // Clean up any existing timer
|
||||
|
||||
dictationTimer = Timer.scheduledTimer(withTimeInterval: maxDictationDuration, repeats: false) { [weak self] _ in
|
||||
dictationTimer = Timer.scheduledTimer(withTimeInterval: settings.dictationTimeLimit, repeats: false) { [weak self] _ in
|
||||
self?.logger.info("Dictation timeout reached")
|
||||
self?.stopListening()
|
||||
}
|
||||
|
|
@ -416,7 +537,7 @@ public class AppController: ObservableObject {
|
|||
|
||||
private func showHUD(state: HUDState) {
|
||||
if hudWindow == nil {
|
||||
hudWindow = HUDWindow()
|
||||
hudWindow = HUDWindow(settings: settings)
|
||||
}
|
||||
hudWindow?.show(state: state)
|
||||
}
|
||||
|
|
@ -445,7 +566,7 @@ public class AppController: ObservableObject {
|
|||
private func showPermissionRequiredNotice() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Permission Required"
|
||||
alert.informativeText = "MenuWhisper needs Accessibility and Input Monitoring permissions to insert text into other applications.\n\nWould you like to open System Settings to grant these permissions?"
|
||||
alert.informativeText = "Tell me needs Accessibility and Input Monitoring permissions to insert text into other applications.\n\nWould you like to open System Settings to grant these permissions?"
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: "Open System Settings")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
|
@ -478,6 +599,7 @@ public class AppController: ObservableObject {
|
|||
modelManager: modelManager,
|
||||
whisperEngine: whisperEngine,
|
||||
permissionManager: permissionManager,
|
||||
settings: settings,
|
||||
initialTab: initialTab
|
||||
)
|
||||
} else {
|
||||
|
|
@ -490,11 +612,67 @@ public class AppController: ObservableObject {
|
|||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func showHelp() {
|
||||
if helpWindow == nil {
|
||||
helpWindow = HelpWindowController()
|
||||
}
|
||||
|
||||
helpWindow?.showWindow(nil)
|
||||
helpWindow?.window?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func showMicrophonePermissionAlert() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = NSLocalizedString("permissions.microphone.title", comment: "Microphone Access Required")
|
||||
alert.informativeText = NSLocalizedString("permissions.microphone.message", comment: "Microphone permission message")
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: NSLocalizedString("permissions.open_settings", comment: "Open System Settings"))
|
||||
alert.addButton(withTitle: NSLocalizedString("general.cancel", comment: "Cancel"))
|
||||
|
||||
let response = alert.runModal()
|
||||
if response == .alertFirstButtonReturn {
|
||||
permissionManager.openSystemSettings(for: .microphone)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func showAccessibilityPermissionAlert() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = NSLocalizedString("permissions.accessibility.title", comment: "Accessibility Access Required")
|
||||
alert.informativeText = NSLocalizedString("permissions.accessibility.message", comment: "Accessibility permission message")
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: NSLocalizedString("permissions.open_settings", comment: "Open System Settings"))
|
||||
alert.addButton(withTitle: NSLocalizedString("general.cancel", comment: "Cancel"))
|
||||
|
||||
let response = alert.runModal()
|
||||
if response == .alertFirstButtonReturn {
|
||||
permissionManager.openSystemSettings(for: .accessibility)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func showInputMonitoringPermissionAlert() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = NSLocalizedString("permissions.input_monitoring.title", comment: "Input Monitoring Required")
|
||||
alert.informativeText = NSLocalizedString("permissions.input_monitoring.message", comment: "Input monitoring permission message")
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: NSLocalizedString("permissions.open_settings", comment: "Open System Settings"))
|
||||
alert.addButton(withTitle: NSLocalizedString("general.cancel", comment: "Cancel"))
|
||||
|
||||
let response = alert.runModal()
|
||||
if response == .alertFirstButtonReturn {
|
||||
permissionManager.openSystemSettings(for: .inputMonitoring)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func showModelSetupAlert() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "No Speech Recognition Model"
|
||||
alert.informativeText = "You need to download and select a speech recognition model before using MenuWhisper.\n\nWould you like to open Preferences to download a model?"
|
||||
alert.informativeText = "You need to download and select a speech recognition model before using Tell me.\n\nWould you like to open Preferences to download a model?"
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "Open Preferences")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
|
@ -511,6 +689,8 @@ public class AppController: ObservableObject {
|
|||
audioEngine.stopCapture()
|
||||
hotkeyManager.disableHotkey()
|
||||
preferencesWindow?.close()
|
||||
onboardingWindow?.close()
|
||||
helpWindow?.close()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import SwiftUI
|
||||
import AppKit
|
||||
import CoreUtils
|
||||
import CoreSettings
|
||||
|
||||
public enum HUDState {
|
||||
case hidden
|
||||
|
|
@ -8,12 +9,21 @@ public enum HUDState {
|
|||
case processing
|
||||
}
|
||||
|
||||
public class HUDWindow: NSPanel {
|
||||
public class HUDWindow: NSPanel, ObservableObject {
|
||||
private var hostingView: NSHostingView<HUDContentView>?
|
||||
private let settings: CoreSettings.Settings
|
||||
@Published var hudState: HUDState = .hidden
|
||||
|
||||
public init(settings: CoreSettings.Settings) {
|
||||
self.settings = settings
|
||||
|
||||
let baseWidth: CGFloat = 320
|
||||
let baseHeight: CGFloat = 160
|
||||
let scaledWidth = baseWidth * settings.hudSize
|
||||
let scaledHeight = baseHeight * settings.hudSize
|
||||
|
||||
public init() {
|
||||
super.init(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 160),
|
||||
contentRect: NSRect(x: 0, y: 0, width: scaledWidth, height: scaledHeight),
|
||||
styleMask: [.nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
|
|
@ -33,7 +43,7 @@ public class HUDWindow: NSPanel {
|
|||
}
|
||||
|
||||
private func setupContentView() {
|
||||
let hudContentView = HUDContentView()
|
||||
let hudContentView = HUDContentView(settings: settings, hudWindow: self)
|
||||
hostingView = NSHostingView(rootView: hudContentView)
|
||||
|
||||
if let hostingView = hostingView {
|
||||
|
|
@ -44,16 +54,16 @@ public class HUDWindow: NSPanel {
|
|||
public func show(state: HUDState) {
|
||||
centerOnScreen()
|
||||
|
||||
if let hostingView = hostingView {
|
||||
hostingView.rootView.updateState(state)
|
||||
}
|
||||
// Update the published state
|
||||
hudState = state
|
||||
print("HUD showing with state: \(state)")
|
||||
|
||||
if !isVisible {
|
||||
orderFront(nil)
|
||||
alphaValue = 0
|
||||
NSAnimationContext.runAnimationGroup({ context in
|
||||
context.duration = 0.2
|
||||
animator().alphaValue = 1.0
|
||||
animator().alphaValue = settings.hudOpacity
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -70,9 +80,7 @@ public class HUDWindow: NSPanel {
|
|||
}
|
||||
|
||||
public func updateLevel(_ level: Float) {
|
||||
if let hostingView = hostingView {
|
||||
hostingView.rootView.updateState(.listening(level: level))
|
||||
}
|
||||
hudState = .listening(level: level)
|
||||
}
|
||||
|
||||
private func centerOnScreen() {
|
||||
|
|
@ -105,7 +113,8 @@ extension Notification.Name {
|
|||
}
|
||||
|
||||
struct HUDContentView: View {
|
||||
@State private var currentState: HUDState = .hidden
|
||||
@ObservedObject var settings: CoreSettings.Settings
|
||||
@ObservedObject var hudWindow: HUDWindow
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
|
|
@ -117,7 +126,7 @@ struct HUDContentView: View {
|
|||
)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
switch currentState {
|
||||
switch hudWindow.hudState {
|
||||
case .hidden:
|
||||
EmptyView()
|
||||
|
||||
|
|
@ -130,7 +139,11 @@ struct HUDContentView: View {
|
|||
}
|
||||
.padding(24)
|
||||
}
|
||||
.frame(width: 320, height: 160)
|
||||
.frame(width: 320 * settings.hudSize, height: 160 * settings.hudSize)
|
||||
.scaleEffect(settings.hudSize)
|
||||
.onAppear {
|
||||
print("HUD Content View appeared with state: \(hudWindow.hudState)")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
@ -140,14 +153,14 @@ struct HUDContentView: View {
|
|||
.font(.system(size: 32))
|
||||
.foregroundColor(.blue)
|
||||
|
||||
Text("Listening...")
|
||||
Text(NSLocalizedString("hud.listening", comment: "Listening..."))
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
AudioLevelView(level: level)
|
||||
.frame(height: 20)
|
||||
|
||||
Text("Press Esc to cancel")
|
||||
Text(NSLocalizedString("hud.cancel", comment: "Press Esc to cancel"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
|
@ -159,21 +172,16 @@ struct HUDContentView: View {
|
|||
ProgressView()
|
||||
.scaleEffect(1.2)
|
||||
|
||||
Text("Processing...")
|
||||
Text(NSLocalizedString("hud.processing", comment: "Processing..."))
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Please wait")
|
||||
Text(NSLocalizedString("hud.please_wait", comment: "Please wait"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
func updateState(_ state: HUDState) {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
currentState = state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AudioLevelView: View {
|
||||
|
|
|
|||
683
Sources/App/HelpWindow.swift
Normal file
683
Sources/App/HelpWindow.swift
Normal file
|
|
@ -0,0 +1,683 @@
|
|||
import SwiftUI
|
||||
|
||||
class HelpWindowController: NSWindowController {
|
||||
init() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 700, height: 600),
|
||||
styleMask: [.titled, .closable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
super.init(window: window)
|
||||
|
||||
window.title = "Tell me Help"
|
||||
window.center()
|
||||
|
||||
let helpView = HelpView()
|
||||
window.contentView = NSHostingView(rootView: helpView)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
struct HelpView: View {
|
||||
@State private var selectedSection = 0
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
// Sidebar
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Help Topics")
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
List(selection: $selectedSection) {
|
||||
HelpSectionItem(title: "Getting Started", systemImage: "play.circle", tag: 0)
|
||||
HelpSectionItem(title: "Permissions", systemImage: "lock.shield", tag: 1)
|
||||
HelpSectionItem(title: "Speech Models", systemImage: "brain.head.profile", tag: 2)
|
||||
HelpSectionItem(title: "Hotkeys & Usage", systemImage: "keyboard", tag: 3)
|
||||
HelpSectionItem(title: "Troubleshooting", systemImage: "wrench.and.screwdriver", tag: 4)
|
||||
HelpSectionItem(title: "Privacy & Security", systemImage: "shield.checkered", tag: 5)
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
}
|
||||
.frame(minWidth: 200, maxWidth: 300)
|
||||
|
||||
// Content area
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
switch selectedSection {
|
||||
case 0:
|
||||
GettingStartedHelp()
|
||||
case 1:
|
||||
PermissionsHelp()
|
||||
case 2:
|
||||
ModelsHelp()
|
||||
case 3:
|
||||
HotkeysHelp()
|
||||
case 4:
|
||||
TroubleshootingHelp()
|
||||
case 5:
|
||||
PrivacyHelp()
|
||||
default:
|
||||
GettingStartedHelp()
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
.frame(width: 700, height: 600)
|
||||
}
|
||||
}
|
||||
|
||||
struct HelpSectionItem: View {
|
||||
let title: String
|
||||
let systemImage: String
|
||||
let tag: Int
|
||||
|
||||
var body: some View {
|
||||
Label(title, systemImage: systemImage)
|
||||
.tag(tag)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Help Content Views
|
||||
|
||||
struct GettingStartedHelp: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Getting Started with Tell me")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Text("Tell me is a privacy-focused speech-to-text application that works completely offline on your Mac.")
|
||||
.font(.body)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Quick Setup")
|
||||
.font(.headline)
|
||||
|
||||
HelpStep(
|
||||
number: 1,
|
||||
title: "Grant Permissions",
|
||||
description: "Allow Microphone, Accessibility, and Input Monitoring access when prompted."
|
||||
)
|
||||
|
||||
HelpStep(
|
||||
number: 2,
|
||||
title: "Download a Model",
|
||||
description: "Go to Preferences → Models and download a speech recognition model (recommended: whisper-small)."
|
||||
)
|
||||
|
||||
HelpStep(
|
||||
number: 3,
|
||||
title: "Start Dictating",
|
||||
description: "Press ⌘⇧V anywhere to start dictation. The HUD will appear to show status."
|
||||
)
|
||||
|
||||
HelpStep(
|
||||
number: 4,
|
||||
title: "Stop and Insert",
|
||||
description: "Release the hotkey (push-to-talk mode) or press it again (toggle mode) to transcribe and insert text."
|
||||
)
|
||||
}
|
||||
|
||||
InfoBox(
|
||||
title: "First Time Setup",
|
||||
content: "If this is your first time, Tell me will guide you through the setup process automatically.",
|
||||
type: .info
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PermissionsHelp: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Permissions")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Text("Tell me requires specific system permissions to function properly.")
|
||||
.font(.body)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
PermissionHelpItem(
|
||||
title: "Microphone",
|
||||
description: "Required to capture your speech for transcription.",
|
||||
required: true,
|
||||
howToGrant: "Grant when prompted, or go to System Settings → Privacy & Security → Microphone."
|
||||
)
|
||||
|
||||
PermissionHelpItem(
|
||||
title: "Accessibility",
|
||||
description: "Required to insert transcribed text into other applications.",
|
||||
required: true,
|
||||
howToGrant: "Go to System Settings → Privacy & Security → Accessibility and add Tell me."
|
||||
)
|
||||
|
||||
PermissionHelpItem(
|
||||
title: "Input Monitoring",
|
||||
description: "Required to register global keyboard shortcuts and send text insertion events.",
|
||||
required: true,
|
||||
howToGrant: "Go to System Settings → Privacy & Security → Input Monitoring and add Tell me."
|
||||
)
|
||||
}
|
||||
|
||||
InfoBox(
|
||||
title: "Permission Issues",
|
||||
content: "If permissions aren't working, try restarting Tell me after granting them. Some permissions may require logging out and back in.",
|
||||
type: .warning
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ModelsHelp: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Speech Recognition Models")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Text("Tell me uses OpenAI Whisper models for speech recognition. All models work entirely offline.")
|
||||
.font(.body)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Available Models")
|
||||
.font(.headline)
|
||||
|
||||
ModelHelpItem(
|
||||
name: "whisper-tiny",
|
||||
size: "39 MB",
|
||||
ram: "~400 MB",
|
||||
speed: "Very Fast",
|
||||
accuracy: "Basic",
|
||||
recommendation: "Good for testing or very low-end hardware"
|
||||
)
|
||||
|
||||
ModelHelpItem(
|
||||
name: "whisper-base",
|
||||
size: "142 MB",
|
||||
ram: "~500 MB",
|
||||
speed: "Fast",
|
||||
accuracy: "Good",
|
||||
recommendation: "Recommended for most users"
|
||||
)
|
||||
|
||||
ModelHelpItem(
|
||||
name: "whisper-small",
|
||||
size: "466 MB",
|
||||
ram: "~1 GB",
|
||||
speed: "Medium",
|
||||
accuracy: "Very Good",
|
||||
recommendation: "Best balance of speed and accuracy"
|
||||
)
|
||||
}
|
||||
|
||||
InfoBox(
|
||||
title: "Model Storage",
|
||||
content: "Models are stored in ~/Library/Application Support/Tell me/Models and can be deleted from Preferences to free up space.",
|
||||
type: .info
|
||||
)
|
||||
|
||||
InfoBox(
|
||||
title: "No Internet Required",
|
||||
content: "Once downloaded, models work completely offline. Your speech never leaves your device.",
|
||||
type: .success
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HotkeysHelp: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Hotkeys & Usage")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Text("Tell me supports global hotkeys to start dictation from anywhere on your system.")
|
||||
.font(.body)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Activation Modes")
|
||||
.font(.headline)
|
||||
|
||||
HelpModeItem(
|
||||
title: "Push-to-Talk (Default)",
|
||||
description: "Hold down the hotkey to dictate, release to stop and transcribe."
|
||||
)
|
||||
|
||||
HelpModeItem(
|
||||
title: "Toggle Mode",
|
||||
description: "Press once to start dictation, press again to stop and transcribe."
|
||||
)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Default Hotkey")
|
||||
.font(.headline)
|
||||
|
||||
HStack {
|
||||
Text("⌘⇧V")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.gray.opacity(0.2))
|
||||
.cornerRadius(8)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("Command + Shift + V")
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
Text("Can be changed in Preferences → General")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Other Controls")
|
||||
.font(.headline)
|
||||
|
||||
HStack {
|
||||
Text("⎋ Esc")
|
||||
.font(.body)
|
||||
.fontWeight(.bold)
|
||||
.frame(width: 60, alignment: .leading)
|
||||
Text("Cancel dictation at any time")
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("⌘↩ Cmd+Return")
|
||||
.font(.body)
|
||||
.fontWeight(.bold)
|
||||
.frame(width: 120, alignment: .leading)
|
||||
Text("Insert text (in preview mode)")
|
||||
}
|
||||
}
|
||||
|
||||
InfoBox(
|
||||
title: "Hotkey Conflicts",
|
||||
content: "If your hotkey conflicts with another app, change it in Preferences → General → Hotkey Combination.",
|
||||
type: .warning
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TroubleshootingHelp: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Troubleshooting")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
TroubleshootingItem(
|
||||
issue: "Hotkey doesn't work",
|
||||
solutions: [
|
||||
"Check that Input Monitoring permission is granted",
|
||||
"Try changing the hotkey in Preferences",
|
||||
"Restart Tell me after granting permissions",
|
||||
"Make sure no other app is using the same hotkey"
|
||||
]
|
||||
)
|
||||
|
||||
TroubleshootingItem(
|
||||
issue: "Text doesn't insert",
|
||||
solutions: [
|
||||
"Grant Accessibility permission in System Settings",
|
||||
"Try switching insertion method in Preferences → Text Insertion",
|
||||
"Check if you're in a secure input field (password, etc.)",
|
||||
"Restart Tell me after granting permissions"
|
||||
]
|
||||
)
|
||||
|
||||
TroubleshootingItem(
|
||||
issue: "Poor transcription quality",
|
||||
solutions: [
|
||||
"Try a larger model (whisper-small or whisper-base)",
|
||||
"Ensure you're in a quiet environment",
|
||||
"Speak clearly and at normal pace",
|
||||
"Check your microphone input level",
|
||||
"Set the correct language in Preferences → Models"
|
||||
]
|
||||
)
|
||||
|
||||
TroubleshootingItem(
|
||||
issue: "App crashes or hangs",
|
||||
solutions: [
|
||||
"Check available RAM (models need 400MB-1GB+)",
|
||||
"Try reducing processing threads in Preferences → Advanced",
|
||||
"Restart Tell me",
|
||||
"Try a smaller model if using whisper-medium or larger"
|
||||
]
|
||||
)
|
||||
|
||||
TroubleshootingItem(
|
||||
issue: "Microphone not working",
|
||||
solutions: [
|
||||
"Grant Microphone permission when prompted",
|
||||
"Check System Settings → Privacy & Security → Microphone",
|
||||
"Test microphone in other apps",
|
||||
"Check input level in System Settings → Sound"
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
InfoBox(
|
||||
title: "Still Having Issues?",
|
||||
content: "Enable logging in Preferences → Advanced and check Console.app for Tell me logs, or try restarting your Mac if permission issues persist.",
|
||||
type: .info
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PrivacyHelp: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Privacy & Security")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("100% Offline Operation")
|
||||
.font(.headline)
|
||||
|
||||
Text("Tell me is designed with privacy as the top priority:")
|
||||
.font(.body)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
PrivacyPoint(
|
||||
icon: "shield.checkered",
|
||||
text: "Your audio never leaves your device"
|
||||
)
|
||||
|
||||
PrivacyPoint(
|
||||
icon: "wifi.slash",
|
||||
text: "No internet connection required for transcription"
|
||||
)
|
||||
|
||||
PrivacyPoint(
|
||||
icon: "eye.slash",
|
||||
text: "No telemetry or usage tracking"
|
||||
)
|
||||
|
||||
PrivacyPoint(
|
||||
icon: "lock.doc",
|
||||
text: "Transcribed text is not stored or logged"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Local Data Storage")
|
||||
.font(.headline)
|
||||
|
||||
Text("Tell me only stores:")
|
||||
.font(.body)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("• Settings and preferences in UserDefaults")
|
||||
.font(.body)
|
||||
Text("• Downloaded models in ~/Library/Application Support/Tell me/")
|
||||
.font(.body)
|
||||
Text("• Optional local logs (if enabled, contains only timing and error data)")
|
||||
.font(.body)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Secure Input Detection")
|
||||
.font(.headline)
|
||||
|
||||
Text("Tell me automatically detects secure input contexts (like password fields) and disables text insertion to protect your security. In these cases, text is copied to clipboard instead.")
|
||||
.font(.body)
|
||||
}
|
||||
|
||||
InfoBox(
|
||||
title: "Open Source",
|
||||
content: "Tell me is open source software. You can review the code and build it yourself for complete transparency.",
|
||||
type: .success
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Views
|
||||
|
||||
struct HelpStep: View {
|
||||
let number: Int
|
||||
let title: String
|
||||
let description: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Text("\(number)")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Color.blue)
|
||||
.clipShape(Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
Text(description)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PermissionHelpItem: View {
|
||||
let title: String
|
||||
let description: String
|
||||
let required: Bool
|
||||
let howToGrant: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
if required {
|
||||
Text("REQUIRED")
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.red)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.red.opacity(0.1))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
|
||||
Text(description)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("How to grant: \(howToGrant)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
struct ModelHelpItem: View {
|
||||
let name: String
|
||||
let size: String
|
||||
let ram: String
|
||||
let speed: String
|
||||
let accuracy: String
|
||||
let recommendation: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(name)
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(size)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("RAM: \(ram)")
|
||||
.font(.caption)
|
||||
Text("Speed: \(speed)")
|
||||
.font(.caption)
|
||||
Text("Accuracy: \(accuracy)")
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text(recommendation)
|
||||
.font(.caption)
|
||||
.foregroundColor(.blue)
|
||||
.italic()
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
struct HelpModeItem: View {
|
||||
let title: String
|
||||
let description: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Circle()
|
||||
.fill(Color.blue)
|
||||
.frame(width: 8, height: 8)
|
||||
.padding(.top, 6)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
Text(description)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TroubleshootingItem: View {
|
||||
let issue: String
|
||||
let solutions: [String]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(issue)
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(solutions, id: \.self) { solution in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text("•")
|
||||
.foregroundColor(.blue)
|
||||
Text(solution)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
struct PrivacyPoint: View {
|
||||
let icon: String
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(.green)
|
||||
.frame(width: 20)
|
||||
|
||||
Text(text)
|
||||
.font(.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct InfoBox: View {
|
||||
let title: String
|
||||
let content: String
|
||||
let type: InfoType
|
||||
|
||||
enum InfoType {
|
||||
case info, warning, success, error
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .info: return .blue
|
||||
case .warning: return .orange
|
||||
case .success: return .green
|
||||
case .error: return .red
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .info: return "info.circle"
|
||||
case .warning: return "exclamationmark.triangle"
|
||||
case .success: return "checkmark.circle"
|
||||
case .error: return "xmark.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: type.icon)
|
||||
.foregroundColor(type.color)
|
||||
.font(.title3)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
Text(content)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(type.color.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
258
Sources/App/HotkeyRecorder.swift
Normal file
258
Sources/App/HotkeyRecorder.swift
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import SwiftUI
|
||||
import Carbon
|
||||
import CoreSettings
|
||||
|
||||
struct HotkeyRecorder: View {
|
||||
@Binding var hotkey: HotkeyConfig
|
||||
@State private var isRecording = false
|
||||
@State private var recordedKeyCode: UInt32?
|
||||
@State private var recordedModifiers: UInt32?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Button(action: {
|
||||
if isRecording {
|
||||
stopRecording()
|
||||
} else {
|
||||
startRecording()
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
if isRecording {
|
||||
Text(NSLocalizedString("hotkey.press_keys", comment: "Press keys..."))
|
||||
.foregroundColor(.primary)
|
||||
} else {
|
||||
Text(hotkeyDisplayString)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 150, minHeight: 30)
|
||||
.background(isRecording ? Color.blue.opacity(0.2) : Color(NSColor.controlBackgroundColor))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(isRecording ? Color.blue : Color.gray.opacity(0.3), lineWidth: 2)
|
||||
)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onKeyDown { event in
|
||||
guard isRecording else { return false }
|
||||
handleKeyEvent(event)
|
||||
return true
|
||||
}
|
||||
|
||||
if hotkey.keyCode != HotkeyConfig.default.keyCode || hotkey.modifiers != HotkeyConfig.default.modifiers {
|
||||
Button(NSLocalizedString("hotkey.reset_default", comment: "Reset to Default")) {
|
||||
hotkey = HotkeyConfig.default
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.controlSize(.small)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text(NSLocalizedString("hotkey.record_description", comment: "Click to record a new hotkey combination"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.focusable()
|
||||
}
|
||||
|
||||
private var hotkeyDisplayString: String {
|
||||
return hotkeyToString(keyCode: hotkey.keyCode, modifiers: hotkey.modifiers)
|
||||
}
|
||||
|
||||
private func startRecording() {
|
||||
isRecording = true
|
||||
recordedKeyCode = nil
|
||||
recordedModifiers = nil
|
||||
}
|
||||
|
||||
private func stopRecording() {
|
||||
if let keyCode = recordedKeyCode, let modifiers = recordedModifiers {
|
||||
hotkey = HotkeyConfig(keyCode: keyCode, modifiers: modifiers)
|
||||
}
|
||||
isRecording = false
|
||||
recordedKeyCode = nil
|
||||
recordedModifiers = nil
|
||||
}
|
||||
|
||||
private func handleKeyEvent(_ event: NSEvent) {
|
||||
let keyCode = event.keyCode
|
||||
let modifierFlags = event.modifierFlags
|
||||
|
||||
// Convert NSEvent modifier flags to Carbon modifier flags
|
||||
var carbonModifiers: UInt32 = 0
|
||||
|
||||
if modifierFlags.contains(.command) {
|
||||
carbonModifiers |= UInt32(cmdKey)
|
||||
}
|
||||
if modifierFlags.contains(.shift) {
|
||||
carbonModifiers |= UInt32(shiftKey)
|
||||
}
|
||||
if modifierFlags.contains(.option) {
|
||||
carbonModifiers |= UInt32(optionKey)
|
||||
}
|
||||
if modifierFlags.contains(.control) {
|
||||
carbonModifiers |= UInt32(controlKey)
|
||||
}
|
||||
|
||||
// Only accept combinations with at least one modifier
|
||||
guard carbonModifiers != 0 else { return }
|
||||
|
||||
recordedKeyCode = UInt32(keyCode)
|
||||
recordedModifiers = carbonModifiers
|
||||
|
||||
// Auto-stop recording after a brief delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
if isRecording {
|
||||
stopRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Functions
|
||||
|
||||
private func hotkeyToString(keyCode: UInt32, modifiers: UInt32) -> String {
|
||||
var result = ""
|
||||
|
||||
// Add modifier symbols
|
||||
if modifiers & UInt32(controlKey) != 0 {
|
||||
result += "⌃"
|
||||
}
|
||||
if modifiers & UInt32(optionKey) != 0 {
|
||||
result += "⌥"
|
||||
}
|
||||
if modifiers & UInt32(shiftKey) != 0 {
|
||||
result += "⇧"
|
||||
}
|
||||
if modifiers & UInt32(cmdKey) != 0 {
|
||||
result += "⌘"
|
||||
}
|
||||
|
||||
// Add key name
|
||||
result += keyCodeToString(keyCode)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func keyCodeToString(_ keyCode: UInt32) -> String {
|
||||
// Map common key codes to their string representations
|
||||
switch keyCode {
|
||||
case 0: return "A"
|
||||
case 1: return "S"
|
||||
case 2: return "D"
|
||||
case 3: return "F"
|
||||
case 4: return "H"
|
||||
case 5: return "G"
|
||||
case 6: return "Z"
|
||||
case 7: return "X"
|
||||
case 8: return "C"
|
||||
case 9: return "V"
|
||||
case 10: return "§"
|
||||
case 11: return "B"
|
||||
case 12: return "Q"
|
||||
case 13: return "W"
|
||||
case 14: return "E"
|
||||
case 15: return "R"
|
||||
case 16: return "Y"
|
||||
case 17: return "T"
|
||||
case 18: return "1"
|
||||
case 19: return "2"
|
||||
case 20: return "3"
|
||||
case 21: return "4"
|
||||
case 22: return "6"
|
||||
case 23: return "5"
|
||||
case 24: return "="
|
||||
case 25: return "9"
|
||||
case 26: return "7"
|
||||
case 27: return "-"
|
||||
case 28: return "8"
|
||||
case 29: return "0"
|
||||
case 30: return "]"
|
||||
case 31: return "O"
|
||||
case 32: return "U"
|
||||
case 33: return "["
|
||||
case 34: return "I"
|
||||
case 35: return "P"
|
||||
case 36: return "⏎"
|
||||
case 37: return "L"
|
||||
case 38: return "J"
|
||||
case 39: return "'"
|
||||
case 40: return "K"
|
||||
case 41: return ";"
|
||||
case 42: return "\\"
|
||||
case 43: return ","
|
||||
case 44: return "/"
|
||||
case 45: return "N"
|
||||
case 46: return "M"
|
||||
case 47: return "."
|
||||
case 48: return "⇥"
|
||||
case 49: return "Space"
|
||||
case 50: return "`"
|
||||
case 51: return "⌫"
|
||||
case 53: return "⎋"
|
||||
case 96: return "F5"
|
||||
case 97: return "F6"
|
||||
case 98: return "F7"
|
||||
case 99: return "F3"
|
||||
case 100: return "F8"
|
||||
case 101: return "F9"
|
||||
case 103: return "F11"
|
||||
case 105: return "F13"
|
||||
case 107: return "F14"
|
||||
case 109: return "F10"
|
||||
case 111: return "F12"
|
||||
case 113: return "F15"
|
||||
case 114: return "Help"
|
||||
case 115: return "Home"
|
||||
case 116: return "⇞"
|
||||
case 117: return "⌦"
|
||||
case 118: return "F4"
|
||||
case 119: return "End"
|
||||
case 120: return "F2"
|
||||
case 121: return "⇟"
|
||||
case 122: return "F1"
|
||||
case 123: return "←"
|
||||
case 124: return "→"
|
||||
case 125: return "↓"
|
||||
case 126: return "↑"
|
||||
default:
|
||||
return "Key \(keyCode)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension View {
|
||||
func onKeyDown(perform action: @escaping (NSEvent) -> Bool) -> some View {
|
||||
self.background(KeyEventHandlingView(onKeyDown: action))
|
||||
}
|
||||
}
|
||||
|
||||
struct KeyEventHandlingView: NSViewRepresentable {
|
||||
let onKeyDown: (NSEvent) -> Bool
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = KeyHandlingNSView()
|
||||
view.onKeyDown = onKeyDown
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||
}
|
||||
|
||||
class KeyHandlingNSView: NSView {
|
||||
var onKeyDown: ((NSEvent) -> Bool)?
|
||||
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
if let handler = onKeyDown, handler(event) {
|
||||
return
|
||||
}
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
}
|
||||
557
Sources/App/OnboardingWindow.swift
Normal file
557
Sources/App/OnboardingWindow.swift
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
import SwiftUI
|
||||
import CorePermissions
|
||||
import CoreModels
|
||||
import CoreSTT
|
||||
|
||||
class OnboardingWindowController: NSWindowController {
|
||||
private let permissionManager: PermissionManager
|
||||
private let modelManager: ModelManager
|
||||
private let whisperEngine: WhisperCPPEngine
|
||||
private var onboardingView: OnboardingView?
|
||||
|
||||
init(permissionManager: PermissionManager, modelManager: ModelManager, whisperEngine: WhisperCPPEngine, onComplete: @escaping () -> Void) {
|
||||
self.permissionManager = permissionManager
|
||||
self.modelManager = modelManager
|
||||
self.whisperEngine = whisperEngine
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 650, height: 550),
|
||||
styleMask: [.titled, .closable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
super.init(window: window)
|
||||
|
||||
window.title = NSLocalizedString("onboarding.title", comment: "Welcome to Tell me")
|
||||
window.center()
|
||||
window.level = .floating
|
||||
|
||||
onboardingView = OnboardingView(
|
||||
permissionManager: permissionManager,
|
||||
modelManager: modelManager,
|
||||
whisperEngine: whisperEngine,
|
||||
onComplete: { [weak self] in
|
||||
print("Onboarding complete, closing window...")
|
||||
onComplete()
|
||||
self?.close()
|
||||
}
|
||||
)
|
||||
|
||||
window.contentView = NSHostingView(rootView: onboardingView!)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingView: View {
|
||||
@ObservedObject var permissionManager: PermissionManager
|
||||
@ObservedObject var modelManager: ModelManager
|
||||
let whisperEngine: WhisperCPPEngine
|
||||
let onComplete: () -> Void
|
||||
|
||||
@State private var currentStep = 0
|
||||
@State private var isDownloading = false
|
||||
@State private var downloadError: String?
|
||||
private let totalSteps = 4
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "mic.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
.font(.system(size: 50))
|
||||
|
||||
Text(NSLocalizedString("onboarding.title", comment: "Welcome to Tell me"))
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Text(NSLocalizedString("onboarding.subtitle", comment: "Offline speech-to-text for macOS"))
|
||||
.font(.title3)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.top, 50)
|
||||
.padding(.bottom, 30)
|
||||
.padding(.horizontal, 50)
|
||||
|
||||
// Progress indicator
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<totalSteps, id: \.self) { step in
|
||||
Circle()
|
||||
.fill(step <= currentStep ? Color.blue : Color.gray.opacity(0.3))
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
|
||||
// Content area
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
switch currentStep {
|
||||
case 0:
|
||||
WelcomeStep()
|
||||
case 1:
|
||||
PermissionsStep(permissionManager: permissionManager)
|
||||
case 2:
|
||||
ModelsStep(
|
||||
modelManager: modelManager,
|
||||
whisperEngine: whisperEngine,
|
||||
isDownloading: $isDownloading,
|
||||
downloadError: $downloadError
|
||||
)
|
||||
case 3:
|
||||
CompletionStep(hasModel: modelManager.activeModel != nil)
|
||||
default:
|
||||
WelcomeStep()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 50)
|
||||
}
|
||||
.frame(height: 280)
|
||||
|
||||
// Navigation buttons (fixed at bottom)
|
||||
HStack(spacing: 12) {
|
||||
Button(NSLocalizedString("onboarding.buttons.skip", comment: "Skip Setup")) {
|
||||
onComplete()
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.controlSize(.regular)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if currentStep > 0 {
|
||||
Button(NSLocalizedString("onboarding.buttons.back", comment: "Back")) {
|
||||
currentStep = max(0, currentStep - 1)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.regular)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if currentStep < totalSteps - 1 {
|
||||
Button(NSLocalizedString("onboarding.buttons.next", comment: "Next")) {
|
||||
currentStep = min(totalSteps - 1, currentStep + 1)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.regular)
|
||||
} else {
|
||||
Button(NSLocalizedString("onboarding.buttons.get_started", comment: "Get Started")) {
|
||||
onComplete()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.regular)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 50)
|
||||
.padding(.top, 30)
|
||||
.padding(.bottom, 50)
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
}
|
||||
.frame(width: 650, height: 550)
|
||||
}
|
||||
}
|
||||
|
||||
struct WelcomeStep: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(NSLocalizedString("onboarding.what_is", comment: "What is Tell me?"))
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
FeatureRow(
|
||||
icon: "mic.fill",
|
||||
title: NSLocalizedString("onboarding.feature.offline", comment: "Offline Speech Recognition"),
|
||||
description: NSLocalizedString("onboarding.feature.offline_desc", comment: "Offline description")
|
||||
)
|
||||
|
||||
FeatureRow(
|
||||
icon: "keyboard",
|
||||
title: NSLocalizedString("onboarding.feature.hotkey", comment: "Global Hotkey"),
|
||||
description: NSLocalizedString("onboarding.feature.hotkey_desc", comment: "Hotkey description")
|
||||
)
|
||||
|
||||
FeatureRow(
|
||||
icon: "lock.shield",
|
||||
title: NSLocalizedString("onboarding.feature.privacy", comment: "Privacy First"),
|
||||
description: NSLocalizedString("onboarding.feature.privacy_desc", comment: "Privacy description")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FeatureRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let description: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(.blue)
|
||||
.frame(width: 20)
|
||||
.font(.title3)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PermissionsStep: View {
|
||||
@ObservedObject var permissionManager: PermissionManager
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(NSLocalizedString("onboarding.permissions.title", comment: "Permissions Required"))
|
||||
.font(.headline)
|
||||
|
||||
Text(NSLocalizedString("onboarding.permissions.description", comment: "Permissions description"))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
PermissionStepRow(
|
||||
title: "Microphone",
|
||||
description: "To capture your speech for transcription",
|
||||
status: permissionManager.microphoneStatus,
|
||||
onGrant: {
|
||||
permissionManager.requestMicrophonePermission { _ in }
|
||||
}
|
||||
)
|
||||
|
||||
PermissionStepRow(
|
||||
title: "Accessibility",
|
||||
description: "To insert transcribed text into applications",
|
||||
status: permissionManager.accessibilityStatus,
|
||||
onGrant: {
|
||||
permissionManager.openSystemSettings(for: .accessibility)
|
||||
}
|
||||
)
|
||||
|
||||
PermissionStepRow(
|
||||
title: "Input Monitoring",
|
||||
description: "To register global keyboard shortcuts",
|
||||
status: permissionManager.inputMonitoringStatus,
|
||||
onGrant: {
|
||||
permissionManager.openSystemSettings(for: .inputMonitoring)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Button("Refresh Permission Status") {
|
||||
permissionManager.checkAllPermissions()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PermissionStepRow: View {
|
||||
let title: String
|
||||
let description: String
|
||||
let status: PermissionStatus
|
||||
let onGrant: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(statusColor)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
Text(statusText)
|
||||
.font(.caption)
|
||||
.foregroundColor(statusColor)
|
||||
|
||||
if status != .granted {
|
||||
Button("Grant") {
|
||||
onGrant()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
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 "Granted"
|
||||
case .denied: return "Denied"
|
||||
case .notDetermined: return "Not Set"
|
||||
case .restricted: return "Restricted"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ModelsStep: View {
|
||||
@ObservedObject var modelManager: ModelManager
|
||||
let whisperEngine: WhisperCPPEngine
|
||||
@Binding var isDownloading: Bool
|
||||
@Binding var downloadError: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(NSLocalizedString("onboarding.models.title", comment: "Download a Speech Model"))
|
||||
.font(.headline)
|
||||
|
||||
Text(NSLocalizedString("onboarding.models.description", comment: "Models description"))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
// Model recommendation
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(NSLocalizedString("onboarding.models.recommended", comment: "Recommended: Whisper Tiny"))
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("whisper-tiny")
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
Text(NSLocalizedString("onboarding.models.tiny_description", comment: "Tiny model description"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let tinyModel = getTinyModel() {
|
||||
if tinyModel.isDownloaded {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text(NSLocalizedString("onboarding.models.downloaded", comment: "Downloaded"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
} else if isDownloading {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Text(NSLocalizedString("onboarding.models.downloading", comment: "Downloading..."))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
Button("Download") {
|
||||
downloadTinyModel()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
|
||||
// Error display
|
||||
if let error = downloadError {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundColor(.red)
|
||||
Text(String(format: NSLocalizedString("onboarding.models.download_failed", comment: "Download failed"), error))
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.red.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
// Additional info
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundColor(.blue)
|
||||
Text(NSLocalizedString("onboarding.models.info1", comment: "Models info 1"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundColor(.green)
|
||||
Text(NSLocalizedString("onboarding.models.info2", comment: "Models info 2"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getTinyModel() -> ModelInfo? {
|
||||
return modelManager.availableModels.first { $0.name == "whisper-tiny" }
|
||||
}
|
||||
|
||||
private func downloadTinyModel() {
|
||||
guard let tinyModel = getTinyModel() else { return }
|
||||
|
||||
isDownloading = true
|
||||
downloadError = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await modelManager.downloadModel(tinyModel) { progress in
|
||||
// We don't need to track progress value for spinner
|
||||
print("Download progress: \(progress.progress * 100)%")
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
isDownloading = false
|
||||
|
||||
// Set as active model
|
||||
modelManager.setActiveModel(tinyModel)
|
||||
|
||||
// Load the model
|
||||
Task {
|
||||
do {
|
||||
if let modelPath = modelManager.getModelPath(for: tinyModel) {
|
||||
try await whisperEngine.loadModel(at: modelPath)
|
||||
}
|
||||
} catch {
|
||||
print("Failed to load model: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
isDownloading = false
|
||||
downloadError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CompletionStep: View {
|
||||
let hasModel: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if hasModel {
|
||||
Text(NSLocalizedString("onboarding.completion.ready_title", comment: "You're All Set!"))
|
||||
.font(.headline)
|
||||
.foregroundColor(.green)
|
||||
|
||||
Text(NSLocalizedString("onboarding.completion.ready_desc", comment: "Ready description"))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top) {
|
||||
Image(systemName: "1.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(NSLocalizedString("onboarding.completion.step1", comment: "Step 1"))
|
||||
.fontWeight(.medium)
|
||||
Text(NSLocalizedString("onboarding.completion.step1_desc", comment: "Step 1 description"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(alignment: .top) {
|
||||
Image(systemName: "2.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(NSLocalizedString("onboarding.completion.step2", comment: "Step 2"))
|
||||
.fontWeight(.medium)
|
||||
Text(NSLocalizedString("onboarding.completion.step2_desc", comment: "Step 2 description"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(alignment: .top) {
|
||||
Image(systemName: "3.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(NSLocalizedString("onboarding.completion.step3", comment: "Step 3"))
|
||||
.fontWeight(.medium)
|
||||
Text(NSLocalizedString("onboarding.completion.step3_desc", comment: "Step 3 description"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(NSLocalizedString("onboarding.completion.incomplete_title", comment: "Setup Not Complete"))
|
||||
.font(.headline)
|
||||
.foregroundColor(.orange)
|
||||
|
||||
Text(NSLocalizedString("onboarding.completion.incomplete_desc", comment: "Incomplete description"))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
Text(NSLocalizedString("onboarding.completion.no_model_warning", comment: "No model warning"))
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.orange.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
|
||||
Text(NSLocalizedString("onboarding.completion.what_next", comment: "What to do next:"))
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(NSLocalizedString("onboarding.completion.next_step1", comment: "Next step 1"))
|
||||
Text(NSLocalizedString("onboarding.completion.next_step2", comment: "Next step 2"))
|
||||
Text(NSLocalizedString("onboarding.completion.next_step3", comment: "Next step 3"))
|
||||
}
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text(NSLocalizedString("onboarding.completion.footer", comment: "Footer text"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,20 +3,23 @@ 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, initialTab: Int = 0) {
|
||||
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: 600, height: 500),
|
||||
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
|
|
@ -24,13 +27,16 @@ class PreferencesWindowController: NSWindowController {
|
|||
|
||||
super.init(window: window)
|
||||
|
||||
window.title = "MenuWhisper Preferences"
|
||||
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()
|
||||
|
|
@ -53,6 +59,7 @@ 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
|
||||
|
|
@ -61,10 +68,11 @@ struct PreferencesView: View {
|
|||
@State private var showingDeleteAlert = false
|
||||
@State private var modelToDelete: ModelInfo?
|
||||
|
||||
init(modelManager: ModelManager, whisperEngine: WhisperCPPEngine, permissionManager: PermissionManager, initialTab: Int = 0, onClose: @escaping () -> Void) {
|
||||
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)
|
||||
}
|
||||
|
|
@ -75,37 +83,56 @@ struct PreferencesView: View {
|
|||
|
||||
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("Models", systemImage: "brain.head.profile")
|
||||
Label(NSLocalizedString("preferences.models", comment: "Models"), systemImage: "brain.head.profile")
|
||||
}
|
||||
.tag(0)
|
||||
.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("Permissions", systemImage: "lock.shield")
|
||||
Label(NSLocalizedString("preferences.permissions", comment: "Permissions"), systemImage: "lock.shield")
|
||||
}
|
||||
.tag(1)
|
||||
|
||||
GeneralTab()
|
||||
.tabItem {
|
||||
Label("General", systemImage: "gearshape")
|
||||
}
|
||||
.tag(2)
|
||||
.tag(5)
|
||||
}
|
||||
.frame(width: 600, height: 500)
|
||||
.alert("Delete Model", isPresented: $showingDeleteAlert) {
|
||||
Button("Cancel", role: .cancel) {
|
||||
.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("Delete", role: .destructive) {
|
||||
Button(NSLocalizedString("preferences.models.delete", comment: "Delete"), role: .destructive) {
|
||||
if let model = modelToDelete {
|
||||
deleteModel(model)
|
||||
}
|
||||
|
|
@ -113,7 +140,7 @@ struct PreferencesView: View {
|
|||
}
|
||||
} message: {
|
||||
if let model = modelToDelete {
|
||||
Text("Are you sure you want to delete '\(model.name)'? This action cannot be undone.")
|
||||
Text(String(format: NSLocalizedString("preferences.models.delete_confirm", comment: "Delete confirmation"), model.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -130,6 +157,7 @@ struct PreferencesView: View {
|
|||
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]
|
||||
|
|
@ -138,17 +166,17 @@ struct ModelsTab: View {
|
|||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Speech Recognition Models")
|
||||
Text(NSLocalizedString("preferences.models.title", comment: "Speech Recognition Models"))
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text("Download and manage speech recognition models. Larger models provide better accuracy but use more memory and processing time.")
|
||||
Text(NSLocalizedString("preferences.models.description", comment: "Model description"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
// Current Model Status
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Current Model")
|
||||
Text(NSLocalizedString("preferences.models.current_model", comment: "Current Model"))
|
||||
.font(.headline)
|
||||
|
||||
if let activeModel = modelManager.activeModel {
|
||||
|
|
@ -168,7 +196,7 @@ struct ModelsTab: View {
|
|||
.fill(whisperEngine.isModelLoaded() ? Color.green : Color.orange)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
Text(whisperEngine.isModelLoaded() ? "Loaded" : "Loading...")
|
||||
Text(whisperEngine.isModelLoaded() ? NSLocalizedString("status.loaded", comment: "Loaded") : NSLocalizedString("status.loading", comment: "Loading..."))
|
||||
.font(.caption)
|
||||
.foregroundColor(whisperEngine.isModelLoaded() ? .green : .orange)
|
||||
}
|
||||
|
|
@ -176,7 +204,7 @@ struct ModelsTab: View {
|
|||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
} else {
|
||||
Text("No model selected")
|
||||
Text(NSLocalizedString("preferences.models.no_model", comment: "No model selected"))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
|
@ -185,9 +213,47 @@ struct ModelsTab: View {
|
|||
}
|
||||
}
|
||||
|
||||
// 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("Available Models")
|
||||
Text(NSLocalizedString("preferences.models.available_models", comment: "Available Models"))
|
||||
.font(.headline)
|
||||
|
||||
ScrollView {
|
||||
|
|
@ -286,7 +352,7 @@ struct ModelRow: View {
|
|||
.fontWeight(.medium)
|
||||
|
||||
if isActive {
|
||||
Text("ACTIVE")
|
||||
Text(NSLocalizedString("preferences.models.active_badge", comment: "ACTIVE"))
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.white)
|
||||
|
|
@ -315,13 +381,13 @@ struct ModelRow: View {
|
|||
if model.isDownloaded {
|
||||
HStack(spacing: 8) {
|
||||
if !isActive {
|
||||
Button("Select") {
|
||||
Button(NSLocalizedString("general.select", comment: "Select")) {
|
||||
onSelect()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Button("Delete") {
|
||||
Button(NSLocalizedString("preferences.models.delete", comment: "Delete")) {
|
||||
onDelete()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
|
@ -329,17 +395,20 @@ struct ModelRow: View {
|
|||
}
|
||||
} else {
|
||||
if isDownloading {
|
||||
VStack {
|
||||
ProgressView(value: downloadProgress)
|
||||
.frame(width: 80)
|
||||
Text("\(Int(downloadProgress * 100))%")
|
||||
HStack {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Text(NSLocalizedString("preferences.models.downloading", comment: "Downloading..."))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
Button("Download") {
|
||||
Button(NSLocalizedString("preferences.models.download", comment: "Download")) {
|
||||
onDownload()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityLabel("Download \(model.name) model")
|
||||
.accessibilityHint("Downloads the speech recognition model for offline use")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -359,19 +428,19 @@ struct PermissionsTab: View {
|
|||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Permissions")
|
||||
Text(NSLocalizedString("preferences.permissions", comment: "Permissions"))
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text("MenuWhisper requires certain system permissions to function properly. Click the buttons below to grant permissions in System Settings.")
|
||||
Text(NSLocalizedString("preferences.permissions.description", comment: "Permissions description"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// Microphone Permission
|
||||
PermissionRow(
|
||||
title: "Microphone",
|
||||
description: "Required to capture speech for transcription",
|
||||
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)
|
||||
|
|
@ -385,8 +454,8 @@ struct PermissionsTab: View {
|
|||
|
||||
// Accessibility Permission
|
||||
PermissionRow(
|
||||
title: "Accessibility",
|
||||
description: "Required to insert transcribed text into other applications",
|
||||
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)
|
||||
|
|
@ -400,8 +469,8 @@ struct PermissionsTab: View {
|
|||
|
||||
// Input Monitoring Permission
|
||||
PermissionRow(
|
||||
title: "Input Monitoring",
|
||||
description: "Required to send keyboard events for text insertion",
|
||||
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)
|
||||
|
|
@ -417,17 +486,17 @@ struct PermissionsTab: View {
|
|||
|
||||
// Help text
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Need Help?")
|
||||
Text(NSLocalizedString("preferences.permissions.need_help", comment: "Need Help?"))
|
||||
.font(.headline)
|
||||
|
||||
Text("After granting permissions in System Settings:")
|
||||
Text(NSLocalizedString("preferences.permissions.after_granting", comment: "After granting permissions"))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("1. Close System Settings")
|
||||
Text("2. Click 'Refresh Status' to update permission status")
|
||||
Text("3. Some permissions may require restarting MenuWhisper")
|
||||
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)
|
||||
|
|
@ -479,14 +548,14 @@ struct PermissionRow: View {
|
|||
|
||||
VStack(spacing: 6) {
|
||||
if status != .granted {
|
||||
Button("Open System Settings") {
|
||||
Button(NSLocalizedString("permissions.open_settings", comment: "Open System Settings")) {
|
||||
onOpenSettings()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
Button("Refresh Status") {
|
||||
Button(NSLocalizedString("permissions.refresh_status", comment: "Refresh Status")) {
|
||||
onRefresh()
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
|
|
@ -510,30 +579,415 @@ struct PermissionRow: View {
|
|||
private var statusText: String {
|
||||
switch status {
|
||||
case .granted:
|
||||
return "Granted"
|
||||
return NSLocalizedString("status.granted", comment: "Granted")
|
||||
case .denied:
|
||||
return "Denied"
|
||||
return NSLocalizedString("status.denied", comment: "Denied")
|
||||
case .notDetermined:
|
||||
return "Not Set"
|
||||
return NSLocalizedString("status.not_set", comment: "Not Set")
|
||||
case .restricted:
|
||||
return "Restricted"
|
||||
return NSLocalizedString("status.restricted", comment: "Restricted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GeneralTab: View {
|
||||
@ObservedObject var settings: CoreSettings.Settings
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("General Settings")
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text(NSLocalizedString("preferences.general.title", comment: "General Settings"))
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text("Additional settings will be available in Phase 4.")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
117
Sources/App/PreviewDialog.swift
Normal file
117
Sources/App/PreviewDialog.swift
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import SwiftUI
|
||||
|
||||
class PreviewDialogController: NSWindowController {
|
||||
private var previewView: PreviewDialogView?
|
||||
|
||||
init(text: String, onInsert: @escaping (String) -> Void, onCancel: @escaping () -> Void) {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
|
||||
styleMask: [.titled, .closable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
super.init(window: window)
|
||||
|
||||
window.title = NSLocalizedString("preview.title", comment: "Preview Transcription")
|
||||
window.center()
|
||||
window.level = .floating
|
||||
|
||||
previewView = PreviewDialogView(
|
||||
text: text,
|
||||
onInsert: { [weak self] editedText in
|
||||
onInsert(editedText)
|
||||
self?.close()
|
||||
},
|
||||
onCancel: { [weak self] in
|
||||
onCancel()
|
||||
self?.close()
|
||||
}
|
||||
)
|
||||
|
||||
window.contentView = NSHostingView(rootView: previewView!)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
struct PreviewDialogView: View {
|
||||
@State private var text: String
|
||||
let onInsert: (String) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@FocusState private var isTextFocused: Bool
|
||||
|
||||
init(text: String, onInsert: @escaping (String) -> Void, onCancel: @escaping () -> Void) {
|
||||
self._text = State(initialValue: text)
|
||||
self.onInsert = onInsert
|
||||
self.onCancel = onCancel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Header
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(NSLocalizedString("preview.title", comment: "Preview Transcription"))
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text(NSLocalizedString("preview.description", comment: "Review and edit the transcribed text before insertion."))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
// Text Editor
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(NSLocalizedString("preview.transcribed_text", comment: "Transcribed Text:"))
|
||||
.font(.headline)
|
||||
|
||||
ScrollView {
|
||||
TextEditor(text: $text)
|
||||
.focused($isTextFocused)
|
||||
.font(.body)
|
||||
.padding(8)
|
||||
.background(Color(NSColor.textBackgroundColor))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.frame(minHeight: 120)
|
||||
}
|
||||
|
||||
// Actions
|
||||
HStack {
|
||||
// Character count
|
||||
Text(String(format: NSLocalizedString("preview.character_count", comment: "%d characters"), text.count))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Buttons
|
||||
HStack(spacing: 12) {
|
||||
Button(NSLocalizedString("general.cancel", comment: "Cancel")) {
|
||||
onCancel()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.keyboardShortcut(.escape)
|
||||
|
||||
Button(NSLocalizedString("preview.insert", comment: "Insert")) {
|
||||
onInsert(text)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.return, modifiers: [.command])
|
||||
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 500, height: 300)
|
||||
.onAppear {
|
||||
isTextFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,16 +4,21 @@
|
|||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>es</string>
|
||||
</array>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Menu-Whisper</string>
|
||||
<string>Tell me</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>MenuWhisper</string>
|
||||
<string>TellMe</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.menuwhisper.app</string>
|
||||
<string>com.fmartingr.tellme</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Menu-Whisper</string>
|
||||
<string>Tell me</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
|
@ -27,7 +32,7 @@
|
|||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2025. All rights reserved.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Menu-Whisper needs access to your microphone to capture speech for offline transcription. Your audio data never leaves your device.</string>
|
||||
<string>Tell me needs access to your microphone to capture speech for offline transcription. Your audio data never leaves your device.</string>
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<true/>
|
||||
<key>NSSupportsSuddenTermination</key>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* Menu-Whisper - English Localization */
|
||||
/* Tell me - English Localization */
|
||||
|
||||
/* General */
|
||||
"app.name" = "Menu-Whisper";
|
||||
"app.name" = "Tell me";
|
||||
"general.ok" = "OK";
|
||||
"general.cancel" = "Cancel";
|
||||
"general.continue" = "Continue";
|
||||
|
|
@ -13,29 +13,54 @@
|
|||
"menubar.listening" = "Listening";
|
||||
"menubar.processing" = "Processing";
|
||||
"menubar.preferences" = "Preferences...";
|
||||
"menubar.quit" = "Quit Menu-Whisper";
|
||||
"menubar.help" = "Help";
|
||||
"menubar.quit" = "Quit Tell me";
|
||||
"menubar.loading_model" = "Loading model...";
|
||||
"menubar.model_status" = "Model: %@";
|
||||
"menubar.model_loading" = "Model: Loading...";
|
||||
"menubar.no_model" = "No model - click Preferences";
|
||||
"menubar.reset_onboarding" = "Reset Onboarding";
|
||||
|
||||
/* HUD States */
|
||||
"hud.listening" = "Listening...";
|
||||
"hud.processing" = "Transcribing...";
|
||||
"hud.cancel" = "Press Esc to cancel";
|
||||
"hud.please_wait" = "Please wait";
|
||||
|
||||
/* Permissions */
|
||||
"permissions.microphone.title" = "Microphone Access Required";
|
||||
"permissions.microphone.message" = "Menu-Whisper needs access to your microphone to perform speech-to-text transcription.";
|
||||
"permissions.microphone.message" = "Tell me needs access to your microphone to capture speech for transcription. Please grant microphone access in System Settings.";
|
||||
"permissions.accessibility.title" = "Accessibility Access Required";
|
||||
"permissions.accessibility.message" = "Menu-Whisper needs Accessibility access to insert transcribed text into applications.";
|
||||
"permissions.accessibility.message" = "Tell me needs Accessibility access to insert transcribed text into other applications. Please grant accessibility access in System Settings.";
|
||||
"permissions.input_monitoring.title" = "Input Monitoring Required";
|
||||
"permissions.input_monitoring.message" = "Menu-Whisper needs Input Monitoring access to register global hotkeys.";
|
||||
"permissions.input_monitoring.message" = "Tell me needs Input Monitoring access to register global hotkeys and insert text. Please grant input monitoring access in System Settings.";
|
||||
"permissions.open_settings" = "Open System Settings";
|
||||
"permissions.refresh_status" = "Refresh Status";
|
||||
|
||||
/* Button Labels */
|
||||
"general.select" = "Select";
|
||||
"general.reset" = "Reset";
|
||||
|
||||
/* Picker Labels */
|
||||
"general.language" = "Language";
|
||||
"general.mode" = "Mode";
|
||||
"general.method" = "Method";
|
||||
|
||||
/* Slider and Control Labels */
|
||||
"preferences.general.time_limit_slider" = "Time Limit";
|
||||
"preferences.hud.opacity_slider" = "HUD Opacity";
|
||||
"preferences.hud.size_slider" = "HUD Size";
|
||||
"preferences.advanced.threads_slider" = "Processing Threads";
|
||||
|
||||
/* Preferences Window */
|
||||
"preferences.title" = "Menu-Whisper Preferences";
|
||||
"preferences.title" = "Tell me Preferences";
|
||||
"preferences.general" = "General";
|
||||
"preferences.models" = "Models";
|
||||
"preferences.hotkeys" = "Hotkeys";
|
||||
"preferences.insertion" = "Text Insertion";
|
||||
"preferences.interface" = "Interface";
|
||||
"preferences.advanced" = "Advanced";
|
||||
"preferences.permissions" = "Permissions";
|
||||
|
||||
/* General Preferences */
|
||||
"preferences.general.hotkey" = "Global Hotkey:";
|
||||
|
|
@ -51,6 +76,7 @@
|
|||
"preferences.models.language" = "Language:";
|
||||
"preferences.models.language.auto" = "Auto-detect";
|
||||
"preferences.models.download" = "Download";
|
||||
"preferences.models.downloading" = "Downloading...";
|
||||
"preferences.models.delete" = "Delete";
|
||||
"preferences.models.size" = "Size:";
|
||||
"preferences.models.languages" = "Languages:";
|
||||
|
|
@ -74,4 +100,161 @@
|
|||
/* Success Messages */
|
||||
"success.model.downloaded" = "Model downloaded successfully";
|
||||
"success.settings.exported" = "Settings exported successfully";
|
||||
"success.settings.imported" = "Settings imported successfully";
|
||||
"success.settings.imported" = "Settings imported successfully";
|
||||
|
||||
/* Status Labels */
|
||||
"status.loaded" = "Loaded";
|
||||
"status.loading" = "Loading...";
|
||||
"status.granted" = "Granted";
|
||||
"status.denied" = "Denied";
|
||||
"status.not_set" = "Not Set";
|
||||
"status.restricted" = "Restricted";
|
||||
|
||||
/* Alert Titles */
|
||||
"alert.delete_model" = "Delete Model";
|
||||
"alert.success" = "Success";
|
||||
"alert.error" = "Error";
|
||||
|
||||
/* Permissions */
|
||||
"permissions.microphone.title_short" = "Microphone";
|
||||
"permissions.microphone.description_short" = "Required to capture speech for transcription";
|
||||
"permissions.accessibility.title_short" = "Accessibility";
|
||||
"permissions.accessibility.description_short" = "Required to insert transcribed text into other applications";
|
||||
"permissions.input_monitoring.title_short" = "Input Monitoring";
|
||||
"permissions.input_monitoring.description_short" = "Required to send keyboard events for text insertion";
|
||||
|
||||
/* Preview Dialog */
|
||||
"preview.title" = "Preview Transcription";
|
||||
"preview.description" = "Review and edit the transcribed text before insertion.";
|
||||
"preview.transcribed_text" = "Transcribed Text:";
|
||||
"preview.character_count" = "%d characters";
|
||||
"preview.insert" = "Insert";
|
||||
|
||||
/* HUD Settings */
|
||||
"preferences.hud.title" = "Interface Settings";
|
||||
"preferences.hud.appearance" = "HUD Appearance";
|
||||
"preferences.hud.opacity" = "Opacity:";
|
||||
"preferences.hud.size" = "Size:";
|
||||
"preferences.hud.audio_feedback" = "Audio Feedback";
|
||||
"preferences.hud.sounds_description" = "Enable audio feedback to know when dictation starts and stops.";
|
||||
|
||||
/* Advanced Settings */
|
||||
"preferences.advanced.title" = "Advanced Settings";
|
||||
"preferences.advanced.processing" = "Processing";
|
||||
"preferences.advanced.threads" = "Processing threads:";
|
||||
"preferences.advanced.threads_description" = "More threads can improve performance on multi-core systems but use more CPU.";
|
||||
"preferences.advanced.logging" = "Logging";
|
||||
"preferences.advanced.logging_description" = "Logs are stored locally only and contain timing and error information. No audio or text is ever logged.";
|
||||
"preferences.advanced.enable_logging" = "Enable local logging";
|
||||
"preferences.advanced.reset" = "Reset";
|
||||
"preferences.advanced.reset_button" = "Reset All Settings to Default";
|
||||
"preferences.advanced.reset_title" = "Reset Settings";
|
||||
"preferences.advanced.reset_message" = "This will reset all settings to their default values. This action cannot be undone.";
|
||||
|
||||
/* General Settings */
|
||||
"preferences.general.global_hotkey" = "Global Hotkey";
|
||||
"preferences.general.hotkey_combination" = "Hotkey Combination:";
|
||||
"preferences.general.audio_timing" = "Audio & Timing";
|
||||
"preferences.general.settings_management" = "Settings Management";
|
||||
"preferences.general.export_settings" = "Export Settings...";
|
||||
"preferences.general.import_settings" = "Import Settings...";
|
||||
|
||||
/* Insertion Settings */
|
||||
"preferences.insertion.title" = "Text Insertion";
|
||||
"preferences.insertion.method_description" = "Paste is faster and more reliable, but may not work in all applications. Type method works everywhere but is slower.";
|
||||
"preferences.insertion.preview_description" = "When enabled, transcribed text will be shown in a dialog before insertion, allowing you to review and edit it.";
|
||||
"preferences.insertion.secure_input_title" = "Secure Input Handling";
|
||||
"preferences.insertion.secure_input_description" = "Text insertion is automatically disabled in secure contexts (password fields, etc.). Text is copied to clipboard instead.";
|
||||
|
||||
/* Languages */
|
||||
"language.auto_detect" = "Auto-detect";
|
||||
"language.english" = "English";
|
||||
"language.spanish" = "Spanish";
|
||||
"language.french" = "French";
|
||||
"language.german" = "German";
|
||||
"language.italian" = "Italian";
|
||||
"language.portuguese" = "Portuguese";
|
||||
"language.russian" = "Russian";
|
||||
"language.chinese" = "Chinese";
|
||||
"language.japanese" = "Japanese";
|
||||
"language.korean" = "Korean";
|
||||
|
||||
/* Hotkey Recorder */
|
||||
"hotkey.press_keys" = "Press keys...";
|
||||
"hotkey.reset_default" = "Reset to Default";
|
||||
"hotkey.record_description" = "Click to record a new hotkey combination";
|
||||
|
||||
/* Onboarding */
|
||||
"onboarding.title" = "Welcome to Tell me";
|
||||
"onboarding.subtitle" = "Offline speech-to-text for macOS";
|
||||
"onboarding.what_is" = "What is Tell me?";
|
||||
"onboarding.feature.offline" = "Offline Speech Recognition";
|
||||
"onboarding.feature.offline_desc" = "Convert your speech to text without internet connection";
|
||||
"onboarding.feature.hotkey" = "Global Hotkey";
|
||||
"onboarding.feature.hotkey_desc" = "Use ⌘⇧V from anywhere to start dictation";
|
||||
"onboarding.feature.privacy" = "Privacy First";
|
||||
"onboarding.feature.privacy_desc" = "Your audio never leaves your device";
|
||||
|
||||
"onboarding.permissions.title" = "Permissions Required";
|
||||
"onboarding.permissions.description" = "Tell me needs the following permissions to work properly:";
|
||||
|
||||
"onboarding.models.title" = "Download a Speech Model";
|
||||
"onboarding.models.description" = "Tell me requires a speech recognition model to work. We recommend starting with the tiny model for quick setup.";
|
||||
"onboarding.models.recommended" = "Recommended: Whisper Tiny";
|
||||
"onboarding.models.tiny_description" = "39 MB • Fast • Good for quick setup";
|
||||
"onboarding.models.downloaded" = "Downloaded";
|
||||
"onboarding.models.downloading" = "Downloading...";
|
||||
"onboarding.models.download_failed" = "Download failed: %@";
|
||||
"onboarding.models.info1" = "You can download larger, more accurate models later from Preferences";
|
||||
"onboarding.models.info2" = "Models work completely offline once downloaded";
|
||||
|
||||
"onboarding.completion.ready_title" = "You're All Set!";
|
||||
"onboarding.completion.ready_desc" = "Tell me is ready to use with your downloaded model!";
|
||||
"onboarding.completion.incomplete_title" = "Setup Not Complete";
|
||||
"onboarding.completion.incomplete_desc" = "Tell me won't work properly without a speech recognition model.";
|
||||
"onboarding.completion.no_model_warning" = "No model downloaded - dictation will not work";
|
||||
"onboarding.completion.what_next" = "What to do next:";
|
||||
"onboarding.completion.step1" = "Press ⌘⇧V to start dictation";
|
||||
"onboarding.completion.step1_desc" = "Or change the hotkey in Preferences";
|
||||
"onboarding.completion.step2" = "Speak clearly when the HUD appears";
|
||||
"onboarding.completion.step2_desc" = "Release the hotkey when done speaking";
|
||||
"onboarding.completion.step3" = "Press Esc to cancel anytime";
|
||||
"onboarding.completion.step3_desc" = "Works in all apps and contexts";
|
||||
"onboarding.completion.next_step1" = "• Go back to step 3 and download the tiny model";
|
||||
"onboarding.completion.next_step2" = "• Or download models later from Preferences → Models";
|
||||
"onboarding.completion.next_step3" = "• You'll see an alert when you try to use dictation without a model";
|
||||
"onboarding.completion.footer" = "Access Preferences from the menu bar icon at any time.";
|
||||
|
||||
"onboarding.buttons.skip" = "Skip Setup";
|
||||
"onboarding.buttons.back" = "Back";
|
||||
"onboarding.buttons.next" = "Next";
|
||||
"onboarding.buttons.get_started" = "Get Started";
|
||||
|
||||
/* Missing Preferences Strings */
|
||||
"preferences.models.speech_recognition" = "Speech Recognition Models";
|
||||
"preferences.models.description" = "Download and manage speech recognition models. Larger models provide better accuracy but use more memory and processing time.";
|
||||
"preferences.models.current_model" = "Current Model";
|
||||
"preferences.models.no_model" = "No model selected";
|
||||
"preferences.models.recognition_language" = "Recognition Language:";
|
||||
"preferences.models.available_models" = "Available Models";
|
||||
"preferences.models.active_badge" = "ACTIVE";
|
||||
"preferences.models.delete_confirm" = "Are you sure you want to delete '%@'? This action cannot be undone.";
|
||||
|
||||
/* Permissions Strings */
|
||||
"preferences.permissions.description" = "Tell me requires certain system permissions to function properly. Click the buttons below to grant permissions in System Settings.";
|
||||
"preferences.permissions.need_help" = "Need Help?";
|
||||
"preferences.permissions.after_granting" = "After granting permissions in System Settings:";
|
||||
"preferences.permissions.step1" = "1. Close System Settings";
|
||||
"preferences.permissions.step2" = "2. Click 'Refresh Status' to update permission status";
|
||||
"preferences.permissions.step3" = "3. Some permissions may require restarting Tell me";
|
||||
|
||||
/* General Tab Strings */
|
||||
"preferences.general.title" = "General Settings";
|
||||
"preferences.general.dictation_limit" = "Dictation time limit:";
|
||||
"preferences.general.minutes" = "minutes";
|
||||
|
||||
/* Advanced Tab Strings */
|
||||
"preferences.advanced.threads_description" = "More threads can improve performance on multi-core systems but use more CPU.";
|
||||
"preferences.advanced.logging_description" = "Logs are stored locally only and contain timing and error information. No audio or text is ever logged.";
|
||||
"preferences.advanced.reset_confirmation" = "This will reset all settings to their default values. This action cannot be undone.";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* Menu-Whisper - Spanish Localization */
|
||||
/* Tell me - Spanish Localization */
|
||||
|
||||
/* General */
|
||||
"app.name" = "Menu-Whisper";
|
||||
"app.name" = "Tell me";
|
||||
"general.ok" = "Aceptar";
|
||||
"general.cancel" = "Cancelar";
|
||||
"general.continue" = "Continuar";
|
||||
|
|
@ -13,29 +13,54 @@
|
|||
"menubar.listening" = "Escuchando";
|
||||
"menubar.processing" = "Procesando";
|
||||
"menubar.preferences" = "Preferencias...";
|
||||
"menubar.quit" = "Salir de Menu-Whisper";
|
||||
"menubar.help" = "Ayuda";
|
||||
"menubar.quit" = "Salir de Tell me";
|
||||
"menubar.loading_model" = "Cargando modelo...";
|
||||
"menubar.model_status" = "Modelo: %@";
|
||||
"menubar.model_loading" = "Modelo: Cargando...";
|
||||
"menubar.no_model" = "Sin modelo - haz clic en Preferencias";
|
||||
"menubar.reset_onboarding" = "Restablecer Configuración Inicial";
|
||||
|
||||
/* HUD States */
|
||||
"hud.listening" = "Escuchando...";
|
||||
"hud.processing" = "Transcribiendo...";
|
||||
"hud.cancel" = "Presiona Esc para cancelar";
|
||||
"hud.please_wait" = "Por favor espera";
|
||||
|
||||
/* Permissions */
|
||||
"permissions.microphone.title" = "Acceso al Micrófono Requerido";
|
||||
"permissions.microphone.message" = "Menu-Whisper necesita acceso a tu micrófono para realizar la transcripción de voz a texto.";
|
||||
"permissions.microphone.message" = "Tell me necesita acceso a tu micrófono para capturar voz para transcripción. Por favor otorga acceso al micrófono en Configuración del Sistema.";
|
||||
"permissions.accessibility.title" = "Acceso de Accesibilidad Requerido";
|
||||
"permissions.accessibility.message" = "Menu-Whisper necesita acceso de Accesibilidad para insertar texto transcrito en aplicaciones.";
|
||||
"permissions.accessibility.message" = "Tell me necesita acceso de Accesibilidad para insertar texto transcrito en otras aplicaciones. Por favor otorga acceso de accesibilidad en Configuración del Sistema.";
|
||||
"permissions.input_monitoring.title" = "Monitoreo de Entrada Requerido";
|
||||
"permissions.input_monitoring.message" = "Menu-Whisper necesita acceso de Monitoreo de Entrada para registrar atajos de teclado globales.";
|
||||
"permissions.input_monitoring.message" = "Tell me necesita acceso de Monitoreo de Entrada para registrar atajos globales e insertar texto. Por favor otorga acceso de monitoreo de entrada en Configuración del Sistema.";
|
||||
"permissions.open_settings" = "Abrir Configuración del Sistema";
|
||||
"permissions.refresh_status" = "Actualizar Estado";
|
||||
|
||||
/* Button Labels */
|
||||
"general.select" = "Seleccionar";
|
||||
"general.reset" = "Restablecer";
|
||||
|
||||
/* Picker Labels */
|
||||
"general.language" = "Idioma";
|
||||
"general.mode" = "Modo";
|
||||
"general.method" = "Método";
|
||||
|
||||
/* Slider and Control Labels */
|
||||
"preferences.general.time_limit_slider" = "Límite de Tiempo";
|
||||
"preferences.hud.opacity_slider" = "Opacidad del HUD";
|
||||
"preferences.hud.size_slider" = "Tamaño del HUD";
|
||||
"preferences.advanced.threads_slider" = "Hilos de Procesamiento";
|
||||
|
||||
/* Preferences Window */
|
||||
"preferences.title" = "Preferencias de Menu-Whisper";
|
||||
"preferences.title" = "Preferencias de Tell me";
|
||||
"preferences.general" = "General";
|
||||
"preferences.models" = "Modelos";
|
||||
"preferences.hotkeys" = "Atajos";
|
||||
"preferences.insertion" = "Inserción de Texto";
|
||||
"preferences.interface" = "Interfaz";
|
||||
"preferences.advanced" = "Avanzado";
|
||||
"preferences.permissions" = "Permisos";
|
||||
|
||||
/* General Preferences */
|
||||
"preferences.general.hotkey" = "Atajo Global:";
|
||||
|
|
@ -51,6 +76,7 @@
|
|||
"preferences.models.language" = "Idioma:";
|
||||
"preferences.models.language.auto" = "Detección automática";
|
||||
"preferences.models.download" = "Descargar";
|
||||
"preferences.models.downloading" = "Descargando...";
|
||||
"preferences.models.delete" = "Eliminar";
|
||||
"preferences.models.size" = "Tamaño:";
|
||||
"preferences.models.languages" = "Idiomas:";
|
||||
|
|
@ -74,4 +100,161 @@
|
|||
/* Success Messages */
|
||||
"success.model.downloaded" = "Modelo descargado exitosamente";
|
||||
"success.settings.exported" = "Configuración exportada exitosamente";
|
||||
"success.settings.imported" = "Configuración importada exitosamente";
|
||||
"success.settings.imported" = "Configuración importada exitosamente";
|
||||
|
||||
/* Status Labels */
|
||||
"status.loaded" = "Cargado";
|
||||
"status.loading" = "Cargando...";
|
||||
"status.granted" = "Otorgado";
|
||||
"status.denied" = "Denegado";
|
||||
"status.not_set" = "No Configurado";
|
||||
"status.restricted" = "Restringido";
|
||||
|
||||
/* Alert Titles */
|
||||
"alert.delete_model" = "Eliminar Modelo";
|
||||
"alert.success" = "Éxito";
|
||||
"alert.error" = "Error";
|
||||
|
||||
/* Permissions */
|
||||
"permissions.microphone.title_short" = "Micrófono";
|
||||
"permissions.microphone.description_short" = "Requerido para capturar voz para transcripción";
|
||||
"permissions.accessibility.title_short" = "Accesibilidad";
|
||||
"permissions.accessibility.description_short" = "Requerido para insertar texto transcrito en otras aplicaciones";
|
||||
"permissions.input_monitoring.title_short" = "Monitoreo de Entrada";
|
||||
"permissions.input_monitoring.description_short" = "Requerido para enviar eventos de teclado para inserción de texto";
|
||||
|
||||
/* Preview Dialog */
|
||||
"preview.title" = "Vista Previa de Transcripción";
|
||||
"preview.description" = "Revisa y edita el texto transcrito antes de insertarlo.";
|
||||
"preview.transcribed_text" = "Texto Transcrito:";
|
||||
"preview.character_count" = "%d caracteres";
|
||||
"preview.insert" = "Insertar";
|
||||
|
||||
/* HUD Settings */
|
||||
"preferences.hud.title" = "Configuración de Interfaz";
|
||||
"preferences.hud.appearance" = "Apariencia del HUD";
|
||||
"preferences.hud.opacity" = "Opacidad:";
|
||||
"preferences.hud.size" = "Tamaño:";
|
||||
"preferences.hud.audio_feedback" = "Retroalimentación de Audio";
|
||||
"preferences.hud.sounds_description" = "Habilita retroalimentación de audio para saber cuándo empieza y termina el dictado.";
|
||||
|
||||
/* Advanced Settings */
|
||||
"preferences.advanced.title" = "Configuración Avanzada";
|
||||
"preferences.advanced.processing" = "Procesamiento";
|
||||
"preferences.advanced.threads" = "Hilos de procesamiento:";
|
||||
"preferences.advanced.threads_description" = "Más hilos pueden mejorar el rendimiento en sistemas multi-núcleo pero usan más CPU.";
|
||||
"preferences.advanced.logging" = "Registro";
|
||||
"preferences.advanced.logging_description" = "Los registros se almacenan solo localmente y contienen información de tiempo y errores. Nunca se registra audio o texto.";
|
||||
"preferences.advanced.enable_logging" = "Habilitar registro local";
|
||||
"preferences.advanced.reset" = "Restablecer";
|
||||
"preferences.advanced.reset_button" = "Restablecer Toda la Configuración por Defecto";
|
||||
"preferences.advanced.reset_title" = "Restablecer Configuración";
|
||||
"preferences.advanced.reset_message" = "Esto restablecerá toda la configuración a sus valores por defecto. Esta acción no se puede deshacer.";
|
||||
|
||||
/* General Settings */
|
||||
"preferences.general.global_hotkey" = "Atajo Global";
|
||||
"preferences.general.hotkey_combination" = "Combinación de Teclas:";
|
||||
"preferences.general.audio_timing" = "Audio y Temporización";
|
||||
"preferences.general.settings_management" = "Gestión de Configuración";
|
||||
"preferences.general.export_settings" = "Exportar Configuración...";
|
||||
"preferences.general.import_settings" = "Importar Configuración...";
|
||||
|
||||
/* Insertion Settings */
|
||||
"preferences.insertion.title" = "Inserción de Texto";
|
||||
"preferences.insertion.method_description" = "Pegar es más rápido y confiable, pero puede no funcionar en todas las aplicaciones. El método de escritura funciona en todas partes pero es más lento.";
|
||||
"preferences.insertion.preview_description" = "Cuando está habilitado, el texto transcrito se mostrará en un diálogo antes de la inserción, permitiéndote revisarlo y editarlo.";
|
||||
"preferences.insertion.secure_input_title" = "Manejo de Entrada Segura";
|
||||
"preferences.insertion.secure_input_description" = "La inserción de texto se desactiva automáticamente en contextos seguros (campos de contraseña, etc.). El texto se copia al portapapeles en su lugar.";
|
||||
|
||||
/* Languages */
|
||||
"language.auto_detect" = "Detección automática";
|
||||
"language.english" = "Inglés";
|
||||
"language.spanish" = "Español";
|
||||
"language.french" = "Francés";
|
||||
"language.german" = "Alemán";
|
||||
"language.italian" = "Italiano";
|
||||
"language.portuguese" = "Portugués";
|
||||
"language.russian" = "Ruso";
|
||||
"language.chinese" = "Chino";
|
||||
"language.japanese" = "Japonés";
|
||||
"language.korean" = "Coreano";
|
||||
|
||||
/* Hotkey Recorder */
|
||||
"hotkey.press_keys" = "Presiona teclas...";
|
||||
"hotkey.reset_default" = "Restablecer por Defecto";
|
||||
"hotkey.record_description" = "Haz clic para grabar una nueva combinación de teclas";
|
||||
|
||||
/* Onboarding */
|
||||
"onboarding.title" = "Bienvenido a Tell me";
|
||||
"onboarding.subtitle" = "Reconocimiento de voz offline para macOS";
|
||||
"onboarding.what_is" = "¿Qué es Tell me?";
|
||||
"onboarding.feature.offline" = "Reconocimiento de Voz Offline";
|
||||
"onboarding.feature.offline_desc" = "Convierte tu voz a texto sin conexión a internet";
|
||||
"onboarding.feature.hotkey" = "Atajo Global";
|
||||
"onboarding.feature.hotkey_desc" = "Usa ⌘⇧V desde cualquier lugar para iniciar dictado";
|
||||
"onboarding.feature.privacy" = "Privacidad Primero";
|
||||
"onboarding.feature.privacy_desc" = "Tu audio nunca sale de tu dispositivo";
|
||||
|
||||
"onboarding.permissions.title" = "Permisos Requeridos";
|
||||
"onboarding.permissions.description" = "Tell me necesita los siguientes permisos para funcionar correctamente:";
|
||||
|
||||
"onboarding.models.title" = "Descargar un Modelo de Voz";
|
||||
"onboarding.models.description" = "Tell me requiere un modelo de reconocimiento de voz para funcionar. Recomendamos empezar con el modelo tiny para configuración rápida.";
|
||||
"onboarding.models.recommended" = "Recomendado: Whisper Tiny";
|
||||
"onboarding.models.tiny_description" = "39 MB • Rápido • Bueno para configuración inicial";
|
||||
"onboarding.models.downloaded" = "Descargado";
|
||||
"onboarding.models.downloading" = "Descargando...";
|
||||
"onboarding.models.download_failed" = "Descarga falló: %@";
|
||||
"onboarding.models.info1" = "Puedes descargar modelos más grandes y precisos después desde Preferencias";
|
||||
"onboarding.models.info2" = "Los modelos funcionan completamente offline una vez descargados";
|
||||
|
||||
"onboarding.completion.ready_title" = "¡Todo Listo!";
|
||||
"onboarding.completion.ready_desc" = "¡Tell me está listo para usar con tu modelo descargado!";
|
||||
"onboarding.completion.incomplete_title" = "Configuración Incompleta";
|
||||
"onboarding.completion.incomplete_desc" = "Tell me no funcionará correctamente sin un modelo de reconocimiento de voz.";
|
||||
"onboarding.completion.no_model_warning" = "Sin modelo descargado - el dictado no funcionará";
|
||||
"onboarding.completion.what_next" = "Qué hacer a continuación:";
|
||||
"onboarding.completion.step1" = "Presiona ⌘⇧V para iniciar dictado";
|
||||
"onboarding.completion.step1_desc" = "O cambia el atajo en Preferencias";
|
||||
"onboarding.completion.step2" = "Habla claramente cuando aparezca el HUD";
|
||||
"onboarding.completion.step2_desc" = "Suelta el atajo cuando termines de hablar";
|
||||
"onboarding.completion.step3" = "Presiona Esc para cancelar en cualquier momento";
|
||||
"onboarding.completion.step3_desc" = "Funciona en todas las aplicaciones y contextos";
|
||||
"onboarding.completion.next_step1" = "• Regresa al paso 3 y descarga el modelo tiny";
|
||||
"onboarding.completion.next_step2" = "• O descarga modelos después desde Preferencias → Modelos";
|
||||
"onboarding.completion.next_step3" = "• Verás una alerta cuando trates de usar dictado sin modelo";
|
||||
"onboarding.completion.footer" = "Accede a Preferencias desde el ícono de la barra de menú en cualquier momento.";
|
||||
|
||||
"onboarding.buttons.skip" = "Saltar Configuración";
|
||||
"onboarding.buttons.back" = "Atrás";
|
||||
"onboarding.buttons.next" = "Siguiente";
|
||||
"onboarding.buttons.get_started" = "Empezar";
|
||||
|
||||
/* Missing Preferences Strings */
|
||||
"preferences.models.speech_recognition" = "Modelos de Reconocimiento de Voz";
|
||||
"preferences.models.description" = "Descarga y gestiona modelos de reconocimiento de voz. Los modelos más grandes proporcionan mejor precisión pero usan más memoria y tiempo de procesamiento.";
|
||||
"preferences.models.current_model" = "Modelo Actual";
|
||||
"preferences.models.no_model" = "Ningún modelo seleccionado";
|
||||
"preferences.models.recognition_language" = "Idioma de Reconocimiento:";
|
||||
"preferences.models.available_models" = "Modelos Disponibles";
|
||||
"preferences.models.active_badge" = "ACTIVO";
|
||||
"preferences.models.delete_confirm" = "¿Estás seguro de que quieres eliminar '%@'? Esta acción no se puede deshacer.";
|
||||
|
||||
/* Permissions Strings */
|
||||
"preferences.permissions.description" = "Tell me requiere ciertos permisos del sistema para funcionar correctamente. Haz clic en los botones de abajo para otorgar permisos en Configuración del Sistema.";
|
||||
"preferences.permissions.need_help" = "¿Necesitas Ayuda?";
|
||||
"preferences.permissions.after_granting" = "Después de otorgar permisos en Configuración del Sistema:";
|
||||
"preferences.permissions.step1" = "1. Cierra Configuración del Sistema";
|
||||
"preferences.permissions.step2" = "2. Haz clic en 'Actualizar Estado' para actualizar el estado de permisos";
|
||||
"preferences.permissions.step3" = "3. Algunos permisos pueden requerir reiniciar Tell me";
|
||||
|
||||
/* General Tab Strings */
|
||||
"preferences.general.title" = "Configuración General";
|
||||
"preferences.general.dictation_limit" = "Límite de tiempo de dictado:";
|
||||
"preferences.general.minutes" = "minutos";
|
||||
|
||||
/* Advanced Tab Strings */
|
||||
"preferences.advanced.threads_description" = "Más hilos pueden mejorar el rendimiento en sistemas multi-núcleo pero usan más CPU.";
|
||||
"preferences.advanced.logging_description" = "Los registros se almacenan solo localmente y contienen información de tiempo y errores. Nunca se registra audio o texto.";
|
||||
"preferences.advanced.reset_confirmation" = "Esto restablecerá toda la configuración a sus valores por defecto. Esta acción no se puede deshacer.";
|
||||
|
||||
|
|
|
|||
|
|
@ -2,16 +2,17 @@ import Foundation
|
|||
import AVFoundation
|
||||
import AppKit
|
||||
import CoreUtils
|
||||
import CoreSettings
|
||||
|
||||
public class SoundManager: ObservableObject {
|
||||
private let logger = Logger(category: "SoundManager")
|
||||
|
||||
@Published public var soundsEnabled: Bool = true
|
||||
private let settings: CoreSettings.Settings
|
||||
|
||||
private var startSound: AVAudioPlayer?
|
||||
private var stopSound: AVAudioPlayer?
|
||||
|
||||
public init() {
|
||||
public init(settings: CoreSettings.Settings) {
|
||||
self.settings = settings
|
||||
setupSounds()
|
||||
}
|
||||
|
||||
|
|
@ -28,7 +29,7 @@ public class SoundManager: ObservableObject {
|
|||
}
|
||||
|
||||
public func playStartSound() {
|
||||
guard soundsEnabled else { return }
|
||||
guard settings.playSounds else { return }
|
||||
|
||||
logger.debug("Playing start sound")
|
||||
// Use a subtle system sound for start
|
||||
|
|
@ -36,7 +37,7 @@ public class SoundManager: ObservableObject {
|
|||
}
|
||||
|
||||
public func playStopSound() {
|
||||
guard soundsEnabled else { return }
|
||||
guard settings.playSounds else { return }
|
||||
|
||||
logger.debug("Playing stop sound")
|
||||
// Use a different system sound for stop
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
}
|
||||
|
||||
@main
|
||||
struct MenuWhisperApp: App {
|
||||
struct TellMeApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
|
|
@ -26,7 +26,7 @@ public struct ModelInfo: Codable, Identifiable {
|
|||
|
||||
public var fileURL: URL {
|
||||
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
let modelsDirectory = appSupport.appendingPathComponent("MenuWhisper/Models")
|
||||
let modelsDirectory = appSupport.appendingPathComponent("TellMe/Models")
|
||||
return modelsDirectory.appendingPathComponent(filename)
|
||||
}
|
||||
|
||||
|
|
@ -102,7 +102,7 @@ public enum ModelError: Error, LocalizedError {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
public class ModelManager: ObservableObject {
|
||||
public class ModelManager: NSObject, ObservableObject {
|
||||
private let logger = Logger(category: "ModelManager")
|
||||
|
||||
@Published public private(set) var availableModels: [ModelInfo] = []
|
||||
|
|
@ -111,19 +111,22 @@ public class ModelManager: ObservableObject {
|
|||
@Published public private(set) var downloadProgress: [String: DownloadProgress] = [:]
|
||||
|
||||
private let modelsDirectory: URL
|
||||
private let urlSession: URLSession
|
||||
private var urlSession: URLSession
|
||||
private var downloadTasks: [String: URLSessionDownloadTask] = [:]
|
||||
private var progressCallbacks: [String: (DownloadProgress) -> Void] = [:]
|
||||
|
||||
public init() {
|
||||
public override init() {
|
||||
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
modelsDirectory = appSupport.appendingPathComponent("MenuWhisper/Models")
|
||||
modelsDirectory = appSupport.appendingPathComponent("TellMe/Models")
|
||||
|
||||
// Configure URLSession for downloads
|
||||
// Configure URLSession for downloads (simple session, delegates created per download)
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 30
|
||||
config.timeoutIntervalForResource = 3600 // 1 hour for large model downloads
|
||||
urlSession = URLSession(configuration: config)
|
||||
|
||||
super.init()
|
||||
|
||||
try? FileManager.default.createDirectory(at: modelsDirectory, withIntermediateDirectories: true)
|
||||
|
||||
// Ensure we have models available - use fallback approach first
|
||||
|
|
@ -172,35 +175,34 @@ public class ModelManager: ObservableObject {
|
|||
throw ModelError.downloadFailed("Invalid download URL")
|
||||
}
|
||||
|
||||
// Create temporary file for download
|
||||
let tempURL = modelsDirectory.appendingPathComponent("\(model.name).tmp")
|
||||
let modelName = model.name
|
||||
let modelSHA256 = model.sha256
|
||||
let modelFileURL = model.fileURL
|
||||
|
||||
do {
|
||||
let (tempFileURL, response) = try await urlSession.download(from: url)
|
||||
print("Starting download for \(modelName) from \(url)")
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
(200..<300).contains(httpResponse.statusCode) else {
|
||||
throw ModelError.downloadFailed("HTTP error: \(String(describing: (response as? HTTPURLResponse)?.statusCode))")
|
||||
}
|
||||
// Use simple URLSession download for reliability (progress spinners don't need exact progress)
|
||||
let (tempURL, response) = try await urlSession.download(from: url)
|
||||
|
||||
// Verify SHA256 checksum if provided
|
||||
if !model.sha256.isEmpty {
|
||||
try await verifyChecksum(fileURL: tempFileURL, expectedSHA256: model.sha256)
|
||||
}
|
||||
|
||||
// Move to final location
|
||||
if FileManager.default.fileExists(atPath: model.fileURL.path) {
|
||||
try FileManager.default.removeItem(at: model.fileURL)
|
||||
}
|
||||
|
||||
try FileManager.default.moveItem(at: tempFileURL, to: model.fileURL)
|
||||
logger.info("Model file \(model.name).bin downloaded successfully")
|
||||
|
||||
} catch {
|
||||
// Clean up temp files on error
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
throw ModelError.downloadFailed(error.localizedDescription)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
(200..<300).contains(httpResponse.statusCode) else {
|
||||
throw ModelError.downloadFailed("HTTP error: \(String(describing: (response as? HTTPURLResponse)?.statusCode))")
|
||||
}
|
||||
|
||||
print("Download completed for \(modelName)")
|
||||
|
||||
// Verify SHA256 checksum if provided
|
||||
if !modelSHA256.isEmpty {
|
||||
try await verifyChecksum(fileURL: tempURL, expectedSHA256: modelSHA256)
|
||||
}
|
||||
|
||||
// Move to final location
|
||||
if FileManager.default.fileExists(atPath: modelFileURL.path) {
|
||||
try FileManager.default.removeItem(at: modelFileURL)
|
||||
}
|
||||
|
||||
try FileManager.default.moveItem(at: tempURL, to: modelFileURL)
|
||||
logger.info("Model file \(modelName).bin downloaded successfully")
|
||||
}
|
||||
|
||||
private func downloadCoreMlEncoder(_ model: ModelInfo) async throws {
|
||||
|
|
@ -402,14 +404,14 @@ public class ModelManager: ObservableObject {
|
|||
|
||||
private func saveActiveModelPreference() {
|
||||
if let activeModel = activeModel {
|
||||
UserDefaults.standard.set(activeModel.name, forKey: "MenuWhisper.ActiveModel")
|
||||
UserDefaults.standard.set(activeModel.name, forKey: "TellMe.ActiveModel")
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: "MenuWhisper.ActiveModel")
|
||||
UserDefaults.standard.removeObject(forKey: "TellMe.ActiveModel")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadActiveModelPreference() {
|
||||
guard let modelName = UserDefaults.standard.string(forKey: "MenuWhisper.ActiveModel") else {
|
||||
guard let modelName = UserDefaults.standard.string(forKey: "TellMe.ActiveModel") else {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -417,7 +419,8 @@ public class ModelManager: ObservableObject {
|
|||
|
||||
if activeModel == nil {
|
||||
// Clear preference if model is no longer available or downloaded
|
||||
UserDefaults.standard.removeObject(forKey: "MenuWhisper.ActiveModel")
|
||||
UserDefaults.standard.removeObject(forKey: "TellMe.ActiveModel")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,20 @@ public struct HotkeyConfig: Codable {
|
|||
public static let `default` = HotkeyConfig(keyCode: 9, modifiers: 768) // V key with Cmd+Shift
|
||||
}
|
||||
|
||||
public enum InsertionMethod: String, CaseIterable, Codable {
|
||||
case paste = "paste"
|
||||
case typing = "typing"
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .paste:
|
||||
return NSLocalizedString("preferences.insertion.method.paste", comment: "Paste method")
|
||||
case .typing:
|
||||
return NSLocalizedString("preferences.insertion.method.type", comment: "Type method")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class Settings: ObservableObject {
|
||||
private let logger = Logger(category: "Settings")
|
||||
private let userDefaults = UserDefaults.standard
|
||||
|
|
@ -49,6 +63,15 @@ public class Settings: ObservableObject {
|
|||
didSet { userDefaults.set(dictationTimeLimit, forKey: "dictationTimeLimit") }
|
||||
}
|
||||
|
||||
// HUD Settings
|
||||
@Published public var hudOpacity: Double {
|
||||
didSet { userDefaults.set(hudOpacity, forKey: "hudOpacity") }
|
||||
}
|
||||
|
||||
@Published public var hudSize: Double {
|
||||
didSet { userDefaults.set(hudSize, forKey: "hudSize") }
|
||||
}
|
||||
|
||||
// Model Settings
|
||||
@Published public var activeModelName: String? {
|
||||
didSet { userDefaults.set(activeModelName, forKey: "activeModelName") }
|
||||
|
|
@ -59,25 +82,47 @@ public class Settings: ObservableObject {
|
|||
}
|
||||
|
||||
// Insertion Settings
|
||||
@Published public var insertionMethod: String {
|
||||
didSet { userDefaults.set(insertionMethod, forKey: "insertionMethod") }
|
||||
@Published public var insertionMethod: InsertionMethod {
|
||||
didSet { userDefaults.set(insertionMethod.rawValue, forKey: "insertionMethod") }
|
||||
}
|
||||
|
||||
@Published public var showPreview: Bool {
|
||||
didSet { userDefaults.set(showPreview, forKey: "showPreview") }
|
||||
}
|
||||
|
||||
// Advanced Settings
|
||||
@Published public var enableLogging: Bool {
|
||||
didSet { userDefaults.set(enableLogging, forKey: "enableLogging") }
|
||||
}
|
||||
|
||||
@Published public var processingThreads: Int {
|
||||
didSet { userDefaults.set(processingThreads, forKey: "processingThreads") }
|
||||
}
|
||||
|
||||
public init() {
|
||||
// Load settings from UserDefaults
|
||||
self.hotkey = Settings.loadHotkey()
|
||||
self.hotkeyMode = HotkeyMode(rawValue: userDefaults.string(forKey: "hotkeyMode") ?? "") ?? .pushToTalk
|
||||
self.playSounds = userDefaults.object(forKey: "playSounds") as? Bool ?? false
|
||||
self.dictationTimeLimit = userDefaults.object(forKey: "dictationTimeLimit") as? TimeInterval ?? 600 // 10 minutes
|
||||
|
||||
// HUD Settings
|
||||
self.hudOpacity = userDefaults.object(forKey: "hudOpacity") as? Double ?? 0.9
|
||||
self.hudSize = userDefaults.object(forKey: "hudSize") as? Double ?? 1.0
|
||||
|
||||
// Model Settings
|
||||
self.activeModelName = userDefaults.string(forKey: "activeModelName")
|
||||
self.forcedLanguage = userDefaults.string(forKey: "forcedLanguage")
|
||||
self.insertionMethod = userDefaults.string(forKey: "insertionMethod") ?? "paste"
|
||||
|
||||
// Insertion Settings
|
||||
let insertionMethodString = userDefaults.string(forKey: "insertionMethod") ?? "paste"
|
||||
self.insertionMethod = InsertionMethod(rawValue: insertionMethodString) ?? .paste
|
||||
self.showPreview = userDefaults.object(forKey: "showPreview") as? Bool ?? false
|
||||
|
||||
// Advanced Settings
|
||||
self.enableLogging = userDefaults.object(forKey: "enableLogging") as? Bool ?? false
|
||||
self.processingThreads = userDefaults.object(forKey: "processingThreads") as? Int ?? 4
|
||||
|
||||
logger.info("Settings initialized")
|
||||
}
|
||||
|
||||
|
|
@ -88,10 +133,14 @@ public class Settings: ObservableObject {
|
|||
"hotkeyMode": hotkeyMode.rawValue,
|
||||
"playSounds": playSounds,
|
||||
"dictationTimeLimit": dictationTimeLimit,
|
||||
"hudOpacity": hudOpacity,
|
||||
"hudSize": hudSize,
|
||||
"activeModelName": activeModelName as Any,
|
||||
"forcedLanguage": forcedLanguage as Any,
|
||||
"insertionMethod": insertionMethod,
|
||||
"showPreview": showPreview
|
||||
"insertionMethod": insertionMethod.rawValue,
|
||||
"showPreview": showPreview,
|
||||
"enableLogging": enableLogging,
|
||||
"processingThreads": processingThreads
|
||||
]
|
||||
|
||||
return try JSONSerialization.data(withJSONObject: settingsDict, options: .prettyPrinted)
|
||||
|
|
@ -118,10 +167,19 @@ public class Settings: ObservableObject {
|
|||
dictationTimeLimit = timeLimit
|
||||
}
|
||||
|
||||
if let opacity = settingsDict["hudOpacity"] as? Double {
|
||||
hudOpacity = opacity
|
||||
}
|
||||
|
||||
if let size = settingsDict["hudSize"] as? Double {
|
||||
hudSize = size
|
||||
}
|
||||
|
||||
activeModelName = settingsDict["activeModelName"] as? String
|
||||
forcedLanguage = settingsDict["forcedLanguage"] as? String
|
||||
|
||||
if let method = settingsDict["insertionMethod"] as? String {
|
||||
if let methodString = settingsDict["insertionMethod"] as? String,
|
||||
let method = InsertionMethod(rawValue: methodString) {
|
||||
insertionMethod = method
|
||||
}
|
||||
|
||||
|
|
@ -129,6 +187,14 @@ public class Settings: ObservableObject {
|
|||
showPreview = preview
|
||||
}
|
||||
|
||||
if let logging = settingsDict["enableLogging"] as? Bool {
|
||||
enableLogging = logging
|
||||
}
|
||||
|
||||
if let threads = settingsDict["processingThreads"] as? Int {
|
||||
processingThreads = threads
|
||||
}
|
||||
|
||||
logger.info("Settings imported successfully")
|
||||
}
|
||||
|
||||
|
|
|
|||
119
Sources/CoreUtils/LocalizationManager.swift
Normal file
119
Sources/CoreUtils/LocalizationManager.swift
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import Foundation
|
||||
|
||||
public class LocalizationManager {
|
||||
public static let shared = LocalizationManager()
|
||||
|
||||
private var currentLanguage: String
|
||||
private var strings: [String: String] = [:]
|
||||
|
||||
private init() {
|
||||
// Detect system language
|
||||
let preferredLanguages = Locale.preferredLanguages
|
||||
let systemLanguage = preferredLanguages.first ?? "en"
|
||||
|
||||
if systemLanguage.hasPrefix("es") {
|
||||
currentLanguage = "es"
|
||||
} else {
|
||||
currentLanguage = "en"
|
||||
}
|
||||
|
||||
loadStrings()
|
||||
}
|
||||
|
||||
public func localizedString(_ key: String, comment: String = "") -> String {
|
||||
return strings[key] ?? key
|
||||
}
|
||||
|
||||
private func loadStrings() {
|
||||
// Load strings for current language
|
||||
if let path = Bundle.main.path(forResource: "Localizable", ofType: "strings", inDirectory: "Localizations/\(currentLanguage).lproj"),
|
||||
let data = FileManager.default.contents(atPath: path),
|
||||
let content = String(data: data, encoding: .utf8) {
|
||||
parseStringsFile(content)
|
||||
} else {
|
||||
// Fallback to embedded strings
|
||||
loadEmbeddedStrings()
|
||||
}
|
||||
}
|
||||
|
||||
private func parseStringsFile(_ content: String) {
|
||||
let lines = content.components(separatedBy: .newlines)
|
||||
|
||||
for line in lines {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.hasPrefix("\"") && trimmed.contains("\" = \"") && trimmed.hasSuffix("\";") {
|
||||
// Parse: "key" = "value";
|
||||
let parts = trimmed.dropFirst().dropLast() // Remove leading " and trailing ";
|
||||
if let equalIndex = parts.firstIndex(of: "=") {
|
||||
let keyPart = String(parts[..<equalIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let valuePart = String(parts[parts.index(after: equalIndex)...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if keyPart.hasPrefix("\"") && keyPart.hasSuffix("\"") &&
|
||||
valuePart.hasPrefix("\"") && valuePart.hasSuffix("\"") {
|
||||
let key = String(keyPart.dropFirst().dropLast())
|
||||
let value = String(valuePart.dropFirst().dropLast())
|
||||
strings[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadEmbeddedStrings() {
|
||||
// Fallback embedded strings for key functionality
|
||||
if currentLanguage == "es" {
|
||||
strings = [
|
||||
"app.name": "Tell me",
|
||||
"preferences.title": "Preferencias de Tell me",
|
||||
"preferences.general": "General",
|
||||
"preferences.models": "Modelos",
|
||||
"preferences.insertion": "Inserción de Texto",
|
||||
"preferences.interface": "Interfaz",
|
||||
"preferences.advanced": "Avanzado",
|
||||
"preferences.permissions": "Permisos",
|
||||
"preferences.general.title": "Configuración General",
|
||||
"preferences.models.title": "Modelos de Reconocimiento de Voz",
|
||||
"preferences.insertion.title": "Inserción de Texto",
|
||||
"preferences.hud.title": "Configuración de Interfaz",
|
||||
"preferences.advanced.title": "Configuración Avanzada",
|
||||
"general.cancel": "Cancelar",
|
||||
"general.ok": "Aceptar",
|
||||
"preferences.models.download": "Descargar",
|
||||
"preferences.models.delete": "Eliminar",
|
||||
"preferences.models.downloading": "Descargando...",
|
||||
"status.loaded": "Cargado",
|
||||
"status.loading": "Cargando...",
|
||||
"alert.delete_model": "Eliminar Modelo"
|
||||
]
|
||||
} else {
|
||||
strings = [
|
||||
"app.name": "Tell me",
|
||||
"preferences.title": "Tell me Preferences",
|
||||
"preferences.general": "General",
|
||||
"preferences.models": "Models",
|
||||
"preferences.insertion": "Text Insertion",
|
||||
"preferences.interface": "Interface",
|
||||
"preferences.advanced": "Advanced",
|
||||
"preferences.permissions": "Permissions",
|
||||
"preferences.general.title": "General Settings",
|
||||
"preferences.models.title": "Speech Recognition Models",
|
||||
"preferences.insertion.title": "Text Insertion",
|
||||
"preferences.hud.title": "Interface Settings",
|
||||
"preferences.advanced.title": "Advanced Settings",
|
||||
"general.cancel": "Cancel",
|
||||
"general.ok": "OK",
|
||||
"preferences.models.download": "Download",
|
||||
"preferences.models.delete": "Delete",
|
||||
"preferences.models.downloading": "Downloading...",
|
||||
"status.loaded": "Loaded",
|
||||
"status.loading": "Loading...",
|
||||
"alert.delete_model": "Delete Model"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global convenience function that doesn't conflict with system NSLocalizedString
|
||||
public func L(_ key: String) -> String {
|
||||
return LocalizationManager.shared.localizedString(key)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue