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