From 54c3b65d4abb17fb702f01b0db26668a2f400c00 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Fri, 19 Sep 2025 13:55:46 +0200 Subject: [PATCH] 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 --- .github/workflows/build.yml | 2 +- Docs/ARCHITECTURE.md | 4 +- Package.swift | 16 +- README.md | 4 +- Scripts/build.sh | 4 +- Scripts/dev-build.sh | 28 +- Scripts/notarize.sh | 4 +- Sources/App/AppController.swift | 248 ++++++- Sources/App/HUDWindow.swift | 54 +- Sources/App/HelpWindow.swift | 683 ++++++++++++++++++ Sources/App/HotkeyRecorder.swift | 258 +++++++ Sources/App/OnboardingWindow.swift | 557 ++++++++++++++ Sources/App/PreferencesWindow.swift | 568 +++++++++++++-- Sources/App/PreviewDialog.swift | 117 +++ Sources/App/Resources/Info.plist | 15 +- .../en.lproj/Localizable.strings | 199 ++++- .../es.lproj/Localizable.strings | 199 ++++- Sources/App/SoundManager.swift | 11 +- .../{MenuWhisperApp.swift => TellMeApp.swift} | 2 +- Sources/CoreModels/ModelManager.swift | 75 +- Sources/CoreSettings/Settings.swift | 78 +- Sources/CoreUtils/LocalizationManager.swift | 119 +++ TODO.md | 70 +- Tests/CoreAudioTests/AudioEngineTests.swift | 2 +- .../Phase2IntegrationTests.swift | 4 +- 25 files changed, 3086 insertions(+), 235 deletions(-) create mode 100644 Sources/App/HelpWindow.swift create mode 100644 Sources/App/HotkeyRecorder.swift create mode 100644 Sources/App/OnboardingWindow.swift create mode 100644 Sources/App/PreviewDialog.swift rename Sources/App/{MenuWhisperApp.swift => TellMeApp.swift} (95%) create mode 100644 Sources/CoreUtils/LocalizationManager.swift diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 643bce8..be17024 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,7 +8,7 @@ on: jobs: build: - name: Build Menu-Whisper + name: Build Tell me runs-on: macos-13 steps: diff --git a/Docs/ARCHITECTURE.md b/Docs/ARCHITECTURE.md index a4990d6..ae9a4dd 100644 --- a/Docs/ARCHITECTURE.md +++ b/Docs/ARCHITECTURE.md @@ -71,7 +71,7 @@ Menu-Whisper follows a modular architecture with clear separation of concerns be - Curated model catalog (JSON-based) - Download management with progress tracking - SHA256 verification and integrity checks -- Local storage in `~/Library/Application Support/MenuWhisper/Models` +- Local storage in `~/Library/Application Support/TellMe/Models` - Model selection and metadata management #### Core/Injection @@ -185,7 +185,7 @@ The application follows a finite state machine pattern: The project uses Swift Package Manager with modular targets: ``` -MenuWhisper/ +TellMe/ ├── Package.swift # SPM configuration ├── Sources/ │ ├── App/ # Main application target diff --git a/Package.swift b/Package.swift index 3d9a225..6de074d 100644 --- a/Package.swift +++ b/Package.swift @@ -2,13 +2,13 @@ import PackageDescription let package = Package( - name: "MenuWhisper", + name: "TellMe", platforms: [ .macOS(.v13) ], products: [ .executable( - name: "MenuWhisper", + name: "TellMe", targets: ["App"] ) ], @@ -20,7 +20,7 @@ let package = Package( .executableTarget( name: "App", dependencies: [ - "MenuWhisperAudio", + "TellMeAudio", "CoreSTT", "CoreModels", "CoreInjection", @@ -36,7 +36,7 @@ let package = Package( // Core Module Targets .target( - name: "MenuWhisperAudio", + name: "TellMeAudio", dependencies: ["CoreUtils"], path: "Sources/CoreAudio" ), @@ -46,7 +46,7 @@ let package = Package( dependencies: [ "CoreUtils", "CoreModels", - "MenuWhisperAudio", + "TellMeAudio", .product(name: "SwiftWhisper", package: "SwiftWhisper") ], path: "Sources/CoreSTT" @@ -83,8 +83,8 @@ let package = Package( // Test Targets .testTarget( - name: "MenuWhisperAudioTests", - dependencies: ["MenuWhisperAudio"], + name: "TellMeAudioTests", + dependencies: ["TellMeAudio"], path: "Tests/CoreAudioTests" ), @@ -126,7 +126,7 @@ let package = Package( .testTarget( name: "IntegrationTests", - dependencies: ["CoreSTT", "CoreModels", "MenuWhisperAudio"], + dependencies: ["CoreSTT", "CoreModels", "TellMeAudio"], path: "Tests/IntegrationTests" ) ] diff --git a/README.md b/README.md index 69f4426..cb1b6d3 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# Menu-Whisper +# Tell me A macOS menu bar application that provides offline speech-to-text transcription using Whisper-family models and automatically inserts the transcribed text into the currently focused application. ## Overview -Menu-Whisper is designed to be a privacy-focused, offline-first speech recognition tool for macOS. It runs entirely locally on Apple Silicon machines, requiring no internet connection during normal operation (only for initial model downloads). +Tell me is designed to be a privacy-focused, offline-first speech recognition tool for macOS. It runs entirely locally on Apple Silicon machines, requiring no internet connection during normal operation (only for initial model downloads). ### Key Features diff --git a/Scripts/build.sh b/Scripts/build.sh index 530cc11..93404a7 100755 --- a/Scripts/build.sh +++ b/Scripts/build.sh @@ -1,11 +1,11 @@ #!/bin/bash -# Build script for Menu-Whisper +# Build script for Tell me # This script builds the project using Swift Package Manager set -e -echo "🔨 Building Menu-Whisper..." +echo "🔨 Building Tell me..." # Clean previous build echo "🧹 Cleaning previous build..." diff --git a/Scripts/dev-build.sh b/Scripts/dev-build.sh index 172576e..75cc857 100755 --- a/Scripts/dev-build.sh +++ b/Scripts/dev-build.sh @@ -6,9 +6,11 @@ set -e PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BUILD_DIR="$PROJECT_DIR/.build" -DEV_APP_DIR="$BUILD_DIR/MenuWhisper-Dev.app" +DEV_APP_DIR="$BUILD_DIR/TellMe-Dev.app" -echo "🔨 Building MenuWhisper for development..." +echo "🔨 Building Tell me for development..." + +defaults delete com.fmartingr.tellme hasShownPermissionOnboarding 2>/dev/null || echo "Onboarding status reset for new domain" # Clean previous dev build rm -rf "$DEV_APP_DIR" @@ -21,7 +23,7 @@ mkdir -p "$DEV_APP_DIR/Contents/MacOS" mkdir -p "$DEV_APP_DIR/Contents/Resources" # Copy executable -cp "$BUILD_DIR/arm64-apple-macosx/debug/MenuWhisper" "$DEV_APP_DIR/Contents/MacOS/MenuWhisper" +cp "$BUILD_DIR/arm64-apple-macosx/debug/TellMe" "$DEV_APP_DIR/Contents/MacOS/TellMe" # Create Info.plist cat > "$DEV_APP_DIR/Contents/Info.plist" << EOF @@ -30,11 +32,11 @@ cat > "$DEV_APP_DIR/Contents/Info.plist" << EOF CFBundleExecutable - MenuWhisper + TellMe CFBundleIdentifier - com.menuwhisper.dev + com.fmartingr.tellme CFBundleName - MenuWhisper Dev + TellMe Dev CFBundlePackageType APPL CFBundleVersion @@ -46,14 +48,20 @@ cat > "$DEV_APP_DIR/Contents/Info.plist" << EOF LSUIElement NSMicrophoneUsageDescription - MenuWhisper needs microphone access to capture speech for transcription. + Tell me needs microphone access to capture speech for transcription. EOF # Copy resources if they exist if [ -d "$PROJECT_DIR/Sources/App/Resources" ]; then - cp -r "$PROJECT_DIR/Sources/App/Resources/"* "$DEV_APP_DIR/Contents/Resources/" 2>/dev/null || true + # Copy non-localization resources + find "$PROJECT_DIR/Sources/App/Resources" -maxdepth 1 -type f -exec cp {} "$DEV_APP_DIR/Contents/Resources/" \; + + # Copy localization files to correct location (directly in Resources, not in Localizations subfolder) + if [ -d "$PROJECT_DIR/Sources/App/Resources/Localizations" ]; then + cp -r "$PROJECT_DIR/Sources/App/Resources/Localizations/"*.lproj "$DEV_APP_DIR/Contents/Resources/" 2>/dev/null || true + fi fi echo "✅ Development app bundle created at: $DEV_APP_DIR" @@ -61,6 +69,6 @@ echo "" echo "To run with proper permissions:" echo "1. open '$DEV_APP_DIR'" echo "2. Grant permissions in System Settings" -echo "3. Or run: '$DEV_APP_DIR/Contents/MacOS/MenuWhisper'" +echo "3. Or run: '$DEV_APP_DIR/Contents/MacOS/TellMe'" echo "" -echo "The app bundle makes it easier to grant permissions in System Settings." \ No newline at end of file +echo "The app bundle makes it easier to grant permissions in System Settings." diff --git a/Scripts/notarize.sh b/Scripts/notarize.sh index f9a23e2..2d9618c 100755 --- a/Scripts/notarize.sh +++ b/Scripts/notarize.sh @@ -1,11 +1,11 @@ #!/bin/bash -# Notarization script for Menu-Whisper +# Notarization script for Tell me # This is a placeholder script that will be completed in Phase 5 set -e -echo "🍎 Menu-Whisper Notarization Script" +echo "🍎 Tell me Notarization Script" echo "📋 This script will handle code signing and notarization for distribution" echo "" echo "⚠️ This is a placeholder script - implementation pending Phase 5" diff --git a/Sources/App/AppController.swift b/Sources/App/AppController.swift index 0e27a13..7b57328 100644 --- a/Sources/App/AppController.swift +++ b/Sources/App/AppController.swift @@ -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) } } diff --git a/Sources/App/HUDWindow.swift b/Sources/App/HUDWindow.swift index cd5b6dd..bb7d2f5 100644 --- a/Sources/App/HUDWindow.swift +++ b/Sources/App/HUDWindow.swift @@ -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? + 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 { diff --git a/Sources/App/HelpWindow.swift b/Sources/App/HelpWindow.swift new file mode 100644 index 0000000..bd15255 --- /dev/null +++ b/Sources/App/HelpWindow.swift @@ -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) + } +} \ No newline at end of file diff --git a/Sources/App/HotkeyRecorder.swift b/Sources/App/HotkeyRecorder.swift new file mode 100644 index 0000000..a563ece --- /dev/null +++ b/Sources/App/HotkeyRecorder.swift @@ -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) + } +} \ No newline at end of file diff --git a/Sources/App/OnboardingWindow.swift b/Sources/App/OnboardingWindow.swift new file mode 100644 index 0000000..e2c6150 --- /dev/null +++ b/Sources/App/OnboardingWindow.swift @@ -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.. 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) + } + } +} \ No newline at end of file diff --git a/Sources/App/PreferencesWindow.swift b/Sources/App/PreferencesWindow.swift index cba6b0e..3f159a1 100644 --- a/Sources/App/PreferencesWindow.swift +++ b/Sources/App/PreferencesWindow.swift @@ -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 + } } \ No newline at end of file diff --git a/Sources/App/PreviewDialog.swift b/Sources/App/PreviewDialog.swift new file mode 100644 index 0000000..9976365 --- /dev/null +++ b/Sources/App/PreviewDialog.swift @@ -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 + } + } +} \ No newline at end of file diff --git a/Sources/App/Resources/Info.plist b/Sources/App/Resources/Info.plist index e09c9ae..093e7f5 100644 --- a/Sources/App/Resources/Info.plist +++ b/Sources/App/Resources/Info.plist @@ -4,16 +4,21 @@ CFBundleDevelopmentRegion en + CFBundleLocalizations + + en + es + CFBundleDisplayName - Menu-Whisper + Tell me CFBundleExecutable - MenuWhisper + TellMe CFBundleIdentifier - com.menuwhisper.app + com.fmartingr.tellme CFBundleInfoDictionaryVersion 6.0 CFBundleName - Menu-Whisper + Tell me CFBundlePackageType APPL CFBundleShortVersionString @@ -27,7 +32,7 @@ NSHumanReadableCopyright Copyright © 2025. All rights reserved. NSMicrophoneUsageDescription - Menu-Whisper needs access to your microphone to capture speech for offline transcription. Your audio data never leaves your device. + Tell me needs access to your microphone to capture speech for offline transcription. Your audio data never leaves your device. NSSupportsAutomaticTermination NSSupportsSuddenTermination diff --git a/Sources/App/Resources/Localizations/en.lproj/Localizable.strings b/Sources/App/Resources/Localizations/en.lproj/Localizable.strings index 13d40ad..e4fdeec 100644 --- a/Sources/App/Resources/Localizations/en.lproj/Localizable.strings +++ b/Sources/App/Resources/Localizations/en.lproj/Localizable.strings @@ -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"; \ No newline at end of file +"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."; + diff --git a/Sources/App/Resources/Localizations/es.lproj/Localizable.strings b/Sources/App/Resources/Localizations/es.lproj/Localizable.strings index 2c873d4..3a86c48 100644 --- a/Sources/App/Resources/Localizations/es.lproj/Localizable.strings +++ b/Sources/App/Resources/Localizations/es.lproj/Localizable.strings @@ -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"; \ No newline at end of file +"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."; + diff --git a/Sources/App/SoundManager.swift b/Sources/App/SoundManager.swift index d45f064..770e743 100644 --- a/Sources/App/SoundManager.swift +++ b/Sources/App/SoundManager.swift @@ -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 diff --git a/Sources/App/MenuWhisperApp.swift b/Sources/App/TellMeApp.swift similarity index 95% rename from Sources/App/MenuWhisperApp.swift rename to Sources/App/TellMeApp.swift index 24aae77..99e392d 100644 --- a/Sources/App/MenuWhisperApp.swift +++ b/Sources/App/TellMeApp.swift @@ -10,7 +10,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @main -struct MenuWhisperApp: App { +struct TellMeApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { diff --git a/Sources/CoreModels/ModelManager.swift b/Sources/CoreModels/ModelManager.swift index 5950086..d6e0e2e 100644 --- a/Sources/CoreModels/ModelManager.swift +++ b/Sources/CoreModels/ModelManager.swift @@ -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") } } -} \ No newline at end of file +} + diff --git a/Sources/CoreSettings/Settings.swift b/Sources/CoreSettings/Settings.swift index be04b8a..c666bf6 100644 --- a/Sources/CoreSettings/Settings.swift +++ b/Sources/CoreSettings/Settings.swift @@ -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") } diff --git a/Sources/CoreUtils/LocalizationManager.swift b/Sources/CoreUtils/LocalizationManager.swift new file mode 100644 index 0000000..074db87 --- /dev/null +++ b/Sources/CoreUtils/LocalizationManager.swift @@ -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[.. String { + return LocalizationManager.shared.localizedString(key) +} \ No newline at end of file diff --git a/TODO.md b/TODO.md index 0a92edf..00f64e2 100644 --- a/TODO.md +++ b/TODO.md @@ -93,7 +93,7 @@ Conventions: - [x] **Model Manager** (backend + minimal UI): - [x] Bundle a **curated JSON catalog** (name, size, languages, license, URL, SHA256). - [x] Download via `URLSession` with progress + resume support. - - [x] Validate **SHA256**; store under `~/Library/Application Support/MenuWhisper/Models`. + - [x] Validate **SHA256**; store under `~/Library/Application Support/TellMe/Models`. - [x] Allow **select active model**; persist selection. - [x] Language: **auto** or **forced** (persist). - [x] Text normalization pass (basic replacements; punctuation from model). @@ -116,10 +116,10 @@ Conventions: - Preferences window with model management UI - NSStatusItem menu bar with model status - Hotkey protection (shows alert if no model loaded) -- Proper model path handling (`~/Library/Application Support/MenuWhisper/Models`) +- Proper model path handling (`~/Library/Application Support/TellMe/Models`) **User Experience:** -1. Launch MenuWhisper → Menu shows "No model - click Preferences" +1. Launch Tell me → Menu shows "No model - click Preferences" 2. Open Preferences → See available models, download options 3. Download model → Progress tracking, SHA256 verification 4. Select model → Loads automatically @@ -177,27 +177,53 @@ No automatic downloads - users must download and select models first. **Goal:** Complete options, localization, and stability. ### Tasks -- [ ] Full **Preferences** window: - - [ ] Hotkey recorder (change ⌘⇧V if needed). - - [ ] Mode: Push-to-talk / Toggle. - - [ ] Model picker: list, **download**, **delete**, **set active**, show size/language/license. - - [ ] Language: Auto / Forced (dropdown). - - [ ] Insertion: **Direct** (default) vs **Preview**; Paste vs Typing preference. - - [ ] HUD: opacity/size, show/hide sounds toggles. - - [ ] Dictation limit: editable (default 10 min). - - [ ] Advanced: threads/batch; **local logs opt-in**. - - [ ] **Export/Import** settings (JSON). -- [ ] Implement **Preview** dialog (off by default): shows transcribed text with **Insert** / **Cancel**. -- [ ] Expand **localization** (ES/EN) for all UI strings. -- [ ] Onboarding & help views (permissions, Secure Input explanation). -- [ ] Persist all settings in `UserDefaults`; validate on load; migrate if needed. -- [ ] UX polish: icons, animation timing, keyboard navigation, VoiceOver labels. -- [ ] Optional: internal **timing instrumentation** (guarded by logs opt-in). +- [x] Full **Preferences** window: + - [x] Hotkey recorder (change ⌘⇧V if needed). + - [x] Mode: Push-to-talk / Toggle. + - [x] Model picker: list, **download**, **delete**, **set active**, show size/language/license. + - [x] Language: Auto / Forced (dropdown). + - [x] Insertion: **Direct** (default) vs **Preview**; Paste vs Typing preference. + - [x] HUD: opacity/size, show/hide sounds toggles. + - [x] Dictation limit: editable (default 10 min). + - [x] Advanced: threads/batch; **local logs opt-in**. + - [x] **Export/Import** settings (JSON). +- [x] Implement **Preview** dialog (off by default): shows transcribed text with **Insert** / **Cancel**. +- [x] Expand **localization** (ES/EN) for all UI strings. +- [x] Onboarding & help views (permissions, Secure Input explanation). +- [x] Persist all settings in `UserDefaults`; validate on load; migrate if needed. +- [x] UX polish: icons, animation timing, keyboard navigation, VoiceOver labels. +- [x] Optional: internal **timing instrumentation** (guarded by logs opt-in). ### AC -- [ ] All preferences persist and take effect without relaunch. -- [ ] Preview (when enabled) allows quick edit & insertion. -- [ ] ES/EN localization passes a manual spot-check. +- [x] All preferences persist and take effect without relaunch. +- [x] Preview (when enabled) allows quick edit & insertion. +- [x] ES/EN localization passes a manual spot-check. + +**Current Status:** Phase 4 **COMPLETE**. + +**What works:** +- Comprehensive preferences window with 6 tabs (General, Models, Text Insertion, Interface, Advanced, Permissions) +- Hotkey recorder with visual feedback and customization +- Push-to-talk and toggle mode selection +- Language selection with 10+ language options +- HUD customization (opacity, size, sound toggles) +- Text insertion preferences (paste vs type, direct vs preview) +- Advanced settings (processing threads, logging, settings reset) +- Settings export/import to JSON format +- Preview dialog for editing transcriptions before insertion +- Complete localization for English and Spanish +- Onboarding flow with step-by-step setup +- Comprehensive help system with troubleshooting guides +- Accessibility improvements with VoiceOver support +- All settings persist in UserDefaults and take effect immediately + +**User Experience:** +1. Full-featured preferences with intuitive tabbed interface +2. Customizable hotkeys with visual recorder +3. Comprehensive help and onboarding for new users +4. Preview transcriptions before insertion +5. Export/import settings for backup and sharing +6. Complete offline experience with multi-language support --- diff --git a/Tests/CoreAudioTests/AudioEngineTests.swift b/Tests/CoreAudioTests/AudioEngineTests.swift index c91416e..371fd32 100644 --- a/Tests/CoreAudioTests/AudioEngineTests.swift +++ b/Tests/CoreAudioTests/AudioEngineTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import MenuWhisperAudio +@testable import TellMeAudio final class AudioEngineTests: XCTestCase { func testAudioEngineInitialization() { diff --git a/Tests/IntegrationTests/Phase2IntegrationTests.swift b/Tests/IntegrationTests/Phase2IntegrationTests.swift index 6d39d4f..48d3b29 100644 --- a/Tests/IntegrationTests/Phase2IntegrationTests.swift +++ b/Tests/IntegrationTests/Phase2IntegrationTests.swift @@ -1,7 +1,7 @@ import XCTest @testable import CoreSTT @testable import CoreModels -@testable import MenuWhisperAudio +@testable import TellMeAudio /// Integration tests to verify Phase 2 whisper.cpp implementation /// These tests validate the architecture without requiring real model files @@ -132,7 +132,7 @@ final class Phase2IntegrationTests: XCTestCase { // Test model path generation let modelPath = testModel.fileURL - XCTAssertTrue(modelPath.absoluteString.contains("MenuWhisper/Models"), "Should use correct models directory") + XCTAssertTrue(modelPath.absoluteString.contains("TellMe/Models"), "Should use correct models directory") XCTAssertTrue(modelPath.lastPathComponent.hasSuffix(".bin"), "Should generate .bin filename") // Test estimated RAM info